@khoavu882/doflow 2.0.0-beta.4 → 2.0.0-beta.5

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 (101) hide show
  1. package/core/.antigravity-plugin/plugin.json +0 -1
  2. package/core/.claude-plugin/plugin.json +1 -1
  3. package/core/.codex-plugin/plugin.json +1 -1
  4. package/core/.plugin/plugin.json +1 -1
  5. package/core/harnesses/claude/hooks/mcp-tool-guard.sh +7 -17
  6. package/core/harnesses/claude/hooks/pre-bash-guard.sh +7 -17
  7. package/core/harnesses/claude/hooks/pre-implement-gate.sh +7 -18
  8. package/core/harnesses/claude/hooks/session-start.sh +5 -3
  9. package/core/harnesses/claude/hooks/stop-check.sh +5 -3
  10. package/core/harnesses/claude/hooks/subagent-audit.sh +5 -5
  11. package/core/harnesses/claude/hooks/user-prompt-submit.sh +5 -3
  12. package/core/harnesses/codex/hooks/mcp-tool-guard.sh +7 -15
  13. package/core/harnesses/codex/hooks/pre-bash-guard.sh +7 -15
  14. package/core/harnesses/codex/hooks/pre-implement-gate.sh +7 -16
  15. package/core/harnesses/codex/hooks/session-start.sh +5 -3
  16. package/core/harnesses/codex/hooks/stop-check.sh +6 -5
  17. package/core/harnesses/codex/hooks/subagent-audit.sh +17 -6
  18. package/core/harnesses/codex/hooks/user-prompt-submit.sh +5 -4
  19. package/core/harnesses/gemini/hooks/mcp-tool-guard.sh +7 -18
  20. package/core/harnesses/gemini/hooks/pre-bash-guard.sh +7 -18
  21. package/core/harnesses/gemini/hooks/pre-implement-gate.sh +7 -19
  22. package/core/harnesses/gemini/hooks/session-start.sh +5 -3
  23. package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +20 -5
  24. package/core/harnesses/kiro/hooks/pre-bash-guard.sh +20 -5
  25. package/core/harnesses/kiro/hooks/pre-implement-gate.sh +20 -5
  26. package/core/harnesses/kiro/hooks/session-start.sh +11 -5
  27. package/core/harnesses/kiro/hooks/stop-check.sh +21 -30
  28. package/core/harnesses/pi/extensions/doflow-guard.js +159 -0
  29. package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +27 -11
  30. package/core/harnesses/shared/hooks/adapters/antigravity.js +14 -57
  31. package/core/harnesses/shared/hooks/bridge/bridge.sh +184 -0
  32. package/core/harnesses/shared/hooks/bridge/envelope.jq +60 -0
  33. package/core/harnesses/shared/hooks/bridge/respond.sh +62 -0
  34. package/core/harnesses/shared/hooks/policies/capture-failure.sh +4 -2
  35. package/core/harnesses/shared/hooks/policies/contract.sh +75 -0
  36. package/core/harnesses/shared/hooks/policies/deny-json.sh +39 -4
  37. package/core/harnesses/shared/hooks/policies/lib.sh +4 -2
  38. package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +46 -34
  39. package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +75 -25
  40. package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +67 -73
  41. package/core/harnesses/shared/hooks/policies/session-context.sh +37 -7
  42. package/core/harnesses/shared/hooks/policies/stop-check.sh +42 -15
  43. package/core/harnesses/shared/hooks/policies/subagent-audit.sh +35 -5
  44. package/core/harnesses/shared/hooks/policies/user-prompt-submit.sh +40 -7
  45. package/core/harnesses/shared/hooks/stream-hook-runner.js +69 -181
  46. package/core/registry/assets.json +19 -0
  47. package/core/registry/contracts.json +2 -2
  48. package/core/registry/harnesses.json +196 -10
  49. package/core/registry/lifecycle.json +2626 -7
  50. package/core/registry/storage.json +24 -1
  51. package/core/shared/scripts/doflow/bin/doflow-run +10 -6
  52. package/core/shared/skills/do-design/SKILL.md +6 -6
  53. package/core/shared/skills/do-execute-plan/SKILL.md +11 -10
  54. package/core/shared/skills/do-execute-plan/references/readiness_gate.md +2 -0
  55. package/core/shared/skills/do-implement/SKILL.md +3 -2
  56. package/core/shared/skills/do-plan/SKILL.md +7 -8
  57. package/package.json +1 -1
  58. package/src/adapters/antigravity/index.js +83 -47
  59. package/src/adapters/claude/index.js +15 -1
  60. package/src/adapters/codex/hooks.js +40 -5
  61. package/src/adapters/codex/index.js +52 -9
  62. package/src/adapters/copy-tree.js +4 -4
  63. package/src/adapters/gemini/hooks.js +57 -16
  64. package/src/adapters/gemini/index.js +57 -18
  65. package/src/adapters/kiro/index.js +15 -1
  66. package/src/adapters/pi/hooks.js +119 -0
  67. package/src/adapters/pi/index.js +34 -9
  68. package/src/cli/commands/install.js +1 -1
  69. package/src/cli/commands/update.js +1 -1
  70. package/src/cli/index.js +17 -5
  71. package/src/cli/runtime-commands.js +51 -12
  72. package/src/lifecycle/coverage.js +57 -0
  73. package/src/lifecycle/hook-step.js +61 -0
  74. package/src/lifecycle/index.js +28 -14
  75. package/src/registry/index.js +136 -7
  76. package/src/runtime/candidate.js +505 -0
  77. package/src/runtime/claims.js +14 -5
  78. package/src/runtime/cli-result.js +11 -4
  79. package/src/runtime/cli.js +35 -268
  80. package/src/runtime/context/budget.js +203 -0
  81. package/src/runtime/context/compose.js +1367 -0
  82. package/src/runtime/context/delivery-store.js +102 -0
  83. package/src/runtime/context/mandatory.js +594 -0
  84. package/src/runtime/context/pointer.js +142 -0
  85. package/src/runtime/context-pack.js +1029 -244
  86. package/src/runtime/evidence-fields.js +39 -0
  87. package/src/runtime/evidence-ledger.js +7 -0
  88. package/src/runtime/freshness.js +44 -7
  89. package/src/runtime/outcome.js +1 -1
  90. package/src/runtime/readiness.js +83 -13
  91. package/src/runtime/retrieval-plan.js +422 -73
  92. package/src/runtime/retrieval-record.js +223 -0
  93. package/src/runtime/scaffold/artifacts.js +3 -0
  94. package/src/runtime/source-version.js +289 -0
  95. package/src/runtime/task-attempts.js +1 -1
  96. package/src/runtime/task-scope.js +28 -3
  97. package/src/runtime/task-state.js +72 -7
  98. package/core/.antigravity-plugin/hooks.json +0 -39
  99. package/core/harnesses/shared/hooks/adapters/arg-fields.js +0 -19
  100. package/core/harnesses/shared/hooks/adapters/gemini.js +0 -114
  101. package/core/harnesses/shared/hooks/hooks.json +0 -47
