feat(lib-core): biblioteca atomica @pulse-libs/core v1.0.0-beta.1

Esta commit conteudo a estrutura atomica completa:

- types:     Result<T,E>, AsyncState<T>, Paginated<T>, SortConfig<T>
- utils:     date, str, num, cn, debounce, throttle, storage, arr, obj
- validators: Zod schemas — email, password, uuid, url, phone, CPF/CNPJ, sanitizedStr, safeParse
- hooks:     useToggle, useAsync, useDebounce, useLocalStorage, useMedia, useInterval, useOnClickOutside, useClipboard, useFetch
- components: Button, Input, Alert, Card, Spinner (atomic design pattern)
- build:     tsup v8 ESM+CJS + DTS + sourcemaps — 0 erros
- tests:     57 testes 100% usuarios
- docker:    multi-stage Dockerfile (node 20-alpine)
- config:    vitest, tsup, tsconfig strict, .npmignore

Filosofia atomica:/utils ← /types ← /validators ← /hooks ← /components
Build: npm run build | Test: npm test | Publish: npm publish

🤖 Generated with Pulse (openclaw + nova-self-improver)
This commit is contained in:
pulse-agent
2026-05-19 21:43:03 -03:00
parent ae39e45460
commit bbdb68a6de
7030 changed files with 2040595 additions and 0 deletions
@@ -0,0 +1,323 @@
#!/usr/bin/env node
/**
* apple-notes-export.mjs (taskflow-012)
*
* Generates an HTML project-status summary from TaskFlow markdown files and
* writes it to an Apple Note via osascript.
*
* Config: $OPENCLAW_WORKSPACE/taskflow.config.json
* {
* "appleNotesId": "x-coredata://...", // persisted after first run
* "appleNotesFolder": "Notes", // Notes folder name
* "appleNotesTitle": "TaskFlow - Project Status"
* }
*
* Usage:
* node scripts/apple-notes-export.mjs
* taskflow notes
*
* macOS only — exits gracefully on other platforms.
*/
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'
import { execSync } from 'node:child_process'
import path from 'node:path'
// ── Platform guard ─────────────────────────────────────────────────────────
if (process.platform !== 'darwin') {
console.log('[apple-notes-export] Skipping: Apple Notes sync is macOS only.')
process.exit(0)
}
// ── Paths ──────────────────────────────────────────────────────────────────
const workspace = process.env.OPENCLAW_WORKSPACE || process.cwd()
const configPath = path.join(workspace, 'taskflow.config.json')
const tasksDir = path.join(workspace, 'tasks')
const projectsFile = path.join(workspace, 'PROJECTS.md')
// ── Config helpers ─────────────────────────────────────────────────────────
/**
* Read taskflow.config.json (returns {} if missing or unparseable).
*/
function readConfig() {
if (!existsSync(configPath)) return {}
try {
return JSON.parse(readFileSync(configPath, 'utf8'))
} catch {
return {}
}
}
/**
* Merge `updates` into taskflow.config.json (non-destructive patch).
*/
function writeConfig(updates) {
const current = readConfig()
const merged = { ...current, ...updates }
writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n', 'utf8')
}
// ── Parse PROJECTS.md ──────────────────────────────────────────────────────
function parseProjects() {
if (!existsSync(projectsFile)) return {}
const lines = readFileSync(projectsFile, 'utf8').split('\n')
const projects = {}
let currentSlug = null
for (const line of lines) {
const h2 = line.match(/^## (.+)/)
if (h2) {
currentSlug = h2[1].trim()
projects[currentSlug] = { name: currentSlug, desc: '' }
continue
}
if (!currentSlug) continue
const nameMatch = line.match(/^- Name: (.+)/)
if (nameMatch) { projects[currentSlug].name = nameMatch[1].trim(); continue }
const descMatch = line.match(/^- Description: (.+)/)
if (descMatch) { projects[currentSlug].desc = descMatch[1].trim() }
}
return projects
}
// ── Parse task files ───────────────────────────────────────────────────────
function parseTasks(projects) {
const result = { in_progress: [], pending: [], backlog: [], done: [], blocked: [] }
if (!existsSync(tasksDir)) return result
const files = readdirSync(tasksDir)
.filter(f => f.endsWith('-tasks.md'))
.sort()
for (const file of files) {
const slug = file.replace('-tasks.md', '')
const projName = projects[slug]?.name ?? slug
const lines = readFileSync(path.join(tasksDir, file), 'utf8').split('\n')
let section = null
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith('## ')) {
const h = trimmed.slice(3).toLowerCase()
if (h.includes('in progress')) section = 'in_progress'
else if (h.includes('pending')) section = 'pending'
else if (h.includes('backlog')) section = 'backlog'
else if (h.includes('done')) section = 'done'
else if (h.includes('blocked')) section = 'blocked'
else section = null
continue
}
if (!trimmed.startsWith('- [') || section === null) continue
// Strip checkbox, task ID, priority/owner tags
let text = trimmed
.replace(/^- \[.\]\s*/, '')
.replace(/\(task:\S+\)\s*/g, '')
.replace(/\[P\d\]\s*/g, '')
.replace(/\[\S+\]\s*/g, '')
.trim()
if (!text) continue
result[section].push(`${projName}: ${text}`)
}
}
return result
}
// ── HTML generation ────────────────────────────────────────────────────────
function escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function ul(items) {
if (!items || items.length === 0) {
return '<ul><li><span style="color:#666;">None</span></li></ul>'
}
return '<ul>' + items.map(i => `<li>${escapeHtml(i)}</li>`).join('') + '</ul>'
}
function generateHtml(tasks, title) {
const { in_progress, pending, backlog, done, blocked } = tasks
const top = [...in_progress, ...pending].slice(0, 5)
const next3 = in_progress.length > 0 ? in_progress.slice(0, 3) : backlog.slice(0, 3)
const now = new Date()
const stamp = now.toLocaleString('en-US', {
timeZone: 'America/Chicago',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: true,
}) + ' CST'
return `<div style="font-family:-apple-system,Helvetica,Arial,sans-serif; line-height:1.35;">
<h1>${escapeHtml(title)}</h1>
<h2>🎯 Top Priorities</h2>${ul(top)}
<h2>🚧 In Progress (${in_progress.length})</h2>${ul(in_progress)}
<h2>⏳ Pending Validation (${pending.length})</h2>${ul(pending)}
<h2>📥 Backlog (top 12)</h2>${ul(backlog.slice(0, 12))}
<h2>✅ Recently Done</h2>${ul(done.slice(0, 10))}
<h2>🧱 Blockers</h2>${ul(blocked)}
<h2>▶️ Next 3 Actions</h2>${ul(next3)}
<p style="color:#888; font-size:0.85em;"><b>Updated:</b> ${escapeHtml(stamp)} &middot; Source: tasks/*.md</p>
</div>`
}
// ── AppleScript helpers ────────────────────────────────────────────────────
/**
* Escape a string for embedding inside an AppleScript string literal.
*/
function asEscape(str) {
// In AppleScript, backslash and double-quote need to be escaped for shell
// We'll write HTML to a temp file and read it in AppleScript to avoid quoting issues.
return str
}
const TMP_HTML = '/tmp/taskflow-apple-notes.html'
/**
* Check whether a note with the given Core Data ID exists.
* Returns true/false.
*/
function noteExists(noteId) {
const script = `
tell application "Notes"
try
set n to note id "${noteId}"
return (name of n) as text
on error
return "NOT_FOUND"
end try
end tell
`
try {
const result = execSync(`/usr/bin/osascript -e '${script.replace(/'/g, "'\\''")}'`, {
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
}).trim()
return result !== 'NOT_FOUND'
} catch {
return false
}
}
/**
* Update an existing note by Core Data ID.
*/
function updateNote(noteId, title, html) {
writeFileSync(TMP_HTML, html, 'utf8')
const script = `
set noteBody to (do shell script "cat /tmp/taskflow-apple-notes.html")
tell application "Notes"
try
set targetNote to note id "${noteId}"
set name of targetNote to "${title.replace(/"/g, '\\"')}"
set body of targetNote to noteBody
return (id of targetNote) as text
on error errMsg
error errMsg
end try
end tell
`
const result = execSync(`/usr/bin/osascript << 'OSASCRIPT'\n${script}\nOSASCRIPT`, {
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
}).trim()
return result
}
/**
* Create a new note in the specified folder.
* Returns the new note's Core Data ID.
*/
function createNote(title, html, folder) {
writeFileSync(TMP_HTML, html, 'utf8')
const script = `
set noteBody to (do shell script "cat /tmp/taskflow-apple-notes.html")
tell application "Notes"
launch
delay 0.5
set targetFolder to missing value
try
set targetFolder to folder "${folder.replace(/"/g, '\\"')}"
on error
-- folder not found, use default account
end try
if targetFolder is missing value then
set newNote to make new note with properties {name:"${title.replace(/"/g, '\\"')}", body:noteBody}
else
set newNote to make new note at targetFolder with properties {name:"${title.replace(/"/g, '\\"')}", body:noteBody}
end if
return (id of newNote) as text
end tell
`
const result = execSync(`/usr/bin/osascript << 'OSASCRIPT'\n${script}\nOSASCRIPT`, {
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
}).trim()
return result
}
// ── Main ───────────────────────────────────────────────────────────────────
async function main() {
const config = readConfig()
const folder = config.appleNotesFolder ?? 'Notes'
const title = config.appleNotesTitle ?? 'TaskFlow - Project Status'
let noteId = config.appleNotesId ?? null
// Parse content
const projects = parseProjects()
const tasks = parseTasks(projects)
const html = generateHtml(tasks, title)
const { in_progress, pending, backlog, done, blocked } = tasks
console.log(`[apple-notes-export] ${in_progress.length} in-progress, ${backlog.length} backlog, ${done.length} done`)
// Retry loop (up to 3 attempts)
for (let attempt = 1; attempt <= 3; attempt++) {
try {
if (noteId && noteExists(noteId)) {
// Update existing note
console.log(`[apple-notes-export] Updating existing note…`)
updateNote(noteId, title, html)
console.log(`[apple-notes-export] ✓ Note updated (${noteId})`)
break
} else {
// Create new note (either no ID configured, or note was deleted)
if (noteId) {
console.log(`[apple-notes-export] Previous note not found — creating new note…`)
} else {
console.log(`[apple-notes-export] No note configured — creating new note in "${folder}"…`)
}
const newId = createNote(title, html, folder)
if (!newId) throw new Error('osascript returned empty note ID')
noteId = newId
writeConfig({
appleNotesId: noteId,
appleNotesFolder: folder,
appleNotesTitle: title,
})
console.log(`[apple-notes-export] ✓ Note created and ID saved to taskflow.config.json`)
console.log(`[apple-notes-export] ID: ${noteId}`)
break
}
} catch (err) {
console.error(`[apple-notes-export] Attempt ${attempt} failed: ${err.message}`)
if (attempt < 3) {
await new Promise(r => setTimeout(r, 2000))
} else {
console.error('[apple-notes-export] All retries exhausted. Note not updated.')
process.exit(1)
}
}
}
}
await main()
@@ -0,0 +1,109 @@
#!/usr/bin/env node
/**
* export-projects-overview.mjs (taskflow-009)
*
* Reads the TaskFlow SQLite DB and emits a clean JSON summary to stdout.
*
* Output shape:
* {
* exported_at: ISO string,
* projects: [ { id, name, description, status, task_counts, progress_pct } ],
* recent_transitions: [ { task_id, from_status, to_status, reason, at } ]
* }
*
* Usage:
* node scripts/export-projects-overview.mjs
* node scripts/export-projects-overview.mjs | jq .
*/
import { DatabaseSync } from 'node:sqlite'
import path from 'node:path'
import { existsSync } from 'node:fs'
// ── DB path resolution ─────────────────────────────────────────────────────
const workspace = process.env.OPENCLAW_WORKSPACE || process.cwd()
const dbPath = path.join(workspace, 'memory', 'taskflow.sqlite')
if (!existsSync(dbPath)) {
process.stderr.write(
`[taskflow-export] DB not found at: ${dbPath}\n` +
` Run 'taskflow init' to bootstrap the database.\n`
)
process.exit(1)
}
const db = new DatabaseSync(dbPath)
db.exec('PRAGMA foreign_keys = ON')
db.exec('PRAGMA journal_mode = WAL')
// ── Fetch projects ─────────────────────────────────────────────────────────
const projects = db
.prepare('SELECT id, name, description, status FROM projects ORDER BY id')
.all()
// ── Task counts per project ────────────────────────────────────────────────
const ALL_STATUSES = ['in_progress', 'pending_validation', 'backlog', 'blocked', 'done']
const countRows = db
.prepare(`
SELECT project_id, status, COUNT(*) AS cnt
FROM tasks_v2
GROUP BY project_id, status
`)
.all()
// Build lookup: { project_id: { status: count } }
const countsByProject = {}
for (const row of countRows) {
if (!countsByProject[row.project_id]) countsByProject[row.project_id] = {}
countsByProject[row.project_id][row.status] = row.cnt
}
// ── Assemble project objects ───────────────────────────────────────────────
const projectsOut = projects.map((p) => {
const counts = countsByProject[p.id] || {}
const task_counts = {
in_progress: counts.in_progress ?? 0,
pending_validation: counts.pending_validation ?? 0,
backlog: counts.backlog ?? 0,
blocked: counts.blocked ?? 0,
done: counts.done ?? 0,
}
const total = Object.values(task_counts).reduce((s, n) => s + n, 0)
const progress_pct = total > 0
? Math.round((task_counts.done / total) * 10000) / 100 // 2 decimal places
: 0
return {
id: p.id,
name: p.name,
description: p.description,
status: p.status,
task_counts,
progress_pct,
}
})
// ── Recent transitions (last 20) ───────────────────────────────────────────
const recentTransitions = db
.prepare(`
SELECT task_id, from_status, to_status, reason, at
FROM task_transitions_v2
ORDER BY id DESC
LIMIT 20
`)
.all()
// Reverse so oldest-first within the window (more natural for consumers)
recentTransitions.reverse()
// ── Output ─────────────────────────────────────────────────────────────────
const output = {
exported_at: new Date().toISOString(),
projects: projectsOut,
recent_transitions: recentTransitions,
}
process.stdout.write(JSON.stringify(output, null, 2) + '\n')
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env node
/**
* init-db.mjs — TaskFlow SQLite bootstrap (idempotent)
*
* Reads schema/taskflow.sql and executes it against the TaskFlow SQLite DB.
* Safe to run multiple times — all DDL uses IF NOT EXISTS.
*
* DB path resolution (in order):
* 1. $OPENCLAW_WORKSPACE/memory/taskflow.sqlite
* 2. process.cwd()/memory/taskflow.sqlite
*
* Usage:
* node scripts/init-db.mjs
* npm run init-db
*
* Requires: Node >=22.5 (node:sqlite / DatabaseSync)
*/
import { DatabaseSync } from 'node:sqlite';
import { readFileSync, mkdirSync, existsSync } from 'node:fs';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
// ---------------------------------------------------------------------------
// Resolve paths
// ---------------------------------------------------------------------------
const __dirname = dirname(fileURLToPath(import.meta.url));
const schemaPath = resolve(__dirname, '..', 'schema', 'taskflow.sql');
const workspaceRoot = process.env.OPENCLAW_WORKSPACE
? process.env.OPENCLAW_WORKSPACE
: process.cwd();
const memoryDir = join(workspaceRoot, 'memory');
const dbPath = join(memoryDir, 'taskflow.sqlite');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function log(msg) {
const ts = new Date().toISOString();
console.log(`[${ts}] ${msg}`);
}
function err(msg) {
const ts = new Date().toISOString();
console.error(`[${ts}] ERROR: ${msg}`);
}
/**
* Split a SQL file into individual statements, stripping comments and blanks.
* Handles multi-line statements and PRAGMA directives.
*/
function splitStatements(sql) {
// Remove -- line comments
const stripped = sql.replace(/--[^\n]*/g, '');
// Split on semicolons, filter blank/whitespace-only entries
return stripped
.split(';')
.map(s => s.trim())
.filter(s => s.length > 0);
}
/**
* Detect the statement type for human-readable logging.
*/
function describeStatement(stmt) {
const upper = stmt.trimStart().toUpperCase();
if (upper.startsWith('CREATE TABLE IF NOT EXISTS')) {
const m = stmt.match(/CREATE TABLE IF NOT EXISTS\s+(\S+)/i);
return m ? `CREATE TABLE IF NOT EXISTS ${m[1]}` : 'CREATE TABLE (IF NOT EXISTS)';
}
if (upper.startsWith('CREATE INDEX IF NOT EXISTS')) {
const m = stmt.match(/CREATE INDEX IF NOT EXISTS\s+(\S+)/i);
return m ? `CREATE INDEX IF NOT EXISTS ${m[1]}` : 'CREATE INDEX (IF NOT EXISTS)';
}
if (upper.startsWith('CREATE')) {
return stmt.trimStart().split(/\s+/).slice(0, 4).join(' ');
}
if (upper.startsWith('INSERT OR IGNORE')) {
const m = stmt.match(/INTO\s+(\S+)/i);
return m ? `INSERT OR IGNORE INTO ${m[1]}` : 'INSERT OR IGNORE';
}
if (upper.startsWith('PRAGMA')) {
return stmt.trimStart().split('\n')[0].trim();
}
return stmt.trimStart().split(/\s+/).slice(0, 3).join(' ');
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
log('TaskFlow init-db starting');
log(`Schema: ${schemaPath}`);
log(`DB: ${dbPath}`);
// Ensure memory directory exists
if (!existsSync(memoryDir)) {
mkdirSync(memoryDir, { recursive: true });
log(`Created directory: ${memoryDir}`);
} else {
log(`Directory exists: ${memoryDir}`);
}
// Read schema
let schemaSQL;
try {
schemaSQL = readFileSync(schemaPath, 'utf8');
} catch (e) {
err(`Cannot read schema file: ${schemaPath}\n${e.message}`);
process.exit(1);
}
// Open (or create) DB
let db;
try {
db = new DatabaseSync(dbPath);
log(`Opened DB (created if new): ${dbPath}`);
} catch (e) {
err(`Cannot open SQLite DB at ${dbPath}\n${e.message}`);
process.exit(1);
}
// Execute each statement
const statements = splitStatements(schemaSQL);
log(`Executing ${statements.length} SQL statement(s)...`);
console.log('');
let ok = 0;
let skipped = 0;
for (const stmt of statements) {
const label = describeStatement(stmt);
try {
db.exec(stmt);
log(`${label}`);
ok++;
} catch (e) {
// PRAGMA errors on read-only pragmas are non-fatal; everything else is fatal.
if (stmt.trim().toUpperCase().startsWith('PRAGMA')) {
log(` ~ ${label} (skipped — ${e.message})`);
skipped++;
} else {
err(`Failed: ${label}\n ${e.message}`);
db.close?.();
process.exit(1);
}
}
}
// Verify expected tables exist
const EXPECTED_TABLES = [
'projects',
'tasks_v2',
'task_transitions_v2',
'sync_state',
'legacy_key_map',
];
console.log('');
log('Verifying tables...');
const tableRows = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all();
const existingTables = new Set(tableRows.map(r => r.name));
let allPresent = true;
for (const t of EXPECTED_TABLES) {
if (existingTables.has(t)) {
log(` ✓ table: ${t}`);
} else {
log(` ✗ MISSING table: ${t}`);
allPresent = false;
}
}
// Verify singleton sync_state row
const syncRow = db.prepare('SELECT id FROM sync_state WHERE id = 1').get();
if (syncRow) {
log(` ✓ sync_state singleton row present`);
} else {
log(` ✗ sync_state singleton row MISSING`);
allPresent = false;
}
// Summary
console.log('');
if (allPresent) {
log(`init-db complete — ${ok} statement(s) executed, ${skipped} skipped`);
log(`TaskFlow DB is ready at: ${dbPath}`);
} else {
err('init-db completed with warnings — some expected tables are missing');
process.exit(1);
}
+386
View File
@@ -0,0 +1,386 @@
#!/usr/bin/env node
/**
* task-sync.mjs -- Sync task markdown files to/from SQLite
*
* Usage:
* node scripts/task-sync.mjs files-to-db # parse markdown, update DB
* node scripts/task-sync.mjs check # detect drift, exit 1 if mismatch
* node scripts/task-sync.mjs db-to-files # project DB state into markdown
*
* Environment:
* OPENCLAW_WORKSPACE Path to the workspace root (defaults to process.cwd())
*/
import { DatabaseSync } from 'node:sqlite'
import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'
import path from 'node:path'
import { createHash } from 'node:crypto'
// === PATHS (taskflow-001: portable, no hardcoded paths) ===
const workspace = process.env.OPENCLAW_WORKSPACE || process.cwd()
const dbPath = path.join(workspace, 'memory', 'taskflow.sqlite')
const tasksDir = path.join(workspace, 'tasks')
const mode = process.argv[2] || 'check'
if (!['files-to-db', 'check', 'db-to-files'].includes(mode)) {
console.error('Usage: node scripts/task-sync.mjs [files-to-db|check|db-to-files]')
process.exit(1)
}
// === STARTUP CHECKS (taskflow-020: clear errors for missing paths) ===
if (!existsSync(workspace)) {
console.error(`ERROR: Workspace directory not found: ${workspace}`)
console.error('Set OPENCLAW_WORKSPACE to your workspace root and try again.')
process.exit(1)
}
if (!existsSync(tasksDir)) {
console.error(`ERROR: Tasks directory not found: ${tasksDir}`)
console.error(`Create it with: mkdir -p "${tasksDir}"`)
process.exit(1)
}
if (!existsSync(dbPath)) {
console.error(`ERROR: Database file not found: ${dbPath}`)
console.error('Run init-db.mjs first to create the database schema.')
process.exit(1)
}
const db = new DatabaseSync(dbPath)
db.exec('PRAGMA foreign_keys = ON')
// === LOCK (taskflow-002: atomic single-UPDATE acquisition) ===
let lockAcquired = false
function acquireLock() {
const owner = `task-sync:${mode}`
const until = new Date(Date.now() + 60_000).toISOString() // 60s TTL
// Ensure the singleton row exists
db.exec(`INSERT OR IGNORE INTO sync_state (id, lock_owner, lock_until) VALUES (1, NULL, NULL)`)
// Single atomic UPDATE: succeeds only when no valid lock is held
const result = db.prepare(`
UPDATE sync_state
SET lock_owner = ?, lock_until = ?
WHERE id = 1
AND (lock_owner IS NULL OR lock_until < datetime('now'))
`).run(owner, until)
if (result.changes === 0) {
const row = db.prepare('SELECT lock_owner, lock_until FROM sync_state WHERE id = 1').get()
console.error(`Sync locked by ${row?.lock_owner} until ${row?.lock_until}`)
process.exit(1)
}
lockAcquired = true
}
function releaseLock(result) {
const now = new Date().toISOString()
db.prepare(
'UPDATE sync_state SET lock_owner = NULL, lock_until = NULL, last_sync_at = ?, last_result = ? WHERE id = 1'
).run(now, result)
lockAcquired = false
}
// === SIGNAL HANDLERS (taskflow-019: release lock on exit) ===
function handleSignal(signal) {
if (lockAcquired) {
try { releaseLock(`interrupted: ${signal}`) } catch (_) {}
}
process.exit(signal === 'SIGTERM' ? 0 : 130)
}
process.on('SIGTERM', () => handleSignal('SIGTERM'))
process.on('SIGINT', () => handleSignal('SIGINT'))
// === PARSE MARKDOWN ===
const STATUS_MAP = {
'in progress': 'in_progress',
'pending validation': 'pending_validation',
'backlog': 'backlog',
'done': 'done',
'blocked': 'blocked',
}
const TASK_RE = /^- \[([ x])\] \(task:([a-z0-9-]+)\)\s*(?:\[([^\]]*)\])?\s*(?:\[([^\]]*)\])?\s*(.+)$/
function parseTaskFile(filePath) {
const content = readFileSync(filePath, 'utf8')
const lines = content.split(/\r?\n/)
const tasks = []
let currentStatus = null
const slug = path.basename(filePath).replace(/-tasks\.md$/, '')
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// Section header
const headerMatch = line.match(/^## (.+)$/)
if (headerMatch) {
const normalized = headerMatch[1].trim().toLowerCase()
currentStatus = STATUS_MAP[normalized] || null
continue
}
if (!currentStatus) continue
const taskMatch = line.match(TASK_RE)
if (taskMatch) {
const [, , id, tag1, tag2, title] = taskMatch
// Parse priority and model from tags
let priority = 'P2', model = null
for (const tag of [tag1, tag2]) {
if (!tag) continue
if (/^P\d$/.test(tag)) priority = tag
else model = tag
}
// Check for note on next line
let notes = null
if (i + 1 < lines.length) {
const noteMatch = lines[i + 1].match(/^\s+- note:\s*(.+)$/)
if (noteMatch) notes = noteMatch[1].trim()
}
tasks.push({
id,
project_id: slug,
title: title.trim(),
status: currentStatus,
priority,
owner_model: model,
notes,
source_file: `tasks/${slug}-tasks.md`,
})
}
}
return tasks
}
function parseAllFiles() {
const files = readdirSync(tasksDir).filter(f => f.endsWith('-tasks.md'))
const allTasks = []
for (const f of files) {
allTasks.push(...parseTaskFile(path.join(tasksDir, f)))
}
return allTasks
}
// === DB READ ===
function getDbTasks() {
return db.prepare(
'SELECT id, project_id, title, status, priority, owner_model, notes, source_file FROM tasks_v2 ORDER BY id'
).all()
}
// === HASH (taskflow-012: include notes field) ===
function hashTasks(tasks) {
const sorted = [...tasks].sort((a, b) => a.id.localeCompare(b.id))
const data = sorted
.map(t => `${t.id}|${t.status}|${t.priority}|${t.owner_model || ''}|${t.title}|${t.notes || ''}`)
.join('\n')
return createHash('sha256').update(data).digest('hex').slice(0, 16)
}
// === DIFF ===
function diffTasks(fileTasks, dbTasks) {
const fileMap = new Map(fileTasks.map(t => [t.id, t]))
const dbMap = new Map(dbTasks.map(t => [t.id, t]))
const diffs = []
for (const [id, ft] of fileMap) {
const dt = dbMap.get(id)
if (!dt) {
diffs.push({ type: 'file_only', id, task: ft })
} else {
const changes = []
if (ft.status !== dt.status) changes.push(`status: ${dt.status}${ft.status}`)
if (ft.priority !== dt.priority) changes.push(`priority: ${dt.priority}${ft.priority}`)
if ((ft.owner_model || null) !== (dt.owner_model || null)) changes.push(`model: ${dt.owner_model}${ft.owner_model}`)
if (ft.title !== dt.title) changes.push('title changed')
if (changes.length) diffs.push({ type: 'changed', id, changes, fileTask: ft, dbTask: dt })
}
}
for (const [id] of dbMap) {
if (!fileMap.has(id)) {
diffs.push({ type: 'db_only', id, task: dbMap.get(id) })
}
}
return diffs
}
// === WRITE TO DB ===
function syncFilesToDb(fileTasks, dbTasks, diffs) {
// Auto-create projects referenced in files but missing from the projects table.
const knownProjects = new Set(db.prepare('SELECT id FROM projects').all().map(r => r.id))
const upsertProject = db.prepare(`
INSERT OR IGNORE INTO projects (id, name, description, status, source_file)
VALUES (?, ?, '', 'active', ?)
`)
const missingProjects = new Set()
for (const t of fileTasks) {
if (!knownProjects.has(t.project_id) && !missingProjects.has(t.project_id)) {
missingProjects.add(t.project_id)
const name = t.project_id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
upsertProject.run(t.project_id, name, `tasks/${t.project_id}-tasks.md`)
console.log(` Auto-created missing project: ${t.project_id} ("${name}")`)
}
}
const upsert = db.prepare(`
INSERT INTO tasks_v2 (id, project_id, title, status, priority, owner_model, notes, source_file, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
status = excluded.status,
priority = excluded.priority,
owner_model = excluded.owner_model,
notes = COALESCE(excluded.notes, tasks_v2.notes),
source_file = excluded.source_file,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
`)
const insertTransition = db.prepare(`
INSERT INTO task_transitions_v2 (task_id, from_status, to_status, reason, actor)
VALUES (?, ?, ?, ?, 'sync')
`)
let updated = 0, added = 0
for (const diff of diffs) {
if (diff.type === 'file_only') {
const t = diff.task
upsert.run(t.id, t.project_id, t.title, t.status, t.priority, t.owner_model, t.notes, t.source_file)
insertTransition.run(t.id, null, t.status, 'added via file sync')
added++
} else if (diff.type === 'changed') {
const t = diff.fileTask
upsert.run(t.id, t.project_id, t.title, t.status, t.priority, t.owner_model, t.notes, t.source_file)
if (diff.dbTask.status !== t.status) {
insertTransition.run(t.id, diff.dbTask.status, t.status, `file sync: ${diff.changes.join(', ')}`)
}
updated++
}
// db_only tasks stay in DB (no silent drops)
}
// Update hashes
const allFileTasks = parseAllFiles()
const allDbTasks = getDbTasks()
db.prepare('UPDATE sync_state SET files_hash = ?, db_hash = ? WHERE id = 1').run(
hashTasks(allFileTasks), hashTasks(allDbTasks)
)
return { added, updated, dbOnly: diffs.filter(d => d.type === 'db_only').length }
}
// === WRITE TO FILES (taskflow-003: blocked is first-class) ===
const STATUS_ORDER = ['in_progress', 'pending_validation', 'blocked', 'backlog', 'done']
const STATUS_HEADERS = {
in_progress: 'In Progress',
pending_validation: 'Pending Validation',
blocked: 'Blocked',
backlog: 'Backlog',
done: 'Done',
}
function projectDbToFiles(dbTasks) {
const byProject = new Map()
for (const t of dbTasks) {
if (!byProject.has(t.project_id)) byProject.set(t.project_id, [])
byProject.get(t.project_id).push(t)
}
// Preserve existing file headers (everything before the first ## section)
const files = readdirSync(tasksDir).filter(f => f.endsWith('-tasks.md'))
const headers = new Map()
for (const f of files) {
const content = readFileSync(path.join(tasksDir, f), 'utf8')
const slug = f.replace(/-tasks\.md$/, '')
const lines = content.split(/\r?\n/)
const headerLines = []
for (const line of lines) {
if (line.startsWith('## ')) break
headerLines.push(line)
}
headers.set(slug, headerLines.join('\n').trimEnd())
}
// All project slugs from DB
const slugs = db.prepare('SELECT id FROM projects ORDER BY id').all().map(r => r.id)
let filesWritten = 0
for (const slug of slugs) {
const tasks = byProject.get(slug) || []
const header = headers.get(slug) || `# Tasks: ${slug}`
let md = header + '\n\n'
for (const section of STATUS_ORDER) {
md += `## ${STATUS_HEADERS[section]}\n`
const sectionTasks = tasks
.filter(t => t.status === section)
.sort((a, b) => a.priority.localeCompare(b.priority) || a.id.localeCompare(b.id))
if (sectionTasks.length === 0) {
md += '\n'
} else {
for (const t of sectionTasks) {
const checkbox = t.status === 'done' ? '[x]' : '[ ]'
const modelTag = t.owner_model ? ` [${t.owner_model}]` : ''
md += `- ${checkbox} (task:${t.id}) [${t.priority}]${modelTag} ${t.title}\n`
if (t.notes) md += ` - note: ${t.notes}\n`
}
md += '\n'
}
}
writeFileSync(path.join(tasksDir, `${slug}-tasks.md`), md)
filesWritten++
}
return { filesWritten }
}
// === MAIN ===
try {
if (mode !== 'check') acquireLock()
const fileTasks = parseAllFiles()
const dbTasks = getDbTasks()
const diffs = diffTasks(fileTasks, dbTasks)
if (mode === 'check') {
const fileHash = hashTasks(fileTasks)
const dbHash = hashTasks(dbTasks)
if (diffs.length === 0) {
console.log(`OK: ${fileTasks.length} tasks in sync (hash: ${fileHash})`)
process.exit(0)
} else {
console.log(`DRIFT DETECTED: ${diffs.length} differences`)
console.log(` File hash: ${fileHash}`)
console.log(` DB hash: ${dbHash}`)
for (const d of diffs) {
if (d.type === 'file_only') console.log(` + ${d.id} (in files, not DB)`)
else if (d.type === 'db_only') console.log(` - ${d.id} (in DB, not files)`)
else console.log(` ~ ${d.id}: ${d.changes.join(', ')}`)
}
process.exit(1)
}
} else if (mode === 'files-to-db') {
const result = syncFilesToDb(fileTasks, dbTasks, diffs)
console.log(`Synced files → DB: ${result.added} added, ${result.updated} updated, ${result.dbOnly} DB-only preserved`)
releaseLock('ok')
} else if (mode === 'db-to-files') {
const freshDbTasks = getDbTasks()
const result = projectDbToFiles(freshDbTasks)
console.log(`Projected DB → files: ${result.filesWritten} files written`)
releaseLock('ok')
}
} catch (err) {
if (mode !== 'check') {
try { releaseLock(`failed: ${err.message}`) } catch (_) {}
}
console.error(err)
process.exit(1)
}
File diff suppressed because it is too large Load Diff