@khoavu882/doflow 2.0.0 → 2.0.2

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 (176) hide show
  1. package/README.md +4 -5
  2. package/bin/doflow.js +1 -1
  3. package/core/.claude-plugin/plugin.json +1 -1
  4. package/core/.codex-plugin/plugin.json +1 -1
  5. package/core/.plugin/plugin.json +1 -1
  6. package/core/harnesses/claude/hooks/mcp-tool-guard.sh +4 -1
  7. package/core/harnesses/claude/hooks/pre-implement-gate.sh +4 -1
  8. package/core/harnesses/claude/hooks/session-end.sh +21 -1
  9. package/core/harnesses/claude/settings/settings.json +8 -9
  10. package/core/harnesses/codex/hooks/hooks.json +0 -10
  11. package/core/harnesses/codex/hooks/mcp-tool-guard.sh +4 -1
  12. package/core/harnesses/codex/hooks/pre-implement-gate.sh +4 -1
  13. package/core/harnesses/codex/hooks/session-end.impl.sh +21 -1
  14. package/core/harnesses/copilot/hooks/doflow-lib.sh +4 -3
  15. package/core/harnesses/copilot/hooks/doflow.json +0 -6
  16. package/core/harnesses/kiro/agents/doflow.json +0 -10
  17. package/core/harnesses/kiro/hooks/doflow.json +0 -10
  18. package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +7 -3
  19. package/core/harnesses/kiro/hooks/pre-implement-gate.sh +7 -3
  20. package/core/harnesses/pi/extensions/doflow-guard.js +3 -2
  21. package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +1 -1
  22. package/core/harnesses/shared/hooks/adapters/antigravity.js +1 -1
  23. package/core/harnesses/shared/hooks/bridge/bridge.sh +35 -25
  24. package/core/harnesses/shared/hooks/bridge/envelope.jq +1 -1
  25. package/core/harnesses/shared/hooks/bridge/respond.sh +6 -0
  26. package/core/harnesses/shared/hooks/policies/capture-failure.sh +1 -1
  27. package/core/harnesses/shared/hooks/policies/compact-text.sh +8 -0
  28. package/core/harnesses/shared/hooks/policies/deny-json.sh +23 -1
  29. package/core/harnesses/shared/hooks/policies/mcp-policy.conf +1 -1
  30. package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +4 -6
  31. package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +20 -21
  32. package/core/harnesses/shared/hooks/policies/stop-check.sh +7 -6
  33. package/core/harnesses/shared/hooks/policies/subagent-audit.sh +26 -2
  34. package/core/harnesses/shared/hooks/stream-hook-runner.js +11 -12
  35. package/core/registry/contracts.json +1 -1
  36. package/core/registry/contribution-points.json +5 -2
  37. package/core/registry/gates.json +5 -5
  38. package/core/registry/harnesses.json +9 -9
  39. package/core/registry/intake.json +53 -0
  40. package/core/registry/lifecycle.json +176 -503
  41. package/core/registry/package-templates/intake-category/files/category.json +8 -0
  42. package/core/registry/package-templates/intake-category/template.json +4 -0
  43. package/core/registry/packages/doflow/doflow-package.json +2 -2
  44. package/core/registry/rules.json +8 -8
  45. package/core/registry/settings.json +12 -16
  46. package/core/registry/storage.json +9 -2
  47. package/core/registry/telemetry.json +1 -1
  48. package/core/registry/verification.json +0 -5
  49. package/core/shared/guidance/references/ARTIFACT_FORMAT.md +7 -0
  50. package/core/shared/guidance/references/DOFLOW_CHAIN.md +4 -4
  51. package/core/shared/guidance/references/ORCHESTRATION.md +2 -0
  52. package/core/shared/guidance/references/WORKFLOW_HANDOFF.md +19 -1
  53. package/core/shared/guidance/rules/RULES.md +2 -2
  54. package/core/shared/scripts/doflow/bash/do-git-state.sh +12 -4
  55. package/core/shared/scripts/doflow/bash/do-parallel-check.sh +86 -33
  56. package/core/shared/scripts/doflow/bash/do-review-package.sh +36 -2
  57. package/core/shared/scripts/doflow/bash/validate-artifacts.sh +1 -1
  58. package/core/shared/scripts/doflow/bin/doflow-run +23 -2
  59. package/core/shared/skills/do/SKILL.md +3 -3
  60. package/core/shared/skills/do-brainstorm/SKILL.md +26 -24
  61. package/core/shared/skills/do-brainstorm/references/handoff_branches.md +2 -1
  62. package/core/shared/skills/do-code-review/SKILL.md +7 -4
  63. package/core/shared/skills/do-design/SKILL.md +11 -5
  64. package/core/shared/skills/do-design/references/data_model_example.md +167 -0
  65. package/core/shared/skills/do-diagnose/SKILL.md +4 -2
  66. package/core/shared/skills/do-document/SKILL.md +1 -0
  67. package/core/shared/skills/do-document/references/embedded-research.md +1 -1
  68. package/core/shared/skills/do-execute-plan/SKILL.md +4 -2
  69. package/core/shared/skills/do-execute-plan/references/run_position.md +3 -1
  70. package/core/shared/skills/do-execute-plan/references/scaffold.md +1 -1
  71. package/core/shared/skills/do-flow/SKILL.md +4 -4
  72. package/core/shared/skills/do-flow/references/task_classes.md +5 -5
  73. package/core/shared/skills/do-implement/SKILL.md +9 -7
  74. package/core/shared/skills/do-plan/SKILL.md +9 -6
  75. package/core/shared/templates/doflow/data-model-template.md +136 -102
  76. package/core/shared/workflows/do-flow-chain.md +2 -0
  77. package/package.json +1 -1
  78. package/src/adapters/antigravity/index.js +54 -12
  79. package/src/adapters/claude/index.js +23 -2
  80. package/src/adapters/codex/agents.js +15 -1
  81. package/src/adapters/codex/config.js +5 -2
  82. package/src/adapters/codex/hooks.js +39 -3
  83. package/src/adapters/codex/index.js +13 -0
  84. package/src/adapters/copilot/index.js +11 -4
  85. package/src/adapters/copy-tree.js +8 -2
  86. package/src/adapters/created-file.js +41 -0
  87. package/src/adapters/mcp-entries.js +11 -1
  88. package/src/adapters/opencode/index.js +32 -5
  89. package/src/adapters/pi/index.js +15 -4
  90. package/src/adapters/pi/mcp.js +44 -18
  91. package/src/cli/commands/remove.js +3 -1
  92. package/src/cli/commands/status.js +1 -1
  93. package/src/cli/commands/update.js +7 -3
  94. package/src/cli/index.js +43 -19
  95. package/src/cli/runtime-commands.js +13 -5
  96. package/src/cli/shared.js +5 -3
  97. package/src/helper/marker-merge.js +11 -7
  98. package/src/install/backup-set.js +7 -3
  99. package/src/install/backup.js +13 -7
  100. package/src/install/context.js +2 -1
  101. package/src/install/manifest.js +35 -11
  102. package/src/install/paths.js +38 -0
  103. package/src/install/targets.js +11 -2
  104. package/src/lifecycle/coverage.js +1 -30
  105. package/src/lifecycle/hook-tier.js +2 -2
  106. package/src/lifecycle/index.js +80 -3
  107. package/src/lifecycle/retired-harnesses.js +16 -1
  108. package/src/lifecycle/view.js +3 -2
  109. package/src/registry/index.js +21 -4
  110. package/src/release/docs-blocks.js +37 -53
  111. package/src/runtime/advice/doctor.js +2 -2
  112. package/src/runtime/brief-tools.js +2 -1
  113. package/src/runtime/capability-router.js +26 -3
  114. package/src/runtime/context/pointer.js +84 -17
  115. package/src/runtime/context-pack.js +52 -9
  116. package/src/runtime/dispatch/integrate.js +27 -8
  117. package/src/runtime/dispatch/loop.js +7 -0
  118. package/src/runtime/dispatch/verify-run.js +9 -3
  119. package/src/runtime/evidence-ledger.js +18 -0
  120. package/src/runtime/feature-pointer.js +10 -1
  121. package/src/runtime/health.js +68 -5
  122. package/src/runtime/implementation-gate.js +14 -6
  123. package/src/runtime/intake/engine.js +679 -0
  124. package/src/runtime/intake/index.js +136 -0
  125. package/src/runtime/intake/manifest.js +140 -0
  126. package/src/runtime/intake/taxonomy.js +206 -0
  127. package/src/runtime/knowledge/code-retrieval.js +11 -5
  128. package/src/runtime/knowledge/semble-provider.js +12 -2
  129. package/src/runtime/lifecycle/cli.js +76 -6
  130. package/src/runtime/lifecycle/followup.js +32 -6
  131. package/src/runtime/lifecycle/overview.js +34 -3
  132. package/src/runtime/lifecycle/release.js +16 -3
  133. package/src/runtime/lifecycle/retention.js +12 -15
  134. package/src/runtime/lifecycle/store-upkeep.js +13 -3
  135. package/src/runtime/mask.js +3 -3
  136. package/src/runtime/mcp-consultations.js +11 -5
  137. package/src/runtime/packages/points.js +10 -0
  138. package/src/runtime/research-request.js +3 -1
  139. package/src/runtime/review/command.js +3 -7
  140. package/src/runtime/review/start.js +24 -10
  141. package/src/runtime/roadmap/attendance.js +137 -0
  142. package/src/runtime/roadmap/gates.js +6 -3
  143. package/src/runtime/roadmap/loop.js +127 -33
  144. package/src/runtime/roadmap/merge.js +17 -8
  145. package/src/runtime/roadmap/rows.js +15 -5
  146. package/src/runtime/roadmap/seed.js +153 -41
  147. package/src/runtime/roadmap/worktree-cleanup.js +147 -0
  148. package/src/runtime/settings/files.js +3 -1
  149. package/src/runtime/settings/prepared-run.js +12 -2
  150. package/src/runtime/settings/registry.js +1 -1
  151. package/src/runtime/settings/resolve.js +13 -1
  152. package/src/runtime/settings/write.js +73 -2
  153. package/src/runtime/slots.js +1 -1
  154. package/src/runtime/storage/registry.js +2 -2
  155. package/src/runtime/storage/roots.js +4 -18
  156. package/src/runtime/storage/rules.js +22 -46
  157. package/src/runtime/storage/work-status.js +14 -4
  158. package/src/runtime/task-classifier.js +39 -4
  159. package/src/runtime/task-scope.js +17 -2
  160. package/src/runtime/task-state.js +13 -7
  161. package/src/runtime/trace/analyze.js +4 -2
  162. package/src/runtime/trace/indicators.js +25 -20
  163. package/src/runtime/trace/spans.js +4 -5
  164. package/src/runtime/verb-view.js +268 -0
  165. package/src/runtime/verification/engine.js +0 -45
  166. package/src/runtime/worker/command.js +4 -2
  167. package/src/runtime/worker/launch.js +5 -37
  168. package/src/runtime/worker/readiness.js +10 -6
  169. package/src/runtime/worker/supervisor.js +18 -6
  170. package/src/runtime/workflow-orchestrator.js +39 -7
  171. package/core/harnesses/claude/hooks/pre-bash-guard.sh +0 -10
  172. package/core/harnesses/codex/hooks/pre-bash-guard.sh +0 -10
  173. package/core/harnesses/copilot/hooks/doflow-pre-bash-guard.sh +0 -9
  174. package/core/harnesses/kiro/hooks/pre-bash-guard.sh +0 -25
  175. package/core/harnesses/shared/hooks/policies/blocked-patterns.conf +0 -105
  176. package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +0 -579
