@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,7 @@ const { MARKER_START, MARKER_END } = require('../../helper/marker-merge.js');
25
25
  const { destructiveCommandGlobs } = require('../../helper/guardrails.js');
26
26
  const { declaredHarnessPaths, resolveHarnessPaths } = require('../../helper/harness-paths.js');
27
27
  const { renderGuidancePointer } = require('../guidance-pointer.js');
28
+ const { isBlank, createdByDoFlow, creationOrigin, removeCreatedFile } = require('../created-file.js');
28
29
 
29
30
  const HARNESS = 'opencode';
30
31
  /**
@@ -141,7 +142,8 @@ function createOpenCodeAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS]
141
142
  }
142
143
 
143
144
  function managedInstruction(existing , rendered ) {
144
- if (existing === null) return { ok: true, operation: 'create', content: rendered };
145
+ // A file holding only whitespace has nothing to protect and counts as absent.
146
+ if (existing === null || isBlank(existing)) return { ok: true, operation: 'create', content: rendered };
145
147
  const start = existing.indexOf(MARKER_START);
146
148
  const end = existing.indexOf(MARKER_END);
147
149
  if (start === -1 && end === -1) return { ok: false, conflict: `${INSTRUCTION_FILE} exists without a DoFlow managed section` };
@@ -316,7 +318,9 @@ function createOpenCodeAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS]
316
318
  else if (outcome.operation !== 'none' && outcome.content !== null) {
317
319
  changes.push({ assetId: guidance.id, target: found.paths.instruction, operation: outcome.operation,
318
320
  content: outcome.content, ownershipIdentity: `${HARNESS}:instructions:managed-section`,
319
- fingerprint: fingerprint(outcome.content), harness: HARNESS, projection: { renderer: 'opencode-instructions' } });
321
+ fingerprint: fingerprint(outcome.content), harness: HARNESS, projection: { renderer: 'opencode-instructions' },
322
+ // A file DoFlow created and a removal that leaves only whitespace: the file goes (remove()).
323
+ ...(removing && createdByDoFlow(ledger, HARNESS, `${HARNESS}:instructions:managed-section`, found.paths.instruction) ? { deleteWhenBlank: true } : {}) });
320
324
  }
321
325
  }
322
326
 
@@ -336,7 +340,9 @@ function createOpenCodeAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS]
336
340
  // src/adapters/copilot/index.js's analogous mcp-merge change already carries).
337
341
  operation: removing ? 'remove' : (found.config.exists ? 'update' : 'create'), content: `${JSON.stringify(next, null, 2)}\n`,
338
342
  ownershipIdentity: `${HARNESS}:config:registration`, fingerprint: fingerprint(next),
339
- harness: HARNESS, projection: { renderer: 'opencode-config' } });
343
+ harness: HARNESS, projection: { renderer: 'opencode-config' },
344
+ // A file DoFlow created that a removal leaves empty goes too (remove()).
345
+ ...(removing && createdByDoFlow(ledger, HARNESS, `${HARNESS}:config:registration`, found.paths.config) ? { deleteWhenEmpty: true } : {}) });
340
346
  }
341
347
  } else if (removing) {
342
348
  changes.push(...releasedConfigRow({ assets, paths: found.paths, ledger }));
@@ -384,14 +390,29 @@ function createOpenCodeAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS]
384
390
  if (change.operation !== 'remove') continue;
385
391
  if (change.kind === 'copy-tree-file' || change.kind === 'mcp-server') continue; // routed below
386
392
  if (typeof change.content !== 'string') continue;
393
+ if (change.deleteWhenBlank && isBlank(change.content) && removeCreatedFile(change.target, fsImpl)) { removed += 1; continue; }
387
394
  writeChange(change, fsImpl);
388
395
  removed += 1;
389
396
  }
390
397
  removed += writeMcpEntries(changes, { container: MCP_CONTAINER, fsImpl });
398
+ // After the entry removals, which edit the same file: what is left of a config DoFlow created.
399
+ for (const change of changes) {
400
+ if (change.deleteWhenEmpty && configIsEmpty(change.target, fsImpl) && removeCreatedFile(change.target, fsImpl)) removed += 1;
401
+ }
391
402
  removed += removeCopyTreeAssets(changes, { fsImpl });
392
403
  return { removed };
393
404
  }
394
405
 
406
+ /** True when the file holds nothing but an empty JSON object (or only whitespace). */
407
+ function configIsEmpty(file , fsImpl ) {
408
+ try {
409
+ const text = fsImpl.readFileSync(file, 'utf8');
410
+ if (isBlank(text)) return true;
411
+ const value = JSON.parse(text);
412
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0;
413
+ } catch { return false; }
414
+ }
415
+
395
416
  function verify({ scope, scopeRoot, assets = [], mcp = [], mcpAdoptable = [], discovery, ledger, operation, context = {}, fsImpl = fs } ) {
396
417
  const found = discover({ scope, scopeRoot, mcp, mcpAdoptable, ledger, context, fsImpl });
397
418
  const removing = (operation ?? context.operation) === 'remove';
@@ -399,6 +420,10 @@ function createOpenCodeAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS]
399
420
  const resources = [];
