@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
package/src/cli/index.js CHANGED
@@ -70,6 +70,8 @@ function parseArgs(argv ) {
70
70
  case '--permissions': o.permissions = true; break;
71
71
  case '--statusline': o.statusline = true; break;
72
72
  case '--json': o.json = true; break;
73
+ case '--bare': o.bare = true; break;
74
+ case '--no-record': o.noRecord = true; break;
73
75
  case '--check': o.check = true; break;
74
76
  // decision --action list: every decision by id, not just the live ones.
75
77
  case '--all': o.all = true; break;
@@ -136,6 +138,7 @@ function parseArgs(argv ) {
136
138
  o.node = val; i++; break;
137
139
  }
138
140
  case '--forced': o.forced = true; break;
141
+ case '--absence': o.absence = true; break;
139
142
  case '--decision': {
140
143
  const val = argv[i + 1];
141
144
  if (val === undefined || val.startsWith('-')) { console.error(`doflow: ${a} requires a value`); process.exit(1); }
@@ -258,6 +261,9 @@ const RUNTIME_STRING_FLAGS = new Map([
258
261
  // change scope, <path>[,<path>...], unioned with the plan's bound
259
262
  ['--invariants', 'invariants'], // readiness: the invariants a refactor must preserve
260
263
  ['--objective', 'objective'], // context-pack
264
+ ['--mandatory-file', 'mandatoryFile'], // context-pack: caller-supplied mandatory fields
265
+ ['--max-bytes', 'maxBytes'], // context-pack: per-call cap in bytes
266
+ ['--expand', 'expand'], // context-pack: pointer id to expand
261
267
  ['--stage-id', 'stageId'], // research-request: active feature stage
262
268
  ['--question', 'question'], // research-request: external question
263
269
  ['--blocking', 'blocking'], // research-request: true | false
@@ -266,6 +272,7 @@ const RUNTIME_STRING_FLAGS = new Map([
266
272
  ['--gap', 'gap'], // research-request: unresolved reason
267
273
  ['--stage', 'stage'], // retrieval-plan: the stage id declaring the plan;
268
274
  // outcome: the stage writing it, refused unless terminal
275
+ ['--status', 'attemptStatus'], // retrieval-plan: attempt status (EMPTY|UNREACHED|UNVERIFIED)
269
276
  ['--state', 'state'], // outcome: the terminal state being recorded
270
277
  ['--result', 'result'], // orchestrate complete-stage: the stage's own outcome, passed | failed (omit → unverified)
271
278
  // outcome: the verdicts the run saw, stated by it and validated against the vocabulary the
@@ -281,6 +288,8 @@ const RUNTIME_STRING_FLAGS = new Map([
281
288
  ['--owner', 'owner'], // brief-tools: the agent type the brief is for
282
289
  ['--tier', 'tier'], // brief-tools: the task line's tier (light | standard | frontier)
283
290
  ['--harness', 'harness'], // brief-tools: the harness the brief is for
291
+ ['--policy', 'policy'], // enforcement: lifecycle policy id
292
+ ['--profile', 'profile'], // enforcement: coverage profile filter
284
293
  ['--heading-level', 'headingLevel'], // brief-tools: 2 (default) or 3
285
294
  ['--tasks', 'tasks'], // brief-tools: comma-separated plan task ids for tier advice
286
295
  ['--plan-path', 'planPath'], // verify: a feature plan.md whose doflow-verification
@@ -388,12 +397,13 @@ Commands:
388
397
  self-update git pull + reinstall
389
398
  tools Inspect or manage registered external tools
390
399
  capabilities Show registered abstract capabilities and resolved providers
400
+ enforcement Report lifecycle policy enforcement coverage (--harness, --policy)
391
401
  doctor System health and capability smoke check diagnostics
392
402
  readiness Evaluate task readiness contract (--task-class, --task-id, both required)
393
403
  evidence Record a stage's evidence batch, or inspect what is recorded (--task-id)
394
404
  claim Record a claim, link evidence to it, or list them (--task-id, --action)
395
405
  context-pack Compile a task's evidence and claims into a context block (--task-id)
396
- retrieval-plan Declare a stage's information needs, then report every declared item
406
+ retrieval-plan Declare a stage's information needs, attempt retrieval, then report every declared item
397
407
  outcome Record a task's terminal state with its basis, or show it (--task-id)
398
408
  classify Validate a proposed task class and return its workflow (--task-class)
399
409
  workflow Resolve a task class to its stages, gates and readiness templates
@@ -453,7 +463,7 @@ Runtime verb arguments (accept --flag value or --flag=value):
453
463
  --task-class classify, workflow, readiness, context-pack, outcome --action record
454
464
  --task-id readiness, evidence, claim, context-pack, retrieval-plan, outcome, verify, advise
455
465
  --action claim: list|add|link|retract|supersede · evidence: list|add|supersede
456
- verify: report|contract · retrieval-plan: declare|report
466
+ verify: report|contract · retrieval-plan: declare|report|attempt
457
467
  outcome: record|show · advise: ask|outcome|configure · tools: see above
458
468
  followup: add|list|take|settle|promote|report
459
469
  task-attempt: claim|finish|release|status
@@ -470,7 +480,9 @@ Runtime verb arguments (accept --flag value or --flag=value):
470
480
  --statement, --claim-id,
471
481
  --evidence-id, --relation claim
472
482
  --kind, --provenance, --provider,
473
- --capability, --locator, --content evidence --action add (one item)
483
+ --capability, --locator, --content,
484
+ --absence evidence --action add (one item)
485
+ --need <intent> evidence --action add (bind evidence to retrieval needs)
474
486
  --batch <file|-> evidence --action add (a stage's JSON batch)
475
487
  --evidence-id, --replaced-by evidence --action supersede (retire one item, point
476
488
  forward to the item that replaces it)
@@ -480,7 +492,7 @@ Runtime verb arguments (accept --flag value or --flag=value):
480
492
  evidence establishes; reported back as such
481
493
  --objective context-pack
482
494
  --need <intent[,intent]>,
483
- --stage retrieval-plan (--need is repeatable), outcome
495
+ --stage, --status retrieval-plan (--need is repeatable), outcome
484
496
  --mcp-outcome <server>=<outcome> orchestrate --action handoff, repeatable: used | nothing-usable |
485
497
  skipped:<reason> | unavailable (a server left out only warns)
486
498
  --state <COMPLETED|BLOCKED|
@@ -565,4 +577,4 @@ async function main(argv ) {
565
577
  }
566
578
  }
567
579
 
568
- module.exports = { main, COMMANDS, commandName };
580
+ module.exports = { main, COMMANDS, commandName, parseArgs };
@@ -24,6 +24,8 @@ const path = require('node:path');
24
24
  const {
25
25
  handleCapabilitiesCommand, handleReadinessCommand, handleEvidenceCommand,
26
26
  } = require('../runtime/cli.js');
27
+ const { loadRegistry } = require('../registry/index.js');
28
+ const { enforcementFor, UnknownEnforcementTarget } = require('../lifecycle/coverage.js');
27
29
  // `doctor` comes from the health module rather than src/runtime/cli.js: the health-probe report
28
30
  // (FR-013) supersedes the presence-check version, and one verb must have one implementation.
29
31
  const { handleDoctorCommand } = require('../runtime/health.js');
@@ -58,7 +60,7 @@ const { handleStorageCommand } = require('../runtime/storage/cli.js');
58
60
  function handleFailureCommand(options ) { return require('../runtime/failure/cli.js').handleFailureCommand(options); }
59
61
  const { handleInventoryCommand } = require('../runtime/inventory/index.js');
60
62
  const { finishRuntime, usageError } = require('../runtime/cli-result.js');
61
- const { setDefaultSlug, invalidSlugRefusal, slugNamesNoFeature } = require('../runtime/task-scope.js');
63
+ const { setDefaultSlug, invalidSlugRefusal, slugNamesNoFeature, resolveFeature } = require('../runtime/task-scope.js');
62
64
  const { listCheckouts } = require('../runtime/checkouts.js');
63
65
  const { featureSlugFor } = require('../runtime/implementation-gate.js');
64
66
  const { handleBriefToolsCommand } = require('../runtime/brief-tools.js');
@@ -111,6 +113,9 @@ function evidenceItemFromFlags(o ) {
111
113
  if (o.locator !== undefined) item.locator = o.locator;
112
114
  if (o.content !== undefined) item.content = o.content;
113
115
  if (o.establishes !== undefined) item.establishes = o.establishes;
116
+ if (o.need !== undefined) item.need = o.need;
117
+ if (o.result !== undefined) item.result = o.result;
118
+ if (o.absence !== undefined) item.absence = o.absence;
114
119
  // Both halves of an observation travel together; a lone half must reach the write boundary as
115
120
  // the partial object it is, so the refusal names the missing field instead of dropping the pair.
116
121
  if (o.observedCommand !== undefined || o.observedExit !== undefined) {
@@ -128,9 +133,9 @@ function evidenceItemFromFlags(o ) {
128
133
  /** Verbs that read or write per-task records, and so accept `--slug` (IC-002). */
129
134
  const TASK_STORE_VERBS = new Set(['evidence', 'claim', 'readiness', 'context-pack', 'research-request', 'outcome', 'retrieval-plan']);
130
135
 
131
- /** Verbs that build a task's readiness: run from a linked worktree whose feature folder lives only
132
- * in the main checkout, they keep the task's records there, beside the folder their evidence names. */
133
- const FEATURE_STORE_VERBS = new Set(['evidence', 'claim', 'readiness']);
136
+ /** Verbs whose task records live beside the feature folder when run from a linked worktree that
137
+ * does not contain it. */
138
+ const FEATURE_STORE_VERBS = new Set(['evidence', 'claim', 'readiness', 'retrieval-plan']);
134
139
 
135
140
  /**
136
141
  * The main checkout, when `o` runs one of FEATURE_STORE_VERBS in a linked worktree that lacks the
@@ -139,11 +144,16 @@ const FEATURE_STORE_VERBS = new Set(['evidence', 'claim', 'readiness']);
139
144
  * the readiness record written there is the one verify, orchestrate and the edit hook read from
140
145
  * the worktree.
141
146
  */
142
- function featureStoreRoot(o ) {
147
+ function featureStoreRoot(o , checkouts ) {
143
148
  if (!FEATURE_STORE_VERBS.has(o.cmd) || o.global) return null;
144
- const checkouts = listCheckouts({ cwd: evidenceRoot(o) });
145
149
  if (!checkouts.isLinked) return null;
146
- const slug = featureSlugFor({ projectRoot: checkouts.current, slug: o.slug });
150
+ let slug = o.slug || null;
151
+ if (!slug) {
152
+ try {
153
+ const feature = resolveFeature({ projectRoot: checkouts.current });
154
+ slug = !feature.error && feature.paths ? feature.paths.feature_slug : null;
155
+ } catch { /* a failed branch lookup cannot route state to a guessed feature */ }
156
+ }
147
157
  const folder = (root ) => path.join(root, 'agent-docs', 'doflow', slug || '');
148
158
  if (!slug || fs.existsSync(folder(checkouts.current)) || !fs.existsSync(folder(checkouts.main))) return null;
149
159
  return { root: checkouts.main, slug };
@@ -169,8 +179,11 @@ function dispatchRuntimeCommandInner(o , moment ) {
169
179
  // here so the verbs whose handlers build their own ledger (readiness, evidence) route the same
170
180
  // way as the ones that take `slug` directly. The orchestration journal is keyed by slug already.
171
181
  // Reset on every dispatch, so one invocation's slug can never carry into the next in-process call.
172
- const featureStore = featureStoreRoot(o);
182
+ const checkouts = FEATURE_STORE_VERBS.has(o.cmd) && !o.global
183
+ ? listCheckouts({ cwd: evidenceRoot(o) }) : null;
184
+ const featureStore = featureStoreRoot(o, checkouts);
173
185
  const storeRoot = featureStore ? featureStore.root : evidenceRoot(o);
186
+ const attemptRoot = checkouts?.ok && !checkouts.sandbox ? (checkouts.main || checkouts.current || null) : null;
174
187
  // Use the same root resolver as the handler: task redirection, the nearest run ledger,
175
188
  // and lifecycle's shared main-worktree store are deliberately different scopes.
176
189
  try {
@@ -197,6 +210,7 @@ function dispatchRuntimeCommandInner(o , moment ) {
197
210
  // REPO_ROOT locates the capability registry; projectRoot is the tree whose index freshness
198
211
  // and build/test commands are being reported on, which follows the usual scope rules.
199
212
  case 'capabilities': return handleCapabilitiesCommand({ json: o.json, check: o.check, repoRoot: REPO_ROOT });
213
+ case 'enforcement': return handleEnforcementCommand(o);
200
214
  case 'doctor': return handleDoctorCommand({ json: o.json, repoRoot: REPO_ROOT, projectRoot: evidenceRoot(o), adapters: buildAdapterRegistry() });
201
215
  // REPO_ROOT locates the registry (templates ship with the package); stateRoot locates the
202
216
  // caller's evidence, which follows the same scope rules as every other command: -g means
@@ -211,8 +225,8 @@ function dispatchRuntimeCommandInner(o , moment ) {
211
225
  // than from evidence, so the gate had one reachable answer for every task. Forwarding is
212
226
  // all this does: the handler names them back under `callerAsserted` so a stated input is
213
227
  // never mistaken for a measured one.
214
- case 'readiness': return handleReadinessCommand({ taskClass: requireTaskClass(o), taskId: requireTaskId(o), verificationPlan: o.verificationPlan, scopeClear: o.scope, invariants: o.invariants, userDecisionPending: o.userDecisionPending, mode: o.mode, slug: storeSlug, json: o.json, repoRoot: REPO_ROOT, stateRoot: storeRoot });
215
- case 'evidence': return handleEvidenceCommand({ taskId: requireTaskId(o), action: o.action, item: evidenceItemFromFlags(o), batchPath: o.batchPath, evidenceId: o.evidenceId, replacedBy: o.replacedBy, json: o.json, repoRoot: REPO_ROOT, stateRoot: storeRoot });
228
+ case 'readiness': return handleReadinessCommand({ taskClass: requireTaskClass(o), taskId: requireTaskId(o), verificationPlan: o.verificationPlan, scopeClear: o.scope, invariants: o.invariants, userDecisionPending: o.userDecisionPending, mode: o.mode, slug: storeSlug, json: o.json, repoRoot: REPO_ROOT, stateRoot: storeRoot, sourceRoot: evidenceRoot(o) });
229
+ case 'evidence': return handleEvidenceCommand({ taskId: requireTaskId(o), action: o.action, item: evidenceItemFromFlags(o), batchPath: o.batchPath, evidenceId: o.evidenceId, replacedBy: o.replacedBy, json: o.json, repoRoot: REPO_ROOT, stateRoot: storeRoot, slug: storeSlug, sourceRoot: evidenceRoot(o), attemptRoot });
216
230
  // Run-ledger views. They resolve their own ledger the way the dispatcher does (nearest
217
231
  // `.doflow` walking up, or the global one) rather than assuming cwd is the project root, so
218
232
  // a view invoked from a subdirectory reads the runs that were actually recorded.
@@ -260,8 +274,8 @@ function dispatchRuntimeCommandInner(o , moment ) {
260
274
  case 'model-role': return handleModelRoleCommand({ role: o.role, exclude: o.exclude, json: o.json, repoRoot: REPO_ROOT });
261
275
  case 'route': return handleRouteCommand({ intent: o.intent, query: o.query, check: o.check, json: o.json, projectRoot: evidenceRoot(o) });
262
276
  case 'claim': return handleClaimCommand({ slug: storeSlug, taskId: requireTaskId(o), action: o.action, statement: o.statement, claimId: o.claimId, evidenceId: o.evidenceId, replacedBy: o.replacedBy, relation: o.relation, role: o.role, json: o.json, stateRoot: storeRoot });
263
- case 'context-pack': return handleContextPackCommand({ taskId: requireTaskId(o), taskClass: o.taskClass, objective: o.objective, json: o.json, stateRoot: evidenceRoot(o), slug: o.slug });
264
- case 'retrieval-plan': return handleRetrievalPlanCommand({ slug: o.slug, taskId: requireTaskId(o), action: o.action, need: o.need, stage: o.stage, json: o.json, repoRoot: REPO_ROOT, stateRoot: evidenceRoot(o) });
277
+ case 'context-pack': return handleContextPackCommand({ taskId: requireTaskId(o), taskClass: o.taskClass, objective: o.objective, json: o.json, stateRoot: evidenceRoot(o), slug: o.slug, mandatoryFile: o.mandatoryFile, harness: o.harness, maxBytes: o.maxBytes, bare: o.bare, noRecord: o.noRecord, reason: o.reason, since: o.since, expand: o.expand });
278
+ case 'retrieval-plan': return handleRetrievalPlanCommand({ slug: storeSlug, taskId: requireTaskId(o), action: o.action, need: o.need, stage: o.stage, provider: o.provider, attemptStatus: o.attemptStatus, reason: o.reason, note: o.note, json: o.json, repoRoot: REPO_ROOT, stateRoot: storeRoot, sourceRoot: evidenceRoot(o) });
265
279
  case 'outcome': return handleOutcomeCommand({ slug: o.slug, taskId: requireTaskId(o), action: o.action, state: o.state, taskClass: o.taskClass, stage: o.stage, readiness: o.readiness, verification: o.verification, json: o.json, repoRoot: REPO_ROOT, stateRoot: evidenceRoot(o) });
266
280
  case 'verify': return handleVerifyCommand({ slug: o.slug, taskId: requireTaskId(o), action: o.action, risk: o.risk, planPath: o.planPath, scope: o.scope, verificationInput: o.verificationInput, verificationPhase: o.verificationPhase, baselineRef: o.baselineRef, json: o.json, projectRoot: evidenceRoot(o) });
267
281
  case 'leak-scan': return handleLeakScanCommand({ paths: o.paths, exclude: o.exclude, json: o.json, repoRoot: evidenceRoot(o) });
@@ -278,4 +292,29 @@ async function dispatchRuntimeCommand(o ) {
278
292
  return result;
279
293
  }
280
294
 
295
+ function handleEnforcementCommand(o ) {
296
+ const fail = (error , detail ) => {
297
+ if (o.json) console.log(JSON.stringify({ error, detail }));
298
+ else console.error(`doflow enforcement: ${detail}`);
299
+ process.exitCode = 2;
300
+ return 2;
301
+ };
302
+ if (typeof o.harness !== 'string' || !o.harness || typeof o.policy !== 'string' || !o.policy || (o.profile !== undefined && typeof o.profile !== 'string')) return fail('usage', 'requires --harness <id> --policy <id> [--profile <id>]');
303
+ try {
304
+ const registry = loadRegistry({ repoRoot: REPO_ROOT });
305
+ const answer = enforcementFor(registry.coverage, { harness: o.harness, policy: o.policy, ...(o.profile ? { profile: o.profile } : {}) });
306
+ if (o.json) console.log(JSON.stringify(answer));
307
+ else {
308
+ console.log(`${answer.harness} ${answer.policy}: ${answer.enforced ? 'enforced' : 'not enforced'} (${answer.class}, ${answer.verification})`);
309
+ for (const item of answer.doesNotCover) console.log(`does not cover: ${item}`);
310
+ for (const item of answer.prerequisites) console.log(`prerequisite: ${item}`);
311
+ }
312
+ process.exitCode = answer.enforced ? 0 : 1;
313
+ return process.exitCode;
314
+ } catch (error ) {
315
+ if (error instanceof UnknownEnforcementTarget) return fail(error.code, error.message);
316
+ return fail('usage', error.message);
317
+ }
318
+ }
319
+
281
320
  module.exports = { dispatchRuntimeCommand };
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const CLASS_ORDER = Object.freeze(['unknown', 'unsupported', 'observation', 'advisory', 'blocking']);
5
+ const POLICY_ORDER = Object.freeze(['pre-implementation-gate', 'pre-bash-guard', 'session-context', 'mcp-tool-guard', 'stop-check']);
6
+ const HARNESS_ORDER = Object.freeze(['claude', 'codex', 'gemini', 'opencode', 'pi', 'copilot', 'kiro', 'antigravity']);
7
+ const NEVER_BLOCKING_EVENTS = new Set([
8
+ 'pi\u0000tool_execution_start', 'antigravity\u0000PostToolUse', 'antigravity\u0000PreInvocation',
9
+ 'antigravity\u0000PostInvocation', 'gemini\u0000AfterTool', 'claude\u0000PostToolUse', 'codex\u0000PostToolUse',
10
+ ]);
11
+ function stable(value ) {
12
+ if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
13
+ if (value && typeof value === 'object') return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`;
14
+ return JSON.stringify(value);
15
+ }
16
+ function rowsDigest(rows ) { return `sha256:${crypto.createHash('sha256').update(stable(rows)).digest('hex')}`; }
17
+ class UnknownEnforcementTarget extends Error {
18
+ code ;
19
+ constructor(code , detail ) { super(detail); this.name = 'UnknownEnforcementTarget'; this.code = code; }
20
+ }
21
+ function enforcementFor(coverage , query ) {
22
+ const rows = coverage?.rows || [];
23
+ const profiles = coverage?.profiles || {};
24
+ if (query.policy === '*') throw new UnknownEnforcementTarget('unknown-policy', "Unknown policy '*'");
25
+ if (!rows.some((row ) => row.harness === query.harness)) throw new UnknownEnforcementTarget('unknown-harness', `Unknown harness '${query.harness}'`);
26
+ if (!rows.some((row ) => row.policy === query.policy)) throw new UnknownEnforcementTarget('unknown-policy', `Unknown policy '${query.policy}'`);
27
+ if (query.profile !== undefined && !Object.hasOwn(profiles, query.profile)) throw new UnknownEnforcementTarget('unknown-profile', `Unknown profile '${query.profile}'`);
28
+ const pairRows = rows.filter((row ) => row.harness === query.harness && (row.policy === query.policy || row.policy === '*'));
29
+ const timing = query.policy === 'stop-check' ? 'stop' : 'pre-tool';
30
+ const applicable = pairRows.filter((row ) => row.timing === timing && (query.profile === undefined || row.profile === query.profile || row.profile === 'any'));
31
+ const enforced = applicable.length > 0 && applicable.every((row ) => row.wired && row.class === 'blocking' && row.verification === 'verified');
32
+ const rank = (row ) => CLASS_ORDER.indexOf(row.class);
33
+ const weakest = [...applicable].sort((a , b ) => rank(a) - rank(b))[0];
34
+ const intersection = (field ) => applicable.length ? [...new Set (applicable[0][field] || [])].filter((item) => applicable.every((row ) => (row[field] || []).includes(item))) : [];
35
+ const union = (field ) => [...new Set(applicable.flatMap((row ) => row[field] || []))];
36
+ const parity = enforced && pairRows.length > 0 && pairRows.every((row ) => row.class === 'blocking');
37
+ return {
38
+ harness: query.harness, policy: query.policy, profile: query.profile ?? null, enforced, parity,
39
+ class: weakest?.class || 'unknown', verification: weakest?.verification || 'unverified',
40
+ prerequisites: union('prerequisites'), covers: intersection('covers'), doesNotCover: union('doesNotCover'), caveats: union('caveats'),
41
+ rows: applicable, matrix: { schemaVersion: coverage.schemaVersion, revision: coverage.revision, rowsDigest: coverage.rowsDigest },
42
+ reason: weakest ? `${weakest.class} at (${weakest.policy}, ${weakest.harness}, ${weakest.event}, ${weakest.profile})` : `unknown at (${query.policy}, ${query.harness}, *, ${query.profile ?? '*'})`,
43
+ };
44
+ }
45
+ function renderEnforcementMatrix(coverage ) {
46
+ const order = (items , value ) => {
47
+ const index = items.indexOf(value);
48
+ return index < 0 ? items.length : index;
49
+ };
50
+ const rows = [...coverage.rows].sort((a , b ) => order(POLICY_ORDER, a.policy) - order(POLICY_ORDER, b.policy)
51
+ || order(HARNESS_ORDER, a.harness) - order(HARNESS_ORDER, b.harness)
52
+ || a.event.localeCompare(b.event) || a.profile.localeCompare(b.profile));
53
+ const lines = [`Schema ${coverage.schemaVersion}; revision ${coverage.revision}; ${coverage.rowsDigest}`, '| Policy | Harness | Native event | Profile | Class | Verification | Prerequisites | Does not cover |', '|---|---|---|---|---|---|---|---|'];
54
+ for (const row of rows) lines.push(`| ${row.policy} | ${row.harness} | ${row.event} | ${row.profile} | ${row.class} | ${row.verification} | ${(row.prerequisites || []).join(', ')} | ${(row.doesNotCover || []).join(', ')} |`);
55
+ return lines.join('\n');
56
+ }
57
+ module.exports = { enforcementFor, renderEnforcementMatrix, rowsDigest, UnknownEnforcementTarget, NEVER_BLOCKING_EVENTS };
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ // TypeScript migration (agent-docs/BRIEF_GOAL_DOFLOW.md): run directly by Node native type-stripping.
4
+
5
+ // IC-037 (DEC-034): the shared hooks tree and the hook front doors of every harness that reads it
6
+ // move together. An install or update that writes changed bytes into `hooks.shared` also plans the
7
+ // front doors of the other harnesses installed in the same scope, so a harness the run does not
8
+ // name is never left with doors written for an older tree. The doors ride the same plan, backup
9
+ // and ledger write as the run's own targets, and a door edited outside DoFlow refuses the whole plan.
10
+ const { selectAssets } = require('../registry/index.js');
11
+ const { resolveAdapter, projectAdapterInput } = require('../adapters/index.js');
12
+ const { renderPolicies } = require('./policies.js');
13
+
14
+ const HOOKS_TREE_ASSET = 'hooks.shared';
15
+ const OPERATIONS = ['install', 'update'];
16
+
17
+ /** Whether any target of the plan creates or updates a file of the shared hooks tree. */
18
+ function hooksTreeChanged(targets ) {
19
+ return targets.some((target ) => !target.skipped
20
+ && target.changes.some((change ) => change.assetId === HOOKS_TREE_ASSET && (change.operation === 'create' || change.operation === 'update')));
21
+ }
22
+
23
+ /** The plan entries for the hook front doors of every harness installed in this scope that the run
24
+ * does not name. Only the doors that differ from the current source appear, each with one notice;
25
+ * a door the adapter refuses to overwrite carries the conflict and the command that resolves it.
26
+ * `harnessScope` is the registry's name for the scope. `targetFromResult` is the lifecycle's own
27
+ * conversion of an adapter result into a plan entry. */
28
+ function planHookCompanions({ registry, adapters, scope, harnessScope, scopeRoot, ledger, context, targets, targetFromResult } ) {
29
+ // DEC-034 is about install and update: every other operation plans exactly the targets it was asked for.
30
+ if (!OPERATIONS.includes(context.operation) || !hooksTreeChanged(targets)) return [];
31
+ const named = new Set(targets.map((target ) => target.harness));
32
+ const installed = new Set((ledger?.resources ?? []).map((resource ) => resource.harness));
33
+ const run = targets.filter((target ) => !target.skipped).map((target ) => target.harness).join(',');
34
+ const companions = [];
35
+ for (const harness of registry.harnesses) {
36
+ if (named.has(harness.id) || !installed.has(harness.id) || !harness.scopes.includes(harnessScope)) continue;
37
+ if (!harness.capabilities?.hooks || harness.capabilities.hooks.status === 'unavailable') continue;
38
+ const adapter = resolveAdapter(adapters, harness);
39
+ if (typeof adapter.planHookDoors !== 'function') continue;
40
+ const assets = selectAssets(registry, { harness: harness.id });
41
+ const policies = renderPolicies(registry, { harness: harness.id });
42
+ const adapterInput = projectAdapterInput({ registry, harness, scope, scopeRoot, assets, mcp: [], mcpAdoptable: [], policies, context });
43
+ const input = { ...adapterInput, registry, ledger };
44
+ const discovery = adapter.discover(input);
45
+ const target = targetFromResult({ harness, assets, mcp: [], mcpSelected: null, policies, adapterInput, discovery,
46
+ result: adapter.planHookDoors({ ...input, discovery }) });
47
+ if (!target.changes.length && !target.conflicts.length) continue;
48
+ companions.push({
49
+ ...target, hookDoorsOnly: true,
50
+ // The assets that own the doors this run plans, not the harness's whole set.
51
+ assets: target.assets.filter((asset ) => target.changes.some((change ) => change.assetId === asset.id)),
52
+ conflicts: target.conflicts.length
53
+ ? [...target.conflicts, `${harness.id} hook front doors must move with the shared hooks tree; run: doflow update -t ${run},${harness.id} (add -f to overwrite edited doors). Working command: doflow update -t ${run},${harness.id} -f`]
54
+ : target.conflicts,
55
+ notices: target.changes.length ? [`refreshed ${harness.id} hook front doors to match the shared hooks tree`, ...target.notices] : target.notices,
56
+ });
57
+ }
58
+ return companions;
59
+ }
60
+
61
+ module.exports = { planHookCompanions };
@@ -16,6 +16,7 @@ const { renderPolicies } = require('./policies.js');
16
16
  const { renderMcpIndex } = require('./mcp-index.js');
17
17
  const { hasBashCapableShell } = require('./bash-availability.js');
18
18
  const { hookTrustFor: geminiHookTrustFor } = require('../adapters/gemini/index.js');
19
+ const { planHookCompanions } = require('./hook-step.js');
19
20
 
20
21
  const OPERATIONS = new Set(['create', 'merge', 'update', 'remove']);
21
22
  /** A harness's MCP selection meaning "the servers it owns now", read through its adapter's
@@ -453,6 +454,18 @@ function pruneRetiredRuntimeFiles(harnessPlans , ledger , { scopeRoot,
453
454
  : withChanges(target, replaced.get(target.harness) , added.get(target.harness) , notices.get(target.harness) )));
454
455
  }
455
456
 
457
+ /** One harness's plan entry, from what its adapter returned: the changes checked against the
458
+ * harness's assets, and the conflicts, prerequisites and notices in the lifecycle's common shape. */
459
+ function targetFromResult({ harness, assets, mcp, mcpSelected, policies, adapterInput, discovery, result } ) {
460
+ if (!result || typeof result !== 'object') throw new Error(`Adapter '${harness.id}' returned an invalid plan`);
461
+ const changes = (result.changes || []).map((change ) => normalizeChange(change, { harness, assets }));
462
+ const conflicts = adapterConflicts(result, harness);
463
+ const prerequisites = [...(result.prerequisites || []), ...changes.map((change ) => change.prerequisite).filter(Boolean)];
464
+ const requiredNativeResources = result.requiredNativeResources ?? changes;
465
+ if (!Array.isArray(requiredNativeResources)) throw new Error(`Adapter '${harness.id}' returned invalid requiredNativeResources`);
466
+ return { harness: harness.id, adapter: harness.adapter, assets, mcp, mcpSelected, policies, adapterInput, discovery, changes, requiredNativeResources, conflicts, prerequisites, notices: adapterNotices(result), skipped: false };
467
+ }
468
+
456
469
  function planLifecycle({ registry, adapters, scope, scopeRoot, targets, mcpSelections = {}, mcpAdoptable = {}, retainedMcpIds = [], ledger, context = {} } ) {
457
470
  assertScope(scope);
458
471
  if (!registry) throw new Error('registry is required');
@@ -478,13 +491,7 @@ function planLifecycle({ registry, adapters, scope, scopeRoot, targets, mcpSelec
478
491
  const input = { ...adapterInput, registry, ledger: baseLedger };
479
492
  const discovery = adapter.discover(input);
480
493
  const result = adapter.plan({ ...input, discovery });
481
- if (!result || typeof result !== 'object') throw new Error(`Adapter '${harness.id}' returned an invalid plan`);
482
- const changes = (result.changes || []).map((change ) => normalizeChange(change, { harness, assets }));
483
- const conflicts = adapterConflicts(result, harness);
484
- const prerequisites = [...(result.prerequisites || []), ...changes.map((change ) => change.prerequisite).filter(Boolean)];
485
- const requiredNativeResources = result.requiredNativeResources ?? changes;
486
- if (!Array.isArray(requiredNativeResources)) throw new Error(`Adapter '${harness.id}' returned invalid requiredNativeResources`);
487
- return { harness: harness.id, adapter: harness.adapter, assets, mcp, mcpSelected: capable ? mcp.map((server ) => server.id) : null, policies, adapterInput, discovery, changes, requiredNativeResources, conflicts, prerequisites, notices: adapterNotices(result), skipped: false };
494
+ return targetFromResult({ harness, assets, mcp, mcpSelected: capable ? mcp.map((server ) => server.id) : null, policies, adapterInput, discovery, result });
488
495
  });
489
496
  // The MCP index names every server a harness of this scope uses: the selections planned here and
490
497
  // the recorded selections of the harnesses this run does not touch.
@@ -495,12 +502,17 @@ function planLifecycle({ registry, adapters, scope, scopeRoot, targets, mcpSelec
495
502
  const retained = markRetainedRemovals(harnessPlans, baseLedger, scope);
496
503
  const annotated = context.operation === 'remove' ? retained
497
504
  : pruneRetiredRuntimeFiles(retained, baseLedger, { scopeRoot, repoRoot: context.repoRoot });
498
- const changes = annotated.flatMap((item ) => item.changes);
499
- const conflicts = annotated.flatMap((item ) => item.conflicts.map((reason ) => ({ harness: item.harness, reason })));
500
- const prerequisites = annotated.flatMap((item ) => item.prerequisites.map((prerequisite ) => ({ harness: item.harness, prerequisite })));
501
- const requiredNativeResources = annotated.flatMap((item ) => item.requiredNativeResources || []);
502
- const notices = annotated.flatMap((item ) => item.notices.map((notice ) => ({ harness: item.harness, notice })));
503
- return Object.freeze({ scope, scopeRoot, mcp: selectedMcp, ledger: baseLedger, targets: annotated, changes, requiredNativeResources, conflicts, prerequisites, notices,
505
+ // The shared hooks tree may have moved under harnesses this run does not name (IC-037): their
506
+ // front doors come along, as targets of this same plan.
507
+ const companions = planHookCompanions({ registry, adapters, scope, harnessScope: registryScope(scope), scopeRoot, ledger: baseLedger, context,
508
+ targets: annotated, targetFromResult });
509
+ const planned = [...annotated, ...companions];
510
+ const changes = planned.flatMap((item ) => item.changes);
511
+ const conflicts = planned.flatMap((item ) => item.conflicts.map((reason ) => ({ harness: item.harness, reason })));
512
+ const prerequisites = planned.flatMap((item ) => item.prerequisites.map((prerequisite ) => ({ harness: item.harness, prerequisite })));
513
+ const requiredNativeResources = planned.flatMap((item ) => item.requiredNativeResources || []);
514
+ const notices = planned.flatMap((item ) => item.notices.map((notice ) => ({ harness: item.harness, notice })));
515
+ return Object.freeze({ scope, scopeRoot, mcp: selectedMcp, ledger: baseLedger, targets: planned, changes, requiredNativeResources, conflicts, prerequisites, notices,
504
516
  retained: retainedChanges(changes), safe: conflicts.length === 0 && prerequisites.length === 0 });
505
517
  }
506
518
 
@@ -644,7 +656,9 @@ function verifyLifecycle({ plan, adapters, context = {} } ) {
644
656
  const plannedWrites = context.checkPlannedWrites
645
657
  ? target.changes.filter((change ) => !change.retained && (change.operation === 'create' || change.operation === 'update')).map((change ) => change.target)
646
658
  : undefined;
647
- let verification = normalizeVerification(adapter.verify({ ...target.adapterInput,
659
+ // A hook-front-door companion (IC-037) is verified for its doors alone: the rest of that
660
+ // harness is not part of this run and may legitimately trail the current source.
661
+ let verification = normalizeVerification(adapter[target.hookDoorsOnly ? 'verifyHookDoors' : 'verify']({ ...target.adapterInput,
648
662
  registry: context.registry ?? plan.registry, discovery: target.discovery, ledger: plan.ledger,
649
663
  operation, context: { ...target.adapterInput.context, operation, ...(plannedWrites ? { plannedWrites } : {}) } }), harness);
650
664
  if (operation === 'remove') verification = normalizeRemovalVerification(verification, target.changes, plan.ledger);