atris 3.14.0 → 3.15.11

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/commands/task.js CHANGED
@@ -1,9 +1,10 @@
1
- // `atris task` SQLite-backed task plane. TODO.md stays the human-readable
2
- // board; this gives agents atomic claims and a compact sync row.
1
+ // `atris task` - SQLite-backed task state. TODO.md is a regenerated view;
2
+ // events are the durable trail that web/desktop/cloud projections can read.
3
3
 
4
4
  'use strict';
5
5
 
6
6
  const fs = require('fs');
7
+ const http = require('http');
7
8
  const path = require('path');
8
9
  const os = require('os');
9
10
 
@@ -36,19 +37,40 @@ function getTaskDb() {
36
37
 
37
38
  function help() {
38
39
  console.log(`
39
- atris task local agent task plane (SQLite, gitignored)
40
+ atris task - durable local task state (SQLite, gitignored)
41
+
42
+ atris task Show the task desk
43
+ atris task new "<title>" Create a task
44
+ atris task next Claim/show the next open task
45
+ atris task say <id> "<message>" Add context to a task
46
+ atris task finish <id> [--proof "..."] Complete, optionally review
40
47
 
41
48
  atris task add "<title>" [--tag <tag>] Create a task
49
+ atris task delegate "<title>" --to <id> Create an assigned task
50
+ atris task day [--json] Show today's owner-grouped task list
42
51
  atris task list [--all] [--status <s>] List tasks (default: this workspace)
43
52
  atris task claim <id> [--as <owner>] Atomic claim
53
+ atris task note <id> "<message>" Append dialogue/context to a task
54
+ atris task show <id> [--json] Show a task card + dialogue
44
55
  atris task done <id> [--failed] Mark complete (or failed)
56
+ atris task review <id> --reward <n> Write review event + RSI episode
57
+ atris task status [--json] Compact live status for web/Swarlo
58
+ atris task setup [--import-todo] Create/refresh task projection
59
+ atris task serve [--port <n>] Open local task factory board
60
+ atris task sync --dry-run Plan cloud/Swarlo task sync writes
45
61
  atris task import <file> One-shot import from TODO.md
62
+ atris task events [id] Print append-only task events
63
+ atris task export [--out <file>] Write web/desktop JSON projection
64
+ atris task render [--out <file>] Regenerate TODO.md view from state
46
65
  atris task where Print db path + workspace scope
47
66
  atris task help This help
48
67
 
49
68
  Env:
50
69
  ATRIS_TASKS_DB Override db path (default ~/.atris/tasks.db)
51
70
  ATRIS_AGENT_ID Owner id for claim/done (default: $USER)
71
+
72
+ Headless:
73
+ Add --json to task commands for machine-readable output and stable automation.
52
74
  `.trim());
53
75
  }
54
76
 
@@ -62,6 +84,32 @@ function hasFlag(args, name) {
62
84
  return args.indexOf(name) !== -1;
63
85
  }
64
86
 
87
+ function wantsJson(args) {
88
+ return hasFlag(args, '--json');
89
+ }
90
+
91
+ function printJson(value) {
92
+ console.log(JSON.stringify(value, null, 2));
93
+ }
94
+
95
+ function jsonModeActive() {
96
+ return process.argv.includes('--json');
97
+ }
98
+
99
+ function failTask(label, reason, detail, exitCode = 2) {
100
+ if (jsonModeActive()) {
101
+ console.error(JSON.stringify({
102
+ ok: false,
103
+ command: label,
104
+ reason,
105
+ detail: detail || null,
106
+ }));
107
+ } else {
108
+ console.error(detail || `${label}: ${reason}`);
109
+ }
110
+ process.exit(exitCode);
111
+ }
112
+
65
113
  function positional(args) {
66
114
  return args.filter((a, i) => {
67
115
  if (a.startsWith('--')) return false;
@@ -70,6 +118,486 @@ function positional(args) {
70
118
  });
71
119
  }
72
120
 
121
+ function writeDefaultProjection(taskDb, db, { all = false } = {}) {
122
+ const projection = enrichTaskProjection(taskDb.taskProjection(db, {
123
+ workspaceRoot: all ? null : taskDb.workspaceRoot(),
124
+ limit: 500,
125
+ }));
126
+ const outPath = path.resolve(path.join('.atris', 'state', 'tasks.projection.json'));
127
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
128
+ fs.writeFileSync(outPath, JSON.stringify(projection, null, 2) + '\n', 'utf8');
129
+ return { projection, outPath };
130
+ }
131
+
132
+ function taskFromProjection(projection, id) {
133
+ return projection.tasks.find(t => t.id === id) || null;
134
+ }
135
+
136
+ function createNextTaskIfRequested(taskDb, db, args, currentTask, title) {
137
+ const nextTitle = String(title || '').trim();
138
+ if (!hasFlag(args, '--create-next') || !nextTitle) return null;
139
+ const result = taskDb.addTask(db, {
140
+ title: nextTitle,
141
+ tag: currentTask && currentTask.tag || null,
142
+ workspaceRoot: taskDb.workspaceRoot(),
143
+ metadata: {
144
+ parent_task_id: currentTask && currentTask.id || null,
145
+ source: 'task_review_next',
146
+ },
147
+ });
148
+ return result;
149
+ }
150
+
151
+ function readLocalBusinessBinding(root = process.cwd()) {
152
+ const file = path.join(root, '.atris', 'business.json');
153
+ if (!fs.existsSync(file)) return null;
154
+ try {
155
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
156
+ } catch (e) {
157
+ throw new Error(`Failed to read .atris/business.json: ${e.message || e}`);
158
+ }
159
+ }
160
+
161
+ function extractGoalLines(text) {
162
+ const goals = [];
163
+ let inFrontmatter = false;
164
+ let seenContent = false;
165
+ for (const raw of String(text || '').split(/\r?\n/)) {
166
+ const line = raw.trim();
167
+ if (line === '---' && !seenContent) {
168
+ inFrontmatter = !inFrontmatter;
169
+ continue;
170
+ }
171
+ if (inFrontmatter) continue;
172
+ if (!line) continue;
173
+ seenContent = true;
174
+ if (line.startsWith('#') || line.startsWith('---') || /^\|[-\s|]+\|$/.test(line)) continue;
175
+ if (/^\|/.test(line)) {
176
+ const cells = line.split('|').map(c => c.trim()).filter(Boolean);
177
+ if (cells[0] && !/^goal$/i.test(cells[0])) goals.push(cells.slice(0, 3).join(' / '));
178
+ continue;
179
+ }
180
+ goals.push(line.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, ''));
181
+ }
182
+ return goals.filter(Boolean).slice(0, 8);
183
+ }
184
+
185
+ function readGoalSources(root = process.cwd()) {
186
+ const candidates = [
187
+ path.join(root, 'atris', 'goals.md'),
188
+ path.join(root, 'goals.md'),
189
+ path.join(root, 'atris', 'wiki', 'concepts', 'atris-labs-goals.md'),
190
+ ];
191
+ for (const file of candidates) {
192
+ if (!fs.existsSync(file)) continue;
193
+ const goals = extractGoalLines(fs.readFileSync(file, 'utf8'));
194
+ if (goals.length) return { path: file, goals };
195
+ }
196
+ return { path: null, goals: [] };
197
+ }
198
+
199
+ function taskReviewSummary(task) {
200
+ const reviewed = (task.events || []).slice().reverse().find(e => e.event_type === 'reviewed');
201
+ const payload = reviewed && reviewed.payload || {};
202
+ return {
203
+ reward: payload.reward === undefined ? null : payload.reward,
204
+ proof: payload.proof || null,
205
+ lesson: payload.lesson || null,
206
+ next_task: payload.next_task || null,
207
+ };
208
+ }
209
+
210
+ function taskAssignee(task) {
211
+ const metadata = task && task.metadata || {};
212
+ return metadata.assigned_to || task.claimed_by || null;
213
+ }
214
+
215
+ function scoreGoalMatch(task, goal) {
216
+ const haystack = `${task.title} ${task.tag || ''}`.toLowerCase();
217
+ const words = String(goal || '').toLowerCase().match(/[a-z0-9]{4,}/g) || [];
218
+ return words.reduce((score, word) => score + (haystack.includes(word) ? 1 : 0), 0);
219
+ }
220
+
221
+ function pickTaskGoal(task, goals) {
222
+ if (!goals.length) return null;
223
+ let best = goals[0];
224
+ let bestScore = -1;
225
+ for (const goal of goals) {
226
+ const score = scoreGoalMatch(task, goal);
227
+ if (score > bestScore) {
228
+ best = goal;
229
+ bestScore = score;
230
+ }
231
+ }
232
+ return best;
233
+ }
234
+
235
+ function buildTaskStreams(tasks, goals) {
236
+ const buckets = new Map();
237
+ for (const task of tasks) {
238
+ const objective = task.objective || 'Unmapped work';
239
+ if (!buckets.has(objective)) {
240
+ buckets.set(objective, {
241
+ objective,
242
+ active_count: 0,
243
+ done_count: 0,
244
+ open_count: 0,
245
+ doing_count: 0,
246
+ blocked_count: 0,
247
+ review_count: 0,
248
+ tasks: [],
249
+ });
250
+ }
251
+ const stream = buckets.get(objective);
252
+ const column = taskColumn(task);
253
+ if (task.status === 'done') stream.done_count += 1; else stream.active_count += 1;
254
+ if (column === 'open') stream.open_count += 1;
255
+ if (column === 'doing') stream.doing_count += 1;
256
+ if (column === 'blocked') stream.blocked_count += 1;
257
+ if (column === 'review') stream.review_count += 1;
258
+ stream.tasks.push({
259
+ id: task.id,
260
+ title: task.title,
261
+ status: task.status,
262
+ tag: task.tag,
263
+ claimed_by: task.claimed_by,
264
+ assigned_to: taskAssignee(task),
265
+ parent_task_id: task.lineage && task.lineage.parent_task_id || null,
266
+ child_task_ids: task.lineage && task.lineage.child_task_ids || [],
267
+ proof: task.review && task.review.proof || null,
268
+ });
269
+ }
270
+ for (const goal of goals) {
271
+ if (!buckets.has(goal)) {
272
+ buckets.set(goal, {
273
+ objective: goal,
274
+ active_count: 0,
275
+ done_count: 0,
276
+ open_count: 0,
277
+ doing_count: 0,
278
+ blocked_count: 0,
279
+ review_count: 0,
280
+ tasks: [],
281
+ });
282
+ }
283
+ }
284
+ return Array.from(buckets.values())
285
+ .sort((a, b) => (b.active_count - a.active_count) || (b.done_count - a.done_count) || a.objective.localeCompare(b.objective));
286
+ }
287
+
288
+ function enrichTaskProjection(projection) {
289
+ const root = projection.workspace_root || process.cwd();
290
+ const goalSource = readGoalSources(root);
291
+ const byId = new Map((projection.tasks || []).map(task => [task.id, task]));
292
+ const children = new Map();
293
+ for (const task of projection.tasks || []) {
294
+ const parentId = task.metadata && task.metadata.parent_task_id;
295
+ if (!parentId) continue;
296
+ if (!children.has(parentId)) children.set(parentId, []);
297
+ children.get(parentId).push(task);
298
+ }
299
+ const enrichedTasks = (projection.tasks || []).map(task => {
300
+ const parentId = task.metadata && task.metadata.parent_task_id || null;
301
+ const parent = parentId ? byId.get(parentId) : null;
302
+ const childTasks = children.get(task.id) || [];
303
+ const review = taskReviewSummary(task);
304
+ return {
305
+ ...task,
306
+ objective: pickTaskGoal(task, goalSource.goals),
307
+ review,
308
+ lineage: {
309
+ parent_task_id: parentId,
310
+ parent_title: parent ? parent.title : null,
311
+ child_task_ids: childTasks.map(child => child.id),
312
+ child_titles: childTasks.map(child => child.title),
313
+ next_task_suggestion: review.next_task,
314
+ },
315
+ };
316
+ });
317
+ return {
318
+ ...projection,
319
+ goals: {
320
+ source_path: goalSource.path,
321
+ items: goalSource.goals,
322
+ },
323
+ streams: buildTaskStreams(enrichedTasks, goalSource.goals),
324
+ tasks: enrichedTasks,
325
+ };
326
+ }
327
+
328
+ function taskTypeForCloud(task) {
329
+ const tag = String(task.tag || '').toLowerCase();
330
+ if (['inbound', 'outbound', 'creative', 'improvement'].includes(tag)) return tag;
331
+ if (['design', 'writing', 'image', 'video', 'launch'].includes(tag)) return 'creative';
332
+ if (['sales', 'gtm', 'customer', 'email'].includes(tag)) return 'outbound';
333
+ return 'improvement';
334
+ }
335
+
336
+ function taskStateForCloud(task) {
337
+ if (task.status === 'claimed') return 'doing';
338
+ if (task.status === 'failed') return 'blocked';
339
+ if (task.status === 'done') return 'done';
340
+ return 'open';
341
+ }
342
+
343
+ function ownerMemberIdForCloud(task) {
344
+ const ownerValue = task.claimed_by || taskAssignee(task);
345
+ if (!ownerValue) return null;
346
+ const owner = String(ownerValue).trim();
347
+ if (!owner) return null;
348
+ if (owner.includes(':')) return owner;
349
+ return `agent:${owner}`;
350
+ }
351
+
352
+ function taskDescriptionForCloud(task) {
353
+ const lines = [
354
+ `Local task: ${task.id}`,
355
+ `Status: ${task.status}`,
356
+ `Latest event: ${task.latest_event_type || 'none'}`,
357
+ ];
358
+ if (task.messages && task.messages.length) {
359
+ lines.push('', 'Thread:');
360
+ for (const message of task.messages.slice(-5)) {
361
+ lines.push(`- ${message.actor || 'unknown'}: ${message.content}`);
362
+ }
363
+ }
364
+ const reviewed = (task.events || []).slice().reverse().find(e => e.event_type === 'reviewed');
365
+ if (reviewed && reviewed.payload) {
366
+ if (reviewed.payload.proof) lines.push('', `Proof: ${reviewed.payload.proof}`);
367
+ if (reviewed.payload.lesson) lines.push(`Lesson: ${reviewed.payload.lesson}`);
368
+ if (reviewed.payload.next_task) lines.push(`Next: ${reviewed.payload.next_task}`);
369
+ }
370
+ return lines.join('\n').slice(0, 5000);
371
+ }
372
+
373
+ function cloudPayloadForTask(task, businessId) {
374
+ const metadata = task.metadata || {};
375
+ const claimedAtEvent = (task.events || []).find(e => e.event_type === 'claimed');
376
+ return {
377
+ type: taskTypeForCloud(task),
378
+ title: String(task.title || '').slice(0, 200),
379
+ description: taskDescriptionForCloud(task),
380
+ owner_member_id: ownerMemberIdForCloud(task),
381
+ needs_approval: false,
382
+ metadata: {
383
+ ...metadata,
384
+ source: 'atris_cli_task',
385
+ business_id: businessId,
386
+ local_task_id: task.id,
387
+ local_status: task.status,
388
+ local_tag: task.tag || null,
389
+ current_version: task.current_version,
390
+ latest_event_type: task.latest_event_type,
391
+ workspace_root: task.workspace_root,
392
+ swarlo: {
393
+ lease_owner: task.claimed_by || null,
394
+ lease_state: task.status === 'claimed' ? 'held' : 'none',
395
+ lease_started_at: claimedAtEvent ? new Date(claimedAtEvent.created_at).toISOString() : null,
396
+ },
397
+ },
398
+ };
399
+ }
400
+
401
+ function syncPlanForProjection(projection, businessId) {
402
+ const endpoint = `/business/${businessId}/work/tasks`;
403
+ const plan = [];
404
+ for (const task of projection.tasks) {
405
+ const payload = cloudPayloadForTask(task, businessId);
406
+ const cloudTaskId = task.metadata && (task.metadata.cloud_task_id || task.metadata.supabase_task_id);
407
+ if (cloudTaskId) {
408
+ plan.push({
409
+ action: 'patch',
410
+ method: 'PATCH',
411
+ endpoint: `${endpoint}/${cloudTaskId}`,
412
+ local_task_id: task.id,
413
+ cloud_task_id: cloudTaskId,
414
+ body: {
415
+ ...payload,
416
+ state: taskStateForCloud(task),
417
+ },
418
+ });
419
+ } else {
420
+ plan.push({
421
+ action: 'post',
422
+ method: 'POST',
423
+ endpoint,
424
+ local_task_id: task.id,
425
+ body: payload,
426
+ after_create: taskStateForCloud(task) === 'open' ? [] : [{
427
+ method: 'PATCH',
428
+ endpoint: `${endpoint}/{created_task_id}`,
429
+ body: { state: taskStateForCloud(task) },
430
+ }],
431
+ });
432
+ }
433
+ }
434
+ return plan;
435
+ }
436
+
437
+ function latestTaskEvent(task) {
438
+ const events = task.events || [];
439
+ return events.length ? events[events.length - 1] : null;
440
+ }
441
+
442
+ function taskStatusSummary(projection) {
443
+ const tasks = projection.tasks || [];
444
+ const columns = {
445
+ plan: tasks.filter(task => taskColumn(task) === 'open'),
446
+ do: tasks.filter(task => taskColumn(task) === 'doing'),
447
+ review: tasks.filter(task => taskColumn(task) === 'review' || taskColumn(task) === 'blocked'),
448
+ done: tasks.filter(task => taskColumn(task) === 'done'),
449
+ };
450
+ const active = [...columns.do, ...columns.review, ...columns.plan];
451
+ const lastUpdated = tasks.reduce((max, task) => Math.max(max, Number(task.updated_at || 0)), 0);
452
+ const swarloFeed = tasks
453
+ .flatMap(task => (task.events || []).map(event => ({
454
+ task_id: task.id,
455
+ task_title: task.title,
456
+ actor: event.actor || task.claimed_by || null,
457
+ kind: event.event_type === 'claimed'
458
+ ? 'claim'
459
+ : event.event_type === 'completed' || event.event_type === 'reviewed'
460
+ ? 'result'
461
+ : 'note',
462
+ channel: task.tag || 'tasks',
463
+ content: event.payload && (event.payload.content || event.payload.proof || event.payload.lesson)
464
+ || humanEventType(event.event_type),
465
+ created_at: event.created_at,
466
+ metadata: {
467
+ swarlo: {
468
+ task_key: task.id,
469
+ kind: event.event_type === 'claimed' ? 'claim' : event.event_type === 'completed' || event.event_type === 'reviewed' ? 'result' : 'note',
470
+ status: taskStateForCloud(task),
471
+ },
472
+ },
473
+ })))
474
+ .sort((a, b) => b.created_at - a.created_at)
475
+ .slice(0, 12);
476
+ return {
477
+ schema: 'atris.task_status.v1',
478
+ generated_at: projection.generated_at,
479
+ workspace_root: projection.workspace_root,
480
+ goals: projection.goals || { source_path: null, items: [] },
481
+ counts: {
482
+ total: tasks.length,
483
+ active: tasks.filter(task => task.status !== 'done').length,
484
+ plan: columns.plan.length,
485
+ do: columns.do.length,
486
+ review: columns.review.length,
487
+ done: columns.done.length,
488
+ },
489
+ current: columns.do[0] || columns.review[0] || null,
490
+ next: columns.plan[0] || null,
491
+ needs_review: columns.review.slice(0, 5),
492
+ streams: (projection.streams || []).slice(0, 8).map(stream => ({
493
+ objective: stream.objective,
494
+ active_count: stream.active_count,
495
+ done_count: stream.done_count,
496
+ open_count: stream.open_count,
497
+ doing_count: stream.doing_count,
498
+ review_count: stream.review_count,
499
+ blocked_count: stream.blocked_count,
500
+ })),
501
+ last_event: active.map(task => ({ task, event: latestTaskEvent(task) })).filter(row => row.event)
502
+ .sort((a, b) => b.event.created_at - a.event.created_at)[0] || null,
503
+ last_updated_at: lastUpdated ? new Date(lastUpdated).toISOString() : null,
504
+ swarlo: {
505
+ feed: swarloFeed,
506
+ realtime_contract: {
507
+ claim: 'Swarlo claim -> canonical task state=doing + lease metadata',
508
+ report_done: 'Swarlo report(done) -> canonical task state=done + proof metadata',
509
+ web: 'atrisos-web reads canonical tasks through /api/agent/:id/tasks or /api/business/* and live activity through public business/Swarlo posts',
510
+ },
511
+ },
512
+ };
513
+ }
514
+
515
+ function humanEventType(type) {
516
+ return String(type || 'event').replace(/_/g, ' ');
517
+ }
518
+
519
+ function formatTaskLine(task) {
520
+ if (!task) return 'none';
521
+ const owner = task.claimed_by ? ` @${task.claimed_by}` : '';
522
+ const assigned = !task.claimed_by && taskAssignee(task) ? ` -> ${taskAssignee(task)}` : '';
523
+ const tag = task.tag ? ` #${task.tag}` : '';
524
+ return `${task.id.slice(0, 8)}${owner}${assigned}${tag} ${task.title}`;
525
+ }
526
+
527
+ function cmdStatus(args) {
528
+ const all = hasFlag(args, '--all');
529
+ const taskDb = getTaskDb();
530
+ const db = taskDb.open();
531
+ const { projection, outPath } = writeDefaultProjection(taskDb, db, { all });
532
+ const status = taskStatusSummary(projection);
533
+ if (wantsJson(args)) {
534
+ printJson({
535
+ ok: true,
536
+ action: 'status',
537
+ projection_path: outPath,
538
+ status,
539
+ });
540
+ return;
541
+ }
542
+ console.log('TASK STATUS');
543
+ console.log(`workspace ${status.workspace_root || '(all)'}`);
544
+ console.log(`plan ${status.counts.plan} / do ${status.counts.do} / review ${status.counts.review} / done ${status.counts.done}`);
545
+ console.log(`current ${formatTaskLine(status.current)}`);
546
+ console.log(`next ${formatTaskLine(status.next)}`);
547
+ if (status.needs_review.length) {
548
+ console.log('review');
549
+ for (const task of status.needs_review.slice(0, 3)) console.log(` ${formatTaskLine(task)}`);
550
+ }
551
+ console.log(`swarlo feed ${status.swarlo.feed.length} event${status.swarlo.feed.length === 1 ? '' : 's'}`);
552
+ }
553
+
554
+ function resolveTaskRef(taskDb, db, ref) {
555
+ const token = String(ref || '').trim();
556
+ if (!token) return { ok: false, reason: 'missing' };
557
+ const exact = taskDb.getTask(db, token);
558
+ if (exact) return { ok: true, id: exact.id, row: exact };
559
+ const rows = taskDb.listTasks(db, { workspaceRoot: taskDb.workspaceRoot(), limit: 500 });
560
+ const matches = rows.filter(r => r.id.startsWith(token));
561
+ if (matches.length === 1) return { ok: true, id: matches[0].id, row: matches[0] };
562
+ if (matches.length > 1) return { ok: false, reason: 'ambiguous', matches };
563
+ return { ok: false, reason: 'not_found' };
564
+ }
565
+
566
+ function requireTaskId(taskDb, db, ref, label) {
567
+ const resolved = resolveTaskRef(taskDb, db, ref);
568
+ if (resolved.ok) return resolved.id;
569
+ if (resolved.reason === 'ambiguous') {
570
+ failTask(label, 'ambiguous', `ambiguous task id prefix "${ref}"`);
571
+ } else if (resolved.reason === 'missing') {
572
+ failTask(label, 'missing_id', 'task id required');
573
+ } else {
574
+ failTask(label, 'not_found', `task not found: ${ref}`);
575
+ }
576
+ }
577
+
578
+ function renderTaskDesk(rows) {
579
+ const active = rows.filter(r => r.status !== 'done');
580
+ const done = rows.filter(r => r.status === 'done');
581
+ if (rows.length === 0) {
582
+ console.log('No tasks yet.');
583
+ console.log('Start with: atris task new "Ship the smallest useful thing"');
584
+ return;
585
+ }
586
+ console.log('TASK DESK');
587
+ console.log('');
588
+ for (const r of active.slice(0, 12)) {
589
+ const owner = r.claimed_by ? ` @${r.claimed_by}` : '';
590
+ const assigned = !r.claimed_by && taskAssignee(r) ? ` -> ${taskAssignee(r)}` : '';
591
+ const tag = r.tag ? ` #${r.tag}` : '';
592
+ console.log(`${r.status.padEnd(7)} ${r.id.slice(0, 8)}${owner}${assigned}${tag}`);
593
+ console.log(` ${r.title}`);
594
+ }
595
+ if (active.length === 0) console.log('clear no active tasks');
596
+ console.log('');
597
+ console.log(`active ${active.length} / done ${done.length}`);
598
+ console.log('next: atris task next');
599
+ }
600
+
73
601
  function cmdAdd(args) {
74
602
  const pos = positional(args);
75
603
  const title = pos.join(' ').trim();
@@ -86,9 +614,186 @@ function cmdAdd(args) {
86
614
  tag: typeof tag === 'string' ? tag : null,
87
615
  workspaceRoot: ws,
88
616
  });
617
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
618
+ if (wantsJson(args)) {
619
+ printJson({
620
+ ok: true,
621
+ action: 'created',
622
+ task_id: result.id,
623
+ inserted: result.inserted !== false,
624
+ projection_path: outPath,
625
+ task: taskFromProjection(projection, result.id),
626
+ });
627
+ return;
628
+ }
89
629
  console.log(`${result.id}\t${title}`);
90
630
  }