@@ -578,17 +578,17 @@ function sharedTreeDestDir(rootDir , nativeDir ) {
578
578
  return dest;
579
579
  }
580
580
 
581
- /** The assets that project a tree several harnesses claim at one scope root: the runtime tree, and
582
- * the guidance tree. A harness whose
581
+ /** The assets that project a tree several harnesses claim at one scope root: the runtime, guidance,
582
+ * and shared hooks trees. A harness whose
583
583
  * install predates its own rows for a shared tree (Pi gained guidance rows in v1.15.0) can only
584
584
  * recognise the bytes a sibling recorded. */
585
585
  const SHARED_TREE_ASSETS = new Set([
586
586
  'scripts.doflow', 'runtime.cli', 'runtime.lib', 'runtime.registry',
587
- 'guidance.context-layer', 'kiro.guidance-tree',
587
+ 'guidance.context-layer', 'kiro.guidance-tree', 'hooks.shared',
588
588
  ]);
589
589
 
590
590
  /** For each shared-tree target, the fingerprints other harnesses recorded and which harness
591
- * recorded each, so an update accepts a runtime or guidance tree a sibling wrote (several harnesses
591
+ * recorded each, so an update accepts a runtime, guidance, or shared hooks tree a sibling wrote (several harnesses
592
592
  * claim one `.doflow` file) while bytes no such row recorded are still refused as a hand edit. */
