create-harness-vibe-coding 0.8.17 → 0.8.19

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.
Files changed (150) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README-CN.md +2 -0
  3. package/README.md +3 -0
  4. package/package.json +5 -2
  5. package/src/generator.js +613 -97
  6. package/src/index.js +556 -309
  7. package/src/prompts.js +18 -0
  8. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js +17 -0
  9. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js.map +1 -0
  10. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js +7 -0
  11. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js.map +1 -0
  12. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js +12 -0
  13. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js.map +1 -0
  14. package/src/ui/dist/assets/TaskList-BN4r8s1i.js +50 -0
  15. package/src/ui/dist/assets/TaskList-BN4r8s1i.js.map +1 -0
  16. package/src/ui/dist/assets/TerminalDrawer-6GBZ9nXN.css +32 -0
  17. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js +74 -0
  18. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js.map +1 -0
  19. package/src/ui/dist/assets/WorkflowRoute-BnuhLJ6X.css +1 -0
  20. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js +39 -0
  21. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js.map +1 -0
  22. package/src/ui/dist/assets/index-BOVYWntB.js +117 -0
  23. package/src/ui/dist/assets/index-BOVYWntB.js.map +1 -0
  24. package/src/ui/dist/assets/index-DaUObq0H.css +1 -0
  25. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js +7 -0
  26. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js.map +1 -0
  27. package/src/ui/dist/assets/plus-QuTJyoT3.js +7 -0
  28. package/src/ui/dist/assets/plus-QuTJyoT3.js.map +1 -0
  29. package/src/ui/dist/assets/proxy-D0vUbds5.js +2 -0
  30. package/src/ui/dist/assets/proxy-D0vUbds5.js.map +1 -0
  31. package/src/ui/dist/assets/refresh-cw-JNmExijd.js +7 -0
  32. package/src/ui/dist/assets/refresh-cw-JNmExijd.js.map +1 -0
  33. package/src/ui/dist/assets/terminal-BBQvnOAK.js +7 -0
  34. package/src/ui/dist/assets/terminal-BBQvnOAK.js.map +1 -0
  35. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js +7 -0
  36. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js.map +1 -0
  37. package/src/ui/dist/assets/x-DoIOGAJz.js +7 -0
  38. package/src/ui/dist/assets/x-DoIOGAJz.js.map +1 -0
  39. package/src/ui/dist/index.html +18 -0
  40. package/src/ui/index.html +17 -0
  41. package/src/ui/package.json +33 -0
  42. package/src/ui/pnpm-lock.yaml +1582 -0
  43. package/src/ui/pnpm-workspace.yaml +2 -0
  44. package/src/ui/src/App.tsx +86 -0
  45. package/src/ui/src/api.ts +93 -0
  46. package/src/ui/src/components/AgentsRoute.tsx +503 -0
  47. package/src/ui/src/components/Footer.tsx +69 -0
  48. package/src/ui/src/components/Header.tsx +175 -0
  49. package/src/ui/src/components/LoadingView.tsx +22 -0
  50. package/src/ui/src/components/RolesRoute.tsx +169 -0
  51. package/src/ui/src/components/SettingsRoute.tsx +166 -0
  52. package/src/ui/src/components/TaskList.tsx +388 -0
  53. package/src/ui/src/components/TerminalDrawer.tsx +611 -0
  54. package/src/ui/src/components/WorkflowRoute.tsx +670 -0
  55. package/src/ui/src/hooks/useReducedMotion.ts +17 -0
  56. package/src/ui/src/hooks/useServerConnection.ts +114 -0
  57. package/src/ui/src/index.css +281 -0
  58. package/src/ui/src/main.tsx +11 -0
  59. package/src/ui/src/types.ts +108 -0
  60. package/src/ui/tsconfig.json +21 -0
  61. package/src/ui/vite.config.ts +19 -0
  62. package/src/wf-ui-server/__tests__/a2a-store.test.mjs +79 -0
  63. package/src/wf-ui-server/__tests__/peer-capsule.test.mjs +268 -0
  64. package/src/wf-ui-server/__tests__/pty-adapter.test.mjs +70 -0
  65. package/src/wf-ui-server/__tests__/runtime-config.test.mjs +43 -0
  66. package/src/wf-ui-server/__tests__/runtime-detector.test.mjs +90 -0
  67. package/src/wf-ui-server/__tests__/security.test.mjs +59 -0
  68. package/src/wf-ui-server/__tests__/server.integration.test.mjs +150 -0
  69. package/src/wf-ui-server/__tests__/session-registry.test.mjs +238 -0
  70. package/src/wf-ui-server/__tests__/settings.test.mjs +135 -0
  71. package/src/wf-ui-server/__tests__/task-parser.test.mjs +200 -0
  72. package/src/wf-ui-server/__tests__/terminal-store.test.mjs +138 -0
  73. package/src/wf-ui-server/__tests__/token.test.mjs +48 -0
  74. package/src/wf-ui-server/__tests__/ws-events.integration.test.mjs +419 -0
  75. package/src/wf-ui-server/__tests__/ws-terminal.integration.test.mjs +383 -0
  76. package/src/wf-ui-server/a2a-store.mjs +296 -0
  77. package/src/wf-ui-server/peer-capsule.mjs +213 -0
  78. package/src/wf-ui-server/pty-adapter.mjs +155 -0
  79. package/src/wf-ui-server/runtime-config.mjs +153 -0
  80. package/src/wf-ui-server/runtime-detector.mjs +374 -0
  81. package/src/wf-ui-server/security.mjs +67 -0
  82. package/src/wf-ui-server/server.mjs +849 -0
  83. package/src/wf-ui-server/session-registry.mjs +204 -0
  84. package/src/wf-ui-server/settings.mjs +100 -0
  85. package/src/wf-ui-server/task-parser.mjs +133 -0
  86. package/src/wf-ui-server/terminal-store.mjs +225 -0
  87. package/src/wf-ui-server/token.mjs +41 -0
  88. package/src/wf-ui-server/ws-events.mjs +354 -0
  89. package/src/wf-ui-server/ws-terminal.mjs +473 -0
  90. package/templates/common/.claude/commands/wf-command-create.md +58 -0
  91. package/templates/common/.claude/commands/wf-help.md +5 -0
  92. package/templates/common/.claude/commands/wf-task-archive.md +26 -0
  93. package/templates/common/.claude/commands/wf-task-list.md +26 -0
  94. package/templates/common/.claude/commands/wf-task-record.md +24 -0
  95. package/templates/common/.claude/commands/wf-ui.md +26 -0
  96. package/templates/common/.claude/commands/wf-update.md +54 -9
  97. package/templates/common/.claude/rules/ecc/common.md +1 -1
  98. package/templates/common/.claude/skills/wf-agents-docs/SKILL.md +15 -30
  99. package/templates/common/.claude/skills/wf-auto/SKILL.md +3 -3
  100. package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +2 -2
  101. package/templates/common/.claude/skills/wf-command-create/SKILL.md +37 -0
  102. package/templates/common/.claude/skills/wf-max/SKILL.md +1 -1
  103. package/templates/common/.claude/skills/wf-review/SKILL.md +29 -2
  104. package/templates/common/.claude/skills/wf-task-archive/SKILL.md +28 -0
  105. package/templates/common/.claude/skills/wf-task-list/SKILL.md +28 -0
  106. package/templates/common/.claude/skills/wf-task-record/SKILL.md +28 -0
  107. package/templates/common/.claude/skills/wf-ui/SKILL.md +78 -0
  108. package/templates/common/.claude/skills/wf-update/SKILL.md +55 -58
  109. package/templates/common/.harness-version +105 -47
  110. package/templates/common/.opencode/commands/wf-command-create.md +61 -0
  111. package/templates/common/.opencode/commands/wf-help.md +5 -0
  112. package/templates/common/.opencode/commands/wf-task-archive.md +29 -0
  113. package/templates/common/.opencode/commands/wf-task-list.md +29 -0
  114. package/templates/common/.opencode/commands/wf-task-record.md +27 -0
  115. package/templates/common/.opencode/commands/wf-ui.md +26 -0
  116. package/templates/common/.opencode/commands/wf-update.md +54 -9
  117. package/templates/common/CLAUDE.md +8 -6
  118. package/templates/common/Harness/MEMORY.md +11 -0
  119. package/templates/common/Harness/README.md +21 -38
  120. package/templates/common/Harness/a2a/role-graph.json +79 -0
  121. package/templates/common/Harness/a2a/runtime-registry.json +5 -0
  122. package/templates/common/Harness/a2a/skills/terminal-control.json +15 -0
  123. package/templates/common/Harness/ownership.manifest.json +142 -2
  124. package/templates/common/Harness/scripts/README.md +134 -0
  125. package/templates/common/Harness/scripts/a2a-terminal.mjs +191 -0
  126. package/templates/common/Harness/scripts/context-budget.mjs +1 -1
  127. package/templates/common/Harness/scripts/sync-host-global.mjs +278 -0
  128. package/templates/common/Harness/scripts/task-state.mjs +949 -26
  129. package/templates/common/Harness/scripts/validate-harness.mjs +637 -62
  130. package/templates/common/Harness/scripts/wf-remove.mjs +42 -5
  131. package/templates/common/Harness/scripts/wf-update-check.mjs +37 -9
  132. package/templates/common/Harness/scripts/wf-update-runner.mjs +325 -0
  133. package/templates/common/Harness/settings.json +35 -0
  134. package/templates/common/Harness/specs/guides/SETUP.md +8 -0
  135. package/templates/common/Harness/specs/protocols/MEMORY_PROTOCOL.md +15 -0
  136. package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +16 -5
  137. package/templates/common/Harness/specs/runtime/command-surface.json +229 -0
  138. package/templates/common/Harness/specs/runtime/subagents.md +6 -0
  139. package/templates/common/Harness/specs/workflows/WF-AUTO-SPARK.md +8 -8
  140. package/templates/common/Harness/specs/workflows/WF-AUTO.md +12 -12
  141. package/templates/common/Harness/specs/workflows/WF-MAX.md +5 -0
  142. package/templates/common/Harness/specs/workflows/WF-STATE.md +66 -0
  143. package/templates/common/Harness/tasks/_template/NAMING.md +16 -16
  144. package/templates/common/Harness/tasks/_template/PLAN.md +17 -60
  145. package/templates/common/Harness/tasks/_template/PROBLEM.md +18 -0
  146. package/templates/common/Harness/tasks/_template/PROGRESS.md +9 -14
  147. package/templates/common/Harness/tasks/_template/REFERENCES.md +26 -0
  148. package/templates/common/Harness/tasks/_template/STATE.json +6 -0
  149. package/templates/common/Harness/tasks/_template/ARTIFACTS.md +0 -3
  150. package/templates/common/Harness/tasks/_template/NOTES.md +0 -3