400
421
  const conflicts = [];
401
422
  const instructionsAssetId = assets.find((asset ) => asset.capability === 'instructions')?.id ?? pseudoAssetId(assets);
423
+ const originOf = (ownershipIdentity , target , before ) => {
424
+ const origin = creationOrigin({ ledger, harness: HARNESS, ownershipIdentity, target, before });
425
+ return origin ? { origin } : {};
426
+ };
402
427
 
403
428
  const hasSection = typeof found.instruction === 'string'
404
429
  && found.instruction.includes(MARKER_START) && found.instruction.includes(MARKER_END);
@@ -408,7 +433,8 @@ function createOpenCodeAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS]
408
433
  if (hasSection) {
409
434
  resources.push({ assetId: instructionsAssetId, target: found.paths.instruction,
410
435
  ownershipIdentity: `${HARNESS}:instructions:managed-section`, fingerprint: fingerprint(found.instruction),
411
- sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'opencode-instructions' } });
436
+ sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'opencode-instructions' },
437
+ ...originOf(`${HARNESS}:instructions:managed-section`, found.paths.instruction, discovery ? discovery.instruction : undefined) });
412
438
  }
413
439
 
414
440
  if (!found.mcpSnapshot[found.paths.config].ok) {
@@ -424,7 +450,8 @@ function createOpenCodeAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS]
424
450
  if (registered) {
425
451
  resources.push({ assetId: pseudoAssetId(assets), target: found.paths.config,
426
452
  ownershipIdentity: `${HARNESS}:config:registration`, fingerprint: fingerprint(value),
427
- sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'opencode-config' } });
453
+ sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'opencode-config' },
454
+ ...originOf(`${HARNESS}:config:registration`, found.paths.config, discovery ? (discovery.config.exists ? {} : null) : undefined) });
428
455
  }