91
631
 
632
+ function delegateHandoff(taskId, owner, via, tag) {
633
+ const shortId = taskId.slice(0, 8);
634
+ const handoff = {
635
+ command: `atris task claim ${shortId} --as ${owner}`,
636
+ };
637
+ if (via === 'swarlo') {
638
+ handoff.swarlo = {
639
+ task_key: taskId,
640
+ action: 'claim',
641
+ channel: tag || 'tasks',
642
+ assignee: owner,
643
+ };
644
+ }
645
+ return handoff;
646
+ }
647
+
648
+ function cmdDelegate(args) {
649
+ const pos = positional(args);
650
+ const title = pos.join(' ').trim();
651
+ const owner = flag(args, '--to') || flag(args, '--as');
652
+ if (!title) {
653
+ console.error('atris task delegate: title required');
654
+ process.exit(2);
655
+ }
656
+ if (!owner || owner === true) {
657
+ console.error('atris task delegate: --to <owner> required');
658
+ process.exit(2);
659
+ }
660
+ const viaFlag = flag(args, '--via');
661
+ const via = viaFlag === 'swarlo' ? 'swarlo' : 'local';
662
+ const tag = flag(args, '--tag');
663
+ const note = flag(args, '--note');
664
+ const claimNow = hasFlag(args, '--claim');
665
+ const taskDb = getTaskDb();
666
+ const db = taskDb.open();
667
+ const ws = taskDb.workspaceRoot();
668
+ const metadata = {
669
+ assigned_to: String(owner),
670
+ delegate_via: via,
671
+ swarlo_channel: via === 'swarlo' ? String(tag || 'tasks') : null,
672
+ created_for_day: new Date().toISOString().slice(0, 10),
673
+ };
674
+ const result = taskDb.addTask(db, {
675
+ title,
676
+ tag: typeof tag === 'string' ? tag : null,
677
+ workspaceRoot: ws,
678
+ status: claimNow ? 'claimed' : 'open',
679
+ claimedBy: claimNow ? String(owner) : null,
680
+ metadata,
681
+ });
682
+ if (typeof note === 'string' && note.trim()) {
683
+ taskDb.noteTask(db, { id: result.id, actor: DEFAULT_OWNER, content: note });
684
+ }
685
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
686
+ const task = taskFromProjection(projection, result.id);
687
+ const handoff = delegateHandoff(result.id, String(owner), via, typeof tag === 'string' ? tag : null);
688
+ if (wantsJson(args)) {
689
+ printJson({
690
+ ok: true,
691
+ action: 'delegated',
692
+ task_id: result.id,
693
+ inserted: result.inserted !== false,
694
+ owner: String(owner),
695
+ via,
696
+ handoff,
697
+ projection_path: outPath,
698
+ task,
699
+ });
700
+ return;
701
+ }
702
+ const tagText = tag && tag !== true ? ` #${tag}` : '';
703
+ console.log(`delegated ${result.id.slice(0, 8)} -> ${owner}${tagText} via=${via}`);
704
+ console.log(`claim: ${handoff.command}`);
705
+ if (handoff.swarlo) console.log(`swarlo: ${handoff.swarlo.channel}/${handoff.swarlo.action}`);
706
+ }
707
+
708
+ function taskDayGroups(tasks) {
709
+ const active = tasks.filter(task => task.status !== 'done');
710
+ const groups = new Map();
711
+ for (const task of active) {
712
+ const owner = taskAssignee(task) || 'unassigned';
713
+ if (!groups.has(owner)) groups.set(owner, []);
714
+ groups.get(owner).push(task);
715
+ }
716
+ return Array.from(groups.entries())
717
+ .sort((a, b) => {
718
+ if (a[0] === 'unassigned') return 1;
719
+ if (b[0] === 'unassigned') return -1;
720
+ return a[0].localeCompare(b[0]);
721
+ })
722
+ .map(([owner, ownerTasks]) => ({
723
+ owner,
724
+ tasks: ownerTasks.sort((a, b) => {
725
+ const statusOrder = { claimed: 0, open: 1, failed: 2, done: 3 };
726
+ return (statusOrder[a.status] - statusOrder[b.status]) || (b.updated_at - a.updated_at);
727
+ }),
728
+ }));
729
+ }
730
+
731
+ function cmdDay(args) {
732
+ const all = hasFlag(args, '--all');
733
+ const taskDb = getTaskDb();
734
+ const db = taskDb.open();
735
+ const { projection, outPath } = writeDefaultProjection(taskDb, db, { all });
736
+ const groups = taskDayGroups(projection.tasks || []);
737
+ const counts = {
738
+ active: groups.reduce((sum, group) => sum + group.tasks.length, 0),
739
+ owners: groups.length,
740
+ open: (projection.tasks || []).filter(task => task.status === 'open').length,
741
+ claimed: (projection.tasks || []).filter(task => task.status === 'claimed').length,
742
+ review: (projection.tasks || []).filter(task => task.status === 'failed' || (task.status === 'done' && task.review && task.review.reward === null)).length,
743
+ };
744
+ const date = new Date().toISOString().slice(0, 10);
745
+ if (wantsJson(args)) {
746
+ printJson({
747
+ ok: true,
748
+ action: 'day',
749
+ date,
750
+ projection_path: outPath,
751
+ counts,
752
+ groups,
753
+ });
754
+ return;
755
+ }
756
+ console.log('TASK DAY');
757
+ console.log(`${date} active ${counts.active} / owners ${counts.owners} / review ${counts.review}`);
758
+ console.log('');
759
+ if (!groups.length) {
760
+ console.log('clear no active tasks');
761
+ }
762
+ for (const group of groups) {
763
+ console.log(`${group.owner}`);
764
+ for (const task of group.tasks.slice(0, 8)) {
765
+ const tag = task.tag ? ` #${task.tag}` : '';
766
+ const claim = task.claimed_by ? ` @${task.claimed_by}` : '';
767
+ console.log(` ${task.status.padEnd(7)} ${task.id.slice(0, 8)}${claim}${tag} ${task.title}`);
768
+ }
769
+ }
770
+ console.log('');
771
+ console.log('add: atris task delegate "..." --to codex --tag tasks');
772
+ }
773
+
774
+ function cmdHome(args) {
775
+ const all = hasFlag(args, '--all');
776
+ const taskDb = getTaskDb();
777
+ const db = taskDb.open();
778
+ const rows = taskDb.listTasks(db, {
779
+ workspaceRoot: all ? null : taskDb.workspaceRoot(),
780
+ limit: 200,
781
+ });
782
+ const { projection, outPath } = writeDefaultProjection(taskDb, db, { all });
783
+ if (wantsJson(args)) {
784
+ printJson({
785
+ ok: true,
786
+ action: 'desk',
787
+ projection_path: outPath,
788
+ active_count: projection.tasks.filter(t => t.status !== 'done').length,
789
+ done_count: projection.tasks.filter(t => t.status === 'done').length,
790
+ projection,
791
+ });
792
+ return;
793
+ }
794
+ renderTaskDesk(rows);
795
+ }
796
+
92
797
  function cmdList(args) {
93
798
  const all = hasFlag(args, '--all');
94
799
  const status = flag(args, '--status');
@@ -99,6 +804,10 @@ function cmdList(args) {
99
804
  status: typeof status === 'string' ? status : null,
100
805
  limit: 200,
101
806
  });
