@khoavu882/doflow 2.0.1 → 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 (93) hide show
  1. package/core/.claude-plugin/plugin.json +1 -1
  2. package/core/.codex-plugin/plugin.json +1 -1
  3. package/core/.plugin/plugin.json +1 -1
  4. package/core/harnesses/claude/hooks/session-end.sh +21 -1
  5. package/core/harnesses/codex/hooks/session-end.impl.sh +21 -1
  6. package/core/harnesses/shared/hooks/policies/compact-text.sh +8 -0
  7. package/core/harnesses/shared/hooks/policies/subagent-audit.sh +25 -1
  8. package/core/registry/contribution-points.json +1 -1
  9. package/core/registry/intake.json +3 -3
  10. package/core/registry/lifecycle.json +256 -5
  11. package/core/registry/packages/doflow/doflow-package.json +2 -2
  12. package/core/registry/settings.json +12 -12
  13. package/core/registry/storage.json +1 -2
  14. package/core/shared/guidance/references/ARTIFACT_FORMAT.md +7 -0
  15. package/core/shared/scripts/doflow/bash/do-git-state.sh +12 -4
  16. package/core/shared/scripts/doflow/bash/do-parallel-check.sh +86 -33
  17. package/core/shared/scripts/doflow/bash/do-review-package.sh +36 -2
  18. package/core/shared/scripts/doflow/bin/doflow-run +18 -0
  19. package/core/shared/skills/do-design/SKILL.md +6 -2
  20. package/core/shared/skills/do-design/references/data_model_example.md +167 -0
  21. package/core/shared/templates/doflow/data-model-template.md +136 -102
  22. package/package.json +1 -1
  23. package/src/adapters/antigravity/index.js +54 -12
  24. package/src/adapters/codex/agents.js +15 -1
  25. package/src/adapters/codex/config.js +5 -2
  26. package/src/adapters/copilot/index.js +11 -4
  27. package/src/adapters/copy-tree.js +7 -0
  28. package/src/adapters/created-file.js +41 -0
  29. package/src/adapters/mcp-entries.js +11 -1
  30. package/src/adapters/opencode/index.js +32 -5
  31. package/src/adapters/pi/index.js +15 -4
  32. package/src/adapters/pi/mcp.js +44 -18
  33. package/src/cli/commands/remove.js +3 -1
  34. package/src/cli/commands/update.js +7 -3
  35. package/src/cli/index.js +24 -12
  36. package/src/cli/runtime-commands.js +1 -1
  37. package/src/cli/shared.js +5 -3
  38. package/src/helper/marker-merge.js +11 -7
  39. package/src/install/backup-set.js +7 -3
  40. package/src/install/backup.js +13 -7
  41. package/src/install/context.js +2 -1
  42. package/src/install/manifest.js +35 -11
  43. package/src/install/paths.js +38 -0
  44. package/src/install/targets.js +11 -2
  45. package/src/lifecycle/index.js +80 -3
  46. package/src/lifecycle/retired-harnesses.js +16 -1
  47. package/src/lifecycle/view.js +3 -2
  48. package/src/registry/index.js +21 -4
  49. package/src/release/docs-blocks.js +5 -1
  50. package/src/runtime/advice/doctor.js +2 -2
  51. package/src/runtime/brief-tools.js +2 -1
  52. package/src/runtime/capability-router.js +26 -3
  53. package/src/runtime/context/pointer.js +17 -5
  54. package/src/runtime/context-pack.js +48 -8
  55. package/src/runtime/dispatch/integrate.js +27 -8
  56. package/src/runtime/dispatch/loop.js +7 -0
  57. package/src/runtime/dispatch/verify-run.js +9 -3
  58. package/src/runtime/feature-pointer.js +10 -1
  59. package/src/runtime/health.js +9 -4
  60. package/src/runtime/implementation-gate.js +10 -2
  61. package/src/runtime/intake/engine.js +11 -9
  62. package/src/runtime/knowledge/code-retrieval.js +11 -5
  63. package/src/runtime/knowledge/semble-provider.js +12 -2
  64. package/src/runtime/lifecycle/cli.js +7 -5
  65. package/src/runtime/lifecycle/overview.js +32 -2
  66. package/src/runtime/lifecycle/release.js +16 -3
  67. package/src/runtime/lifecycle/retention.js +12 -15
  68. package/src/runtime/lifecycle/store-upkeep.js +13 -3
  69. package/src/runtime/mask.js +3 -3
  70. package/src/runtime/mcp-consultations.js +11 -5
  71. package/src/runtime/research-request.js +3 -1
  72. package/src/runtime/review/command.js +3 -7
  73. package/src/runtime/review/start.js +24 -10
  74. package/src/runtime/roadmap/loop.js +14 -3
  75. package/src/runtime/roadmap/rows.js +2 -1
  76. package/src/runtime/settings/files.js +3 -1
  77. package/src/runtime/settings/prepared-run.js +12 -2
  78. package/src/runtime/settings/resolve.js +13 -1
  79. package/src/runtime/settings/write.js +73 -2
  80. package/src/runtime/storage/registry.js +2 -2
  81. package/src/runtime/storage/roots.js +4 -18
  82. package/src/runtime/storage/rules.js +22 -46
  83. package/src/runtime/storage/work-status.js +14 -4
  84. package/src/runtime/task-scope.js +17 -2
  85. package/src/runtime/task-state.js +11 -4
  86. package/src/runtime/trace/analyze.js +4 -2
  87. package/src/runtime/trace/indicators.js +25 -20
  88. package/src/runtime/trace/spans.js +4 -5
  89. package/src/runtime/verb-view.js +6 -5
  90. package/src/runtime/worker/command.js +4 -2
  91. package/src/runtime/worker/readiness.js +8 -4
  92. package/src/runtime/worker/supervisor.js +18 -6
  93. package/src/runtime/workflow-orchestrator.js +6 -2
