@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
@@ -202,8 +202,11 @@ function planCodexConfig({ file, scope, managedResources = [], desiredResources
202
202
  if (nextLines.length && nextLines.at(-1) !== '') nextLines.push('');
203
203
  nextLines.push(`[${table}]`, ...lines);
204
204
  }
205
- let content = nextLines.join('\n');
206
- if (content && !content.endsWith('\n')) content += '\n';
205
+ // The file keeps its own line ending (the first one it uses) and its own final-newline state; only a file
206
+ // that was empty or ended in a newline gets one, so a remove can hand back the user's exact bytes.
207
+ const eol = /\r?\n/.exec(original)?.[0] ?? '\n';
208
+ let content = nextLines.join(eol);
209
+ if (content && !content.endsWith('\n') && (original === '' || original.endsWith('\n'))) content += eol;
207
210
  const invalid = newViolation({ file, original, content, identities: changes.map((change ) => change.identity) });
208
211
  if (invalid) return { ok: false, status: 'conflict', file, original, changes: [], conflicts: [invalid], managedResources };
209
212
  const nextManagedResources = managedResources.filter((resource ) => !isOwnedRecord(resource, scope) || desired.has(resource.identity));
@@ -17,6 +17,41 @@ const SUPPORTED_EVENTS = new Set([
17
17
  'UserPromptSubmit', 'SubagentStop', 'Stop', 'SessionStart', 'SubagentStart', 'SessionEnd',
18
18
  ]);
19
19
 
20
+ // An earlier DoFlow wired a pre-bash-guard front door into hooks.json and deployed its script. Both
21
+ // are gone from the shipped source; a merge drops the entry from the live file so an updated install
22
+ // does not keep a hook that runs a script nothing refreshes, and the script goes with it. Only the
23
+ // exact command DoFlow shipped is dropped: a user's own hook that happens to name a script of the same
24
+ // file name stays.
25
+ const RETIRED_HOOK_SCRIPT = 'pre-bash-guard.sh';
26
+ const RETIRED_HOOK_COMMAND = 'bash "$(root=$(git rev-parse --show-toplevel 2>/dev/null) && [ -f "$root/.codex/hooks/pre-bash-guard.sh" ] && printf \'%s/.codex/hooks/pre-bash-guard.sh\' "$root" || printf \'%s/hooks/pre-bash-guard.sh\' "${CODEX_HOME:-$HOME/.codex}")"';
27
+
28
+ function withoutRetiredHooks(hooks ) {
29
+ const result = {};
30
+ for (const [event, groups] of Object.entries (hooks || {})) {
31
+ if (!Array.isArray(groups)) { result[event] = groups; continue; }
32
+ const kept = groups.map((group ) => (Array.isArray(group?.hooks)
33
+ ? { ...group, hooks: group.hooks.filter((handler ) => handler?.command !== RETIRED_HOOK_COMMAND) }
34
+ : group)).filter((group ) => !Array.isArray(group?.hooks) || group.hooks.length > 0);
35
+ if (kept.length) result[event] = kept;
36
+ }
37
+ return result;
38
+ }
39
+
40
+ /** Whether the retired front door is still on disk as DoFlow's own (it names the bridge call it was written with). */
41
+ function ownsRetiredHookScript(destinationHooksDir , fsImpl = fs) {
42
+ const target = path.join(destinationHooksDir, RETIRED_HOOK_SCRIPT);
43
+ try {
44
+ return fsImpl.lstatSync(target).isFile() && fsImpl.readFileSync(target, 'utf8').includes('doflow_bridge codex PreToolUse pre-bash-guard');
45
+ } catch { return false; }
46
+ }
47
+
48
+ /** Delete the retired front door when it is still DoFlow's own. */
49
+ function removeRetiredHookScript(destinationHooksDir , fsImpl = fs, dryRun = false) {
50
+ if (!ownsRetiredHookScript(destinationHooksDir, fsImpl)) return 0;
51
+ if (!dryRun) { try { fsImpl.unlinkSync(path.join(destinationHooksDir, RETIRED_HOOK_SCRIPT)); } catch { return 0; } }
52
+ return 1;
53
+ }
54
+
20
55
  function validationError(errors , message ) { errors.push(message); }
21
56
 
22
57
  function validateHooksConfig(config ) {
@@ -148,7 +183,7 @@ function planCodexHooks({ config, sourceFile, sourceHooksDir, destinationContext
148
183
  try {
149
184
  const existing = JSON.parse(original);
150
185
  if (existing?.hooks && desired?.hooks) {
151
- mergedDesired = { ...existing, ...desired, hooks: mergeHooks(existing.hooks, desired.hooks) };
186
+ mergedDesired = { ...existing, ...desired, hooks: mergeHooks(withoutRetiredHooks(existing.hooks), desired.hooks) };
152
187
  }
153
188
  } catch {
154
189
  // malformed existing will be flagged or left for schema validation
@@ -167,7 +202,7 @@ function planCodexHooks({ config, sourceFile, sourceHooksDir, destinationContext
167
202
  return { ok: false, status: 'conflict', destination, original, changes: [], commands,
168
203
  errors: scripts.edited.map((name ) => `Codex hook script '${name}' was modified outside DoFlow`) };
169
204
  }
170
- const scriptsOnly = !configChanged && scripts.stale.length > 0;
205
+ const scriptsOnly = !configChanged && (scripts.stale.length > 0 || ownsRetiredHookScript(destinationHooksDir, fsImpl));
171
206
  const changed = configChanged || scriptsOnly;
172
207
  // The scripts deployCodexHooks copies beside hooks.json, so a backup of this change holds them too.
173
208
  const companionTargets = [...new Set([
@@ -204,6 +239,7 @@ function deployCodexHooks(plan , { dryRun = false, fsImpl = fs }
204
239
  fsImpl.copyFileSync(source, target);
205
240
  fsImpl.chmodSync(target, fsImpl.statSync(source).mode & 0o777);
206
241
  }
242
+ removeRetiredHookScript(plan.destinationHooksDir, fsImpl);
207
243
  return { ...plan, applied: true };
208
244
  }
209
245
 
@@ -220,7 +256,7 @@ function shippedHookScripts(scriptsDir , fsImpl = fs) {
220
256
  * user edited or added stays. The directory goes only once it is empty. */
221
257
  function removeCodexHookScripts({ scriptsDir, destinationHooksDir } = {}, { dryRun = false, fsImpl = fs } = {}) {
222
258
  if (!destinationHooksDir || !fsImpl.existsSync(destinationHooksDir)) return { removed: 0 };
223
- let removed = 0;
259
+ let removed = removeRetiredHookScript(destinationHooksDir, fsImpl, dryRun);
224
260
  for (const name of shippedHookScripts(scriptsDir, fsImpl)) {
225
261
  const target = path.join(destinationHooksDir, name);
226
262
  // lstat, not stat: a symlink (even to a file with the shipped bytes) is not DoFlow's copy.
@@ -503,9 +503,22 @@ function createCodexAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
503
503
  hooks = { ...hooks, ok: false, status: 'conflict', changes: [], errors: ['Codex hooks.json was modified outside DoFlow'] };
504
504
  }
505
505
  }
506
+ if (hooks.ok) hooks = withStaleScriptReleases(hooks, scriptRows(neutralResources, context));
506
507
  return hooks;
507
508
  }
508
509
 
510
+ /** A script row for a name the source no longer ships (a retired front door) is released with the
511
+ * plan, so the ledger does not keep a row for a file the update deletes. The script itself goes by
512
+ * its bytes (deployCodexHooks), so this only drops the row. */
513
+ function withStaleScriptReleases(hooks , rows ) {
514
+ const shipped = new Set(hooks.scriptNames);
515
+ const releases = rows.filter((row ) => !shipped.has(row.identity)).map((row ) => ({ type: 'remove', assetId: row.assetId, identity: row.identity,
516
+ target: row.target, ownershipIdentity: row.ownershipIdentity, kind: row.kind, release: true }));
517
+ if (!releases.length) return hooks;
518
+ return { ...hooks, changes: [...hooks.changes, ...releases],
519
+ ...(hooks.status === 'unchanged' ? { status: 'change', scriptsOnly: true } : {}) };
520
+ }
521
+
509
522
  /** Agents/hooks share one removing-vs-planning fork: removal always owns both via the neutral
510
523
  * ledger's own records, regardless of what native input the caller provided this run. */
511
524
  /** A removal drops the rows of the hook scripts too. The scripts themselves go by their bytes
@@ -36,6 +36,7 @@ const path = require('node:path');
36
36
  const { MARKER_START, MARKER_END } = require('../../helper/marker-merge.js');
37
37
  const { planTree, applyTree, removeTree, verifyTree, copyTreeDestDir, ledgerFileResources, ledgerSiblingFingerprints, siblingReplacedNotices, fingerprint, sourceDirFor, resolveTransform } = require('../copy-tree.js');
38
38
  const { renderGuidancePointer } = require('../guidance-pointer.js');
39
+ const { isBlank, createdByDoFlow, creationOrigin, removeCreatedFile } = require('../created-file.js');
39
40
  const { readMcpFiles, planMcpEntries, verifyMcpEntries, ownedMcpIds, writeMcpEntries } = require('../mcp-entries.js');
40
41
  const { declaredHarnessPaths, resolveHarnessPaths } = require('../../helper/harness-paths.js');
41
42
 
@@ -108,7 +109,8 @@ function createCopilotAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS],
108
109
  * file it doesn't fully own is a decision for the file's owner, not a guess for the installer. Add
109
110
  * an empty marker pair to opt in. */
110
111
  function managedInstruction(existing , rendered ) {
111
- if (existing === null) return { ok: true, operation: 'create', content: rendered };
112
+ // A file holding only whitespace has nothing to protect and counts as absent.
113
+ if (existing === null || isBlank(existing)) return { ok: true, operation: 'create', content: rendered };
112
114
  const start = existing.indexOf(MARKER_START);
113
115
  const end = existing.indexOf(MARKER_END);
114
116
  if (start === -1 && end === -1) return { ok: false, conflict: `${INSTRUCTION_FILE} exists without a DoFlow managed section` };
@@ -132,7 +134,7 @@ function createCopilotAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS],
132
134
  /** Plan the single instructions change (or none). Global scope always yields no change and no
133
135
  * conflict — the user-level ~/.copilot/copilot-instructions.md is not projected, so there is no
134
136
  * global target, the same way other adapters skip a capability they do not project at a scope. */
135
- function planInstructionsChange({ assets, found, scope, scopeRoot, context, removing, fsImpl } ) {
137
+ function planInstructionsChange({ assets, found, scope, scopeRoot, context, removing, ledger, fsImpl } ) {
136
138
  if (!found.paths.instruction) return { changes: [], conflicts: [] };
137
139
  // The marker-managed instruction file is one specific asset: exclude tree-projected rule
138
140
  // assets (copilot-rule-instructions), whose `source` is a directory, not a file.
@@ -148,6 +150,8 @@ function createCopilotAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS],
148
150
  assetId: guidance.id, target: found.paths.instruction, operation: outcome.operation,
149
151
  content: outcome.content, ownershipIdentity: `${HARNESS}:instructions:managed-section`,
150
152
  fingerprint: fingerprint(outcome.content), harness: HARNESS, projection: { renderer: 'copilot-instructions' },
153
+ // A file DoFlow created and a removal that leaves only whitespace: the file goes (remove()).
154
+ ...(removing && createdByDoFlow(ledger, HARNESS, `${HARNESS}:instructions:managed-section`, found.paths.instruction) ? { deleteWhenBlank: true } : {}),
151
155
  }],
152
156
  conflicts: [],
153
157
  };
@@ -305,7 +309,7 @@ function createCopilotAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS],
305
309
  const conflicts = [];
306
310
  const removing = context.operation === 'remove';
307
311
 
308
- const instructions = planInstructionsChange({ assets, found, scope, scopeRoot, context, removing, fsImpl });
312
+ const instructions = planInstructionsChange({ assets, found, scope, scopeRoot, context, removing, ledger, fsImpl });
309
313
  changes.push(...instructions.changes);
310
314
  conflicts.push(...instructions.conflicts);
311
315
 
@@ -365,6 +369,7 @@ function createCopilotAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS],
365
369
  if (change.operation !== 'remove') continue;
366
370
  if (change.projection?.renderer === 'copy-tree' || change.kind === 'mcp-server') continue;
367
371
  if (typeof change.content !== 'string') continue;
372
+ if (change.deleteWhenBlank && isBlank(change.content) && removeCreatedFile(change.target, fsImpl)) { removed += 1; continue; }
368
373
  writeChange(change, fsImpl);
369
374
  removed += 1;
370
375
  }
@@ -389,9 +394,11 @@ function createCopilotAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS],
389
394
  status: hasSection ? 'managed' : 'absent', target: found.paths.instruction,
390
395
  ownershipIdentity: `${HARNESS}:instructions:managed-section` });
