clawdo 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/db.js ADDED
@@ -0,0 +1,906 @@
1
+ import Database from 'better-sqlite3';
2
+ import { existsSync, mkdirSync, chmodSync, appendFileSync } from 'fs';
3
+ import { appendFile } from 'fs/promises';
4
+ import { dirname } from 'path';
5
+ import { sanitizeText, sanitizeNotes, sanitizeTag, validateTaskId, generateTaskId, LIMITS } from './sanitize.js';
6
+ import { ClawdoError } from './errors.js';
7
+ // Helper to wrap SQLite constraint errors with ClawdoError
8
+ function wrapSQLiteError(error) {
9
+ const message = error.message || '';
10
+ // Check constraint failed errors
11
+ if (message.includes('CHECK constraint failed')) {
12
+ if (message.includes('urgency IN')) {
13
+ return new ClawdoError('INVALID_URGENCY', 'Invalid urgency. Must be one of: now, soon, whenever, someday');
14
+ }
15
+ if (message.includes('autonomy IN')) {
16
+ return new ClawdoError('INVALID_AUTONOMY', 'Invalid autonomy level. Must be one of: auto, auto-notify, collab');
17
+ }
18
+ if (message.includes('status IN')) {
19
+ return new ClawdoError('INVALID_STATUS', 'Invalid status. Must be one of: proposed, todo, in_progress, done, archived');
20
+ }
21
+ if (message.includes('added_by IN')) {
22
+ return new Error('Invalid added_by. Must be one of: human, agent');
23
+ }
24
+ }
25
+ // UNIQUE constraint failed
26
+ if (message.includes('UNIQUE constraint failed')) {
27
+ if (message.includes('tasks.id')) {
28
+ return new Error('Task ID already exists');
29
+ }
30
+ }
31
+ // FOREIGN KEY constraint failed
32
+ if (message.includes('FOREIGN KEY constraint failed')) {
33
+ return new ClawdoError('BLOCKER_NOT_FOUND', 'Referenced task does not exist');
34
+ }
35
+ // Default: return original error
36
+ return error;
37
+ }
38
+ export class TodoDatabase {
39
+ db;
40
+ auditPath;
41
+ auditQueue = [];
42
+ flushTimer = null;
43
+ AUDIT_BATCH_SIZE = 50;
44
+ AUDIT_BATCH_MS = 100;
45
+ constructor(dbPath, auditPath) {
46
+ // Ensure directory exists with secure permissions
47
+ const dir = dirname(dbPath);
48
+ if (!existsSync(dir)) {
49
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
50
+ }
51
+ this.db = new Database(dbPath);
52
+ this.auditPath = auditPath;
53
+ // Enable WAL mode
54
+ this.db.pragma('journal_mode = WAL');
55
+ this.db.pragma('synchronous = NORMAL');
56
+ // Set file permissions (owner only)
57
+ try {
58
+ chmodSync(dbPath, 0o600);
59
+ }
60
+ catch (error) {
61
+ if (process.env.NODE_ENV === 'production') {
62
+ console.warn('Warning: Database permission setup failed');
63
+ }
64
+ else {
65
+ console.warn(`Warning: Could not set database file permissions: ${error}`);
66
+ }
67
+ }
68
+ // Ensure audit log exists with secure permissions
69
+ if (!existsSync(auditPath)) {
70
+ appendFileSync(auditPath, '', { mode: 0o600 });
71
+ }
72
+ this.migrate();
73
+ }
74
+ migrate() {
75
+ // Create config table first
76
+ this.db.exec(`
77
+ CREATE TABLE IF NOT EXISTS config (
78
+ key TEXT PRIMARY KEY,
79
+ value TEXT
80
+ );
81
+ `);
82
+ // Check schema version
83
+ const versionRow = this.db.prepare('SELECT value FROM config WHERE key = ?').get('schema_version');
84
+ const currentVersion = versionRow?.value ? parseInt(versionRow.value, 10) : 0;
85
+ if (currentVersion < 1) {
86
+ this.migrateV1();
87
+ this.db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)').run('schema_version', '1');
88
+ }
89
+ }
90
+ migrateV1() {
91
+ this.db.exec(`
92
+ CREATE TABLE IF NOT EXISTS tasks (
93
+ id TEXT PRIMARY KEY CHECK(length(id) = 8 AND id GLOB '[a-z0-9]*'),
94
+ text TEXT NOT NULL CHECK(length(trim(text)) > 0 AND length(text) <= 1000),
95
+ status TEXT DEFAULT 'todo' CHECK(status IN ('proposed','todo','in_progress','done','archived')),
96
+ autonomy TEXT DEFAULT 'collab' CHECK(autonomy IN ('auto','auto-notify','collab')),
97
+ urgency TEXT DEFAULT 'whenever' CHECK(urgency IN ('now','soon','whenever','someday')),
98
+ project TEXT CHECK(project IS NULL OR (project GLOB '+[a-z0-9-]*' AND length(project) <= 50)),
99
+ context TEXT CHECK(context IS NULL OR (context GLOB '@[a-z0-9-]*' AND length(context) <= 50)),
100
+ due_date TEXT,
101
+ blocked_by TEXT REFERENCES tasks(id),
102
+ added_by TEXT DEFAULT 'human' CHECK(added_by IN ('human','agent')),
103
+ created_at TEXT NOT NULL,
104
+ started_at TEXT,
105
+ completed_at TEXT,
106
+ notes TEXT CHECK(notes IS NULL OR length(notes) <= 5000),
107
+ attempts INTEGER DEFAULT 0,
108
+ last_attempt_at TEXT,
109
+ tokens_used INTEGER DEFAULT 0,
110
+ duration_sec INTEGER DEFAULT 0
111
+ );
112
+
113
+ CREATE INDEX IF NOT EXISTS idx_status ON tasks(status);
114
+ CREATE INDEX IF NOT EXISTS idx_autonomy ON tasks(autonomy);
115
+ CREATE INDEX IF NOT EXISTS idx_urgency ON tasks(urgency);
116
+ CREATE INDEX IF NOT EXISTS idx_project ON tasks(project);
117
+ CREATE INDEX IF NOT EXISTS idx_blocked_by ON tasks(blocked_by);
118
+
119
+ CREATE TABLE IF NOT EXISTS task_history (
120
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
121
+ task_id TEXT NOT NULL REFERENCES tasks(id),
122
+ action TEXT NOT NULL,
123
+ actor TEXT NOT NULL CHECK(actor IN ('human','agent')),
124
+ timestamp TEXT NOT NULL,
125
+ notes TEXT,
126
+ session_id TEXT,
127
+ session_log_path TEXT,
128
+ old_value TEXT,
129
+ new_value TEXT,
130
+ tools_used TEXT
131
+ );
132
+
133
+ CREATE INDEX IF NOT EXISTS idx_history_task ON task_history(task_id);
134
+ CREATE INDEX IF NOT EXISTS idx_history_timestamp ON task_history(timestamp);
135
+
136
+ -- Fallback table for failed audit writes
137
+ CREATE TABLE IF NOT EXISTS _failed_audits (
138
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
139
+ timestamp TEXT NOT NULL,
140
+ action TEXT NOT NULL,
141
+ actor TEXT NOT NULL,
142
+ task_id TEXT NOT NULL,
143
+ details TEXT,
144
+ error TEXT
145
+ );
146
+
147
+ INSERT OR IGNORE INTO config (key, value) VALUES ('heartbeat_lock', NULL);
148
+ INSERT OR IGNORE INTO config (key, value) VALUES ('auto_execution_enabled', 'true');
149
+ `);
150
+ }
151
+ // Audit log helper - batched async writes to prevent I/O blocking
152
+ audit(action, actor, taskId, details = {}) {
153
+ const timestamp = new Date().toISOString();
154
+ const entry = {
155
+ timestamp,
156
+ action,
157
+ actor,
158
+ taskId,
159
+ ...details
160
+ };
161
+ // Add to queue
162
+ this.auditQueue.push(JSON.stringify(entry) + '\n');
163
+ // Flush immediately if batch size reached
164
+ if (this.auditQueue.length >= this.AUDIT_BATCH_SIZE) {
165
+ this.flushAudit();
166
+ return;
167
+ }
168
+ // Otherwise schedule delayed flush
169
+ if (!this.flushTimer) {
170
+ this.flushTimer = setTimeout(() => {
171
+ this.flushAudit();
172
+ }, this.AUDIT_BATCH_MS);
173
+ }
174
+ }
175
+ // Flush audit queue to disk (async, non-blocking)
176
+ flushAudit() {
177
+ if (this.flushTimer) {
178
+ clearTimeout(this.flushTimer);
179
+ this.flushTimer = null;
180
+ }
181
+ if (this.auditQueue.length === 0)
182
+ return;
183
+ const batch = this.auditQueue.splice(0); // Drain queue
184
+ const batchText = batch.join('');
185
+ // Async write (non-blocking)
186
+ appendFile(this.auditPath, batchText, { flag: 'a' }).catch((error) => {
187
+ // Production: generic message, Dev: detailed error
188
+ if (process.env.NODE_ENV === 'production') {
189
+ console.warn('Warning: Audit log write failed');
190
+ }
191
+ else {
192
+ console.warn(`Warning: Could not write to audit log: ${error}`);
193
+ }
194
+ // Fallback: store in database
195
+ for (const line of batch) {
196
+ try {
197
+ const entry = JSON.parse(line);
198
+ this.db.prepare(`
199
+ INSERT INTO _failed_audits (timestamp, action, actor, task_id, details, error)
200
+ VALUES (?, ?, ?, ?, ?, ?)
201
+ `).run(entry.timestamp, entry.action, entry.actor, entry.taskId, JSON.stringify(entry), String(error));
202
+ }
203
+ catch (dbError) {
204
+ if (process.env.NODE_ENV !== 'production') {
205
+ console.error(`CRITICAL: Could not write to audit fallback: ${dbError}`);
206
+ }
207
+ }
208
+ }
209
+ });
210
+ }
211
+ // Create task
212
+ createTask(text, addedBy, options = {}) {
213
+ // Sanitize inputs (will throw if validation fails)
214
+ const cleanText = sanitizeText(text);
215
+ const cleanProject = sanitizeTag(options.project);
216
+ const cleanContext = sanitizeTag(options.context);
217
+ // Validate project/context format if provided
218
+ if (cleanProject && !/^\+[a-z0-9-]+$/.test(cleanProject)) {
219
+ throw new ClawdoError('INVALID_PROJECT_FORMAT', 'Project must start with + and contain only lowercase letters, numbers, and hyphens', { project: cleanProject });
220
+ }
221
+ if (cleanContext && !/^@[a-z0-9-]+$/.test(cleanContext)) {
222
+ throw new ClawdoError('INVALID_PROJECT_FORMAT', 'Context must start with @ and contain only lowercase letters, numbers, and hyphens', { context: cleanContext });
223
+ }
224
+ // Validate due date format if provided
225
+ if (options.dueDate) {
226
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(options.dueDate)) {
227
+ throw new ClawdoError('INVALID_STATUS', 'Due date must be in YYYY-MM-DD format', { dueDate: options.dueDate });
228
+ }
229
+ // Verify it's a valid date
230
+ const date = new Date(options.dueDate);
231
+ if (isNaN(date.getTime())) {
232
+ throw new ClawdoError('INVALID_STATUS', 'Due date is not a valid date', { dueDate: options.dueDate });
233
+ }
234
+ }
235
+ // Generate unique ID first (needed for cycle detection)
236
+ let id = generateTaskId();
237
+ while (this.getTask(id)) {
238
+ id = generateTaskId();
239
+ }
240
+ // Validate blocker exists and check for cycles if provided
241
+ if (options.blockedBy) {
242
+ if (!validateTaskId(options.blockedBy)) {
243
+ throw new ClawdoError('BLOCKER_NOT_FOUND', 'Invalid blocker task ID format', { blockerId: options.blockedBy });
244
+ }
245
+ const blocker = this.getTask(options.blockedBy);
246
+ if (!blocker) {
247
+ throw new ClawdoError('BLOCKER_NOT_FOUND', `Blocker task not found: ${options.blockedBy}`, { blockerId: options.blockedBy });
248
+ }
249
+ // Check for circular dependency
250
+ if (this.detectBlockerCycle(id, options.blockedBy)) {
251
+ throw new ClawdoError('CIRCULAR_DEPENDENCY', 'Cannot block: would create circular dependency', { taskId: id, blockerId: options.blockedBy });
252
+ }
253
+ }
254
+ // Rate limiting for agent proposals (DB-level enforcement)
255
+ if (addedBy === 'agent') {
256
+ const proposedCount = this.countProposed();
257
+ if (proposedCount >= 5) {
258
+ throw new ClawdoError('RATE_LIMIT_EXCEEDED', 'Too many proposed tasks (max 5 active). Confirm or reject existing proposals first.', { proposedCount, limit: 5 });
259
+ }
260
+ // Enforce cooldown between agent proposals (60 seconds)
261
+ const lastProposal = this.getConfig('last_agent_proposal');
262
+ if (lastProposal) {
263
+ const cooldownMs = 60000; // 60 seconds
264
+ const elapsed = Date.now() - parseInt(lastProposal, 10);
265
+ if (elapsed < cooldownMs) {
266
+ throw new ClawdoError('RATE_LIMIT_EXCEEDED', `Agent must wait ${Math.ceil((cooldownMs - elapsed) / 1000)}s between proposals`, { cooldownSec: Math.ceil((cooldownMs - elapsed) / 1000) });
267
+ }
268
+ }
269
+ this.setConfig('last_agent_proposal', Date.now().toString());
270
+ }
271
+ // Determine initial status
272
+ // Agents always create proposed tasks (confirmed flag ignored for security)
273
+ // Humans always create todo tasks
274
+ const status = (addedBy === 'agent') ? 'proposed' : 'todo';
275
+ const now = new Date().toISOString();
276
+ try {
277
+ this.db.prepare(`
278
+ INSERT INTO tasks (
279
+ id, text, status, autonomy, urgency, project, context, due_date,
280
+ blocked_by, added_by, created_at
281
+ )
282
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
283
+ `).run(id, cleanText, status, options.autonomy || 'collab', options.urgency || 'whenever', cleanProject, cleanContext, options.dueDate || null, options.blockedBy || null, addedBy, now);
284
+ }
285
+ catch (error) {
286
+ throw wrapSQLiteError(error);
287
+ }
288
+ // Log to history
289
+ this.addHistory({
290
+ taskId: id,
291
+ action: 'created',
292
+ actor: addedBy,
293
+ timestamp: now,
294
+ notes: status === 'proposed' ? 'Agent proposed task' : null,
295
+ });
296
+ // Audit log
297
+ this.audit('create', addedBy, id, { text: cleanText, status, autonomy: options.autonomy || 'collab' });
298
+ return id;
299
+ }
300
+ // Get task by ID
301
+ getTask(id) {
302
+ if (!validateTaskId(id))
303
+ return null;
304
+ const row = this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
305
+ if (!row)
306
+ return null;
307
+ return this.rowToTask(row);
308
+ }
309
+ // Find tasks by ID prefix
310
+ findTasksByPrefix(prefix) {
311
+ if (!prefix || prefix.length === 0)
312
+ return [];
313
+ if (!/^[a-z0-9]+$/.test(prefix))
314
+ return [];
315
+ const rows = this.db.prepare('SELECT * FROM tasks WHERE id LIKE ?').all(`${prefix}%`);
316
+ return rows.map(row => this.rowToTask(row));
317
+ }
318
+ // Resolve task ID from prefix (returns full ID if unambiguous, null otherwise)
319
+ resolveTaskId(idOrPrefix) {
320
+ // Guard against overly long prefixes (task IDs are exactly 8 chars)
321
+ if (idOrPrefix.length > 8) {
322
+ return null;
323
+ }
324
+ // If it's already a full ID, return it
325
+ if (validateTaskId(idOrPrefix)) {
326
+ return this.getTask(idOrPrefix) ? idOrPrefix : null;
327
+ }
328
+ // Try prefix matching
329
+ const matches = this.findTasksByPrefix(idOrPrefix);
330
+ if (matches.length === 0) {
331
+ return null;
332
+ }
333
+ if (matches.length === 1) {
334
+ return matches[0].id;
335
+ }
336
+ // Multiple matches - ambiguous
337
+ throw new ClawdoError('AMBIGUOUS_ID', `Multiple tasks match '${idOrPrefix}'`, {
338
+ prefix: idOrPrefix,
339
+ matches: matches.map(t => t.id)
340
+ });
341
+ }
342
+ // Detect circular blocker dependency
343
+ detectBlockerCycle(taskId, blockerId) {
344
+ const visited = new Set();
345
+ let current = blockerId;
346
+ while (current) {
347
+ if (current === taskId)
348
+ return true; // Cycle detected!
349
+ if (visited.has(current))
350
+ return false; // Different cycle, not our problem
351
+ visited.add(current);
352
+ const task = this.getTask(current);
353
+ current = task?.blockedBy || null;
354
+ }
355
+ return false;
356
+ }
357
+ // List tasks with filters
358
+ listTasks(filters = {}) {
359
+ let query = 'SELECT * FROM tasks WHERE 1=1';
360
+ const params = [];
361
+ if (filters.status) {
362
+ if (Array.isArray(filters.status)) {
363
+ query += ` AND status IN (${filters.status.map(() => '?').join(',')})`;
364
+ params.push(...filters.status);
365
+ }
366
+ else {
367
+ query += ' AND status = ?';
368
+ params.push(filters.status);
369
+ }
370
+ }
371
+ if (filters.autonomy) {
372
+ query += ' AND autonomy = ?';
373
+ params.push(filters.autonomy);
374
+ }
375
+ if (filters.urgency) {
376
+ query += ' AND urgency = ?';
377
+ params.push(filters.urgency);
378
+ }
379
+ if (filters.project) {
380
+ query += ' AND project = ?';
381
+ params.push(filters.project);
382
+ }
383
+ if (filters.addedBy) {
384
+ query += ' AND added_by = ?';
385
+ params.push(filters.addedBy);
386
+ }
387
+ if (filters.blocked === true) {
388
+ query += ' AND blocked_by IS NOT NULL';
389
+ }
390
+ else if (filters.blocked === false) {
391
+ query += ' AND blocked_by IS NULL';
392
+ }
393
+ if (filters.ready) {
394
+ query += ' AND status IN (?, ?) AND blocked_by IS NULL';
395
+ params.push('todo', 'in_progress');
396
+ }
397
+ // Sort by urgency, then created_at
398
+ query += ' ORDER BY CASE urgency WHEN \'now\' THEN 0 WHEN \'soon\' THEN 1 WHEN \'whenever\' THEN 2 WHEN \'someday\' THEN 3 END, created_at ASC';
399
+ const rows = this.db.prepare(query).all(...params);
400
+ return rows.map(this.rowToTask);
401
+ }
402
+ // Get next task (highest priority ready task)
403
+ getNextTask(options = {}) {
404
+ let query = 'SELECT * FROM tasks WHERE status = ? AND blocked_by IS NULL';
405
+ const params = ['todo'];
406
+ if (options.auto) {
407
+ query += ' AND autonomy IN (?, ?)';
408
+ params.push('auto', 'auto-notify');
409
+ }
410
+ query += ' ORDER BY CASE urgency WHEN \'now\' THEN 0 WHEN \'soon\' THEN 1 WHEN \'whenever\' THEN 2 WHEN \'someday\' THEN 3 END, created_at ASC LIMIT 1';
411
+ const row = this.db.prepare(query).get(...params);
412
+ return row ? this.rowToTask(row) : null;
413
+ }
414
+ // Update task
415
+ updateTask(id, updates, actor) {
416
+ if (!validateTaskId(id)) {
417
+ throw new ClawdoError('TASK_NOT_FOUND', 'Invalid task ID format', { id });
418
+ }
419
+ const existing = this.getTask(id);
420
+ if (!existing) {
421
+ throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
422
+ }
423
+ // Autonomy level changes are not allowed via edit - security gate
424
+ // This prevents agents from escalating their own autonomy
425
+ if (updates.autonomy !== undefined) {
426
+ throw new ClawdoError('PERMISSION_DENIED', 'Autonomy level cannot be changed after task creation. This prevents agents from escalating their own permissions.', {
427
+ taskId: id,
428
+ currentAutonomy: existing.autonomy
429
+ });
430
+ }
431
+ const allowedFields = ['text', 'status', 'urgency', 'project', 'context', 'due_date', 'blocked_by', 'notes', 'started_at', 'completed_at'];
432
+ const setClauses = [];
433
+ const params = [];
434
+ for (const [key, value] of Object.entries(updates)) {
435
+ const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
436
+ if (!allowedFields.includes(snakeKey))
437
+ continue;
438
+ // Sanitize value based on field (will throw if validation fails)
439
+ let cleanValue = value;
440
+ if (key === 'text' && typeof value === 'string') {
441
+ cleanValue = sanitizeText(value);
442
+ }
443
+ else if (key === 'notes') {
444
+ cleanValue = sanitizeNotes(value);
445
+ }
446
+ else if (key === 'project' || key === 'context') {
447
+ cleanValue = sanitizeTag(value);
448
+ }
449
+ setClauses.push(`${snakeKey} = ?`);
450
+ params.push(cleanValue);
451
+ }
452
+ if (setClauses.length === 0)
453
+ return;
454
+ params.push(id);
455
+ try {
456
+ this.db.prepare(`UPDATE tasks SET ${setClauses.join(', ')} WHERE id = ?`).run(...params);
457
+ }
458
+ catch (error) {
459
+ throw wrapSQLiteError(error);
460
+ }
461
+ // Log to history
462
+ const now = new Date().toISOString();
463
+ this.addHistory({
464
+ taskId: id,
465
+ action: 'updated',
466
+ actor,
467
+ timestamp: now,
468
+ oldValue: JSON.stringify(existing),
469
+ newValue: JSON.stringify(this.getTask(id)),
470
+ });
471
+ // Audit log
472
+ this.audit('update', actor, id, { updates });
473
+ }
474
+ // Start task (marks in_progress)
475
+ startTask(id, actor) {
476
+ const task = this.getTask(id);
477
+ if (!task) {
478
+ throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
479
+ }
480
+ if (task.status === 'in_progress') {
481
+ throw new ClawdoError('TASK_ALREADY_IN_PROGRESS', `Task already in progress: ${id}`, { id, status: task.status });
482
+ }
483
+ if (task.status !== 'todo') {
484
+ throw new ClawdoError('INVALID_STATUS_TRANSITION', `Task must be in todo status to start (current: ${task.status})`, { id, status: task.status });
485
+ }
486
+ // Cannot start blocked tasks
487
+ if (task.blockedBy) {
488
+ const blocker = this.getTask(task.blockedBy);
489
+ if (blocker && blocker.status !== 'done' && blocker.status !== 'archived') {
490
+ throw new ClawdoError('TASK_BLOCKED', `Task is blocked by ${task.blockedBy}. Complete blocker first.`, { id, blockerId: task.blockedBy });
491
+ }
492
+ }
493
+ const now = new Date().toISOString();
494
+ this.db.prepare('UPDATE tasks SET status = ?, started_at = ? WHERE id = ?').run('in_progress', now, id);
495
+ this.addHistory({
496
+ taskId: id,
497
+ action: 'started',
498
+ actor,
499
+ timestamp: now,
500
+ });
501
+ this.audit('start', actor, id);
502
+ }
503
+ // Complete task (marks done)
504
+ completeTask(id, actor, sessionId, sessionLogPath, toolsUsed) {
505
+ const task = this.getTask(id);
506
+ if (!task) {
507
+ throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
508
+ }
509
+ // Cannot complete proposed tasks - must be confirmed first
510
+ if (task.status === 'proposed') {
511
+ throw new ClawdoError('TASK_NOT_CONFIRMED', `Task is proposed and must be confirmed first.\n Run: clawdo confirm ${id}`, { id, status: task.status });
512
+ }
513
+ // Cannot complete already done tasks
514
+ if (task.status === 'done') {
515
+ throw new ClawdoError('TASK_ALREADY_DONE', `Task already completed: ${id}`, { id });
516
+ }
517
+ // Cannot complete blocked tasks
518
+ if (task.blockedBy) {
519
+ const blocker = this.getTask(task.blockedBy);
520
+ if (blocker && blocker.status !== 'done' && blocker.status !== 'archived') {
521
+ throw new ClawdoError('TASK_BLOCKED', `Task is blocked by ${task.blockedBy}. Complete blocker first.`, { id, blockerId: task.blockedBy });
522
+ }
523
+ }
524
+ const now = new Date().toISOString();
525
+ // Atomic transaction for all DB operations
526
+ const transaction = this.db.transaction(() => {
527
+ this.db.prepare('UPDATE tasks SET status = ?, completed_at = ? WHERE id = ?').run('done', now, id);
528
+ this.addHistory({
529
+ taskId: id,
530
+ action: 'completed',
531
+ actor,
532
+ timestamp: now,
533
+ sessionId,
534
+ sessionLogPath,
535
+ toolsUsed: toolsUsed ? JSON.stringify(toolsUsed) : null,
536
+ });
537
+ // Unblock any tasks that were waiting on this one
538
+ this.db.prepare('UPDATE tasks SET blocked_by = NULL WHERE blocked_by = ?').run(id);
539
+ });
540
+ transaction(); // Execute atomically
541
+ // Audit AFTER successful commit (can fail safely)
542
+ this.audit('complete', actor, id, { sessionId, toolsUsed });
543
+ }
544
+ // Fail task attempt
545
+ failTask(id, reason) {
546
+ const task = this.getTask(id);
547
+ if (!task) {
548
+ throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
549
+ }
550
+ const now = new Date().toISOString();
551
+ const newAttempts = task.attempts + 1;
552
+ // Reset to todo for retry, unless max attempts reached
553
+ if (newAttempts >= 3) {
554
+ // Upgrade to collab after 3 failures
555
+ this.db.prepare('UPDATE tasks SET status = ?, autonomy = ?, attempts = ?, last_attempt_at = ?, notes = ? WHERE id = ?')
556
+ .run('todo', 'collab', newAttempts, now, `[Auto-failed 3 times] ${task.notes || ''}`.substring(0, LIMITS.notes), id);
557
+ }
558
+ else {
559
+ this.db.prepare('UPDATE tasks SET status = ?, attempts = ?, last_attempt_at = ? WHERE id = ?')
560
+ .run('todo', newAttempts, now, id);
561
+ }
562
+ this.addHistory({
563
+ taskId: id,
564
+ action: 'failed',
565
+ actor: 'agent',
566
+ timestamp: now,
567
+ notes: reason,
568
+ });
569
+ this.audit('fail', 'agent', id, { reason, attempts: newAttempts });
570
+ }
571
+ // Archive task
572
+ archiveTask(id, actor) {
573
+ const task = this.getTask(id);
574
+ if (!task) {
575
+ throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
576
+ }
577
+ if (task.status === 'archived') {
578
+ throw new ClawdoError('TASK_ALREADY_ARCHIVED', `Task already archived: ${id}`, { id });
579
+ }
580
+ this.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run('archived', id);
581
+ const now = new Date().toISOString();
582
+ this.addHistory({
583
+ taskId: id,
584
+ action: 'archived',
585
+ actor,
586
+ timestamp: now,
587
+ });
588
+ this.audit('archive', actor, id);
589
+ }
590
+ // Unarchive task
591
+ unarchiveTask(id, actor) {
592
+ const task = this.getTask(id);
593
+ if (!task) {
594
+ throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
595
+ }
596
+ this.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run('todo', id);
597
+ const now = new Date().toISOString();
598
+ this.addHistory({
599
+ taskId: id,
600
+ action: 'unarchived',
601
+ actor,
602
+ timestamp: now,
603
+ });
604
+ this.audit('unarchive', actor, id);
605
+ }
606
+ // Bulk complete tasks matching filters
607
+ bulkComplete(filters, actor) {
608
+ const tasks = this.listTasks(filters);
609
+ const now = new Date().toISOString();
610
+ let count = 0;
611
+ // Atomic transaction for all bulk operations
612
+ const transaction = this.db.transaction(() => {
613
+ for (const task of tasks) {
614
+ if (task.status === 'todo' || task.status === 'in_progress') {
615
+ this.db.prepare('UPDATE tasks SET status = ?, completed_at = ? WHERE id = ?').run('done', now, task.id);
616
+ this.addHistory({
617
+ taskId: task.id,
618
+ action: 'bulk_completed',
619
+ actor,
620
+ timestamp: now,
621
+ });
622
+ // Unblock any tasks that were waiting on this one
623
+ this.db.prepare('UPDATE tasks SET blocked_by = NULL WHERE blocked_by = ?').run(task.id);
624
+ count++;
625
+ }
626
+ }
627
+ });
628
+ transaction(); // Execute atomically
629
+ if (count > 0) {
630
+ this.audit('bulk_complete', actor, 'multiple', { count, filters });
631
+ }
632
+ return count;
633
+ }
634
+ // Bulk archive tasks matching filters
635
+ bulkArchive(filters, actor) {
636
+ const tasks = this.listTasks(filters);
637
+ const now = new Date().toISOString();
638
+ let count = 0;
639
+ // Atomic transaction for all bulk operations
640
+ const transaction = this.db.transaction(() => {
641
+ for (const task of tasks) {
642
+ if (task.status !== 'archived') {
643
+ this.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run('archived', task.id);
644
+ this.addHistory({
645
+ taskId: task.id,
646
+ action: 'bulk_archived',
647
+ actor,
648
+ timestamp: now,
649
+ });
650
+ count++;
651
+ }
652
+ }
653
+ });
654
+ transaction(); // Execute atomically
655
+ if (count > 0) {
656
+ this.audit('bulk_archive', actor, 'multiple', { count, filters });
657
+ }
658
+ return count;
659
+ }
660
+ // Confirm proposed task
661
+ confirmTask(id, actor) {
662
+ const task = this.getTask(id);
663
+ if (!task) {
664
+ throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
665
+ }
666
+ if (task.status !== 'proposed') {
667
+ throw new ClawdoError('INVALID_STATUS_TRANSITION', `Task is not in proposed status: ${id}`, { id, status: task.status });
668
+ }
669
+ this.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run('todo', id);
670
+ const now = new Date().toISOString();
671
+ this.addHistory({
672
+ taskId: id,
673
+ action: 'confirmed',
674
+ actor,
675
+ timestamp: now,
676
+ });
677
+ this.audit('confirm', actor, id);
678
+ }
679
+ // Reject proposed task
680
+ rejectTask(id, actor, reason) {
681
+ const task = this.getTask(id);
682
+ if (!task) {
683
+ throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
684
+ }
685
+ if (task.status !== 'proposed') {
686
+ throw new ClawdoError('INVALID_STATUS_TRANSITION', `Task is not in proposed status: ${id}`, { id, status: task.status });
687
+ }
688
+ this.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run('archived', id);
689
+ const now = new Date().toISOString();
690
+ this.addHistory({
691
+ taskId: id,
692
+ action: 'rejected',
693
+ actor,
694
+ timestamp: now,
695
+ notes: reason || null,
696
+ });
697
+ this.audit('reject', actor, id, { reason });
698
+ }
699
+ // Block task
700
+ blockTask(id, blockerId, actor) {
701
+ if (!validateTaskId(id) || !validateTaskId(blockerId)) {
702
+ throw new ClawdoError('TASK_NOT_FOUND', 'Invalid task ID format', { id, blockerId });
703
+ }
704
+ const task = this.getTask(id);
705
+ const blocker = this.getTask(blockerId);
706
+ if (!task)
707
+ throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
708
+ if (!blocker)
709
+ throw new ClawdoError('BLOCKER_NOT_FOUND', `Blocker task not found: ${blockerId}`, { blockerId });
710
+ if (blocker.status === 'done' || blocker.status === 'archived') {
711
+ throw new ClawdoError('BLOCKER_ALREADY_DONE', 'Cannot block by a completed or archived task', { blockerId, status: blocker.status });
712
+ }
713
+ // Check for circular dependency
714
+ if (this.detectBlockerCycle(id, blockerId)) {
715
+ throw new ClawdoError('CIRCULAR_DEPENDENCY', 'Cannot block: would create circular dependency', { taskId: id, blockerId });
716
+ }
717
+ this.db.prepare('UPDATE tasks SET blocked_by = ? WHERE id = ?').run(blockerId, id);
718
+ const now = new Date().toISOString();
719
+ this.addHistory({
720
+ taskId: id,
721
+ action: 'blocked',
722
+ actor,
723
+ timestamp: now,
724
+ notes: `Blocked by ${blockerId}`,
725
+ });
726
+ this.audit('block', actor, id, { blockerId });
727
+ }
728
+ // Unblock task
729
+ unblockTask(id, actor) {
730
+ const task = this.getTask(id);
731
+ if (!task) {
732
+ throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
733
+ }
734
+ this.db.prepare('UPDATE tasks SET blocked_by = NULL WHERE id = ?').run(id);
735
+ const now = new Date().toISOString();
736
+ this.addHistory({
737
+ taskId: id,
738
+ action: 'unblocked',
739
+ actor,
740
+ timestamp: now,
741
+ });
742
+ this.audit('unblock', actor, id);
743
+ }
744
+ // Add note to task
745
+ addNote(id, note, actor) {
746
+ const task = this.getTask(id);
747
+ if (!task) {
748
+ throw new ClawdoError('TASK_NOT_FOUND', `Task not found: ${id}`, { id });
749
+ }
750
+ // Sanitize the new note (will throw if too long)
751
+ const cleanNote = sanitizeNotes(note);
752
+ const now = new Date().toISOString();
753
+ const dateStamp = now.split('T')[0];
754
+ const newNote = `[${dateStamp}] ${cleanNote}`;
755
+ const updatedNotes = task.notes ? `${task.notes}\n${newNote}` : newNote;
756
+ // Check combined length
757
+ if (updatedNotes.length > LIMITS.notes) {
758
+ throw new ClawdoError('TEXT_TOO_LONG', `Combined notes too long: ${updatedNotes.length} chars (max ${LIMITS.notes})`, { length: updatedNotes.length, max: LIMITS.notes });
759
+ }
760
+ this.db.prepare('UPDATE tasks SET notes = ? WHERE id = ?').run(updatedNotes, id);
761
+ this.addHistory({
762
+ taskId: id,
763
+ action: 'note_added',
764
+ actor,
765
+ timestamp: now,
766
+ notes: cleanNote,
767
+ });
768
+ this.audit('note', actor, id, { note: cleanNote });
769
+ }
770
+ // Get task history
771
+ getHistory(id) {
772
+ const rows = this.db.prepare('SELECT * FROM task_history WHERE task_id = ? ORDER BY timestamp DESC').all(id);
773
+ return rows.map(row => ({
774
+ id: row.id,
775
+ taskId: row.task_id,
776
+ action: row.action,
777
+ actor: row.actor,
778
+ timestamp: row.timestamp,
779
+ notes: row.notes,
780
+ sessionId: row.session_id,
781
+ sessionLogPath: row.session_log_path,
782
+ oldValue: row.old_value,
783
+ newValue: row.new_value,
784
+ toolsUsed: row.tools_used,
785
+ }));
786
+ }
787
+ // Add history entry
788
+ addHistory(entry) {
789
+ this.db.prepare(`
790
+ INSERT INTO task_history (task_id, action, actor, timestamp, notes, session_id, session_log_path, old_value, new_value, tools_used)
791
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
792
+ `).run(entry.taskId, entry.action, entry.actor, entry.timestamp, entry.notes || null, entry.sessionId || null, entry.sessionLogPath || null, entry.oldValue || null, entry.newValue || null, entry.toolsUsed || null);
793
+ }
794
+ // Config methods
795
+ getConfig(key) {
796
+ const row = this.db.prepare('SELECT value FROM config WHERE key = ?').get(key);
797
+ return row ? row.value : null;
798
+ }
799
+ setConfig(key, value) {
800
+ this.db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)').run(key, value);
801
+ }
802
+ // Advisory lock for heartbeat
803
+ acquireLock(lockId = 'heartbeat_lock') {
804
+ const result = this.db.prepare('UPDATE config SET value = ? WHERE key = ? AND value IS NULL')
805
+ .run(new Date().toISOString(), lockId);
806
+ return result.changes > 0;
807
+ }
808
+ releaseLock(lockId = 'heartbeat_lock') {
809
+ this.db.prepare('UPDATE config SET value = NULL WHERE key = ?').run(lockId);
810
+ }
811
+ // Check if task can be retried (< 3 attempts, 1hr cooldown)
812
+ // Returns true if retry is allowed AND atomically marks the task for retry
813
+ canRetry(id) {
814
+ if (!validateTaskId(id))
815
+ return false;
816
+ // Atomic check and update - prevents race condition
817
+ const result = this.db.prepare(`
818
+ UPDATE tasks
819
+ SET status = 'in_progress'
820
+ WHERE id = ?
821
+ AND status = 'todo'
822
+ AND attempts < 3
823
+ AND (last_attempt_at IS NULL OR last_attempt_at < datetime('now', '-1 hour'))
824
+ `).run(id);
825
+ return result.changes > 0;
826
+ }
827
+ // Count proposed tasks (for limits)
828
+ countProposed() {
829
+ const row = this.db.prepare('SELECT COUNT(*) as count FROM tasks WHERE status = ? AND added_by = ?')
830
+ .get('proposed', 'agent');
831
+ return row ? row.count : 0;
832
+ }
833
+ // Count tasks completed in last N hours
834
+ countCompletedInLast(hours) {
835
+ const since = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
836
+ const row = this.db.prepare('SELECT COUNT(*) as count FROM task_history WHERE action = ? AND timestamp > ?')
837
+ .get('completed', since);
838
+ return row ? row.count : 0;
839
+ }
840
+ // Get stats
841
+ getStats() {
842
+ const total = this.db.prepare('SELECT COUNT(*) as count FROM tasks').get();
843
+ const byStatus = this.db.prepare('SELECT status, COUNT(*) as count FROM tasks GROUP BY status').all();
844
+ const byAutonomy = this.db.prepare('SELECT autonomy, COUNT(*) as count FROM tasks WHERE status IN (?, ?) GROUP BY autonomy')
845
+ .all('todo', 'in_progress');
846
+ return {
847
+ total: total.count,
848
+ byStatus: Object.fromEntries(byStatus.map(r => [r.status, r.count])),
849
+ byAutonomy: Object.fromEntries(byAutonomy.map(r => [r.autonomy, r.count])),
850
+ };
851
+ }
852
+ // Helper to convert DB row to Task object
853
+ rowToTask(row) {
854
+ return {
855
+ id: row.id,
856
+ text: row.text,
857
+ status: row.status,
858
+ autonomy: row.autonomy,
859
+ urgency: row.urgency,
860
+ project: row.project,
861
+ context: row.context,
862
+ dueDate: row.due_date,
863
+ blockedBy: row.blocked_by,
864
+ addedBy: row.added_by,
865
+ createdAt: row.created_at,
866
+ startedAt: row.started_at,
867
+ completedAt: row.completed_at,
868
+ notes: row.notes,
869
+ attempts: row.attempts,
870
+ lastAttemptAt: row.last_attempt_at,
871
+ tokensUsed: row.tokens_used,
872
+ durationSec: row.duration_sec,
873
+ };
874
+ }
875
+ close() {
876
+ // Flush any pending audit entries synchronously before closing
877
+ // (CLI exits immediately, so we need sync flush here)
878
+ if (this.flushTimer) {
879
+ clearTimeout(this.flushTimer);
880
+ this.flushTimer = null;
881
+ }
882
+ if (this.auditQueue.length > 0) {
883
+ const batch = this.auditQueue.splice(0);
884
+ const batchText = batch.join('');
885
+ try {
886
+ appendFileSync(this.auditPath, batchText, { flag: 'a' });
887
+ }
888
+ catch (error) {
889
+ // Fallback to DB on error
890
+ for (const line of batch) {
891
+ try {
892
+ const entry = JSON.parse(line);
893
+ this.db.prepare(`
894
+ INSERT INTO _failed_audits (timestamp, action, actor, task_id, details, error)
895
+ VALUES (?, ?, ?, ?, ?, ?)
896
+ `).run(entry.timestamp, entry.action, entry.actor, entry.taskId, JSON.stringify(entry), String(error));
897
+ }
898
+ catch {
899
+ // Silent fail on close
900
+ }
901
+ }
902
+ }
903
+ }
904
+ this.db.close();
905
+ }
906
+ }