429
456
  if (context.permissions) {
430
457
  const ours = guardrailBashRules(context);
@@ -27,6 +27,7 @@ const { declaredHarnessPaths, resolveHarnessPaths } = require('../../helper/harn
27
27
  const { mcpRows, discoverPiMcp, ownedPiMcpIds, planPiMcp, applyPiMcp, verifyPiMcp } = require('./mcp.js');
28
28
  const { planPiHooks, verifyPiHooks, removePiHooks } = require('./hooks.js');
29
29
  const { renderGuidancePointer } = require('../guidance-pointer.js');
30
+ const { isBlank, createdByDoFlow, creationOrigin, removeCreatedFile } = require('../created-file.js');
30
31
 
31
32
  const HARNESS = 'pi';
32
33
  const AGENT_DIR_MCP_NOTICE = 'PI_CODING_AGENT_DIR is set: Pi reads its whole agent dir from it, but DoFlow moves only mcp.json there; skills and AGENTS.md stay in ~/.pi/agent.';
@@ -73,9 +74,10 @@ function createPiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS], env
73
74
 
74
75
  /** Replace only the span between DoFlow's markers. An AGENTS.md with no DoFlow section is refused
75
76
  * rather than appended to — Pi shares AGENTS.md with every other agent that reads it, so where
76
- * DoFlow's content belongs is its owner's decision. Add an empty marker pair to opt in. */
77
+ * DoFlow's content belongs is its owner's decision. Add an empty marker pair to opt in. A file
78
+ * holding only whitespace has nothing to protect and counts as absent. */
77
79
  function managedInstruction(existing , rendered ) {
78
- if (existing === null) return { ok: true, operation: 'create', content: rendered };
80
+ if (existing === null || isBlank(existing)) return { ok: true, operation: 'create', content: rendered };
79
81
  const start = existing.indexOf(MARKER_START);
80
82
  const end = existing.indexOf(MARKER_END);
81
83
  if (start === -1 && end === -1) return { ok: false, conflict: 'AGENTS.md exists without a DoFlow managed section' };
@@ -190,7 +192,9 @@ function createPiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS], env
190
192
  else if (outcome.operation !== 'none' && outcome.content !== null) {
191
193
  changes.push({ assetId: guidance.id, target: found.paths.instruction, operation: outcome.operation,
192
194
  content: outcome.content, ownershipIdentity: `${HARNESS}:instructions:managed-section`,
193
- fingerprint: fingerprint(outcome.content), harness: HARNESS, projection: { renderer: 'pi-instructions' } });
195
+ fingerprint: fingerprint(outcome.content), harness: HARNESS, projection: { renderer: 'pi-instructions' },
196
+ // A file DoFlow created and a removal that leaves only whitespace: the file goes (remove()).
197
+ ...(removing && createdByDoFlow(ledger, HARNESS, `${HARNESS}:instructions:managed-section`, found.paths.instruction) ? { deleteWhenBlank: true } : {}) });
194
198
  }
195
199
  }
196
200
 
@@ -252,6 +256,7 @@ function createPiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS], env
252
256
  if (change.operation !== 'remove' || change.projection?.renderer === 'copy-tree' || isPiMcp(change)) continue;
253
257
  if (change.projection?.renderer === 'pi-guard-extension') continue;
254
258
  if (change.content === null) continue;
259
+ if (change.deleteWhenBlank && isBlank(change.content) && removeCreatedFile(change.target, fsImpl)) { removed += 1; continue; }
255
260
  writeChange(change, fsImpl);
256
261
  removed += 1;
257
262
  }
@@ -260,6 +265,11 @@ function createPiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS], env
260
265
  return { removed };
261
266
  }
262
267
 
268
+ function originField({ ledger, target, before } ) {
269
+ const origin = creationOrigin({ ledger, harness: HARNESS, ownershipIdentity: `${HARNESS}:instructions:managed-section`, target, before });
270
+ return origin ? { origin } : {};
271
+ }
272
+
263
273
  function verify({ scope, scopeRoot, assets = [], mcp = [], discovery, ledger, context = {}, fsImpl = fs } ) {
264
274
  const found = discover({ scope, scopeRoot, mcp, ledger, context, fsImpl });
265
275
  const statuses = [];
@@ -275,7 +285,8 @@ function createPiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS], env
275
285
  if (hasSection) {
276
286
  resources.push({ assetId: instructionsAssetId, target: found.paths.instruction,
277
287
  ownershipIdentity: `${HARNESS}:instructions:managed-section`, fingerprint: fingerprint(found.instruction),
278
- sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'pi-instructions' } });
288
+ sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'pi-instructions' },
289
+ ...originField({ ledger, target: found.paths.instruction, before: discovery?.instruction }) });
279
290
  }
280
291
 
281
292
  const copyTree = verifyCopyTreeAssets({ assets, scope, scopeRoot, context, fsImpl });
@@ -104,7 +104,8 @@ function twinOf(doc , key ) {
104
104
  return doc.servers?.members.find((member ) => member.key !== key && piServerKey(member.key) === piServerKey(key)) ?? null;
105
105
  }