391
396
  if (hasSection) {
397
+ const origin = creationOrigin({ ledger, harness: HARNESS, ownershipIdentity: `${HARNESS}:instructions:managed-section`, target: found.paths.instruction, before: discovery?.instruction });
392
398
  resources.push({ assetId, target: found.paths.instruction,
393
399
  ownershipIdentity: `${HARNESS}:instructions:managed-section`, fingerprint: fingerprint(found.instruction),
394
- sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'copilot-instructions' } });
400
+ sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'copilot-instructions' },
401
+ ...(origin ? { origin } : {}) });
395
402
  }
396
403
  }
397
404
 
@@ -371,14 +371,13 @@ function kiroHook({ name, trigger, matcher, script, timeout }
371
371
  };
372
372
  }
373
373
 
374
- /** The three preToolUse hooks every DoFlow-rendered Kiro agent carries. The MCP guard uses the
374
+ /** The two preToolUse hooks every DoFlow-rendered Kiro agent carries. The MCP guard uses the
375
375
  * "*" matcher: Kiro documents "@<server>" (every tool of one server) and "*" but no all-MCP
376
376
  * matcher, and mcp-tool-guard.sh exits 0 immediately for any non-MCP tool name, so "*" costs one
377
377
  * fast no-op for built-in tools and still covers every MCP server. */