593
593
  function ledgerSiblingFingerprints(resources , harness ) {
594
594
  const byTarget = new Map ();
@@ -14,6 +14,7 @@
14
14
  // if a third harness with the same shape is ever added.
15
15
  const fs = require('node:fs');
16
16
  const path = require('node:path');
17
+ const crypto = require('node:crypto');
17
18
  const { verifyHookCommands } = require('../hook-commands.js');
18
19
  const { mergeHooks } = require('../../helper/settings-merge.js');
19
20
 
@@ -103,7 +104,39 @@ function classifyClaudeGuardrails(claudeHooks = {})
103
104
  * exists but fails to parse is a hard error, not silently treated as empty (this path merges
104
105
  * into content DoFlow does not own — a malformed-file work-around here risks losing it).
105
106
  */
106
- function planGeminiHooks({ config, sourceFile, sourceHooksDir, settingsFile, destinationHooksDir: declaredHooksDir, trusted = false, fsImpl = fs } = {}) {
107
+ /** Every regular, non-JSON file in the shipped scripts directory: what deployGeminiHooks copies. */
108
+ function shippedScripts(scriptsDir , fsImpl = fs) {
109
+ if (!scriptsDir || !fsImpl.existsSync(scriptsDir)) return [];
110
+ return fsImpl.readdirSync(scriptsDir)
111
+ .filter((name ) => !name.endsWith('.json') && fsImpl.statSync(path.join(scriptsDir, name)).isFile());
112
+ }
113
+
114
+ /** DoFlow's record of a deployed hook script: the sha256 of its bytes. */
115
+ function scriptFingerprint(bytes ) { return crypto.createHash('sha256').update(bytes).digest('hex'); }
116
+
117
+ /** The scripts beside the config that differ from the shipped source (or are absent), by name, and
118
+ * those among them a ledger row says were edited since DoFlow wrote them. A script with no row
119
+ * (an install from before scripts were recorded) is DoFlow's to refresh: the config deploy always
120
+ * overwrote it. `rows` are this harness's script rows: { target, fingerprint }. */
121
+ function planHookScripts({ commands, sourceHooksDir, destinationHooksDir, rows = [], force = false, fsImpl = fs } ) {
122
+ const sources = new Map (commands.checks.map((check ) => [check.script, check.file] ));
123
+ for (const name of shippedScripts(sourceHooksDir, fsImpl)) if (!sources.has(name)) sources.set(name, path.join(sourceHooksDir, name));
124
+ const stale = [];
125
+ const edited = [];
126
+ for (const [name, source] of sources) {
127
+ const target = path.join(destinationHooksDir, name);
128
+ if (fsImpl.existsSync(target)) {
129
+ const current = fsImpl.readFileSync(target);
130
+ if (current.equals(fsImpl.readFileSync(source))) continue;
131
+ const row = rows.find((item ) => path.resolve(item.target) === path.resolve(target));
132
+ if (row && row.fingerprint !== scriptFingerprint(current) && !force) edited.push(name);
133
+ }
134
+ stale.push(name);
135
+ }
136
+ return { names: [...sources.keys()], stale, edited };
137
+ }
138
+
139
+ function planGeminiHooks({ config, sourceFile, sourceHooksDir, settingsFile, destinationHooksDir: declaredHooksDir, trusted = false, scriptRows = [], force = false, fsImpl = fs } = {}) {
107
140
  let desired = config;
108
141
  try {
109
142
  if (!desired && sourceFile) desired = JSON.parse(fsImpl.readFileSync(sourceFile, 'utf8'));
@@ -124,20 +157,26 @@ function planGeminiHooks({ config, sourceFile, sourceHooksDir, settingsFile, des
124
157
 
125
158
  const mergedHooks = existing.hooks ? mergeHooks(existing.hooks, desired.hooks) : desired.hooks;
126
159
  const merged = { ...existing, hooks: mergedHooks };
127
- const changed = JSON.stringify(existing.hooks) !== JSON.stringify(mergedHooks);
160
+ const configChanged = JSON.stringify(existing.hooks) !== JSON.stringify(mergedHooks);
128
161
  // Preferred: the declared hooks directory (harnesses.json "paths".hooksDirectory); the derived
129
162
  // sibling-of-settings fallback stays for direct callers passing raw inputs, and resolves to the
130
163
  // same location.
131
164
  const destinationHooksDir = declaredHooksDir || path.join(path.dirname(settingsFile), 'hooks');
165
+ // The scripts are deployed with the hooks key but are not part of it: a script whose bytes differ
166
+ // from the source is redeployed even when the key is already right.
167
+ const scripts = planHookScripts({ commands, sourceHooksDir, destinationHooksDir, rows: scriptRows, force, fsImpl });
168
+ if (scripts.edited.length) {
169
+ return { ok: false, status: 'conflict', settingsFile, existing, changes: [], commands,
170
+ errors: scripts.edited.map((name ) => `Gemini hook script '${name}' was modified outside DoFlow`) };
171
+ }
172
+ const scriptsOnly = !configChanged && scripts.stale.length > 0;
173
+ const changed = configChanged || scriptsOnly;
132
174
  // The scripts deployGeminiHooks copies beside settings.json, so a backup of this change holds them too.
133
- const shipped = sourceHooksDir && fsImpl.existsSync(sourceHooksDir)
134
- ? fsImpl.readdirSync(sourceHooksDir).filter((name ) => !name.endsWith('.json') && fsImpl.statSync(path.join(sourceHooksDir, name)).isFile())
135
- : [];
136
175
  const companionTargets = [...new Set([
137
176
  ...commands.checks.map((check ) => path.join(destinationHooksDir, check.script)),
138
- ...shipped.map((name ) => path.join(destinationHooksDir, name)),
177
+ ...shippedScripts(sourceHooksDir, fsImpl).map((name ) => path.join(destinationHooksDir, name)),
139
178
  ])].sort();
140
- return { ok: true, status: changed ? 'change' : 'unchanged', settingsFile, existing, merged,
179
+ return { ok: true, status: changed ? 'change' : 'unchanged', settingsFile, existing, merged, scriptsOnly, scriptNames: scripts.names,
141
180
  changes: changed ? [{ type: fsImpl.existsSync(settingsFile) ? 'update' : 'create', file: settingsFile, key: 'hooks' }] : [],
142
181
  errors: [], commands, trust: commands.trust, scriptsDir: sourceHooksDir, destinationHooksDir, companionTargets };
143
182
  }
@@ -145,14 +184,16 @@ function planGeminiHooks({ config, sourceFile, sourceHooksDir, settingsFile, des
145
184
  function deployGeminiHooks(plan , { dryRun = false, fsImpl = fs } = {}) {
146
185
  if (!plan.ok) return { ...plan, applied: false };
147
186
  if (dryRun || plan.status === 'unchanged') return { ...plan, applied: false };
148
- fsImpl.mkdirSync(path.dirname(plan.settingsFile), { recursive: true });
149
- const content = `${JSON.stringify(plan.merged, null, 2)}\n`;
150
- const temporary = path.join(path.dirname(plan.settingsFile), `.${path.basename(plan.settingsFile)}.${process.pid}.${Date.now()}.tmp`);
151
- try {
152
- fsImpl.writeFileSync(temporary, content, { flag: 'wx' });
153
- fsImpl.renameSync(temporary, plan.settingsFile);
154
- } finally {
155
- if (fsImpl.existsSync(temporary)) fsImpl.unlinkSync(temporary);
187
+ if (!plan.scriptsOnly) {
188
+ fsImpl.mkdirSync(path.dirname(plan.settingsFile), { recursive: true });
189
+ const content = `${JSON.stringify(plan.merged, null, 2)}\n`;
190
+ const temporary = path.join(path.dirname(plan.settingsFile), `.${path.basename(plan.settingsFile)}.${process.pid}.${Date.now()}.tmp`);
191
+ try {
192
+ fsImpl.writeFileSync(temporary, content, { flag: 'wx' });
193
+ fsImpl.renameSync(temporary, plan.settingsFile);
194
+ } finally {
195
+ if (fsImpl.existsSync(temporary)) fsImpl.unlinkSync(temporary);
196
+ }
156
197
  }
157
198
  const deployed = new Set ();
158
199
  for (const check of plan.commands.checks) {
@@ -207,4 +248,4 @@ function deployRemoveGeminiHooks(plan , { dryRun = false, fsImpl = fs }
207
248
  return { ...plan, applied: true };
208
249
  }
209
250
 
210
- module.exports = { SUPPORTED_EVENTS, validateHooksConfig, classifyClaudeGuardrails, verifyHookCommands, planGeminiHooks, deployGeminiHooks, planRemoveGeminiHooks, deployRemoveGeminiHooks };
251
+ module.exports = { SUPPORTED_EVENTS, validateHooksConfig, classifyClaudeGuardrails, verifyHookCommands, planGeminiHooks, deployGeminiHooks, scriptFingerprint, shippedScripts, planRemoveGeminiHooks, deployRemoveGeminiHooks };
@@ -9,7 +9,7 @@ const fs = require('node:fs');
9
9
  const path = require('node:path');
10
10
  const { planTree, applyTree, removeTree, verifyTree, copyTreeAssets, copyTreeDestDir, ledgerFileResources, ledgerSiblingFingerprints, siblingReplacedNotices, fingerprint, readJson, sourceDirFor, resolveTransform } = require('../copy-tree.js');
11
11
  const { declaredHarnessPaths, resolveHarnessPaths } = require('../../helper/harness-paths.js');
12
- const { planGeminiHooks, deployGeminiHooks, planRemoveGeminiHooks, deployRemoveGeminiHooks } = require('./hooks.js');
12
+ const { planGeminiHooks, deployGeminiHooks, planRemoveGeminiHooks, deployRemoveGeminiHooks, scriptFingerprint } = require('./hooks.js');
13
13
 
14
14
  const MARKER_START = '<!-- doflow:start -->';
15
15
  const MARKER_END = '<!-- doflow:end -->';
@@ -229,17 +229,32 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
229
229
  return assets.find((asset ) => asset.id === 'guidance.core')?.id ?? assets[0]?.id;
230
230
  }
231
231
 
232
+ /** This scope's ledger rows for the hook scripts deployed beside settings.json. */
233
+ function scriptRows(ledger , scope ) {
234
+ return (ledger?.resources || []).filter((row ) => row?.harness === 'gemini' && row.kind === 'hooks-script' && (!row.scope || row.scope === scope));
235
+ }
236
+
232
237
  /** Plan the hooks pseudo-component's single change (or none), mirroring the {changes, conflicts}
233
238
  * shape planCopyTreeAssets already returns so plan() can fold it in the same way. The hook
234
239
  * scripts directory beside settings.json is declared (paths.hooksDirectory); the merge/remove
235
- * planning itself lives in ./hooks.ts. */
236
- function planHooksChange({ removing, found, context, assets, fsImpl } ) {
240
+ * planning itself lives in ./hooks.ts. A removal also releases the rows of the hook scripts. */
241
+ function planHooksChange({ removing, found, context, assets, ledger, scope, fsImpl } ) {
242
+ const component = planHooksComponentChange({ removing, found, context, assets, ledger, scope, fsImpl });
243
+ if (!removing) return component;
244
+ const releases = scriptRows(ledger, scope).map((row ) => ({ assetId: row.assetId, target: row.target, operation: 'remove',
245
+ ownershipIdentity: row.ownershipIdentity, kind: row.kind, identity: row.identity, projection: row.projection,
246
+ retained: true, retainedFor: [] }));
247
+ return { ...component, changes: [...component.changes, ...releases] };
248
+ }
249
+
250
+ function planHooksComponentChange({ removing, found, context, assets, ledger, scope, fsImpl } ) {
237
251
  if (found.settings.error) return { changes: [], conflicts: [] };
238
252
  const hooksPlan = removing
239
253
  ? planRemoveGeminiHooks({ settingsFile: found.paths.settings, fsImpl })
240
254
  : (context.geminiHooksSourceFile
241
255
  ? planGeminiHooks({ sourceFile: context.geminiHooksSourceFile, sourceHooksDir: context.geminiHooksSourceDir,
242
- settingsFile: found.paths.settings, destinationHooksDir: found.paths.hooksDirectory, trusted: context.hooksTrusted, fsImpl })
256
+ settingsFile: found.paths.settings, destinationHooksDir: found.paths.hooksDirectory, trusted: context.hooksTrusted,
257
+ scriptRows: scriptRows(ledger, scope), force: context.force === true, fsImpl })
243
258
  : null);
244
259
  if (hooksPlan && !hooksPlan.ok) return { changes: [], conflicts: hooksPlan.errors || [] };
245
260
  if (hooksPlan?.status !== 'change') return { changes: [], conflicts: [] };
@@ -270,7 +285,7 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
270
285
  const copyTree = planCopyTreeAssets({ assets, scope, scopeRoot, context, ledger, removing, fsImpl });
271
286
  changes.push(...copyTree.changes); conflicts.push(...copyTree.conflicts);
272
287
  if (found.settings.error) conflicts.push(found.settings.error);
273
- const hooks = planHooksChange({ removing, found, context, assets, fsImpl });
288
+ const hooks = planHooksChange({ removing, found, context, assets, ledger, scope, fsImpl });
274
289
  changes.push(...hooks.changes); conflicts.push(...hooks.conflicts);
275
290
  return {
276
291
  changes, conflicts, prerequisites: [], notices: copyTree.notices,
@@ -331,6 +346,37 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
331
346
  removeCopyTreeAssets(changes, { fsImpl });
332
347
  }
333
348
 
349
+ /** Whether the hooks pseudo-component is on disk as the source declares it, with the resource that
350
+ * records it when so. Null status when the run carries no hooks source or settings.json is unreadable. */
351
+ function verifyHooksComponent({ found, assets, context, fsImpl } ) {
352
+ if (!context.geminiHooksSourceFile || found.settings.error) return { hooksStatus: null, resources: [], conflicts: [] };
353
+ const hooksPlan = planGeminiHooks({ sourceFile: context.geminiHooksSourceFile, sourceHooksDir: context.geminiHooksSourceDir,
354
+ settingsFile: found.paths.settings, destinationHooksDir: found.paths.hooksDirectory, trusted: context.hooksTrusted, fsImpl });
355
+ const hooksStatus = hooksPlan.ok && hooksPlan.status === 'unchanged' ? 'managed' : (hooksPlan.ok ? 'missing' : 'conflict');
356
+ const resources = hooksStatus === 'managed' ? [{ assetId: hooksAssetId(assets), target: hooksPlan.settingsFile, ownershipIdentity: 'gemini:hooks',
357
+ fingerprint: fingerprint(hooksPlan.merged.hooks), sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'gemini-hooks' } },
358
+ ...hooksPlan.scriptNames.map((identity ) => {
359
+ const target = path.join(hooksPlan.destinationHooksDir, identity);
360
+ return { assetId: hooksAssetId(assets), target, ownershipIdentity: `gemini:hooks-script:${identity}`, kind: 'hooks-script', identity,
361
+ fingerprint: scriptFingerprint(fs.readFileSync(target)), sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'gemini-hooks' } };
362
+ })] : [];
363
+ return { hooksStatus, resources, conflicts: hooksPlan.ok ? [] : (hooksPlan.errors || []) };
364
+ }
365
+
366
+ /** The hook front doors (IC-037): the hooks pseudo-component alone. */
367
+ function planHookDoors({ scope, scopeRoot, assets = [], discovery, context = {}, ledger, fsImpl = fs } ) {
368
+ const found = discovery || discover({ scope, scopeRoot, context, fsImpl });
369
+ const hooks = planHooksChange({ removing: false, found, context, assets, ledger, scope, fsImpl });
370
+ return { changes: hooks.changes, conflicts: [...(found.settings.error ? [found.settings.error] : []), ...hooks.conflicts], prerequisites: [], notices: [] };
371
+ }
372
+
373
+ function verifyHookDoors({ scope, scopeRoot, assets = [], context = {}, fsImpl = fs } ) {
374
+ const found = discover({ scope, scopeRoot, context, fsImpl });
375
+ const hooks = verifyHooksComponent({ found, assets, context, fsImpl });
376
+ const conflicts = [...(found.settings.error ? [found.settings.error] : []), ...hooks.conflicts];
377
+ return { ok: conflicts.length === 0 && hooks.hooksStatus !== 'conflict', resources: hooks.resources, statuses: { hooks: hooks.hooksStatus }, conflicts };
378
+ }
379
+
334
380
  function verify({ scope, scopeRoot, assets = [], context = {}, fsImpl = fs } ) {
335
381
  const found = discover({ scope, scopeRoot, context, fsImpl });
336
382
  const resources = [];
@@ -339,23 +385,16 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
339
385
  }
340
386
  const copyTree = verifyCopyTreeAssets({ assets, scope, scopeRoot, context, fsImpl });
341
387
  resources.push(...copyTree.resources);
342
- let hooksStatus = null; const conflicts = [...(found.settings.error ? [found.settings.error] : []), ...copyTree.conflicts];
343
- if (context.geminiHooksSourceFile && !found.settings.error) {
344
- const hooksPlan = planGeminiHooks({ sourceFile: context.geminiHooksSourceFile, sourceHooksDir: context.geminiHooksSourceDir,
345
- settingsFile: found.paths.settings, destinationHooksDir: found.paths.hooksDirectory, trusted: context.hooksTrusted, fsImpl });
346
- hooksStatus = hooksPlan.ok && hooksPlan.status === 'unchanged' ? 'managed' : (hooksPlan.ok ? 'missing' : 'conflict');
347
- if (!hooksPlan.ok) conflicts.push(...(hooksPlan.errors || []));
348
- if (hooksStatus === 'managed') {
349
- resources.push({ assetId: hooksAssetId(assets), target: hooksPlan.settingsFile, ownershipIdentity: 'gemini:hooks',
350
- fingerprint: fingerprint(hooksPlan.merged.hooks), sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'gemini-hooks' } });
351
- }
352
- }
388
+ const hooks = verifyHooksComponent({ found, assets, context, fsImpl });
389
+ resources.push(...hooks.resources);
390
+ const hooksStatus = hooks.hooksStatus;
391
+ const conflicts = [...(found.settings.error ? [found.settings.error] : []), ...copyTree.conflicts, ...hooks.conflicts];
353
392
  return { ok: !found.settings.error && copyTree.conflicts.length === 0 && hooksStatus !== 'conflict', resources,
354
393
  statuses: { settings: found.settings.error ? 'invalid' : 'supported', extensions: 'different', policies: policyStatuses(context.policies), copyTree: copyTree.statuses, hooks: hooksStatus },
355
394
  conflicts };
356
395
  }
357
396
 
358
- return { nativePaths, discover, render, plan, apply, remove, verify };
397
+ return { nativePaths, discover, render, plan, apply, remove, verify, planHookDoors, verifyHookDoors };
359
398
  }
360
399
 
361
400
  const singleton = createGeminiAdapter();
@@ -385,6 +424,6 @@ module.exports = {
385
424
  MARKER_START, MARKER_END,
386
425
  nativePaths: singleton.nativePaths, discover: singleton.discover, render: singleton.render,
387
426
  managedInstruction, plan: singleton.plan, apply: singleton.apply, remove: singleton.remove,
388
- verify: singleton.verify, planGeminiHooks, hookTrustFor,
427
+ verify: singleton.verify, planHookDoors: singleton.planHookDoors, verifyHookDoors: singleton.verifyHookDoors, planGeminiHooks, hookTrustFor,
389
428
  createGeminiAdapter,
390
429
  };
@@ -258,6 +258,19 @@ function createKiroAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] } =
258
258
  };
259
259
  }
260
260
 
261
+ /** The hook front doors (IC-037): the scripts and the agent that wires them. */
262
+ function hookDoorAssets(assets ) { return (assets || []).filter((asset ) => asset.id === 'kiro.hooks-scripts' || asset.id === 'kiro.doflow-agent'); }
263
+
264
+ function planHookDoors({ scope, scopeRoot, assets = [], context = {}, ledger, fsImpl = fs } ) {
265
+ const doors = planCopyTreeAssets({ assets: hookDoorAssets(assets), scope, scopeRoot, context, ledger, removing: false, fsImpl });
266
+ return { changes: doors.changes, conflicts: doors.conflicts, prerequisites: [], notices: doors.notices };
267
+ }
268
+
269
+ function verifyHookDoors({ scope, scopeRoot, assets = [], context = {}, fsImpl = fs } ) {
270
+ const doors = verifyCopyTreeAssets({ assets: hookDoorAssets(assets), scope, scopeRoot, context, fsImpl });
271
+ return { ok: doors.conflicts.length === 0, statuses: doors.statuses, resources: doors.resources, conflicts: doors.conflicts };
272
+ }
273
+
261
274
  function apply({ changes = [], fsImpl = fs } ) {
262
275
  writeMcpEntries(changes, { container: MCP_CONTAINER, fsImpl });
263
276
  applyCopyTreeAssets(changes, { fsImpl });
@@ -297,7 +310,7 @@ function createKiroAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] } =
297
310
  };