package/src/cli/index.js CHANGED
@@ -53,6 +53,16 @@ function assertNoBackupRequiresForce(o ) {
53
53
  }
54
54
  }
55
55
 
56
+ /**
57
+ * Reports an argument error and exits. `review` answers in its own JSON usage shape when `--json` is anywhere in the
58
+ * arguments (before or after the bad one); every other verb prints the plain line on stderr.
59
+ */
60
+ function argError(o , argv , message , code ) {
61
+ if (o.cmd === 'review' && argv.includes('--json')) console.log(JSON.stringify({ ok: false, error: 'usage', message }));
62
+ else console.error(`doflow: ${message}`);
63
+ process.exit(code);
64
+ }
65
+
56
66
  function parseArgs(argv ) {
57
67
  const o = { cmd: null, positional: [], targets: [], mcp: null, dryRun: false, force: false, adopt: false,
58
68
  otlpHttp: false,
@@ -68,7 +78,7 @@ function parseArgs(argv ) {
68
78
  if (a.startsWith('-')) o.flagsGiven.push(a);
69
79
  // `--commit` is the roadmap verb's own flag: anywhere else it must reach the default branch and get
70
80
  // that verb's unknown-flag answer, as it did before it was added. '' matches no case.
71
- switch (a === '--commit' && o.cmd !== 'roadmap' ? '' : a) {
81
+ switch (a === '--commit' && o.cmd !== 'roadmap' ? '' : a.startsWith('--action=') ? '--action' : a) {
72
82
  case '-h': case '--help': o.help = true; break;
73
83
  case '-v': o.version = true; break;
74
84
  case '--version':
@@ -92,7 +102,7 @@ function parseArgs(argv ) {
92
102
  case '--':
93
103
  if (o.cmd === 'slot') { o.slotCommand = argv.slice(i + 1); i = argv.length; break; }
94
104
  if (o.cmd === 'plan-check') break;
95
- console.error(unknownFlagMessage(a)); process.exit(unknownFlagExit(o.cmd));
105
+ argError(o, argv, unknownFlagMessage(a), unknownFlagExit(o.cmd));
96
106
  break;
97
107
  // worker --action run|describe|select: the isolated-edit recipe and baseline (082 IC-014).
98
108
  case '--edit': o.edit = true; break;
@@ -148,10 +158,12 @@ function parseArgs(argv ) {
148
158
  o.tools = val.split(',').map((s) => s.trim()).filter(Boolean); i++; break;
149
159
  }
150
160
  case '--action': {
151
- const val = argv[i + 1];
161
+ // `--action <value>` or `--action=<value>`.
162
+ const inline = a.startsWith('--action=');
163
+ const val = inline ? a.slice('--action='.length) : argv[i + 1];
152
164
  // `review`, `protocol`, `config`, `primary` and `package` answer a missing value as usage, exit 2; other verbs keep exit 1.
153
- if (val === undefined || val.startsWith('-')) { console.error(`doflow: ${a} requires a value`); process.exit(o.cmd === 'review' || o.cmd === 'protocol' || o.cmd === 'config' || o.cmd === 'primary' || o.cmd === 'fleet' || o.cmd === 'transcript' || o.cmd === 'package' ? 2 : 1); }
154
- o.action = val; o.actionGiven = true; i++; break;
165
+ if (val === undefined || val === '' || val.startsWith('-')) argError(o, argv, '--action requires a value', o.cmd === 'review' || o.cmd === 'protocol' || o.cmd === 'config' || o.cmd === 'primary' || o.cmd === 'fleet' || o.cmd === 'transcript' || o.cmd === 'package' || o.cmd === 'worktree' ? 2 : 1);
166
+ o.action = val; o.actionGiven = true; if (!inline) i++; break;
155
167
  }
156
168
  case '--task-class': {
157
169
  const val = argv[i + 1];
@@ -236,7 +248,7 @@ function parseArgs(argv ) {
236
248
  if (runtime !== null) { i = runtime; break; }
237
249
  // slot, primary, fleet and transcript report an unknown flag as their own usage error (exit 2, 115 IC-008, 120 IC-008).
238
250
  if (a.startsWith('-') && (o.cmd === 'slot' || o.cmd === 'primary' || o.cmd === 'fleet' || o.cmd === 'transcript')) (o.extraFlags = o.extraFlags || []).push(a);
239
- else if (a.startsWith('-') && o.cmd !== 'plan-check') { console.error(unknownFlagMessage(a)); process.exit(unknownFlagExit(o.cmd)); }
251
+ else if (a.startsWith('-') && o.cmd !== 'plan-check') argError(o, argv, unknownFlagMessage(a), unknownFlagExit(o.cmd));
240
252
  else if (!o.cmd) o.cmd = a;
241
253
  else o.positional.push(a);
242
254
  }
@@ -248,18 +260,18 @@ function parseArgs(argv ) {
248
260
 
249
261
  /** Verbs that answer an unrecognised flag as a usage error (exit 2); every other verb keeps exit 1. */
250
262
  function unknownFlagExit(cmd ) {
251
- return cmd === 'protocol' || cmd === 'config' || cmd === 'rules' || cmd === 'review' ? 2 : 1;
263
+ return cmd === 'protocol' || cmd === 'config' || cmd === 'rules' || cmd === 'review' || cmd === 'worktree' || cmd === 'attendance' ? 2 : 1;
252
264
  }
253
265
 
254
266
  /** Longest part of an unrecognised argument echoed back; the rest is cut and marked. */
255
267
  const UNKNOWN_FLAG_ECHO_CHARS = 80;
256
268
 
257
- /** The unknown-flag error: the argument is untrusted text, so it is made print-safe (no control
269
+ /** The unknown-flag message (without the `doflow: ` prefix): the argument is untrusted text, so it is made print-safe (no control
258
270
  * characters, one line) and cut to a bounded length (FU-8eqytg). */
259
271
  function unknownFlagMessage(arg ) {
260
272
  const safe = printSafe(String(arg));
261
273
  const shown = safe.length > UNKNOWN_FLAG_ECHO_CHARS ? `${safe.slice(0, UNKNOWN_FLAG_ECHO_CHARS)}...` : safe;
262
- return `doflow: unknown flag '${shown}'`;
274
+ return `unknown flag '${shown}'`;
263
275
  }
264
276
 
265
277
  /** Single-value arguments of the runtime verbs → the option key each one writes. */
@@ -277,6 +289,7 @@ const RUNTIME_STRING_FLAGS = new Map([
277
289
  ['--request', 'request'], // classify: request text for an advisory second opinion
278
290
  ['--request-file', 'requestFile'], // classify: request text read from a file
279
291
  // decision --action add. `--statement`, `--stage`, `--slug` and `--batch` above are shared.
292
+ ['--run-id', 'runId'], // intake --action undo: the sort run to undo (default: the latest)
280
293
  ['--topic', 'topic'], // the subject key a decision is live on
281
294
  ['--channel', 'channel'], // question | gate | prompt | default | resolution
282
295
  ['--supersedes', 'supersedes'], // comma-separated DEC-### ids this decision replaces
@@ -434,8 +447,9 @@ const RUNTIME_STRING_FLAGS = new Map([
434
447
  ['--dir', 'dir'], // package --action new: authored package target directory
435
448
  ['--name', 'name'], // slot: the slot's name, 1 to 64 characters of a-z, 0-9 and - (115 IC-008)
436
449
  ['--label', 'label'], // slot --action run: the holder's label, at most 80 characters (115 IC-008)
437
- ['--cursor', 'cursor'], // transcript --action read: the cursor an earlier read returned (093 IC-022)
438
- ['--limit', 'limit'], // transcript --action read: at most this many events, validated in the verb (093 IC-022)
450
+ ['--cursor', 'cursor'], // transcript --action read: the cursor an earlier read returned (093 IC-022) · followup --action list: the nextCursor of an earlier page (137 IC-004)
451
+ ['--limit', 'limit'], // transcript --action read: at most this many events, validated in the verb (093 IC-022) · followup --action list: the page size (137 IC-004)
452
+ ['--view', 'view'], // followup --action list, lifecycle --action overview, classify: brief or full, validated in the verb (137 IC-001)
439
453
  ['--window', 'window'], // transcript --action stats: minutes, 1 to 60, validated in the verb (093 IC-023)
440
454
  ['--port', 'port'], // ui: the Console's port, 0 to 65535, checked in commands/ui.ts (092 IC-008)
441
455
  ]);
@@ -496,14 +510,14 @@ function parseRuntimeFlag(arg , argv , i , o )
496
510
  // `--batch <file|->`. Rejecting it made the documented spelling exit 2 while only `--batch=-`
497
511
  // worked — a help text endorsing a form the parser refuses.
498
512
  const isStdin = value === '-';
499
- if (value === undefined || (value.startsWith('-') && !isStdin)) { console.error(`doflow: ${name} requires a value`); process.exit(2); }
513
+ if (value === undefined || (value.startsWith('-') && !isStdin)) argError(o, argv, `${name} requires a value`, 2);
500
514
  next = i + 1;
501
515
  }
502
- if (value === '') { console.error(`doflow: ${name} requires a value`); process.exit(2); }
516
+ if (value === '') argError(o, argv, `${name} requires a value`, 2);
503
517
 
504
518
  if (RUNTIME_INT_FLAGS.has(name)) {
505
519
  const parsed = parseInt(value, 10);
506
- if (!Number.isFinite(parsed) || parsed < 0) { console.error(`doflow: ${name} expects a non-negative integer, got '${value}'`); process.exit(2); }
520
+ if (!Number.isFinite(parsed) || parsed < 0) argError(o, argv, `${name} expects a non-negative integer, got '${value}'`, 2);
507
521
  o[key] = parsed;
508
522
  } else if (RUNTIME_LIST_FLAGS.has(name)) {
509
523
  // --exclude and --exclude-family both accept comma lists or repeated flags. Keep --exclude's
@@ -546,7 +560,7 @@ Commands:
546
560
  context-grant Grant one compacted summary to a launched run and lineage (--summary, --to-run, --to-lineage)
547
561
  retrieval-plan Declare a stage's information needs, attempt retrieval, then report every declared item
548
562
  outcome Record a task's terminal state with its basis, or show it (--task-id)
549
- classify Validate a proposed task class and return its workflow (--task-class)
563
+ classify Validate a proposed task class and return its workflow (--task-class [--view brief|full])
550
564
  workflow Resolve a task class to its stages, gates and readiness templates
551
565
  orchestrate Start or resume a run: complete stages, decide gates (--action)
552
566
  research-request Open, list or resolve a feature-stage research question (--task-id)
@@ -577,15 +591,16 @@ Commands:
577
591
  config unset <key> --layer user|project|local [--json] remove the key from that layer's file
578
592
  scaffold Emit the reviewable code scaffold the active feature's artifacts imply
579
593
  decision Register, list or compact the active feature's decisions (--action init|add|list|compact)
580
- followup Record, list, take, settle, promote or report what a feature left unfinished (--action add|list|take|settle|promote|report)
581
- lifecycle Open follow-ups, feature tracking, releases and status for the project (--action overview|init|release|status|merged)
594
+ intake Sort, preview, undo, list or file prompt text for the active feature's additional-context folder (--action sort|preview|undo|list|file-prompt)
595
+ followup Record, list, take, settle, promote or report what a feature left unfinished (--action add|list|take|settle|promote|report; list: [--view brief|full] [--cursor <c>] [--limit <n>] [--ids <FU-id,...>])
596
+ lifecycle Open follow-ups, feature tracking, releases and status for the project (--action overview|init|release|status|merged; overview: [--view brief|full])
582
597
  goal Add, check, link, list or close a goal and its checklist (--action add|item|check|link|list|done)
583
598
  failure List, settle or switch off the failures DoFlow captured on this machine (--action list|settle|capture)
584
599
  (a value beginning with '-' must be written --flag=value)
585
600
  roadmap Session-driven autopilot runner (--action render|status|next|audit|init-policy [--check] [--row <id>] [--run <id>]).
586
601
  roadmap --action init-policy [--commit] writes the gate policy for review, or with --commit commits it onto local develop when develop is not checked out in a worktree (otherwise it prints the commands to run there; never pushes);
587
602
  roadmap seed --action check|apply|amend --draft <path> [--json] validates and writes new rows;
588
- rows written while autopilot is on land proposed; roadmap promote <id...> [--json] (person, autopilot off) makes them ready
603
+ rows written while autopilot is on land proposed; roadmap promote <id...> [--json] (person: asks y/N on the terminal) makes them ready
589
604
  plan-check Check a plan's task lines the way the task-attempt ledger reads them: plan-check <plan path> [--json]
590
605
  review Validate a feature's review/findings.json; print errors, advisories and counts (exit 0 valid, 1 invalid, 2 usage).
591
606
  review --action check [--slug <name> | --path <findings.json>] [--json]
@@ -604,6 +619,10 @@ Commands:
604
619
  transcript Read an agent's recent events, or the project's traffic (read-only, run in a project).
605
620
  transcript --action read --agent <key> [--cursor <c>] [--limit <n>] [--json] masked events and DoFlow's record events (exit 0, 1 agent-not-in-project or session-not-in-project, 2 usage)
606
621
  transcript --action stats [--window <minutes 1..60>] [--json] events per minute, tool calls, tokens, who works with whom (exit 0, 2 usage)
622
+ worktree Remove a merged feature's worktree and branch, never by force (--action cleanup).
623
+ worktree --action cleanup --slug <row slug> [--json] copies agent-docs/doflow/<slug> back to the main checkout first (exit 0 cleaned, 1 kept or lease-held with the reason, 2 usage)
624
+ attendance Say whether a person is watching a stage (read-only).
625
+ attendance --task-id <row slug> [--run <rl-id>] [--json] prints "unattended <source>" or "attended <reason>" (exit 0, 2 usage)
607
626
  package List the contribution points a package may fill, check a package directory, show the packages, add and update a local package, or turn it on and off.
608
627
  package --action points [--json]
609
628
  package --action check --path <dir> [--json] (read-only; exit 0 clean, 1 findings, 2 usage)
@@ -673,6 +692,7 @@ Runtime verb arguments (accept --flag value or --flag=value):
673
692
  failure: list|settle|capture
674
693
  roadmap: render|status|next|audit|init-policy (seed: check|apply|amend; promote <id...>)
675
694
  slot: run|list|peek|release
695
+ worktree: cleanup
676
696
  models: sync|list|describe
677
697
  review: check|start
678
698
  protocol: list|resolve
@@ -700,7 +720,9 @@ Runtime verb arguments (accept --flag value or --flag=value):
700
720
  disposition in the JSON; 2 usage or an unreadable store or plan)
701
721
  --rationale, --proposed-by, --calling-skill classify
702
722
  --request <text>, --request-file <file> classify (mutually exclusive; optional second opinion)
703
- --intent, --query, --check route
723
+ --view brief|full followup --action list (default brief), lifecycle --action overview, classify (default full); brief drops detail, full is the whole answer
724
+ --cursor <c>, --limit <n> followup --action list (the cursor is opaque: pass back a page's nextCursor); also transcript --action read
725
+ --intent, --query, --check, --harness route
704
726
  --statement, --claim-id,
705
727
  --evidence-id, --relation claim
706
728
  --kind, --provenance, --provider,
@@ -772,6 +794,8 @@ Runtime verb arguments (accept --flag value or --flag=value):
772
794
  --tag, --confirm, --feature,
773
795
  --exclude (repeatable), --reason lifecycle (release, merged)
774
796
  --fp, --as, --reason, --set, --all failure (list, settle, capture)
797
+ --run-id <id> intake --action undo (default: the latest sort run that has a move to undo)
798
+ --stdin | --file <path>, --stage intake --action file-prompt (the text, unchanged, from exactly one of --stdin or --file; --stage names the filing stage)
775
799
  --harness, --need (repeatable),
776
800
  --model-check, --budget-tokens,
777
801
  --task, --token, --prompt-file,
@@ -54,6 +54,7 @@ const { handleLeakScanCommand } = require('../runtime/leak-scan.js');
54
54
  const { handleRecoverCommand } = require('../runtime/recovery.js');
55
55
  const { handleScaffoldCommand } = require('../runtime/scaffold/generate.js');
56
56
  const { handleDecisionCommand, isReservedChannel, CHANNEL_RESERVED } = require('../runtime/decision-register.js');
57
+ const { handleIntakeCommand } = require('../runtime/intake/index.js');
57
58
  const { handleFollowupCommand, handleLifecycleCommand, handleGoalCommand } = require('../runtime/lifecycle/cli.js');
58
59
  const { projectRoot: lifecycleProjectRoot } = require('../runtime/lifecycle/root.js');
59
60
  const { upkeepAfterVerb } = require('../runtime/storage/upkeep.js');
@@ -66,6 +67,8 @@ const { handleProtocolCommand } = require('../runtime/review/protocol-command.js
66
67
  const { handleRulesCommand } = require('../runtime/rules-report.js');
67
68
  const { handlePackageCommand } = require('../runtime/packages/command.js');
68
69
  const { handleModelsCommand } = require('../runtime/models/command.js');
70
+ const { handleWorktreeCommand } = require('../runtime/roadmap/worktree-cleanup.js');
71
+ const { handleAttendanceCommand } = require('../runtime/roadmap/attendance.js');
69
72
  // `plan-check` is read-only (115 IC-001): no upkeep, which would write `.gitignore` and `upkeep-due`.
70
73
  // `review` writes only its one telemetry line (095 IC-003).
71
74
  // `protocol` only prints the registry and the resolved protocol (119 IC-004), so it has nothing to keep up.
@@ -73,9 +76,10 @@ const { handleModelsCommand } = require('../runtime/models/command.js');
73
76
  // `primary` only prints the resolved main session (120 IC-008) and writes nothing.
74
77
  // `fleet` only lists the projects this machine knows from two read-only stores (093 IC-019) and writes nothing.
75
78
  // `transcript` only reads agents' transcripts and DoFlow's own records and prints them (093 IC-022, IC-023); it writes nothing.
79
+ // `attendance` is read-only; `worktree --action cleanup` removes a merged feature's worktree under the roadmap lease and writes no DoFlow state, so neither runs upkeep.
76
80
  // `package` stays here (122 IC-012): `new` writes only the target folder; `add --plan`, `update --plan` and `remove` write the store and lock and run upkeep (see `runsUpkeep`).
77
81
  // `slot` is not here: `run`, `list` and `release` write the slot files and run upkeep; only `slot --action peek` is read-only and skips it (093 IC-018 b, see `runsUpkeep`).
78
- const NO_UPKEEP = new Set(['storage', 'config', 'plan-check', 'review', 'protocol', 'rules', 'primary', 'package', 'models', 'fleet', 'transcript']);
82
+ const NO_UPKEEP = new Set(['storage', 'config', 'plan-check', 'review', 'protocol', 'rules', 'primary', 'package', 'models', 'fleet', 'transcript', 'worktree', 'attendance']);
79
83
  const runsUpkeep = (o ) => (!NO_UPKEEP.has(o.cmd) && !(o.cmd === 'slot' && o.actionGiven && o.action === 'peek'))
80
84
  || (o.cmd === 'package' && ((typeof o.planId === 'string' && (o.action === 'add' || o.action === 'update')) || o.action === 'remove'));
81
85
  // Required on first use, so a CLI whose failure modules cannot be loaded still runs every other verb.
@@ -378,10 +382,12 @@ function dispatchRuntimeCommandInner(o , moment ) {
378
382
  // The feature is resolved from the working directory (or --slug) by the same resolver
379
383
  // `scaffold` uses; the flags are the caller's own statements, validated by the register module.
380
384
  case 'decision': return handleDecisionCommand({ action: o.action, projectRoot: evidenceRoot(o), slug: o.slug, json: o.json, flags: { topic: o.topic, statement: o.statement, channel: o.channel, stage: o.stage, rationale: o.rationale, supersedes: o.supersedes, refs: o.refs, source: o.source, batch: o.batchPath, all: o.all } });
385
+ // 136: the feature is resolved the way `decision` resolves it; the engine moves files only inside its additional-context folder.
386
+ case 'intake': return handleIntakeCommand({ action: o.action, projectRoot: evidenceRoot(o), slug: o.slug, json: o.json, flags: { runId: o.runId, stdin: o.stdin, file: o.file, stage: o.stage } });
381
387
  // Feature 046: the project's follow-up store lives at the repository root (never `-g`, which
382
388
  // the handlers refuse), so the working directory is the starting point, not a scope switch.
383
- case 'followup': return handleFollowupCommand({ action: o.actionGiven ? o.action : undefined, cwd: evidenceRoot(o), global: o.global, slug: o.slug, json: o.json, flags: { statement: o.statement, stage: o.stage, source: o.source, taskClass: o.taskClass, taskId: o.taskId, release: o.release, batch: o.batchPath, channel: o.channel, state: o.state, ids: o.ids, as: o.as, reason: o.reason, evidence: o.evidence, title: o.title, file: o.file, stdin: o.stdin, text: o.text, feature: o.feature, tag: o.tag, confirm: o.confirm, exclude: o.exclude } });
384
- case 'lifecycle': return handleLifecycleCommand({ action: o.actionGiven ? o.action : undefined, cwd: evidenceRoot(o), global: o.global, slug: o.slug, json: o.json, flags: { take: o.take, intent: o.intent, goal: o.goal, maintain: o.maintain, since: o.since, tag: o.tag, confirm: o.confirm, feature: o.feature, exclude: o.exclude, reason: o.reason, channel: o.channel } });
389
+ case 'followup': return handleFollowupCommand({ action: o.actionGiven ? o.action : undefined, cwd: evidenceRoot(o), global: o.global, slug: o.slug, json: o.json, flags: { statement: o.statement, stage: o.stage, source: o.source, taskClass: o.taskClass, taskId: o.taskId, release: o.release, batch: o.batchPath, channel: o.channel, state: o.state, ids: o.ids, view: o.view, cursor: o.cursor, limit: o.limit, as: o.as, reason: o.reason, evidence: o.evidence, title: o.title, file: o.file, stdin: o.stdin, text: o.text, feature: o.feature, tag: o.tag, confirm: o.confirm, exclude: o.exclude } });
390
+ case 'lifecycle': return handleLifecycleCommand({ action: o.actionGiven ? o.action : undefined, cwd: evidenceRoot(o), global: o.global, slug: o.slug, json: o.json, flags: { view: o.view, take: o.take, intent: o.intent, goal: o.goal, maintain: o.maintain, since: o.since, tag: o.tag, confirm: o.confirm, feature: o.feature, exclude: o.exclude, reason: o.reason, channel: o.channel } });
385
391
  case 'goal': return handleGoalCommand({ action: o.actionGiven ? o.action : undefined, cwd: evidenceRoot(o), global: o.global, slug: o.slug, json: o.json, flags: { goal: o.goal, statement: o.statement, item: o.item, text: o.text, evidence: o.evidence, unmet: o.unmet, replace: o.replace, reason: o.reason, channel: o.channel } });
386
392
  // The failure store is per machine, so `-g` changes nothing and the working directory only matters
387
393
  // to `settle --as imported`, which looks for the DoFlow repository from there.
@@ -389,7 +395,7 @@ function dispatchRuntimeCommandInner(o , moment ) {
389
395
  // The rest of design §4.2's Node arm. REPO_ROOT locates the registries that ship with the
390
396
  // package (workflows, capabilities, verification); evidenceRoot(o) locates the caller's own
391
397
  // state and source tree, following the same scope rules as every other command.
392
- case 'classify': return handleClassifyCommand({ taskClass: o.taskClass, rationale: o.rationale, proposedBy: o.proposedBy, callingSkill: o.callingSkill, request: o.request, requestFile: o.requestFile, taskId: o.taskId, projectRoot: evidenceRoot(o), json: o.json });
398
+ case 'classify': return handleClassifyCommand({ taskClass: o.taskClass, rationale: o.rationale, proposedBy: o.proposedBy, callingSkill: o.callingSkill, view: o.view, request: o.request, requestFile: o.requestFile, taskId: o.taskId, projectRoot: evidenceRoot(o), json: o.json });
393
399
  case 'workflow': return handleWorkflowCommand({ taskClass: o.taskClass, json: o.json });
394
400
  case 'orchestrate': return handleOrchestrateCommand({ action: o.action, taskId: o.taskId, taskClass: o.taskClass, stage: o.stage, gate: o.gate, node: o.node, decision: o.decision, policy: o.policy, note: o.note, reason: o.reason, forced: o.forced, verificationPlan: o.verificationPlan, scope: o.scope, invariants: o.invariants, result: o.result, mcpOutcome: o.mcpOutcome, callingSkill: o.callingSkill, slug: o.slug, recordSlug: storeSlug, dryRun: o.dryRun, json: o.json, repoRoot: REPO_ROOT, stateRoot: storeRoot, sourceRoot: evidenceRoot(o), buildAdapters: buildAdapterRegistry });
395
401
  case 'research-request': return handleResearchRequestCommand({ slug: storeSlug, action: o.action === 'status' ? 'list' : o.action, taskId: requireTaskId(o), stageId: o.stageId, question: o.question, reason: o.reason, blocking: o.blocking === undefined ? undefined : o.blocking === 'true' ? true : o.blocking === 'false' ? false : o.blocking, requestId: o.requestId, outcome: o.researchOutcome, claimId: o.claimId, evidenceIds: o.evidenceIds, gap: o.gap, json: o.json, projectRoot: storeRoot, sourceRoot: evidenceRoot(o) });
@@ -409,7 +415,7 @@ function dispatchRuntimeCommandInner(o , moment ) {
409
415
  case 'brief-tools': return handleBriefToolsCommand({ owner: o.owner, tier: o.tier, harness: o.harness, slug: o.slug, headingLevel: o.headingLevel, tasks: o.tasks, json: o.json, repoRoot: REPO_ROOT, projectRoot: storeRoot, buildAdapters: buildAdapterRegistry });
410
416
  case 'advise': return handleAdviseCommand({ action: o.action, actionGiven: o.actionGiven, kind: o.kind, server: o.server, state: o.state, stateFile: o.stateFile, staticAnswer: o.staticAnswer, questionsFile: o.questionsFile, taskId: o.taskId, pickId: o.pickId, actual: o.actual, model: o.model, endpoint: o.endpoint, off: o.off, project: o.project, force: o.force, global: o.global, json: o.json, projectRoot: evidenceRoot(o) });
411
417
  case 'model-role': return handleModelRoleCommand({ role: o.role, exclude: o.exclude, excludeFamilies: o.excludeFamily, json: o.json, repoRoot: REPO_ROOT });
412
- case 'route': return handleRouteCommand({ intent: o.intent, query: o.query, check: o.check, json: o.json, projectRoot: evidenceRoot(o) });
418
+ case 'route': return handleRouteCommand({ intent: o.intent, query: o.query, harness: o.harness, check: o.check, json: o.json, projectRoot: evidenceRoot(o) });
413
419
  case 'claim': return handleClaimCommand({ slug: storeSlug, taskId: requireTaskId(o), action: o.action, statement: o.statement, claimId: o.claimId, evidenceId: o.evidenceId, replacedBy: o.replacedBy, relation: o.relation, role: o.role, json: o.json, stateRoot: storeRoot });
414
420
  case 'context-pack': return handleContextPackCommand({ taskId: requireTaskId(o), taskClass: o.taskClass, objective: o.objective, json: o.json, stateRoot: storeRoot, sourceRoot: evidenceRoot(o), slug: storeSlug, explicitSlug: o.slug || null, mandatoryFile: o.mandatoryFile, harness: o.harness, maxBytes: o.maxBytes, bare: o.bare, noRecord: o.noRecord, reason: o.reason, since: o.since, expand: o.expand });
415
421
  // 078 IC-005 and IC-013: identity is the process environment's alone, never a flag.
@@ -432,6 +438,8 @@ function dispatchRuntimeCommandInner(o , moment ) {
432
438
  case 'fleet': return handleFleetCommand({ action: o.actionGiven ? o.action : undefined, json: o.json, flags: o.flagsGiven, env: process.env, cwd: process.cwd() });
433
439
  case 'transcript': return handleTranscriptCommand({ action: o.actionGiven ? o.action : undefined, agent: o.agent, cursor: o.cursor, limit: o.limit, window: o.window, json: o.json, flags: o.flagsGiven, env: process.env, cwd: process.cwd() });
434
440
  case 'models': return handleModelsCommand({ action: o.actionGiven ? o.action : (o.positional?.[0] || 'list'), positional: o.positional, harness: o.harness, model: o.model, json: o.json, projectRoot: evidenceRoot(o) });
441
+ case 'worktree': return handleWorktreeCommand({ action: o.actionGiven ? o.action : undefined, slug: o.slug, json: o.json, flags: o.flagsGiven, env: process.env, cwd: process.cwd() });
442
+ case 'attendance': return handleAttendanceCommand({ taskId: o.taskId, run: o.run, json: o.json, flags: o.flagsGiven, env: process.env, cwd: process.cwd() });
435
443
  default: console.error(`doflow: unknown command '${o.cmd}'`); process.exit(1);
436
444
  }
437
445
  }
package/src/cli/shared.js CHANGED
@@ -10,7 +10,7 @@ const fs = require('node:fs');
10
10
  const os = require('node:os');
11
11
  const path = require('node:path');
12
12
  const { REPO_ROOT } = require('../helper/repo-root.js');
13
- const { doflowPaths } = require('../install/paths.js');
13
+ const { doflowPaths, canonicalPath, canonicalLedger } = require('../install/paths.js');
14
14
  const { readInstallManifest } = require('../install/manifest.js');
15
15
  const { DEFAULT_BACKUP_RETENTION, formatBytes } = require('../install/backup.js');
16
16
  const { stateRoot, readLedger } = require('../state/index.js');
@@ -64,7 +64,9 @@ function buildAdapterRegistry() {
64
64
 
65
65
  /** Resolve {global, projectRoot} scope options for src/install/targets.js#toolDirs from parsed args. */
66
66
  function scopeOf(o ) {
67
- return { global: o.global, projectRoot: o.positional[0] || '.' };
67
+ // The single place a project path enters the verbs: canonical, so an aliased spelling (macOS
68
+ // /var -> /private/var) and the real one name the same scope, ledger rows and lock.
69
+ return { global: o.global, projectRoot: o.global ? (o.positional[0] || '.') : canonicalPath(o.positional[0] || '.') };
68
70
  }
69
71
 
70
72
  function installPaths(scope ) {
@@ -141,7 +143,7 @@ function scopeSelectionState(scope ) {
141
143
  const scopeRoot = scope.global ? os.homedir() : path.resolve(scope.projectRoot);
142
144
  return {
143
145
  lock: readLock(scope.global ? { scope: 'global', homeDir: scopeRoot } : { scope: 'project', projectRoot: scopeRoot }),
144
- ledger: readLedger(stateRoot({ scope: scope.global ? 'global' : 'project', projectRoot: scopeRoot, homeDir: scopeRoot })),
146
+ ledger: canonicalLedger(readLedger(stateRoot({ scope: scope.global ? 'global' : 'project', projectRoot: scopeRoot, homeDir: scopeRoot })), scopeRoot),
145
147
  manifestServers: readInstallManifest({ scopeRoot })?.mcpServers ?? null,
146
148
  };
147
149
  }
@@ -70,8 +70,7 @@ function mergeMarkedSection(srcAbs , dstAbs , { dryRun = false, co
70
70
  // original file's matching trailing "\n" (if present) must be consumed here —
71
71
  // otherwise a replace with no real change grows the file by one newline
72
72
  // every time it runs (breaks idempotency).
73
- let afterEnd = endIdx + MARKER_END.length;
74
- if (existing[afterEnd] === '\n') afterEnd += 1;
73
+ const afterEnd = afterEndMarker(existing, endIdx + MARKER_END.length);
75
74
  newContent = existing.slice(0, startIdx) + section + existing.slice(afterEnd);
76
75
  }
77
76
  }
@@ -84,10 +83,16 @@ function mergeMarkedSection(srcAbs , dstAbs , { dryRun = false, co
84
83
  return { changed: true };
85
84
  }
86
85
 
86
+ /** `at` moved past the one line break (LF or CRLF) that follows an end marker, if there is one. */
87
+ function afterEndMarker(text , at ) {
88
+ if (text[at] === '\n') return at + 1;
89
+ return text[at] === '\r' && text[at + 1] === '\n' ? at + 2 : at;
90
+ }
91
+
87
92
  /**
88
93
  * Where `text`'s doflow span is: the first of `startMarkers`, the first MARKER_END after it, and no
89
94
  * start marker after that end (a second span is `malformed`). `after` is past the end marker and the
90
- * one '\n' that follows it.
95
+ * one line break (LF or CRLF) that follows it.
91
96
  */
92
97
  function markedSpan(text , startMarkers = [MARKER_START]) {
93
98
  const found = startMarkers.map((marker) => ({ at: text.indexOf(marker), length: marker.length })).filter((item) => item.at !== -1);
@@ -95,10 +100,9 @@ function markedSpan(text , startMarkers = [MARKER_START])
95
100
  const first = found.reduce((a, b) => (b.at < a.at ? b : a));
96
101
  const end = text.indexOf(MARKER_END, first.at + first.length);
97
102
  if (end === -1) return { state: 'malformed' };
98
- let after = end + MARKER_END.length;
99
- if (startMarkers.some((marker) => text.indexOf(marker, after) !== -1)) return { state: 'malformed' };
100
- if (text[after] === '\n') after += 1;
101
- return { state: 'ok', start: first.at, after };
103
+ const closed = end + MARKER_END.length;
104
+ if (startMarkers.some((marker) => text.indexOf(marker, closed) !== -1)) return { state: 'malformed' };
105
+ return { state: 'ok', start: first.at, after: afterEndMarker(text, closed) };
102
106
  }
103
107
 
104
108
  /**
@@ -89,8 +89,9 @@ function addCandidate(candidates , file , harness
89
89
  * lock); an entry at or under one of them is reported in `excluded`, not backed up.
90
90
  * `records` lists DoFlow record files backed up beside those files although an `exclude` root holds
91
91
  * them (the ownership ledger), with no harness; a rollback restores only the ledger rows of the files
92
- * it writes back (backup.ts mergeRestoredLedger). They join only a set that holds some file already,
93
- * so a run that backs up nothing still backs up nothing.
92
+ * it writes back (backup.ts mergeRestoredLedger). They join a set that holds some file already, or one whose
93
+ * plan gives up a retired harness's ledger row (only the ledger is then snapshotted); a run that changes
94
+ * neither backs up nothing.
94
95
  */
95
96
  function backupSetFromPlan({ plan, scope, scopeRoot, exclude = [], records = [], fsImpl = fs } ) {
96
97
  const candidates = new Map();
@@ -131,7 +132,10 @@ function backupSetFromPlan({ plan, scope, scopeRoot, exclude = [], records = [],
131
132
  const excluded = [...mcpOnly]
132
133
  .filter(([target, onlyMcp]) => onlyMcp && !candidates.has(target))
133
134
  .map(([target]) => ({ path: target, reason: 'mcp-entries-only' }));
134
- if (candidates.size) for (const file of records) addCandidate(candidates, file, null, 'record');
135
+ // A retired harness's row given up (every sweep action but `defer`) is a ledger change even when no file
136
+ // changes: the ledger alone is snapshotted then, so a rollback restores the row. Deferred rows stay.
137
+ const givesUpRow = plan.changes.some((change ) => change.retired === true && change.action !== 'defer');
138
+ if (candidates.size || givesUpRow) for (const file of records) addCandidate(candidates, file, null, 'record');
135
139
  return toBackupSet(candidates, { scope, scopeRoot, exclude, excluded, records, fsImpl });
136
140
  }
137
141
 
@@ -591,11 +591,12 @@ function planRestore({ bid, backupRoot, scope, scopeRoot, targets, explicitTarge
591
591
  * staler than the current one; a current row of it the backup lacks stays. A restored row whose file
592
592
  * a live harness now claims is released by the next sweep (claim rule 2), file untouched.
593
593
  */
594
- function mergeRestoredLedger(backup , current , restored , retired = new Set()) {
594
+ function mergeRestoredLedger(backup , current , restored , retired = new Set(), lost = new Set()) {
595
595
  if (!current) return backup;
596
596
  const at = (target ) => typeof target === 'string' && restored.has(path.resolve(target));
597
597
  const whole = (row ) => retired.has(row?.harness);
598
- const back = backup.resources.filter((r ) => at(r.target) || whole(r));
598
+ // A retired row whose file this restore tried and could not write back stays out: it would name a file the next sweep drops as absent.
599
+ const back = backup.resources.filter((r ) => at(r.target) || (whole(r) && !(typeof r.target === 'string' && lost.has(path.resolve(r.target)))));
599
600
  const backKeys = new Set(back.filter(whole).map(ownershipKey));
600
601
  const targets = Object.entries(backup.targets ?? {}).filter(([id]) => retired.has(id));
601
602
  return {
@@ -610,20 +611,23 @@ function mergeRestoredLedger(backup , current , restored , r
610
611
  }
611
612
 
612
613
  /** Ids the registry retires (row 127 IC-001): `registry` when a caller passes one, else the shipped
613
- * registry. One that cannot be read retires nothing, which leaves the merge per restored file. */
614
+ * registry. One that cannot be read throws, so a restore stops before it writes anything rather than
615
+ * silently skipping the retired rows and targets. */
614
616
  function retiredIds(registry ) {
615
617
  if (registry === undefined) {
616
618
  const { REPO_ROOT } = require('../helper/repo-root.js');
617
- try { registry = require('../registry/index.js').loadRegistry({ repoRoot: REPO_ROOT }); } catch { registry = null; }
619
+ try { registry = require('../registry/index.js').loadRegistry({ repoRoot: REPO_ROOT }); } catch (err ) {
620
+ throw new BackupError(`cannot read the registry to find retired harnesses: ${err?.message ?? err}`);
621
+ }
618
622
  }
619
623
  return new Set((Array.isArray(registry?.retired) ? registry.retired : []).map((entry ) => entry?.id).filter((id ) => typeof id === 'string'));
620
624
  }
621
625
 
622
626
  /** The bytes a ledger restore item writes: the backup's ledger merged onto the current one. */
623
- function restoredLedgerBytes(item , restored , retired , fsImpl ) {
627
+ function restoredLedgerBytes(item , restored , retired , lost , fsImpl ) {
624
628
  const backup = validateLedger(JSON.parse(fsImpl.readFileSync(item.stored, 'utf8')));
625
629
  const current = fsImpl.existsSync(item.path) ? validateLedger(JSON.parse(fsImpl.readFileSync(item.path, 'utf8'))) : null;
626
- return Buffer.from(`${JSON.stringify(validateLedger(mergeRestoredLedger(backup, current, restored, retired)), null, 2)}\n`);
630
+ return Buffer.from(`${JSON.stringify(validateLedger(mergeRestoredLedger(backup, current, restored, retired, lost)), null, 2)}\n`);
627
631
  }
628
632
 
629
633
  /**
@@ -641,6 +645,8 @@ function executeRestore(plan , { backupRoot, dryRun = false, registry, fsImp
641
645
  return { restored: plan.v1.tools.map((t ) => t.tool), failed: [], absent: [], refused: [], untargeted: 0, legacy: true };
642
646
  }
643
647
 
648
+ // Read before the first write: an unreadable registry stops the restore with nothing changed.
649
+ const retired = dryRun || plan.narrowed || !plan.restore.some((item ) => item.ledger) ? new Set () : retiredIds(registry);
644
650
  const restored = [];
645
651
  const failed = [];
646
652
  const files = new Set ();
@@ -654,7 +660,7 @@ function executeRestore(plan , { backupRoot, dryRun = false, registry, fsImp
654
660
  try {
655
661
  const dir = path.dirname(item.path);
656
662
  fsImpl.mkdirSync(dir, { recursive: true });
657
- const bytes = item.ledger ? restoredLedgerBytes(item, files, plan.narrowed ? new Set() : retiredIds(registry), fsImpl) : fsImpl.readFileSync(item.stored);
663
+ const bytes = item.ledger ? restoredLedgerBytes(item, files, retired, new Set([...failed.map((f ) => f.path), ...plan.refused.map((f ) => f.path)].filter((p ) => typeof p === 'string').map((p ) => path.resolve(p))), fsImpl) : fsImpl.readFileSync(item.stored);
658
664
  let isLink = false;
659
665
  try {
660
666
  isLink = fsImpl.lstatSync(item.path).isSymbolicLink();
@@ -7,6 +7,7 @@
7
7
  // native type-stripping — no build step, no runtime dependency. Body stays CommonJS so a `.js`
8
8
  // caller's `require('../install/context.js')` keeps working while the tree is still `.js`.
9
9
  const path = require('node:path');
10
+ const { canonicalPath } = require('./paths.js');
10
11
  const { sourceCommit: gitSourceCommit } = require('../helper/git.js');
11
12
 
12
13
 
@@ -27,7 +28,7 @@ const { sourceCommit: gitSourceCommit } = require('../helper/git.js');
27
28
  function resolveContext({ repoRoot, global, projectRoot, targets, dirs, sourceCommit, managedResources } ) {
28
29
  const context = {
29
30
  scope: global ? 'global' : 'project',
30
- scopeRoot: global ? path.dirname(dirs.claude) : path.resolve(projectRoot),
31
+ scopeRoot: global ? path.dirname(dirs.claude) : canonicalPath(projectRoot),
31
32
  targets,
32
33
  dirs: Object.fromEntries(targets.map((t) => [t, dirs[t]])),
33
34
  repoRoot,
@@ -25,6 +25,20 @@ function canonicalManifestPath(scopeRoot ) {
25
25
  return path.join(path.resolve(scopeRoot), '.doflow', MANIFEST_FILE_NAME);
26
26
  }
27
27
 
28
+ /** Atomic same-directory write of the manifest document. */
29
+ function atomicWrite(file , manifest , date ) {
30
+ fs.mkdirSync(path.dirname(file), { recursive: true });
31
+ // Security: the temp file must live next to `file` itself, not a shared os.tmpdir() — a
32
+ // world-writable /tmp plus a PID+timestamp-derived (i.e. guessable) name is a symlink-race
33
+ // waiting to happen (plant a symlink at the predicted path, the write clobbers its target).
34
+ // 'wx' (O_CREAT|O_EXCL) refuses to open through an existing symlink or file at all, and being
35
+ // on the same filesystem as `file` makes the final rename a guaranteed atomic same-fs op
36
+ // (os.tmpdir() offered no such guarantee — e.g. containers where /tmp is a separate tmpfs mount).
37
+ const tmp = path.join(path.dirname(file), `.install-manifest-${process.pid}-${date.getTime()}.json.tmp`);
38
+ fs.writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' });
39
+ fs.renameSync(tmp, file); // atomic on the same filesystem, matches sync.sh's mktemp + mv
40
+ }
41
+
28
42
 
29
43
 
30
44
 
@@ -88,19 +102,29 @@ function writeManifest({ scopeRoot, claudeDir, scriptVersion, operation, repoRoo
88
102
  const resources = managedResources ?? existingManagedResources;
89
103
  if (resources !== undefined) manifest.managed_resources = resources;
90
104
 
91
- fs.mkdirSync(path.dirname(file), { recursive: true });
92
- // Security: the temp file must live next to `file` itself, not a shared os.tmpdir() — a
93
- // world-writable /tmp plus a PID+timestamp-derived (i.e. guessable) name is a symlink-race
94
- // waiting to happen (plant a symlink at the predicted path, the write clobbers its target).
95
- // 'wx' (O_CREAT|O_EXCL) refuses to open through an existing symlink or file at all, and being
96
- // on the same filesystem as `file` makes the final rename a guaranteed atomic same-fs op
97
- // (os.tmpdir() offered no such guarantee — e.g. containers where /tmp is a separate tmpfs mount).
98
- const tmp = path.join(path.dirname(file), `.install-manifest-${process.pid}-${date.getTime()}.json.tmp`);
99
- fs.writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' });
100
- fs.renameSync(tmp, file); // atomic on the same filesystem, matches sync.sh's mktemp + mv
105
+ atomicWrite(file, manifest, date);
101
106
  return file;
102
107
  }
103
108
 
109
+ /**
110
+ * After a removal: stop listing `tools` as installed. Nothing else in the record changes except the
111
+ * operation and time, and a missing or unreadable manifest is left alone (nothing lists them).
112
+ * Returns whether the file was rewritten.
113
+ */
114
+ function forgetManifestTools({ scopeRoot, tools, date = new Date() } ) {
115
+ const file = canonicalManifestPath(scopeRoot);
116
+ if (!fs.existsSync(file)) return false;
117
+ let manifest ;
118
+ try { manifest = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return false; }
119
+ const listed = tools.filter((tool) => manifest?.tools && Object.hasOwn(manifest.tools, tool));
120
+ if (!listed.length) return false;
121
+ for (const tool of listed) delete manifest.tools[tool];
122
+ manifest.last_operation = 'remove';
123
+ manifest.last_run = date.toISOString().replace(/\.\d+Z$/, 'Z');
124
+ atomicWrite(file, manifest, date);
125
+ return true;
126
+ }
127
+
104
128
  /** null if no manifest yet. */
105
129
  function readManifestFile(file ) {
106
130
  if (!fs.existsSync(file)) return null;
@@ -141,5 +165,5 @@ function readManifest(claudeDir ) {
141
165
 
142
166
  module.exports = {
143
167
  LEGACY_MANIFEST_FILE_NAME, MANIFEST_FILE_NAME, manifestPath, canonicalManifestPath,
144
- writeManifest, readManifest, readInstallManifest,
168
+ writeManifest, forgetManifestTools, readManifest, readInstallManifest,
145
169
  };
@@ -3,6 +3,7 @@
3
3
  // TypeScript migration (agent-docs/BRIEF_GOAL_DOFLOW.md): authored as `.ts`, run directly by Node
4
4
  // native type-stripping — no build step, no runtime dependency. Body stays CommonJS so a `.js`
5
5
  // caller's `require('../install/paths.js')` keeps working while the rest of the tree is still `.js`.
6
+ const fs = require('node:fs');
6
7
  const path = require('node:path');
7
8
 
8
9
  const DOFLOW_DIR_NAME = '.doflow';
@@ -72,7 +73,44 @@ function doflowPaths({ scopeRoot } ) {
72
73
  };
73
74
  }
74
75
 
76
+ /** The one spelling of a path that ownership comparisons use: `fs.realpathSync` of the resolved path,
77
+ * or the resolved path itself when it does not exist (nothing to follow yet). macOS reaches the
78
+ * temp folder as both /var/... and /private/var/..., and a ledger row written under one spelling
79
+ * must match the other. */
80
+ function canonicalPath(target ) {
81
+ const resolved = path.resolve(target);
82
+ try { return fs.realpathSync(resolved); } catch { return resolved; }
83
+ }
84
+
85
+ function rebaseString(value , from , to ) {
86
+ if (value === from) return to;
87
+ return value.startsWith(from + path.sep) ? to + value.slice(from.length) : value;
88
+ }
89
+
90
+ function rebaseDeep(value , from , to ) {
91
+ if (typeof value === 'string') return rebaseString(value, from, to);
92
+ if (Array.isArray(value)) return value.map((item) => rebaseDeep(item, from, to));
93
+ if (value && typeof value === 'object') {
94
+ const out = {};
95
+ for (const key of Object.keys(value)) out[key] = rebaseDeep(value[key], from, to);
96
+ return out;
97
+ }
98
+ return value;
99
+ }
100
+
101
+ /** A ledger read from disk, with every path that names the scope root under a different spelling of
102
+ * it (the aliased path an earlier install recorded) rewritten to `scopeRoot`, the canonical one.
103
+ * The stored format is unchanged; a ledger already in canonical form, with no scope root of its
104
+ * own, or recording an unrelated directory is returned as it came. */
105
+ function canonicalLedger(ledger , scopeRoot ) {
106
+ const recorded = ledger && typeof ledger.scopeRoot === 'string' && ledger.scopeRoot ? path.resolve(ledger.scopeRoot) : null;
107
+ if (!recorded || recorded === scopeRoot || canonicalPath(recorded) !== scopeRoot) return ledger;
108
+ return rebaseDeep(ledger, recorded, scopeRoot);
109
+ }
110
+
75
111
  module.exports = {
112
+ canonicalPath,
113
+ canonicalLedger,
76
114
  DOFLOW_DIR_NAME,
77
115
  BACKUP_DIR_NAME,
78
116
  INSTALL_MANIFEST_FILE_NAME,
@@ -14,6 +14,7 @@
14
14
  // caller's `require('../install/targets.js')` keeps working while the rest of the tree is still `.js`.
15
15
  const os = require('node:os');
16
16
  const path = require('node:path');
17
+ const { canonicalPath } = require('./paths.js');
17
18
 
18
19
  const VALID = ['claude', 'codex', 'copilot', 'kiro', 'opencode', 'pi', 'antigravity'];
19
20
 
@@ -58,6 +59,14 @@ function resolveTargets(requested , { registry }
58
59
  return [...new Set(targets)];
59
60
  }
60
61
 
62
+ /** The harnesses an install manifest lists as installed, in VALID order; [] when there is no
63
+ * manifest or it lists none DoFlow still knows (a retired id is not a target). `update` without
64
+ * `-t` acts on these instead of DEFAULT_TARGETS, so it never installs a harness the scope lacks. */
65
+ function installedTargets(manifest ) {
66
+ const tools = manifest?.tools ?? {};
67
+ return VALID.filter((id) => Object.hasOwn(tools, id) && tools[id]?.installed !== false);
68
+ }
69
+
61
70
 
62
71
 
63
72
 
@@ -91,7 +100,7 @@ function resolveTargets(requested , { registry }
91
100
  */
92
101
  function toolDirs(opts = {}) {
93
102
  const { global = false, projectRoot = '.' } = opts;
94
- const root = global ? os.homedir() : path.resolve(projectRoot);
103
+ const root = global ? os.homedir() : canonicalPath(projectRoot);
95
104
  return {
96
105
  claude: path.join(root, '.claude'),
97
106
  codex: path.join(root, '.codex'),
@@ -102,4 +111,4 @@ function toolDirs(opts = {}) {
102
111
  };
103
112
  }
104
113
 
105
- module.exports = { VALID, DEFAULT_TARGETS, resolveTargets, toolDirs };
114
+ module.exports = { VALID, DEFAULT_TARGETS, resolveTargets, installedTargets, toolDirs };