@kontourai/flow-agents 2.2.0 → 2.4.0

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 (250) hide show
  1. package/.github/CODEOWNERS +8 -0
  2. package/.github/workflows/ci.yml +32 -0
  3. package/AGENTS.md +4 -4
  4. package/CHANGELOG.md +46 -0
  5. package/CONTEXT.md +14 -0
  6. package/README.md +9 -3
  7. package/agents/dev.json +1 -1
  8. package/agents/tool-code-reviewer.json +1 -1
  9. package/agents/tool-planner.json +3 -3
  10. package/agents/tool-verifier.json +3 -3
  11. package/build/src/cli/console-learning-projection.js +3 -2
  12. package/build/src/cli/init.js +104 -21
  13. package/build/src/cli/kit.js +37 -6
  14. package/build/src/cli/pull-work-provider.js +1 -1
  15. package/build/src/cli/usage-feedback.js +3 -3
  16. package/build/src/cli/validate-hook-influence.js +1 -0
  17. package/build/src/cli/validate-workflow-artifacts.js +22 -6
  18. package/build/src/cli/workflow-artifact-cleanup-audit.js +36 -2
  19. package/build/src/cli/workflow-sidecar.d.ts +14 -7
  20. package/build/src/cli/workflow-sidecar.js +792 -69
  21. package/build/src/flow-kit/validate.d.ts +23 -0
  22. package/build/src/flow-kit/validate.js +67 -1
  23. package/build/src/index.d.ts +1 -1
  24. package/build/src/index.js +1 -1
  25. package/build/src/lib/flow-resolver.d.ts +6 -4
  26. package/build/src/lib/flow-resolver.js +125 -58
  27. package/build/src/lib/local-artifact-root.d.ts +14 -2
  28. package/build/src/lib/local-artifact-root.js +22 -5
  29. package/build/src/lib/workflow-learning-projection.js +2 -2
  30. package/build/src/runtime-adapters.d.ts +12 -0
  31. package/build/src/runtime-adapters.js +56 -16
  32. package/build/src/tools/build-universal-bundles.js +49 -16
  33. package/build/src/tools/generate-context-map.js +10 -8
  34. package/build/src/tools/validate-source-tree.js +23 -1
  35. package/console.telemetry.json +22 -0
  36. package/context/contracts/artifact-contract.md +38 -7
  37. package/context/contracts/builder-kit-workflow-state-contract.md +3 -2
  38. package/context/contracts/decision-registry-contract.md +263 -0
  39. package/context/contracts/delivery-contract.md +1 -1
  40. package/context/contracts/planning-contract.md +2 -1
  41. package/context/contracts/review-contract.md +5 -3
  42. package/context/contracts/verification-contract.md +9 -1
  43. package/context/contracts/work-item-contract.md +1 -1
  44. package/context/deferred/parallelization.md +1 -1
  45. package/context/gate-awareness.md +4 -4
  46. package/context/scripts/git-status.sh +2 -2
  47. package/context/scripts/hooks/config-protection.js +502 -2
  48. package/context/scripts/hooks/lib/config-protection-remedies.js +69 -0
  49. package/context/scripts/hooks/run-hook.js +14 -1
  50. package/context/scripts/hooks/stop-goal-fit.js +1140 -99
  51. package/context/scripts/hooks/workflow-steering.js +212 -6
  52. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  53. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +35 -0
  54. package/docs/adr/0019-kit-dependency-ownership.md +62 -0
  55. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +290 -0
  56. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +221 -0
  57. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +283 -0
  58. package/docs/agent-system-guidebook.md +3 -3
  59. package/docs/agent-usage-feedback-loop.md +6 -6
  60. package/docs/context-map.md +9 -7
  61. package/docs/decisions/decision-records.md +57 -0
  62. package/docs/decisions/index.md +14 -0
  63. package/docs/decisions/promotion-gate.md +52 -0
  64. package/docs/developer-architecture.md +1 -1
  65. package/docs/fixture-ownership.md +3 -0
  66. package/docs/flow-kit-repository-contract.md +5 -5
  67. package/docs/getting-started.md +12 -2
  68. package/docs/integrations/harness-install.md +2 -1
  69. package/docs/kit-authoring-guide.md +64 -5
  70. package/docs/knowledge-kit.md +5 -1
  71. package/docs/learnings/2026-07-improvement-program.md +203 -0
  72. package/docs/migrations.md +6 -5
  73. package/docs/north-star.md +2 -2
  74. package/docs/operating-layers.md +4 -3
  75. package/docs/repository-structure.md +5 -5
  76. package/docs/skills-map.md +8 -8
  77. package/docs/spec/runtime-hook-surface.md +19 -15
  78. package/docs/spikes/graph-provider-2026-07.md +155 -0
  79. package/docs/standards-register.md +7 -7
  80. package/docs/trust-anchor-adoption.md +105 -4
  81. package/docs/work-item-adapters.md +1 -1
  82. package/docs/workflow-artifact-lifecycle.md +72 -9
  83. package/docs/workflow-eval-strategy.md +3 -3
  84. package/docs/workflow-shared-contracts.md +1 -1
  85. package/docs/workflow-usage-guide.md +254 -21
  86. package/evals/acceptance/DEMO-false-completion.md +20 -11
  87. package/evals/acceptance/prove-capture-teeth-declared.sh +15 -15
  88. package/evals/acceptance/prove-capture-teeth.sh +11 -11
  89. package/evals/acceptance/test_kiro_harness.sh +1 -1
  90. package/evals/ci/antigaming-suite.sh +5 -0
  91. package/evals/ci/run-baseline.sh +65 -1
  92. package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/flows/review.flow.json +26 -0
  93. package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/kit.json +13 -0
  94. package/evals/fixtures/flow-kit-repository/valid-with-dependency/flows/review.flow.json +26 -0
  95. package/evals/fixtures/flow-kit-repository/valid-with-dependency/kit.json +13 -0
  96. package/evals/fixtures/hook-influence/cases.json +32 -0
  97. package/evals/fixtures/pull-work-provider/github-issues.json +6 -6
  98. package/evals/fixtures/trust-reconcile-exploits/fabricated-attestation.json +59 -0
  99. package/evals/fixtures/trust-reconcile-exploits/no-label-bypass.json +36 -0
  100. package/evals/fixtures/trust-reconcile-exploits/skip-assumed-bypass.json +59 -0
  101. package/evals/fixtures/trust-reconcile-exploits/status-misassertion.json +48 -0
  102. package/evals/fixtures/trust-reconcile-exploits/waived-command-check.json +44 -0
  103. package/evals/fixtures/trust-reconcile-mixed-bundle/mixed-bundle.json +159 -0
  104. package/evals/fixtures/trust-reconcile-ws3/ws3-bundle.json +920 -0
  105. package/evals/integration/test_actor_identity.sh +254 -0
  106. package/evals/integration/test_builder_step_producers.sh +33 -23
  107. package/evals/integration/test_bundle_install.sh +22 -16
  108. package/evals/integration/test_bundle_lifecycle.sh +11 -11
  109. package/evals/integration/test_captured_fail_reconciliation.sh +50 -50
  110. package/evals/integration/test_checkpoint_signing.sh +43 -0
  111. package/evals/integration/test_claim_lookup.sh +4 -4
  112. package/evals/integration/test_codex_hook_resolution.sh +114 -0
  113. package/evals/integration/test_command_log_concurrency.sh +8 -8
  114. package/evals/integration/test_command_log_fork_classification.sh +3 -3
  115. package/evals/integration/test_command_log_integrity.sh +16 -16
  116. package/evals/integration/test_console_learning_projection.sh +1 -1
  117. package/evals/integration/test_dual_emit_flow_step.sh +145 -0
  118. package/evals/integration/test_enforcer_expects_driven.sh +12 -12
  119. package/evals/integration/test_evidence_capture_hook.sh +14 -13
  120. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  121. package/evals/integration/test_flow_agents_statusline.sh +2 -2
  122. package/evals/integration/test_flow_kit_install_git.sh +52 -0
  123. package/evals/integration/test_flow_kit_repository.sh +2 -0
  124. package/evals/integration/test_flowdef_session_activation.sh +6 -6
  125. package/evals/integration/test_flowdef_union_floor_regression.sh +429 -0
  126. package/evals/integration/test_gate_bypass_chain.sh +52 -40
  127. package/evals/integration/test_gate_lockdown.sh +97 -73
  128. package/evals/integration/test_gate_review_inquiry_records.sh +3 -3
  129. package/evals/integration/test_goal_fit_escape_hatch.sh +10 -10
  130. package/evals/integration/test_goal_fit_ghost_session.sh +104 -0
  131. package/evals/integration/test_goal_fit_hook.sh +32 -32
  132. package/evals/integration/test_goal_fit_rederive.sh +9 -9
  133. package/evals/integration/test_hook_category_behaviors.sh +103 -0
  134. package/evals/integration/test_hook_influence_cases.sh +1 -0
  135. package/evals/integration/test_install_merge.sh +262 -1
  136. package/evals/integration/test_kit_identity_trust.sh +6 -6
  137. package/evals/integration/test_liveness_conflict_injection.sh +587 -0
  138. package/evals/integration/test_liveness_heartbeat.sh +677 -0
  139. package/evals/integration/test_liveness_verdict.sh +394 -0
  140. package/evals/integration/test_local_flow_kit_install.sh +15 -0
  141. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -11
  142. package/evals/integration/test_promote_gate.sh +178 -0
  143. package/evals/integration/test_publish_delivery.sh +2 -2
  144. package/evals/integration/test_pull_work_liveness_preflight.sh +386 -0
  145. package/evals/integration/test_pull_work_provider.sh +2 -2
  146. package/evals/integration/test_reconcile_soundness.sh +4 -4
  147. package/evals/integration/test_resolvefirststep_security.sh +22 -0
  148. package/evals/integration/test_runtime_adapter_activation.sh +87 -18
  149. package/evals/integration/test_session_resume_roundtrip.sh +147 -0
  150. package/evals/integration/test_sidecar_field_preservation.sh +249 -0
  151. package/evals/integration/test_trust_reconcile.sh +2 -2
  152. package/evals/integration/test_trust_reconcile_manifest.sh +79 -0
  153. package/evals/integration/test_trust_reconcile_mixed_bundle.sh +96 -0
  154. package/evals/integration/test_trust_reconcile_negatives.sh +151 -0
  155. package/evals/integration/test_usage_feedback_global.sh +5 -5
  156. package/evals/integration/test_validate_artifacts_portability.sh +60 -0
  157. package/evals/integration/test_verify_cli.sh +2 -2
  158. package/evals/integration/test_veritas_governance_kit.sh +117 -0
  159. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +26 -1
  160. package/evals/integration/test_workflow_artifacts.sh +33 -0
  161. package/evals/integration/test_workflow_sidecar_writer.sh +723 -74
  162. package/evals/integration/test_workflow_steering_hook.sh +175 -5
  163. package/evals/lib/codex-provider.sh +1 -1
  164. package/evals/lib/node.sh +19 -1
  165. package/evals/run.sh +22 -0
  166. package/evals/static/test_decisions.sh +150 -0
  167. package/evals/static/test_flowdef_codeowners_coverage.sh +56 -0
  168. package/evals/static/test_package.sh +19 -10
  169. package/evals/static/test_universal_bundles.sh +48 -3
  170. package/evals/static/test_workflow_skills.sh +41 -8
  171. package/kits/builder/flows/build.flow.json +3 -74
  172. package/kits/builder/flows/publish-learn.flow.json +90 -0
  173. package/kits/builder/kit.json +11 -0
  174. package/kits/builder/skills/builder-shape/SKILL.md +1 -1
  175. package/kits/builder/skills/deliver/SKILL.md +13 -11
  176. package/kits/builder/skills/design-probe/SKILL.md +4 -4
  177. package/kits/builder/skills/evidence-gate/SKILL.md +26 -1
  178. package/kits/builder/skills/execute-plan/SKILL.md +2 -2
  179. package/kits/builder/skills/fix-bug/SKILL.md +2 -0
  180. package/kits/builder/skills/gate-review/SKILL.md +6 -6
  181. package/kits/builder/skills/idea-to-backlog/SKILL.md +1 -1
  182. package/kits/builder/skills/learning-review/SKILL.md +7 -7
  183. package/kits/builder/skills/pickup-probe/SKILL.md +5 -4
  184. package/kits/builder/skills/plan-work/SKILL.md +27 -5
  185. package/kits/builder/skills/pull-work/SKILL.md +80 -5
  186. package/kits/builder/skills/release-readiness/SKILL.md +4 -4
  187. package/kits/builder/skills/review-work/SKILL.md +24 -1
  188. package/kits/builder/skills/tdd-workflow/SKILL.md +2 -0
  189. package/kits/builder/skills/verify-work/SKILL.md +8 -1
  190. package/kits/catalog.json +6 -0
  191. package/kits/veritas-governance/adapter/readiness-to-trust-bundle.mjs +178 -0
  192. package/kits/veritas-governance/docs/README.md +75 -0
  193. package/kits/veritas-governance/fixtures/readiness/not-ready.readiness-report.json +4645 -0
  194. package/kits/veritas-governance/fixtures/readiness/ready.readiness-report.json +4403 -0
  195. package/kits/veritas-governance/flows/readiness-check.flow.json +35 -0
  196. package/kits/veritas-governance/kit.json +16 -0
  197. package/package.json +8 -4
  198. package/packaging/README.md +2 -2
  199. package/packaging/conformance/fixtures/config-protection--allow-read-state-json.json +20 -0
  200. package/packaging/conformance/fixtures/config-protection--block-write-state-json.json +20 -0
  201. package/packaging/conformance/fixtures/stop-goal-fit--block-bundle-disputed-claim.json +1 -1
  202. package/packaging/conformance/fixtures/stop-goal-fit--block-capture-contradicts-claimed-pass.json +1 -1
  203. package/packaging/manifest.json +9 -10
  204. package/schemas/decision-record.schema.json +109 -0
  205. package/schemas/workflow-evidence.schema.json +1 -1
  206. package/schemas/workflow-state.schema.json +5 -0
  207. package/scripts/README.md +8 -3
  208. package/scripts/check-content-boundary.cjs +1 -1
  209. package/scripts/check-decisions.cjs +356 -0
  210. package/scripts/ci/derive-claim-status.mjs +74 -0
  211. package/scripts/ci/trust-reconcile.js +436 -79
  212. package/scripts/git-status.sh +2 -2
  213. package/scripts/hooks/claude-telemetry-hook.js +37 -3
  214. package/scripts/hooks/codex-telemetry-hook.js +36 -2
  215. package/scripts/hooks/config-protection.js +102 -30
  216. package/scripts/hooks/evidence-capture.js +3 -3
  217. package/scripts/hooks/lib/actor-identity.js +368 -0
  218. package/scripts/hooks/lib/config-protection-remedies.js +69 -0
  219. package/scripts/hooks/lib/liveness-heartbeat.js +361 -0
  220. package/scripts/hooks/lib/liveness-policy.js +127 -0
  221. package/scripts/hooks/lib/liveness-read.js +105 -12
  222. package/scripts/hooks/lib/liveness-write.js +46 -0
  223. package/scripts/hooks/lib/local-artifact-paths.js +12 -6
  224. package/scripts/hooks/opencode-telemetry-hook.js +27 -1
  225. package/scripts/hooks/pi-telemetry-hook.js +27 -1
  226. package/scripts/hooks/stop-goal-fit.js +157 -26
  227. package/scripts/hooks/workflow-steering.js +77 -3
  228. package/scripts/install-codex-home.sh +138 -38
  229. package/scripts/statusline/flow-agents-statusline.js +2 -2
  230. package/src/cli/console-learning-projection.ts +3 -2
  231. package/src/cli/init.ts +101 -21
  232. package/src/cli/kit.ts +37 -6
  233. package/src/cli/public-api.test.mjs +58 -6
  234. package/src/cli/pull-work-provider.ts +1 -1
  235. package/src/cli/trust-bundle-policy-order.test.mjs +87 -0
  236. package/src/cli/usage-feedback.ts +3 -3
  237. package/src/cli/validate-hook-influence.ts +1 -0
  238. package/src/cli/validate-workflow-artifacts.ts +22 -6
  239. package/src/cli/veritas-readiness-adapter.test.mjs +267 -0
  240. package/src/cli/workflow-artifact-cleanup-audit.ts +35 -2
  241. package/src/cli/workflow-sidecar.ts +795 -71
  242. package/src/flow-kit/validate.ts +74 -1
  243. package/src/index.ts +6 -2
  244. package/src/lib/flow-resolver.ts +123 -55
  245. package/src/lib/local-artifact-root.ts +24 -5
  246. package/src/lib/workflow-learning-projection.ts +2 -2
  247. package/src/runtime-adapters.ts +61 -15
  248. package/src/tools/build-universal-bundles.ts +49 -16
  249. package/src/tools/generate-context-map.ts +10 -8
  250. package/src/tools/validate-source-tree.ts +23 -1
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Stop Hook: warn when an active workflow is about to stop short of its goal.
4
4
  *