298
311
  }
299
312
 
300
- return { nativePaths, discover, render, plan, apply, remove, verify, buildServerEntry };
313
+ return { nativePaths, discover, render, plan, apply, remove, verify, planHookDoors, verifyHookDoors, buildServerEntry };
301
314
  }
302
315
 
303
316
  const singleton = createKiroAdapter();
@@ -305,6 +318,7 @@ const singleton = createKiroAdapter();
305
318
  module.exports = {
306
319
  nativePaths: singleton.nativePaths, discover: singleton.discover, render: singleton.render,
307
320
  plan: singleton.plan, apply: singleton.apply, remove: singleton.remove, verify: singleton.verify,
321
+ planHookDoors: singleton.planHookDoors, verifyHookDoors: singleton.verifyHookDoors,
308
322
  buildServerEntry: singleton.buildServerEntry,
309
323
  createKiroAdapter,
310
324
  };
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ // TypeScript migration (agent-docs/BRIEF_GOAL_DOFLOW.md): run directly by Node native type-stripping.
4
+
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+ const { fingerprint } = require('../copy-tree.js');
8
+
9
+ const ASSET_ID = 'pi.guard-extension';
10
+ const EXTENSION_NAME = 'doflow-guard.js';
11
+ const OWNERSHIP = 'pi:guard-extension';
12
+ const TEMPLATE = 'core/harnesses/pi/extensions/doflow-guard.js';
13
+ const BRIDGE_SUFFIX = ['.doflow', 'shared', 'hooks', 'bridge', 'bridge.sh'];
14
+
15
+ function userAgentDir(scopeRoot , env ) {
16
+ const override = env?.PI_CODING_AGENT_DIR;
17
+ return typeof override === 'string' && override.trim() ? path.resolve(override.trim()) : path.join(scopeRoot, '.pi', 'agent');
18
+ }
19
+
20
+ function targetPath(scopeRoot , env , scope = 'user') {
21
+ return scope === 'project'
22
+ ? path.join(scopeRoot, '.pi', 'extensions', EXTENSION_NAME)
23
+ : path.join(userAgentDir(scopeRoot, env), 'extensions', EXTENSION_NAME);
24
+ }
25
+
26
+ function renderExtension({ repoRoot, scopeRoot, target, fsImpl = fs } ) {
27
+ const templatePath = path.join(repoRoot, TEMPLATE);
28
+ const template = fsImpl.readFileSync(templatePath, 'utf8');
29
+ const bridgePath = path.join(scopeRoot, ...BRIDGE_SUFFIX);
30
+ const relative = path.relative(path.dirname(target), bridgePath).split(path.sep).join('/');
31
+ const rendered = template.replace("const BRIDGE = new URL('../../shared/hooks/bridge/bridge.sh', import.meta.url);",
32
+ `const BRIDGE = new URL(${JSON.stringify(relative)}, import.meta.url);`);
33
+ if (rendered === template) throw new Error('Pi extension bridge path template was not found');
34
+ return rendered;
35
+ }
36
+
37
+ function planPiHooks({ scope, scopeRoot, assets = [], ledger, context = {}, adopt = false, env = process.env, fsImpl = fs } ) {
38
+ if (scope !== 'user' && scope !== 'global' && scope !== 'project') return { changes: [], conflicts: [], notices: [] };
39
+ const asset = assets.find((row ) => row.id === ASSET_ID);
40
+ if (!asset) return { changes: [], conflicts: [], notices: [] };
41
+ const computedTarget = targetPath(scopeRoot, env, scope);
42
+ const removing = context.operation === 'remove';
43
+ const ownedRows = (ledger?.resources || []).filter((row ) => row.harness === 'pi' && row.assetId === ASSET_ID
44
+ && row.ownershipIdentity === OWNERSHIP);
45
+ const exactOwned = ownedRows.find((row ) => row.target === computedTarget);
46
+ const owned = exactOwned || (removing && ownedRows.length === 1 ? ownedRows[0] : undefined);
47
+ const target = removing && owned ? owned.target : computedTarget;
48
+ const exists = fsImpl.existsSync(target);
49
+ const notices = [];
50
+ if (removing) {
51
+ if (!owned) return { changes: [], conflicts: [], notices };
52
+ if (!exists) return { changes: [{ assetId: ASSET_ID, target, operation: 'remove', release: true,
53
+ ownershipIdentity: OWNERSHIP, harness: 'pi', projection: { renderer: 'pi-guard-extension' } }], conflicts: [], notices };
54
+ const current = fsImpl.readFileSync(target, 'utf8');
55
+ if (fingerprint(current) !== owned.fingerprint) {
56
+ notices.push(`kept hand-edited ${target}; DoFlow no longer manages it`);
57
+ return { changes: [{ assetId: ASSET_ID, target, operation: 'remove', release: true,
58
+ ownershipIdentity: OWNERSHIP, harness: 'pi', projection: { renderer: 'pi-guard-extension' } }], conflicts: [], notices };
59
+ }
60
+ return { changes: [{ assetId: ASSET_ID, target, operation: 'remove', content: null,
61
+ ownershipIdentity: OWNERSHIP, harness: 'pi', projection: { renderer: 'pi-guard-extension' } }], conflicts: [], notices };
62
+ }
63
+
64
+ const content = renderExtension({ repoRoot: context.repoRoot, scopeRoot, target, fsImpl });
65
+ if (!exists) return { changes: [{ assetId: ASSET_ID, target, operation: 'create', content,
66
+ ownershipIdentity: OWNERSHIP, harness: 'pi', fingerprint: fingerprint(content),
67
+ projection: { renderer: 'pi-guard-extension' } }], conflicts: [], notices };
68
+ if (!owned && !adopt) {
69
+ return { changes: [], conflicts: [`Pi extension already exists and is not owned by DoFlow: ${target}`], notices };
70
+ }
71
+ const current = fsImpl.readFileSync(target, 'utf8');
72
+ if (owned && fingerprint(current) !== owned.fingerprint) {
73
+ return { changes: [], conflicts: [`Pi extension was modified outside DoFlow: ${target}`], notices };
74
+ }
75
+ if (current === content) return { changes: [], conflicts: [], notices };
76
+ return { changes: [{ assetId: ASSET_ID, target, operation: 'update', content,
77
+ ownershipIdentity: OWNERSHIP, harness: 'pi', fingerprint: fingerprint(content),
78
+ projection: { renderer: 'pi-guard-extension' } }], conflicts: [], notices };
79
+ }
80
+
81
+ function verifyPiHooks({ scope, scopeRoot, assets = [], ledger, context = {}, adopt = false, env = process.env, fsImpl = fs, pathEnv = process.env.PATH } ) {
82
+ if (scope !== 'user' && scope !== 'global' && scope !== 'project') return { statuses: [], resources: [], conflicts: [] };
83
+ const asset = assets.find((row ) => row.id === ASSET_ID);
84
+ if (!asset) return { statuses: [], resources: [], conflicts: [] };
85
+ const target = targetPath(scopeRoot, env, scope);
86
+ const owned = (ledger?.resources || []).find((row ) => row.harness === 'pi' && row.assetId === ASSET_ID
87
+ && row.ownershipIdentity === OWNERSHIP && row.target === target);
88
+ const bridgePath = path.join(scopeRoot, ...BRIDGE_SUFFIX);
89
+ const present = fsImpl.existsSync(target);
90
+ const content = present ? fsImpl.readFileSync(target, 'utf8') : null;
91
+ const actualFingerprint = content === null ? null : fingerprint(content);
92
+ const fingerprintMatches = !!owned && actualFingerprint === owned.fingerprint;
93
+ const plannedWrite = (context?.plannedWrites || []).includes(target);
94
+ const bridgeMatches = !!content && content.includes(JSON.stringify(path.relative(path.dirname(target), bridgePath).split(path.sep).join('/')));
95
+ const commandOnPath = (command ) => String(pathEnv || '').split(path.delimiter)
96
+ .some((dir) => fsImpl.existsSync(path.join(dir, command)));
97
+ const status = present && !owned && !plannedWrite && !adopt ? 'conflict'
98
+ : (present && (fingerprintMatches || plannedWrite) && bridgeMatches && commandOnPath('bash') && commandOnPath('jq') ? 'managed' : 'absent');
99
+ const resources = status === 'managed' ? [{ assetId: ASSET_ID, target, ownershipIdentity: OWNERSHIP,
100
+ fingerprint: actualFingerprint, sourceVersion: context.sourceVersion ?? 'unknown',
101
+ projection: { renderer: 'pi-guard-extension' } }] : [];
102
+ const statuses = [{ harness: 'pi', assetId: ASSET_ID, capability: 'hooks', status, target,
103
+ ownershipIdentity: OWNERSHIP, fingerprint: actualFingerprint, fingerprintMatches,
104
+ bridgePath, bridgeExists: fsImpl.existsSync(bridgePath), bash: commandOnPath('bash'), jq: commandOnPath('jq'),
105
+ prerequisites: scope === 'project' ? ['project-trust'] : [], piCodingAgentDir: typeof env?.PI_CODING_AGENT_DIR === 'string' && env.PI_CODING_AGENT_DIR.trim() ? env.PI_CODING_AGENT_DIR.trim() : null }];
106
+ const conflicts = status === 'conflict' ? [`Pi extension is not owned by DoFlow: ${target}`] : [];
107
+ return { statuses, resources, conflicts };
108
+ }
109
+
110
+ function removePiHooks(changes , { fsImpl = fs } = {}) {
111
+ let removed = 0;
112
+ for (const change of changes) {
113
+ if (change.projection?.renderer !== 'pi-guard-extension' || change.operation !== 'remove' || change.release) continue;
114
+ if (fsImpl.existsSync(change.target)) { fsImpl.unlinkSync(change.target); removed += 1; }
115
+ }
116
+ return removed;
117
+ }
118
+
119
+ module.exports = { ASSET_ID, EXTENSION_NAME, OWNERSHIP, userAgentDir, targetPath, renderExtension, planPiHooks, verifyPiHooks, removePiHooks };
@@ -13,9 +13,7 @@
13
13
  //