@@ -11,7 +11,7 @@ const args = process.argv.slice(2);
11
11
  const command = args[0] && !args[0].startsWith('--') ? args[0] : 'help';
12
12
 
13
13
  const OUTER_TASK_CAP = 5;
14
- const RESERVED = new Set(['_template', '_archive', 'auto']);
14
+ const RESERVED = new Set(['_template', '_archive', 'continuous']);
15
15
  const TASK_ID_RE = /^task-[a-z]+(-[a-z0-9]+){1,4}$/;
16
16
  const NEVER_ARCHIVE_STATUSES = new Set([
17
17
  'active',
@@ -71,6 +71,8 @@ const STATUS_ALIASES = new Map([
71
71
  ['need-user-decision', 'needs-user-decision'],
72
72
  ['close-out', 'closeout'],
73
73
  ]);
74
+ const OPEN_TASK_STATUSES = new Set(['active', 'blocked', 'in_progress', 'running', 'pending', 'needs-user-decision']);
75
+ const VALUE_FLAGS = new Set(['--keep', '--mode', '--phase', '--status', '--task', '--text', '--title', '--note', '--context']);
74
76
 
75
77
  function hasFlag(name) {
76
78
  return args.includes(name);
@@ -82,6 +84,22 @@ function flagValue(name, fallback = null) {
82
84
  return args[index + 1];
83
85
  }
84
86
 
87
+ function findTaskIdArg(startIndex = 1) {
88
+ for (let i = startIndex; i < args.length; i++) {
89
+ const a = args[i];
90
+ if (a.startsWith('--')) {
91
+ if (a.includes('=')) continue;
92
+ const next = args[i + 1];
93
+ if (VALUE_FLAGS.has(a) && next && !next.startsWith('--')) {
94
+ i++; // skip the value
95
+ }
96
+ continue;
97
+ }
98
+ return a;
99
+ }
100
+ return null;
101
+ }
102
+
85
103
  const outputJson = hasFlag('--json');
86
104
 
87
105
  function print(payload) {
@@ -90,7 +108,41 @@ function print(payload) {
90
108
  return;
91
109
  }
92
110
 
93
- if (payload.command === 'list' || payload.command === 'validate') {
111
+ if (payload.command === 'list') {
112
+ console.log(`Active Task: ${payload.activeTask || 'None'}`);
113
+ console.log(`Tasks: ${payload.taskCount}`);
114
+ for (const task of payload.tasks || []) {
115
+ const deps = task.dependsOn?.length ? ` dependsOn: [${task.dependsOn.join(', ')}]` : '';
116
+ const blocks = task.blocks?.length ? ` blocks: [${task.blocks.join(', ')}]` : '';
117
+ console.log(`- ${task.id}: status=${task.status || '-'} phase=${task.phase || '-'}${deps}${blocks}`);
118
+ }
119
+
120
+ // Render graph
121
+ const g = payload.graph;
122
+ if (g) {
123
+ console.log('');
124
+ console.log('=== Task Graph ===');
125
+ if (g.roots.length > 0) {
126
+ console.log(`\nRoots (${g.roots.length} tasks, no internal dependencies):`);
127
+ for (const id of g.roots) console.log(` → ${id}`);
128
+ }
129
+ if (g.depEdges.length > 0) {
130
+ console.log(`\nDependency chains (${g.depEdges.length} edges):`);
131
+ for (const e of g.depEdges) console.log(` ${e.from} ──▶ ${e.to}`);
132
+ }
133
+ if (g.blockEdges.length > 0) {
134
+ console.log(`\nBlocks (${g.blockEdges.length} edges):`);
135
+ for (const e of g.blockEdges) console.log(` ${e.from} ▸▸ ${e.to}`);
136
+ }
137
+ if (g.orphanedDeps.length > 0) {
138
+ console.log(`\nOrphaned dependencies (${g.orphanedDeps.length}, target not in active tasks):`);
139
+ for (const o of g.orphanedDeps) console.log(` ${o.task} ──▶ ${o.missingDep} (missing)`);
140
+ }
141
+ if (g.roots.length === 0 && g.depEdges.length === 0 && g.blockEdges.length === 0) {
142
+ console.log(' (no relationships — all tasks are independent)');
143
+ }
144
+ }
145
+ } else if (payload.command === 'validate') {
94
146
  console.log(`Active Task: ${payload.activeTask || 'None'}`);
95
147
  console.log(`Tasks: ${payload.taskCount}`);
96
148
  for (const task of payload.tasks || []) {
@@ -104,7 +156,7 @@ function print(payload) {
104
156
  if (payload.dryRun) console.log('[DRY RUN] No files moved. Use --apply to execute.');
105
157
  console.log(`Scanned: ${payload.scanned}, Archiveable: ${payload.archiveable}, To archive: ${payload.toArchive}, Kept: ${payload.kept}, Skipped: ${payload.skipped}`);
106
158
  for (const r of payload.results) {
107
- const suffix = r.year ? ` -> _archive/${r.year}` : '';
159
+ const suffix = r.path ? ` -> _archive/${r.path}` : '';
108
160
  console.log(`- ${r.action}: ${r.dir} (${r.status})${suffix}`);
109
161
  }
110
162
  } else {
@@ -127,12 +179,23 @@ function usage() {
127
179
  message: `Usage: node Harness/scripts/task-state.mjs <command> [options]
128
180
 
129
181
  Commands:
130
- list [--json] List task state.
131
- validate [--strict] [--json] Validate state consistency.
182
+ list [--json] List task state with dependency/resume info.
183
+ validate [--strict] [--json] Validate state consistency (includes link checks).
132
184
  reconcile [--dry-run|--apply] [--json] Normalize STATE.json and root PROGRESS.md.
133
185
  set-active <task-id> [--dry-run] Set the single active task.
134
186
  transition <task-id> --status <s> --phase <p> [--dry-run]
135
187
  archive [--dry-run|--apply] [--keep n] [--task id] [--json]
188
+ Archive eligible tasks to _archive/YYYY/MM/DD/.
189
+ Explicit --apply (no --task filter) archives ALL.
190
+ history list [--year YYYY] [--month MM] [--json]
191
+ List archived tasks, optional year/month filter.
192
+ history search <keyword> [--json] Full-text search archived PLAN/PROGRESS/PROBLEM/REFERENCES.
193
+ history load <task-id> [--json] Load one archived task's full record.
194
+ history delete <task-id> [--dry-run|--apply] [--json]
195
+ Delete an archived task (audit trail written).
196
+ record <task-id> [--create] [--text "description"] [--status <s>] [--mode <m>] [--dry-run|--apply] [--json]
197
+ Create or update a task record.
198
+ open [--json] List open (non-archived, active-status) tasks.
136
199
 
137
200
  Archive defaults to dry-run and keeps ${OUTER_TASK_CAP} non-archived task capsules.`,
138
201
  }, 0);
@@ -226,6 +289,35 @@ function defaultQueues() {
226
289
  };
227
290
  }
228
291
 
292
+ function defaultTaskRuntime() {
293
+ if (process.env.HARNESS_DEFAULT_RUNTIME) return process.env.HARNESS_DEFAULT_RUNTIME;
294
+ const settingsPath = path.join(harnessDir, 'settings.json');
295
+ try {
296
+ if (!fs.existsSync(settingsPath)) return 'codex';
297
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
298
+ return settings?.terminal?.defaultRuntime || 'codex';
299
+ } catch {
300
+ return 'codex';
301
+ }
302
+ }
303
+
304
+ const VALID_MODES = new Set([
305
+ 'direct',
306
+ 'wf',
307
+ 'wf-max',
308
+ 'wf-auto',
309
+ 'wf-auto-spark',
310
+ 'wf-review',
311
+ 'wf-browser',
312
+ ]);
313
+
314
+ function normalizeMode(value) {
315
+ if (value === null || value === undefined) return '';
316
+ const raw = String(value).trim().toLowerCase();
317
+ if (!raw) return '';
318
+ if (VALID_MODES.has(raw)) return raw;
319
+ return '';
320
+ }
229
321
  function normalizeQueues(state) {
230
322
  const source = state && typeof state.queues === 'object' && state.queues ? state.queues : state || {};
231
323
  return {
@@ -398,6 +490,48 @@ function validateState({ strict = false } = {}) {
398
490
  if (task.id === rootProgress.activeTask && normalizeStatus(task.state.status) && normalizeStatus(task.state.status) !== 'active') {
399
491
  issue(`${task.id}: root Active Task points here but STATE.json status is "${normalizeStatus(task.state.status)}"`, true);
400
492
  }
493
+
494
+ const links = task.state.links || {};
495
+ if (Array.isArray(links.dependsOn)) {
496
+ for (const depId of links.dependsOn) {
497
+ if (!taskIds.has(depId)) issue(`${task.id}: links.dependsOn references non-existent task "${depId}"`);
498
+ }
499
+ }
500
+ if (Array.isArray(links.blocks)) {
501
+ for (const blockId of links.blocks) {
502
+ if (!taskIds.has(blockId)) issue(`${task.id}: links.blocks references non-existent task "${blockId}"`);
503
+ }
504
+ }
505
+ if (Array.isArray(task.state.workItems)) {
506
+ const runningItems = task.state.workItems.filter(wi => wi && normalizeStatus(wi.status) === 'running');
507
+ if (runningItems.length > 0 && (!Array.isArray(task.state.dispatchLedger) || task.state.dispatchLedger.length === 0)) {
508
+ issue(`${task.id}: workItems has ${runningItems.length} running item(s) but no dispatchLedger entries`);
509
+ }
510
+ }
511
+
512
+ const queues = normalizeQueues(task.state);
513
+ const queueMembership = new Map();
514
+ for (const queueName of ['ready', 'running', 'blocked', 'done']) {
515
+ for (const item of queues[queueName]) {
516
+ const itemId = typeof item === 'string' ? item : (item && typeof item.id === 'string' ? item.id : null);
517
+ if (!itemId) {
518
+ issue(`${task.id}: queues.${queueName} contains an item without an id`, true);
519
+ continue;
520
+ }
521
+ const priorQueue = queueMembership.get(itemId);
522
+ if (priorQueue) {
523
+ issue(`${task.id}: queue item "${itemId}" appears in both ${priorQueue} and ${queueName}`, true);
524
+ } else {
525
+ queueMembership.set(itemId, queueName);
526
+ }
527
+ }
528
+ }
529
+ const status = normalizeStatus(task.state.status);
530
+ const phase = normalizePhase(task.state.phase);
531
+ if ((SAFE_ARCHIVE_STATUSES.has(status) || SAFE_ARCHIVE_STATUSES.has(phase)) &&
532
+ (queues.ready.length > 0 || queues.running.length > 0 || queues.blocked.length > 0)) {
533
+ issue(`${task.id}: closed task has non-empty ready/running/blocked queues`, true);
534
+ }
401
535
  }
402
536
 
403
537
  const activeStateTasks = tasks.filter(task => normalizeStatus(task.state?.status) === 'active');
@@ -405,7 +539,7 @@ function validateState({ strict = false } = {}) {
405
539
  issue(`Multiple STATE.json files are active: ${activeStateTasks.map(task => task.id).join(', ')}`, true);
406
540
  }
407
541
  if (tasks.length > OUTER_TASK_CAP) {
408
- issue(`Harness/tasks/ has ${tasks.length} outer task capsules (cap ${OUTER_TASK_CAP}); run node Harness/scripts/task-state.mjs archive --apply`);
542
+ issue(`Harness/tasks/ has ${tasks.length} outer task capsules (cap ${OUTER_TASK_CAP}); remind the user to run $wf-task-archive when they want to archive completed tasks`);
409
543
  }
410
544
 
411
545
  return {
@@ -455,11 +589,14 @@ function desiredStatusForTask(task, activeTask, desiredPhase) {
455
589
  }
456
590
 
457
591
  function defaultState(taskId, status, phase, now) {
592
+ const runtime = defaultTaskRuntime();
458
593
  return {
459
594
  schemaVersion: 1,
460
595
  taskId,
461
596
  status,
462
597
  mode: 'direct',
598
+ defaultRuntime: runtime,
599
+ defaultAgentRuntime: runtime,
463
600
  tier: 'none',
464
601
  phase,
465
602
  gate: null,
@@ -488,6 +625,8 @@ function normalizeState(task, activeTask, now) {
488
625
  state.status = status;
489
626
  state.phase = phase;
490
627
  if (!state.mode) state.mode = 'direct';
628
+ if (!state.defaultRuntime) state.defaultRuntime = defaultTaskRuntime();
629
+ if (!state.defaultAgentRuntime) state.defaultAgentRuntime = state.defaultRuntime;
491
630
  if (!state.tier) state.tier = 'none';
492
631
  if (!Object.prototype.hasOwnProperty.call(state, 'gate')) state.gate = null;
493
632
  if (!Object.prototype.hasOwnProperty.call(state, 'activeQuestion')) state.activeQuestion = null;
@@ -687,9 +826,78 @@ function applyOperations(operations) {
687
826
  }
688
827
  }
689
828
 
829
+ function buildListGraph(expandedTasks) {
830
+ const byId = new Map(expandedTasks.map(t => [t.id, t]));
831
+ const graph = { roots: [], depEdges: [], blockEdges: [], orphanedDeps: [] };
832
+
833
+ // Roots: tasks with no dependsOn pointing to other active tasks (or empty dependsOn)
834
+ // Non-roots: tasks whose dependsOn includes at least one other active task
835
+ const hasInternalDep = new Set();
836
+ const incomingBlocks = new Map(); // taskId → who blocks it (for reverse lookup)
837
+
838
+ for (const task of expandedTasks) {
839
+ for (const depId of task.dependsOn) {
840
+ if (byId.has(depId)) {
841
+ hasInternalDep.add(task.id);
842
+ graph.depEdges.push({ from: depId, to: task.id });
843
+ } else if (depId) {
844
+ graph.orphanedDeps.push({ task: task.id, missingDep: depId });
845
+ }
846
+ }
847
+ for (const blockId of task.blocks) {
848
+ if (byId.has(blockId)) {
849
+ graph.blockEdges.push({ from: task.id, to: blockId });
850
+ }
851
+ if (!incomingBlocks.has(blockId)) incomingBlocks.set(blockId, []);
852
+ incomingBlocks.get(blockId).push(task.id);
853
+ }
854
+ }
855
+
856
+ graph.roots = expandedTasks.filter(t => !hasInternalDep.has(t.id));
857
+
858
+ return graph;
859
+ }
860
+
690
861
  function runList() {
691
- const validation = validateState();
692
- finish({ ...validation, command: 'list', ok: true }, 0);
862
+ const { rootProgress, tasks } = collectTasks();
863
+
864
+ const expandedTasks = tasks.map(task => {
865
+ const state = task.state || {};
866
+ const links = state.links || {};
867
+ const status = normalizeStatus(state.status) || task.status;
868
+ return {
869
+ id: task.id,
870
+ status,
871
+ phase: normalizePhase(state.phase) || task.phase,
872
+ rootPhase: task.rootPhase,
873
+ progressPhase: task.progressPhase,
874
+ dependsOn: Array.isArray(links.dependsOn) ? links.dependsOn : [],
875
+ blocks: Array.isArray(links.blocks) ? links.blocks : [],
876
+ statusDisplay: status || '-',
877
+ openTasks: OPEN_TASK_STATUSES.has(status),
878
+ nextAction: state.nextAction || null,
879
+ archive: (state ? archiveEligibility(task, rootProgress.activeTask) : { ok: false, reason: 'no state' }),
880
+ };
881
+ });
882
+
883
+ const graph = buildListGraph(expandedTasks);
884
+
885
+ const payload = {
886
+ ok: true,
887
+ command: 'list',
888
+ taskCount: expandedTasks.length,
889
+ activeTask: rootProgress.activeTask,
890
+ tasks: expandedTasks,
891
+ graph: {
892
+ roots: graph.roots.map(t => t.id),
893
+ depEdges: graph.depEdges,
894
+ blockEdges: graph.blockEdges,
895
+ orphanedDeps: graph.orphanedDeps,
896
+ },
897
+ errors: [],
898
+ warnings: [],
899
+ };
900
+ finish(payload, 0);
693
901
  }
694
902
 
695
903
  function runValidate() {
@@ -765,38 +973,67 @@ function buildArchivePlan() {
765
973
  else skipped.push({ task, reason: eligibility.reason });
766
974
  }
767
975
 
976
+ const dryRun = !hasFlag('--apply');
977
+ const explicitTrigger = dryRun === false && !taskFilter;
768
978
  archiveable.sort((a, b) => a.mtimeMs - b.mtimeMs || a.id.localeCompare(b.id));
769
- const toArchiveCount = taskFilter ? archiveable.length : Math.max(0, tasks.length - keepValue);
979
+
980
+ // Explicit user trigger (no --task filter, with --apply): archive ALL eligible tasks.
981
+ // Explicit --task targets the selected eligible task in both dry-run and apply.
982
+ // Auto/scheduled/dry-run: only archive tasks exceeding --keep cap.
983
+ const toArchiveCount = taskFilter
984
+ ? archiveable.length
985
+ : explicitTrigger
986
+ ? archiveable.length
987
+ : Math.max(0, tasks.length - keepValue);
770
988
  const toArchive = archiveable.slice(0, toArchiveCount);
771
989
  const toArchiveIds = new Set(toArchive.map(task => task.id));
772
990
  const keptArchiveable = archiveable.filter(task => !toArchiveIds.has(task.id));
773
991
 
774
992
  const errors = [];
775
993
  for (const task of toArchive) {
776
- const year = new Date(task.mtimeMs).getFullYear().toString();
777
- const dest = safeTaskPath('_archive', year, task.id);
778
- if (fs.existsSync(dest)) errors.push(`Archive destination already exists: Harness/tasks/_archive/${year}/${task.id}`);
994
+ const mtime = new Date(task.mtimeMs);
995
+ const year = mtime.getFullYear().toString();
996
+ const month = String(mtime.getMonth() + 1).padStart(2, '0');
997
+ const day = String(mtime.getDate()).padStart(2, '0');
998
+ const dest = safeTaskPath('_archive', year, month, day, task.id);
999
+ const relPath = `_archive/${year}/${month}/${day}/${task.id}`;
1000
+ if (fs.existsSync(dest)) errors.push(`Archive destination already exists: Harness/tasks/${relPath}`);
779
1001
  }
780
1002
  if (errors.length) {
781
1003
  return { ok: false, command: 'archive', errors, warnings: [], results: [] };
782
1004
  }
783
1005
 
1006
+ function dateParts(ts) {
1007
+ const d = new Date(ts);
1008
+ return {
1009
+ year: d.getFullYear().toString(),
1010
+ month: String(d.getMonth() + 1).padStart(2, '0'),
1011
+ day: String(d.getDate()).padStart(2, '0'),
1012
+ path: `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`,
1013
+ };
1014
+ }
1015
+
784
1016
  const results = [
785
1017
  ...toArchive.map(task => ({
786
1018
  dir: task.id,
787
- year: new Date(task.mtimeMs).getFullYear().toString(),
1019
+ ...dateParts(task.mtimeMs),
788
1020
  action: hasFlag('--apply') ? 'archived' : 'would-archive',
789
- status: hasFlag('--apply') ? 'moved' : 'dry-run',
1021
+ status: hasFlag('--apply')
1022
+ ? (explicitTrigger ? 'explicit --apply: all eligible' : 'moved')
1023
+ : (taskFilter ? 'dry-run' : `dry-run (${toArchiveCount} of ${archiveable.length} eligible)`),
790
1024
  })),
791
1025
  ...keptArchiveable.map(task => ({
792
1026
  dir: task.id,
793
- year: new Date(task.mtimeMs).getFullYear().toString(),
1027
+ ...dateParts(task.mtimeMs),
794
1028
  action: 'kept',
795
1029
  status: `kept by --keep ${keepValue}`,
796
1030
  })),
797
1031
  ...skipped.map(({ task, reason }) => ({
798
1032
  dir: task.id,
799
1033
  year: null,
1034
+ month: null,
1035
+ day: null,
1036
+ path: null,
800
1037
  action: 'skipped',
801
1038
  status: reason,
802
1039
  })),
@@ -818,18 +1055,195 @@ function buildArchivePlan() {
818
1055
  toArchiveIds,
819
1056
  tasks,
820
1057
  rootProgress,
1058
+ graphGenerated: hasFlag('--apply') && archiveable.length > 0,
821
1059
  };
822
1060
  }
823
1061
 
824
- function appendArchiveIndex(entries) {
825
- if (entries.length === 0) return;
1062
+ function buildArchiveGraphIndex() {
1063
+ // Walk _archive/YYYY/MM/DD/task-id/ for all archived tasks
1064
+ const allArchived = [];
1065
+ if (fs.existsSync(archiveDir)) {
1066
+ const yearDirs = fs.readdirSync(archiveDir, { withFileTypes: true })
1067
+ .filter(e => e.isDirectory() && /^\d{4}$/.test(e.name))
1068
+ .map(e => e.name).sort();
1069
+ for (const year of yearDirs) {
1070
+ const yearPath = path.join(archiveDir, year);
1071
+ const monthDirs = fs.readdirSync(yearPath, { withFileTypes: true })
1072
+ .filter(e => e.isDirectory() && /^\d{2}$/.test(e.name))
1073
+ .map(e => e.name).sort();
1074
+ for (const month of monthDirs) {
1075
+ const monthPath = path.join(yearPath, month);
1076
+ const dayDirs = fs.readdirSync(monthPath, { withFileTypes: true })
1077
+ .filter(e => e.isDirectory() && /^\d{2}$/.test(e.name))
1078
+ .map(e => e.name).sort();
1079
+ for (const day of dayDirs) {
1080
+ const dayPath = path.join(monthPath, day);
1081
+ const taskDirs = fs.readdirSync(dayPath, { withFileTypes: true })
1082
+ .filter(e => e.isDirectory()).map(e => e.name);
1083
+ for (const taskId of taskDirs) {
1084
+ const statePath = path.join(dayPath, taskId, 'STATE.json');
1085
+ const state = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, 'utf8')) : null;
1086
+ const planPath = path.join(dayPath, taskId, 'PLAN.md');
1087
+ const planText = fs.existsSync(planPath) ? fs.readFileSync(planPath, 'utf8') : '';
1088
+ const progressPath2 = path.join(dayPath, taskId, 'PROGRESS.md');
1089
+ const progressText = fs.existsSync(progressPath2) ? fs.readFileSync(progressPath2, 'utf8') : '';
1090
+ allArchived.push({ id: taskId, year, month, day, state, planText, progressText });
1091
+ }
1092
+ }
1093
+ }
1094
+ }
1095
+ }
1096
+
1097
+ if (allArchived.length === 0) return null;
1098
+
1099
+ const byId = new Map(allArchived.map(t => [t.id, t]));
1100
+ const graphLines = [];
1101
+ graphLines.push('## Task Graph\n');
1102
+
1103
+ // Identify root tasks (no dependsOn, or all dependsOn are archived but not in this set)
1104
+ const hasDep = new Set();
1105
+ for (const task of allArchived) {
1106
+ const deps = task.state?.links?.dependsOn || [];
1107
+ for (const depId of deps) {
1108
+ if (byId.has(depId)) hasDep.add(task.id);
1109
+ }
1110
+ }
1111
+
1112
+ // Root tasks
1113
+ const roots = allArchived.filter(t => !hasDep.has(t.id));
1114
+ if (roots.length > 0) {
1115
+ graphLines.push('### Roots (no dependencies)');
1116
+ for (const task of roots) {
1117
+ const deps = (task.state?.links?.dependsOn || []).map(id => `\`${id}\``).join(', ') || 'none';
1118
+ const blocks = (task.state?.links?.blocks || []).map(id => `\`${id}\``).join(', ') || 'none';
1119
+ graphLines.push(`- \`${task.id}\` → blocks: ${blocks}`);
1120
+ }
1121
+ }
1122
+
1123
+ // Build dependency chains
1124
+ const visited = new Set();
1125
+ function chainLines(taskId, indent) {
1126
+ if (visited.has(taskId)) return [];
1127
+ visited.add(taskId);
1128
+ const task = byId.get(taskId);
1129
+ if (!task) return [];
1130
+ const deps = task.state?.links?.dependsOn || [];
1131
+ const lines = [];
1132
+ if (deps.length > 0) {
1133
+ const depList = deps.map(depId => {
1134
+ const depTask = byId.get(depId);
1135
+ return depTask
1136
+ ? `[\`${depId}\`](#${depId.toLowerCase().replace(/-/g, '')})`
1137
+ : `\`${depId}\``;
1138
+ }).join(' → ');
1139
+ lines.push(`${indent}- \`${taskId}\` ← depends on: ${depList}`);
1140
+ } else {
1141
+ lines.push(`${indent}- \`${taskId}\``);
1142
+ }
1143
+ for (const depId of deps) {
1144
+ lines.push(...chainLines(depId, indent + ' '));
1145
+ }
1146
+ return lines;
1147
+ }
1148
+
1149
+ // Show dependency relationships
1150
+ const depGraph = [];
1151
+ for (const task of allArchived) {
1152
+ const deps = task.state?.links?.dependsOn || [];
1153
+ for (const depId of deps) {
1154
+ if (byId.has(depId)) {
1155
+ depGraph.push({ from: depId, to: task.id });
1156
+ }
1157
+ }
1158
+ }
1159
+
1160
+ if (depGraph.length > 0) {
1161
+ graphLines.push('\n### Dependencies');
1162
+ graphLines.push('```\n' + depGraph.map(e => ` ${e.from} ──▶ ${e.to}`).join('\n') + '\n```\n');
1163
+ graphLines.push('| From | To |');
1164
+ graphLines.push('|------|----|');
1165
+ for (const e of depGraph) {
1166
+ graphLines.push(`| \`${e.from}\` | \`${e.to}\` |`);
1167
+ }
1168
+ }
1169
+
1170
+ // Blocks relationships
1171
+ const blocksGraph = [];
1172
+ for (const task of allArchived) {
1173
+ const blocks = task.state?.links?.blocks || [];
1174
+ for (const blockId of blocks) {
1175
+ if (byId.has(blockId)) {
1176
+ blocksGraph.push({ from: task.id, to: blockId });
1177
+ }
1178
+ }
1179
+ }
1180
+ if (blocksGraph.length > 0) {
1181
+ graphLines.push('\n### Blocks (completion triggers)');
1182
+ graphLines.push('| Task | Unblocks |');
1183
+ graphLines.push('|------|----------|');
1184
+ for (const e of blocksGraph) {
1185
+ graphLines.push(`| \`${e.from}\` | \`${e.to}\` |`);
1186
+ }
1187
+ }
1188
+
1189
+ // Per-task detail section
1190
+ graphLines.push('\n## Task Details\n');
1191
+ const detailOrder = allArchived.slice().sort((a, b) => {
1192
+ if (a.year !== b.year) return b.year.localeCompare(a.year);
1193
+ if (a.month !== b.month) return b.month.localeCompare(a.month);
1194
+ if (a.day !== b.day) return b.day.localeCompare(a.day);
1195
+ return a.id.localeCompare(b.id);
1196
+ });
1197
+ for (const task of detailOrder) {
1198
+ const anchor = task.id.toLowerCase().replace(/-/g, '');
1199
+ const phase = task.state?.phase || '-';
1200
+ const status = task.state?.status || '-';
1201
+ const goal = (task.planText.match(/^## Goal\s*\n+([\s\S]*?)(?=\n## |$)/mi) || ['', ''])[1].trim().slice(0, 120) || (task.state?.nextAction || '-');
1202
+ const deps = (task.state?.links?.dependsOn || []).join(', ') || '-';
1203
+ const blocks = (task.state?.links?.blocks || []).join(', ') || '-';
1204
+ graphLines.push(`### \`${task.id}\` {#${anchor}}`);
1205
+ graphLines.push(`- **Archived:** ${task.year}/${task.month}/${task.day}`);
1206
+ graphLines.push(`- **Status:** ${status} **Phase:** ${phase}`);
1207
+ graphLines.push(`- **Goal:** ${goal.slice(0, 120)}${goal !== '-' && goal.length >= 120 ? '...' : ''}`);
1208
+ graphLines.push(`- **Depends on:** ${deps} **Blocks:** ${blocks}`);
1209
+ }
1210
+
1211
+ return graphLines.join('\n');
1212
+ }
1213
+
1214
+ function appendArchiveIndex(entries, graphContent) {
1215
+ if (entries.length === 0 && !graphContent) return;
826
1216
  const indexPath = path.join(archiveDir, 'INDEX.md');
827
- const existing = fs.existsSync(indexPath)
828
- ? fs.readFileSync(indexPath, 'utf8').trimEnd() + '\n'
829
- : '| Task | Year | Archived |\n|------|------|----------|\n';
830
1217
  const date = new Date().toISOString().slice(0, 10);
831
- const lines = entries.map(entry => `| ${entry.id} | ${entry.year} | ${date} |`).join('\n');
832
- writeTextAtomic(indexPath, `${existing}${lines}\n`);
1218
+
1219
+ let existing = '';
1220
+ if (fs.existsSync(indexPath)) {
1221
+ const raw = fs.readFileSync(indexPath, 'utf8');
1222
+ // Strip old graph section if present so we regenerate fresh
1223
+ const graphStart = raw.indexOf('\n## Task Graph\n');
1224
+ existing = graphStart >= 0 ? raw.slice(0, graphStart).trimEnd() : raw.trimEnd();
1225
+ }
1226
+ if (existing && !existing.endsWith('\n')) existing += '\n';
1227
+
1228
+ const lines = [];
1229
+ lines.push(existing);
1230
+ if (entries.length > 0) {
1231
+ if (!existing || !existing.includes('| Task | Path | Archived |')) {
1232
+ lines.push('| Task | Path | Archived |');
1233
+ lines.push('|------|------|----------|');
1234
+ }
1235
+ for (const entry of entries) {
1236
+ const entryPath = `${entry.year}/${entry.month}/${entry.day}`;
1237
+ lines.push(`| \`${entry.id}\` | ${entryPath} | ${date} |`);
1238
+ }
1239
+ }
1240
+
1241
+ if (graphContent) {
1242
+ lines.push('');
1243
+ lines.push(graphContent);
1244
+ }
1245
+
1246
+ writeTextAtomic(indexPath, lines.join('\n') + '\n');
833
1247
  }
834
1248
 
835
1249
  function runArchive() {
@@ -840,8 +1254,8 @@ function runArchive() {
840
1254
  const indexEntries = [];
841
1255
  for (const result of plan.results.filter(result => result.action === 'archived')) {
842
1256
  const src = safeTaskPath(result.dir);
843
- const destDir = safeTaskPath('_archive', result.year);
844
- const dest = safeTaskPath('_archive', result.year, result.dir);
1257
+ const destDir = safeTaskPath('_archive', result.year, result.month, result.day);
1258
+ const dest = safeTaskPath('_archive', result.year, result.month, result.day, result.dir);
845
1259
  fs.mkdirSync(destDir, { recursive: true });
846
1260
  fs.renameSync(src, dest);
847
1261
  const movedStatePath = path.join(dest, 'STATE.json');
@@ -854,9 +1268,10 @@ function runArchive() {
854
1268
  writeJsonAtomic(movedStatePath, movedState);
855
1269
  const movedProgressPath = path.join(dest, 'PROGRESS.md');
856
1270
  writeTextAtomic(movedProgressPath, renderTaskProgress(readText(movedProgressPath), result.dir, movedState));
857
- indexEntries.push({ id: result.dir, year: result.year });
1271
+ indexEntries.push({ id: result.dir, year: result.year, month: result.month, day: result.day });
858
1272
  }
859
- appendArchiveIndex(indexEntries);
1273
+ const graphContent = buildArchiveGraphIndex();
1274
+ appendArchiveIndex(indexEntries, graphContent);
860
1275
 
861
1276
  for (const task of plan.tasks) {
862
1277
  task.desiredState = task.state || defaultState(task.id, task.status, task.phase || 'intake', new Date().toISOString());
@@ -873,6 +1288,500 @@ function runArchive() {
873
1288
  finish(plan, 0);
874
1289
  }
875
1290
 
1291
+ function readTemplateState() {
1292
+ const templatePath = path.join(tasksDir, '_template', 'STATE.json');
1293
+ if (!fs.existsSync(templatePath)) return null;
1294
+ try {
1295
+ return JSON.parse(fs.readFileSync(templatePath, 'utf8'));
1296
+ } catch {
1297
+ return null;
1298
+ }
1299
+ }
1300
+
1301
+ function generateTaskId(title, note, context) {
1302
+ // lowercase first, then sanitize — ensures uppercase letters survive as lowercase
1303
+ const raw = (title || note || context || 'record')
1304
+ .toLowerCase()
1305
+ .replace(/[^a-z0-9]+/g, '-')
1306
+ .replace(/^-+|-+$/g, '');
1307
+ const parts = raw.split('-').filter(Boolean).slice(0, 3).join('-');
1308
+ // fallback: empty slug (pure CJK/emoji) defaults to 'task'
1309
+ const slug = parts.slice(0, 25) || 'task';
1310
+ const now = new Date();
1311
+ const suffix = `${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}`;
1312
+ return `task-${slug}-${suffix}`;
1313
+ }
1314
+
1315
+ function findMatchingTask(title, note, context) {
1316
+ const { tasks } = collectTasks();
1317
+ const openTasks = tasks.filter(t => OPEN_TASK_STATUSES.has(normalizeStatus(t.state?.status) || t.status));
1318
+
1319
+ // Step 1: title match (highest priority)
1320
+ if (title) {
1321
+ const slugKey = title.replace(/\s+/g, '-').toLowerCase();
1322
+ const matches = openTasks.filter(t => {
1323
+ if (t.id === `task-${slugKey}`) return true;
1324
+ if (t.id.includes(slugKey)) return true;
1325
+ const goal = (t.rootRow?.goal || t.state?.nextAction || '').toLowerCase();
1326
+ return slugKey.split('-').every(part => goal.includes(part));
1327
+ });
1328
+ if (matches.length === 1) return { matched: matches[0], candidates: null };
1329
+ if (matches.length > 1) return { matched: null, candidates: matches };
1330
+ }
1331
+
1332
+ // Step 2: note/context overlap match
1333
+ if (note || context) {
1334
+ const query = ((note || '') + ' ' + (context || '')).toLowerCase();
1335
+ const terms = query.split(/\s+/).filter(t => t.length > 3);
1336
+ if (terms.length === 0) return { matched: null, candidates: null };
1337
+ const scored = openTasks.map(t => {
1338
+ const fields = [t.state?.nextAction || '', t.state?.goal || '', t.rootRow?.goal || ''].join(' ').toLowerCase();
1339
+ const score = terms.filter(term => fields.includes(term)).length;
1340
+ return { task: t, score };
1341
+ }).filter(s => s.score > 0);
1342
+ scored.sort((a, b) => b.score - a.score);
1343
+ if (scored.length === 0) return { matched: null, candidates: null };
1344
+ const bestScore = scored[0].score;
1345
+ const best = scored.filter(s => s.score === bestScore);
1346
+ if (best.length === 1 && bestScore >= Math.max(2, Math.ceil(terms.length * 0.5))) {
1347
+ return { matched: best[0].task, candidates: null };
1348
+ }
1349
+ if (best.length > 1) {
1350
+ return { matched: null, candidates: best.map(s => s.task) };
1351
+ }
1352
+ }
1353
+
1354
+ return { matched: null, candidates: null };
1355
+ }
1356
+
1357
+ function runRecord() {
1358
+ const title = flagValue('--title');
1359
+ const note = flagValue('--note');
1360
+ const context = flagValue('--context');
1361
+ const forceNew = hasFlag('--new');
1362
+
1363
+ let taskId = findTaskIdArg(1);
1364
+ let createOrResume = false;
1365
+
1366
+ if (!taskId) {
1367
+ if (!title && !note && !context) {
1368
+ finish({ ok: false, command: 'record', errors: ['requires <task-id> or --title/--note/--context'], warnings: [] }, 1);
1369
+ return;
1370
+ }
1371
+ if (!forceNew) {
1372
+ const { matched, candidates } = findMatchingTask(title, note, context);
1373
+ if (matched) {
1374
+ taskId = matched.id;
1375
+ } else if (candidates && candidates.length > 0) {
1376
+ finish({ ok: false, command: 'record', errors: [`Ambiguous match: ${candidates.map(t => t.id).join(', ')}. Use --new to force new, or specify --title more precisely.`], warnings: [] }, 1);
1377
+ return;
1378
+ }
1379
+ }
1380
+ if (!taskId) {
1381
+ taskId = generateTaskId(title, note, context);
1382
+ createOrResume = true;
1383
+ }
1384
+
1385
+ // --new uniqueness: probe existing task dirs, append incrementing suffix on collision
1386
+ if (forceNew && createOrResume) {
1387
+ const existingNames = new Set(listOuterTaskNames());
1388
+ let counter = 1;
1389
+ const baseId = taskId;
1390
+ while (existingNames.has(taskId)) {
1391
+ counter++;
1392
+ taskId = `${baseId}-${counter}`;
1393
+ }
1394
+ // if suffix made it invalid, fall back to base id
1395
+ try {
1396
+ ensureValidTaskId(taskId);
1397
+ } catch {
1398
+ taskId = baseId;
1399
+ }
1400
+ }
1401
+ }
1402
+
1403
+ if (!taskId) {
1404
+ finish({ ok: false, command: 'record', errors: ['record requires a <task-id>'], warnings: [] }, 1);
1405
+ return;
1406
+ }
1407
+ try {
1408
+ ensureValidTaskId(taskId);
1409
+ } catch (err) {
1410
+ finish({ ok: false, command: 'record', errors: [err.message], warnings: [] }, 1);
1411
+ return;
1412
+ }
1413
+
1414
+ const isCreate = hasFlag('--create') || createOrResume;
1415
+ const isDryRun = hasFlag('--dry-run');
1416
+ const isApply = hasFlag('--apply');
1417
+ const actuallyApply = isApply && !isDryRun;
1418
+
1419
+ const existing = readState(taskId);
1420
+ if (!existing.state && !isCreate) {
1421
+ finish({ ok: false, command: 'record', errors: [`Task "${taskId}" not found; use --create to create`], warnings: [] }, 1);
1422
+ return;
1423
+ }
1424
+
1425
+ const text = flagValue('--text');
1426
+ const statusRaw = flagValue('--status');
1427
+ const modeRaw = flagValue('--mode');
1428
+
1429
+ if (!existing.state && isCreate) {
1430
+ const now = new Date().toISOString();
1431
+ if (statusRaw) {
1432
+ const ns = normalizeStatus(statusRaw);
1433
+ if (!ns) {
1434
+ finish({ ok: false, command: 'record', errors: [`Invalid status "${statusRaw}". Valid: ${[...VALID_STATUSES].join(', ')}`], warnings: [] }, 1);
1435
+ return;
1436
+ }
1437
+ }
1438
+ const status = statusRaw ? normalizeStatus(statusRaw) : 'pending';
1439
+ const mode = modeRaw || 'direct';
1440
+ const newState = defaultState(taskId, status, 'intake', now);
1441
+ newState.mode = mode;
1442
+ if (text) newState.nextAction = text;
1443
+ if (modeRaw) {
1444
+ const normalizedMode = normalizeMode(modeRaw);
1445
+ if (!normalizedMode) {
1446
+ finish({ ok: false, command: 'record', errors: [`Invalid mode "${modeRaw}". Valid: ${[...VALID_MODES].join(', ')}`], warnings: [] }, 1);
1447
+ return;
1448
+ }
1449
+ newState.mode = normalizedMode;
1450
+ }
1451
+
1452
+ const templateState = readTemplateState();
1453
+ if (templateState) {
1454
+ if (Array.isArray(templateState.acceptance)) newState.acceptance = [...templateState.acceptance];
1455
+ if (templateState.links) newState.links = JSON.parse(JSON.stringify(templateState.links));
1456
+ }
1457
+
1458
+ if (actuallyApply) {
1459
+ const taskDir = safeTaskPath(taskId);
1460
+ fs.mkdirSync(taskDir, { recursive: true });
1461
+ writeJsonAtomic(path.join(taskDir, 'STATE.json'), newState);
1462
+ const progressFile = path.join(taskDir, 'PROGRESS.md');
1463
+ writeTextAtomic(progressFile, renderTaskProgress(readText(progressFile), taskId, newState));
1464
+
1465
+ const planFile = path.join(taskDir, 'PLAN.md');
1466
+ if (!fs.existsSync(planFile)) {
1467
+ writeTextAtomic(planFile, `# ${taskId} - PLAN\n\n## Goal\n\n${text || taskTitle(taskId)}\n\n## Scope\n\nWrite set:\n-\n\nForbidden:\n-\n\n## Decisions\n\n| # | Decision | Reason | Date |\n|---|----------|--------|------|\n\n## Acceptance\n\n| ID | Criterion | Evidence | Status |\n|----|-----------|----------|--------|\n| AC-001 | | | pending |\n\n## Risks\n\n| Risk | Mitigation | Status |\n|------|------------|--------|\n`);
1468
+ }
1469
+ const problemFile = path.join(taskDir, 'PROBLEM.md');
1470
+ if (!fs.existsSync(problemFile)) {
1471
+ writeTextAtomic(problemFile, `# ${taskId} - PROBLEM\n\n## Active\n\n| ID | Problem | Root cause | Fix | Status |\n|----|---------|------------|-----|--------|\n\n## Resolved\n\n| ID | Problem | Root cause | Fix | Resolved |\n|----|---------|------------|-----|----------|\n`);
1472
+ }
1473
+ const refFile = path.join(taskDir, 'REFERENCES.md');
1474
+ if (!fs.existsSync(refFile)) {
1475
+ writeTextAtomic(refFile, `# ${taskId} - REFERENCES\n\n## Logs\n\n| Description | File / Command | Date |\n|-------------|---------------|------|\n\n## Evidence\n\n| What | Pointer | Verified |\n|------|---------|----------|\n\n## Links\n\n| Description | URL / Path |\n|-------------|------------|\n\n## Notes\n\n-\n`);
1476
+ }
1477
+
1478
+ const rootText = readText(progressPath);
1479
+ const parsed = parseRootProgress();
1480
+ const newRow = { id: taskId, goal: text || taskTitle(taskId), phase: displayPhase('intake'), closed: '-' };
1481
+ parsed.rows.push(newRow);
1482
+ const rows = parsed.rows.map(r => ({ id: r.id, goal: r.goal, phase: r.phase, closed: r.closed }));
1483
+ const newRoot = renderRootProgress(rootText, parsed.activeTask, rows);
1484
+ writeTextAtomic(progressPath, newRoot);
1485
+ }
1486
+
1487
+ finish({
1488
+ ok: true,
1489
+ command: 'record',
1490
+ action: 'created',
1491
+ dryRun: !actuallyApply,
1492
+ taskId,
1493
+ state: newState,
1494
+ warnings: [],
1495
+ }, 0);
1496
+ return;
1497
+ }
1498
+
1499
+ if (existing.state) {
1500
+ const now = new Date().toISOString();
1501
+ const updated = { ...existing.state };
1502
+ updated.updatedAt = now;
1503
+ if (!updated.defaultRuntime) updated.defaultRuntime = defaultTaskRuntime();
1504
+ if (!updated.defaultAgentRuntime) updated.defaultAgentRuntime = updated.defaultRuntime;
1505
+ if (statusRaw) {
1506
+ const ns = normalizeStatus(statusRaw);
1507
+ if (!ns) {
1508
+ finish({ ok: false, command: 'record', errors: [`Invalid status "${statusRaw}". Valid: ${[...VALID_STATUSES].join(', ')}`], warnings: [] }, 1);
1509
+ return;
1510
+ }
1511
+ updated.status = ns;
1512
+ }
1513
+ if (modeRaw) {
1514
+ const normalizedMode = normalizeMode(modeRaw);
1515
+ if (!normalizedMode) {
1516
+ finish({ ok: false, command: 'record', errors: [`Invalid mode "${modeRaw}". Valid: ${[...VALID_MODES].join(', ')}`], warnings: [] }, 1);
1517
+ return;
1518
+ }
1519
+ updated.mode = normalizedMode;
1520
+ }
1521
+ if (text) updated.nextAction = text;
1522
+
1523
+ if (actuallyApply) {
1524
+ writeJsonAtomic(existing.path, updated);
1525
+ }
1526
+
1527
+ finish({
1528
+ ok: true,
1529
+ command: 'record',
1530
+ action: 'updated',
1531
+ dryRun: !actuallyApply,
1532
+ taskId,
1533
+ state: updated,
1534
+ warnings: [],
1535
+ }, 0);
1536
+ return;
1537
+ }
1538
+ }
1539
+
1540
+ function runOpen() {
1541
+ const { rootProgress, tasks } = collectTasks();
1542
+
1543
+ const openTasks = tasks.filter(task => {
1544
+ const status = normalizeStatus(task.state?.status) || task.status;
1545
+ return OPEN_TASK_STATUSES.has(status);
1546
+ }).map(task => {
1547
+ const state = task.state || {};
1548
+ const links = state.links || {};
1549
+ const status = normalizeStatus(state.status) || task.status;
1550
+ const dependsOn = Array.isArray(links.dependsOn) ? links.dependsOn : [];
1551
+ const openDepTasks = dependsOn.filter(depId => {
1552
+ const depTask = tasks.find(t => t.id === depId);
1553
+ if (!depTask) return false;
1554
+ const depStatus = normalizeStatus(depTask.state?.status) || depTask.status;
1555
+ return OPEN_TASK_STATUSES.has(depStatus);
1556
+ });
1557
+ return {
1558
+ id: task.id,
1559
+ status,
1560
+ phase: normalizePhase(state.phase) || task.phase,
1561
+ dependsOn,
1562
+ blocks: Array.isArray(links.blocks) ? links.blocks : [],
1563
+ blockedByOpenDeps: openDepTasks,
1564
+ nextAction: state.nextAction || null,
1565
+ statusDisplay: status || '-',
1566
+ openTasks: true,
1567
+ };
1568
+ });
1569
+
1570
+ finish({
1571
+ ok: true,
1572
+ command: 'open',
1573
+ taskCount: openTasks.length,
1574
+ tasks: openTasks,
1575
+ errors: [],
1576
+ warnings: [],
1577
+ }, 0);
1578
+ }
1579
+
1580
+ // ---- history ----
1581
+ function walkArchived(filter = {}) {
1582
+ const results = [];
1583
+ if (!fs.existsSync(archiveDir)) return results;
1584
+
1585
+ function addTask(taskPath, year, month, day) {
1586
+ const taskId = path.basename(taskPath);
1587
+ if (taskId.startsWith('_') || taskId.startsWith('.')) return;
1588
+ if (filter.taskId && taskId !== filter.taskId) return;
1589
+ const statePath = path.join(taskPath, 'STATE.json');
1590
+ const state = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, 'utf8')) : null;
1591
+ results.push({
1592
+ id: taskId,
1593
+ year, month, day,
1594
+ archivePath: `${year}/${month}/${day}`,
1595
+ fullPath: taskPath,
1596
+ mtime: fs.statSync(taskPath).mtime.toISOString(),
1597
+ state,
1598
+ });
1599
+ }
1600
+
1601
+ // Walk new layout: _archive/YYYY/MM/DD/task-id/
1602
+ // Walk old layout: _archive/YYYY/task-id/ (legacy, auto-assign month=01, day=01)
1603
+ const entries = fs.readdirSync(archiveDir, { withFileTypes: true });
1604
+ const yearDirs = entries
1605
+ .filter(e => e.isDirectory() && /^\d{4}$/.test(e.name) && (!filter.year || e.name === filter.year))
1606
+ .map(e => e.name).sort();
1607
+
1608
+ for (const year of yearDirs) {
1609
+ const yearPath = path.join(archiveDir, year);
1610
+ const yearEntries = fs.readdirSync(yearPath, { withFileTypes: true });
1611
+
1612
+ // New layout: month dirs (two-digit)
1613
+ const monthDirs = yearEntries
1614
+ .filter(e => e.isDirectory() && /^\d{2}$/.test(e.name) && (!filter.month || e.name === filter.month));
1615
+ const hasMonthDirs = monthDirs.length > 0;
1616
+
1617
+ if (hasMonthDirs) {
1618
+ for (const month of monthDirs.map(e => e.name).sort()) {
1619
+ const monthPath = path.join(yearPath, month);
1620
+ const dayDirs = fs.readdirSync(monthPath, { withFileTypes: true })
1621
+ .filter(e => e.isDirectory() && /^\d{2}$/.test(e.name))
1622
+ .map(e => e.name).sort();
1623
+ for (const day of dayDirs) {
1624
+ const dayPath = path.join(monthPath, day);
1625
+ const taskDirs = fs.readdirSync(dayPath, { withFileTypes: true })
1626
+ .filter(e => e.isDirectory());
1627
+ for (const taskDir of taskDirs) {
1628
+ addTask(path.join(dayPath, taskDir.name), year, month, day);
1629
+ }
1630
+ }
1631
+ }
1632
+ }
1633
+
1634
+ // Legacy layout: task dirs directly under year
1635
+ const legacyTasks = yearEntries
1636
+ .filter(e => e.isDirectory() && !/^\d{2}$/.test(e.name) && e.name !== '_deleted.jsonl');
1637
+ for (const taskDir of legacyTasks) {
1638
+ addTask(path.join(yearPath, taskDir.name), year, '01', '01');
1639
+ }
1640
+ }
1641
+ return results;
1642
+ }
1643
+
1644
+ function searchArchivedText(keyword) {
1645
+ const results = [];
1646
+ const archived = walkArchived();
1647
+ const kw = keyword.toLowerCase();
1648
+ for (const task of archived) {
1649
+ const files = ['PLAN.md', 'PROGRESS.md', 'PROBLEM.md', 'REFERENCES.md', 'STATE.json'];
1650
+ const hits = [];
1651
+ for (const f of files) {
1652
+ const fp = path.join(task.fullPath, f);
1653
+ if (!fs.existsSync(fp)) continue;
1654
+ const content = fs.readFileSync(fp, 'utf8');
1655
+ const lines = content.split('\n');
1656
+ for (let i = 0; i < lines.length; i++) {
1657
+ if (lines[i].toLowerCase().includes(kw)) {
1658
+ hits.push({ file: f, line: i + 1, snippet: lines[i].trim().slice(0, 120) });
1659
+ }
1660
+ }
1661
+ }
1662
+ if (hits.length > 0) {
1663
+ results.push({ id: task.id, archivePath: task.archivePath, hits });
1664
+ }
1665
+ }
1666
+ return results;
1667
+ }
1668
+
1669
+ function runHistoryList() {
1670
+ const year = flagValue('--year');
1671
+ const month = flagValue('--month');
1672
+ const tasks = walkArchived({ year, month });
1673
+ const payload = {
1674
+ ok: true,
1675
+ command: 'history/list',
1676
+ count: tasks.length,
1677
+ tasks: tasks.map(t => ({
1678
+ id: t.id,
1679
+ archivePath: t.archivePath,
1680
+ mtime: t.mtime,
1681
+ status: t.state?.status || '-',
1682
+ phase: t.state?.phase || '-',
1683
+ })),
1684
+ };
1685
+ finish(payload, 0);
1686
+ }
1687
+
1688
+ function runHistorySearch() {
1689
+ const keyword = args[2] || flagValue('--keyword');
1690
+ if (!keyword) {
1691
+ finish({ ok: false, command: 'history/search', errors: ['history search requires <keyword>'], warnings: [], count: 0, results: [] }, 1);
1692
+ return;
1693
+ }
1694
+ const results = searchArchivedText(keyword);
1695
+ finish({
1696
+ ok: true,
1697
+ command: 'history/search',
1698
+ keyword,
1699
+ count: results.length,
1700
+ results,
1701
+ }, 0);
1702
+ }
1703
+
1704
+ function runHistoryLoad() {
1705
+ const taskId = args[2];
1706
+ if (!taskId) {
1707
+ finish({ ok: false, command: 'history/load', errors: ['history load requires <task-id>'], warnings: [] }, 1);
1708
+ return;
1709
+ }
1710
+ const tasks = walkArchived({ taskId });
1711
+ if (tasks.length === 0) {
1712
+ finish({ ok: false, command: 'history/load', errors: [`Task "${taskId}" not found in archive`], warnings: [] }, 1);
1713
+ return;
1714
+ }
1715
+ const task = tasks[0];
1716
+ const result = {
1717
+ id: task.id,
1718
+ archivePath: task.archivePath,
1719
+ mtime: task.mtime,
1720
+ state: task.state,
1721
+ files: {},
1722
+ };
1723
+ for (const f of ['PLAN.md', 'PROGRESS.md', 'PROBLEM.md', 'REFERENCES.md']) {
1724
+ const fp = path.join(task.fullPath, f);
1725
+ if (fs.existsSync(fp)) {
1726
+ result.files[f] = fs.readFileSync(fp, 'utf8');
1727
+ }
1728
+ }
1729
+ finish({ ok: true, command: 'history/load', task: result }, 0);
1730
+ }
1731
+
1732
+ function runHistoryDelete() {
1733
+ const taskId = args[2];
1734
+ if (!taskId) {
1735
+ finish({ ok: false, command: 'history/delete', errors: ['history delete requires <task-id>'], warnings: [] }, 1);
1736
+ return;
1737
+ }
1738
+ const tasks = walkArchived({ taskId });
1739
+ if (tasks.length === 0) {
1740
+ finish({ ok: false, command: 'history/delete', errors: [`Task "${taskId}" not found in archive`], warnings: [] }, 1);
1741
+ return;
1742
+ }
1743
+ const task = tasks[0];
1744
+ if (hasFlag('--apply')) {
1745
+ // Audit: write a deletion record before removing
1746
+ const auditPath = path.join(archiveDir, '_deleted.jsonl');
1747
+ const auditEntry = JSON.stringify({
1748
+ id: task.id,
1749
+ archivePath: task.archivePath,
1750
+ deletedAt: new Date().toISOString(),
1751
+ deletedBy: process.env.USER || process.env.USERNAME || 'unknown',
1752
+ });
1753
+ fs.appendFileSync(auditPath, auditEntry + '\n');
1754
+ fs.rmSync(task.fullPath, { recursive: true, force: true });
1755
+ // Clean up empty day/month/year dirs
1756
+ const dayPath = path.dirname(task.fullPath);
1757
+ const monthPath = path.dirname(dayPath);
1758
+ const yearPath = path.dirname(monthPath);
1759
+ for (const dir of [dayPath, monthPath, yearPath]) {
1760
+ try {
1761
+ if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
1762
+ } catch {}
1763
+ }
1764
+ finish({
1765
+ ok: true,
1766
+ command: 'history/delete',
1767
+ action: 'deleted',
1768
+ taskId: task.id,
1769
+ archivePath: task.archivePath,
1770
+ auditSaved: true,
1771
+ }, 0);
1772
+ } else {
1773
+ finish({
1774
+ ok: true,
1775
+ command: 'history/delete',
1776
+ dryRun: true,
1777
+ taskId: task.id,
1778
+ archivePath: task.archivePath,
1779
+ message: 'Dry run. Use --apply to delete.',
1780
+ }, 0);
1781
+ }
1782
+ }
1783
+
1784
+ // ---- dispatch ----
876
1785
  if (command === 'help' || hasFlag('--help') || hasFlag('-h')) usage();
877
1786
  if (command === 'list') runList();
878
1787
  if (command === 'validate') runValidate();
@@ -880,6 +1789,20 @@ if (command === 'reconcile') runReconcile();
880
1789
  if (command === 'set-active') runSetActive();
881
1790
  if (command === 'transition') runTransition();
882
1791
  if (command === 'archive') runArchive();
1792
+ if (command === 'record') runRecord();
1793
+ if (command === 'open') runOpen();
1794
+ if (command === 'history') {
1795
+ const sub = args[1];
1796
+ if (sub === 'list') runHistoryList();
1797
+ else if (sub === 'search') runHistorySearch();
1798
+ else if (sub === 'load') runHistoryLoad();
1799
+ else if (sub === 'delete') runHistoryDelete();
1800
+ else finish({
1801
+ ok: false, command: 'history',
1802
+ errors: [`Unknown history subcommand "${sub}". Try: list, search <kw>, load <id>, delete <id>`],
1803
+ warnings: [],
1804
+ }, 1);
1805
+ }
883
1806
 
884
1807
  finish({
885
1808
  ok: false,