@@ -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 };
@@ -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'");
@@ -463,7 +463,11 @@ function main(argv , root = REPO_ROOT) {
463
463
  }
464
464
  const findings = argv[0] === '--write' ? writeAll(root) : checkAll(root);
465
465
  for (const finding of findings) process.stderr.write(`docs-blocks: ${finding.page}: ${finding.id}: ${finding.kind}${finding.detail ? ` (${oneLine(finding.detail)})` : ''}\n`);
466
- 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
+ }
467
471
  return findings.length ? 1 : 0;
468
472
  }
469
473
 
@@ -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
  }
@@ -40,6 +40,14 @@ function inside(root , file , fsImpl = fs) {
40
40
  return normalized;
41
41
  }
42
42
 
43
+ /** `file` as a '/'-separated path under `root` as written, links not resolved, or null when it is not under `root`.
44
+ * A pointer keeps this logical path: a linked `agent-docs/` must not hide a user file from the ledger's path test. */
45
+ function logicalRel(root , file ) {
46
+ const relative = path.relative(path.resolve(root), path.resolve(file));
47
+ if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
48
+ return relative.split(path.sep).join('/');
49
+ }
50
+
43
51
  function lineSlice(lines , startLine , endLine ) {
44
52
  return lines.slice(startLine - 1, endLine).join('\n') + '\n';
45
53
  }
@@ -157,8 +165,10 @@ function collectPointers({ projectRoot, feature, fsImpl = fs, intakeFile } )
157
165
  const root = path.resolve(projectRoot);
158
166
  const pointers = [];
159
167
  for (const source of sourceFiles({ projectRoot: root, feature, fsImpl, intakeFile })) {
160
- const rel = inside(root, source.absolute, fsImpl);
161
- if (!rel) continue;
168
+ // The realpath decides whether the file is inside the project; the pointer names it as it is written.
169
+ const real = inside(root, source.absolute, fsImpl);
170
+ if (!real) continue;
171
+ const rel = logicalRel(root, source.absolute) ?? real;
162
172
  const user = source.user || Boolean(originOf(rel));
163
173
  let text ;
164
174
  try {
@@ -184,9 +194,11 @@ function collectPointers({ projectRoot, feature, fsImpl = fs, intakeFile } )
184
194
  }
185
195
 
186
196
  function readPointerSlice({ projectRoot, pointer, fsImpl = fs } ) {
187
- const rel = inside(projectRoot, path.resolve(projectRoot, pointer.path), fsImpl);
188
- if (!rel || rel !== pointer.path) return null;
189
- const file = path.resolve(projectRoot, rel);
197
+ // A pointer path is a plain relative path under the project (a logical one through a link, or an older
198
+ // pointer's realpath) whose realpath is inside it too.
199
+ const absolute = path.resolve(projectRoot, pointer.path);
200
+ if (!inside(projectRoot, absolute, fsImpl) || logicalRel(projectRoot, absolute) !== pointer.path) return null;
201
+ const file = absolute;
190
202
  try {
191
203
  const lines = fsImpl.readFileSync(file, 'utf8').split(/\r?\n/);
192
204
  const slice = lineSlice(lines, pointer.range.startLine, pointer.range.endLine);