14
14
  // MCP servers are merged per server into Pi's mcp.json (.pi/mcp.json project; ~/.pi/agent/mcp.json
15
15
  // or $PI_CODING_AGENT_DIR/mcp.json user) by ./mcp.ts; Pi reads it through its built-in MCP from
16
- // 0.99.0. Hooks are absent — Pi lifecycle handlers are TypeScript modules registered via pi.on(),
17
- // executing with full system permissions, which is a different trust model from the shell scripts
18
- // DoFlow ships.
16
+ // 0.99.0. The user- and project-scope lifecycle extension is ledger-owned and resolves its shared bridge at install time.
19
17
  //
20
18
  // Evidence: https://pi.dev/docs/latest/settings, https://pi.dev/docs/latest/skills,
21
19
  // https://pi.dev/docs/latest/quickstart, https://pi.dev/docs/latest/extensions,
@@ -27,9 +25,11 @@ const { MARKER_START, MARKER_END } = require('../../helper/marker-merge.js');
27
25
  const { planTree, applyTree, removeTree, verifyTree, copyTreeAssets, copyTreeDestDir, sharedTreeDestDir, ledgerFileResources, ledgerSiblingFingerprints, siblingReplacedNotices, fingerprint, sourceDirFor } = require('../copy-tree.js');
