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:
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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)} · 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')
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user