5
- * The hook reads .flow-agents artifacts, looks for the most recent active
5
+ * The hook reads .kontourai/flow-agents artifacts, looks for the most recent active
6
6
  * delivery/session file, and reports missing Definition Of Done, Goal Fit, or
7
7
  * Final Acceptance state.
8
8
  *
@@ -15,7 +15,7 @@
15
15
  * Code at L2) set block so the installed product enforces while the engine
16
16
  * default and conformance contract stay warn.
17
17
  *
18
- * Scope: the gate evaluates the session's current task (.flow-agents/current.json)
18
+ * Scope: the gate evaluates the session's current task (.kontourai/flow-agents/current.json)
19
19
  * when set, so an unrelated active workflow elsewhere in the repo does not gate
20
20
  * this stop. It also never hard-blocks a pre-execution (not-yet-started) task on
21
21
  * mere incompleteness — only genuine false-completion signals (a claimed pass the
@@ -27,6 +27,21 @@
27
27
  const fs = require('fs');
28
28
  const path = require('path');
29
29
  const { spawnSync } = require('child_process');
30
+ const crypto = require('crypto');
31
+
32
+ // Hash-chain primitives + the exit-code-laundering heuristic come from ONE shared
33
+ // module, so this verifier can never drift from the writer (evidence-capture.js).
34
+ // CHAIN_GENESIS is re-aliased to CHAIN_GENESIS_VERIFY to preserve the long-standing
35
+ // export name consumed by repair-command-log.js and the fork-classification eval.
36
+ const {
37
+ CHAIN_GENESIS: CHAIN_GENESIS_VERIFY,
38
+ canonicalJsonForChain,
39
+ hasLaunderingOperator,
40
+ } = require('../lib/command-log-chain.js');
41
+ const {
42
+ flowAgentsArtifactRoot,
43
+ flowAgentsArtifactRootsForRead,
44
+ } = require('./lib/local-artifact-paths');
30
45
 
31
46
  const MAX_STDIN = 1024 * 1024;
32
47
  const ACTIVE_STATUSES = new Set([
@@ -44,8 +59,9 @@ const ACTIVE_STATUSES = new Set([
44
59
  ]);
45
60
  // WORKFLOW_SESSION_TYPES: used for artifact identification only, not for verdict production.
46
61
  const WORKFLOW_SESSION_TYPES = new Set(['deliver', 'delivery', 'fix-bug', 'execute-plan', 'verify-work']);
47
- const SIDECAR_NAMES = new Set(['state.json', 'acceptance.json', 'evidence.json', 'handoff.json']);
48
- const OPTIONAL_SIDECAR_NAMES = new Set(['critique.json']);
62
+ // Phase 4c: bundle-only. Required set = {state.json, handoff.json, trust.bundle}. Drop evidence.json/acceptance.json/critique.json.
63
+ const SIDECAR_NAMES = new Set(['state.json', 'handoff.json', 'trust.bundle']);
64
+ const OPTIONAL_SIDECAR_NAMES = new Set();
49
65
 
50
66
  // A workflow that has not started execution is EXPECTED to be incomplete, so the
51
67
  // Stop gate must not hard-block on its missing DOD / Goal Fit / not-done state.
@@ -136,7 +152,23 @@ function sidecarValidation(root, artifactDir) {
136
152
  if (requireSidecars || requireCritique) {
137
153
  const present = new Set(sidecarFiles.map(file => path.basename(file)));
138
154
  const requiredNames = new Set(requireSidecars ? SIDECAR_NAMES : []);
139
- if (requireCritique) requiredNames.add('critique.json');
155
+ // Phase 4c: critique.json is no longer written; trust.bundle carries critique claims.
156
+ // FLOW_AGENTS_REQUIRE_CRITIQUE is satisfied by:
157
+ // - critique.json (legacy, may not be in SIDECAR_NAMES but may still be on disk), OR
158
+ // - trust.bundle that contains at least one workflow.critique.review claim.
159
+ if (requireCritique) {
160
+ // Check disk directly (critique.json is no longer in SIDECAR_NAMES so may not be in present)
161
+ const hasCritiqueJson = fs.existsSync(path.join(artifactDir, 'critique.json'));
162
+ const bundleFile = path.join(artifactDir, 'trust.bundle');
163
+ let hasBundleCritique = false;
164
+ if (fs.existsSync(bundleFile)) {
165
+ try {
166
+ const b = JSON.parse(fs.readFileSync(bundleFile, 'utf8'));
167
+ hasBundleCritique = Array.isArray(b.claims) && b.claims.some(c => c && c.claimType === 'workflow.critique.review');
168
+ } catch { /* fall through — no bundle critique */ }
169
+ }
170
+ if (!hasCritiqueJson && !hasBundleCritique) requiredNames.add('critique.json');
171
+ }
140
172
  const missing = [...requiredNames].filter(name => !present.has(name)).sort();
141
173
  if (missing.length > 0) {
142
174
  return missing.map(name => `${relative(root, path.join(artifactDir, name))} sidecar validation: required sidecar is missing`);
@@ -264,6 +296,49 @@ async function tryLoadSurface() {
264
296
  }
265
297
  }
266
298
 
299
+ // ─── ADR 0016 Abstraction A P-c: flow-resolver integration ────────────────────
300
+ // Load the compiled flow-resolver (build/src/lib/flow-resolver.js) via CJS
301
+ // require behind the same hasBuild guard used for the validator. Fail-open:
302
+ // returns null when build/ is absent, require throws, or current.json has no
303
+ // active_flow_id / active_step_id. The caller (bundleEnforcement, sidecarGuidance)
304
+ // treats null as "no active FlowDefinition" and falls back to the workflow.* path.
305
+ function loadActiveFlowStep(flowAgentsDir) {
306
+ const packageRoot = path.resolve(__dirname, '..', '..');
307
+ const builtResolver = path.join(packageRoot, 'build', 'src', 'lib', 'flow-resolver.js');
308
+ if (!fs.existsSync(builtResolver)) return null; // hasBuild guard: no build/ yet
309
+ try {
310
+ const resolver = require(builtResolver);
311
+ if (typeof resolver.resolveActiveFlowStep !== 'function') return null;
312
+ return resolver.resolveActiveFlowStep(flowAgentsDir);
313
+ } catch {
314
+ return null; // require failed or resolver threw — fail-open
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Message-assembly only: derive the user-facing gate identity prefix from the
320
+ * resolved active FlowDefinition step (loadActiveFlowStep). Produces
321
+ * "[<flowId>/<gateId>]" (e.g. "[builder.build/verify-gate]") when a
322
+ * FlowDefinition gate is active, else the generic "[stop-gate]" fallback.
323
+ * Purely cosmetic — never affects claim selection, HARD_BLOCK/FULL_BLOCK
324
+ * classification, block counting, or exit codes.
325
+ */
326
+ function gateLabel(activeFlowStep) {
327
+ return activeFlowStep && activeFlowStep.flowId && activeFlowStep.gateId
328
+ ? `[${activeFlowStep.flowId}/${activeFlowStep.gateId}]`
329
+ : '[stop-gate]';
330
+ }
331
+
332
+ /**
333
+ * Derive the declared claimType Set from an active FlowDefinition's gate expects[].
334
+ * Returns null when no FlowDefinition is active (callers fall back to workflow.* only).
335
+ */
336
+ function declaredClaimTypesFor(activeFlowStep) {
337
+ return activeFlowStep && Array.isArray(activeFlowStep.gateExpects)
338
+ ? new Set(activeFlowStep.gateExpects.map(e => e && e.bundle_claim && e.bundle_claim.claimType).filter(Boolean))
339
+ : null;
340
+ }
341
+
267
342
  function safeOneLine(value, maxLength = 220) {
268
343
  const text = String(value || '').replace(/\s+/g, ' ').trim();
269
344
  if (text.length <= maxLength) return text;
@@ -274,11 +349,211 @@ function normalizedStatus(value) {
274
349
  return safeOneLine(value, 80).toLowerCase();
275
350
  }
276
351
 
277
- function sidecarGuidance(root, artifactDir) {
352
+ // ─── ADR 0010 Phase 4b: bundle-first helpers for consumer migration ────────────
353
+ // These helpers extract evidence/critique/acceptance data from the trust.bundle
354
+ // when it is present, falling back to the bespoke sidecar for bundle-less sessions.
355
+ // The sidecar content is IDENTICAL to the bundle projection (Phase 4a guarantee),
356
+ // so consumer reads produce identical verdicts.
357
+
358
+ /**
359
+ * Extract the effective "verdict" from trust.bundle workflow.check.* claims,
360
+ * or from declared claimTypes when a FlowDefinition is active (P-c extension).
361
+ * Priority of non-pass statuses: fail > not_verified > partial > pass.
362
+ * Returns null when the bundle has no matching claims.
363
+ *
364
+ * @param {Array} claims - trust.bundle claims array
365
+ * @param {Set<string>|null} [declaredClaimTypes] - optional set of declared claimTypes from gateExpects[]
366
+ */
367
+ function bundleEvidenceVerdict(claims, declaredClaimTypes) {
368
+ const checkClaims = claims.filter(c => {
369
+ if (!c || typeof c.claimType !== 'string') return false;
370
+ if (c.claimType.startsWith('workflow.check.')) return true;
371
+ return declaredClaimTypes != null && declaredClaimTypes.has(c.claimType);
372
+ });
373
+ if (checkClaims.length === 0) return null;
374
+ let worst = 'pass';
375
+ const PRIORITY = { fail: 4, failed: 4, not_verified: 3, 'not-verified': 3, partial: 2, pass: 1, skip: 0 };
376
+ for (const c of checkClaims) {
377
+ const v = normalizedStatus(c.value || 'pass');
378
+ if ((PRIORITY[v] || 0) > (PRIORITY[worst] || 0)) worst = v;
379
+ }
380
+ return worst;
381
+ }
382
+
383
+ /**
384
+ * Extract the check ID from a claim's subjectId (format: "${slug}/${checkId}").
385
+ * Returns the part after the first slash, or the full subjectId if no slash.
386
+ */
387
+ function claimCheckId(subjectId) {
388
+ const s = String(subjectId || '');
389
+ const slash = s.indexOf('/');
390
+ return slash >= 0 ? s.slice(slash + 1) : s;
391
+ }
392
+
393
+ /**
394
+ * Build the list of blocking check-claims from trust.bundle (equivalent to
395
+ * evidence.json.checks filtered to non-pass status).
396
+ * Returns objects shaped like { id, status, summary } (summary from fieldOrBehavior).
397
+ *
398
+ * @param {Array} claims - trust.bundle claims array
399
+ * @param {Set<string>|null} [declaredClaimTypes] - optional set of declared claimTypes from gateExpects[]
400
+ */
401
+ function bundleBlockingChecks(claims, declaredClaimTypes) {
402
+ return claims.filter(c => {
403
+ if (!c || typeof c.claimType !== 'string') return false;
404
+ const typeMatch = c.claimType.startsWith('workflow.check.')
405
+ || (declaredClaimTypes != null && declaredClaimTypes.has(c.claimType));
406
+ if (!typeMatch) return false;
407
+ const v = normalizedStatus(c.value || '');
408
+ return v === 'fail' || v === 'failed' || v === 'not_verified' || v === 'not-verified';
409
+ }).map(c => ({
410
+ id: claimCheckId(c.subjectId),
411
+ status: c.value || 'unknown',
412
+ summary: c.fieldOrBehavior || '',
413
+ }));
414
+ }
415
+
416
+ /**
417
+ * Determine critique status from trust.bundle workflow.critique.review claims,
418
+ * or from declared claimTypes when a FlowDefinition is active (P-c extension).
419
+ * Returns the "worst" value among critique claims, or null when none present.
420
+ *
421
+ * @param {Array} claims - trust.bundle claims array
422
+ * @param {Set<string>|null} [declaredClaimTypes] - optional set of declared claimTypes from gateExpects[]
423
+ */
424
+ function bundleCritiqueStatus(claims, declaredClaimTypes) {
425
+ const critiqueClaims = claims.filter(c => {
426
+ if (!c || typeof c.claimType !== 'string') return false;
427
+ if (c.claimType === 'workflow.critique.review') return true;
428
+ return declaredClaimTypes != null && declaredClaimTypes.has(c.claimType);
429
+ });
430
+ if (critiqueClaims.length === 0) return null;
431
+ // A disputed or failed critique is blocking
432
+ for (const c of critiqueClaims) {
433
+ const v = normalizedStatus(c.value || '');
434
+ if (v === 'fail' || v === 'failed' || c.status === 'disputed' || c.status === 'rejected') return c.value || 'fail';
435
+ }
436
+ return 'pass';
437
+ }
438
+
439
+ /**
440
+ * Build the list of claimed-pass command checks from the trust.bundle's evidence[]
441
+ * (items with execution.label) and from workflow.check.command claims whose effective
442
+ * value is "pass" (never-captured claimed pass). Falls back to an empty list when
443
+ * the bundle has no evidence items.
444
+ *
445
+ * Returns objects shaped like { id, kind, status, command } — same shape as
446
+ * evidence.json.checks — so captureCrossReference's body logic is unchanged.
447
+ *
448
+ * @param {object} bundle - trust.bundle object
449
+ * @param {Set<string>|null} [declaredClaimTypes] - optional set of declared claimTypes from gateExpects[]
450
+ */
451
+ function bundleClaimedPassCommandChecks(bundle, declaredClaimTypes) {
452
+ const allEvidence = Array.isArray(bundle.evidence) ? bundle.evidence : [];
453
+ const allClaims = Array.isArray(bundle.claims) ? bundle.claims : [];
454
+
455
+ // Build a map from claimId -> claim for fast lookup
456
+ const claimById = new Map();
457
+ for (const c of allClaims) {
458
+ if (c && c.id) claimById.set(c.id, c);
459
+ }
460
+
461
+ const checks = [];
462
+ const seen = new Set();
463
+
464
+ // (A) Evidence items with execution.label (command captures).
465
+ // These represent commands that actually ran — include them regardless of
466
+ // effective status so we can cross-reference against the live log.
467
+ for (const ev of allEvidence) {
468
+ if (!ev || !ev.execution || !ev.execution.label) continue;
469
+ const cmd = String(ev.execution.label || '').replace(/\s+/g, ' ').trim();
470
+ if (!cmd) continue;
471
+ const claim = claimById.get(ev.claimId);
472
+ if (!claim) continue;
473
+ const claimTypeStr = String(claim.claimType || '');
474
+ if (!claimTypeStr.startsWith('workflow.check.') && !(declaredClaimTypes != null && declaredClaimTypes.has(claimTypeStr))) continue;
475
+ // Deduplicate by command
476
+ if (seen.has(cmd)) continue;
477
+ seen.add(cmd);
478
+ const id = claimCheckId(claim.subjectId);
479
+ // Use 'pass' as the nominal claimed status; cross-reference catches contradictions.
480
+ checks.push({ id, kind: 'command', status: 'pass', command: cmd });
481
+ }
482
+
483
+ // (B) Workflow.check.command claims with effective value "pass" but no capture
484
+ // (no evidence item with execution) — these are originally-claimed-pass checks
485
+ // that were never captured.
486
+ for (const c of allClaims) {
487
+ if (!c || typeof c.claimType !== 'string') continue;
488
+ const isCommandType = c.claimType === 'workflow.check.command'
489
+ || (declaredClaimTypes != null && declaredClaimTypes.has(c.claimType));
490
+ if (!isCommandType) continue;
491
+ if (normalizedStatus(c.value || '') !== 'pass') continue;
492
+ // Check if this claim already has a capture evidence item (covered in (A))
493
+ const hasCapture = allEvidence.some(ev => ev && ev.claimId === c.id && ev.execution && ev.execution.label);
494
+ if (hasCapture) continue;
495
+ // No capture — use fieldOrBehavior as command identifier for backstop resolution.
496
+ const evItem = allEvidence.find(ev => ev && ev.claimId === c.id);
497
+ const cmd = evItem
498
+ ? normalizeCommand(evItem.excerptOrSummary || '')
499
+ : normalizeCommand(c.fieldOrBehavior || '');
500
+ const id = claimCheckId(c.subjectId);
501
+ if (!cmd) {
502
+ checks.push({ id, kind: 'command', status: 'pass', command: '' });
503
+ continue;
504
+ }
505
+ if (seen.has(cmd)) continue;
506
+ seen.add(cmd);
507
+ checks.push({ id, kind: 'command', status: 'pass', command: cmd });
508
+ }
509
+
510
+ return checks;
511
+ }
512
+
513
+ /**
514
+ * Extract pending acceptance criteria from trust.bundle workflow.acceptance.criterion claims,
515
+ * or from declared claimTypes when a FlowDefinition is active (P-c extension).
516
+ * Returns the count of claims whose value is pending/not_started/empty/unknown.
517
+ * Returns null when the bundle has no matching claims.
518
+ *
519
+ * @param {Array} claims - trust.bundle claims array
520
+ * @param {Set<string>|null} [declaredClaimTypes] - optional set of declared claimTypes from gateExpects[]
521
+ */
522
+ function bundlePendingCriteriaCount(claims, declaredClaimTypes) {
523
+ const criteriaClaims = claims.filter(c => {
524
+ if (!c || typeof c.claimType !== 'string') return false;
525
+ if (c.claimType === 'workflow.acceptance.criterion') return true;
526
+ return declaredClaimTypes != null && declaredClaimTypes.has(c.claimType);
527
+ });
528
+ if (criteriaClaims.length === 0) return null;
529
+ const pending = criteriaClaims.filter(c => {
530
+ const v = normalizedStatus(c.value || '');
531
+ return v === 'pending' || v === 'not_started' || v === '' || v === 'unknown';
532
+ });
533
+ return pending.length;
534
+ }
535
+
536
+ // ─────────────────────────────────────────────────────────────────────────────
537
+
538
+ /**
539
+ * ADR 0010 Phase 4b: sidecarGuidance — bundle-first evidence/critique reads.
540
+ * state.json reads are UNCHANGED (state.json stays as primary source).
541
+ * evidence.json verdict/checks: read from trust.bundle when present, fall back
542
+ * to evidence.json for bundle-less sessions (no regression).
543
+ * not_verified_gaps: always from evidence.json (no bundle equivalent).
544
+ * critique status: read from trust.bundle when present, fall back to critique.json.
545
+ * Finding details: still from critique.json when present (both bundle and sidecar paths).
546
+ *
547
+ * ADR 0016 P-c: when activeFlowStep is non-null, pass its declared claimTypes to
548
+ * bundle helpers so declared-type claims (e.g. builder.verify.tests) produce the
549
+ * same sidecar guidance signals as workflow.* claims.
550
+ */
551
+ function sidecarGuidance(root, artifactDir, activeFlowStep) {
552
+ // Build the declared claimType set from the FlowDefinition gate expects[] (P-c).
553
+ // Null when no FlowDefinition is active (fallback: helpers use workflow.* prefix only).
554
+ const declaredClaimTypes = declaredClaimTypesFor(activeFlowStep);
278
555
  const warnings = [];
279
556
  const state = readJsonFile(path.join(artifactDir, 'state.json'));
280
- const evidence = readJsonFile(path.join(artifactDir, 'evidence.json'));
281
- const critique = readJsonFile(path.join(artifactDir, 'critique.json'));
282
557
  const base = relative(root, artifactDir);
283
558
 
284
559
  if (state) {
@@ -301,38 +576,88 @@ function sidecarGuidance(root, artifactDir) {
301
576
  warnings.push(`${base} next action: ${safeOneLine(next.summary)}${next.target_phase ? ` (target phase: ${safeOneLine(next.target_phase, 80)})` : ''}`);
302
577
  }
303
578
 
304
- if (evidence && normalizedStatus(evidence.verdict) && normalizedStatus(evidence.verdict) !== 'pass') {
305
- warnings.push(`${base} evidence verdict:${safeOneLine(evidence.verdict, 40)}; do not deliver without accepted gap or new evidence.`);
306
- }
307
- if (evidence && Array.isArray(evidence.not_verified_gaps) && evidence.not_verified_gaps.length > 0) {
308
- for (const gap of evidence.not_verified_gaps.slice(0, 3)) {
309
- warnings.push(`${base} evidence NOT_VERIFIED gap: ${safeOneLine(gap)}`);
579
+ // ── Evidence verdict + checks: bundle-first, fallback to evidence.json ────
580
+ const bundle = readJsonFile(path.join(artifactDir, 'trust.bundle'));
581
+ const bundleClaims = bundle && Array.isArray(bundle.claims) ? bundle.claims : null;
582
+
583
+ if (bundleClaims) {
584
+ // Phase 4b: read verdict and per-check signals from trust.bundle claims.
585
+ // P-c: pass declaredClaimTypes so declared-type claims are included alongside workflow.*.
586
+ const verdict = bundleEvidenceVerdict(bundleClaims, declaredClaimTypes);
587
+ if (verdict && verdict !== 'pass' && verdict !== 'skip') {
588
+ warnings.push(`${base} evidence verdict:${safeOneLine(verdict, 40)}; do not deliver without accepted gap or new evidence.`);
310
589
  }
311
- }
312
- if (evidence && Array.isArray(evidence.checks)) {
313
- const blockingChecks = evidence.checks.filter(check => {
314
- const status = normalizedStatus(check && check.status);
315
- return status === 'fail' || status === 'failed' || status === 'not_verified' || status === 'not-verified';
316
- });
590
+ const blockingChecks = bundleBlockingChecks(bundleClaims, declaredClaimTypes);
317
591
  for (const check of blockingChecks.slice(0, 4)) {
318
592
  const status = safeOneLine(check.status || 'unknown', 40);
319
593
  warnings.push(`${base} evidence check ${safeOneLine(check.id || 'unknown', 80)} status:${status}: ${safeOneLine(check.summary)}`);
320
594
  }
595
+ } else {
596
+ // Fallback: no bundle — read from evidence.json (existing behavior, no regression).
597
+ const evidence = readJsonFile(path.join(artifactDir, 'evidence.json'));
598
+ if (evidence && normalizedStatus(evidence.verdict) && normalizedStatus(evidence.verdict) !== 'pass') {
599
+ warnings.push(`${base} evidence verdict:${safeOneLine(evidence.verdict, 40)}; do not deliver without accepted gap or new evidence.`);
600
+ }
601
+ if (evidence && Array.isArray(evidence.checks)) {
602
+ const blockingChecks = evidence.checks.filter(check => {
603
+ const status = normalizedStatus(check && check.status);
604
+ return status === 'fail' || status === 'failed' || status === 'not_verified' || status === 'not-verified';
605
+ });
606
+ for (const check of blockingChecks.slice(0, 4)) {
607
+ const status = safeOneLine(check.status || 'unknown', 40);
608
+ warnings.push(`${base} evidence check ${safeOneLine(check.id || 'unknown', 80)} status:${status}: ${safeOneLine(check.summary)}`);
609
+ }
610
+ }
611
+ }
612
+
613
+ // not_verified_gaps: always from evidence.json (no bundle equivalent).
614
+ const evidence = readJsonFile(path.join(artifactDir, 'evidence.json'));
615
+ if (evidence && Array.isArray(evidence.not_verified_gaps) && evidence.not_verified_gaps.length > 0) {
616
+ for (const gap of evidence.not_verified_gaps.slice(0, 3)) {
617
+ warnings.push(`${base} evidence NOT_VERIFIED gap: ${safeOneLine(gap)}`);
618
+ }
321
619
  }
322
620
 
323
- if (critique && critique.required === true && normalizedStatus(critique.status) !== 'pass') {
324
- warnings.push(`${base} critique status:${safeOneLine(critique.status || 'unknown', 40)}; required critique must pass or findings be accepted.`);
325
- const critiques = Array.isArray(critique.critiques) ? critique.critiques : [];
326
- let openCount = 0;
327
- for (const review of critiques) {
328
- const findings = Array.isArray(review && review.findings) ? review.findings : [];
329
- for (const finding of findings) {
330
- if (!finding || normalizedStatus(finding.status) !== 'open') continue;
331
- warnings.push(`${base} critique open ${safeOneLine(finding.severity || 'unknown', 40)}: ${safeOneLine(finding.description)}`);
332
- openCount += 1;
621
+ // ── Critique: bundle-first status, critique.json for finding details ──────
622
+ const critique = readJsonFile(path.join(artifactDir, 'critique.json'));
623
+
624
+ if (bundleClaims) {
625
+ // Phase 4b: read critique status from trust.bundle claims.
626
+ // P-c: pass declaredClaimTypes so declared-type critique claims are included.
627
+ const critiqueStatusVal = bundleCritiqueStatus(bundleClaims, declaredClaimTypes);
628
+ const critiqueIsBlocking = critiqueStatusVal !== null && normalizedStatus(critiqueStatusVal) !== 'pass';
629
+ if (critiqueIsBlocking) {
630
+ warnings.push(`${base} critique status:${safeOneLine(critiqueStatusVal || 'unknown', 40)}; required critique must pass or findings be accepted.`);
631
+ // Finding details: still from critique.json when present (both paths use the same details source).
632
+ const critiques = critique && Array.isArray(critique.critiques) ? critique.critiques : [];
633
+ let openCount = 0;
634
+ for (const review of critiques) {
635
+ const findings = Array.isArray(review && review.findings) ? review.findings : [];
636
+ for (const finding of findings) {
637
+ if (!finding || normalizedStatus(finding.status) !== 'open') continue;
638
+ warnings.push(`${base} critique open ${safeOneLine(finding.severity || 'unknown', 40)}: ${safeOneLine(finding.description)}`);
639
+ openCount += 1;
640
+ if (openCount >= 3) break;
641
+ }
642
+ if (openCount >= 3) break;
643
+ }
644
+ }
645
+ } else {
646
+ // Fallback: no bundle — read from critique.json (existing behavior, no regression).
647
+ if (critique && critique.required === true && normalizedStatus(critique.status) !== 'pass') {
648
+ warnings.push(`${base} critique status:${safeOneLine(critique.status || 'unknown', 40)}; required critique must pass or findings be accepted.`);
649
+ const critiques = Array.isArray(critique.critiques) ? critique.critiques : [];
650
+ let openCount = 0;
651
+ for (const review of critiques) {
652
+ const findings = Array.isArray(review && review.findings) ? review.findings : [];
653
+ for (const finding of findings) {
654
+ if (!finding || normalizedStatus(finding.status) !== 'open') continue;
655
+ warnings.push(`${base} critique open ${safeOneLine(finding.severity || 'unknown', 40)}: ${safeOneLine(finding.description)}`);
656
+ openCount += 1;
657
+ if (openCount >= 3) break;
658
+ }
333
659
  if (openCount >= 3) break;
334
660
  }
335
- if (openCount >= 3) break;
336
661
  }
337
662
  }
338
663
 
@@ -342,12 +667,16 @@ function sidecarGuidance(root, artifactDir) {
342
667
  // -----------------------------------------------------------------------
343
668
  // Capture-first evidence determinism (Part B)
344
669
  //
345
- // evidence.json is the MODEL transcribing what it thinks happened. The capture
346
- // hook (evidence-capture.js) writes the REAL command results to
347
- // command-log.jsonl at the source. Here at the Stop gate we cross-reference the
348
- // model's claimed-pass command checks against that captured truth, and only fall
349
- // back to re-running a TRUSTED command when the log has no execution for a
350
- // claimed-pass command (i.e. it was never actually run).
670
+ // The trust.bundle (emitted by workflow-sidecar via @kontourai/surface) carries
671
+ // capture-authoritative evidence items. The capture hook (evidence-capture.js)
672
+ // writes REAL command results to command-log.jsonl at the source. Here at the
673
+ // Stop gate we cross-reference claimed-pass command checks against that captured
674
+ // truth, and only fall back to re-running a TRUSTED command when the log has no
675
+ // execution for a claimed-pass command (i.e. it was never actually run).
676
+ //
677
+ // ADR 0010 Phase 4b: source the claimed-pass command checks from the bundle's
678
+ // evidence[] (execution/command items) instead of evidence.json checks.
679
+ // command-log.jsonl path UNCHANGED — it stays the capture truth source.
351
680
  // -----------------------------------------------------------------------
352
681
 
353
682
  function normalizeCommand(value) {
@@ -383,6 +712,195 @@ function readCommandLog(artifactDir) {
383
712
  return byCommand;
384
713
  }
385
714
 
715
+ /**
716
+ * Read command-log.jsonl into a map of normalized-command -> LATEST capture outcome.
717
+ * The LAST entry for each command wins (unlike readCommandLog which makes FAIL sticky).
718
+ * Used for both capturedFailReconciliation and captureCrossReference (Fix C): we want to
719
+ * know the LAST result, so a genuine re-run-to-pass clears the earlier FAIL. Only an actual
720
+ * re-run (new PASS entry in the log) clears it — a new claim cannot change the log.
721
+ */
722
+ function readLatestCommandLog(artifactDir) {
723
+ const file = path.join(artifactDir, 'command-log.jsonl');
724
+ let raw = '';
725
+ try { raw = fs.readFileSync(file, 'utf8'); } catch { return new Map(); }
726
+ const byCommand = new Map();
727
+ for (const line of raw.split('\n')) {
728
+ const trimmed = line.trim();
729
+ if (!trimmed) continue;
730
+ let entry;
731
+ try { entry = JSON.parse(trimmed); } catch { continue; }
732
+ if (!entry || typeof entry.command !== 'string') continue;
733
+ const key = normalizeCommand(entry.command);
734
+ if (!key) continue;
735
+ const failed = entry.observedResult === 'fail' || (Number.isInteger(entry.exitCode) && entry.exitCode !== 0);
736
+ // LAST entry wins — genuine re-run-to-pass overwrites the earlier FAIL.
737
+ byCommand.set(key, {
738
+ ran: true,
739
+ failed,
740
+ exitCode: Number.isInteger(entry.exitCode) ? entry.exitCode : null,
741
+ });
742
+ }
743
+ return byCommand;
744
+ }
745
+
746
+ // ─── Claim-status helpers for capturedFailReconciliation ─────────────────────
747
+
748
+ /**
749
+ * Returns true when a claim's stored status+value asserts the command PASSED.
750
+ * Used to detect namespace-agnostic false-completions.
751
+ */
752
+ function claimAssertsPass(status, value) {
753
+ const s = String(status || '').toLowerCase();
754
+ const v = String(value || '').toLowerCase().replace(/\s+/g, ' ').trim();
755
+ // Fix E: added 'approved' status alias and 'true'/'ok' value aliases
756
+ return (s === 'verified' || s === 'assumed' || s === 'accepted' || s === 'trusted' || s === 'approved')
757
+ && (v === 'pass' || v === 'passed' || v === 'verified' || v === 'true' || v === 'ok');
758
+ }
759
+
760
+ /**
761
+ * Returns true when a claim's stored status+value ACKNOWLEDGES a failure
762
+ * (the agent owned the failure rather than claiming pass).
763
+ */
764
+ function claimAcknowledgesFailure(status, value) {
765
+ const s = String(status || '').toLowerCase();
766
+ const v = String(value || '').toLowerCase().replace(/\s+/g, ' ').trim();
767
+ return s === 'disputed' || s === 'rejected' || s === 'failing' || s === 'failed'
768
+ || s === 'not_verified' || s === 'not-verified'
769
+ || v === 'fail' || v === 'failed' || v === 'not_verified' || v === 'failing';
770
+ }
771
+
772
+ // hasLaunderingOperator (the exit-code-mask heuristic) is imported from
773
+ // ../lib/command-log-chain.js so this verifier and scripts/ci/trust-reconcile.js
774
+ // share one normative definition. Applied in captureCrossReference's satisfied
775
+ // path and capturedFailReconciliation.
776
+
777
+ // ─── Hash-chain integrity verification (Increment B2, tamper-EVIDENCE) ────────
778
+ //
779
+ // command-log.jsonl is the independent capture truth source. Each chained entry
780
+ // carries a `_chain` block: {seq, prevHash, hash} where
781
+ // hash = sha256(prevHash + canonicalJson(record))
782
+ // and canonicalJson is the record WITHOUT `_chain`, keys sorted alphabetically.
783
+ //
784
+ // HONEST FRAMING: this is tamper-EVIDENCE, not tamper-PROOF. An agent that
785
+ // rewrites all hashes can still forge a valid chain. The real tamper-proof
786
+ // boundary is the signed checkpoint (B1). This raises the local bar and catches
787
+ // casual/accidental tampering and corruption.
788
+ //
789
+ // verifyCommandLogChain returns:
790
+ // "ok" — all entries have _chain and every hash/linkage is valid.
791
+ // "legacy" — no _chain fields at all (pre-B2 log); treat as today, no
792
+ // integrity claim. Backward-compat: existing fixtures stay green.
793
+ // "broken" — a chained entry has a bad hash or bad linkage; the capture
794
+ // truth source appears altered/removed/reordered.
795
+ //
796
+ // The genesis prevHash is a fixed arbitrary sentinel — NOT the SHA256 of any
797
+ // specific input string. The comment in evidence-capture.js previously (and
798
+ // incorrectly) claimed it was sha256("flow-agents:command-log:genesis"); it is not.
799
+ // Both the genesis (CHAIN_GENESIS_VERIFY, imported above) and the canonical-JSON
800
+ // helper (canonicalJsonForChain) come from ../lib/command-log-chain.js, the single
801
+ // source the writer in evidence-capture.js imports too — so they cannot diverge.
802
+
803
+ /**
804
+ * Verify the hash chain of command-log.jsonl.
805
+ * Returns { status, brokenAt, forkAt } where:
806
+ * status = "ok" | "legacy" | "broken" | "forked"
807
+ * brokenAt = index (0-based) of the first broken entry, or null
808
+ * forkAt = index (0-based) of the first concurrent-fork sibling, or null
809
+ *
810
+ * "forked" is a BENIGN concurrent-append race, not tampering: two PostToolUse
811
+ * captures appended off the same parent tip (e.g. parallel agents sharing one
812
+ * log) before the writer lock (flow-agents#232) serialized them. It is
813
+ * distinguished from "broken" because:
814
+ * - every entry's hash is still self-consistent (no content was edited), and
815
+ * - every entry's parent is reachable (nothing was reordered or removed);
816
+ * - the only anomaly is a parent claimed by >1 capture-sourced sibling.
817
+ * Tamper — a content edit (self-hash mismatch), a reorder, or a deletion
818
+ * (unreachable parent) — still returns "broken". A fork cannot be used to
819
+ * launder a content edit: editing a record breaks its self-hash, which is
820
+ * checked before fork classification.
821
+ */
822
+ function verifyCommandLogChain(artifactDir) {
823
+ const file = path.join(artifactDir, 'command-log.jsonl');
824
+ let raw = '';
825
+ try { raw = fs.readFileSync(file, 'utf8'); } catch { return { status: 'legacy', brokenAt: null, forkAt: null }; }
826
+
827
+ const lines = raw.split('\n').filter(l => l.trim());
828
+ if (lines.length === 0) return { status: 'legacy', brokenAt: null, forkAt: null };
829
+
830
+ // Parse all entries, tolerating unparseable lines (they count as legacy/unchained).
831
+ const entries = [];
832
+ for (const line of lines) {
833
+ try {
834
+ const entry = JSON.parse(line);
835
+ if (entry && typeof entry === 'object') entries.push(entry);
836
+ } catch { /* skip malformed lines */ }
837
+ }
838
+ if (entries.length === 0) return { status: 'legacy', brokenAt: null, forkAt: null };
839
+
840
+ // Classify: are there any chained entries?
841
+ const hasAnyChain = entries.some(e => e._chain && typeof e._chain.hash === 'string');
842
+ if (!hasAnyChain) return { status: 'legacy', brokenAt: null, forkAt: null };
843
+
844
+ // Walk in file order. A chained entry is ACCEPTED when both:
845
+ // (a) self-consistent: hash === sha256(prevHash + canonicalJson(record)),
846
+ // so a content edit (e.g. flipping exitCode) without rehashing fails; and
847
+ // (b) reachable: prevHash is genesis or the hash of any prior accepted entry.
848
+ // We track the SET of reachable hashes (not just the latest tip) so that
849
+ // concurrent-fork siblings — which share a still-reachable parent — are
850
+ // tolerated, while a reorder/deletion (parent not reachable) is caught.
851
+ const reachable = new Set([CHAIN_GENESIS_VERIFY]);
852
+ const parentSources = new Map(); // prevHash -> [source, ...] (fork detection)
853
+ let prevWasChained = false;
854
+ let forked = false;
855
+ let firstForkAt = null;
856
+
857
+ for (let i = 0; i < entries.length; i++) {
858
+ const entry = entries[i];
859
+ const chain = entry._chain;
860
+ if (!chain || typeof chain.hash !== 'string') {
861
+ // Legacy entry without _chain. If we have already seen a chained entry,
862
+ // a gap in the chain (a legacy entry in the middle) counts as broken
863
+ // (it could indicate a removed chained entry was replaced by a legacy one).
864
+ if (prevWasChained) return { status: 'broken', brokenAt: i, forkAt: null };
865
+ // Before any chained entry: tolerate (legacy prefix).
866
+ continue;
867
+ }
868
+
869
+ // (a) Self-consistency. A content edit without rehashing fails here.
870
+ if (typeof chain.prevHash !== 'string') return { status: 'broken', brokenAt: i, forkAt: null };
871
+ const selfHash = crypto.createHash('sha256')
872
+ .update(chain.prevHash + canonicalJsonForChain(entry), 'utf8')
873
+ .digest('hex');
874
+ if (chain.hash !== selfHash) return { status: 'broken', brokenAt: i, forkAt: null };
875
+
876
+ // (b) Reachability. An unreachable parent means a reorder or a removed
877
+ // predecessor — structural tamper, not a benign concurrent append.
878
+ if (!reachable.has(chain.prevHash)) return { status: 'broken', brokenAt: i, forkAt: null };
879
+
880
+ // Fork detection: a parent claimed by more than one entry is a fork. It is
881
+ // benign only when EVERY sibling on that parent is a PostToolUse capture
882
+ // (two captures racing on the same tip). Any non-capture sibling on a
883
+ // shared parent is treated as tamper (conservative).
884
+ const sources = parentSources.get(chain.prevHash) || [];
885
+ sources.push(entry.source);
886
+ parentSources.set(chain.prevHash, sources);
887
+ if (sources.length > 1) {
888
+ if (!sources.every(s => s === 'postToolUse-capture')) {
889
+ return { status: 'broken', brokenAt: i, forkAt: null };
890
+ }
891
+ if (firstForkAt === null) firstForkAt = i;
892
+ forked = true;
893
+ }
894
+
895
+ reachable.add(chain.hash);
896
+ prevWasChained = true;
897
+ }
898
+
899
+ if (forked) return { status: 'forked', brokenAt: null, forkAt: firstForkAt };
900
+ return { status: 'ok', brokenAt: null, forkAt: null };
901
+ }
902
+ // ─────────────────────────────────────────────────────────────────────────────
903
+
386
904
  /**
387
905
  * Resolve a TRUSTED command to re-run for a claimed-pass check whose command was
388
906
  * never captured. Priority (most trusted first):
@@ -394,10 +912,39 @@ function readCommandLog(artifactDir) {
394
912
  * FLOW_AGENTS_GOAL_FIT_RECHECK=true (the RCE-risky opt-in path).
395
913
  * Returns { argv, cwd, source } or null when nothing trusted resolves.
396
914
  */
915
+ // WS8 (AC10b): a kind:"command" evidence ref's excerpt/command must be a literally runnable
916
+ // shell command, not a prose description of a manual verification step. This heuristic
917
+ // rejects prose so the goal-fit backstop never spawns `bash -lc "<a sentence>"` and
918
+ // misreports the resulting shell error as a caught false-completion. Pairs with the rule
919
+ // stated in context/contracts/planning-contract.md (AC11).
920
+ function isRunnableCommandText(text) {
921
+ const s = typeof text === 'string' ? text.trim() : '';
922
+ if (!s) return false;
923
+ // Sentence-like prose (a . ! or ? followed by more words) is not a single command.
924
+ if (/[.!?]\s+\S/.test(s)) return false;
925
+ const first = s.split(/\s+/)[0] || '';
926
+ // WS8 (AC10b, iteration 2): the first-token allowlist is broadened to the common
927
+ // real-world verify binaries a project's runnable evidence names (git, tsc, eslint,
928
+ // python/python3, docker, curl, jq, diff, grep, test) so honest command evidence is not
929
+ // misclassified as prose. Still fail-closed for genuinely non-command prose: a bare
930
+ // sentence whose first token is none of these (and carries no path prefix or shell
931
+ // metacharacter) is rejected rather than executed.
932
+ const knownBinary = /^(npm|npx|pnpm|yarn|node|bash|sh|zsh|make|just|task|cargo|go|pytest|tox|deno|bun|ruby|rake|mvn|gradle|dotnet|swift|flutter|dart|git|tsc|eslint|python|python3|docker|curl|jq|diff|grep|test)$/.test(first)
933
+ || first.startsWith('./') || first.startsWith('/') || first.includes('/');
934
+ const hasShellMeta = /[|&;<>()$`]/.test(s);
935
+ return knownBinary || hasShellMeta;
936
+ }
937
+
397
938
  function resolveTrustedCommand(root, artifactDir, check, acceptance) {
398
939
  // (a) acceptance criterion command for the matching criterion.
399
940
  const fromAcceptance = acceptanceCommandFor(check, acceptance);
400
- if (fromAcceptance) return { argv: ['bash', '-lc', fromAcceptance], cwd: root, source: 'acceptance' };
941
+ if (fromAcceptance) {
942
+ // WS8 (AC10b): never spawn a prose "excerpt" as bash. A kind:"command" ref whose text
943
+ // is not a runnable shell command is malformed-evidence — reported distinctly, not
944
+ // executed, and not conflated with a caught false-completion.
945
+ if (!isRunnableCommandText(fromAcceptance)) return { malformed: fromAcceptance };
946
+ return { argv: ['bash', '-lc', fromAcceptance], cwd: root, source: 'acceptance' };
947
+ }
401
948
 
402
949
  // (b) declared manifest target. Map the check command/id to a declared script.
403
950
  const declared = declaredManifestTarget(root, check);
@@ -509,42 +1056,147 @@ function runBackstop(trusted) {
509
1056
  }
510
1057
 
511
1058
  /**
512
- * Cross-reference each evidence.checks[] of kind:"command" claiming status:"pass"
513
- * that carries a command against the capture log, with the trusted backstop as a
514
- * thin fallback only when the log has no execution for that command.
1059
+ * ADR 0010 Phase 4b: captureCrossReference — bundle-first command check sourcing.
1060
+ * Sources the claimed-pass command checks from trust.bundle evidence[] (execution/
1061
+ * command items) when the bundle is present, falling back to evidence.json checks
1062
+ * for bundle-less sessions. command-log.jsonl UNCHANGED — it stays the capture
1063
+ * truth source. The teeth (claimed-pass + captured-fail → block) are byte-identical.
515
1064
  *
516
- * Emits warnings (which feed the existing block/MAX_BLOCKS machinery) when a
517
- * claimed-pass command actually FAILED (log or backstop), and NOT_VERIFIED notes
518
- * when nothing trusted can confirm it.
1065
+ * ADR 0016 P-c (fix): accept activeFlowStep so declared-type sessions (e.g.
1066
+ * builder.verify.tests) are visible to the cross-reference, closing the hole
1067
+ * where captureCrossReference was the only capture consumer not threaded with
1068
+ * the FlowDefinition. Mirrors the pattern in bundleEnforcement / sidecarGuidance.
519
1069
  */
520
- function captureCrossReference(root, artifactDir) {
521
- const evidence = readJsonFile(path.join(artifactDir, 'evidence.json'));
522
- if (!evidence || !Array.isArray(evidence.checks)) return [];
1070
+ function captureCrossReference(root, artifactDir, activeFlowStep) {
1071
+ // Build the declared claimType set from the FlowDefinition gate expects[] (P-c).
1072
+ // Null when no FlowDefinition is active (fallback: bundleClaimedPassCommandChecks
1073
+ // uses workflow.check.* prefix only — no regression for non-FlowDefinition sessions).
1074
+ const declaredClaimTypes = declaredClaimTypesFor(activeFlowStep);
1075
+ const bundle = readJsonFile(path.join(artifactDir, 'trust.bundle'));
523
1076
  const acceptance = readJsonFile(path.join(artifactDir, 'acceptance.json'));
524
- const log = readCommandLog(artifactDir);
1077
+ const log = readLatestCommandLog(artifactDir); // Fix C: latest-wins; genuine fix-then-rerun-to-pass clears the block
525
1078
  const base = relative(root, artifactDir);
526
1079
  const backstopMode = resolveBackstopMode();
527
1080
  const warnings = [];
528
1081
 
529
- const claimedPass = evidence.checks.filter(check => {
530
- if (!check || typeof check !== 'object') return false;
531
- const kind = normalizedStatus(check.kind);
532
- const status = normalizedStatus(check.status);
533
- return kind === 'command' && (status === 'pass' || status === 'passed') && normalizeCommand(check.command);
534
- });
1082
+ // AC3 fail-closed: detect a missing command log in a post-execution session.
1083
+ // When state.json confirms the session is past the planning phase (commands should
1084
+ // have run), but command-log.jsonl is absent, emit a FULL_BLOCK warning.
1085
+ // A genuine pre-execution session (no commands expected yet) is NOT blocked —
1086
+ // distinguished by requiring BOTH a non-pre-execution phase AND a non-pre-execution
1087
+ // status (avoids blocking a freshly planned task that has state.json but no log yet).
1088
+ //
1089
+ // BAR-RAISER: an agent could forge state.json with a pre-execution phase to avoid
1090
+ // this. The real anchor is external (signed checkpoints + human review).
1091
+ {
1092
+ const logFileMissing = !fs.existsSync(path.join(artifactDir, 'command-log.jsonl'));
1093
+ if (logFileMissing) {
1094
+ const captureState = readJsonFile(path.join(artifactDir, 'state.json'));
1095
+ if (captureState) {
1096
+ const capturePhase = normalizedStatus(captureState.phase || '');
1097
+ const captureStatus = normalizedStatus(captureState.status || '');
1098
+ // Post-execution: phase is set AND not a pre-execution phase (idea/backlog/pickup/planning).
1099
+ // Also require status is not a pre-execution status (new/planning/planned/backlog).
1100
+ const postExecPhase = capturePhase && !PRE_EXECUTION_PHASES.has(capturePhase);
1101
+ const preExecStatus = !captureStatus || captureStatus === 'new' || PRE_EXECUTION_STATUSES.has(captureStatus);
1102
+ if (postExecPhase && !preExecStatus) {
1103
+ // Fix #216 over-block: only emit the missing-log warning when a command was
1104
+ // actually EXPECTED to be captured — i.e., the trust.bundle evidence has at
1105
+ // least one item with execution.label (concrete proof a command was meant to
1106
+ // be captured). A no-command session (doc review, policy task advanced to
1107
+ // verification without running shell commands) must NOT be blocked here.
1108
+ // Note: `bundle` is already read at the top of captureCrossReference.
1109
+ const captureEvidence = bundle && Array.isArray(bundle.evidence) ? bundle.evidence : [];
1110
+ const hasExpectedCapture = captureEvidence.some(ev => ev && ev.execution && ev.execution.label);
1111
+ if (hasExpectedCapture) {
1112
+ warnings.push(
1113
+ `${base} expected capture log is missing — possible deletion of the capture truth source; ` +
1114
+ `phase:${capturePhase} status:${captureStatus} indicates commands should have run. ` +
1115
+ 'Cannot verify command execution deterministically. ' +
1116
+ 'Restore from a checkpoint or investigate.'
1117
+ );
1118
+ }
1119
+ }
1120
+ }
1121
+ }
1122
+ }
1123
+
1124
+ // ── Hash-chain integrity check ──────────────────────────────────────────────
1125
+ // Verify command-log.jsonl before trusting its pass/fail signals. If the chain
1126
+ // is broken (altered, removed, or reordered entries), the capture truth source
1127
+ // is compromised: we must NOT trust its pass signals for claimed-pass checks.
1128
+ //
1129
+ // ok → proceed normally (chain is valid, log is trustworthy).
1130
+ // legacy → proceed normally (pre-B2 log, no chain to verify, existing behavior).
1131
+ // forked → benign concurrent-append race (not tampering): emit a loud but
1132
+ // NON-blocking advisory and keep trusting the records. The capture
1133
+ // contradiction teeth still run (the records are genuine, just not
1134
+ // linearly ordered); the operator can re-linearize with the repair
1135
+ // tool. This is what stops honest parallel work from being trapped.
1136
+ // broken → emit a loud warning and treat ALL claimed-pass commands relying on
1137
+ // this log as NOT_VERIFIED/blocking — do not let them sail through.
1138
+ let chainBroken = false;
1139
+ {
1140
+ const chainResult = verifyCommandLogChain(artifactDir);
1141
+ if (chainResult.status === 'broken') {
1142
+ chainBroken = true;
1143
+ const brokenIdx = chainResult.brokenAt !== null ? ` (entry ${chainResult.brokenAt})` : '';
1144
+ warnings.push(
1145
+ `${base} command-log integrity check FAILED — capture truth source appears tampered${brokenIdx}: ` +
1146
+ 'claimed-pass checks relying on it are NOT trusted. ' +
1147
+ 'This is tamper-EVIDENCE (hash-chain broken); alteration, removal, or reordering detected. ' +
1148
+ 'NOT_VERIFIED: cannot confirm or deny claimed passes.'
1149
+ );
1150
+ } else if (chainResult.status === 'forked') {
1151
+ // NOT a hard block: this string must not match HARD_BLOCK/FULL_BLOCK. A
1152
+ // concurrent fork is benign — no content was edited and nothing was
1153
+ // removed — so honest parallel work proceeds. We surface it loudly and
1154
+ // point at the deterministic repair.
1155
+ const forkIdx = chainResult.forkAt !== null ? ` (entry ${chainResult.forkAt})` : '';
1156
+ warnings.push(
1157
+ `${base} command-log shows a concurrent-capture fork${forkIdx} — two PostToolUse captures appended off the same parent ` +
1158
+ '(parallel writers before the writer lock). This is NOT tampering: every record is self-consistent and reachable. ' +
1159
+ 'Records remain trusted; re-linearize with: node scripts/repair-command-log.js <artifact-dir>'
1160
+ );
1161
+ }
1162
+ }
1163
+
1164
+ // Build the list of claimed-pass command checks — bundle-first, evidence.json fallback.
1165
+ let claimedPass;
1166
+ if (bundle && Array.isArray(bundle.claims)) {
1167
+ // Phase 4b: source from trust.bundle evidence[] (execution/command items).
1168
+ claimedPass = bundleClaimedPassCommandChecks(bundle, declaredClaimTypes);
1169
+ } else {
1170
+ // Fallback: no bundle — read from evidence.json (existing behavior, no regression).
1171
+ const evidence = readJsonFile(path.join(artifactDir, 'evidence.json'));
1172
+ if (!evidence || !Array.isArray(evidence.checks)) return warnings;
1173
+ claimedPass = evidence.checks.filter(check => {
1174
+ if (!check || typeof check !== 'object') return false;
1175
+ const kind = normalizedStatus(check.kind);
1176
+ const status = normalizedStatus(check.status);
1177
+ return kind === 'command' && (status === 'pass' || status === 'passed') && normalizeCommand(check.command);
1178
+ });
1179
+ }
535
1180
 
536
1181
  for (const check of claimedPass.slice(0, 8)) {
537
1182
  const cmd = normalizeCommand(check.command);
1183
+ if (!cmd) continue;
538
1184
  const id = safeOneLine(check.id || cmd, 80);
539
1185
  const logged = log.get(cmd);
540
1186
 
541
- if (logged && logged.ran) {
542
- // (1) Cross-reference the capture log first.
1187
+ if (!chainBroken && logged && logged.ran) {
1188
+ // (1) Cross-reference the capture log first (only when chain is intact).
1189
+ // A broken chain means we cannot trust the log's pass signals — skip this
1190
+ // shortcut and fall through to the backstop/NOT_VERIFIED path below.
543
1191
  if (logged.failed) {
544
1192
  const exit = Number.isInteger(logged.exitCode) ? ` (exitCode:${logged.exitCode})` : '';
545
1193
  warnings.push(`${base} evidence check ${id}: capture log CONTRADICTS claimed pass — command "${safeOneLine(cmd, 120)}" was recorded as FAIL${exit}. This is a caught false-completion.`);
1194
+ } else if (hasLaunderingOperator(cmd)) {
1195
+ // Fix D: exit-code laundering. The captured exit-0 is not trustworthy — the command
1196
+ // baked in '|| true' / '|| :' / '; true' / '; exit 0' / '| true' to mask the real result.
1197
+ warnings.push(`${base} evidence check ${id}: claimed pass relies on an exit-code-laundered command "${safeOneLine(cmd, 120)}" — the exit code is not a trustworthy signal (laundering operators mask the real exit code).`);
546
1198
  }
547
- // log shows it ran and passed → satisfied deterministically, no re-run.
1199
+ // else: log shows it ran and passed with no laundering → satisfied deterministically.
548
1200
  continue;
549
1201
  }
550
1202
 
@@ -554,6 +1206,12 @@ function captureCrossReference(root, artifactDir) {
554
1206
  continue;
555
1207
  }
556
1208
  const trusted = resolveTrustedCommand(root, artifactDir, check, acceptance);
1209
+ if (trusted && trusted.malformed) {
1210
+ // WS8 (AC10b): the matching acceptance criterion named a kind:"command" evidence ref
1211
+ // whose text is prose, not a runnable command. Do NOT execute it; classify it.
1212
+ warnings.push(`${base} evidence check ${id}: malformed-evidence — acceptance criterion names a kind:"command" evidence ref whose text is not a runnable shell command ("${safeOneLine(trusted.malformed, 120)}"); it was NOT executed. Use a literal runnable command, or record the check as not_verified/accepted_gap (see context/contracts/planning-contract.md).`);
1213
+ continue;
1214
+ }
557
1215
  if (!trusted) {
558
1216
  warnings.push(`${base} evidence check ${id}: claimed pass but NOT_VERIFIED — command "${safeOneLine(cmd, 120)}" was never captured and no trusted command (acceptance criterion / declared manifest target) resolves to re-run it. Set FLOW_AGENTS_GOAL_FIT_RECHECK=true to opt into re-running the model's free-form command.`);
559
1217
  continue;
@@ -574,6 +1232,153 @@ function captureCrossReference(root, artifactDir) {
574
1232
  return warnings;
575
1233
  }
576
1234
 
1235
+ /**
1236
+ * Namespace-agnostic captured-FAIL reconciliation (AC1 — closes the allowlist bypass).
1237
+ *
1238
+ * The existing captureCrossReference only checks claims that pass the namespace
1239
+ * allowlist (workflow.* prefix or declared gateExpects[]). A kit-typed claim
1240
+ * (e.g. builder.verify.tests) whose command-log entry says FAIL can slip through
1241
+ * when no active FlowDefinition declares that claimType.
1242
+ *
1243
+ * This function is namespace-agnostic: it builds the LATEST-capture-per-command map
1244
+ * and for each command whose last capture is FAIL it checks:
1245
+ * (A) Any claim (ANY namespace) asserting pass for that command → false-completion HARD_BLOCK
1246
+ * Fix A: runs on EVERY stop (status-independent). A claim contradicting the capture is
1247
+ * a false-completion regardless of whether state.json shows the task as 'done'.
1248
+ * Fix D: also checks commands with laundering operators whose latest capture is PASS (exit 0);
1249
+ * a claimed-pass for a laundered command is NOT a trustworthy signal.
1250
+ * Fix B: Case B (unaccounted at completion — no-claim-at-all branch) REMOVED.
1251
+ * It over-blocked incidental failures (grep no-match, git diff --exit-code, etc.).
1252
+ * Case A covers the real threat (claimed pass contradicts captured fail).
1253
+ * Fix E: verifyCommandLogChain called; on broken chain reconciliation is skipped (log
1254
+ * integrity is already signalled by captureCrossReference).
1255
+ *
1256
+ * No-over-block guarantees:
1257
+ * - Fail-then-re-run-to-pass: latest is PASS → not in latestFails → no warning.
1258
+ * - Acknowledged failure: claim has failing/disputed status → ackClaims → no warning.
1259
+ * - No-command session: no log → latestLog empty → no warning.
1260
+ * - Incidental fail (grep/diff/find) with no pass-claim → no warning (Case B removed).
1261
+ */
1262
+ function capturedFailReconciliation(root, artifactDir, taskStatus) {
1263
+ // Fix A: removed the `completing` guard. Run on EVERY stop — status-independent.
1264
+ // A claim contradicting the capture is a false-completion whether or not the agent
1265
+ // has set state.json.status to a terminal value. (taskStatus param kept for compat.)
1266
+
1267
+ const latestLog = readLatestCommandLog(artifactDir);
1268
+ if (latestLog.size === 0) return []; // No captures — nothing to reconcile
1269
+
1270
+ // Fix E: verify chain integrity; skip reconciliation when broken (log untrusted).
1271
+ // The main integrity warning is already emitted by captureCrossReference.
1272
+ const chainResult = verifyCommandLogChain(artifactDir);
1273
+ if (chainResult.status === 'broken') return []; // Can't trust pass/fail signals
1274
+
1275
+ // Collect commands whose LATEST capture is FAIL (Case A).
1276
+ const latestFails = new Map(); // cmd -> {failed:true, exitCode}
1277
+ for (const [cmd, info] of latestLog) {
1278
+ if (info.failed) latestFails.set(cmd, info);
1279
+ }
1280
+
1281
+ // Fix D: Collect commands whose latest capture is PASS (exit 0) but whose command
1282
+ // string contains an exit-code-neutralizing operator (laundering). The captured
1283
+ // exit-0 is not a trustworthy signal for these — real test failures are hidden.
1284
+ const launderedPass = new Map(); // cmd -> {failed:false, exitCode:0}
1285
+ for (const [cmd, info] of latestLog) {
1286
+ if (!info.failed && hasLaunderingOperator(cmd)) launderedPass.set(cmd, info);
1287
+ }
1288
+
1289
+ if (latestFails.size === 0 && launderedPass.size === 0) return []; // Nothing to flag
1290
+
1291
+ const base = relative(root, artifactDir);
1292
+ const warnings = [];
1293
+
1294
+ // Load the trust.bundle for claim/evidence analysis.
1295
+ const bundle = readJsonFile(path.join(artifactDir, 'trust.bundle'));
1296
+ const allClaims = bundle && Array.isArray(bundle.claims) ? bundle.claims : [];
1297
+ const allEvidence = bundle && Array.isArray(bundle.evidence) ? bundle.evidence : [];
1298
+
1299
+ // Build: claimId → claim (for fast evidence→claim lookup)
1300
+ const claimById = new Map();
1301
+ for (const c of allClaims) {
1302
+ if (c && c.id) claimById.set(c.id, c);
1303
+ }
1304
+
1305
+ // commandsToCheck: FAIL-latest commands + laundered-pass commands
1306
+ const commandsToCheck = new Set([...latestFails.keys(), ...launderedPass.keys()]);
1307
+
1308
+ // For each relevant command, track what claims say about it.
1309
+ // cmdAcc: cmd → {passClaims: [...], ackClaims: []}
1310
+ const cmdAcc = new Map();
1311
+ const initAcc = (cmd) => {
1312
+ if (!cmdAcc.has(cmd)) cmdAcc.set(cmd, { passClaims: [], ackClaims: [] });
1313
+ return cmdAcc.get(cmd);
1314
+ };
1315
+
1316
+ // Path A: evidence items with execution.label link a claim to a specific command.
1317
+ for (const ev of allEvidence) {
1318
+ if (!ev || !ev.execution || !ev.execution.label) continue;
1319
+ const cmd = normalizeCommand(ev.execution.label);
1320
+ if (!cmd || !commandsToCheck.has(cmd)) continue;
1321
+ const claim = claimById.get(ev.claimId);
1322
+ if (!claim) continue;
1323
+ const acc = initAcc(cmd);
1324
+ const s = String(claim.status || '').toLowerCase();
1325
+ const v = normalizedStatus(claim.value || '');
1326
+ if (claimAssertsPass(s, v)) acc.passClaims.push(claim);
1327
+ if (claimAcknowledgesFailure(s, v)) acc.ackClaims.push(claim);
1328
+ }
1329
+
1330
+ // Path B: claim.fieldOrBehavior resolves directly to the command (field-based resolution).
1331
+ for (const c of allClaims) {
1332
+ if (!c) continue;
1333
+ const cmd = normalizeCommand(c.fieldOrBehavior || '');
1334
+ if (!cmd || !commandsToCheck.has(cmd)) continue;
1335
+ const acc = initAcc(cmd);
1336
+ const s = String(c.status || '').toLowerCase();
1337
+ const v = normalizedStatus(c.value || '');
1338
+ if (claimAssertsPass(s, v)) acc.passClaims.push(c);
1339
+ if (claimAcknowledgesFailure(s, v)) acc.ackClaims.push(c);
1340
+ }
1341
+
1342
+ // Case A: Evaluate each FAIL-latest command for pass-claims (status-independent).
1343
+ for (const [cmd, failInfo] of latestFails) {
1344
+ const exit = Number.isInteger(failInfo.exitCode) ? failInfo.exitCode : null;
1345
+ const exitStr = exit !== null ? ` (exit ${exit})` : '';
1346
+ const acc = cmdAcc.get(cmd);
1347
+
1348
+ if (acc && acc.passClaims.length > 0) {
1349
+ // Any-namespace claim asserts pass for a command whose latest capture is FAIL.
1350
+ // This is the namespace-agnostic false-completion signal.
1351
+ const claim = acc.passClaims[0];
1352
+ warnings.push(
1353
+ `${base} captured command '${safeOneLine(cmd, 120)}' last ran FAIL${exitStr} ` +
1354
+ `but claim ${safeOneLine(claim.subjectId || claim.id, 80)} (${safeOneLine(claim.claimType, 48)}) ` +
1355
+ `asserts pass — namespace-agnostic caught false-completion.`
1356
+ );
1357
+ }
1358
+ // Fix B: Case B (unaccounted at completion — no-claim-at-all) REMOVED.
1359
+ // It over-blocked incidental failures with no pass-claim. Case A covers the real threat.
1360
+ // Acknowledged failure (ackClaims.length > 0) → OK, no warning.
1361
+ }
1362
+
1363
+ // Fix D: Evaluate laundered-pass commands for pass-claims.
1364
+ for (const [cmd] of launderedPass) {
1365
+ const acc = cmdAcc.get(cmd);
1366
+ if (acc && acc.passClaims.length > 0) {
1367
+ const claim = acc.passClaims[0];
1368
+ warnings.push(
1369
+ `${base} captured command '${safeOneLine(cmd, 120)}' claimed pass relies on an ` +
1370
+ `exit-code-laundered command — claim ${safeOneLine(claim.subjectId || claim.id, 80)} ` +
1371
+ `(${safeOneLine(claim.claimType, 48)}) asserts pass but the exit code is not a ` +
1372
+ `trustworthy signal (laundering operators mask the real exit code).`
1373
+ );
1374
+ }
1375
+ }
1376
+
1377
+ return warnings;
1378
+ }
1379
+
1380
+ // ─── ADR 0010 Phase 2: enforce on the canonical Hachure trust.bundle ──────────
1381
+
577
1382
  // ─── ADR 0010 Phase 2: enforce on the canonical Hachure trust.bundle ──────────
578
1383
  // The trust.bundle (emitted by workflow-sidecar via @kontourai/surface) carries
579
1384
  // each claim's Surface-derived status — including capture-authoritative results
@@ -589,7 +1394,13 @@ function captureCrossReference(root, artifactDir) {
589
1394
  // stored status (e.g. stored "verified" but evidence re-derives to "disputed"),
590
1395
  // that mismatch is a strong tamper signal — block with an explicit warning.
591
1396
  // Fail-open: if Surface is unavailable, fall back to the stored-status check.
592
- async function bundleEnforcement(artifactDir) {
1397
+ //
1398
+ // ADR 0016 P-c: when activeFlowStep is non-null, claim-selection uses the gate's
1399
+ // declared claimType set (gateExpects[].bundle_claim.claimType). When null, the
1400
+ // existing workflow.* prefix filter runs unchanged (fallback). The re-derivation
1401
+ // loop, tamper-detection, high/critical filter, and block/exit-2 logic are
1402
+ // STRUCTURALLY UNCHANGED — only WHICH claims are selected changes.
1403
+ async function bundleEnforcement(artifactDir, activeFlowStep) {
593
1404
  const bundle = readJsonFile(path.join(artifactDir, 'trust.bundle'));
594
1405
  if (!bundle || !Array.isArray(bundle.claims)) return [];
595
1406
 
@@ -600,11 +1411,72 @@ async function bundleEnforcement(artifactDir) {
600
1411
  const allEvents = Array.isArray(bundle.events) ? bundle.events : [];
601
1412
  const allPolicies = Array.isArray(bundle.policies) ? bundle.policies : [];
602
1413
 
1414
+ // P-c: claim-selection predicate.
1415
+ // When activeFlowStep is non-null, select claims whose claimType is in the
1416
+ // gate's declared set. When null, fall back to the existing workflow.* prefix
1417
+ // filter so no-FlowDefinition sessions are unaffected.
1418
+ const declaredClaimTypes = declaredClaimTypesFor(activeFlowStep);
1419
+
1420
+ // SECURITY (Layer 2 — gate-bypass-chain fix): use UNION form instead of if/else.
1421
+ // With the old if/else, an empty declaredClaimTypes (Set{}) from a fake flow with
1422
+ // expects:[] caused isSelectedClaim to return false for EVERY claim — all
1423
+ // bundleEnforcement checks were silently bypassed. The union form ensures workflow.*
1424
+ // claims are ALWAYS enforced regardless of whether a FlowDefinition is active or what
1425
+ // its expects[] contains. Declared claimTypes are added on top of the baseline.
1426
+ //
1427
+ // AC3 two-part dependency (regression guard — see test_captured_fail_reconciliation.sh):
1428
+ // Part 1 (this union form): ensures bundleEnforcement always enforces workflow.* claims
1429
+ // regardless of declaredClaimTypes being null or empty Set.
1430
+ // Part 2 (empty-expects guard below): emits gate-misconfiguration HARD_BLOCK when
1431
+ // declaredClaimTypes is a non-null empty Set (active flow with expects:[]).
1432
+ // Both parts are required: Part 1 alone lets the empty-expects bypass slip through
1433
+ // without a loud signal; Part 2 alone without the union would silently pass 0 claims.
1434
+ const isSelectedClaim = (claim) => {
1435
+ const ct = String(claim.claimType || '');
1436
+ // Union: workflow.* is always selected (baseline); declared types extend it.
1437
+ return ct.startsWith('workflow.') || (declaredClaimTypes != null && declaredClaimTypes.has(ct));
1438
+ };
1439
+
1440
+ // Misconfiguration guard: an active FlowDefinition with zero expects[] is suspicious.
1441
+ // Empty expects can indicate a tampered flow definition (the gate-bypass exploit).
1442
+ // Emit a hard warning so operators see it regardless of session phase.
1443
+ if (declaredClaimTypes !== null && declaredClaimTypes.size === 0) {
1444
+ warnings.push(`gate misconfiguration: active FlowDefinition has empty expects[] (possible tampered flow definition or zero-expects bypass attempt); verify the flow definition is authentic before delivery.`);
1445
+ }
1446
+
1447
+ // AC3 fail-closed (high-assurance path): when @kontourai/surface is unavailable, we
1448
+ // cannot re-derive claim status from evidence/events/policies. For high/critical-impact
1449
+ // selected claims this means we silently trust the stored status field — exactly the
1450
+ // vector re-derive-at-gate (ADR 0010 Phase 2b) was designed to close.
1451
+ // Fail-closed: emit a FULL_BLOCK warning so operators see that re-derivation is absent.
1452
+ // Low-impact-only bundles stay non-blocking (noise reduction for common dev sessions).
1453
+ //
1454
+ // BAR-RAISER, NOT AIRTIGHT: an agent that deletes/corrupts @kontourai/surface to force
1455
+ // surface=null would trigger this path deliberately. The real anchor is external:
1456
+ // signed checkpoints + human review. This raises the cost of the silent-trust vector.
1457
+ if (!surface) {
1458
+ const highImpactCount = bundle.claims.reduce((n, claim) => {
1459
+ if (!claim || typeof claim !== 'object') return n;
1460
+ const impact = String(claim.impactLevel || '').toLowerCase();
1461
+ if (impact !== 'high' && impact !== 'critical') return n;
1462
+ return isSelectedClaim(claim) ? n + 1 : n;
1463
+ }, 0);
1464
+ if (highImpactCount > 0) {
1465
+ warnings.push(
1466
+ `surface unavailable — ${highImpactCount} high/critical-impact claim(s) could not be re-derived at gate; ` +
1467
+ 'stored claim status is trusted without independent re-derivation (fail-closed: high-assurance path). ' +
1468
+ 'Ensure @kontourai/surface is installed and importable, or escalate for operator review.'
1469
+ );
1470
+ }
1471
+ }
1472
+
603
1473
  for (const claim of bundle.claims) {
604
1474
  if (!claim || typeof claim !== 'object') continue;
605
1475
  const impact = String(claim.impactLevel || '').toLowerCase();
606
1476
  const storedStatus = String(claim.status || '').toLowerCase();
607
1477
  if (impact !== 'high' && impact !== 'critical') continue;
1478
+ // P-c: claim-selection — only process claims matching the active predicate.
1479
+ if (!isSelectedClaim(claim)) continue;
608
1480
 
609
1481
  // Step 1: Re-derive status via Surface when available.
610
1482
  // This closes the gaming vector: editing the stored status field cannot bypass
@@ -645,18 +1517,18 @@ async function bundleEnforcement(artifactDir) {
645
1517
  && (recomputedStatus === 'disputed' || recomputedStatus === 'rejected');
646
1518
 
647
1519
  if (isTampered) {
648
- warnings.push(`trust.bundle claim tampered: ${safeOneLine(claim.subjectId || claim.id, 80)} (${safeOneLine(claim.claimType, 48)}) — stored status "${storedStatus}" does not match recompute "${recomputedStatus}" (possible tampered bundle); caught false-completion.`);
1520
+ warnings.push(`trust.bundle claim tampered: ${safeOneLine(claim.subjectId || claim.id, 80)} (${safeOneLine(claim.claimType, 48)}) — stored status "${storedStatus}" does not match recompute "${recomputedStatus}" (possible tampered bundle); caught false-completion. Run: npm run workflow:sidecar -- claim ${safeOneLine(claim.subjectId || claim.id, 80)} ${artifactDir}`);
649
1521
  } else {
650
- warnings.push(`trust.bundle claim disputed: ${safeOneLine(claim.subjectId || claim.id, 80)} (${safeOneLine(claim.claimType, 48)}) — Surface recompute shows not verified; caught false-completion.`);
1522
+ warnings.push(`trust.bundle claim disputed: ${safeOneLine(claim.subjectId || claim.id, 80)} (${safeOneLine(claim.claimType, 48)}) — Surface recompute shows not verified; caught false-completion. Run: npm run workflow:sidecar -- claim ${safeOneLine(claim.subjectId || claim.id, 80)} ${artifactDir}`);
651
1523
  }
652
1524
  }
653
1525
  return warnings;
654
1526
  }
655
1527
 
656
1528
  /**
657
- * Scope to the session's current task when .flow-agents/current.json points at
1529
+ * Scope to the session's current task when .kontourai/flow-agents/current.json points at
658
1530
  * one (mirroring evidence-capture.js). Returns the slug dir, or null to fall back
659
- * to scanning all of .flow-agents (newest-mtime).
1531
+ * to scanning all of .kontourai/flow-agents (newest-mtime).
660
1532
  */
661
1533
  function preferredArtifactDir(flowAgentsDir) {
662
1534
  const current = readJsonFile(path.join(flowAgentsDir, 'current.json'));
@@ -668,6 +1540,24 @@ function preferredArtifactDir(flowAgentsDir) {
668
1540
  return dir.startsWith(flowAgentsDir + path.sep) && fs.existsSync(dir) ? dir : null;
669
1541
  }
670
1542
 
1543
+ /** WS8 (AC10a): a session dir is eligible as "active" only with real sidecar presence. */
1544
+ function hasSidecarPresence(artifactDir) {
1545
+ return fs.existsSync(path.join(artifactDir, 'state.json')) || fs.existsSync(path.join(artifactDir, 'trust.bundle'));
1546
+ }
1547
+
1548
+ // WS8 (AC10a): when current.json names a slug whose session directory does NOT exist,
1549
+ // return that slug so analyze() can log the staleness rather than silently falling back to
1550
+ // a global mtime scan that could resurface an abandoned/never-real session as active.
1551
+ function staleCurrentSlug(flowAgentsDir) {
1552
+ const current = readJsonFile(path.join(flowAgentsDir, 'current.json'));
1553
+ if (!current) return null;
1554
+ const slug = current.artifact_dir || current.active_slug;
1555
+ if (typeof slug !== 'string' || !slug.trim()) return null;
1556
+ const safe = slug.replace(/\.\.+/g, '').replace(/^[/\\]+/, '');
1557
+ const dir = path.join(flowAgentsDir, safe);
1558
+ return (dir.startsWith(flowAgentsDir + path.sep) && !fs.existsSync(dir)) ? slug : null;
1559
+ }
1560
+
671
1561
  /**
672
1562
  * A task is pre-execution (work not yet started) when its state.json status/phase
673
1563
  * is still in the idea→planning band, or (no state.json) its markdown status is.
@@ -689,10 +1579,15 @@ function isPreExecution(artifactDir, markdownStatus) {
689
1579
  // If a trust.bundle exists, bundleEnforcement handles it. If state.json exists,
690
1580
  // sidecarGuidance handles it. The gap: a session with only a markdown artifact.
691
1581
  //
692
- // Adjustment A (sidecar-driven Final Acceptance): when acceptance.json has
693
- // pending criteria and the task state shows delivered, emit the Final Acceptance
694
- // hygiene warning from the sidecar rather than markdown template parsing.
695
- function missingBundleOrStateSignal(artifactDir) {
1582
+ // ADR 0010 Phase 4b: Adjustment A (Final Acceptance hygiene):
1583
+ // When the task is delivered but acceptance criteria are still pending, emit the
1584
+ // Final Acceptance reminder. Read from trust.bundle claims when present; fall back
1585
+ // to acceptance.json for bundle-less sessions.
1586
+ //
1587
+ // ADR 0016 P-c: pass activeFlowStep so bundlePendingCriteriaCount includes declared types.
1588
+ function missingBundleOrStateSignal(artifactDir, activeFlowStep) {
1589
+ // Build the declared claimType set from the FlowDefinition gate expects[] (P-c).
1590
+ const declaredClaimTypes = declaredClaimTypesFor(activeFlowStep);
696
1591
  const warnings = [];
697
1592
  const hasBundle = fs.existsSync(path.join(artifactDir, 'trust.bundle'));
698
1593
  const state = readJsonFile(path.join(artifactDir, 'state.json'));
@@ -705,39 +1600,79 @@ function missingBundleOrStateSignal(artifactDir) {
705
1600
  return warnings;
706
1601
  }
707
1602
 
708
- // Adjustment A: sidecar-driven Final Acceptance hygiene.
709
- // When the task is delivered but acceptance.json still has pending criteria,
710
- // emit the Final Acceptance reminder from the sidecar (not markdown parsing).
711
- const acceptance = readJsonFile(path.join(artifactDir, 'acceptance.json'));
712
- if (acceptance && Array.isArray(acceptance.criteria)) {
713
- const pendingCriteria = acceptance.criteria.filter(c => {
714
- const s = normalizedStatus(c && c.status);
715
- return s === 'pending' || s === 'not_started' || s === '' || s === 'unknown';
716
- });
717
- if (pendingCriteria.length > 0) {
1603
+ // Adjustment A: Final Acceptance hygiene.
1604
+ // When the task is delivered but acceptance criteria are still pending, emit the
1605
+ // Final Acceptance reminder. Bundle-first; fall back to acceptance.json.
1606
+ const bundle = readJsonFile(path.join(artifactDir, 'trust.bundle'));
1607
+ const bundleClaims = bundle && Array.isArray(bundle.claims) ? bundle.claims : null;
1608
+
1609
+ if (bundleClaims) {
1610
+ // Phase 4b: read pending criteria count from trust.bundle claims.
1611
+ // P-c: pass declaredClaimTypes so declared-type acceptance claims are included.
1612
+ const pendingCount = bundlePendingCriteriaCount(bundleClaims, declaredClaimTypes);
1613
+ if (pendingCount !== null && pendingCount > 0) {
718
1614
  const base = path.basename(artifactDir);
719
- warnings.push(`${base} Final Acceptance: ${pendingCriteria.length} acceptance criterion/criteria still pending; complete CI/merge/docs before final delivery.`);
1615
+ warnings.push(`${base} Final Acceptance: ${pendingCount} acceptance criterion/criteria still pending; complete CI/merge/docs before final delivery.`);
1616
+ }
1617
+ } else {
1618
+ // Fallback: no bundle — read from acceptance.json (existing behavior, no regression).
1619
+ const acceptance = readJsonFile(path.join(artifactDir, 'acceptance.json'));
1620
+ if (acceptance && Array.isArray(acceptance.criteria)) {
1621
+ const pendingCriteria = acceptance.criteria.filter(c => {
1622
+ const s = normalizedStatus(c && c.status);
1623
+ return s === 'pending' || s === 'not_started' || s === '' || s === 'unknown';
1624
+ });
1625
+ if (pendingCriteria.length > 0) {
1626
+ const base = path.basename(artifactDir);
1627
+ warnings.push(`${base} Final Acceptance: ${pendingCriteria.length} acceptance criterion/criteria still pending; complete CI/merge/docs before final delivery.`);
1628
+ }
720
1629
  }
721
1630
  }
722
1631
 
723
1632
  return warnings;
724
1633
  }
725
1634
 
1635
+ // ─── Gate severity classification regexes (module scope — used by analyze() and run()) ─
1636
+ //
1637
+ // HARD_BLOCK: always blocks, even for pre-execution and terminal tasks.
1638
+ // Fires on genuine false-completion signals (a claimed pass the capture log or
1639
+ // evidence.json contradicts), integrity failures, and gate misconfiguration.
1640
+ //
1641
+ // FULL_BLOCK: fires for execution-onward tasks (post-planning, non-terminal).
1642
+ // Includes all HARD_BLOCK patterns plus completeness/hygiene and not-done state.
1643
+ //
1644
+ // Both are used in analyze() for blocking decisions AND in run() for the AC2
1645
+ // MAX_BLOCKS hard-block guard (preventing auto-release of hard blocks).
1646
+ const HARD_BLOCK = /contradicts evidence\.json|caught false-completion|evidence verdict:|evidence check .+ status:|critique status|critique open|required sidecar is missing|command-log integrity check FAILED|gate misconfiguration:|exit-code-laundered/;
1647
+ // FULL_BLOCK adds: workflow-state hygiene, surface-unavailable fail-closed, missing log.
1648
+ const FULL_BLOCK = /status:|Definition Of Done|Goal Fit|sidecar validation:|contradicts evidence\.json|workflow state|evidence verdict|evidence check|NOT_VERIFIED gap|critique status|critique open|next action|caught false-completion|NOT_VERIFIED —|command-log integrity check FAILED|gate misconfiguration:|surface unavailable —|expected capture log is missing|exit-code-laundered|malformed-evidence/;
1649
+
726
1650
  async function analyze(root, now = Date.now()) {
727
- const flowAgentsDir = path.join(root, '.flow-agents');
1651
+ const flowAgentsDirs = flowAgentsArtifactRootsForRead(root);
728
1652
  // Scope to the session's current task when current.json names one, so an
729
1653
  // unrelated active workflow elsewhere in the repo does not gate this stop.
730
- const scoped = preferredArtifactDir(flowAgentsDir);
731
- const searchDirs = scoped ? [scoped] : [flowAgentsDir];
732
- const artifacts = searchDirs
1654
+ const scoped = flowAgentsDirs.map(preferredArtifactDir).find(Boolean);
1655
+ // WS8 (AC10a): if current.json points at a nonexistent slug, LOG the staleness and, in
1656
+ // the global fallback, require sidecar presence so a stale pointer cannot resurface an
1657
+ // abandoned/never-real markdown-only directory as "the active session".
1658
+ const staleSlug = scoped ? null : flowAgentsDirs.map(staleCurrentSlug).find(Boolean);
1659
+ if (staleSlug) {
1660
+ process.stderr.write(`[Hook] Goal Fit: current.json names slug "${safeOneLine(staleSlug, 80)}" but no such session directory exists — ignoring the stale pointer instead of resurfacing an abandoned session via a global mtime scan.\n`);
1661
+ }
1662
+ const searchDirs = scoped ? [scoped] : flowAgentsDirs;
1663
+ let artifacts = searchDirs
733
1664
  .flatMap(dir => walkMarkdown(dir))
734
1665
  .map(readArtifact)
735
1666
  .filter(isWorkflowArtifact)
736
1667
  .sort((a, b) => b.mtimeMs - a.mtimeMs);
1668
+ if (staleSlug) {
1669
+ artifacts = artifacts.filter(a => a && hasSidecarPresence(path.dirname(a.file)));
1670
+ }
737
1671
 
738
1672
  if (artifacts.length === 0) return { warnings: [], blocking: false };
739
1673
 
740
1674
  const latest = artifacts[0];
1675
+ const latestArtifactDir = path.dirname(latest.file);
741
1676
  const warnings = [];
742
1677
  const relPath = relative(root, latest.file);
743
1678
  const status = latest.status || 'unknown';
@@ -751,31 +1686,64 @@ async function analyze(root, now = Date.now()) {
751
1686
  // Verdict is now bundle-driven via bundleEnforcement + sidecarGuidance.
752
1687
  // Sessions with neither trust.bundle nor state.json are caught by missingBundleOrStateSignal.
753
1688
 
754
- warnings.push(...sidecarValidation(root, path.dirname(latest.file)));
755
- warnings.push(...sidecarGuidance(root, path.dirname(latest.file)));
756
- warnings.push(...captureCrossReference(root, path.dirname(latest.file)));
757
- warnings.push(...(await bundleEnforcement(path.dirname(latest.file))));
758
- warnings.push(...missingBundleOrStateSignal(path.dirname(latest.file)));
1689
+ // ADR 0016 P-c: load the active FlowDefinition gate (fail-open: null when absent).
1690
+ // Null → existing workflow.* fallback path unchanged. Non-null → expects[]-driven claim selection.
1691
+ const activeFlowStep = loadActiveFlowStep(path.dirname(latestArtifactDir));
1692
+
1693
+ warnings.push(...sidecarValidation(root, latestArtifactDir));
1694
+ warnings.push(...sidecarGuidance(root, latestArtifactDir, activeFlowStep));
1695
+ const captureWarnings = captureCrossReference(root, latestArtifactDir, activeFlowStep);
1696
+ warnings.push(...captureWarnings);
1697
+ // Dedup: bundleEnforcement and captureCrossReference can both fire "caught false-completion"
1698
+ // for the same disputed claim. Suppress the bundleEnforcement warning ONLY when
1699
+ // captureCrossReference already produced a hard-block warning ("caught false-completion")
1700
+ // for the same check. NOT_VERIFIED / backstop-skip capture warnings must NOT suppress
1701
+ // the bundle tamper/disputed signal — that mismatch is a re-derive block independent of
1702
+ // whether the command was ever captured (anti-gaming guarantee, ADR 0010 Phase 2b).
1703
+ const captureHardBlockIds = new Set();
1704
+ for (const w of captureWarnings) {
1705
+ if (!/caught false-completion/.test(w)) continue; // only hard blocks suppress bundle warning
1706
+ const m = /evidence check ([^\s:]+):/.exec(w);
1707
+ if (m) captureHardBlockIds.add(m[1]);
1708
+ }
1709
+ const bundleWarnings = (await bundleEnforcement(latestArtifactDir, activeFlowStep)).filter(w => {
1710
+ if (!captureHardBlockIds.size) return true;
1711
+ // bundleEnforcement warns: "trust.bundle claim disputed: <subjectId> ..."
1712
+ const m = /trust\.bundle claim (?:disputed|tampered): ([^\s(]+)/.exec(w);
1713
+ if (!m) return true;
1714
+ const subjectId = m[1];
1715
+ // subjectId = "slug/checkId" — extract the checkId (last segment)
1716
+ const checkId = subjectId.includes('/') ? subjectId.slice(subjectId.indexOf('/') + 1) : subjectId;
1717
+ // If captureCrossReference already hard-blocked this check, suppress the bundle warning.
1718
+ return !captureHardBlockIds.has(checkId);
1719
+ });
1720
+ warnings.push(...bundleWarnings);
1721
+ warnings.push(...missingBundleOrStateSignal(latestArtifactDir, activeFlowStep));
759
1722
 
760
1723
  // A pre-execution task (not started) OR a terminal task (which is itself a
761
1724
  // completion *claim*) must not block on mere incompleteness — but a FALSE claim
762
1725
  // (capture/evidence contradiction) still blocks at any phase. This is the whole
763
1726
  // point of the capture cross-reference: catch a task that falsely claims done.
764
- const gateState = readJsonFile(path.join(path.dirname(latest.file), 'state.json'));
1727
+ const gateState = readJsonFile(path.join(latestArtifactDir, 'state.json'));
765
1728
  const taskStatus = gateState ? normalizedStatus(gateState.status) : normalizedStatus(status);
766
- const preExecution = isPreExecution(path.dirname(latest.file), status);
1729
+ const preExecution = isPreExecution(latestArtifactDir, status);
767
1730
  const terminal = TERMINAL_STATUSES.has(taskStatus);
768
- // Always-block: a claimed pass the capture log or evidence.json contradicts.
769
- const HARD_BLOCK = /contradicts evidence\.json|caught false-completion|evidence verdict:|evidence check .+ status:|critique status|critique open|required sidecar is missing/;
770
- // Full gate (execution onward): also completeness/hygiene and not-done state.
771
- const FULL_BLOCK = /status:|Definition Of Done|Goal Fit|sidecar validation:|contradicts evidence\.json|workflow state|evidence verdict|evidence check|NOT_VERIFIED gap|critique status|critique open|next action|caught false-completion|NOT_VERIFIED —/;
1731
+
1732
+ // Namespace-agnostic captured-FAIL reconciliation (AC1 closes the allowlist bypass).
1733
+ // Fix A: status-independent runs on EVERY stop. A claim contradicting the capture
1734
+ // is a false-completion whether or not the agent says the task is 'done'.
1735
+ warnings.push(...capturedFailReconciliation(root, latestArtifactDir, taskStatus));
1736
+
1737
+ // Use module-scope HARD_BLOCK / FULL_BLOCK (defined above analyze()).
1738
+ // pre-execution/terminal tasks: only HARD_BLOCK signals cause a block.
1739
+ // execution-onward tasks: FULL_BLOCK signals cause a block.
772
1740
  const blockRe = (preExecution || terminal) ? HARD_BLOCK : FULL_BLOCK;
773
1741
  const blocking = warnings.some(w => {
774
1742
  // Capture cross-reference warn-mode notes never block (operator opted out).
775
1743
  if (/\[backstop in warn mode — not blocking\]/.test(w)) return false;
776
1744
  return blockRe.test(w);
777
1745
  });
778
- return { warnings, blocking, preExecution };
1746
+ return { warnings, blocking, preExecution, gatePrefix: gateLabel(activeFlowStep) };
779
1747
  }
780
1748
 
781
1749
  /**
@@ -802,7 +1770,8 @@ function resolveMaxBlocks() {
802
1770
  }
803
1771
 
804
1772
  function blockStreakFile(root) {
805
- return path.join(root, '.flow-agents', '.goal-fit-block-streak.json');
1773
+ const artifactRoot = flowAgentsArtifactRoot(root);
1774
+ return path.join(artifactRoot, '.goal-fit-block-streak.json');
806
1775
  }
807
1776
 
808
1777
  function reasonsHash(warnings) {
@@ -827,6 +1796,52 @@ function bumpBlockStreak(root, hash) {
827
1796
  return count;
828
1797
  }
829
1798
 
1799
+ /**
1800
+ * Message-assembly only: per-gap remediation guidance (linter-style " → " line)
1801
+ * appended under a matching warning. Fix-first / accept-second ordering per
1802
+ * class; the "accept" path always names a reason requirement and is never the
1803
+ * only or primary option. Does not affect which warnings are emitted, HARD_BLOCK/
1804
+ * FULL_BLOCK classification, block counting, or exit codes — text only.
1805
+ * Returns null when a warning does not match one of the four known classes.
1806
+ */
1807
+ function remediationFor(warning) {
1808
+ const w = String(warning || '');
1809
+
1810
+ // (1) claimed pass but the trusted re-run failed — reuse the exact command
1811
+ // already named in the warning text; never invent a different one.
1812
+ // NOTE: anchor on the quoted command substring + fixed tail, NOT on
1813
+ // `trusted backstop (<source>)` paren-matching. trusted.source can itself
1814
+ // contain parens (e.g. "model-command (FLOW_AGENTS_GOAL_FIT_RECHECK)"), and a
1815
+ // non-nesting `[^)]*` silently fails to match that nested-paren source,
1816
+ // dropping the guidance line for the RECHECK backstop path.
1817
+ const rerun = /re-run of "([\s\S]+?)" FAILED with exit \d+, contradicting the claimed pass/.exec(w);
1818
+ if (rerun) {
1819
+ return ` → run: ${rerun[1]} — fix the failures, then re-record the check`;
1820
+ }
1821
+
1822
+ // (2) claimed pass never captured (no trusted command resolved, backstop
1823
+ // disabled via FLOW_AGENTS_GOAL_FIT_BACKSTOP=skip, or the backstop could not launch).
1824
+ // Same nested-paren fix as (1): match on the invariant "could not run" tail
1825
+ // rather than paren-counting the `(<source>)` segment.
1826
+ if (/claimed pass but NOT_VERIFIED — command ".+?" was never captured/.test(w)
1827
+ || /claimed pass but NOT_VERIFIED — trusted backstop .*could not run/.test(w)) {
1828
+ return " → run the command in this session (evidence capture is active), then re-record the check with the exact command string via: npm run workflow:sidecar -- record-evidence <artifact-dir> --verdict <v> --check-json '...'";
1829
+ }
1830
+
1831
+ // (3) critique verdict fail / open findings.
1832
+ if (/critique status:/.test(w) || /critique open /.test(w)) {
1833
+ return ' → resolve the findings and record a passing critique (record-critique), or mark each finding accepted with a reason';
1834
+ }
1835
+
1836
+ // (4) evidence verdict fail / missing acceptance evidence.
1837
+ if (/evidence verdict:/.test(w) || /evidence check .+ status:/.test(w)
1838
+ || /evidence NOT_VERIFIED gap:/.test(w) || /Final Acceptance:/.test(w)) {
1839
+ return ' → fix the failing check, or record it as an accepted gap with justification in the session artifact';
1840
+ }
1841
+
1842
+ return null;
1843
+ }
1844
+
830
1845
  async function run(rawInput) {
831
1846
  const input = parseJson(rawInput);
832
1847
  const root = findRepoRoot(input.cwd || process.cwd());
@@ -838,9 +1853,15 @@ async function run(rawInput) {
838
1853
  return rawInput;
839
1854
  }
840
1855
 
1856
+ const gatePrefix = result.gatePrefix || '[stop-gate]';
841
1857
  const message = [
842
- '[Hook] Goal Fit warning:',
843
- ...result.warnings.map(w => ` - ${w}`),
1858
+ `${gatePrefix} Goal Fit warning:`,
1859
+ ...result.warnings.flatMap(w => {
1860
+ const lines = [` - ${w}`];
1861
+ const guidance = remediationFor(w);
1862
+ if (guidance) lines.push(guidance);
1863
+ return lines;
1864
+ }),
844
1865
  ].join('\n');
845
1866
 
846
1867
  if (mode !== 'block' || !result.blocking) {
@@ -851,16 +1872,36 @@ async function run(rawInput) {
851
1872
  const maxBlocks = resolveMaxBlocks();
852
1873
  const count = bumpBlockStreak(root, reasonsHash(result.warnings));
853
1874
  if (count >= maxBlocks) {
1875
+ // AC2: never auto-release a HARD block (caught false-completion, capture contradiction,
1876
+ // tamper signal, gate misconfiguration, integrity failure). An agent burning through
1877
+ // N identical hard blocks to escape via the streak counter must not work.
1878
+ //
1879
+ // BAR-RAISER, NOT AIRTIGHT: an agent with shell access can still reset the streak
1880
+ // with runtime-constructed paths or by modifying the warning
1881
+ // text so the hash changes. The real anchor is external (signed checkpoints + human
1882
+ // review). This raises the cost of the burn-through-the-counter escape vector.
1883
+ const isHardBlock = result.warnings.some(w => {
1884
+ if (/\[backstop in warn mode — not blocking\]/.test(w)) return false;
1885
+ return HARD_BLOCK.test(w);
1886
+ });
1887
+ if (isHardBlock) {
1888
+ // Do NOT clear the streak — keep accumulating so the same hard block stays visible.
1889
+ return {
1890
+ stdout: rawInput,
1891
+ stderr: `${message}\n${gatePrefix} max-blocks reached but the block is a caught false-completion / integrity failure — not auto-releasing; requires a real fix or operator override.`,
1892
+ exitCode: 2,
1893
+ };
1894
+ }
854
1895
  clearBlockStreak(root);
855
1896
  return {
856
1897
  stdout: rawInput,
857
- stderr: `${message}\n[Hook] Goal Fit block RELEASED after ${count} consecutive identical blocks (FLOW_AGENTS_GOAL_FIT_MAX_BLOCKS=${maxBlocks}): the same gap persists, surfacing to the human instead of looping.`,
1898
+ stderr: `${message}\n${gatePrefix} released the same gap(s) blocked ${count}x without progress; needs your decision (listed above).`,
858
1899
  exitCode: 0,
859
1900
  };
860
1901
  }
861
1902
  return {
862
1903
  stdout: rawInput,
863
- stderr: `${message}\n[Hook] Goal Fit BLOCK ${count}/${maxBlocks}.`,
1904
+ stderr: `${message}\n${gatePrefix} Stop blocked ${result.warnings.length} evidence gap(s) (block ${count}; after ${maxBlocks} identical blocks I stop blocking and hand this to you)`,
864
1905
  exitCode: 2,
865
1906
  };
866
1907
  }
@@ -896,4 +1937,4 @@ if (require.main === module) {
896
1937
  });
897
1938
  }
898
1939
 
899
- module.exports = { analyze, run, resolveGoalFitMode, uncheckedInSection, findRepoRoot, sidecarGuidance, safeOneLine, captureCrossReference, bundleEnforcement, readCommandLog, resolveTrustedCommand, declaredManifestTarget };
1940
+ module.exports = { analyze, run, resolveGoalFitMode, uncheckedInSection, findRepoRoot, sidecarGuidance, safeOneLine, captureCrossReference, bundleEnforcement, loadActiveFlowStep, readCommandLog, resolveTrustedCommand, declaredManifestTarget, verifyCommandLogChain, CHAIN_GENESIS_VERIFY, hasLaunderingOperator };