28
26
  const { declaredHarnessPaths, resolveHarnessPaths } = require('../../helper/harness-paths.js');
29
27
  const { mcpRows, discoverPiMcp, ownedPiMcpIds, planPiMcp, applyPiMcp, verifyPiMcp } = require('./mcp.js');
28
+ const { planPiHooks, verifyPiHooks, removePiHooks } = require('./hooks.js');
30
29
 
31
30
  const HARNESS = 'pi';
32
- const AGENT_DIR_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.';
31
+ 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.';
32
+ const AGENT_DIR_NOTICE = 'PI_CODING_AGENT_DIR is set: Pi reads its whole agent dir from it, and DoFlow moves mcp.json and the guard extension there; skills and AGENTS.md stay in ~/.pi/agent.';
33
33
 
34
34
  // Native path facts live in core/registry/harnesses.json under this harness's "paths" section and
35
35
  // resolve through the shared harness-paths resolver; nothing below hardcodes them.
@@ -43,8 +43,8 @@ function createPiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS], env
43
43
  function nativePaths({ scope, scopeRoot } = {}) {
44
44
  const paths = resolveHarnessPaths(declaredPaths, { scope, scopeRoot });
45
45
  // Pi's user-scope mcp.json follows $PI_CODING_AGENT_DIR when set. It is not declared in the
46
- // registry because the path schema has no environment base (src/helper/harness-paths.js); only
47
- // mcp moves, skills and AGENTS.md keep their declared locations.
46
+ // registry because the path schema has no environment base (src/helper/harness-paths.js); the
47
+ // guard extension follows it too, while skills and AGENTS.md keep their declared locations.
48
48
  if ((scope === 'user' || scope === 'global') && agentDirOverride()) paths.mcp = path.join(path.resolve(agentDirOverride()), 'mcp.json');
49
49
  return paths;
50
50
  }
