@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
@@ -2,7 +2,6 @@
2
2
 
3
3
  const crypto = require('node:crypto');
4
4
  const CLASS_ORDER = Object.freeze(['unknown', 'unsupported', 'observation', 'advisory', 'blocking']);
5
- const POLICY_ORDER = Object.freeze(['pre-implementation-gate', 'pre-bash-guard', 'session-context', 'mcp-tool-guard', 'stop-check']);
6
5
  const HARNESS_ORDER = Object.freeze(['claude', 'codex', 'opencode', 'pi', 'copilot', 'kiro', 'antigravity']);
7
6
  const BEHAVIOUR_ORDER = Object.freeze(['context-authority', 'default-silence', 'summary-scope', 'summary-grant', 'identity', 'check-freshness']);
8
7
  const NEVER_BLOCKING_EVENTS = new Set([
@@ -43,32 +42,4 @@ function enforcementFor(coverage , query
43
42
  reason: weakest ? `${weakest.class} at (${weakest.policy}, ${weakest.harness}, ${weakest.event}, ${weakest.profile})` : `unknown at (${query.policy}, ${query.harness}, *, ${query.profile ?? '*'})`,
44
43
  };
45
44
  }
46
- function renderEnforcementMatrix(coverage ) {
47
- const order = (items , value ) => {
48
- const index = items.indexOf(value);
49
- return index < 0 ? items.length : index;
50
- };
51
- const rows = [...coverage.rows].sort((a , b ) => order(POLICY_ORDER, a.policy) - order(POLICY_ORDER, b.policy)
52
- || order(HARNESS_ORDER, a.harness) - order(HARNESS_ORDER, b.harness)
53
- || a.event.localeCompare(b.event) || a.profile.localeCompare(b.profile));
54
- const lines = [`Schema ${coverage.schemaVersion}; revision ${coverage.revision}; ${coverage.rowsDigest}`, '| Policy | Harness | Native event | Profile | Class | Verification | Prerequisites | Does not cover |', '|---|---|---|---|---|---|---|---|'];
55
- for (const row of rows) lines.push(`| ${row.policy} | ${row.harness} | ${row.event} | ${row.profile} | ${row.class} | ${row.verification} | ${(row.prerequisites || []).join(', ')} | ${(row.doesNotCover || []).join(', ')} |`);
56
- return lines.join('\n');
57
- }
58
- // IC-019: one row per behaviour x harness, ordered by IC-018's behaviour list, then HARNESS_ORDER.
59
- function renderBehaviourMatrix(coverage ) {
60
- const order = (items , value ) => {
61
- const index = items.indexOf(value);
62
- return index < 0 ? items.length : index;
63
- };
64
- const entries = [...(coverage.behaviours || [])].sort((a , b ) => order(BEHAVIOUR_ORDER, a.behaviour) - order(BEHAVIOUR_ORDER, b.behaviour)
65
- || order(HARNESS_ORDER, a.harness) - order(HARNESS_ORDER, b.harness)
66
- || a.behaviour.localeCompare(b.behaviour) || a.harness.localeCompare(b.harness));
67
- const lines = [`Schema ${coverage.schemaVersion}; revision ${coverage.revision}`, '| Behaviour | Harness | Status | Limitation or tests |', '|---|---|---|---|'];
68
- for (const entry of entries) {
69
- const detail = entry.status === 'enforced' ? (entry.tests || []).join(', ') : (entry.limitation || '');
70
- lines.push(`| ${entry.behaviour} | ${entry.harness} | ${entry.status} | ${detail} |`);
71
- }
72
- return lines.join('\n');
73
- }
74
- module.exports = { enforcementFor, renderEnforcementMatrix, renderBehaviourMatrix, BEHAVIOUR_ORDER, HARNESS_ORDER, rowsDigest, UnknownEnforcementTarget, NEVER_BLOCKING_EVENTS };
45
+ module.exports = { enforcementFor, BEHAVIOUR_ORDER, HARNESS_ORDER, rowsDigest, UnknownEnforcementTarget, NEVER_BLOCKING_EVENTS };
@@ -39,13 +39,13 @@ function resolveHookTier({ env = process.env, loadSettings }
39
39
  }
40
40
 
41
41
  /** One WARN line per planned harness that gets no hooks although it is the primary session (claude)
42
- * or a worker the person configured: there it runs without DoFlow's destructive-command guard. */
42
+ * or a worker the person configured: there it runs without DoFlow's gates. */
43
43
  function hookTierWarnings(registry , targets , tier ) {
44
44
  const warnings = [];
45
45
  for (const harness of registry.harnesses) {
46
46
  if (!targets.includes(harness.id) || tier.harnesses.includes(harness.id) || !getsHooks(registry, harness)) continue;
47
47
  const role = harness.id === 'claude' ? 'the primary session' : tier.workers?.includes(harness.id) ? `a worker in ${WORKERS_KEY}` : null;
48
- if (role) warnings.push(`${harness.id} is ${role} but not in ${HOOKS_KEY}: it runs without DoFlow's hooks, gates or destructive-command guard`);
48
+ if (role) warnings.push(`${harness.id} is ${role} but not in ${HOOKS_KEY}: it runs without DoFlow's hooks or gates`);
49
49
  }
50
50
  return warnings;
51
51
  }
@@ -17,6 +17,7 @@ const { renderMcpIndex } = require('./mcp-index.js');
17
17
  const { hasBashCapableShell } = require('./bash-availability.js');
18
18
  const { planHookCompanions } = require('./hook-step.js');
19
19
  const { tieredRegistry, hookHoldersFromLedger, hooksTieredOff, withoutHookSources, planHookTierRemovals } = require('./hook-tier.js');
20
+ const { forgetManifestTools } = require('../install/manifest.js');
20
21
  const { readLock, lockPackages } = require('../state/lockfile.js');
21
22
  const { resolveRegistrySource } = require('../adapters/resolve-source.js');
22
23
  const { planRetiredHarnesses, planHandovers, handoversFor, applyRetiredHarnesses, retiredReleaseKeys, claimKey } = require('./retired-harnesses.js');
@@ -118,6 +119,34 @@ function markUnownedAbsences(statuses , ledger , harness ) {
118
119
  ));