807
+ if (wantsJson(args)) {
808
+ printJson({ ok: true, action: 'list', tasks: rows });
809
+ return;
810
+ }
102
811
  if (rows.length === 0) {
103
812
  console.log('(no tasks)');
104
813
  return;
@@ -120,15 +829,163 @@ function cmdClaim(args) {
120
829
  const owner = flag(args, '--as') || DEFAULT_OWNER;
121
830
  const taskDb = getTaskDb();
122
831
  const db = taskDb.open();
123
- const result = taskDb.claimTask(db, { id, claimedBy: String(owner) });
832
+ const taskId = requireTaskId(taskDb, db, id, 'atris task claim');
833
+ const result = taskDb.claimTask(db, { id: taskId, claimedBy: String(owner) });
124
834
  if (result.claimed) {
125
- console.log(`claimed ${id} as ${owner}`);
835
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
836
+ if (wantsJson(args)) {
837
+ printJson({
838
+ ok: true,
839
+ action: 'claimed',
840
+ task_id: taskId,
841
+ owner: String(owner),
842
+ projection_path: outPath,
843
+ task: taskFromProjection(projection, taskId),
844
+ });
845
+ return;
846
+ }
847
+ console.log(`claimed ${taskId} as ${owner}`);
126
848
  } else {
127
849
  console.error(`claim failed: ${result.reason}${result.claimed_by ? ` (held by ${result.claimed_by})` : ''}`);
128
850
  process.exit(1);
129
851
  }
130
852
  }
131
853
 
854
+ function cmdNext(args) {
855
+ const owner = flag(args, '--as') || DEFAULT_OWNER;
856
+ const taskDb = getTaskDb();
857
+ const db = taskDb.open();
858
+ const claimed = taskDb.listTasks(db, {
859
+ workspaceRoot: taskDb.workspaceRoot(),
860
+ status: 'claimed',
861
+ claimedBy: String(owner),
862
+ limit: 1,
863
+ });
864
+ if (claimed.length) {
865
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
866
+ if (wantsJson(args)) {
867
+ printJson({
868
+ ok: true,
869
+ action: 'current',
870
+ task_id: claimed[0].id,
871
+ owner: String(owner),
872
+ projection_path: outPath,
873
+ task: taskFromProjection(projection, claimed[0].id),
874
+ });
875
+ return;
876
+ }
877
+ console.log(`current ${claimed[0].id.slice(0, 8)} @${owner}`);
878
+ console.log(claimed[0].title);
879
+ return;
880
+ }
881
+ const open = taskDb.listTasks(db, {
882
+ workspaceRoot: taskDb.workspaceRoot(),
883
+ status: 'open',
884
+ limit: 1,
885
+ });
886
+ if (!open.length) {
887
+ const { outPath } = writeDefaultProjection(taskDb, db);
888
+ if (wantsJson(args)) {
889
+ printJson({
890
+ ok: true,
891
+ action: 'none',
892
+ task_id: null,
893
+ owner: String(owner),
894
+ projection_path: outPath,
895
+ });
896
+ return;
897
+ }
898
+ console.log('No open tasks.');
899
+ console.log('Start with: atris task new "..."');
900
+ return;
901
+ }
902
+ const result = taskDb.claimTask(db, { id: open[0].id, claimedBy: String(owner) });
903
+ if (!result.claimed) {
904
+ console.error(`next failed: ${result.reason}`);
905
+ process.exit(1);
906
+ }
907
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
908
+ if (wantsJson(args)) {
909
+ printJson({
910
+ ok: true,
911
+ action: 'next',
912
+ task_id: open[0].id,
913
+ owner: String(owner),
914
+ projection_path: outPath,
915
+ task: taskFromProjection(projection, open[0].id),
916
+ });
917
+ return;
918
+ }
919
+ console.log(`next ${open[0].id.slice(0, 8)} @${owner}`);
920
+ console.log(open[0].title);
921
+ }
922
+
923
+ function cmdNote(args) {
924
+ const pos = positional(args);
925
+ const id = pos[0];
926
+ const content = pos.slice(1).join(' ').trim();
927
+ if (!id || !content) {
928
+ console.error('atris task note: id and message required');
929
+ process.exit(2);
930
+ }
931
+ const actor = flag(args, '--as') || DEFAULT_OWNER;
932
+ const taskDb = getTaskDb();
933
+ const db = taskDb.open();
934
+ const taskId = requireTaskId(taskDb, db, id, 'atris task note');
935
+ const result = taskDb.noteTask(db, { id: taskId, actor: String(actor), content });
936
+ if (!result.noted) {
937
+ console.error(`note failed: ${result.reason}`);
938
+ process.exit(1);
939
+ }
940
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
941
+ if (wantsJson(args)) {
942
+ printJson({
943
+ ok: true,
944
+ action: 'noted',
945
+ task_id: taskId,
946
+ version: result.event.version,
947
+ projection_path: outPath,
948
+ task: taskFromProjection(projection, taskId),
949
+ });
950
+ return;
951
+ }
952
+ console.log(`noted ${taskId} v${result.event.version}`);
953
+ }
954
+
955
+ function cmdShow(args) {
956
+ const pos = positional(args);
957
+ const id = pos[0];
958
+ if (!id) {
959
+ console.error('atris task show: id required');
960
+ process.exit(2);
961
+ }
962
+ const taskDb = getTaskDb();
963
+ const db = taskDb.open();
964
+ const taskId = requireTaskId(taskDb, db, id, 'atris task show');
965
+ const projection = taskDb.taskProjection(db, { taskId });
966
+ const task = projection.tasks[0];
967
+ if (!task) {
968
+ console.error(`task not found: ${id}`);
969
+ process.exit(1);
970
+ }
971
+ if (hasFlag(args, '--json')) {
972
+ console.log(JSON.stringify(task, null, 2));
973
+ return;
974
+ }
975
+ const owner = task.claimed_by ? ` / ${task.claimed_by}` : '';
976
+ const tag = task.tag ? ` #${task.tag}` : '';
977
+ console.log(`${task.status.toUpperCase()} ${task.id} v${task.current_version}${owner}${tag}`);
978
+ console.log(task.title);
979
+ if (task.messages.length) {
980
+ console.log('');
981
+ console.log('Dialogue:');
982
+ for (const m of task.messages) {
983
+ const who = m.actor || 'unknown';
984
+ console.log(`- v${m.version} ${who}: ${m.content}`);
985
+ }
986
+ }
987
+ }
988
+
132
989
  function cmdDone(args) {
133
990
  const pos = positional(args);
134
991
  const id = pos[0];
@@ -139,27 +996,147 @@ function cmdDone(args) {
139
996
  const failed = hasFlag(args, '--failed');
140
997
  const taskDb = getTaskDb();
141
998
  const db = taskDb.open();
142
- const result = taskDb.doneTask(db, { id, status: failed ? 'failed' : 'done' });
999
+ const taskId = requireTaskId(taskDb, db, id, 'atris task done');
1000
+ const result = taskDb.doneTask(db, { id: taskId, status: failed ? 'failed' : 'done' });
143
1001
  if (result.updated) {
144
- console.log(`${failed ? 'failed' : 'done'} ${id}`);
1002
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
1003
+ if (wantsJson(args)) {
1004
+ printJson({
1005
+ ok: true,
1006
+ action: failed ? 'failed' : 'done',
1007
+ task_id: taskId,
1008
+ projection_path: outPath,
1009
+ task: taskFromProjection(projection, taskId),
1010
+ });
1011
+ return;
1012
+ }
1013
+ console.log(`${failed ? 'failed' : 'done'} ${taskId}`);
145
1014
  } else {
146
- console.error(`done failed: ${id} not in open|claimed`);
1015
+ console.error(`done failed: ${taskId} not in open|claimed`);
147
1016
  process.exit(1);
148
1017
  }
149
1018
  }
150
1019
 
151
- function cmdImport(args) {
1020
+ function cmdFinish(args) {
152
1021
  const pos = positional(args);
153
- const target = pos[0] || 'atris/TODO.md';
1022
+ const id = pos[0];
1023
+ if (!id) {
1024
+ console.error('atris task finish: id required');
1025
+ process.exit(2);
1026
+ }
1027
+ const taskDb = getTaskDb();
1028
+ const db = taskDb.open();
1029
+ const taskId = requireTaskId(taskDb, db, id, 'atris task finish');
1030
+ const currentTask = taskDb.getTask(db, taskId);
1031
+ const done = taskDb.doneTask(db, { id: taskId, status: hasFlag(args, '--failed') ? 'failed' : 'done' });
1032
+ if (!done.updated) {
1033
+ console.error(`finish failed: ${taskId} not in open|claimed`);
1034
+ process.exit(1);
1035
+ }
1036
+ const hasReview = hasFlag(args, '--review') || flag(args, '--lesson') || flag(args, '--next') || flag(args, '--proof') || flag(args, '--reward');
1037
+ if (hasReview) {
1038
+ const result = taskDb.reviewTask(db, {
1039
+ id: taskId,
1040
+ actor: String(flag(args, '--as') || DEFAULT_OWNER),
1041
+ reward: flag(args, '--reward') || 1,
1042
+ lesson: typeof flag(args, '--lesson') === 'string' ? flag(args, '--lesson') : '',
1043
+ nextTask: typeof flag(args, '--next') === 'string' ? flag(args, '--next') : '',
1044
+ proof: typeof flag(args, '--proof') === 'string' ? flag(args, '--proof') : '',
1045
+ });
1046
+ const nextCreated = createNextTaskIfRequested(taskDb, db, args, currentTask, result.episode.next_task_suggestion);
1047
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
1048
+ if (wantsJson(args)) {
1049
+ printJson({
1050
+ ok: true,
1051
+ action: 'finished',
1052
+ task_id: taskId,
1053
+ reviewed: true,
1054
+ reward: result.episode.reward.value,
1055
+ episode: result.episode,
1056
+ next_task_id: nextCreated ? nextCreated.id : null,
1057
+ projection_path: outPath,
1058
+ projection,
1059
+ task: taskFromProjection(projection, taskId),
1060
+ });
1061
+ return;
1062
+ }
1063
+ console.log(`finished ${taskId} reward=${result.episode.reward.value}`);
1064
+ if (result.episode.next_task_suggestion) console.log(`next: ${result.episode.next_task_suggestion}`);
1065
+ if (nextCreated) console.log(`created next ${nextCreated.id}`);
1066
+ return;
1067
+ }
1068
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
1069
+ if (wantsJson(args)) {
1070
+ printJson({
1071
+ ok: true,
1072
+ action: 'finished',
1073
+ task_id: taskId,
1074
+ reviewed: false,
1075
+ projection_path: outPath,
1076
+ task: taskFromProjection(projection, taskId),
1077
+ });
1078
+ return;
1079
+ }
1080
+ console.log(`finished ${taskId}`);
1081
+ }
1082
+
1083
+ function cmdReview(args) {
1084
+ const pos = positional(args);
1085
+ const id = pos[0];
1086
+ if (!id) {
1087
+ console.error('atris task review: id required');
1088
+ process.exit(2);
1089
+ }
1090
+ const reward = flag(args, '--reward');
1091
+ const lesson = flag(args, '--lesson') || '';
1092
+ const nextTask = flag(args, '--next') || '';
1093
+ const proof = flag(args, '--proof') || '';
1094
+ const actor = flag(args, '--as') || DEFAULT_OWNER;
1095
+ const taskDb = getTaskDb();
1096
+ const db = taskDb.open();
1097
+ const taskId = requireTaskId(taskDb, db, id, 'atris task review');
1098
+ const currentTask = taskDb.getTask(db, taskId);
1099
+ const result = taskDb.reviewTask(db, {
1100
+ id: taskId,
1101
+ actor: String(actor),
1102
+ reward: reward === true || reward === null ? 0 : reward,
1103
+ lesson: typeof lesson === 'string' ? lesson : '',
1104
+ nextTask: typeof nextTask === 'string' ? nextTask : '',
1105
+ proof: typeof proof === 'string' ? proof : '',
1106
+ });
1107
+ if (!result.reviewed) {
1108
+ console.error(`review failed: ${result.reason}`);
1109
+ process.exit(1);
1110
+ }
1111
+ const nextCreated = createNextTaskIfRequested(taskDb, db, args, currentTask, result.episode.next_task_suggestion);
1112
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
1113
+ if (wantsJson(args)) {
1114
+ printJson({
1115
+ ok: true,
1116
+ action: 'reviewed',
1117
+ task_id: taskId,
1118
+ version: result.event.version,
1119
+ reward: result.episode.reward.value,
1120
+ episode: result.episode,
1121
+ next_task_id: nextCreated ? nextCreated.id : null,
1122
+ projection_path: outPath,
1123
+ projection,
1124
+ task: taskFromProjection(projection, taskId),
1125
+ });
1126
+ return;
1127
+ }
1128
+ console.log(`reviewed ${taskId} v${result.event.version} reward=${result.episode.reward.value}`);
1129
+ if (result.episode.next_task_suggestion) console.log(`next: ${result.episode.next_task_suggestion}`);
1130
+ if (nextCreated) console.log(`created next ${nextCreated.id}`);
1131
+ }
1132
+
1133
+ function importTodoFile(taskDb, db, target) {
154
1134
  const filePath = path.resolve(target);
155
1135
  if (!fs.existsSync(filePath)) {
156
- console.error(`atris task import: file not found: ${filePath}`);
157
- process.exit(2);
1136
+ return { ok: false, reason: 'not_found', filePath };
158
1137
  }
159
1138
  const { parseTodoFile } = require('../lib/todo-fallback');
160
1139
  const parsed = parseTodoFile(filePath);
161
- const taskDb = getTaskDb();
162
- const db = taskDb.open();
163
1140
  const ws = taskDb.workspaceRoot();
164
1141
  const all = [
165
1142
  ...parsed.backlog.map(t => ({ ...t, importStatus: 'open' })),
@@ -181,28 +1158,691 @@ function cmdImport(args) {
181
1158
  });
182
1159
  if (result.inserted) inserted++; else skipped++;
183
1160
  }
1161
+ return { ok: true, inserted, skipped, filePath };
1162
+ }
1163
+
1164
+ function cmdImport(args) {
1165
+ const pos = positional(args);
1166
+ const target = pos[0] || 'atris/TODO.md';
1167
+ const taskDb = getTaskDb();
1168
+ const db = taskDb.open();
1169
+ const result = importTodoFile(taskDb, db, target);
1170
+ if (!result.ok) {
1171
+ console.error(`atris task import: file not found: ${result.filePath}`);
1172
+ process.exit(2);
1173
+ }
1174
+ const { inserted, skipped, filePath } = result;
1175
+ const { outPath } = writeDefaultProjection(taskDb, db);
1176
+ if (wantsJson(args)) {
1177
+ printJson({
1178
+ ok: true,
1179
+ action: 'imported',
1180
+ inserted,
1181
+ skipped,
1182
+ source: filePath,
1183
+ projection_path: outPath,
1184
+ });
1185
+ return;
1186
+ }
184
1187
  console.log(`imported ${inserted} new, skipped ${skipped} (already imported), source=${filePath}`);
185
1188
  }
186
1189
 
187
- function cmdWhere() {
1190
+ function cmdWhere(args) {
188
1191
  const taskDb = getTaskDb();
1192
+ if (wantsJson(args)) {
1193
+ printJson({
1194
+ ok: true,
1195
+ db: taskDb.getDbPath(),
1196
+ workspace: taskDb.workspaceRoot(),
1197
+ owner: DEFAULT_OWNER,
1198
+ });
1199
+ return;
1200
+ }
189
1201
  console.log(`db: ${taskDb.getDbPath()}`);
190
1202
  console.log(`workspace: ${taskDb.workspaceRoot()}`);
191
1203
  console.log(`owner: ${DEFAULT_OWNER}`);
192
1204
  }
193
1205
 
1206
+ function cmdEvents(args) {
1207
+ const pos = positional(args);
1208
+ let taskId = pos[0] || null;
1209
+ const all = hasFlag(args, '--all');
1210
+ const taskDb = getTaskDb();
1211
+ const db = taskDb.open();
1212
+ if (taskId) taskId = requireTaskId(taskDb, db, taskId, 'atris task events');
1213
+ const events = taskDb.listTaskEvents(db, {
1214
+ taskId,
1215
+ workspaceRoot: all || taskId ? null : taskDb.workspaceRoot(),
1216
+ limit: 500,
1217
+ });
1218
+ if (wantsJson(args)) {
1219
+ printJson({ ok: true, action: 'events', events });
1220
+ return;
1221
+ }
1222
+ if (events.length === 0) {
1223
+ console.log('(no task events)');
1224
+ return;
1225
+ }
1226
+ for (const e of events) {
1227
+ const actor = e.actor ? ` actor=${e.actor}` : '';
1228
+ console.log(`${e.version}\t${e.event_type}\t${e.task_id}${actor}\t${JSON.stringify(e.payload || {})}`);
1229
+ }
1230
+ }
1231
+
1232
+ function cmdExport(args) {
1233
+ const out = flag(args, '--out') || path.join('.atris', 'state', 'tasks.projection.json');
1234
+ const all = hasFlag(args, '--all');
1235
+ const taskDb = getTaskDb();
1236
+ const db = taskDb.open();
1237
+ const outPath = path.resolve(String(out));
1238
+ const projection = enrichTaskProjection(taskDb.taskProjection(db, {
1239
+ workspaceRoot: all ? null : taskDb.workspaceRoot(),
1240
+ limit: 500,
1241
+ }));
1242
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
1243
+ fs.writeFileSync(outPath, JSON.stringify(projection, null, 2) + '\n', 'utf8');
1244
+ if (wantsJson(args)) {
1245
+ printJson({
1246
+ ok: true,
1247
+ action: 'exported',
1248
+ count: projection.tasks.length,
1249
+ projection_path: outPath,
1250
+ projection,
1251
+ });
1252
+ return;
1253
+ }
1254
+ console.log(`exported ${projection.tasks.length} task${projection.tasks.length === 1 ? '' : 's'} -> ${outPath}`);
1255
+ }
1256
+
1257
+ function cmdSetup(args) {
1258
+ const taskDb = getTaskDb();
1259
+ const db = taskDb.open();
1260
+ const ws = taskDb.workspaceRoot();
1261
+ let importResult = null;
1262
+ if (hasFlag(args, '--import-todo')) {
1263
+ importResult = importTodoFile(taskDb, db, flag(args, '--todo') || 'atris/TODO.md');
1264
+ if (!importResult.ok && flag(args, '--todo')) {
1265
+ console.error(`atris task setup: TODO file not found: ${importResult.filePath}`);
1266
+ process.exit(2);
1267
+ }
1268
+ }
1269
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
1270
+ if (wantsJson(args)) {
1271
+ printJson({
1272
+ ok: true,
1273
+ action: 'setup',
1274
+ count: projection.tasks.length,
1275
+ projection_path: outPath,
1276
+ import: importResult && importResult.ok ? {
1277
+ inserted: importResult.inserted,
1278
+ skipped: importResult.skipped,
1279
+ source: importResult.filePath,
1280
+ } : null,
1281
+ projection,
1282
+ });
1283
+ return;
1284
+ }
1285
+ console.log(`tasks ready: ${projection.tasks.length} task${projection.tasks.length === 1 ? '' : 's'}`);
1286
+ console.log(`projection: ${outPath}`);
1287
+ if (importResult && importResult.ok) {
1288
+ console.log(`imported ${importResult.inserted} new, skipped ${importResult.skipped}`);
1289
+ }
1290
+ }
1291
+
1292
+ function cmdRender(args) {
1293
+ const out = flag(args, '--out') || path.join('atris', 'TODO.md');
1294
+ const all = hasFlag(args, '--all');
1295
+ const taskDb = getTaskDb();
1296
+ const db = taskDb.open();
1297
+ const rows = taskDb.listTasks(db, {
1298
+ workspaceRoot: all ? null : taskDb.workspaceRoot(),
1299
+ limit: 500,
1300
+ });
1301
+ const markdown = taskDb.renderTodoMarkdown(rows);
1302
+ const outPath = path.resolve(String(out));
1303
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
1304
+ fs.writeFileSync(outPath, markdown, 'utf8');
1305
+ if (wantsJson(args)) {
1306
+ printJson({
1307
+ ok: true,
1308
+ action: 'rendered',
1309
+ count: rows.length,
1310
+ path: outPath,
1311
+ });
1312
+ return;
1313
+ }
1314
+ console.log(`rendered ${rows.length} task${rows.length === 1 ? '' : 's'} -> ${outPath}`);
1315
+ }
1316
+
1317
+ function cmdSync(args) {
1318
+ const dryRun = hasFlag(args, '--dry-run');
1319
+ const businessIdFlag = flag(args, '--business-id');
1320
+ if (!dryRun) {
1321
+ console.error('atris task sync: only --dry-run is supported right now');
1322
+ process.exit(2);
1323
+ }
1324
+
1325
+ const taskDb = getTaskDb();
1326
+ const db = taskDb.open();
1327
+ const binding = readLocalBusinessBinding(taskDb.workspaceRoot());
1328
+ const businessId = String(
1329
+ businessIdFlag && businessIdFlag !== true
1330
+ ? businessIdFlag
1331
+ : binding && (binding.business_id || binding.id) || ''
1332
+ ).trim();
1333
+ if (!businessId) {
1334
+ const detail = 'business id required: run inside a business workspace or pass --business-id <id>';
1335
+ if (wantsJson(args)) {
1336
+ printJson({ ok: false, action: 'sync_plan', reason: 'missing_business_id', detail });
1337
+ return;
1338
+ }
1339
+ console.error(`atris task sync: ${detail}`);
1340
+ process.exit(2);
1341
+ }
1342
+
1343
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
1344
+ const plan = syncPlanForProjection(projection, businessId);
1345
+ if (wantsJson(args)) {
1346
+ printJson({
1347
+ ok: true,
1348
+ action: 'sync_plan',
1349
+ dry_run: true,
1350
+ business_id: businessId,
1351
+ workspace_root: projection.workspace_root,
1352
+ projection_path: outPath,
1353
+ planned_writes: plan.length,
1354
+ plan,
1355
+ });
1356
+ return;
1357
+ }
1358
+
1359
+ console.log(`task sync dry-run: ${plan.length} planned write${plan.length === 1 ? '' : 's'}`);
1360
+ console.log(`business: ${businessId}`);
1361
+ for (const item of plan) {
1362
+ console.log(`${item.method.padEnd(5)} ${item.endpoint} <= ${item.local_task_id.slice(0, 8)} ${item.body.title}`);
1363
+ for (const followup of item.after_create || []) {
1364
+ console.log(` then ${followup.method} ${followup.endpoint} state=${followup.body.state}`);
1365
+ }
1366
+ }
1367
+ }
1368
+
1369
+ function taskColumn(task) {
1370
+ if (task.status === 'open') return 'open';
1371
+ if (task.status === 'claimed') return 'doing';
1372
+ if (task.status === 'failed') return 'blocked';
1373
+ if (task.status === 'done' && task.latest_event_type !== 'reviewed') return 'review';
1374
+ return 'done';
1375
+ }
1376
+
1377
+ function taskBoardHtml() {
1378
+ return `<!doctype html>
1379
+ <html lang="en">
1380
+ <head>
1381
+ <meta charset="utf-8">
1382
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1383
+ <title>Atris Task Factory</title>
1384
+ <style>
1385
+ :root { color-scheme: dark; --bg:#101113; --panel:#17191d; --line:#292d34; --text:#f0f2f5; --muted:#9299a6; --accent:#68d391; --warn:#f6c177; --bad:#f38ba8; }
1386
+ * { box-sizing: border-box; }
1387
+ body { margin:0; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--text); }
1388
+ header { height:56px; display:flex; align-items:center; justify-content:space-between; padding:0 18px; border-bottom:1px solid var(--line); background:#121418; }
1389
+ h1 { font-size:15px; margin:0; font-weight:650; letter-spacing:0; }
1390
+ .sub { color:var(--muted); font-size:12px; }
1391
+ main { display:grid; grid-template-columns: 320px 1fr; height:calc(100vh - 56px); }
1392
+ aside { border-right:1px solid var(--line); padding:14px; overflow:auto; background:#121418; }
1393
+ section { min-width:0; overflow:auto; padding:14px; }
1394
+ label { display:block; color:var(--muted); font-size:11px; margin:10px 0 5px; }
1395
+ input, textarea, select { width:100%; border:1px solid var(--line); background:#0d0f12; color:var(--text); border-radius:7px; padding:9px 10px; font:inherit; font-size:13px; }
1396
+ textarea { min-height:82px; resize:vertical; }
1397
+ button { border:1px solid var(--line); background:#20242a; color:var(--text); border-radius:7px; padding:8px 10px; font:inherit; font-size:12px; cursor:pointer; }
1398
+ button:hover { border-color:#3b414b; background:#252a32; }
1399
+ .primary { background:#214b35; border-color:#2f684a; }
1400
+ .grid { display:grid; grid-template-columns: repeat(5, minmax(180px, 1fr)); gap:12px; align-items:start; }
1401
+ .overview { display:grid; grid-template-columns: minmax(260px, 1.4fr) minmax(260px, 1fr); gap:12px; margin-bottom:12px; }
1402
+ .goalbox, .chainbox { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:11px; min-height:88px; }
1403
+ .goalbox h2, .chainbox h2 { margin:0 0 8px; color:var(--muted); font-size:12px; font-weight:650; }
1404
+ .goalitem { font-size:13px; line-height:1.3; margin:5px 0; }
1405
+ .chainitem { display:grid; grid-template-columns:72px 1fr; gap:8px; font-size:12px; line-height:1.3; margin:5px 0; color:var(--muted); }
1406
+ .chainitem strong { color:var(--text); font-weight:600; }
1407
+ .streams { display:grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap:12px; margin-bottom:12px; }
1408
+ .stream { background:#12161b; border:1px solid var(--line); border-radius:8px; padding:10px; min-height:126px; }
1409
+ .stream h2 { margin:0 0 8px; font-size:12px; color:var(--text); line-height:1.25; }
1410
+ .streambar { display:flex; height:7px; overflow:hidden; border-radius:999px; background:#0d0f12; border:1px solid var(--line); margin:8px 0; }
1411
+ .streambar span { display:block; min-width:2px; }
1412
+ .seg-open { background:#667085; }
1413
+ .seg-doing { background:#68d391; }
1414
+ .seg-review { background:#f6c177; }
1415
+ .seg-blocked { background:#f38ba8; }
1416
+ .streamtask { display:grid; grid-template-columns:64px 1fr; gap:8px; color:var(--muted); font-size:11px; line-height:1.25; margin-top:6px; }
1417
+ .streamtask strong { color:var(--text); font-weight:550; }
1418
+ .col { background:var(--panel); border:1px solid var(--line); border-radius:8px; min-height:160px; overflow:hidden; }
1419
+ .col h2 { margin:0; padding:10px 11px; font-size:12px; color:var(--muted); border-bottom:1px solid var(--line); display:flex; justify-content:space-between; }
1420
+ .cards { padding:8px; display:flex; flex-direction:column; gap:8px; }
1421
+ .card { text-align:left; width:100%; background:#111419; border:1px solid #252a31; border-radius:8px; padding:9px; }
1422
+ .card.active { border-color:#4c7a61; box-shadow:0 0 0 1px rgba(104,211,145,.2); }
1423
+ .title { font-size:13px; line-height:1.25; }
1424
+ .meta { margin-top:6px; color:var(--muted); font-size:11px; display:flex; gap:6px; flex-wrap:wrap; }
1425
+ .pill { border:1px solid var(--line); border-radius:999px; padding:1px 6px; }
1426
+ .why { margin-top:7px; color:var(--muted); font-size:11px; line-height:1.25; }
1427
+ .fact { margin:10px 0; background:#0d0f12; border:1px solid var(--line); border-radius:7px; padding:8px; font-size:12px; line-height:1.35; }
1428
+ .fact b { color:var(--muted); font-size:11px; display:block; margin-bottom:3px; }
1429
+ .room { margin-top:14px; border-top:1px solid var(--line); padding-top:12px; }
1430
+ .room h3 { margin:0 0 4px; font-size:14px; }
1431
+ .thread { margin:10px 0; display:flex; flex-direction:column; gap:7px; }
1432
+ .msg { background:#0d0f12; border:1px solid var(--line); border-radius:7px; padding:8px; font-size:12px; }
1433
+ .msg .who { color:var(--muted); font-size:11px; margin-bottom:3px; }
1434
+ .actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:10px; }
1435
+ .full { grid-column:1 / -1; }
1436
+ .empty { color:var(--muted); font-size:12px; padding:10px; }
1437
+ @media (max-width: 980px) { main { grid-template-columns:1fr; height:auto; } aside { border-right:0; border-bottom:1px solid var(--line); } .grid, .overview { grid-template-columns:1fr; } }
1438
+ </style>
1439
+ </head>
1440
+ <body>
1441
+ <header>
1442
+ <div>
1443
+ <h1>Atris Task Factory</h1>
1444
+ <div class="sub">local durable tasks / Swarlo-ready event stream</div>
1445
+ </div>
1446
+ <button id="refresh">Refresh</button>
1447
+ </header>
1448
+ <main>
1449
+ <aside>
1450
+ <form id="create">
1451
+ <label>New task</label>
1452
+ <textarea id="title" placeholder="Need something done..."></textarea>
1453
+ <label>Lane</label>
1454
+ <input id="tag" value="tasks">
1455
+ <button class="primary full" type="submit" style="margin-top:10px;width:100%">Create task</button>
1456
+ </form>
1457
+ <div class="room" id="room">
1458
+ <div class="empty">Select a task to open its room.</div>
1459
+ </div>
1460
+ </aside>
1461
+ <section>
1462
+ <div class="overview" id="overview"></div>
1463
+ <div class="streams" id="streams"></div>
1464
+ <div class="grid" id="board"></div>
1465
+ </section>
1466
+ </main>
1467
+ <script>
1468
+ const columns = [
1469
+ ['open', 'Open'],
1470
+ ['doing', 'Doing'],
1471
+ ['review', 'Review'],
1472
+ ['blocked', 'Blocked'],
1473
+ ['done', 'Done']
1474
+ ];
1475
+ let state = { tasks: [] };
1476
+ let selected = null;
1477
+ const $ = (id) => document.getElementById(id);
1478
+
1479
+ async function api(path, options = {}) {
1480
+ const res = await fetch(path, {
1481
+ ...options,
1482
+ headers: { 'content-type': 'application/json', ...(options.headers || {}) }
1483
+ });
1484
+ const data = await res.json();
1485
+ if (!res.ok || data.ok === false) throw new Error(data.detail || data.reason || 'request failed');
1486
+ return data;
1487
+ }
1488
+
1489
+ function taskColumn(task) {
1490
+ if (task.status === 'open') return 'open';
1491
+ if (task.status === 'claimed') return 'doing';
1492
+ if (task.status === 'failed') return 'blocked';
1493
+ if (task.status === 'done' && task.latest_event_type !== 'reviewed') return 'review';
1494
+ return 'done';
1495
+ }
1496
+
1497
+ async function load() {
1498
+ const data = await api('/api/tasks');
1499
+ state = data.projection;
1500
+ render();
1501
+ }
1502
+
1503
+ function render() {
1504
+ renderOverview();
1505
+ renderStreams();
1506
+ const board = $('board');
1507
+ board.innerHTML = '';
1508
+ for (const [key, label] of columns) {
1509
+ const tasks = state.tasks.filter((task) => taskColumn(task) === key);
1510
+ const col = document.createElement('div');
1511
+ col.className = 'col';
1512
+ col.innerHTML = '<h2><span>' + label + '</span><span>' + tasks.length + '</span></h2><div class="cards"></div>';
1513
+ const cards = col.querySelector('.cards');
1514
+ if (!tasks.length) cards.innerHTML = '<div class="empty">No tasks</div>';
1515
+ for (const task of tasks) cards.appendChild(card(task));
1516
+ board.appendChild(col);
1517
+ }
1518
+ renderRoom();
1519
+ }
1520
+
1521
+ function taskById(id) {
1522
+ return state.tasks.find((task) => task.id === id) || null;
1523
+ }
1524
+
1525
+ function renderOverview() {
1526
+ const active = state.tasks.filter((task) => task.status !== 'done');
1527
+ const reviewed = state.tasks.filter((task) => task.latest_event_type === 'reviewed');
1528
+ const goals = state.goals && state.goals.items || [];
1529
+ const goalHtml = goals.length
1530
+ ? goals.slice(0, 4).map((goal) => '<div class="goalitem"></div>').join('')
1531
+ : '<div class="empty">No atris/goals.md found. Add goals to give tasks a north star.</div>';
1532
+ const latest = reviewed.slice(0, 3);
1533
+ const chainHtml = latest.length
1534
+ ? latest.map((task) => '<div class="chainitem"><span>' + task.id.slice(0, 8) + '</span><strong></strong></div>').join('')
1535
+ : '<div class="empty">Complete a task with proof to start the chain.</div>';
1536
+ $('overview').innerHTML = [
1537
+ '<div class="goalbox"><h2>Goals</h2>' + goalHtml + '</div>',
1538
+ '<div class="chainbox"><h2>Compounding Chain</h2><div class="chainitem"><span>active</span><strong>' + active.length + ' open loops</strong></div>' + chainHtml + '</div>'
1539
+ ].join('');
1540
+ $('overview').querySelectorAll('.goalitem').forEach((el, i) => { el.textContent = goals[i]; });
1541
+ $('overview').querySelectorAll('.chainbox .chainitem strong').forEach((el, i) => {
1542
+ if (i === 0) return;
1543
+ const task = latest[i - 1];
1544
+ el.textContent = (task.review && task.review.next_task) ? task.title + ' -> ' + task.review.next_task : task.title;
1545
+ });
1546
+ }
1547
+
1548
+ function renderStreams() {
1549
+ const streams = (state.streams || []).filter((stream) => stream.active_count || stream.done_count).slice(0, 6);
1550
+ const root = $('streams');
1551
+ if (!streams.length) {
1552
+ root.innerHTML = '';
1553
+ return;
1554
+ }
1555
+ root.innerHTML = streams.map((stream) => {
1556
+ const total = Math.max(1, stream.open_count + stream.doing_count + stream.review_count + stream.blocked_count);
1557
+ const widths = {
1558
+ open: Math.max(0, Math.round(stream.open_count / total * 100)),
1559
+ doing: Math.max(0, Math.round(stream.doing_count / total * 100)),
1560
+ review: Math.max(0, Math.round(stream.review_count / total * 100)),
1561
+ blocked: Math.max(0, Math.round(stream.blocked_count / total * 100))
1562
+ };
1563
+ const tasks = stream.tasks.filter((task) => task.status !== 'done').slice(0, 3);
1564
+ const taskHtml = tasks.length
1565
+ ? tasks.map((task) => '<div class="streamtask"><span>' + task.id.slice(0, 8) + '</span><strong></strong></div>').join('')
1566
+ : '<div class="empty">No active tasks in this stream.</div>';
1567
+ return [
1568
+ '<div class="stream">',
1569
+ '<h2></h2>',
1570
+ '<div class="meta"><span class="pill">' + stream.active_count + ' active</span><span class="pill">' + stream.done_count + ' done</span></div>',
1571
+ '<div class="streambar"><span class="seg-open" style="width:' + widths.open + '%"></span><span class="seg-doing" style="width:' + widths.doing + '%"></span><span class="seg-review" style="width:' + widths.review + '%"></span><span class="seg-blocked" style="width:' + widths.blocked + '%"></span></div>',
1572
+ taskHtml,
1573
+ '</div>'
1574
+ ].join('');
1575
+ }).join('');
1576
+ root.querySelectorAll('.stream h2').forEach((el, i) => { el.textContent = streams[i].objective; });
1577
+ root.querySelectorAll('.stream').forEach((streamEl, i) => {
1578
+ const tasks = streams[i].tasks.filter((task) => task.status !== 'done').slice(0, 3);
1579
+ streamEl.querySelectorAll('.streamtask strong').forEach((el, idx) => { el.textContent = tasks[idx].title; });
1580
+ });
1581
+ }
1582
+
1583
+ function card(task) {
1584
+ const btn = document.createElement('button');
1585
+ btn.className = 'card' + (selected === task.id ? ' active' : '');
1586
+ btn.onclick = () => { selected = task.id; render(); };
1587
+ const owner = task.claimed_by ? '@' + task.claimed_by : 'unowned';
1588
+ btn.innerHTML = '<div class="title"></div><div class="meta"><span class="pill"></span><span class="pill"></span><span class="pill"></span></div><div class="why"></div>';
1589
+ btn.querySelector('.title').textContent = task.title;
1590
+ const pills = btn.querySelectorAll('.pill');
1591
+ pills[0].textContent = task.id.slice(0, 8);
1592
+ pills[1].textContent = owner;
1593
+ pills[2].textContent = 'v' + task.current_version;
1594
+ const why = task.objective || (task.lineage && task.lineage.parent_title) || (task.review && task.review.proof) || '';
1595
+ btn.querySelector('.why').textContent = why;
1596
+ return btn;
1597
+ }
1598
+
1599
+ function renderRoom() {
1600
+ const task = state.tasks.find((t) => t.id === selected);
1601
+ const room = $('room');
1602
+ if (!task) {
1603
+ room.innerHTML = '<div class="empty">Select a task to open its room.</div>';
1604
+ return;
1605
+ }
1606
+ const messages = task.messages.map((m) => '<div class="msg"><div class="who">' + (m.actor || 'unknown') + ' / v' + m.version + '</div><div></div></div>').join('');
1607
+ const parent = task.lineage && task.lineage.parent_title ? task.lineage.parent_title : 'none';
1608
+ const children = task.lineage && task.lineage.child_titles && task.lineage.child_titles.length ? task.lineage.child_titles.join(' | ') : (task.review && task.review.next_task || 'none yet');
1609
+ room.innerHTML = [
1610
+ '<h3></h3>',
1611
+ '<div class="meta"><span class="pill">' + task.status + '</span><span class="pill">' + (task.claimed_by || 'unowned') + '</span><span class="pill">v' + task.current_version + '</span></div>',
1612
+ '<div class="fact"><b>Goal</b><div id="taskGoal"></div></div>',
1613
+ '<div class="fact"><b>Lineage</b><div id="taskLineage"></div></div>',
1614
+ '<div class="fact"><b>Proof / lesson</b><div id="taskProof"></div></div>',
1615
+ '<div class="thread">' + (messages || '<div class="empty">No thread yet.</div>') + '</div>',
1616
+ '<label>Add context</label><textarea id="note" placeholder="Decision, blocker, context, update..."></textarea>',
1617
+ '<label>Proof</label><input id="proof" placeholder="npm test, PR link, screenshot, blocked reason...">',
1618
+ '<label>Lesson</label><textarea id="lesson" placeholder="What did this task teach us?"></textarea>',
1619
+ '<label>Next task</label><input id="nextTask" placeholder="Optional next sharper task">',
1620
+ '<div class="actions"><button id="claim">Claim</button><button id="saveNote">Say</button><button id="finish" class="primary full">Finish + review</button></div>'
1621
+ ].join('');
1622
+ room.querySelector('h3').textContent = task.title;
1623
+ $('taskGoal').textContent = task.objective || 'No matching goal yet.';
1624
+ $('taskLineage').textContent = 'parent: ' + parent + ' / next: ' + children;
1625
+ $('taskProof').textContent = task.review && (task.review.proof || task.review.lesson)
1626
+ ? ((task.review.proof || 'no proof') + ' / ' + (task.review.lesson || 'no lesson'))
1627
+ : 'No proof yet.';
1628
+ room.querySelectorAll('.msg div:last-child').forEach((el, i) => { el.textContent = task.messages[i].content; });
1629
+ $('claim').onclick = () => mutate('/api/tasks/' + task.id + '/claim', { owner: 'operator' });
1630
+ $('saveNote').onclick = () => mutate('/api/tasks/' + task.id + '/message', { actor: 'operator', content: $('note').value });
1631
+ $('finish').onclick = () => mutate('/api/tasks/' + task.id + '/finish', {
1632
+ actor: 'operator',
1633
+ proof: $('proof').value,
1634
+ lesson: $('lesson').value,
1635
+ next: $('nextTask').value,
1636
+ createNext: Boolean($('nextTask').value.trim())
1637
+ });
1638
+ }
1639
+
1640
+ async function mutate(path, body) {
1641
+ await api(path, { method: 'POST', body: JSON.stringify(body) });
1642
+ await load();
1643
+ }
1644
+
1645
+ $('create').onsubmit = async (e) => {
1646
+ e.preventDefault();
1647
+ const title = $('title').value.trim();
1648
+ if (!title) return;
1649
+ const data = await api('/api/tasks', { method: 'POST', body: JSON.stringify({ title, tag: $('tag').value || 'tasks' }) });
1650
+ selected = data.task_id;
1651
+ $('title').value = '';
1652
+ await load();
1653
+ };
1654
+ $('refresh').onclick = load;
1655
+ load();
1656
+ setInterval(load, 2500);
1657
+ </script>
1658
+ </body>
1659
+ </html>`;
1660
+ }
1661
+
1662
+ function readJsonBody(req) {
1663
+ return new Promise((resolve, reject) => {
1664
+ let body = '';
1665
+ req.on('data', chunk => {
1666
+ body += chunk;
1667
+ if (body.length > 1_000_000) {
1668
+ reject(new Error('body too large'));
1669
+ req.destroy();
1670
+ }
1671
+ });
1672
+ req.on('end', () => {
1673
+ if (!body.trim()) return resolve({});
1674
+ try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
1675
+ });
1676
+ req.on('error', reject);
1677
+ });
1678
+ }
1679
+
1680
+ function sendJson(res, status, value) {
1681
+ res.writeHead(status, {
1682
+ 'content-type': 'application/json; charset=utf-8',
1683
+ 'access-control-allow-origin': 'http://localhost',
1684
+ });
1685
+ res.end(JSON.stringify(value, null, 2));
1686
+ }
1687
+
1688
+ function sendHtml(res, value) {
1689
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
1690
+ res.end(value);
1691
+ }
1692
+
1693
+ async function handleTaskApi(req, res, taskDb, db) {
1694
+ const url = new URL(req.url, 'http://127.0.0.1');
1695
+ if (req.method === 'OPTIONS') return sendJson(res, 200, { ok: true });
1696
+ if (req.method === 'GET' && url.pathname === '/') return sendHtml(res, taskBoardHtml());
1697
+ if (req.method === 'GET' && url.pathname === '/api/tasks') {
1698
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
1699
+ return sendJson(res, 200, { ok: true, projection_path: outPath, projection });
1700
+ }
1701
+ if (req.method === 'POST' && url.pathname === '/api/tasks') {
1702
+ const body = await readJsonBody(req);
1703
+ const title = String(body.title || '').trim();
1704
+ if (!title) return sendJson(res, 400, { ok: false, reason: 'missing_title', detail: 'title required' });
1705
+ const result = taskDb.addTask(db, {
1706
+ title,
1707
+ tag: body.tag ? String(body.tag) : 'tasks',
1708
+ workspaceRoot: taskDb.workspaceRoot(),
1709
+ });
1710
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
1711
+ return sendJson(res, 200, { ok: true, action: 'created', task_id: result.id, projection_path: outPath, task: taskFromProjection(projection, result.id) });
1712
+ }
1713
+ const match = url.pathname.match(/^\/api\/tasks\/([^/]+)\/(claim|message|finish|review|events)$/);
1714
+ if (!match) return sendJson(res, 404, { ok: false, reason: 'not_found' });
1715
+ const resolved = resolveTaskRef(taskDb, db, match[1]);
1716
+ if (!resolved.ok) return sendJson(res, resolved.reason === 'ambiguous' ? 409 : 404, { ok: false, reason: resolved.reason });
1717
+ const taskId = resolved.id;
1718
+ const op = match[2];
1719
+ if (req.method === 'GET' && op === 'events') {
1720
+ const events = taskDb.listTaskEvents(db, { taskId, limit: 500 });
1721
+ return sendJson(res, 200, { ok: true, events });
1722
+ }
1723
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, reason: 'method_not_allowed' });
1724
+ const body = await readJsonBody(req);
1725
+ if (op === 'claim') {
1726
+ const owner = String(body.owner || body.actor || DEFAULT_OWNER);
1727
+ const result = taskDb.claimTask(db, { id: taskId, claimedBy: owner });
1728
+ if (!result.claimed) return sendJson(res, 409, { ok: false, reason: result.reason, claimed_by: result.claimed_by || null });
1729
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
1730
+ return sendJson(res, 200, { ok: true, action: 'claimed', task_id: taskId, projection_path: outPath, task: taskFromProjection(projection, taskId) });
1731
+ }
1732
+ if (op === 'message') {
1733
+ const result = taskDb.noteTask(db, { id: taskId, actor: String(body.actor || DEFAULT_OWNER), content: String(body.content || '') });
1734
+ if (!result.noted) return sendJson(res, 404, { ok: false, reason: result.reason });
1735
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
1736
+ return sendJson(res, 200, { ok: true, action: 'noted', task_id: taskId, projection_path: outPath, task: taskFromProjection(projection, taskId) });
1737
+ }
1738
+ if (op === 'finish') {
1739
+ const currentTask = taskDb.getTask(db, taskId);
1740
+ const done = taskDb.doneTask(db, { id: taskId, status: body.failed ? 'failed' : 'done' });
1741
+ if (!done.updated) return sendJson(res, 409, { ok: false, reason: 'not_open_or_claimed' });
1742
+ const shouldReview = body.proof || body.lesson || body.next || body.reward !== undefined;
1743
+ let episode = null;
1744
+ let nextCreated = null;
1745
+ if (shouldReview) {
1746
+ const reviewed = taskDb.reviewTask(db, {
1747
+ id: taskId,
1748
+ actor: String(body.actor || DEFAULT_OWNER),
1749
+ reward: body.reward === undefined ? 1 : body.reward,
1750
+ lesson: String(body.lesson || ''),
1751
+ nextTask: String(body.next || ''),
1752
+ proof: String(body.proof || ''),
1753
+ });
1754
+ episode = reviewed.episode;
1755
+ nextCreated = body.createNext ? createNextTaskIfRequested(taskDb, db, ['--create-next'], currentTask, episode.next_task_suggestion) : null;
1756
+ }
1757
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
1758
+ return sendJson(res, 200, {
1759
+ ok: true,
1760
+ action: 'finished',
1761
+ task_id: taskId,
1762
+ reviewed: Boolean(episode),
1763
+ episode,
1764
+ next_task_id: nextCreated ? nextCreated.id : null,
1765
+ projection_path: outPath,
1766
+ task: taskFromProjection(projection, taskId),
1767
+ });
1768
+ }
1769
+ if (op === 'review') {
1770
+ const currentTask = taskDb.getTask(db, taskId);
1771
+ const reviewed = taskDb.reviewTask(db, {
1772
+ id: taskId,
1773
+ actor: String(body.actor || DEFAULT_OWNER),
1774
+ reward: body.reward === undefined ? 1 : body.reward,
1775
+ lesson: String(body.lesson || ''),
1776
+ nextTask: String(body.next || ''),
1777
+ proof: String(body.proof || ''),
1778
+ });
1779
+ const nextCreated = body.createNext ? createNextTaskIfRequested(taskDb, db, ['--create-next'], currentTask, reviewed.episode.next_task_suggestion) : null;
1780
+ const { projection, outPath } = writeDefaultProjection(taskDb, db);
1781
+ return sendJson(res, 200, { ok: true, action: 'reviewed', task_id: taskId, episode: reviewed.episode, next_task_id: nextCreated ? nextCreated.id : null, projection_path: outPath, task: taskFromProjection(projection, taskId) });
1782
+ }
1783
+ }
1784
+
1785
+ function cmdServe(args) {
1786
+ const host = String(flag(args, '--host') || '127.0.0.1');
1787
+ const port = Number(flag(args, '--port') || process.env.PORT || 8787);
1788
+ const taskDb = getTaskDb();
1789
+ const db = taskDb.open();
1790
+ const server = http.createServer((req, res) => {
1791
+ handleTaskApi(req, res, taskDb, db).catch((e) => {
1792
+ sendJson(res, 500, { ok: false, reason: 'server_error', detail: String(e && e.message || e) });
1793
+ });
1794
+ });
1795
+ return new Promise((resolve, reject) => {
1796
+ server.on('error', reject);
1797
+ server.listen(port, host, () => {
1798
+ const addr = server.address();
1799
+ const actualPort = addr && addr.port || port;
1800
+ console.log(`Task board: http://${host}:${actualPort}`);
1801
+ console.log(`Workspace: ${taskDb.workspaceRoot()}`);
1802
+ });
1803
+
1804
+ const shutdown = () => {
1805
+ server.close(() => resolve());
1806
+ };
1807
+ process.once('SIGTERM', shutdown);
1808
+ process.once('SIGINT', shutdown);
1809
+ });
1810
+ }
1811
+
194
1812
  async function run(args) {
195
- const sub = (args && args[0]) || 'help';
196
- const rest = (args || []).slice(1);
1813
+ const raw = args || [];
1814
+ const first = raw[0];
1815
+ const sub = !first || first.startsWith('--') ? 'desk' : first;
1816
+ const rest = !first || first.startsWith('--') ? raw : raw.slice(1);
197
1817
  switch (sub) {
1818
+ case 'desk': return cmdHome(rest);
1819
+ case 'today': return cmdDay(rest);
1820
+ case 'day': return cmdDay(rest);
198
1821
  case 'add': return cmdAdd(rest);
1822
+ case 'new': return cmdAdd(rest);
1823
+ case 'delegate': return cmdDelegate(rest);
1824
+ case 'assign': return cmdDelegate(rest);
199
1825
  case 'list': return cmdList(rest);
200
1826
  case 'ls': return cmdList(rest);
201
1827
  case 'claim': return cmdClaim(rest);
1828
+ case 'start': return cmdClaim(rest);
1829
+ case 'next': return cmdNext(rest);
1830
+ case 'note': return cmdNote(rest);
1831
+ case 'say': return cmdNote(rest);
1832
+ case 'show': return cmdShow(rest);
202
1833
  case 'done': return cmdDone(rest);
1834
+ case 'finish': return cmdFinish(rest);
203
1835
  case 'fail': return cmdDone([...rest, '--failed']);
1836
+ case 'review': return cmdReview(rest);
1837
+ case 'status': return cmdStatus(rest);
1838
+ case 'setup': return cmdSetup(rest);
1839
+ case 'serve': return cmdServe(rest);
204
1840
  case 'import': return cmdImport(rest);
205
- case 'where': return cmdWhere();
1841
+ case 'events': return cmdEvents(rest);
1842
+ case 'export': return cmdExport(rest);
1843
+ case 'render': return cmdRender(rest);
1844
+ case 'sync': return cmdSync(rest);
1845
+ case 'where': return cmdWhere(rest);
206
1846
  case 'help':
207
1847
  case '--help':
208
1848
  case '-h':