@@ -191,15 +191,32 @@ function createPiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS], env
191
191
  changes.push(...copyTree.changes);
192
192
  conflicts.push(...copyTree.conflicts);
193
193
 
194
+ const piHooks = planPiHooks({ scope, scopeRoot, assets, ledger, context, adopt: context?.adopt === true, env, fsImpl });
195
+ changes.push(...piHooks.changes);
196
+ conflicts.push(...piHooks.conflicts);
197
+
194
198
  const piMcp = planPiMcp({ selected: mcp, rows: mcpRows(ledger), file: found.paths.mcp, scope, removing, snapshot: found.mcp, assets });
195
199
  changes.push(...piMcp.changes);
196
200
  conflicts.push(...piMcp.conflicts);
197
201
 
198
- const notices = [...copyTree.notices, ...piMcp.notices];
199
- if (!removing && scope !== 'project' && agentDirOverride()) notices.push(AGENT_DIR_NOTICE);
202
+ const notices = [...copyTree.notices, ...piMcp.notices, ...piHooks.notices];
203
+ if (!removing && scope !== 'project' && agentDirOverride()) {
204
+ notices.push(assets.some((asset ) => asset.id === 'pi.guard-extension') ? AGENT_DIR_NOTICE : AGENT_DIR_MCP_NOTICE);
205
+ }
200
206
  return { changes, conflicts, notices, paths: found.paths };