106
106
 
107
- function sameValue(a , b ) { return JSON.stringify(a) === JSON.stringify(b); }
107
+ /** Equal as JSON values, whatever order an object's keys sit in (an owned entry the user reordered is still ours). */
108
+ function sameValue(a , b ) { return fingerprint(a) === fingerprint(b); }
108
109
 
109
110
  function baseChange({ target, operation, id, snapshot } ) {
110
111
  return {
@@ -271,26 +272,51 @@ function applyPiMcp(changes , { fsImpl = fs } = {})
271
272
  for (const [target, all] of groups) {
272
273
  const group = all.filter((change ) => !change.release);
273
274
  if (!group.length) continue;
274
- const exists = fsImpl.existsSync(target);
275
- const bytes = exists ? fsImpl.readFileSync(target) : Buffer.alloc(0);
276
- if (sha256(bytes) !== group[0].baseHash) throw new Error(`Pi MCP: ${target} changed after planning; nothing was written, re-run the command`);
277
- const text = bytes.toString('utf8');
278
- const creates = group.filter((change ) => change.operation === 'create');
279
- const origin = group.find((change ) => change.origin)?.origin;
280
- let next = exists ? editedText(text, group) : renderNewDocument(creates.map((change ) => [change.identity, change.entry]));
281
- // Only a file DoFlow created is deleted, and never through a symlink; a user's own file that
282
- // happens to equal the skeleton stays.
283
- if (exists && origin?.created === 'file' && next === SKELETON) {
284
- if (!isSymlink(target, fsImpl)) { fsImpl.rmSync(target); applied += 1; continue; }
285
- } else if (exists && origin && !creates.length) next = restoredText(next, origin);
286
- if (next !== text) {
287
- atomicWrite(target, next, fsImpl);
288
- applied += 1;
289
- }
275
+ // The hash check and the write are one critical section across the installers of this machine,
276
+ // keyed by the file a write lands in, so two links to one mcp.json share a lock.
277
+ const release = lockTarget(target, fsImpl);
278
+ try { applied += applyGroup(target, group, fsImpl); } finally { release(); }
290
279
  }
291
280
  return { applied };
292
281
  }
293
282
 
283
+ /** The file a write to `target` lands in, as one name for every path that reaches it. */
284
+ function canonicalTarget(target , fsImpl ) {
285
+ const dest = writePath(target, fsImpl);
286
+ try { return fsImpl.realpathSync(dest); } catch { /* not there yet: its directory names it */ }
287
+ try { return path.join(fsImpl.realpathSync(path.dirname(dest)), path.basename(dest)); } catch { return path.resolve(dest); }
288
+ }
289
+
290
+ /** Waits for another installer's lock on the same mcp.json (`<file>.lock`, task-state's protocol: a
291
+ * crashed holder's lock is reclaimed once stale) and refuses with that lock named when it stays held. */
292
+ function lockTarget(target , fsImpl ) {
293
+ const { acquireLock } = require('../../runtime/task-state.js');
294
+ const canonical = canonicalTarget(target, fsImpl);
295
+ fsImpl.mkdirSync(path.dirname(canonical), { recursive: true });
296
+ try { return acquireLock(fsImpl, canonical); } catch (error ) {
297
+ throw new Error(`Pi MCP: ${target} is being changed by another DoFlow run; nothing was written, re-run the command (${error.message})`);
298
+ }
299
+ }
300
+
301
+ /** One file's changes, applied against the bytes read now. Returns 1 when the file changed. */
302
+ function applyGroup(target , group , fsImpl ) {
303
+ const exists = fsImpl.existsSync(target);
304
+ const bytes = exists ? fsImpl.readFileSync(target) : Buffer.alloc(0);
305
+ if (sha256(bytes) !== group[0].baseHash) throw new Error(`Pi MCP: ${target} changed after planning; nothing was written, re-run the command`);
306
+ const text = bytes.toString('utf8');
307
+ const creates = group.filter((change ) => change.operation === 'create');
308
+ const origin = group.find((change ) => change.origin)?.origin;
309
+ let next = exists ? editedText(text, group) : renderNewDocument(creates.map((change ) => [change.identity, change.entry]));
310
+ // Only a file DoFlow created is deleted, and never through a symlink; a user's own file that
311
+ // happens to equal the skeleton stays.
312
+ if (exists && origin?.created === 'file' && next === SKELETON) {
313
+ if (!isSymlink(target, fsImpl)) { fsImpl.rmSync(target); return 1; }
314
+ } else if (exists && origin && !creates.length) next = restoredText(next, origin);
315
+ if (next === text) return 0;
316
+ atomicWrite(target, next, fsImpl);
317
+ return 1;
318
+ }
319
+
294
320
  function statusFor({ id, target, status } ) {
295
321
  return { harness: HARNESS, assetId: ASSET_ID, capability: 'mcp', identity: id, target,
296
322
  ownershipIdentity: `doflow:${HARNESS}:mcp-server:${id}`, status };
@@ -370,4 +396,4 @@ function verifyPiMcp({ selected = [], rows = [], file, snapshot = {}, removing =
370
396
  return { statuses, resources, conflicts };
371
397
  }
372
398
 
373
- module.exports = { NOTICES, renderPiEntry, piServerKey, mcpRows, discoverPiMcp, ownedPiMcpIds, planPiMcp, applyPiMcp, verifyPiMcp };
399
+ module.exports = { NOTICES, renderPiEntry, piServerKey, mcpRows, discoverPiMcp, ownedPiMcpIds, planPiMcp, applyPiMcp, verifyPiMcp, canonicalTarget };
@@ -10,7 +10,7 @@ const { loadRegistry } = require('../../registry/index.js');
10
10
  const { storageForgetScope } = require('../../runtime/storage/upkeep.js');
11
11
  const { removeLifecycle, retentionSummary } = require('../../lifecycle/index.js');
12
12
  const {
13
- codexScope, registryLifecycleView, printRegistryLifecycle, LIFECYCLE_HARNESSES,
13
+ codexScope, registryLifecycleView, printRegistryLifecycle, printPlanNotices, LIFECYCLE_HARNESSES,
14
14
  assertSafeRegistryPlan, lockDocument, recordLock,
15
15
  } = require('../../lifecycle/view.js');
16
16
  const { recordedMcpSelections } = require('../../install/mcp.js');
@@ -63,6 +63,8 @@ function cmdRemove(o ) {
63
63
  // antigravity/copilot) mean a removal can legitimately leave files standing. Saying only "removed"
64
64
  // would be half the truth, so what was kept and who still claims it is printed, not implied.
65
65
  for (const line of retentionSummary(result.retained)) console.log(`[INFO] ${line}`);
66
+ // The plan's notices (a file left untouched, a kept edit) read the same on a real run as under --dry-run.
67
+ printPlanNotices(view);
66
68
  console.log(`[OK] Removed ${result.ledger.resources.length === 0 ? 'all' : 'eligible'} native resource(s) for ${lifecycleTargets.join(', ')}; ${result.ledger.resources.length} owned record(s) remain.`);
67
69
 
68
70
  // Re-pin what still stands: every harness the ledger still holds keeps its pin and its recorded
@@ -4,7 +4,7 @@
4
4
  // TypeScript migration (agent-docs/BRIEF_GOAL_DOFLOW.md): run directly by Node native type-stripping.
5
5
  const os = require('node:os');
6
6
  const path = require('node:path');
7
- const { resolveTargets, toolDirs } = require('../../install/targets.js');
7
+ const { resolveTargets, installedTargets, toolDirs } = require('../../install/targets.js');
8
8
  const { resolveContext, printContext } = require('../../install/context.js');
9
9
  const { DEFAULT_BACKUP_RETENTION, createFileBackup, applyRetention, retentionNote } = require('../../install/backup.js');
10
10
  const { backupSetFromPlan } = require('../../install/backup-set.js');
@@ -29,12 +29,17 @@ const {
29
29
 
30
30
  function cmdUpdate(o ) {
31
31
  refuseHomeRootedProject(o, 'update');
32
- const targets = resolveTargets(o.targets);
32
+ const requested = resolveTargets(o.targets); // validates an explicit -t before anything is read
33
33
  const scope = scopeOf(o);
34
34
  const hold = holdRunLock(o, scope, 'update');
35
35
  try {
36
36
  const dirs = toolDirs(scope);
37
37
  const lifecyclePaths = installPaths(scope);
38
+ const existingManifest = readInstallManifest({ scopeRoot: lifecyclePaths.scopeRoot });
39
+ // No -t: act on the harnesses this scope's manifest lists as installed, not on the install default.
40
+ // With no manifest (or none listed) the default stays, as before.
41
+ const listed = installedTargets(existingManifest);
42
+ const targets = o.targets?.length || !listed.length ? requested : listed;
38
43
  const backupRoot = lifecyclePaths.backupRoot;
39
44
  const commit = sourceCommit(SCRIPT_DIR);
40
45
  printContext(resolveContext({ repoRoot: REPO_ROOT, targets, dirs, sourceCommit: commit, ...scope }));
@@ -42,7 +47,6 @@ function cmdUpdate(o ) {
42
47
  // Never interactive: update reuses each harness's recorded selection, or applies an explicit --mcp
43
48
  // override, without re-prompting.
44
49
  const registry = loadRegistry({ repoRoot: REPO_ROOT });
45
- const existingManifest = readInstallManifest({ scopeRoot: lifecyclePaths.scopeRoot });
46
50
  const { lock, ledger } = scopeSelectionState(scope);
47
51
  const selection = resolveMcpSelections({
48
52
  cmd: 'update', requested: o.mcp, targets, registry, lock, ledger, manifestServers: existingManifest?.mcpServers ?? null,
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' || o.cmd === 'worktree' ? 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
  }
@@ -254,12 +266,12 @@ function unknownFlagExit(cmd ) {
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. */
@@ -498,14 +510,14 @@ function parseRuntimeFlag(arg , argv , i , o )
498
510
  // `--batch <file|->`. Rejecting it made the documented spelling exit 2 while only `--batch=-`
499
511
  // worked — a help text endorsing a form the parser refuses.
500
512
  const isStdin = value === '-';
501
- 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);
502
514
  next = i + 1;
503
515
  }
504
- if (value === '') { console.error(`doflow: ${name} requires a value`); process.exit(2); }
516
+ if (value === '') argError(o, argv, `${name} requires a value`, 2);
505
517
 
506
518
  if (RUNTIME_INT_FLAGS.has(name)) {
507
519
  const parsed = parseInt(value, 10);
508
- 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);
509
521
  o[key] = parsed;
510
522
  } else if (RUNTIME_LIST_FLAGS.has(name)) {
511
523
  // --exclude and --exclude-family both accept comma lists or repeated flags. Keep --exclude's
@@ -710,7 +722,7 @@ Runtime verb arguments (accept --flag value or --flag=value):
710
722
  --request <text>, --request-file <file> classify (mutually exclusive; optional second opinion)
711
723
  --view brief|full followup --action list (default brief), lifecycle --action overview, classify (default full); brief drops detail, full is the whole answer
712
724
  --cursor <c>, --limit <n> followup --action list (the cursor is opaque: pass back a page's nextCursor); also transcript --action read
713
- --intent, --query, --check route
725
+ --intent, --query, --check, --harness route
714
726
  --statement, --claim-id,
715
727
  --evidence-id, --relation claim
716
728
  --kind, --provenance, --provider,
@@ -415,7 +415,7 @@ function dispatchRuntimeCommandInner(o , moment ) {
415
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 });
416
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) });
417
417
  case 'model-role': return handleModelRoleCommand({ role: o.role, exclude: o.exclude, excludeFamilies: o.excludeFamily, json: o.json, repoRoot: REPO_ROOT });
418
- 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) });
419
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 });
420
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 });
421
421
  // 078 IC-005 and IC-013: identity is the process environment's alone, never a flag.
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,