378
378
  function kiroPreToolUseHooks() {
379
379
  return [
380
380
  kiroHook({ name: 'doflow-pre-implementation-gate', trigger: 'preToolUse', matcher: 'write', script: 'pre-implement-gate.sh', timeout: 10 }),
381
- kiroHook({ name: 'doflow-pre-bash-guard', trigger: 'preToolUse', matcher: 'shell', script: 'pre-bash-guard.sh', timeout: 10 }),
382
381
  kiroHook({ name: 'doflow-mcp-tool-guard', trigger: 'preToolUse', matcher: '*', script: 'mcp-tool-guard.sh', timeout: 10 }),
383
382
  ];
384
383
  }
@@ -662,6 +661,13 @@ function applyTree({ changes = [], fsImpl = fs, transform } )
662
661
  if (change.operation === 'remove') continue;
663
662
  fsImpl.mkdirSync(path.dirname(change.target), { recursive: true });
664
663
  const sourceStat = fsImpl.statSync(change.source);
664
+ // A destination symlink (`install -f` plans over one) is replaced by the regular file, never written through:
665
+ // its target, which may be a file outside the install, keeps its bytes and mode.
666
+ try {
667
+ if (fsImpl.lstatSync(change.target).isSymbolicLink()) fsImpl.unlinkSync(change.target);
668
+ } catch (error ) {
669
+ if (error.code !== 'ENOENT') throw error;
670
+ }
665
671
  if (mapContent) {
666
672
  // Re-derive the bytes from the source at write time instead of copying it: the transform,
667
673
  // not the source file, is what the recorded fingerprint describes. Keeps recovery retries
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ // Shared by the adapters whose instruction file (and opencode.json) is a file DoFlow may have
4
+ // created: the ledger row records `origin: { created: 'file' }` when the file was absent before the
5
+ // install, so a removal that leaves nothing but whitespace can delete exactly the files DoFlow made
6
+ // and keep every file that existed beforehand. Same record Pi's mcp.json rows carry (pi/mcp.ts).
7
+ const fs = require('node:fs');
8
+
9
+ /** Whitespace is not content: a file holding only whitespace carries none of the user's text. */
10
+ function isBlank(text ) { return typeof text === 'string' && text.trim() === ''; }
11
+
12
+ function ownedRow(ledger , harness , ownershipIdentity , target ) {
13
+ return (ledger?.resources || []).find((row ) => row.harness === harness
14
+ && row.ownershipIdentity === ownershipIdentity && row.target === target);
15
+ }
16
+
17
+ /** Whether the ledger row for `ownershipIdentity` at `target` says DoFlow created that file. */
18
+ function createdByDoFlow(ledger , harness , ownershipIdentity , target ) {
19
+ return ownedRow(ledger, harness, ownershipIdentity, target)?.origin?.created === 'file';
20
+ }
21
+
22
+ /** The origin to record on the row verify emits. A row already in the ledger passes its origin on
23
+ * (absent for rows an older version wrote, which are never guessed at). With no row, `before` is
24
+ * the file as plan read it: `null` means absent, so this install creates it. `undefined` means the
25
+ * caller has no pre-apply read (a status verify), and nothing is recorded. */
26
+ function creationOrigin({ ledger, harness, ownershipIdentity, target, before } ) {
27
+ const row = ownedRow(ledger, harness, ownershipIdentity, target);
28
+ if (row) return row.origin;
29
+ return before === null ? { created: 'file' } : undefined;
30
+ }
31
+
32
+ /** Delete `target` unless it is a symlink (the link is the user's, not DoFlow's). */
33
+ function removeCreatedFile(target , fsImpl = fs) {
34
+ try {
35
+ if (fsImpl.lstatSync(target).isSymbolicLink()) return false;
36
+ fsImpl.rmSync(target);
37
+ return true;
38
+ } catch { return false; }
39
+ }
40
+
41
+ module.exports = { isBlank, createdByDoFlow, creationOrigin, removeCreatedFile };
@@ -281,15 +281,25 @@ function writePath(file , fsImpl ) {
281
281
  try { return fsImpl.realpathSync(file); } catch { return path.resolve(path.dirname(file), fsImpl.readlinkSync(file)); }
282
282
  }
283
283
 
284
+ /** The indent the file already uses (its first indented line, tabs included), else two spaces. */
285
+ function indentOf(file , fsImpl ) {
286
+ try {
287
+ const found = /^\s*[{[][ \t]*\r?\n([ \t]+)\S/.exec(fsImpl.readFileSync(file, 'utf8'));
288
+ if (found) return found[1].slice(0, 10);
289
+ } catch { /* absent or unreadable: the default */ }
290
+ return ' ';
291
+ }
292
+
284
293
  /** Write a JSON document atomically, keeping the existing file's permission bits: an MCP file kept
285
294
  * private for its tokens stays so. Creates missing directories; never deletes a file. */
286
295
  function writeMcpJson(file , doc , { fsImpl = fs } = {}) {
287
296
  const dest = writePath(file, fsImpl);
288
297
  fsImpl.mkdirSync(path.dirname(dest), { recursive: true });
289
298
  const mode = fsImpl.existsSync(dest) ? fsImpl.statSync(dest).mode & 0o7777 : null;
299
+ const indent = indentOf(dest, fsImpl);
290
300
  const temp = path.join(path.dirname(dest), `.${path.basename(dest)}.${process.pid}.${Date.now()}.tmp`);
291
301
  try {
292
- fsImpl.writeFileSync(temp, `${JSON.stringify(doc, null, 2)}\n`, { encoding: 'utf8', flag: 'wx', ...(mode === null ? {} : { mode }) });
302
+ fsImpl.writeFileSync(temp, `${JSON.stringify(doc, null, indent)}\n`, { encoding: 'utf8', flag: 'wx', ...(mode === null ? {} : { mode }) });
293
303
  if (mode !== null) fsImpl.chmodSync(temp, mode);
294
304
  fsImpl.renameSync(temp, dest);
295
305
  } finally {
@@ -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
@@ -136,7 +136,7 @@ function cmdStatus(o ) {
136
136
  if (line) console.log(line);
137
137
  if (status.errors.length) console.log(` ${`${label} issues:`.padEnd(24)}${status.errors.join('; ')}`);
138
138
  if (harness === 'codex') {
139
- console.log(' Codex capability gaps: no Claude-only event emulation is installed; review docs/capability-map.md#codex-capability-detail');
139
+ console.log(' Codex capability gaps: no Claude-only event emulation is installed');
140
140
  }
141
141
  }
142
142
  if (ctx.registry) {
@@ -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,