119
120
  }
120
121
 
122
+ /** An adapter verifies every asset the current source declares for it, comparing disk to source. For
123
+ * an asset the removal leaves this harness nothing to delete from, that is not its business: the
124
+ * files belong to another harness's rows (retained removals, or an older install that gave this
125
+ * harness no rows there: a v1.14.2 Pi install recorded none for the guidance, scripts and runtime
126
+ * trees) and so carry that install's legacy bytes. Those bytes read as conflicts on files this
127
+ * removal never touches, and the ones that do match source would be written back as NEW rows for
128
+ * the harness being removed. So for such an asset the `conflict` status, its `<assetId>: ...`
129
+ * conflict strings and its copy-tree resources are dropped. An asset the plan deletes from, or one
130
+ * where a ledger row has no removal, is judged exactly as before. Without a ledger nothing is
131
+ * proven settled, so the strict reading is kept. */
132
+ function dropSettledAssetVerification(verification , statuses , conflicts , removals , ledger ) {
133
+ const unchanged = { resources: verification.resources, statuses, conflicts };
134
+ if (!ledger) return unchanged;
135
+ const rows = (ledger.resources || []).filter((resource ) => resource.harness === verification.harness);
136
+ const settled = (assetId ) => !removals.some((change ) => change.assetId === assetId && !change.retained)
137
+ && !rows.some((row ) => row.assetId === assetId && !removals.some((change ) => matchesChange(row, change)));
138
+ const settledAssets = new Set([
139
+ ...statuses.filter((status ) => status?.status === 'conflict').map((status ) => status.assetId),
140
+ ...verification.resources.filter((resource ) => resource?.kind === 'copy-tree-file').map((resource ) => resource.assetId),
141
+ ].filter((assetId ) => assetId && settled(assetId)));
142
+ if (!settledAssets.size) return unchanged;
143
+ return {
144
+ resources: verification.resources.filter((resource ) => !(resource?.kind === 'copy-tree-file' && settledAssets.has(resource.assetId))),
145
+ statuses: statuses.map((status ) => (settledAssets.has(status?.assetId) && status.status === 'conflict' ? { ...status, status: 'not-managed' } : status)),
146
+ conflicts: conflicts.filter((reason ) => ![...settledAssets].some((assetId) => String(reason).startsWith(`${assetId}: `))),
147
+ };
148
+ }
149
+
121
150
  function normalizeRemovalVerification(verification , changes , ledger ) {
122
151
  const removals = changes.filter((change ) => change.operation === 'remove' && change.harness === verification.harness);
123
152
  if (!removals.length) return verification;
@@ -128,7 +157,9 @@ function normalizeRemovalVerification(verification , changes , ledger
128
157
  // before.
129
158
  const removed = removals.filter((change ) => !change.retained);
130
159
  let statuses = [...verification.statuses];
131
- const conflicts = [...(verification.conflicts || [])];
160
+ let conflicts = [...(verification.conflicts || [])];
161
+ let resources ;
162
+ ({ resources, statuses, conflicts } = dropSettledAssetVerification(verification, statuses, conflicts, removals, ledger));
132
163
  for (const change of removed) {
133
164
  const outcome = reconcileRemovedChange(verification, statuses, change);
134
165
  if (outcome.conflict) { conflicts.push(outcome.conflict); continue; }
@@ -137,7 +168,7 @@ function normalizeRemovalVerification(verification , changes , ledger
137
168
  }
138
169
  // After reconciling the plan's own removals, so this only ever judges what the plan left over.
139
170
  statuses = markUnownedAbsences(statuses, ledger, verification.harness);
140
- return { ...verification, statuses, conflicts, ok: removalVerificationOk(verification.ok, statuses, conflicts) };
171
+ return { ...verification, resources, statuses, conflicts, ok: removalVerificationOk(verification.ok, statuses, conflicts) };
141
172
  }
142
173
 
143
174
  /** An adapter may initially report ok:false because a now-removed resource is missing. Once
@@ -913,7 +944,15 @@ function updateLedger({ ledger, scope, scopeRoot, verifications, changes, recove
913
944
  // Record the relocation, then sweep the stale copy — but only when its bytes still hash to the
914
945
  // fingerprint DoFlow last verified there. A mismatch means someone edited that file after us:
915
946
  // their content outranks our tidiness, so the file stays and the unswept tombstone says why.
947
+ // An MCP entry is one member of a file the user shares, and the adapter has already taken it out
948
+ // of (or released it in) the old file by entry. A whole-file tombstone for it would carry the
949
+ // entry's fingerprint, never match the file's bytes and so stay unswept for good. Earlier runs
950
+ // left such rows behind for Pi's mcp.json (which borrows the guidance.codex-pointer asset id);
951
+ // they are retired here, and no file is touched.
952
+ next.tombstones = next.tombstones.filter((entry ) => entry.sweptAt || entry.harness !== 'pi'
953
+ || entry.assetId !== 'guidance.codex-pointer' || path.basename(String(entry.fromTarget)) !== 'mcp.json');
916
954
  for (const resource of next.resources) {
955
+ if (resource.kind === 'mcp-server') continue;
917
956
  const prior = priorByIdentity.get(`${resource.harness}\u0000${resource.assetId}\u0000${resource.identity ?? ''}`);
918
957
  if (!prior || prior.target === resource.target) continue;
919
958
  if (!next.tombstones.some((entry ) => entry.harness === resource.harness
@@ -925,6 +964,16 @@ function updateLedger({ ledger, scope, scopeRoot, verifications, changes, recove
925
964
  });
926
965
  }
927
966
  }
967
+ // A legacy retirement: an adapter removed an old projection that no row owned (its change carries
968
+ // `legacyRetirement.toTarget`, where the claim lives now). Journaled as a tombstone already swept.
969
+ for (const change of changes) {
970
+ if (!change.legacyRetirement || change.operation !== 'remove' || change.retained) continue;
971
+ const { harness, assetId, target: fromTarget, fingerprint } = change;
972
+ const toTarget = change.legacyRetirement.toTarget;
973
+ if (next.tombstones.some((entry ) => entry.harness === harness && entry.fromTarget === fromTarget && entry.toTarget === toTarget)) continue;
974
+ const at = new Date().toISOString();
975
+ next.tombstones.push({ harness, assetId, fromTarget, toTarget, fingerprint, movedAt: at, sweptAt: at });
976
+ }
928
977
  const claims = new Set(next.resources.map((resource ) => resource.target));
929
978
  for (const entry of next.tombstones) {
930
979
  if (entry.sweptAt || !fsImpl.existsSync(entry.fromTarget) || claims.has(entry.fromTarget)) continue;
@@ -1152,9 +1201,37 @@ function settleSuspendedProjection({ homeDir } ) {
1152
1201
  }
1153
1202
  }
1154
1203
 
1204
+ /** Remove the directories a removal emptied, from each removed file's folder upward, tool root
1205
+ * included (`.pi`, `.opencode`, `.agents`); `rmdir` refuses a folder that holds anything, so no
1206
+ * file is touched. The walk stops at the scope root and never takes a shared `.config` folder. */
1207
+ function pruneEmptiedDirs(changes , scopeRoot , fsImpl = fs) {
1208
+ const root = path.resolve(scopeRoot);
1209
+ for (const change of changes) {
1210
+ if (change.operation !== 'remove' || change.retained || change.release || typeof change.target !== 'string') continue;
1211
+ let dir = path.dirname(path.resolve(change.target));
1212
+ if (!isUnder(dir, root)) continue;
1213
+ // The removed file's own folders may already be gone; the first one still standing starts the walk.
1214
+ while (dir !== root && isUnder(dir, root) && !fsImpl.existsSync(dir)) dir = path.dirname(dir);
1215
+ while (dir !== root && isUnder(dir, root) && path.basename(dir) !== '.config') {
1216
+ try { fsImpl.rmdirSync(dir); } catch { break; }
1217
+ dir = path.dirname(dir);
1218
+ }
1219
+ }
1220
+ }
1221
+
1222
+ /** Stop the install manifest listing a harness the removal left with no ownership row. */
1223
+ function forgetRemovedTools(targets , ledger , scopeRoot ) {
1224
+ const standing = new Set((ledger?.resources || []).map((resource ) => resource.harness));
1225
+ const gone = (targets || []).filter((harness) => !standing.has(harness));
1226
+ if (gone.length) forgetManifestTools({ scopeRoot, tools: gone });
1227
+ }
1228
+
1155
1229
  function removeLifecycle(options ) {
1156
1230
  const plan = planLifecycle({ ...options, context: { ...(options.context || {}), operation: 'remove' } });
1157
- return applyLifecycle({ ...options, plan, mode: 'remove', acceptPrerequisites: options.acceptPrerequisites });
1231
+ const result = applyLifecycle({ ...options, plan, mode: 'remove', acceptPrerequisites: options.acceptPrerequisites });
1232
+ pruneEmptiedDirs(plan.targets.flatMap((target ) => target.changes), plan.scopeRoot);
1233
+ forgetRemovedTools(options.targets, result.ledger, plan.scopeRoot);
1234
+ return result;
1158
1235
  }
1159
1236
 
1160
1237
  module.exports = { OPERATIONS, MCP_KEEP, recordMcpOwnership, isPackageAsset, registryScope, normalizeTargets, normalizeChange, matchesChange, normalizeRemovalVerification, adapterConflicts, planLifecycle, verifyLifecycle, applyLifecycle, removeLifecycle, updateLedger, mcpIndexPath, applyMcpIndex, targetNeedsHooks, assertBashAvailableForHooks, hookWiringStatus, markRetainedRemovals, retentionSummary, PRUNED_TREES, settleSuspendedProjection };
@@ -128,6 +128,20 @@ function markerState(text ) { return markedS
128
128
  /** Rule 3a's verdict for one retired row (IC-003, IC-007, design D7). */
129
129
 
130
130
 
131
+ /** The one rule for "the replacement manages this file": at project scope the replacement writes its
132
+ * declared instruction file whatever else shares it; at global scope it stays out while another live
133
+ * harness in `registry` declares the same file (that harness's adapter keeps it). The handover plan
134
+ * here and antigravity's `instructionTarget` both ask it, so a row is never released to a harness that
135
+ * then plans nothing. */
136
+ function replacementManagesFile({ registry, harnessId, surface, target, scope, scopeRoot } ) {
137
+ if (scope !== 'global') return true;
138
+ const declaredBy = (harness ) => {
139
+ if (harness?.id === harnessId || !harness?.paths?.[surface]) return false;
140
+ try { return resolveHarnessPaths({ [surface]: harness.paths[surface] }, { scope: 'global', scopeRoot, homeDir: scopeRoot })[surface] === target; } catch { return false; }
141
+ };
142
+ return !(registry?.harnesses ?? []).some(declaredBy);
143
+ }
144
+
131
145
  /**
132
146
  * Rule 3a, decided before the live harnesses plan, because the replacement needs the verdict to plan
133
147
  * its own change: `context.handovers` for each `to`. A retired row qualifies when a `removal.handover`
@@ -155,6 +169,7 @@ function planHandovers({ registry, ledger, scope, scopeRoot, planned = [], fsImp
155
169
  let declared = null;
156
170
  try { declared = resolveHarnessPaths({ [entry.surface]: to.paths[entry.surface] }, { scope, scopeRoot, homeDir: scopeRoot })[entry.surface]; } catch { continue; }
157
171
  if (declared !== row.target || !fsImpl.existsSync(row.target) || !resolvesInPlace(row.target, scopeRoot, fsImpl)) continue;
172
+ if (!replacementManagesFile({ registry, harnessId: to.id, surface: entry.surface, target: declared, scope, scopeRoot })) continue;
158
173
  const base = { to: to.id, target: row.target, fromKey: ownershipKey(row) };
159
174
  if (plannedIds.has(to.id)) {
160
175
  decisions.set(base.fromKey, { action: 'handover', ...base, edited: sha256(fsImpl.readFileSync(row.target)) !== row.fingerprint });
@@ -315,4 +330,4 @@ function formatRetiredPlans(plans ) {
315
330
  ]);
316
331
  }
317
332
 
318
- module.exports = { planRetiredHarnesses, planHandovers, handoversFor, applyRetiredHarnesses, retiredReleaseKeys, formatRetiredPlans, claimKey };
333
+ module.exports = { replacementManagesFile, planRetiredHarnesses, planHandovers, handoversFor, applyRetiredHarnesses, retiredReleaseKeys, formatRetiredPlans, claimKey };
@@ -19,6 +19,7 @@ const { createCopilotAdapter } = require('../adapters/copilot/index.js');
19
19
  const { createKiroAdapter } = require('../adapters/kiro/index.js');
20
20
  const { createAntigravityAdapter } = require('../adapters/antigravity/index.js');
21
21
  const { planLifecycle } = require('./index.js');
22
+ const { canonicalPath, canonicalLedger } = require('../install/paths.js');
22
23
  const { formatRetiredPlans } = require('./retired-harnesses.js');
23
24
  const { resolveHookTier, hookTierWarnings } = require('./hook-tier.js');
24
25
  const { stateRoot, readLedger, defaultLedger } = require('../state/index.js');
@@ -51,9 +52,9 @@ function codexConfigResources(repoRoot , fsImpl ) {
51
52
  * reloaded — the same registry also resolves every harness's MCP selection for that command. */
52
53
  function registryLifecycleView({ registry, scope, targets, mcpSelections = {}, mcpAdoptable = {}, retainedMcpIds = [], operation, repoRoot, force = false, adopt = false, permissions = false, statusline = false, hookHarnesses, fsImpl = fs } ) {
53
54
  const lifecycleScope = codexScope(scope);
54
- const scopeRoot = scope.global ? os.homedir() : path.resolve(scope.projectRoot);
55
+ const scopeRoot = scope.global ? os.homedir() : canonicalPath(scope.projectRoot);
55
56
  const neutralStateRoot = stateRoot({ scope: lifecycleScope, projectRoot: scopeRoot, homeDir: scopeRoot });
56
- const ledger = readLedger(neutralStateRoot) ?? defaultLedger({ scope: lifecycleScope, scopeRoot });
57
+ const ledger = canonicalLedger(readLedger(neutralStateRoot), scopeRoot) ?? defaultLedger({ scope: lifecycleScope, scopeRoot });
57
58
  const tier = hookHarnesses ? { harnesses: hookHarnesses, workers: null } : resolveHookTier();
58
59
  // Mirrors buildAdapterRegistry() in src/cli/shared.js — including the declared-paths wiring —
59
60
  // and is kept as a literal construction here because the registry guard pins every declared
@@ -582,6 +582,8 @@ const WORKER_BILLING = new Set(['free', 'metered', 'unknown']);
582
582
  const WORKER_KEYS = Object.freeze(['profile', 'billing', ...Object.keys(WORKER_CAPABILITIES), 'modelChecks']);
583
583
  const WORKER_ALLOWED_KEYS = Object.freeze([...WORKER_KEYS, ...Object.keys(WORKER_OPTIONAL_CAPABILITIES), 'family']);
584
584
  const WORKER_ENTRY_FIELDS = new Set(['status', 'evidence', 'verification', 'verifiedOn', 'reason']);
585
+ // Soft hyphen, Mongolian vowel separator, zero-width and directional marks, word joiner and invisible operators, BOM.
586
+ const INVISIBLE_CHARACTER = /[\u00ad\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]/;
585
587
  const WORKER_RECIPE_FIELDS = new Set(['binary', 'versionArgv', 'argv', 'promptVia', 'parser', 'marker', 'quotaSignals']);
586
588
  const WORKER_QUOTA_SIGNALS_FIELDS = new Set(['signals', 'basis', 'evidence']);
587
589
  const WORKER_FACTS = new Set(['hooksLoaded', 'identityForwarded', 'guidanceLoaded', 'login']);
@@ -681,10 +683,25 @@ function validateWorker(value , location , errors ) {
681
683
  else {
682
684
  for (const field of Object.keys(quota)) if (!WORKER_QUOTA_SIGNALS_FIELDS.has(field)) fail(key, `quotaSignals has unknown field '${field}'`);
683
685
  if (!Array.isArray(quota.signals) || quota.signals.length < 1 || quota.signals.length > 16) fail(key, 'quotaSignals.signals must contain 1 to 16 signals');
684
- else for (const signal of quota.signals) {
685
- if (typeof signal !== 'string' || signal !== signal.trim() || signal.length < 3 || signal.length > 64 || /[\u0000-\u001f\u007f]/.test(signal)) {
686
- fail(key, 'quotaSignals.signals must be trimmed strings of 3 to 64 characters without control characters');
687
- break;
686
+ else {
687
+ const folded = new Set ();
688
+ for (const signal of quota.signals) {
689
+ if (typeof signal !== 'string' || signal !== signal.trim() || signal.length < 3 || signal.length > 64 || /[\u0000-\u001f\u007f]/.test(signal)) {
690
+ fail(key, 'quotaSignals.signals must be trimmed strings of 3 to 64 characters without control characters');
691
+ break;
692
+ }
693
+ // The matcher lowercases both sides, so a signal that differs only by case never adds a match; a
694
+ // zero-width or format character makes one that reads like another yet matches nothing.
695
+ if (INVISIBLE_CHARACTER.test(signal)) {
696
+ fail(key, 'quotaSignals.signals must not contain zero-width or invisible format characters');
697
+ break;
698
+ }
699
+ const lowered = signal.toLowerCase();
700
+ if (folded.has(lowered)) {
701
+ fail(key, `quotaSignals.signals has a duplicate signal '${signal}' (compared without letter case)`);
702
+ break;
703
+ }
704
+ folded.add(lowered);
688
705
  }
689
706
  }
690
707
  if (quota.basis !== 'unverified' && quota.basis !== 'transcript') fail(key, "quotaSignals.basis must be 'unverified' or 'transcript'");
@@ -20,7 +20,6 @@ const os = require('node:os');
20
20
  const path = require('node:path');
21
21
  const { spawnSync } = require('node:child_process');
22
22
  const { REPO_ROOT } = require('../helper/repo-root.js');
23
- const { renderEnforcementMatrix, renderBehaviourMatrix } = require('../lifecycle/coverage.js');
24
23
  const { loadSettingsRegistry } = require('../runtime/settings/registry.js');
25
24
  const { REF_META } = require('../runtime/settings/refs.js');
26
25
 
@@ -36,13 +35,6 @@ const DOFLOW_RUN_BIN = 'core/shared/scripts/doflow/bin/doflow-run';
36
35
 
37
36
  const NUMBER_WORDS = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve'];
38
37
 
39
- // Display labels for capability keys; a key without a label is shown capitalized, so a capability
40
- // added to the registry still gets its row.
41
- const CAPABILITY_LABELS = {
42
- instructions: 'Instructions', skills: 'Skills', agents: 'Agents', scripts: 'Scripts', templates: 'Templates',
43
- modes: 'Modes', settings: 'Settings', hooks: 'Hooks', mcp: 'MCP', plugin: 'Plugin / extension',
44
- };
45
-
46
38
 
47
39
 
48
40
 
@@ -94,38 +86,8 @@ function table(headers , rows ) {
94
86
  ].join('\n');
95
87
  }
96
88
 
97
- /** A copy of registry data with every string made inert, for renderers that format cells themselves. */
98
- function inert(value ) {
99
- if (typeof value === 'string') return value === '' ? value : text(value);
100
- if (Array.isArray(value)) return value.map(inert);
101
- if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, inert(entry)]));
102
- return value;
103
- }
104
-
105
- function capitalize(text ) {
106
- return text ? text[0].toUpperCase() + text.slice(1) : text;
107
- }
108
-
109
89
  // ── Renderers (one pure function of the root per block id) ──────────────────────────────────────
110
90
 
111
- function renderCapabilityMatrix(root ) {
112
- const harnesses = readJson(root, 'core/registry/harnesses.json').harnesses;
113
- if (!Array.isArray(harnesses) || harnesses.length === 0) throw new Error('harnesses.json has no harnesses');
114
- const keys = Object.keys(harnesses[0].capabilities || {});
115
- if (keys.length === 0) throw new Error('harnesses.json declares no capabilities');
116
- const status = (harness , key ) => {
117
- const value = harness.capabilities?.[key]?.status;
118
- if (typeof value !== 'string' || !value) return '—';
119
- const target = harness.nativeTargets?.[key];
120
- const label = text(capitalize(value));
121
- return target && (value === 'supported' || value === 'different') ? `${label} — ${code(target)}` : label;
122
- };
123
- return table(
124
- ['Capability', ...harnesses.map((harness ) => oneLine(harness.displayName))],
125
- keys.map((key) => [text(CAPABILITY_LABELS[key] || capitalize(key)), ...harnesses.map((harness ) => status(harness, key))]),
126
- );
127
- }
128
-
129
91
  function renderTaskClasses(root ) {
130
92
  const classes = readJson(root, 'core/registry/workflows.json').classes;
131
93
  const templates = new Set(Object.keys(readJson(root, 'core/registry/readiness-templates.json').templates || {}));
@@ -287,13 +249,31 @@ function renderCommandIndex(root ) {
287
249
  ].join('\n');
288
250
  }
289
251
 
252
+ /** One rule as a short phrase: its extensions, a name filter, and where it files or what it follows. */
253
+ function ruleText(rule ) {
254
+ const parts = [rule.extensions.map((ext ) => `.${ext}`).join(' ')];
255
+ if (rule.namePattern) parts.push(`name matches ${rule.namePattern}`);
256
+ if (rule.companionOf) parts.push(`beside a ${rule.companionOf.map((ext ) => `.${ext}`).join(' ')} file`);
257
+ if (rule.into) parts.push(`into ${rule.into}/`);
258
+ return parts.join(', ');
259
+ }
260
+
261
+ function renderIntakeCategories(root ) {
262
+ const categories = readJson(root, 'core/registry/intake.json').categories;
263
+ if (!Array.isArray(categories) || categories.length === 0) throw new Error('intake.json has no categories');
264
+ return table(['Folder', 'Also accepted as', 'Takes', 'Purpose'], categories.map((category ) => [
265
+ code(category.id),
266
+ text(category.names.join(', ')),
267
+ text(category.rules.map(ruleText).join('; ')),
268
+ text(category.summary),
269
+ ]));
270
+ }
271
+
290
272
  const BLOCKS = Object.freeze([
291
- { id: 'capability-matrix', page: 'capability-map.md', sources: ['core/registry/harnesses.json'], render: renderCapabilityMatrix },
292
- { id: 'enforcement-matrix', page: 'capability-map.md', sources: ['core/registry/lifecycle.json'], render: (root ) => renderEnforcementMatrix(inert(readJson(root, 'core/registry/lifecycle.json').coverage)) },
293
- { id: 'context-behaviour-matrix', page: 'capability-map.md', sources: ['core/registry/lifecycle.json'], render: (root ) => renderBehaviourMatrix(inert(readJson(root, 'core/registry/lifecycle.json').coverage)) },
294
273
  { id: 'task-classes', page: 'how-doflow-work.md', sources: ['core/registry/workflows.json', 'core/registry/readiness-templates.json'], render: renderTaskClasses },
295
274
  { id: 'settings-keys', page: 'settings.md', sources: ['core/registry/settings.json'], render: renderSettingsKeys },
296
275
  { id: 'command-index', page: 'reference.md', sources: [DOFLOW_BIN, DOFLOW_RUN_BIN], render: renderCommandIndex },
276
+ { id: 'intake-categories', page: 'guide.md', sources: ['core/registry/intake.json'], render: renderIntakeCategories },
297
277
  ]);
298
278
 
299
279
  function renderBlock(id , root ) {
@@ -380,7 +360,7 @@ function renderLlms(root ) {
380
360
  * (a lone BEGIN or END, an END before its BEGIN, a doubled pair) or nested inside another span is an
381
361
  * orphan. Markers are whole lines only, so marker-shaped text inside a table row is never one.
382
362
  */
383
- function markerFindings(page , text ) {
363
+ function markerFindings(page , text , blocks = BLOCKS) {
384
364
  const markers = [...text.matchAll(MARKER_LIKE)].map((match) => ({ kind: match[1], id: match[2], index: match.index ?? 0, exact: match[0] === `<!-- ${match[1]} GENERATED:${match[2]} -->` }));
385
365
  const bad = new Set (markers.filter((marker) => !marker.exact).map((marker) => marker.id));
386
366
  const counts = new Map ();
@@ -401,7 +381,7 @@ function markerFindings(page , text ) {
401
381
  if (open !== null) bad.add(open);
402
382
  const findings = [];
403
383
  for (const [id, { begin, end }] of counts) {
404
- const registered = MARKER_ID.test(id) && BLOCKS.some((block) => block.id === id && block.page === page);
384
+ const registered = MARKER_ID.test(id) && blocks.some((block) => block.id === id && block.page === page);
405
385
  if (!registered || begin !== 1 || end !== 1 || bad.has(id)) findings.push({ page, id, kind: 'orphan' });
406
386
  }
407
387
  return findings;
@@ -422,15 +402,15 @@ function readDoc(root , page ) {
422
402
  }
423
403
 
424
404
  /** Every finding for the registered blocks and llms.txt; empty when the docs are current. */
425
- function checkAll(root ) {
405
+ function checkAll(root , blocks = BLOCKS) {
426
406
  const findings = [];
427
- const pages = [...new Set(BLOCKS.map((block) => block.page))];
407
+ const pages = [...new Set(blocks.map((block) => block.page))];
428
408
  const texts = new Map(pages.map((page) => [page, readDoc(root, page)]));
429
409
  for (const page of pages) {
430
410
  const text = texts.get(page);
431
- if (text !== null && text !== undefined) findings.push(...markerFindings(page, text));
411
+ if (text !== null && text !== undefined) findings.push(...markerFindings(page, text, blocks));
432
412
  }
433
- for (const block of BLOCKS) {
413
+ for (const block of blocks) {
434
414
  const text = texts.get(block.page);
435
415
  const span = typeof text === 'string' ? spanOf(text, block.id) : null;
436
416
  if (!span) {
@@ -456,11 +436,11 @@ function checkAll(root ) {
456
436
  }
457
437
 
458
438
  /** Rewrites every well-formed span and llms.txt, then returns what `checkAll` still finds. */
459
- function writeAll(root ) {
460
- for (const block of BLOCKS) {
439
+ function writeAll(root , blocks = BLOCKS) {
440
+ for (const block of blocks) {
461
441
  const file = path.join(root, 'docs', block.page);
462
442
  const text = readDoc(root, block.page);
463
- if (text === null || markerFindings(block.page, text).some((f) => f.id === block.id)) continue;
443
+ if (text === null || markerFindings(block.page, text, blocks).some((f) => f.id === block.id)) continue;
464
444
  const span = spanOf(text, block.id);
465
445
  if (!span) continue;
466
446
  let rendered ;
@@ -472,7 +452,7 @@ function writeAll(root ) {
472
452
  const llms = renderLlms(root);
473
453
  if (readDoc(root, 'llms.txt') !== llms) fs.writeFileSync(path.join(root, 'docs', 'llms.txt'), llms);
474
454
  } catch { /* reported by checkAll as source-unreadable */ }
475
- return checkAll(root);
455
+ return checkAll(root, blocks);
476
456
  }
477
457
 
478
458
  /** The CLI: 0 current (or written clean), 1 findings, 2 usage. */
@@ -483,10 +463,14 @@ function main(argv , root = REPO_ROOT) {
483
463
  }
484
464
  const findings = argv[0] === '--write' ? writeAll(root) : checkAll(root);
485
465
  for (const finding of findings) process.stderr.write(`docs-blocks: ${finding.page}: ${finding.id}: ${finding.kind}${finding.detail ? ` (${oneLine(finding.detail)})` : ''}\n`);
486
- if (findings.length && argv[0] === '--check') process.stderr.write('docs-blocks: run node src/release/docs-blocks.js --write, then review the diff\n');
466
+ if (findings.length && argv[0] === '--check') {
467
+ process.stderr.write('docs-blocks: run node src/release/docs-blocks.js --write, then review the diff\n');
468
+ // --write rewrites llms.txt from the pages; when the rewritten text is itself over the cap, only shorter descriptions fix it.
469
+ if (findings.some((f) => f.kind === 'over-cap')) process.stderr.write(`docs-blocks: llms.txt over ${LLMS_CAP} bytes: if --write leaves it over, shorten the page descriptions it is built from\n`);
470
+ }
487
471
  return findings.length ? 1 : 0;
488
472
  }
489
473
 
490
- module.exports = { BLOCKS, LLMS_CAP, MARKER_ID, markerFindings, text, code, inert, renderBlock, renderLlms, readNav, captureHelp, pageDescription, checkAll, writeAll, main };
474
+ module.exports = { BLOCKS, LLMS_CAP, MARKER_ID, markerFindings, text, code, renderBlock, renderLlms, readNav, captureHelp, pageDescription, checkAll, writeAll, main };
491
475
 
492
476
  if (require.main === module) process.exitCode = main(process.argv.slice(2));
@@ -116,12 +116,12 @@ async function buildAdviceDoctor({ repoRoot = REPO_ROOT, projectRoot = process.c
116
116
  decisionModels: [], lastCall: latestCall(window.records),
117
117
  };
118
118
  }
119
- return { decisionModel, advice, _inspection: inspection, _offReason: setting.state === 'off' ? offReason(setting) : null };
119
+ return { decisionModel, advice, _inspection: inspection, _offReason: setting.state === 'off' ? (loaded.ok ? offReason(setting) : `unreadable advice registry: ${loaded.detail}`) : null };
120
120
  } catch (error ) {
121
121
  return { decisionModel: { setting: { state: 'off', reason: 'config-invalid', decidedBy: { user: null, project: null }, modelFrom: null, notes: [error.message] },
122
122
  endpoint: { url: null, state: 'not-checked', version: null, minVersion: null, versionOk: false, message: null },
123
123
  model: { name: null, installed: false, decisionCapable: false }, decisionModels: [], lastCall: null },
124
- advice: { windowDays: WINDOW_DAYS, store: 'advice-picks-user', kinds: [], unreadableLines: 0 }, _inspection: null, _offReason: 'unreadable setting in decision-model.json' };
124
+ advice: { windowDays: WINDOW_DAYS, store: 'advice-picks-user', kinds: [], unreadableLines: 0 }, _inspection: null, _offReason: `advice doctor could not complete: ${error.message}` };
125
125
  }
126
126
  }
127
127
 
@@ -328,7 +328,7 @@ function adviceRecord(result ) {
328
328
  /**
329
329
  * The tier renderer remains synchronous/pure; optional local advice is requested afterward.
330
330
  */
331
- async function handleBriefToolsCommand({ owner, tier, harness, slug, headingLevel, tasks, json = false, repoRoot = REPO_ROOT, projectRoot = process.cwd(), env = process.env, buildAdapters = null, probe = probeProviders, adviceClient = null } = {}) {
331
+ async function handleBriefToolsCommand({ owner, tier, harness, slug, headingLevel, tasks, json = false, repoRoot = REPO_ROOT, projectRoot = process.cwd(), env = process.env, buildAdapters = null, probe = probeProviders, adviceClient = null, now = () => new Date() } = {}) {
332
332
  if (!owner) return usageError('brief-tools', '--owner is required (the agent type the brief is for)', json);
333
333
  const level = headingLevel === undefined ? 2 : Number(headingLevel);
334
334
  if (level !== 2 && level !== 3) return usageError('brief-tools', `--heading-level must be 2 or 3, got '${headingLevel}'`, json);
@@ -349,6 +349,7 @@ async function handleBriefToolsCommand({ owner, tier, harness, slug, headingLeve
349
349
  let markdown = rendered.markdown;
350
350
  const record = rendered.record;
351
351
  record.missing.push(...notes);
352
+ record.createdAt = now().toISOString();
352
353
 
353
354
  const taskIds = Array.isArray(tasks) ? tasks : typeof tasks === 'string' ? tasks.split(',').filter(Boolean) : [];
354
355
  if (taskIds.length) {
@@ -298,6 +298,15 @@ class CapabilityRouter {
298
298
  };
299
299
  }
300
300
 
301
+ /** The registry's `capabilities.mcp.toolName` template for a harness id, or null when it has none. */
302
+ harnessToolTemplate(harnessId ) {
303
+ return this.harnessList().find((item ) => item.id === harnessId)?.capabilities?.mcp?.toolName || null;
304
+ }
305
+
306
+ harnessList() {
307
+ return parseYamlFile(path.join(this.registryDir, 'harnesses.json'), this.fsImpl).harnesses || [];
308
+ }
309
+
301
310
  /**
302
311
  * Formats concrete execution instructions for a provider based on intent parameters.
303
312
  *
@@ -320,7 +329,17 @@ class CapabilityRouter {
320
329
  // back to the registry entry with that id.
321
330
  const mcp = provider.mcp
322
331
  || Object.values(this.capabilities).flatMap((cap ) => cap.providers || []).find((p ) => p.id === provider.id && p.mcp)?.mcp;
323
- const mcpTool = mcp ? { mcpTool: `mcp__${mcp.server}__${mcp.tool}` } : {};
332
+ // Default (no `params.harness`): the Claude tool-name form, unchanged. With a target harness:
333
+ // that harness's registry `capabilities.mcp.toolName` template, or — when it has no verified
334
+ // template — the neutral `{server, tool}` identifiers and no tool name.
335
+ let mcpTool = {};
336
+ if (mcp && !params.harness) mcpTool = { mcpTool: `mcp__${mcp.server}__${mcp.tool}` };
337
+ else if (mcp) {
338
+ const template = this.harnessToolTemplate(params.harness);
339
+ mcpTool = template
340
+ ? { mcpTool: template.replace('{server}', () => mcp.server).replace('{tool}', () => mcp.tool) }
341
+ : { mcp: { server: mcp.server, tool: mcp.tool } };
342
+ }
324
343
  const withDisplay = (result ) => ({ ...mcpTool, ...result, ...(result.argv ? { cliCommand: result.argv.map(shellQuote).join(' ') } : {}) });
325
344
 
326
345
  if (provider.id === 'semble.search') {
@@ -580,15 +599,19 @@ class CapabilityRouter {
580
599
  * @param {string} [options.projectRoot]
581
600
  * @returns {number} exit code
582
601
  */
583
- function handleRouteCommand({ intent, query, check = false, json = false, projectRoot } = {}) {
602
+ function handleRouteCommand({ intent, query, check = false, json = false, projectRoot, harness } = {}) {
584
603
  const router = new CapabilityRouter({ repoRoot: REPO_ROOT, projectRoot });
585
604
  if (typeof intent !== 'string' || intent.trim() === '') {
586
605
  return usageError('route', `--intent is required. Declared intents: ${Object.keys(router.routes).join(', ')}`, json);
587
606
  }
588
607
 
608
+ if (harness !== undefined && !router.harnessList().some((item ) => item.id === harness)) {
609
+ return usageError('route', `unknown --harness '${harness}'. Known harnesses: ${router.harnessList().map((item ) => item.id).join(', ')}`, json);
610
+ }
611
+
589
612
  let resolution ;
590
613
  try {
591
- resolution = router.resolveIntent(intent, { query, path: projectRoot || '.' }, { deepCheck: check });
614
+ resolution = router.resolveIntent(intent, { query, path: projectRoot || '.', ...(harness ? { harness } : {}) }, { deepCheck: check });
592
615
  } catch (error ) {
593
616
  return usageError('route', `${error.message}. Declared intents: ${Object.keys(router.routes).join(', ')}`, json, error);
594
617
  }