201
207
  }
202
208
 
209
+ /** The hook front door (IC-037): the guard extension. */
210
+ function planHookDoors({ scope, scopeRoot, assets = [], context = {}, ledger, fsImpl = fs } ) {
211
+ const door = planPiHooks({ scope, scopeRoot, assets, ledger, context, adopt: context?.adopt === true, env, fsImpl });
212
+ return { changes: door.changes, conflicts: door.conflicts, notices: door.notices };
213
+ }
214
+
215
+ function verifyHookDoors({ scope, scopeRoot, assets = [], ledger, context = {}, fsImpl = fs } ) {
216
+ const door = verifyPiHooks({ scope, scopeRoot, assets, ledger, context, adopt: context?.adopt === true, env, fsImpl });
217
+ return { ok: door.conflicts.length === 0 && !door.statuses.some((s ) => s.status === 'invalid' || s.status === 'conflict'), ...door };
218
+ }
219
+
203
220
  function writeChange(change , fsImpl ) {
204
221
  fsImpl.mkdirSync(path.dirname(change.target), { recursive: true });
205
222
  fsImpl.writeFileSync(change.target, change.content, 'utf8');
@@ -226,10 +243,12 @@ function createPiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS], env
226
243
  let removed = applyPiMcp(changes.filter(isPiMcp), { fsImpl }).applied;
227
244
  for (const change of changes) {
228
245
  if (change.operation !== 'remove' || change.projection?.renderer === 'copy-tree' || isPiMcp(change)) continue;
246
+ if (change.projection?.renderer === 'pi-guard-extension') continue;
229
247
  if (change.content === null) continue;
230
248
  writeChange(change, fsImpl);
231
249
  removed += 1;
232
250
  }
251
+ removed += removePiHooks(changes, { fsImpl });
233
252
  removed += removeCopyTreeAssets(changes, { fsImpl });
234
253
  return { removed };
235
254
  }
@@ -263,10 +282,15 @@ function createPiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS], env
263
282
  resources.push(...piMcp.resources);
264
283
  conflicts.push(...piMcp.conflicts);
265
284
 
285
+ const piHooks = verifyPiHooks({ scope, scopeRoot, assets, ledger, context, adopt: context?.adopt === true, env, fsImpl });
286
+ statuses.push(...piHooks.statuses);
287
+ resources.push(...piHooks.resources);
288
+ conflicts.push(...piHooks.conflicts);
289
+
266
290
  return { ok: conflicts.length === 0 && !statuses.some((s) => s.status === 'invalid' || s.status === 'conflict'), resources, statuses, conflicts };
267
291
  }
268
292
 
269
- return { nativePaths, discover, render, plan, apply, remove, verify };
293
+ return { nativePaths, discover, render, plan, apply, remove, verify, planHookDoors, verifyHookDoors };
270
294
  }
271
295
 
272
296
  const singleton = createPiAdapter();
@@ -274,6 +298,7 @@ const singleton = createPiAdapter();
274
298
  module.exports = {
275
299
  HARNESS, nativePaths: singleton.nativePaths, discover: singleton.discover, render: singleton.render,
276
300
  plan: singleton.plan, apply: singleton.apply, remove: singleton.remove, verify: singleton.verify,
301
+ planHookDoors: singleton.planHookDoors, verifyHookDoors: singleton.verifyHookDoors,
277
302
  managedInstruction: (singleton ).managedInstruction, strippedInstruction: (singleton ).strippedInstruction,
278
303
  createPiAdapter,
279
304
  };
@@ -55,7 +55,7 @@ function cmdInstall(o ) {
55
55
  // One lifecycle view across every requested target — computed unconditionally (not only under
56
56
  // --dry-run) so its safety gate and its plan are the exact same object the real apply below uses.
57
57
  const lifecycleView = registryLifecycleView({ registry, repoRoot: REPO_ROOT, scope, dirs, targets,
58
- mcpSelections: selection.selections, mcpAdoptable: selection.adoptable, retainedMcpIds: selection.retainedMcpIds,
58
+ mcpSelections: selection.selections, mcpAdoptable: selection.adoptable, retainedMcpIds: selection.retainedMcpIds, operation: 'install',
59
59
  force: o.force, adopt: o.adopt === true, permissions: o.permissions === true, statusline: o.statusline === true });
60
60
  if (!lifecycleView.plan.safe) { assertSafeRegistryPlan(lifecycleView); return; }
61
61
 
@@ -51,7 +51,7 @@ function cmdUpdate(o ) {
51
51
  // One lifecycle view across every requested target — computed unconditionally (not only under
52
52
  // --dry-run) so its safety gate and its plan are the exact same object the real apply below uses.
53
53
  const lifecycleView = registryLifecycleView({ registry, repoRoot: REPO_ROOT, scope, dirs, targets,
54
- mcpSelections: selection.selections, mcpAdoptable: selection.adoptable, retainedMcpIds: selection.retainedMcpIds,
54
+ mcpSelections: selection.selections, mcpAdoptable: selection.adoptable, retainedMcpIds: selection.retainedMcpIds, operation: 'update',
55
55
  force: o.force, permissions: o.permissions === true, statusline: o.statusline === true });
56
56
  if (!lifecycleView.plan.safe) { assertSafeRegistryPlan(lifecycleView); return; }
57
57
  printMcpSelection(lifecycleView, selection.sources, { requested: o.mcp, prefix: o.dryRun ? '[DRY]' : '[INFO]' });