@kontourai/flow-agents 3.3.0 → 3.4.1

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 (256) hide show
  1. package/.github/workflows/add-to-project.yml +15 -0
  2. package/.github/workflows/ci.yml +161 -0
  3. package/CHANGELOG.md +48 -0
  4. package/CONTEXT.md +5 -1
  5. package/README.md +19 -8
  6. package/build/src/builder-flow-run-adapter.d.ts +80 -0
  7. package/build/src/builder-flow-run-adapter.js +241 -0
  8. package/build/src/builder-flow-runtime.d.ts +16 -0
  9. package/build/src/builder-flow-runtime.js +290 -0
  10. package/build/src/cli/builder-run.d.ts +1 -0
  11. package/build/src/cli/builder-run.js +27 -0
  12. package/build/src/cli/effective-backlog-settings.js +70 -2
  13. package/build/src/cli/init.d.ts +34 -0
  14. package/build/src/cli/init.js +341 -61
  15. package/build/src/cli/kit.js +55 -12
  16. package/build/src/cli/pull-work-provider.js +346 -5
  17. package/build/src/cli/skill-drift-check.d.ts +1 -0
  18. package/build/src/cli/skill-drift-check.js +165 -0
  19. package/build/src/cli/telemetry-doctor.d.ts +37 -0
  20. package/build/src/cli/telemetry-doctor.js +53 -6
  21. package/build/src/cli/validate-hook-influence.js +37 -7
  22. package/build/src/cli/workflow-sidecar.d.ts +93 -8
  23. package/build/src/cli/workflow-sidecar.js +1175 -158
  24. package/build/src/cli.js +5 -0
  25. package/build/src/flow-kit/validate.d.ts +54 -34
  26. package/build/src/flow-kit/validate.js +237 -26
  27. package/build/src/index.d.ts +2 -0
  28. package/build/src/index.js +1 -0
  29. package/build/src/lib/console-connect-options.d.ts +97 -0
  30. package/build/src/lib/console-connect-options.js +199 -0
  31. package/build/src/lib/console-telemetry-validate.d.ts +49 -0
  32. package/build/src/lib/console-telemetry-validate.js +91 -0
  33. package/build/src/lib/flow-resolver.d.ts +56 -3
  34. package/build/src/lib/flow-resolver.js +151 -11
  35. package/build/src/lib/fs.d.ts +17 -0
  36. package/build/src/lib/fs.js +172 -0
  37. package/build/src/lib/local-artifact-root.d.ts +44 -1
  38. package/build/src/lib/local-artifact-root.js +131 -3
  39. package/build/src/runtime-adapters.d.ts +39 -3
  40. package/build/src/runtime-adapters.js +77 -31
  41. package/build/src/tools/build-universal-bundles.js +40 -2
  42. package/build/src/tools/codex-agent-routing.d.ts +2 -0
  43. package/build/src/tools/codex-agent-routing.js +49 -0
  44. package/build/src/tools/generate-context-map.js +1 -0
  45. package/build/src/tools/validate-source-tree.js +27 -1
  46. package/context/scripts/hooks/lib/kit-catalog.js +235 -0
  47. package/context/scripts/hooks/lib/runnable-command.js +177 -0
  48. package/context/scripts/hooks/stop-goal-fit.js +278 -48
  49. package/context/scripts/hooks/workflow-steering.js +121 -21
  50. package/context/scripts/package.json +3 -0
  51. package/context/scripts/telemetry/install-console-config.sh +25 -4
  52. package/context/scripts/telemetry/lib/config.sh +102 -12
  53. package/context/scripts/telemetry/lib/pricing.sh +50 -0
  54. package/context/scripts/telemetry/lib/session.sh +3 -0
  55. package/context/scripts/telemetry/lib/transport.sh +87 -0
  56. package/context/scripts/telemetry/lib/usage.sh +205 -4
  57. package/context/scripts/telemetry/telemetry.conf +6 -0
  58. package/context/scripts/telemetry/telemetry.sh +48 -0
  59. package/context/settings/workspace-backlog-provider-settings.example.json +48 -0
  60. package/docs/agent-usage-feedback-loop.md +35 -0
  61. package/docs/architecture-engine-and-kits.md +110 -0
  62. package/docs/context-map.md +2 -0
  63. package/docs/decisions/embeddable-engine.md +152 -0
  64. package/docs/decisions/index.md +3 -1
  65. package/docs/decisions/trust-ledger-retention.md +88 -0
  66. package/docs/decisions/workflow-enforcement.md +31 -9
  67. package/docs/fixture-ownership.md +3 -0
  68. package/docs/implementing-trust-reconciliation.md +129 -0
  69. package/docs/index.md +19 -9
  70. package/docs/integrations/flow-agents-console.md +167 -0
  71. package/docs/kit-authoring-guide.md +52 -21
  72. package/docs/spec/builder-flow-runtime.md +80 -0
  73. package/docs/spec/runtime-hook-surface.md +45 -1
  74. package/docs/specs/economics-record-contract.md +270 -0
  75. package/docs/specs/harness-capability-matrix.md +74 -0
  76. package/docs/specs/learning-review-proposals-contract.md +340 -0
  77. package/docs/specs/routing-efficiency-review.md +59 -0
  78. package/docs/verifiable-trust.md +74 -25
  79. package/docs/workflow-usage-guide.md +10 -0
  80. package/evals/acceptance/prove-capture-teeth.sh +132 -0
  81. package/evals/ci/antigaming-suite.sh +1 -0
  82. package/evals/ci/run-baseline.sh +72 -4
  83. package/evals/fixtures/economics/acceptance.json +12 -0
  84. package/evals/fixtures/economics/agents/tool-worker-1/events.jsonl +2 -0
  85. package/evals/fixtures/economics/agents/tool-worker-2/events.jsonl +2 -0
  86. package/evals/fixtures/economics/agents/tool-worker-3/events.jsonl +2 -0
  87. package/evals/fixtures/economics/agents/tool-worker-4/events.jsonl +1 -0
  88. package/evals/fixtures/economics/agents/tool-worker-5/events.jsonl +2 -0
  89. package/evals/fixtures/economics/critique.json +22 -0
  90. package/evals/fixtures/economics/expected-record.json +71 -0
  91. package/evals/fixtures/economics/session-usage-event.json +1 -0
  92. package/evals/fixtures/economics/state.json +11 -0
  93. package/evals/fixtures/economics/transcript.jsonl +3 -0
  94. package/evals/fixtures/hook-influence/cases.json +7 -7
  95. package/evals/fixtures/learning-review-proposals/balanced/economics.jsonl +6 -0
  96. package/evals/fixtures/learning-review-proposals/effect-follow-up/economics.jsonl +5 -0
  97. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-1/trust.bundle +21 -0
  98. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-2/trust.bundle +21 -0
  99. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-3/trust.bundle +21 -0
  100. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-4/trust.bundle +21 -0
  101. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-5/trust.bundle +21 -0
  102. package/evals/fixtures/learning-review-proposals/pattern-present/economics.jsonl +6 -0
  103. package/evals/fixtures/learning-review-proposals/pattern-present/expected-aggregates.json +30 -0
  104. package/evals/fixtures/learning-review-proposals/pattern-present/expected-aggregates.md +66 -0
  105. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-1/gate-review.inquiries.json +26 -0
  106. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-1/trust.bundle +21 -0
  107. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-2/gate-review.inquiries.json +26 -0
  108. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-2/trust.bundle +21 -0
  109. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-3/gate-review.inquiries.json +26 -0
  110. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-3/trust.bundle +21 -0
  111. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-4/gate-review.inquiries.json +26 -0
  112. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-4/trust.bundle +21 -0
  113. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-5/trust.bundle +21 -0
  114. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-6/trust.bundle +21 -0
  115. package/evals/fixtures/learning-review-proposals/repeat-window/economics.jsonl +6 -0
  116. package/evals/fixtures/learning-review-proposals/under-threshold/economics.jsonl +3 -0
  117. package/evals/fixtures/telemetry/usage-transcript-sample.jsonl +4 -0
  118. package/evals/fixtures/trust-reconcile-exploits/mcp-degrade.json +42 -0
  119. package/evals/integration/test_builder_entry_enforcement.sh +241 -0
  120. package/evals/integration/test_builder_step_producers.sh +18 -10
  121. package/evals/integration/test_bundle_install.sh +172 -0
  122. package/evals/integration/test_console_tenant_isolation.sh +167 -0
  123. package/evals/integration/test_critique_supersession_roundtrip.sh +4 -1
  124. package/evals/integration/test_dual_emit_flow_step.sh +10 -4
  125. package/evals/integration/test_economics_record.sh +674 -0
  126. package/evals/integration/test_effective_backlog_settings.sh +1 -1
  127. package/evals/integration/test_evidence_capture_hook.sh +17 -2
  128. package/evals/integration/test_exemption_usage_review.sh +198 -0
  129. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  130. package/evals/integration/test_flow_kit_install_git.sh +83 -0
  131. package/evals/integration/test_flowdef_session_activation.sh +0 -1
  132. package/evals/integration/test_flowdef_session_history_preservation.sh +13 -3
  133. package/evals/integration/test_gate_lockdown.sh +7 -0
  134. package/evals/integration/test_gate_review_inquiry_records.sh +9 -1
  135. package/evals/integration/test_goal_fit_hook.sh +2031 -0
  136. package/evals/integration/test_hook_category_behaviors.sh +8 -1
  137. package/evals/integration/test_hook_influence_cases.sh +25 -1
  138. package/evals/integration/test_install_merge.sh +227 -2
  139. package/evals/integration/test_kit_conformance_levels.sh +6 -6
  140. package/evals/integration/test_learning_review_proposals.sh +329 -0
  141. package/evals/integration/test_liveness_conflict_injection.sh +26 -22
  142. package/evals/integration/test_liveness_console_relay.sh +166 -0
  143. package/evals/integration/test_liveness_heartbeat.sh +17 -17
  144. package/evals/integration/test_liveness_worktree_root.sh +575 -0
  145. package/evals/integration/test_phase_map_and_gate_claim.sh +6 -1
  146. package/evals/integration/test_publish_delivery.sh +331 -1
  147. package/evals/integration/test_pull_work_board.sh +200 -0
  148. package/evals/integration/test_pull_work_provider.sh +1 -1
  149. package/evals/integration/test_record_check.sh +378 -0
  150. package/evals/integration/test_routing_efficiency.sh +71 -0
  151. package/evals/integration/test_runtime_adapter_activation.sh +28 -0
  152. package/evals/integration/test_session_resume_roundtrip.sh +16 -19
  153. package/evals/integration/test_skill_drift_check.sh +870 -0
  154. package/evals/integration/test_telemetry.sh +445 -0
  155. package/evals/integration/test_telemetry_doctor.sh +66 -0
  156. package/evals/integration/test_telemetry_usage_pipeline.sh +228 -0
  157. package/evals/integration/test_trust_reconcile_negatives.sh +30 -13
  158. package/evals/integration/test_trust_reconcile_trailer_diagnostic.sh +247 -0
  159. package/evals/integration/test_usage_cost.sh +61 -0
  160. package/evals/integration/test_workflow_sidecar_writer.sh +1395 -0
  161. package/evals/integration/test_workflow_steering_hook.sh +157 -16
  162. package/evals/integration/test_workspace_settings.sh +176 -0
  163. package/evals/lib/env.sh +26 -0
  164. package/evals/lib/node.sh +8 -0
  165. package/evals/run.sh +29 -0
  166. package/evals/static/test_ci_integration_coverage.sh +115 -0
  167. package/evals/static/test_declared_scope_forms_documented.sh +114 -0
  168. package/evals/static/test_universal_bundles.sh +34 -0
  169. package/evals/static/test_validate_source_kit_asset_scope.sh +259 -0
  170. package/evals/static/test_workflow_skills.sh +1 -1
  171. package/kits/builder/flows/build.flow.json +9 -18
  172. package/kits/builder/flows/publish-learn.flow.json +5 -1
  173. package/kits/builder/kit.json +120 -0
  174. package/kits/builder/skills/deliver/SKILL.md +42 -0
  175. package/kits/builder/skills/evidence-gate/SKILL.md +12 -0
  176. package/kits/builder/skills/execute-plan/SKILL.md +9 -0
  177. package/kits/builder/skills/learning-review/SKILL.md +51 -0
  178. package/kits/builder/skills/plan-work/SKILL.md +17 -20
  179. package/kits/builder/skills/pull-work/SKILL.md +21 -0
  180. package/kits/builder/skills/release-readiness/SKILL.md +12 -0
  181. package/kits/knowledge/kit.json +9 -0
  182. package/kits/veritas-governance/docs/README.md +35 -7
  183. package/kits/veritas-governance/fixtures/exemption-review/mixed-fresh-stale.DECLARED.json +14 -0
  184. package/kits/veritas-governance/kit.json +14 -0
  185. package/kits/veritas-governance/skills/exemption-usage-review/SKILL.md +128 -0
  186. package/kits/veritas-governance/skills/exemption-usage-review/review-exemptions.mjs +231 -0
  187. package/package.json +2 -2
  188. package/packaging/manifest.json +29 -0
  189. package/schemas/backlog-provider-settings.schema.json +13 -0
  190. package/schemas/workflow-state.schema.json +44 -0
  191. package/scripts/README.md +4 -0
  192. package/scripts/check-content-boundary.cjs +8 -1
  193. package/scripts/ci/trust-reconcile.js +136 -0
  194. package/scripts/hooks/codex-hook-adapter.js +77 -2
  195. package/scripts/hooks/evidence-capture.js +38 -5
  196. package/scripts/hooks/lib/codex-exit-code.js +316 -0
  197. package/scripts/hooks/lib/kit-catalog.js +235 -0
  198. package/scripts/hooks/lib/liveness-write.js +28 -1
  199. package/scripts/hooks/lib/local-artifact-paths.js +97 -1
  200. package/scripts/hooks/lib/runnable-command.js +177 -0
  201. package/scripts/hooks/lib/skill-drift.js +350 -0
  202. package/scripts/hooks/stop-goal-fit.js +278 -48
  203. package/scripts/hooks/workflow-steering.js +121 -21
  204. package/scripts/install-codex-home.sh +97 -47
  205. package/scripts/install-merge.js +72 -14
  206. package/scripts/install-owned-files.js +178 -0
  207. package/scripts/liveness/relay.sh +84 -0
  208. package/scripts/telemetry/economics-record.schema.json +145 -0
  209. package/scripts/telemetry/economics-record.sh +331 -0
  210. package/scripts/telemetry/install-console-config.sh +25 -4
  211. package/scripts/telemetry/learning-review-decide.sh +124 -0
  212. package/scripts/telemetry/learning-review-proposals.schema.json +161 -0
  213. package/scripts/telemetry/learning-review-proposals.sh +484 -0
  214. package/scripts/telemetry/lib/config.sh +102 -12
  215. package/scripts/telemetry/lib/pricing.sh +14 -6
  216. package/scripts/telemetry/lib/session.sh +3 -0
  217. package/scripts/telemetry/lib/transport.sh +133 -15
  218. package/scripts/telemetry/lib/usage.sh +121 -28
  219. package/scripts/telemetry/routing-efficiency.sh +0 -0
  220. package/scripts/telemetry/telemetry.conf +6 -0
  221. package/scripts/telemetry/telemetry.sh +48 -0
  222. package/src/builder-flow-run-adapter.ts +357 -0
  223. package/src/builder-flow-runtime.ts +348 -0
  224. package/src/cli/builder-flow-run-adapter.test.mjs +495 -0
  225. package/src/cli/builder-flow-runtime.test.mjs +213 -0
  226. package/src/cli/builder-run.ts +28 -0
  227. package/src/cli/codex-agent-routing.test.mjs +44 -0
  228. package/src/cli/codex-exit-code.test.mjs +207 -0
  229. package/src/cli/console-connect-options.test.mjs +329 -0
  230. package/src/cli/console-telemetry-validate.test.mjs +157 -0
  231. package/src/cli/effective-backlog-settings.ts +68 -2
  232. package/src/cli/flow-resolver-composition.test.mjs +101 -0
  233. package/src/cli/init.test.mjs +161 -0
  234. package/src/cli/init.ts +407 -62
  235. package/src/cli/kit-metadata-security.test.mjs +443 -0
  236. package/src/cli/kit.ts +50 -12
  237. package/src/cli/pull-work-provider.ts +377 -3
  238. package/src/cli/sidecar-pure-helpers.test.mjs +64 -0
  239. package/src/cli/skill-drift-check.ts +196 -0
  240. package/src/cli/telemetry-doctor.test.mjs +53 -0
  241. package/src/cli/telemetry-doctor.ts +50 -7
  242. package/src/cli/validate-hook-influence.ts +37 -6
  243. package/src/cli/workflow-sidecar.ts +1150 -151
  244. package/src/cli.ts +5 -0
  245. package/src/flow-kit/validate.ts +277 -38
  246. package/src/index.ts +19 -0
  247. package/src/lib/console-connect-options.ts +261 -0
  248. package/src/lib/console-telemetry-validate.ts +88 -0
  249. package/src/lib/flow-resolver.ts +153 -10
  250. package/src/lib/fs.ts +160 -0
  251. package/src/lib/local-artifact-root.ts +129 -3
  252. package/src/runtime-adapters.ts +113 -33
  253. package/src/tools/build-universal-bundles.ts +36 -2
  254. package/src/tools/codex-agent-routing.ts +48 -0
  255. package/src/tools/generate-context-map.ts +1 -0
  256. package/src/tools/validate-source-tree.ts +26 -1
@@ -2,13 +2,14 @@
2
2
  import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
- import { execFileSync } from "node:child_process";
5
+ import { execFileSync, spawnSync } from "node:child_process";
6
6
  import { createHash } from "node:crypto";
7
7
  import { createRequire } from "node:module";
8
8
  import { fileURLToPath } from "node:url";
9
9
  // ADR 0016 Abstraction A: shared FlowDefinition resolver (P-a)
10
- import { resolveActiveFlowStep, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy } from "../lib/flow-resolver.js";
10
+ import { resolveActiveFlowStep, resolveAllFlowGateExpects, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy } from "../lib/flow-resolver.js";
11
11
  import { defaultArtifactRootForRead, flowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
12
+ import { syncBuilderFlowSessionIfPresent } from "../builder-flow-runtime.js";
12
13
  // #291 Wave 1 Task 1.1 exports: ensure-session's ownership guard reuses the EXACT same
13
14
  // assignment ⋈ liveness join / claim / supersede logic #290 already ships for the
14
15
  // `assignment-provider` CLI, rather than reimplementing a second, parallel join (static ESM
@@ -54,6 +55,32 @@ function workItemSlug(ref) {
54
55
  const [owner, repo] = parts;
55
56
  return slugify(`${owner}-${repo}-${id}`, "work-item");
56
57
  }
58
+ function sessionWorkItem(p, slug, dir) {
59
+ const providerRef = opt(p, "work-item");
60
+ if (providerRef) {
61
+ return { ref: providerRef };
62
+ }
63
+ const existingState = loadJson(path.join(dir, "state.json"));
64
+ if (Array.isArray(existingState.work_item_refs) && typeof existingState.work_item_refs[0] === "string") {
65
+ return { ref: existingState.work_item_refs[0] };
66
+ }
67
+ const title = opt(p, "title", slug).trim() || slug;
68
+ const body = opt(p, "summary").trim();
69
+ return {
70
+ ref: `local:${slug}`,
71
+ localRecord: {
72
+ id: slug,
73
+ title,
74
+ ...(body ? { body } : {}),
75
+ status: "ready",
76
+ source_provider: {
77
+ kind: "local",
78
+ path: "work-item.json",
79
+ },
80
+ artifact_refs: ["state.json", "handoff.json"],
81
+ },
82
+ };
83
+ }
57
84
  /** Pure, lock-free, side-effect-free CLI wrapper around workItemSlug() — the single source of
58
85
  * truth for the deterministic subjectId/session-directory-name slug. Named resolveSlugCmd (not
59
86
  * resolveSlug) to avoid colliding with any future export named resolveSlug. */
@@ -471,6 +498,68 @@ function critiqueToEventStatus(verdict, findings) {
471
498
  return "assumed";
472
499
  return null; // not_verified or unknown → no event → Surface returns "unknown"
473
500
  }
501
+ /**
502
+ * Fold a raw command-log (command-log.jsonl entries) into a per-command classification,
503
+ * keyed by normalized command text (whitespace-collapsed, trimmed). Pure and side-effect-free
504
+ * so it is directly unit-testable (#470 iteration 2, finding #2 — HIGH: the prior inline reducer
505
+ * collapsed every non-"fail" `observedResult` — including "ambiguous" — to "pass", feeding a
506
+ * verified event + `passing:true` and reintroducing the #470 false-pass through the trust-bundle
507
+ * reconciliation path).
508
+ *
509
+ * Three-way classification, keyed on `observedResult` (never re-derived from `exitCode` alone,
510
+ * which would miscoerce the #362 grep/diff absence carve-out `ambiguous,exitCode:1` entry to
511
+ * `fail`):
512
+ * - "fail" when `observedResult==="fail"`, or (legacy, no observedResult) a nonzero
513
+ * integer `exitCode`.
514
+ * - "ambiguous" when `observedResult==="ambiguous"`, or (legacy) `exitCode` is `null` with no
515
+ * fail signal.
516
+ * - "pass" when `observedResult==="pass"`, or (legacy) `exitCode===0`.
517
+ *
518
+ * Precedence across repeated entries for the same command: fail > pass > ambiguous. A genuine
519
+ * exit-0 pass is positive evidence and confirms; ambiguous holds only when there is neither a
520
+ * fail nor a positive pass, since a "pass" always requires positive evidence.
521
+ *
522
+ * The caller (buildTrustBundle) maps "ambiguous" onto the existing canonical non-confirming
523
+ * status `not_verified` — consume-never-fork, mirroring record-check's identical mapping — so
524
+ * `checkStatusToEventStatus("not_verified")` returns null (no verification event emitted) and the
525
+ * evidence item is stamped `passing:false`. See Decision/finding #2 in the iteration-2 plan.
526
+ */
527
+ export function reduceCaptureLogByCommand(commandLog) {
528
+ const captureByCommand = new Map();
529
+ for (const entry of Array.isArray(commandLog) ? commandLog : []) {
530
+ if (!entry || typeof entry.command !== "string")
531
+ continue;
532
+ const key = entry.command.replace(/\s+/g, " ").trim();
533
+ if (!key)
534
+ continue;
535
+ const exitCode = Number.isInteger(entry.exitCode) ? entry.exitCode : null;
536
+ let result;
537
+ if (entry.observedResult === "fail" || (entry.observedResult === undefined && exitCode !== null && exitCode !== 0)) {
538
+ result = "fail";
539
+ }
540
+ else if (entry.observedResult === "pass" || (entry.observedResult === undefined && exitCode === 0)) {
541
+ result = "pass";
542
+ }
543
+ else {
544
+ // Covers observedResult==="ambiguous" AND the legacy no-observedResult, exitCode:null case.
545
+ result = "ambiguous";
546
+ }
547
+ const prev = captureByCommand.get(key);
548
+ let merged = result;
549
+ if (prev) {
550
+ // fail > pass > ambiguous precedence.
551
+ if (prev.observedResult === "fail" || result === "fail")
552
+ merged = "fail";
553
+ else if (prev.observedResult === "pass" || result === "pass")
554
+ merged = "pass";
555
+ else
556
+ merged = "ambiguous";
557
+ }
558
+ const mergedExitCode = exitCode !== null ? exitCode : (prev ? prev.exitCode : null);
559
+ captureByCommand.set(key, { observedResult: merged, exitCode: mergedExitCode });
560
+ }
561
+ return captureByCommand;
562
+ }
474
563
  /**
475
564
  * Build a Hachure trust.bundle from raw check/criterion/critique inputs.
476
565
  * trust.bundle is the PRIMARY artifact (ADR 0010 Phase 4a producer inversion).
@@ -498,6 +587,114 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
498
587
  // writeTrustBundle below) threads through to resolveActiveFlowStep's per-actor-first,
499
588
  // legacy-fallback current.json read; omitted, this is IDENTICAL to pre-#291 behavior.
500
589
  const activeStep = flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir, actorKey) : null;
590
+ // #270 CRITICAL/HIGH fix: resolve the session's active_flow_id independent of whether the
591
+ // CURRENTLY-active step resolves — a stamped gate claim names the STEP IT WAS ORIGINALLY
592
+ // RECORDED AT (frozen in metadata.gate_claim.step_id), which is routinely a DIFFERENT step than
593
+ // whatever is active now. Validating the stamp against only the currently-active step's
594
+ // expects[] would reject every honest round-trip the moment the session advances past the step
595
+ // the gate claim was recorded at — the validation must be against the FULL flow definition
596
+ // (every step's gate expects[], via resolveAllFlowGateExpects), not just the active one.
597
+ const sessionFlowId = (() => {
598
+ if (!flowAgentsDir)
599
+ return null;
600
+ try {
601
+ const pointer = loadCurrentPointerHelper().readCurrentPointer(flowAgentsDir, actorKey);
602
+ const payload = pointer.payload;
603
+ const fid = payload && typeof payload["active_flow_id"] === "string" ? payload["active_flow_id"] : null;
604
+ return fid && fid.length > 0 ? fid : null;
605
+ }
606
+ catch {
607
+ return null;
608
+ }
609
+ })();
610
+ const workflowSubjectRef = (() => {
611
+ if (!flowAgentsDir)
612
+ return null;
613
+ try {
614
+ const state = loadJson(path.join(flowAgentsDir, slug, "state.json"));
615
+ const refs = Array.isArray(state.work_item_refs) ? state.work_item_refs : [];
616
+ return refs.length === 1 && typeof refs[0] === "string" && refs[0].length > 0 ? refs[0] : null;
617
+ }
618
+ catch {
619
+ return null;
620
+ }
621
+ })();
622
+ // repoRoot resolution mirrors resolveActiveFlowStep's own internal findRepoRoot(path.dirname(
623
+ // flowAgentsDir)) call exactly (flow-resolver.ts) — findRepoRootFromDir is this file's
624
+ // fallback-to-cwd equivalent of that unexported helper. NOTE (#270 MEDIUM fix, iteration 3):
625
+ // findRepoRootFromDir ALWAYS returns a truthy string (it falls back to process.cwd() — see
626
+ // findRepoRootFromDir's own doc comment above) whenever flowAgentsDir is set, so `flowRepoRoot`
627
+ // is only ever null when `!flowAgentsDir`. It can therefore never by itself signal a FlowDefinition
628
+ // load failure; that signal now comes from resolveAllFlowGateExpects's own null return (below).
629
+ const flowRepoRoot = flowAgentsDir ? findRepoRootFromDir(path.dirname(flowAgentsDir)) : null;
630
+ // Built lazily (only when at least one check actually carries a metadata.gate_claim stamp or
631
+ // gate-claim shape to validate) and cached across every check in this call's loop — the
632
+ // FlowDefinition file is read once per buildTrustBundle call, not once per claim.
633
+ //
634
+ // #270 MEDIUM fix (iteration 3): tri-state cache — `undefined` (not yet built), `null` (the
635
+ // FlowDefinition could not be loaded/parsed — see resolveAllFlowGateExpects's doc comment),
636
+ // or an array (loaded; possibly genuinely empty). This lets assertStampedGateClaimValid tell
637
+ // "cannot load the flow definition" apart from "loaded fine, tuple just doesn't match" — the
638
+ // previous code collapsed both into `[]`, which always failed the `.some()` match below and
639
+ // was misreported as "forged or corrupt" even when the real cause was an unloadable
640
+ // FlowDefinition (e.g. a bogus/renamed active_flow_id, or a `kits/` path that no longer
641
+ // resolves) — a `!flowRepoRoot` check can never catch this because flowRepoRoot is always
642
+ // truthy here (see the note above), so that check was dead code against this failure mode.
643
+ let _allGateExpectsCache = undefined;
644
+ const allGateExpectsForSession = () => {
645
+ if (_allGateExpectsCache !== undefined)
646
+ return _allGateExpectsCache;
647
+ _allGateExpectsCache = (sessionFlowId && flowRepoRoot) ? resolveAllFlowGateExpects(sessionFlowId, flowRepoRoot) : null;
648
+ return _allGateExpectsCache;
649
+ };
650
+ /**
651
+ * Validate a RESTORED metadata.gate_claim stamp's (expectation_id, claim_type, subject_type,
652
+ * step_id) tuple against the session's FULL flow definition (every step's gate expects[], not
653
+ * just the currently-active step). This is the honest round-trip check: a claim recorded by
654
+ * record-gate-claim/buildTrustBundle can only ever carry a tuple that matches some real
655
+ * expects[] entry, because that is the only place the stamp is ever written (see the
656
+ * declaredMetadata.gate_claim assembly below). A tuple that does NOT match a real expectation is
657
+ * either FORGED (a hand-edited/attacker-supplied metadata.gate_claim) or CORRUPT — never a
658
+ * legitimate state this code can produce — so it dies loudly naming the claim id and the
659
+ * invalid stamp. It must NEVER fall through to matchExpectsEntry: that silent fall-through
660
+ * IS the re-typing bug this fix closes (a forged/corrupt stamp would otherwise get a fresh,
661
+ * heuristic-derived typing instead of being rejected).
662
+ *
663
+ * Accepted residual (#270, documented not fixed — iteration 3): this check validates
664
+ * STRUCTURAL conformance of the stamp's tuple against the flow definition's declared
665
+ * expects[] shape — it does not (and cannot, at this layer) verify WHO produced the stamp. A
666
+ * stamp that reuses a REAL (expectation_id, claim_type, subject_type, step_id) tuple copied
667
+ * from a legitimately-earned claim will PASS this validation, because it is, structurally,
668
+ * indistinguishable from an honest one. This is an accepted floor equal to the pre-existing
669
+ * trust boundary: any local process with filesystem access to this session already has an
670
+ * equivalent capability by invoking record-gate-claim directly. This validator closes the
671
+ * STRICTLY WORSE case — a forged tuple that does NOT correspond to any real expects[] entry,
672
+ * or a stamp silently re-typed via matchExpectsEntry's heuristic fallback — not general
673
+ * authorship/provenance binding. Provenance binding (e.g. cryptographically tying a stamp to
674
+ * the actor/process that recorded it) is future work; see issue #270's review thread.
675
+ *
676
+ * #270 MEDIUM fix (iteration 3): a DIFFERENT failure class — the FlowDefinition genuinely
677
+ * cannot be loaded/parsed (missing kits/ file, unreadable, invalid JSON, or a since-renamed/
678
+ * bogus active_flow_id) — must NEVER be reported as "forged or corrupt"; that message asserts
679
+ * the stamp itself is untrustworthy, which is not what happened here. This case dies with its
680
+ * own dedicated, distinctly-worded message instead.
681
+ */
682
+ function assertStampedGateClaimValid(claimId, stamp) {
683
+ if (!sessionFlowId) {
684
+ die(`buildTrustBundle: claim '${claimId}' carries a metadata.gate_claim stamp (expectation_id='${stamp.expectationId}') but no flow definition is resolvable for this session (no active_flow_id) — a stamped gate claim cannot be validated without the flow definition it was recorded against. This is either a legacy non-flow session or a missing --flow-id; re-record the gate claim inside a session with a resolvable --flow-id.`);
685
+ }
686
+ const allExpects = allGateExpectsForSession();
687
+ if (allExpects === null) {
688
+ die(`buildTrustBundle: claim '${claimId}' carries a metadata.gate_claim stamp (expectation_id='${stamp.expectationId}') naming flow '${sessionFlowId}', but FlowDefinition '${sessionFlowId}' cannot be loaded — cannot validate the gate-claim stamp. This is a load/parse failure (missing FlowDefinition file, unreadable, invalid JSON, or a renamed/bogus active_flow_id) — never treat this as a forged stamp. Restore or correct the '${sessionFlowId}' FlowDefinition (or the session's active_flow_id) before this claim can be validated.`);
689
+ }
690
+ const match = allExpects.some((entry) => entry.gateExpects.some((exp) => exp.id === stamp.expectationId
691
+ && exp.bundle_claim.claimType === stamp.claimType
692
+ && exp.bundle_claim.subjectType === stamp.subjectType
693
+ && (stamp.stepId === null || stamp.stepId === entry.stepId)));
694
+ if (!match) {
695
+ die(`buildTrustBundle: claim '${claimId}' carries a metadata.gate_claim stamp that does not match any expects[] entry declared in the '${sessionFlowId}' flow definition (expectation_id='${stamp.expectationId}', claim_type='${stamp.claimType}', subject_type='${stamp.subjectType}', step_id='${stamp.stepId ?? "<none>"}'). This stamp is forged or corrupt — it can never legitimately fail validation, since it is only ever written by this same code against a real expects[] entry. Refusing to trust it; never silently re-typing via matchExpectsEntry.`);
696
+ }
697
+ }
501
698
  const claims = [];
502
699
  const evidenceItems = [];
503
700
  const events = [];
@@ -528,19 +725,15 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
528
725
  }
529
726
  return p;
530
727
  };
531
- // Index the deterministic capture log by normalized command (a single FAIL wins),
532
- // so a claimed-pass check whose command actually FAILED becomes authoritative here.
533
- const captureByCommand = new Map();
534
- for (const entry of Array.isArray(commandLog) ? commandLog : []) {
535
- if (!entry || typeof entry.command !== "string")
536
- continue;
537
- const key = entry.command.replace(/\s+/g, " ").trim();
538
- if (!key)
539
- continue;
540
- const failed = entry.observedResult === "fail" || (Number.isInteger(entry.exitCode) && entry.exitCode !== 0);
541
- const prev = captureByCommand.get(key);
542
- captureByCommand.set(key, { observedResult: failed || (prev && prev.observedResult === "fail") ? "fail" : "pass", exitCode: Number.isInteger(entry.exitCode) ? entry.exitCode : (prev ? prev.exitCode : null) });
543
- }
728
+ // Index the deterministic capture log by normalized command (fail > pass > ambiguous
729
+ // precedence wins across repeated entries for the same command), so a claimed-pass check
730
+ // whose command actually FAILED (or never produced usable signal) becomes authoritative here.
731
+ // Extracted to reduceCaptureLogByCommand (below) for direct unit testing (#470 iteration 2,
732
+ // finding #2): the three-way classification is keyed on `observedResult`, NEVER re-derived
733
+ // from `exitCode` alone, so an `ambiguous,exitCode:1` entry (the #362 grep/diff absence
734
+ // carve-out) is never miscoerced to `fail` here, and a no-signal `ambiguous` (exitCode:null)
735
+ // is never coerced to `pass`.
736
+ const captureByCommand = reduceCaptureLogByCommand(commandLog);
544
737
  // ─── P-b dual-emit helper ──────────────────────────────────────────────────
545
738
  // Semantic matching table (ADR 0016 Abstraction A P-b):
546
739
  // check (non-policy kind) → expects[] entry where claimType does NOT contain
@@ -658,18 +851,75 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
658
851
  ? { evidenceType: "attestation", method: "attestation", reconcilable: false }
659
852
  : classifyEvidence(check.kind, cmd.length > 0);
660
853
  const policy = ensurePolicy(legacyClaimType, "high", [evClass.evidenceType]);
661
- const effectiveStatus = captured ? captured.observedResult : String(check.status ?? "");
854
+ // #470 iteration 2, finding #2: an "ambiguous" capture (no positive pass/fail signal —
855
+ // e.g. the codex no-signal default, or the #362 grep/diff absence carve-out) is
856
+ // non-confirming and must never be surfaced as a claim value of "pass". Map it onto the
857
+ // EXISTING canonical non-confirming status "not_verified" (consume-never-fork; the same
858
+ // mapping record-check already applies at its `ambiguous` branch above) so
859
+ // checkStatusToEventStatus("not_verified") returns null — no verification event, and the
860
+ // evidence item below is stamped passing:false. `isError` stays fail-only (ambiguous is
861
+ // not an error).
862
+ const effectiveStatus = captured ? (captured.observedResult === "ambiguous" ? "not_verified" : captured.observedResult) : String(check.status ?? "");
662
863
  const evStatus = waiver ? "assumed" : checkStatusToEventStatus(effectiveStatus);
663
864
  // Promotion claim marker (issue #312): a `promote` check carries a session-local
664
865
  // _promotion object that must survive onto claim.metadata.promotion so the archive gate
665
866
  // (workflow-artifact-cleanup-audit) and validators can detect the promotion claim without a
666
867
  // new manifest entry. It rides alongside any waiver in a single merged metadata object.
667
868
  const promotionMeta = (check._promotion && typeof check._promotion === "object") ? check._promotion : null;
869
+ // #298: artifact_refs/standard_refs are validated on input by normalizeCheck but were
870
+ // previously never persisted onto the claim — silently lost on the very first write, not
871
+ // just on round-trip. Stamp them onto claim.metadata (additive, mirrors waiver/promotion
872
+ // above) so checksFromBundle can restore them for every subsequent writer.
873
+ const artifactRefsMeta = Array.isArray(check.artifact_refs) && check.artifact_refs.length > 0 ? check.artifact_refs : null;
874
+ const standardRefsMeta = Array.isArray(check.standard_refs) && check.standard_refs.length > 0 ? check.standard_refs : null;
875
+ // #270/#380 MEDIUM fix: record-check's captured-output sha256 digest (see recordCheck's
876
+ // outputSha256 computation) — hash ONLY, never the raw output text, so this is secret-safe by
877
+ // construction even though trust.bundle is often committed/shared. Stamped onto
878
+ // claim.metadata (additive, mirrors artifact_refs/standard_refs above) so checksFromBundle can
879
+ // restore it for every subsequent writer, and so it survives any later rebuild.
880
+ // NOTE (#270 LOW fix, iteration 3): this digest is over the CLAMPED capture (recordCheck's
881
+ // clampOutput, first RECORD_CHECK_MAX_OUTPUT / 64 KiB of combined stdout+stderr), not the full
882
+ // raw command output — a command producing >64KiB of output has its digest computed only over
883
+ // the retained prefix, so two runs whose outputs diverge only past the 64KiB boundary hash
884
+ // identically.
885
+ const outputDigestMeta = typeof check._output_sha256 === "string" && check._output_sha256.length > 0
886
+ ? { algorithm: "sha256", hex: check._output_sha256 }
887
+ : null;
888
+ // #270(a)/(c): a gate claim's declared claimType/subjectType, once resolved (either freshly
889
+ // via matchExpectsEntry below, or restored from a prior write's metadata.gate_claim stamp by
890
+ // checksFromBundle), is stamped here so it is frozen at record time — a later bundle rebuild
891
+ // (record-evidence/record-critique/record-learning) must never re-derive it from whatever
892
+ // step happens to be active THEN. See the matchExpectsEntry call site immediately below.
893
+ const gateClaimExpectationId = typeof check._gate_claim_expectation_id === "string" ? check._gate_claim_expectation_id : null;
894
+ const gateClaimDeclaredType = typeof check._gate_claim_declared_type === "string" ? check._gate_claim_declared_type : null;
895
+ const gateClaimDeclaredSubject = typeof check._gate_claim_declared_subject === "string" ? check._gate_claim_declared_subject : null;
896
+ const gateClaimDeclaredStepId = typeof check._gate_claim_declared_step_id === "string" ? check._gate_claim_declared_step_id : null;
897
+ const gateClaimRouteReason = typeof check._gate_claim_route_reason === "string" ? check._gate_claim_route_reason : null;
898
+ // #270 CRITICAL/HIGH fix: checksFromBundle stamps this when it read a claim that is
899
+ // gate-claim-SHAPED (origin:"check", check_kind:"external", kit-typed claimType) but carries
900
+ // NO metadata.gate_claim stamp — a claim this code could not have produced without also
901
+ // writing the stamp, so it predates cluster #270/#344. Die loudly with the same remedy
902
+ // pattern requireStampedClaim already uses elsewhere in this file, instead of silently
903
+ // falling through to matchExpectsEntry (which would re-derive a FRESH, possibly-wrong typing
904
+ // for what is actually a previously-recorded, now-untraceable gate claim — the re-typing bug).
905
+ const gateClaimShapeUnstampedClaimId = typeof check._gate_claim_shape_unstamped_claim_id === "string" ? check._gate_claim_shape_unstamped_claim_id : null;
906
+ if (gateClaimShapeUnstampedClaimId) {
907
+ die(`pre-cluster-270 gate claim '${gateClaimShapeUnstampedClaimId}' has no metadata.gate_claim stamp and cannot be re-typed authoritatively — re-record it (record-gate-claim) to regenerate.`);
908
+ }
668
909
  // #268: stamp a stable origin discriminator so checksFromBundle / critiquesFromBundle can
669
910
  // distinguish check vs critique vs acceptance claims across round-trips even under --flow-id,
670
911
  // where all three collapse onto the same declared claimType (and a command-less critique claim
671
912
  // would otherwise be re-absorbed as a test_output check → permanent [not-run] divergence).
672
- const claimMetadata = { origin: "check", check_kind: String(check.kind ?? "external"), ...(waiver ? { waiver } : {}), ...(promotionMeta ? { promotion: promotionMeta } : {}) };
913
+ const claimMetadata = {
914
+ origin: "check",
915
+ check_kind: String(check.kind ?? "external"),
916
+ ...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}),
917
+ ...(waiver ? { waiver } : {}),
918
+ ...(promotionMeta ? { promotion: promotionMeta } : {}),
919
+ ...(artifactRefsMeta ? { artifact_refs: artifactRefsMeta } : {}),
920
+ ...(standardRefsMeta ? { standard_refs: standardRefsMeta } : {}),
921
+ ...(outputDigestMeta ? { output_digest: outputDigestMeta } : {}),
922
+ };
673
923
  const claimEvents = [];
674
924
  if (evStatus) {
675
925
  const evt = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [evId], createdAt: ts, verifiedAt: ts };
@@ -692,11 +942,47 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
692
942
  evidenceItems.push(evItem);
693
943
  // P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
694
944
  // When record-gate-claim sets _gate_claim_expectation_id, pass it for exact lookup (ADR 0016 P-d Increment 2).
695
- const declared = matchExpectsEntry("check", check.kind, typeof check._gate_claim_expectation_id === "string" ? check._gate_claim_expectation_id : undefined);
945
+ // #270(c): a REBUILD of a previously-recorded gate claim (both the expectation id AND its
946
+ // originally-declared claimType/subjectType round-tripped via checksFromBundle's
947
+ // metadata.gate_claim restoration) uses the STAMPED typing directly instead of re-resolving
948
+ // matchExpectsEntry against whatever step is active NOW — that re-resolution is the exact
949
+ // defect (a claim recorded at step N silently re-typed as step N+1's claim on rebuild).
950
+ // matchExpectsEntry is still the resolver for the FIRST write (no prior stamp to trust).
951
+ //
952
+ // #270 CRITICAL/HIGH fix: a RESTORED stamp (gateClaimDeclaredType/gateClaimDeclaredSubject
953
+ // both present — meaning this is a REBUILD reading back a prior write's metadata.gate_claim,
954
+ // not a fresh record-gate-claim call, which only ever sets gateClaimExpectationId) must be
955
+ // VALIDATED against the session's full flow definition before being trusted — see
956
+ // assertStampedGateClaimValid above. A stamp that fails validation dies loudly; it must NEVER
957
+ // silently fall through to matchExpectsEntry (that fall-through was the exact #270 defect:
958
+ // ANY invalid/forged/corrupt stamp got silently re-typed by the heuristic matcher instead of
959
+ // being rejected). A FRESH write (only gateClaimExpectationId set, no declared type/subject
960
+ // yet) has no stamp to validate — matchExpectsEntry is still the correct, and only, resolver
961
+ // for that case, exactly as before.
962
+ if (gateClaimExpectationId && gateClaimDeclaredType && gateClaimDeclaredSubject) {
963
+ assertStampedGateClaimValid(claimId, {
964
+ expectationId: gateClaimExpectationId,
965
+ claimType: gateClaimDeclaredType,
966
+ subjectType: gateClaimDeclaredSubject,
967
+ stepId: gateClaimDeclaredStepId,
968
+ });
969
+ }
970
+ const declared = (gateClaimExpectationId && gateClaimDeclaredType && gateClaimDeclaredSubject)
971
+ ? { claimType: gateClaimDeclaredType, subjectType: gateClaimDeclaredSubject }
972
+ : matchExpectsEntry("check", check.kind, gateClaimExpectationId ?? undefined);
696
973
  if (declared) {
697
974
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
698
975
  const declaredPolicy = ensurePolicy(declared.claimType, "high", [evClass.evidenceType]);
699
- const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, ...(claimMetadata ? { metadata: claimMetadata } : {}) };
976
+ // Freeze the resolved typing (fresh or restored) into metadata.gate_claim so it survives
977
+ // any subsequent rebuild regardless of the then-active step (#270a/c).
978
+ // step_id prefers the ORIGINALLY-recorded step (restored from a prior stamp) on a
979
+ // rebuild that has no active flow step of its own; only a genuinely first write (no
980
+ // restored stamp) takes the currently-active step's id.
981
+ const declaredStepId = gateClaimDeclaredStepId ?? (activeStep ? activeStep.stepId : null);
982
+ const declaredMetadata = gateClaimExpectationId
983
+ ? { ...claimMetadata, gate_claim: { expectation_id: gateClaimExpectationId, claim_type: declared.claimType, subject_type: declared.subjectType, step_id: declaredStepId, ...(gateClaimRouteReason ? { route_reason: gateClaimRouteReason } : {}) } }
984
+ : claimMetadata;
985
+ const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, ...(declaredMetadata ? { metadata: declaredMetadata } : {}) };
700
986
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [evItem], events: claimEvents, policies: [declaredPolicy] });
701
987
  claims.push({ ...declaredClaimObj, status: declaredStatus });
702
988
  }
@@ -728,7 +1014,7 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
728
1014
  if (declared) {
729
1015
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
730
1016
  const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
731
- const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, metadata: { origin: "acceptance" } };
1017
+ const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, metadata: { origin: "acceptance", ...(workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}) } };
732
1018
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [], events: claimEvents, policies: [declaredPolicy] });
733
1019
  claims.push({ ...declaredClaimObj, status: declaredStatus });
734
1020
  }
@@ -752,7 +1038,7 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
752
1038
  const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
753
1039
  const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
754
1040
  const critiqueReviewedAt = String(c.reviewed_at ?? ts);
755
- const critMeta = { origin: "critique", reviewer: critiqueReviewer, reviewed_at: critiqueReviewedAt, ...(supersededBy ? { superseded_by: supersededBy } : {}) };
1041
+ const critMeta = { origin: "critique", reviewer: critiqueReviewer, reviewed_at: critiqueReviewedAt, ...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}), ...(supersededBy ? { superseded_by: supersededBy } : {}) };
756
1042
  // A superseded historical write gets a distinct, stable claimId so it co-exists with the live
757
1043
  // claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
758
1044
  // across rebuilds because superseded_by + reviewed_at are preserved in metadata.
@@ -848,6 +1134,7 @@ export async function writeTrustBundle(dir, slug, timestamp, checks, criteria, c
848
1134
  return { written: false, errors: result.errors };
849
1135
  }
850
1136
  writeJson(path.join(dir, "trust.bundle"), bundle);
1137
+ await syncBuilderFlowSessionIfPresent(dir);
851
1138
  return { written: true, errors: [] };
852
1139
  }
853
1140
  catch (err) {
@@ -1087,8 +1374,23 @@ function parseCriterion(line, index) {
1087
1374
  if (m)
1088
1375
  text = text.slice(0, m.index).trim().replace(/\.$/, "");
1089
1376
  const item = { id: slugify(text, `criterion-${index + 1}`), description: text, status: "pending" };
1090
- if (evidence)
1091
- item.evidence_refs = [evidenceRef("command", { excerpt: evidence })];
1377
+ // #412 (AC8 interaction, planning-contract.md line ~46/84): the "- Evidence: <...>" Markdown
1378
+ // field is contractually a "test, command, screenshot, dashboard, doc, CI, or manual check" —
1379
+ // NOT always a runnable command. Unconditionally synthesizing kind:"command" with the raw text
1380
+ // in `excerpt` for EVERY Evidence line (the pre-existing behavior here) was already
1381
+ // contract-incorrect for prose evidence; it is now also a hard failure at record time
1382
+ // (validateAcceptanceEvidenceRefs's new runnability rejection targets kind:"command"'s
1383
+ // excerpt/url), so fixing the classification here is required, not optional polish. Use the
1384
+ // SAME isRunnableCommandText heuristic the record-time rejection itself uses (single-sourced):
1385
+ // literally-runnable text goes in `excerpt` (reconcilable, execution.label-eligible); anything
1386
+ // else (prose, or ensure-session's own "pending" scaffold placeholder) goes in `summary`
1387
+ // instead (never validated for runnability, never executed) — still kind:"command" (a
1388
+ // structured ref is still produced either way; only WHICH field carries the text changes),
1389
+ // per the contract's own "prose belongs in ref.summary" rule.
1390
+ if (evidence) {
1391
+ const { isRunnableCommandText } = loadRunnableCommandHelper();
1392
+ item.evidence_refs = [evidenceRef("command", isRunnableCommandText(evidence) ? { excerpt: evidence } : { summary: evidence })];
1393
+ }
1092
1394
  return item;
1093
1395
  }
1094
1396
  function artifactDirFrom(value) { return path.extname(value) ? path.dirname(value) : value; }
@@ -1213,6 +1515,45 @@ function loadCurrentPointerHelper() {
1213
1515
  const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/current-pointer.js");
1214
1516
  return _req(helperPath);
1215
1517
  }
1518
+ /**
1519
+ * #412: delegate to the shared pure-CJS runnable-command-text heuristic
1520
+ * (scripts/hooks/lib/runnable-command.js), mirroring the createRequire idiom used by
1521
+ * loadCurrentPointerHelper()/loadActorIdentityHelper() above. Single-sources the heuristic
1522
+ * between stop-goal-fit.js's Stop-time backstop and this file's record-time rejection
1523
+ * (validateAcceptanceEvidenceRefs, recordGateClaim --command, recordCheck --command) — see
1524
+ * AC9.
1525
+ *
1526
+ * #362 (Wave 3, AC6/AC7): extended (not a new sibling loader — same module, same
1527
+ * createRequire call, single require() of runnable-command.js) to also return
1528
+ * `isAmbiguousAbsenceCommand`, landed in that module by Wave 1's Task 1.2. This keeps
1529
+ * `recordCheck`'s record-time ambiguous-status stamp and `validateAcceptanceEvidenceRefs`'s
1530
+ * record-time advisory single-sourced against the SAME heuristic `runBackstop`/
1531
+ * `readCommandLog`/`evidence-capture.js`'s `observeResult` already consume (Waves 1-2) —
1532
+ * never a second, divergent implementation of "what counts as ambiguous."
1533
+ */
1534
+ function loadRunnableCommandHelper() {
1535
+ const _req = createRequire(import.meta.url);
1536
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/runnable-command.js");
1537
+ return _req(helperPath);
1538
+ }
1539
+ /**
1540
+ * #362 (iteration-2 fix item 4/LOW): single-sourced human-facing remediation fragment shared by
1541
+ * this file's two ambiguous-absence-command emission sites (`validateAcceptanceEvidenceRefs`'s
1542
+ * record-time advisory and `recordCheck`'s ambiguous-status stderr note) — mirrors how
1543
+ * `isAmbiguousAbsenceCommand` itself is single-sourced above. Cross-ref: scripts/hooks/
1544
+ * stop-goal-fit.js keeps its OWN copy of the equivalent shared string (`AMBIGUOUS_REMEDIATION`)
1545
+ * for its three Stop-hook emission sites — the two files do not share a module for this string,
1546
+ * so each file single-sources independently.
1547
+ */
1548
+ const AMBIGUOUS_REMEDIATION_ADVICE = "self-asserting command ('! grep ...' or 'grep -c ... | grep -qx 0')";
1549
+ function validateRunnableCheckCommand(check, context) {
1550
+ if (check.kind !== "command" || !hasNonEmptyString(check.command))
1551
+ return;
1552
+ const { isRunnableCommandText } = loadRunnableCommandHelper();
1553
+ if (!isRunnableCommandText(check.command)) {
1554
+ die(`${context}: kind:"command" check command is not a runnable shell command: "${check.command}" — remediate by either (1) moving the prose to summary and omitting command/execution.label, or (2) reclassifying this check as kind:"external" (session-local attestation) instead of kind:"command".`);
1555
+ }
1556
+ }
1216
1557
  /**
1217
1558
  * #291 Wave 2 Task 2.1 (§5): writes the UNCHANGED legacy global `<root>/current.json` (the
1218
1559
  * compat-shim's write-side half — every existing consumer without an actorKey keeps reading
@@ -1222,7 +1563,7 @@ function loadCurrentPointerHelper() {
1222
1563
  * byte-identical to this function's pre-#291 behavior — with a stderr note mirroring the existing
1223
1564
  * unresolved-actor branch-naming diagnostic.
1224
1565
  */
1225
- function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId, adHocReason, actorKey) {
1566
+ function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId, actorKey) {
1226
1567
  // #289: mirror the active session's already-recorded branch (state.json.branch) into
1227
1568
  // current.json so consumers of current.json (which has no schema of its own — not one of the
1228
1569
  // 9 schemas under schemas/) see the routing branch without re-reading state.json separately.
@@ -1241,11 +1582,6 @@ function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId, adHoc
1241
1582
  // FlowDefinition omit them and fall through to the workflow.* claim type path.
1242
1583
  ...(flowId ? { active_flow_id: flowId } : {}),
1243
1584
  ...(stepId ? { active_step_id: stepId } : {}),
1244
- // WS8 (AC12): sanctioned ad-hoc direct entry marker. Set when --step-id explicitly
1245
- // targets a step other than the flow's resolved first step, so stop-goal-fit /
1246
- // gate-review can distinguish an intentional direct entry (e.g. a planning-only
1247
- // session that skips pull-work) from a stale/mis-stamped active_step_id.
1248
- ...(adHocReason ? { ad_hoc_entry: true, ad_hoc_reason: adHocReason } : {}),
1249
1585
  };
1250
1586
  writeJson(path.join(root, "current.json"), payload);
1251
1587
  if (actorKey && !loadActorIdentityHelper().isUnresolvedActor(actorKey)) {
@@ -1324,7 +1660,7 @@ function updateCurrentAgent(root, dir, agentId, status, timestamp, actorKey) {
1324
1660
  }
1325
1661
  }
1326
1662
  }
1327
- function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp, markdown) {
1663
+ function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp, markdown, workItemRefs = [], initialLifecycle) {
1328
1664
  const criteria = markdown ? definitionAcceptanceLines(markdown).map(parseCriterion) : [];
1329
1665
  // #289/#309: `markdown` here is NOT always the session `<slug>--deliver.md` that
1330
1666
  // ensureSession seeds the `branch:` line into — initPlan is called against the tool-planner's
@@ -1355,9 +1691,11 @@ function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp,
1355
1691
  // current call's timestamp silently rewrites a session's original creation time. Preserve the
1356
1692
  // existing state.json's created_at when present; stamp only on true first-creation.
1357
1693
  // updated_at still reflects "now" on every call — that field is intentionally mutable.
1694
+ const retainedWorkItemRefs = workItemRefs.length > 0 ? workItemRefs : (Array.isArray(existingState.work_item_refs) ? existingState.work_item_refs : []);
1358
1695
  writeJson(path.join(dir, "state.json"), {
1359
- ...sidecarBase(slug), status: "planned", phase: "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
1696
+ ...sidecarBase(slug), status: initialLifecycle?.status ?? "planned", phase: initialLifecycle?.phase ?? "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
1360
1697
  ...(branch ? { branch } : {}),
1698
+ ...(retainedWorkItemRefs.length > 0 ? { work_item_refs: retainedWorkItemRefs } : {}),
1361
1699
  artifact_paths: relArtifacts(dir),
1362
1700
  next_action: { status: "continue", summary: nextAction || summary },
1363
1701
  });
@@ -1567,10 +1905,40 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
1567
1905
  * --claim-ttl-seconds <n> Overrides the liveness-policy TTL default for a new claim.
1568
1906
  * --reason <text> Audit-trail reason recorded on the claim/supersede record.
1569
1907
  */
1908
+ function resolveEnsureSessionEntry(p, dir) {
1909
+ const flowId = opt(p, "flow-id");
1910
+ const explicitStep = opt(p, "step-id");
1911
+ if (!flowId) {
1912
+ if (explicitStep)
1913
+ die("ensure-session --step-id requires --flow-id");
1914
+ if (opt(p, "ad-hoc-reason"))
1915
+ die("ensure-session --ad-hoc-reason is no longer supported");
1916
+ return { flowId: "", stepId: "", firstStep: "" };
1917
+ }
1918
+ const firstStep = resolveFirstStep(flowId, findRepoRootFromDir(dir));
1919
+ if (!firstStep)
1920
+ die(`ensure-session could not resolve the first step for Flow Definition ${JSON.stringify(flowId)}`);
1921
+ if (opt(p, "ad-hoc-reason")) {
1922
+ die("ensure-session --ad-hoc-reason cannot authorize workflow entry; start at the Flow Definition's first step or resume persisted run state");
1923
+ }
1924
+ if (explicitStep && explicitStep !== firstStep) {
1925
+ die(`ensure-session refused workflow entry at ${JSON.stringify(explicitStep)}: new runs must start at first step ${JSON.stringify(firstStep)}; resume an existing run without --step-id`);
1926
+ }
1927
+ return { flowId, stepId: explicitStep || firstStep, firstStep };
1928
+ }
1929
+ function preflightEnsureSession(p) {
1930
+ const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
1931
+ const slug = opt(p, "task-slug") || (opt(p, "work-item") ? workItemSlug(opt(p, "work-item")) : die("--task-slug is required (or pass --work-item to derive it)"));
1932
+ const dir = sessionDirFor(root, slug);
1933
+ resolveEnsureSessionEntry(p, dir);
1934
+ sessionWorkItem(p, slug, dir);
1935
+ }
1570
1936
  function ensureSession(p) {
1571
1937
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
1572
1938
  const slug = opt(p, "task-slug") || (opt(p, "work-item") ? workItemSlug(opt(p, "work-item")) : die("--task-slug is required (or pass --work-item to derive it)"));
1573
1939
  const dir = sessionDirFor(root, slug);
1940
+ const entry = resolveEnsureSessionEntry(p, dir);
1941
+ const workItem = sessionWorkItem(p, slug, dir);
1574
1942
  // #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
1575
1943
  // any directory/file is created — a refusal must never leave a stray empty session dir. Reused
1576
1944
  // below (writeCurrent's per-actor dual-write) so the branch-naming actor and the
@@ -1579,6 +1947,9 @@ function ensureSession(p) {
1579
1947
  enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution);
1580
1948
  fs.mkdirSync(dir, { recursive: true });
1581
1949
  const timestamp = opt(p, "timestamp", now());
1950
+ if (workItem.localRecord && !fs.existsSync(path.join(dir, "work-item.json"))) {
1951
+ writeJson(path.join(dir, "work-item.json"), workItem.localRecord);
1952
+ }
1582
1953
  let md = fs.existsSync(path.join(dir, `${slug}--deliver.md`)) ? read(path.join(dir, `${slug}--deliver.md`)) : "";
1583
1954
  if (!md) {
1584
1955
  // #289: derive the routing branch ONLY on fresh session creation (this `if (!md)` guard).
@@ -1586,11 +1957,19 @@ function ensureSession(p) {
1586
1957
  // of code is skipped on a resumed/taken-over session, which is what makes ADR 0021 §5
1587
1958
  // takeover continuity true by construction (see Design Decision 3 in the plan).
1588
1959
  const branch = resolveSessionBranch(p, slug);
1589
- md = `# ${opt(p, "title", slug)}\n\nbranch: ${branch}\nworktree: main\ncreated: ${timestamp}\nstatus: planning\ntype: deliver\niteration: 1\n\n## Plan\n\n${opt(p, "summary", "")}\n\n## Definition Of Done\n\n- **User outcome:** ${opt(p, "summary", "Workflow session is durable.")}\n- **Scope:** Workflow session artifacts and sidecars.\n- **Acceptance criteria:**\n${opts(p, "criterion").map((c) => ` - [ ] ${c} - Evidence: pending.`).join("\n")}\n- **Usefulness checks:**\n - [ ] User-facing workflow is documented or discoverable\n- **Stop-short risks:** Workflow artifacts could drift.\n- **Durable docs target:** not needed\n- **Sandbox mode:** local-edit\n\n## Execution Progress\n\n- [ ] Session initialized.\n\n## Verification Report\n\nBuild: [NOT_VERIFIED] Verification has not run yet.\n\n### Acceptance Criteria\n- [NOT_VERIFIED] Verification has not run yet - Evidence: pending workflow execution and checks.\n\n### Verdict: NOT_VERIFIED\n\n## Goal Fit Gate\n\n- [ ] Original user goal restated\n\n## Final Acceptance\n\n- [ ] CI/relevant checks passed or local equivalent recorded\n`;
1960
+ const initialMarkdownStatus = entry.flowId ? "new" : "planning";
1961
+ md = `# ${opt(p, "title", slug)}\n\nbranch: ${branch}\nworktree: main\ncreated: ${timestamp}\nstatus: ${initialMarkdownStatus}\ntype: deliver\niteration: 1\n\n## Plan\n\n${opt(p, "summary", "")}\n\n## Definition Of Done\n\n- **User outcome:** ${opt(p, "summary", "Workflow session is durable.")}\n- **Scope:** Workflow session artifacts and sidecars.\n- **Acceptance criteria:**\n${opts(p, "criterion").map((c) => ` - [ ] ${c} - Evidence: pending.`).join("\n")}\n- **Usefulness checks:**\n - [ ] User-facing workflow is documented or discoverable\n- **Stop-short risks:** Workflow artifacts could drift.\n- **Durable docs target:** not needed\n- **Sandbox mode:** local-edit\n\n## Execution Progress\n\n- [ ] Session initialized.\n\n## Verification Report\n\nBuild: [NOT_VERIFIED] Verification has not run yet.\n\n### Acceptance Criteria\n- [NOT_VERIFIED] Verification has not run yet - Evidence: pending workflow execution and checks.\n\n### Verdict: NOT_VERIFIED\n\n## Goal Fit Gate\n\n- [ ] Original user goal restated\n\n## Final Acceptance\n\n- [ ] CI/relevant checks passed or local equivalent recorded\n`;
1590
1962
  fs.writeFileSync(path.join(dir, `${slug}--deliver.md`), md);
1591
1963
  }
1592
1964
  if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
1593
- initSidecars(dir, slug, opt(p, "source-request"), opt(p, "summary"), opt(p, "next-action", "Continue."), timestamp, md);
1965
+ const phaseMap = entry.flowId ? resolvePhaseMap(entry.flowId, findRepoRootFromDir(dir)) : null;
1966
+ const initialPhase = Object.entries(phaseMap ?? {}).find(([, step]) => step === entry.firstStep)?.[0];
1967
+ const nextAction = entry.flowId
1968
+ ? entry.flowId === "builder.build"
1969
+ ? `Start the canonical Flow run with \`flow-agents builder-run start --session-dir .kontourai/flow-agents/${slug}\`; activate \`pull-work\` for work item ${JSON.stringify(workItem.ref)}, and satisfy the declared gate before advancing.`
1970
+ : `Continue at Flow step ${JSON.stringify(entry.stepId)} for work item ${JSON.stringify(workItem.ref)}; satisfy its declared gate before advancing.`
1971
+ : opt(p, "next-action", "Continue.");
1972
+ initSidecars(dir, slug, opt(p, "source-request"), opt(p, "summary"), nextAction, timestamp, md, [workItem.ref], initialPhase ? { status: "new", phase: initialPhase } : undefined);
1594
1973
  }
1595
1974
  // ADR 0016 Abstraction A (P-a): optional --flow-id / --step-id flags persist FlowDefinition
1596
1975
  // routing keys into current.json for the producer (P-b) and enforcer (P-c) to consume.
@@ -1599,29 +1978,14 @@ function ensureSession(p) {
1599
1978
  // active_step_id to the FIRST step in the FlowDefinition's steps[] list. This ensures
1600
1979
  // ensure-session --flow-id builder.build produces a FlowDefinition-driven session even
1601
1980
  // before the first advance-state call.
1602
- const flowId = opt(p, "flow-id");
1603
- const explicitStep = opt(p, "step-id");
1604
- let stepId = explicitStep;
1605
- let adHocReason;
1606
- if (flowId && !stepId) {
1607
- const repoRoot = findRepoRootFromDir(dir);
1608
- const firstStep = resolveFirstStep(flowId, repoRoot);
1609
- if (firstStep)
1610
- stepId = firstStep;
1611
- }
1612
- else if (flowId && explicitStep) {
1613
- // WS8 (AC12): --step-id is the sanctioned ad-hoc direct-entry mechanism. When it names
1614
- // a step other than the flow's resolved first step, record an explicit ad_hoc_entry
1615
- // marker (with a reason) instead of silently letting the mis-stamp look like the
1616
- // flow's normal first step. This is the root-cause fix for a planning-only session
1617
- // whose active_step_id would otherwise default to builder.build's first step.
1618
- const repoRoot = findRepoRootFromDir(dir);
1619
- const firstStep = resolveFirstStep(flowId, repoRoot);
1620
- if (firstStep && firstStep !== explicitStep) {
1621
- adHocReason = opt(p, "ad-hoc-reason") || `direct entry at step "${explicitStep}" via --step-id (flow first step is "${firstStep}")`;
1622
- }
1623
- }
1624
- writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined, adHocReason, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
1981
+ const persistedCurrent = loadCurrent(root);
1982
+ const resumedStep = !opt(p, "step-id")
1983
+ && persistedCurrent?.active_slug === slug
1984
+ && persistedCurrent?.active_flow_id === entry.flowId
1985
+ && typeof persistedCurrent?.active_step_id === "string"
1986
+ ? persistedCurrent.active_step_id
1987
+ : entry.stepId;
1988
+ writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
1625
1989
  console.log(dir);
1626
1990
  return 0;
1627
1991
  }
@@ -1750,14 +2114,62 @@ export function normalizeEvidenceRefs(raw, label) {
1750
2114
  return validateEvidenceRef({ ...ref }, label);
1751
2115
  });
1752
2116
  }
1753
- export function normalizeCheck(raw) {
2117
+ // #270 HIGH fix (iteration 3): the `gate-claim-` check-id prefix is RESERVED for
2118
+ // record-gate-claim's own internally-constructed ids (`id: \`gate-claim-${checkId}\`` — see
2119
+ // recordGateClaim below). Every OTHER writer of check ids (record-evidence --check-json,
2120
+ // record-check, dogfood-pass --check-json) flows through this same normalizeCheck, and until
2121
+ // this fix any of them could ALSO hand-supply an id starting with "gate-claim-". That collision
2122
+ // silently satisfies the id-shape half of gateClaimShapeUnstampedId's four-signal detector (see
2123
+ // its comment above) without ever having gone through record-gate-claim, so a later rebuild
2124
+ // misclassifies the caller's check as an unstamped pre-cluster-270 gate claim and dies, losing
2125
+ // the write. Rejecting the prefix HERE, at record time, for every caller-supplied id makes the
2126
+ // id-shape signal sound again: only record-gate-claim's own path can ever produce that shape.
2127
+ // record-gate-claim opts in via allowGateClaimPrefix=true since it is the sole legitimate
2128
+ // producer of that id shape; every other caller uses the default (reject).
2129
+ //
2130
+ // #270 follow-up fix (publish-preflight, iteration 5): the rejection above must apply ONLY to
2131
+ // NEW mints, not to a correction of an id that already exists as a check claim id in the
2132
+ // session's CURRENT trust.bundle. A mis-recorded claim that already carries a `gate-claim-`
2133
+ // prefixed id (e.g. one that slipped through before this guard shipped, or was recorded via a
2134
+ // binary predating it) can otherwise NEVER be corrected: every attempt to re-record that exact
2135
+ // id — the only way to supersede/fix it — is itself rejected by this same guard, permanently
2136
+ // wedging that id.
2137
+ //
2138
+ // #270 CRITICAL fix (iteration 6, narrowing the above): "the id's identity already exists in
2139
+ // the bundle" is NOT by itself a safe supersession signal — an id that already exists MIGHT be
2140
+ // a REAL, properly-stamped claim produced by record-gate-claim itself (its own generated
2141
+ // `gate-claim-${checkId}` ids always already "exist" once minted). Exempting every existing id
2142
+ // unconditionally let record-evidence/record-check/dogfood-pass silently overwrite a live,
2143
+ // correctly-stamped gate claim — destroying its metadata.gate_claim stamp — and the caller
2144
+ // fully controls check_kind, so the replacement claim can trivially be shaped to evade
2145
+ // gateClaimShapeUnstampedId's detector (that detector requires check_kind==="external", which
2146
+ // only fires the alarm when the caller happens to pick that kind). The narrowed rule: an
2147
+ // existing id is supersedable via this path ONLY when the EXISTING claim for that id carries NO
2148
+ // metadata.gate_claim stamp — i.e. only the mis-recorded/wedged shape (the legitimate
2149
+ // correction target this exemption exists for). An existing id whose claim IS stamped is a live
2150
+ // gate claim; only record-gate-claim (allowGateClaimPrefix=true, its own unconditional opt-in)
2151
+ // may ever supersede it. Callers therefore pass `existingCheckStampById` (a Map from the
2152
+ // bundle's CURRENT check ids to whether that check's claim already carries a metadata.gate_claim
2153
+ // stamp, read via readBundleState/checksFromBundle's `_gate_claim_expectation_id` restoration
2154
+ // BEFORE normalizeCheck runs — see applyGateClaimStamp). This does not weaken new-mint
2155
+ // enforcement: a NOVEL gate-claim-* id (not already present at all) is still rejected exactly as
2156
+ // before, and superseding a STAMPED existing id is now rejected too.
2157
+ export function normalizeCheck(raw, allowGateClaimPrefix = false, existingCheckStampById) {
1754
2158
  const check = { ...raw };
1755
2159
  if (!check.id || !check.kind || !check.status || !check.summary)
1756
2160
  die("check requires id, kind, status, and summary");
2161
+ if (!allowGateClaimPrefix && typeof check.id === "string" && check.id.startsWith("gate-claim-")) {
2162
+ const existingHasStamp = existingCheckStampById?.get(check.id);
2163
+ if (existingHasStamp === true)
2164
+ die(`check id "${check.id}" belongs to a live, properly-stamped gate claim — only record-gate-claim may supersede it. Superseding a stamped gate claim via record-evidence/record-check/dogfood-pass would silently destroy its metadata.gate_claim stamp. Re-record via record-gate-claim, or choose a different --id.`);
2165
+ if (existingHasStamp === undefined)
2166
+ die(`check id "${check.id}" starts with the reserved "gate-claim-" prefix — that namespace is reserved for record-gate-claim's own generated ids. Choose a different --id (or omit --id to let the check derive one from its command/summary). Supersession of an EXISTING, UNSTAMPED check claim with this same id (a correction) is permitted — this rejection applies to newly-minted ids not already present in the session's trust.bundle, and to ids that already belong to a stamped (live) gate claim.`);
2167
+ }
1757
2168
  if (!checkKinds.has(check.kind))
1758
2169
  die("kind must be one of: build, types, lint, test, command, security, diff, browser, runtime, policy, external");
1759
2170
  if (!checkStatuses.has(check.status))
1760
2171
  die("status must be one of: pass, fail, not_verified, skip");
2172
+ validateRunnableCheckCommand(check, `check ${String(check.id)}`);
1761
2173
  if (Array.isArray(check.standard_refs))
1762
2174
  for (const ref of check.standard_refs)
1763
2175
  if (!["junit", "sarif", "coverage", "veritas"].includes(ref.standard))
@@ -1889,16 +2301,59 @@ function surfaceCheckFromArtifact(file, index) {
1889
2301
  }
1890
2302
  return { id: `surface-trust-${index + 1}`, kind: "policy", status: ref.status, summary: ref.summary, surface_trust_refs: [ref] };
1891
2303
  }
1892
- function validateAcceptanceEvidenceRefs(dir) {
2304
+ /**
2305
+ * #270 MEDIUM security fix: a `kind:"command"` evidence_refs entry whose command source is
2306
+ * genuinely non-runnable-by-design (e.g. a screenshot/dashboard reference the record-time
2307
+ * heuristic misclassifies) previously had NO escape hatch — this validator's die() was an
2308
+ * unconditional lockout. Mirrors --skip-ownership-guard's existing pattern in this file exactly:
2309
+ * a named, logged (never silent) bypass flag, not a silent fallback. `--skip-evidence-ref-
2310
+ * runnability-guard` is intentionally verbose/unambiguous so it is never reached for
2311
+ * accidentally — the die() message itself names the concrete remediations FIRST (move the prose
2312
+ * to ref.summary, or reclassify as kind:"external"/"artifact"), so the bypass is a last resort,
2313
+ * not the first thing an agent reaches for.
2314
+ */
2315
+ function validateAcceptanceEvidenceRefs(dir, p) {
2316
+ if (p?.flags.has("skip-evidence-ref-runnability-guard")) {
2317
+ process.stderr.write("[record-evidence] evidence-ref runnability guard skipped via --skip-evidence-ref-runnability-guard\n");
2318
+ return;
2319
+ }
1893
2320
  const file = path.join(dir, "acceptance.json");
1894
2321
  if (!fs.existsSync(file))
1895
2322
  return;
1896
2323
  const data = loadJson(file);
1897
2324
  if (!Array.isArray(data.criteria))
1898
2325
  return;
2326
+ const { isRunnableCommandText, isAmbiguousAbsenceCommand } = loadRunnableCommandHelper();
1899
2327
  data.criteria.forEach((criterion, index) => {
1900
- if (criterion.evidence_refs !== undefined)
1901
- normalizeEvidenceRefs(criterion.evidence_refs, `acceptance.criteria[${index}].evidence_refs`);
2328
+ if (criterion.evidence_refs === undefined)
2329
+ return;
2330
+ const refs = normalizeEvidenceRefs(criterion.evidence_refs, `acceptance.criteria[${index}].evidence_refs`);
2331
+ // #412 (AC8): a kind:"command" ref's command source (excerpt, falling back to url when that
2332
+ // is where the command string lives) must be a literally runnable shell command, not prose
2333
+ // describing a manual verification step — reject at RECORD time instead of letting it reach
2334
+ // the Stop-hook backstop (stop-goal-fit.js's bundleClaimedPassCommandChecks section B), which
2335
+ // would otherwise spawn `bash -lc "<a sentence>"` and misreport the resulting shell error as
2336
+ // a caught false-completion. Prose belongs in `summary` (never executed).
2337
+ refs.forEach((ref, refIndex) => {
2338
+ if (ref.kind !== "command")
2339
+ return;
2340
+ const commandSource = hasNonEmptyString(ref.excerpt) ? ref.excerpt : (hasNonEmptyString(ref.url) ? ref.url : "");
2341
+ if (!commandSource)
2342
+ return; // command refs may carry only `summary` — nothing to validate
2343
+ if (!isRunnableCommandText(commandSource)) {
2344
+ die(`acceptance.criteria[${index}].evidence_refs[${refIndex}]: kind:"command" ref's command text is not a runnable shell command: "${commandSource}" — remediate by either (1) moving the prose to ref.summary (never executed) and dropping/emptying ref.excerpt/ref.url, or (2) reclassifying this ref as kind:"external" (session-local attestation) or kind:"artifact" (a file/screenshot/dashboard reference) instead of kind:"command". As a last resort, --skip-evidence-ref-runnability-guard bypasses this check (logged, not silent).`);
2345
+ }
2346
+ // #362 (Wave 3, AC7): ADVISORY ONLY — never die(), never rejects/alters the ref. This is
2347
+ // deliberately the opposite posture from the runnability guard immediately above (which
2348
+ // stays fatal, unchanged). Guidance, not enforcement, per the plan's item 2: nudge a
2349
+ // newly-recorded bare (non-negated, non-count-asserted, non-chained) grep/diff evidence
2350
+ // ref toward a self-asserting form, without breaking back-compat with any already-recorded
2351
+ // evidence ref (#412) — an already-recorded ref reaching this validator on every subsequent
2352
+ // record-evidence call must keep passing, just with a repeated nudge, never a new failure.
2353
+ if (isAmbiguousAbsenceCommand(commandSource)) {
2354
+ process.stderr.write(`[record-evidence] advisory: acceptance.criteria[${index}].evidence_refs[${refIndex}] kind:"command" ref "${commandSource}" is a bare grep/diff invocation — its exit code is AMBIGUOUS (zero matches/no diff could mean PASS for an absence check or FAIL for a presence check). Consider re-recording it as a ${AMBIGUOUS_REMEDIATION_ADVICE} to remove the ambiguity. This is advisory only; the ref is NOT rejected.\n`);
2355
+ }
2356
+ });
1902
2357
  });
1903
2358
  }
1904
2359
  export function writeState(dir, slug, status, phase, timestamp, summary, next = "continue") {
@@ -1968,6 +2423,98 @@ function checksFromBundle(dir) {
1968
2423
  const md = claim.metadata;
1969
2424
  return md && typeof md === "object" && md.waiver && typeof md.waiver === "object" ? md.waiver : undefined;
1970
2425
  };
2426
+ // #298: read side of the artifact_refs/standard_refs stamp (write side: buildTrustBundle's
2427
+ // claimMetadata.artifact_refs/.standard_refs, above). Previously these were validated on input
2428
+ // but never persisted at all — restoring them here is what makes them round-trip through a
2429
+ // second writer's rebuild instead of vanishing on the very first write.
2430
+ const refsOf = (claim) => {
2431
+ const md = claim.metadata;
2432
+ if (!md || typeof md !== "object")
2433
+ return {};
2434
+ const out = {};
2435
+ if (Array.isArray(md.artifact_refs) && md.artifact_refs.length > 0)
2436
+ out.artifact_refs = md.artifact_refs;
2437
+ if (Array.isArray(md.standard_refs) && md.standard_refs.length > 0)
2438
+ out.standard_refs = md.standard_refs;
2439
+ return out;
2440
+ };
2441
+ // #270/#380: read side of the record-check output-digest stamp (write side: buildTrustBundle's
2442
+ // claimMetadata.output_digest, above). Restoring it here is what makes the digest survive a
2443
+ // subsequent writer's rebuild instead of vanishing on the very next record-evidence/
2444
+ // record-critique/record-learning call.
2445
+ const outputSha256Of = (claim) => {
2446
+ const md = claim.metadata;
2447
+ const od = md && typeof md === "object" ? md.output_digest : undefined;
2448
+ return od && typeof od === "object" && od.algorithm === "sha256" && typeof od.hex === "string" && od.hex.length > 0 ? od.hex : undefined;
2449
+ };
2450
+ // #270(a)/(c): read side of the gate_claim stamp (write side: buildTrustBundle's
2451
+ // claimMetadata.gate_claim, above). Restoring expectation_id/claim_type/subject_type/step_id
2452
+ // is what lets a REBUILD (record-evidence/record-critique/record-learning, after the recorded
2453
+ // gate claim) recognize this as a previously-typed gate claim and reuse its frozen typing
2454
+ // instead of re-deriving via matchExpectsEntry against whatever step is active at rebuild time.
2455
+ const gateClaimOf = (claim) => {
2456
+ const md = claim.metadata;
2457
+ return md && typeof md === "object" && md.gate_claim && typeof md.gate_claim === "object" ? md.gate_claim : undefined;
2458
+ };
2459
+ const applyGateClaimStamp = (check, claim) => {
2460
+ const gc = gateClaimOf(claim);
2461
+ if (!gc)
2462
+ return;
2463
+ if (typeof gc.expectation_id === "string")
2464
+ check._gate_claim_expectation_id = gc.expectation_id;
2465
+ if (typeof gc.claim_type === "string")
2466
+ check._gate_claim_declared_type = gc.claim_type;
2467
+ if (typeof gc.subject_type === "string")
2468
+ check._gate_claim_declared_subject = gc.subject_type;
2469
+ if (typeof gc.step_id === "string")
2470
+ check._gate_claim_declared_step_id = gc.step_id;
2471
+ if (typeof gc.route_reason === "string")
2472
+ check._gate_claim_route_reason = gc.route_reason;
2473
+ };
2474
+ // #270 CRITICAL/HIGH fix: a claim that is gate-claim-SHAPED but carries NO metadata.gate_claim
2475
+ // stamp predates this cluster (#270/#344): buildTrustBundle could not have produced this shape
2476
+ // without ALSO writing the stamp, once the stamping code shipped, EXCEPT for one legitimate,
2477
+ // longstanding case that must NOT be flagged: a plain record-evidence check (kind:"external" or
2478
+ // otherwise) that simply happens to auto-match a declared kit-typed claim via matchExpectsEntry's
2479
+ // existing P-d fallback (ADR 0016) while a flow step is active — that path has ALWAYS existed,
2480
+ // never goes through record-gate-claim, and correctly has no gate_claim stamp (there is no
2481
+ // expectation id to freeze; matchExpectsEntry's heuristic result is expected to re-derive on
2482
+ // every rebuild for a plain check). The ONLY structurally reliable signal that a claim was
2483
+ // ACTUALLY produced by record-gate-claim (as opposed to a plain check that merely resembles one)
2484
+ // is record-gate-claim's own check-id-generation convention: `id: \`gate-claim-${checkId}\``
2485
+ // (see recordGateClaim) — no other producer in this file ever creates that id shape. Requiring
2486
+ // ALL FOUR signals (origin:"check", check_kind:"external", a kit-typed claimType — i.e. does NOT
2487
+ // start with "workflow." — AND a subjectId whose last path segment starts with "gate-claim-")
2488
+ // narrows detection to exactly the pre-cluster-270 defect class, without misclassifying an
2489
+ // ordinary declared check. Detected here (not in buildTrustBundle) because the claim's OWN
2490
+ // claimType/origin/check_kind/subjectId — the fields the detection needs — live on the bundle
2491
+ // claim, not on the reconstructed `check` object; buildTrustBundle only ever sees the `check`,
2492
+ // so the detection result is threaded through as a stamp on the check object itself, exactly
2493
+ // like every other gate_claim field above. buildTrustBundle's job is then only to die() loudly
2494
+ // on this stamp — never to re-derive or silently re-type it (that silent re-typing IS the
2495
+ // #268/#270 defect class).
2496
+ const gateClaimShapeUnstampedId = (claim) => {
2497
+ if (claimOrigin(claim) !== "check")
2498
+ return null;
2499
+ const md = (claim.metadata && typeof claim.metadata === "object") ? claim.metadata : {};
2500
+ if (md.check_kind !== "external")
2501
+ return null;
2502
+ const claimType = typeof claim.claimType === "string" ? claim.claimType : "";
2503
+ if (!claimType || claimType.startsWith("workflow."))
2504
+ return null;
2505
+ const subjectId = typeof claim.subjectId === "string" ? claim.subjectId : "";
2506
+ const lastSegment = subjectId.split("/").pop() ?? "";
2507
+ if (!lastSegment.startsWith("gate-claim-"))
2508
+ return null; // not record-gate-claim-shaped at all
2509
+ if (gateClaimOf(claim))
2510
+ return null; // properly stamped — not this defect class
2511
+ return typeof claim.id === "string" ? claim.id : "<unknown>";
2512
+ };
2513
+ const applyGateClaimShapeUnstamped = (check, claim) => {
2514
+ const unstampedId = gateClaimShapeUnstampedId(claim);
2515
+ if (unstampedId)
2516
+ check._gate_claim_shape_unstamped_claim_id = unstampedId;
2517
+ };
1971
2518
  for (const ev of bundle.evidence) {
1972
2519
  if (!ev || !ev.claimId)
1973
2520
  continue;
@@ -1989,6 +2536,12 @@ function checksFromBundle(dir) {
1989
2536
  const waiver = waiverOf(claim);
1990
2537
  if (waiver)
1991
2538
  check._waiver = waiver;
2539
+ Object.assign(check, refsOf(claim));
2540
+ const outputSha256 = outputSha256Of(claim);
2541
+ if (outputSha256)
2542
+ check._output_sha256 = outputSha256;
2543
+ applyGateClaimStamp(check, claim);
2544
+ applyGateClaimShapeUnstamped(check, claim);
1992
2545
  checks.push(check);
1993
2546
  }
1994
2547
  // Also include check claims that have no evidence item (surface_trust_refs style).
@@ -2005,10 +2558,67 @@ function checksFromBundle(dir) {
2005
2558
  const waiver = waiverOf(claim);
2006
2559
  if (waiver)
2007
2560
  check._waiver = waiver;
2561
+ Object.assign(check, refsOf(claim));
2562
+ const outputSha256 = outputSha256Of(claim);
2563
+ if (outputSha256)
2564
+ check._output_sha256 = outputSha256;
2565
+ applyGateClaimStamp(check, claim);
2566
+ applyGateClaimShapeUnstamped(check, claim);
2008
2567
  checks.push(check);
2009
2568
  }
2010
2569
  return checks;
2011
2570
  }
2571
+ /**
2572
+ * #270 CRITICAL fix (iteration 6): build the id -> hasStamp map normalizeCheck's narrowed
2573
+ * reserved-prefix exemption needs. A check's reconstructed `_gate_claim_expectation_id` (set by
2574
+ * applyGateClaimStamp above, from the claim's metadata.gate_claim) is present if and only if the
2575
+ * bundle claim backing this check id currently carries a live stamp. Every caller of
2576
+ * normalizeCheck (record-evidence, record-check, dogfood-pass) must derive this from the SAME
2577
+ * readBundleState/checksFromBundle snapshot used for the compose-safe merge, taken BEFORE
2578
+ * normalizeCheck runs, so the exemption sees the bundle state as it stands right now — not a
2579
+ * stale or partial view.
2580
+ */
2581
+ function existingCheckStampMap(checks) {
2582
+ const byId = new Map();
2583
+ for (const c of checks)
2584
+ if (c && typeof c.id === "string")
2585
+ byId.set(c.id, typeof c._gate_claim_expectation_id === "string");
2586
+ return byId;
2587
+ }
2588
+ /**
2589
+ * #298/#270: the shared compose-safe read path every trust-bundle writer should use instead of
2590
+ * hand-assembling its own partial slice. Reconstructs ALL THREE families losslessly from the
2591
+ * existing bundle (checks via checksFromBundle, extended per above; criteria from
2592
+ * acceptance.json; critiques via critiquesFromBundle) so a writer that only intends to touch ONE
2593
+ * family never has to pass `[]` for the other two — that `[]`-for-untouched-slices pattern is
2594
+ * exactly what caused #270's 21-claims-to-1 wipe. Generalizes the pattern recordLearning already
2595
+ * used inline; behavior-neutral for recordLearning itself.
2596
+ */
2597
+ function readBundleState(dir) {
2598
+ const acceptance = loadJson(path.join(dir, "acceptance.json"));
2599
+ return {
2600
+ checks: checksFromBundle(dir),
2601
+ criteria: Array.isArray(acceptance.criteria) ? acceptance.criteria : [],
2602
+ critiques: critiquesFromBundle(dir),
2603
+ };
2604
+ }
2605
+ /**
2606
+ * #298: compose-safe merge-by-id for the `checks` slice of readBundleState — a later check
2607
+ * with the same `id` supersedes/replaces the earlier one (same-id resupply is a legitimate
2608
+ * update, e.g. re-recording the same gate claim or the same named check after a fix); a check
2609
+ * with a new `id` is additive. Order is preserved (existing order, then newly-introduced ids
2610
+ * appended) so unrelated diagnostics (e.g. CI reconcile output) do not reorder churn.
2611
+ */
2612
+ function mergeChecksById(existing, incoming) {
2613
+ const byId = new Map();
2614
+ for (const c of existing)
2615
+ if (c && c.id)
2616
+ byId.set(c.id, c);
2617
+ for (const c of incoming)
2618
+ if (c && c.id)
2619
+ byId.set(c.id, c);
2620
+ return [...byId.values()];
2621
+ }
2012
2622
  function critiquesFromBundle(dir) {
2013
2623
  const bundle = loadJson(path.join(dir, "trust.bundle"));
2014
2624
  if (!Array.isArray(bundle.claims))
@@ -2060,7 +2670,14 @@ async function recordEvidence(p) {
2060
2670
  const slug = taskSlugFor(dir, opt(p, "task-slug"));
2061
2671
  const _ts0 = opt(p, "timestamp", now());
2062
2672
  const _waiver = parseWaiver(p, _ts0);
2063
- const _checksRaw = [...opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"))), ...opts(p, "surface-trust-json").map(surfaceCheckFromArtifact)];
2673
+ // #270 follow-up fix (iteration 5, narrowed iteration 6): read the bundle's CURRENT check ids
2674
+ // (and stamp status) before normalizeCheck runs so a correction of an UNSTAMPED wedged id is
2675
+ // exempted from the reserved gate-claim- prefix rejection below, while a brand-new mint of
2676
+ // that shape, OR supersession of a STAMPED (live) gate claim, is still rejected. Reused below
2677
+ // (not re-read) as _existingState for the compose-safe merge — one readBundleState call.
2678
+ const _existingState = readBundleState(dir);
2679
+ const _existingCheckStampById = existingCheckStampMap(_existingState.checks);
2680
+ const _checksRaw = [...opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"), false, _existingCheckStampById)), ...opts(p, "surface-trust-json").map(surfaceCheckFromArtifact)];
2064
2681
  // WS8 (AC4, iteration 2): a command-backed check reconciles against CI or fails — it can
2065
2682
  // NEVER be waived. Reject --accepted-gap-reason/--waived-by on any check whose evidence
2066
2683
  // classifies as test_output (build/types/lint/test/command, and security/browser/runtime
@@ -2079,22 +2696,182 @@ async function recordEvidence(p) {
2079
2696
  const checks = _checksRaw.map((c) => _waiver ? { ...c, _waiver } : c);
2080
2697
  if (!checks.length && opts(p, "surface-trust-json").length === 0)
2081
2698
  die("record-evidence requires at least one --check-json or --surface-trust-json");
2082
- validateAcceptanceEvidenceRefs(dir);
2699
+ validateAcceptanceEvidenceRefs(dir, p);
2083
2700
  // Phase 4c: bundle is the sole verification artifact — stop writing evidence.json and acceptance.json update.
2084
2701
  const ts = opt(p, "timestamp", now());
2085
- const _existingAcceptance = loadJson(path.join(dir, "acceptance.json"));
2086
- const _existingCriteria = Array.isArray(_existingAcceptance.criteria) ? _existingAcceptance.criteria : [];
2702
+ // #298: readBundleState + merge-by-id instead of unconditionally replacing every prior check —
2703
+ // record-evidence previously never called checksFromBundle(dir), so it dropped every check
2704
+ // recorded by an earlier record-evidence/record-check/record-gate-claim call. A later check
2705
+ // with the same id supersedes the earlier one (same-id resupply); a new id is additive.
2706
+ // (_existingState was already read above, before normalizeCheck ran, so the reserved-prefix
2707
+ // exists-check sees the SAME bundle snapshot the merge below uses — no second read needed.)
2087
2708
  const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
2088
- const _criteriaForBundle = _existingCriteria.map((c) => ({ ...c, status: _criteriaStatus }));
2709
+ const _criteriaForBundle = _existingState.criteria.map((c) => ({ ...c, status: _criteriaStatus }));
2710
+ const _mergedChecks = mergeChecksById(_existingState.checks, checks);
2089
2711
  // #268: preserve any existing critique claims (including superseded history) instead of dropping
2090
2712
  // them — record-evidence previously hardcoded critiques:[] here, silently erasing finding history
2091
2713
  // whenever it ran after a critique existed.
2092
- const _existingCritiques = critiquesFromBundle(dir);
2093
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, _existingCritiques));
2714
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _criteriaForBundle, _existingState.critiques));
2094
2715
  const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
2095
2716
  writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
2096
2717
  return 0;
2097
2718
  }
2719
+ // #380: bounded stdout+stderr digest for a record-check execution, mirroring
2720
+ // scripts/hooks/evidence-capture.js's MAX_OUTPUT_SCAN bound (64 KiB) so the two capture code
2721
+ // paths (host-observed vs. self-executed) stay size-disciplined the same way, even though this
2722
+ // one runs the command itself rather than observing a host tool_response.
2723
+ const RECORD_CHECK_MAX_OUTPUT = 64 * 1024;
2724
+ function clampOutput(text) {
2725
+ return text.length > RECORD_CHECK_MAX_OUTPUT ? text.slice(0, RECORD_CHECK_MAX_OUTPUT) : text;
2726
+ }
2727
+ /**
2728
+ * record-check — run a command and record a capture-backed check in one step (#380).
2729
+ *
2730
+ * Trust posture (#270 MEDIUM fix — was previously misdescribed): record-check EXECUTES a command
2731
+ * ON THE AGENT'S BEHALF, in-process, right here — it is not an observer. This is a materially
2732
+ * DIFFERENT trust posture than scripts/hooks/evidence-capture.js, which never executes anything
2733
+ * itself; it only OBSERVES a host tool_response that some other, already-executed tool call
2734
+ * produced (a PostToolUse hook). record-check's exitCode/observedResult classification mirrors
2735
+ * evidence-capture.js's observeResult() heuristic (a clean integer exit code is authoritative),
2736
+ * but the EXECUTION itself is this function's own doing, under this process's privileges — an
2737
+ * agent invoking record-check is asking this CLI to run a command for it, not merely to attest to
2738
+ * something that already ran. Callers and reviewers should weigh that accordingly: record-check
2739
+ * is a convenience that collapses "run a command" + "record its result" into one step, not a
2740
+ * passive-observation capture path.
2741
+ *
2742
+ * Syntax: `workflow-sidecar record-check <artifact-dir> -- <command...>` (argv after the FIRST
2743
+ * literal `--` token is the command to execute verbatim, split by main() before parseArgs — see
2744
+ * main()'s pre-split below) or `--command "<shell string>"` for parity with record-gate-claim
2745
+ * when the caller already has a single shell string rather than an argv array.
2746
+ *
2747
+ * Captures { exitCode, observedResult } the same way evidence-capture.js's observeResult() does:
2748
+ * a clean integer exit code drives observedResult (0 => pass, nonzero => fail). Records a
2749
+ * kind:"command" check with a real execution.label (the executed command), composed losslessly
2750
+ * through the same readBundleState + mergeChecksById + writeTrustBundle path every other writer
2751
+ * in this file uses — record-check is the compose-safe pattern PLUS an execution step in front
2752
+ * of it, not a parallel bundle-writing implementation.
2753
+ *
2754
+ * The captured stdout+stderr is hashed (sha256) and the digest — never the raw output — is
2755
+ * persisted onto the resulting claim's metadata.output_digest (see buildTrustBundle's
2756
+ * outputDigestMeta) so a later reviewer can confirm two independent runs produced byte-identical
2757
+ * output without the trust.bundle itself ever carrying raw command output (secret-safe by
2758
+ * construction).
2759
+ *
2760
+ * A non-zero exit records status "fail" (never silently swallowed) AND this command itself exits
2761
+ * non-zero — a check that failed to run correctly must be loud, not silently recorded as if
2762
+ * nothing happened.
2763
+ */
2764
+ async function recordCheck(p, commandArgv) {
2765
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
2766
+ const slug = taskSlugFor(dir, opt(p, "task-slug"));
2767
+ const ts = opt(p, "timestamp", now());
2768
+ const commandString = opt(p, "command");
2769
+ if (!commandArgv?.length && !commandString)
2770
+ die('record-check requires a command: either `record-check <dir> -- <command...>` or `--command "<shell string>"`');
2771
+ if (commandArgv?.length && commandString)
2772
+ die("record-check: pass the command via EITHER `-- <command...>` OR --command, not both");
2773
+ const repoRoot = findRepoRootFromDir(dir);
2774
+ let displayCommand;
2775
+ let exitCode;
2776
+ let stdout = "";
2777
+ let stderr = "";
2778
+ try {
2779
+ if (commandArgv?.length) {
2780
+ displayCommand = commandArgv.join(" ");
2781
+ const out = execFileSync(commandArgv[0], commandArgv.slice(1), { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 600000 });
2782
+ stdout = out;
2783
+ exitCode = 0;
2784
+ }
2785
+ else {
2786
+ // #412: a --command shell string is validated the same way record-gate-claim's --command
2787
+ // is (single-sourced isRunnableCommandText) — prose does not belong here either.
2788
+ const { isRunnableCommandText } = loadRunnableCommandHelper();
2789
+ if (!isRunnableCommandText(commandString))
2790
+ die(`record-check --command "${commandString}" is not a runnable shell command.`);
2791
+ displayCommand = commandString;
2792
+ const out = execFileSync("bash", ["-lc", commandString], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 600000 });
2793
+ stdout = out;
2794
+ exitCode = 0;
2795
+ }
2796
+ }
2797
+ catch (err) {
2798
+ const e = err;
2799
+ exitCode = typeof e.status === "number" ? e.status : null;
2800
+ stdout = typeof e.stdout === "string" ? e.stdout : "";
2801
+ stderr = typeof e.stderr === "string" ? e.stderr : (e.message ?? "");
2802
+ displayCommand = commandArgv?.length ? commandArgv.join(" ") : commandString;
2803
+ }
2804
+ // Mirror evidence-capture.js's observeResult(): a clean integer exit code is authoritative
2805
+ // (0 => pass, nonzero => fail); this path always HAS a clean integer exit code because it ran
2806
+ // the command itself (unlike the host-observation path, which sometimes only has failure
2807
+ // indicators). See scripts/hooks/evidence-capture.js's observeResult()/cleanExitCode().
2808
+ //
2809
+ // #362 (Wave 3, AC6): the SAME narrow, two-binary carve-out `runBackstop`/`readCommandLog`
2810
+ // (stop-goal-fit.js) and `observeResult` (evidence-capture.js) already apply — a bare
2811
+ // (non-negated, non-count-asserted, non-chained) `grep`/`diff` invocation that exits EXACTLY
2812
+ // 1 is exit-code-ambiguous (zero matches/no diff could be the author's intended PASS for an
2813
+ // absence check, or an unintended miss for a presence check) — is applied HERE at record time
2814
+ // too, since record-check is the most direct match to the issue's own AC12 field evidence: it
2815
+ // executes the command itself and stamps the resulting status. Exit codes >= 2 are untouched
2816
+ // (still "fail", a real tool error) — the carve-out is exit-code-1-only, never widened.
2817
+ //
2818
+ // There is no "ambiguous" value in the check-status enum (`checkStatuses`,
2819
+ // schemas/workflow-acceptance.schema.json's criteria[].status enum) and none is added here —
2820
+ // per the plan (and ADR 0008/0010 consume-never-fork), the ambiguous case maps onto the
2821
+ // EXISTING "not_verified" status rather than inventing a new enum value that every consumer of
2822
+ // that enum would need auditing for. The distinction from a hard "fail" is carried in the
2823
+ // stderr note and the check summary text, not a new status value.
2824
+ const { isAmbiguousAbsenceCommand } = loadRunnableCommandHelper();
2825
+ const ambiguous = exitCode === 1 && isAmbiguousAbsenceCommand(displayCommand ?? "");
2826
+ const status = exitCode === 0 ? "pass" : (ambiguous ? "not_verified" : "fail");
2827
+ // #270 MEDIUM fix: hash the captured (clamped) output — the digest itself is what gets
2828
+ // persisted onto claim.metadata (below, via buildTrustBundle), NEVER the raw output. This is
2829
+ // secret-safe by construction: even if the executed command's stdout/stderr contained a
2830
+ // credential or token, only its sha256 hex digest ever reaches the trust.bundle (which is
2831
+ // frequently committed/shared), not the text itself. The previous `_output_digest` field held
2832
+ // the raw clamped text under a misleading "digest" name and was never actually persisted
2833
+ // anywhere (dead code) — this replaces it with a REAL digest that IS persisted.
2834
+ const capturedOutput = clampOutput(`${stdout}${stderr ? `
2835
+ ${stderr}` : ""}`.trim());
2836
+ const outputSha256 = capturedOutput ? createHash("sha256").update(capturedOutput, "utf8").digest("hex") : null;
2837
+ const checkIdRaw = opt(p, "id") || displayCommand;
2838
+ const checkId = checkIdRaw.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "record-check";
2839
+ const summary = opt(p, "summary", `record-check: ${displayCommand}${exitCode !== null ? ` (exit ${exitCode})` : ""}${ambiguous ? " — AMBIGUOUS (bare grep/diff exit 1): NOT_VERIFIED, not silently pass/fail" : ""}`);
2840
+ // #270 follow-up fix (iteration 5, narrowed iteration 6): read the bundle's CURRENT check ids
2841
+ // (and stamp status) BEFORE normalizeCheck runs so a correction of an UNSTAMPED wedged id is
2842
+ // exempted from the reserved gate-claim- prefix rejection, while a brand-new mint of that
2843
+ // shape, OR supersession of a STAMPED (live) gate claim, is still rejected. Reused below (not
2844
+ // re-read) for the compose-safe merge — one readBundleState call.
2845
+ const _existingState = readBundleState(dir);
2846
+ const _existingCheckStampById = existingCheckStampMap(_existingState.checks);
2847
+ // kind:"command" + a real `command` string already derives evidenceType "test_output" via
2848
+ // classifyEvidence in buildTrustBundle — no separate evidenceType input is consumed there.
2849
+ const check = normalizeCheck({
2850
+ id: checkId,
2851
+ kind: "command",
2852
+ status,
2853
+ summary,
2854
+ command: displayCommand,
2855
+ }, false, _existingCheckStampById);
2856
+ if (outputSha256)
2857
+ check._output_sha256 = outputSha256;
2858
+ const _mergedChecks = mergeChecksById(_existingState.checks, [check]);
2859
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _existingState.criteria, _existingState.critiques));
2860
+ if (ambiguous) {
2861
+ // #362 (AC6): never silent-pass, never hard-fail — a bare grep/diff exit 1 is recorded
2862
+ // "not_verified" (above) and this is surfaced loudly on stderr, but record-check itself
2863
+ // still exits 0: the command DID run (this is not a tool/execution error), and the operator
2864
+ // needs an actionable nudge, not a blocked pipeline. Exit codes >= 2 for grep/diff remain a
2865
+ // hard "fail" via the branch below, unchanged.
2866
+ process.stderr.write(`[record-check] command "${displayCommand}" exited 1 — for bare grep/diff this may mean zero matches/no differences (PASS for an absence check) or an unintended miss (FAIL for a presence check); recorded status: not_verified (ambiguous), not silently pass or fail. Re-record this check as a ${AMBIGUOUS_REMEDIATION_ADVICE} to remove the ambiguity.\n`);
2867
+ return 0;
2868
+ }
2869
+ if (status === "fail") {
2870
+ process.stderr.write(`[record-check] command failed (exit ${exitCode ?? "unknown"}): ${displayCommand}\n${stderr ? `${stderr}\n` : ""}`);
2871
+ return 1;
2872
+ }
2873
+ return 0;
2874
+ }
2098
2875
  function diagnostic(dir, code, summary) {
2099
2876
  const payload = { timestamp: now(), code, summary };
2100
2877
  appendJsonl(path.join(dir, "transition-diagnostics.jsonl"), payload);
@@ -2108,6 +2885,7 @@ function diagnostic(dir, code, summary) {
2108
2885
  * --status <pass|fail|not_verified> (required)
2109
2886
  * --summary <text> (required)
2110
2887
  * --expectation <id> (optional; auto-resolved when the gate has one entry)
2888
+ * --route-reason <classifier> (optional; fail only, interpreted by Flow)
2111
2889
  * --evidence-json <json> (optional; structured evidence refs)
2112
2890
  *
2113
2891
  * The producer emits a check of kind="external" targeting the gate expectation's declared
@@ -2135,6 +2913,11 @@ async function recordGateClaim(p) {
2135
2913
  die("--status must be one of: pass, fail, not_verified");
2136
2914
  const summary = opt(p, "summary") || die("--summary is required");
2137
2915
  const expectationId = opt(p, "expectation");
2916
+ const routeReason = opt(p, "route-reason");
2917
+ if (routeReason && statusVal !== "fail")
2918
+ die("--route-reason is only valid with --status fail");
2919
+ if (routeReason && !/^[a-z][a-z0-9_-]*$/.test(routeReason))
2920
+ die("--route-reason must be a lowercase classifier identifier");
2138
2921
  // Resolve the active flow step from current.json. #291 Wave 2 Task 2.1 (§7)/Task 2.2: resolve
2139
2922
  // the CALLING actor's own current-pointer (per-actor-first, legacy-fallback) rather than an
2140
2923
  // unconditional legacy-only read — this is the fix for record-gate-claim's pre-existing (#291
@@ -2145,6 +2928,9 @@ async function recordGateClaim(p) {
2145
2928
  const activeStep = resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
2146
2929
  if (!activeStep)
2147
2930
  die("record-gate-claim requires an active flow step in current.json (set via ensure-session --flow-id or advance-state --flow-definition)");
2931
+ if (routeReason && !activeStep.routeBackReasons.includes(routeReason)) {
2932
+ die(`--route-reason "${routeReason}" is not declared by gate "${activeStep.gateId}". Available: ${activeStep.routeBackReasons.join(", ") || "none"}`);
2933
+ }
2148
2934
  const expects = activeStep.gateExpects;
2149
2935
  if (expects.length === 0)
2150
2936
  die(`record-gate-claim: active step "${activeStep.stepId}" gate "${activeStep.gateId}" has no expects[] entries`);
@@ -2176,20 +2962,41 @@ async function recordGateClaim(p) {
2176
2962
  status: statusVal,
2177
2963
  summary,
2178
2964
  _gate_claim_expectation_id: targetExpectation.id,
2965
+ ...(routeReason ? { _gate_claim_route_reason: routeReason } : {}),
2179
2966
  };
2180
2967
  // Include structured evidence refs if provided
2181
2968
  const evidenceRefs = opts(p, "evidence-ref-json").map((v) => validateEvidenceRef(parseJson(v, "--evidence-ref-json"), "--evidence-ref-json"));
2182
2969
  if (evidenceRefs.length > 0) {
2183
2970
  check.artifact_refs = evidenceRefs;
2184
2971
  }
2185
- const checkNormalized = normalizeCheck(check);
2972
+ // #270(b)/#412: --command gives a gate claim a real, runnable execution.label distinct from
2973
+ // the human --summary prose, so stop-goal-fit.js's bundleClaimedPassCommandChecks section (B)
2974
+ // finds a real command to re-run instead of falling back to fieldOrBehavior (the --summary
2975
+ // prose itself), which it would otherwise execute as a shell command under
2976
+ // FLOW_AGENTS_GOAL_FIT_RECHECK. Validated at record time with the same isRunnableCommandText
2977
+ // heuristic the Stop-hook backstop uses (single-sourced — see loadRunnableCommandHelper).
2978
+ const gateCommand = opt(p, "command");
2979
+ if (gateCommand) {
2980
+ const { isRunnableCommandText } = loadRunnableCommandHelper();
2981
+ if (!isRunnableCommandText(gateCommand))
2982
+ die(`record-gate-claim --command "${gateCommand}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
2983
+ check.command = gateCommand;
2984
+ }
2985
+ const checkNormalized = normalizeCheck(check, /* allowGateClaimPrefix */ true);
2186
2986
  // WS8 (ADR 0020): honor the accepted-gap waiver flags for a gate claim too.
2187
2987
  const gateWaiver = parseWaiver(p, ts);
2188
2988
  if (gateWaiver)
2189
2989
  checkNormalized._waiver = gateWaiver;
2190
2990
  // Log the targeted gate expectation for transparency (goes to stderr only)
2191
2991
  process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}${gateWaiver ? " (WAIVED accepted_gap)" : ""}\n`);
2192
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], [], gateClaimActorKey));
2992
+ // #270: accumulate into the FULL existing bundle state (checks/criteria/critiques) instead of
2993
+ // the prior [checkNormalized], [], [] call, which clobbered every other check/criterion/critique
2994
+ // in the bundle on every gate-claim write (the 21-claims-to-1 wipe). A gate claim against the
2995
+ // SAME expectation id supersedes the earlier check for that expectation (mergeChecksById); a
2996
+ // gate claim against a different expectation is additive.
2997
+ const _existingState = readBundleState(dir);
2998
+ const _mergedChecks = mergeChecksById(_existingState.checks, [checkNormalized]);
2999
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _existingState.criteria, _existingState.critiques, gateClaimActorKey));
2193
3000
  return 0;
2194
3001
  }
2195
3002
  /**
@@ -2278,6 +3085,8 @@ async function promote(p) {
2278
3085
  throw err;
2279
3086
  if (err instanceof NotFreshHolderError)
2280
3087
  throw err;
3088
+ if (err instanceof RepoHeadMismatchError)
3089
+ throw err;
2281
3090
  process.stderr.write(`[promote] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
2282
3091
  });
2283
3092
  }
@@ -2337,7 +3146,7 @@ async function advanceState(p) {
2337
3146
  // call site ALSO dual-writes the per-actor projection, not only ensure-session's call site —
2338
3147
  // otherwise a session that only ever calls advance-state (never re-running ensure-session)
2339
3148
  // would never get a per-actor current.json mirror for its own FlowDefinition routing keys.
2340
- writeCurrent(root, dir, timestamp, "workflow-sidecar", "advance-state", flow, stepId, undefined, resolveReadActorKey(p));
3149
+ writeCurrent(root, dir, timestamp, "workflow-sidecar", "advance-state", flow, stepId, resolveReadActorKey(p));
2341
3150
  }
2342
3151
  }
2343
3152
  livenessLifecycle(dir, slug, LIVENESS_TERMINAL.has(status) ? "release" : "heartbeat", timestamp);
@@ -2359,6 +3168,8 @@ async function advanceState(p) {
2359
3168
  throw err;
2360
3169
  if (err instanceof NotFreshHolderError)
2361
3170
  throw err;
3171
+ if (err instanceof RepoHeadMismatchError)
3172
+ throw err;
2362
3173
  process.stderr.write(`[advance-state] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
2363
3174
  });
2364
3175
  }
@@ -2399,10 +3210,13 @@ async function recordCritique(p) {
2399
3210
  return e;
2400
3211
  });
2401
3212
  const critiques = [..._mergedCritiques, critique];
2402
- // Phase 4c: build bundle from raw inputs; read checks from trust.bundle (evidence.json no longer written).
2403
- const _critiqueEvChecks = checksFromBundle(dir);
2404
- const _critiqueAccCriteria = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
2405
- assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueEvChecks, _critiqueAccCriteria, critiques));
3213
+ // Phase 4c: build bundle from raw inputs; read checks/criteria via the shared compose-safe
3214
+ // readBundleState path (#270 LOW consolidation) instead of hand-rolling the identical
3215
+ // checksFromBundle + acceptance.json read inline this is the exact pattern readBundleState
3216
+ // already exists to share; recordLearning uses it directly (see below) and recordCritique
3217
+ // previously duplicated it by hand for no reason.
3218
+ const _critiqueState = readBundleState(dir);
3219
+ assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueState.checks, _critiqueState.criteria, critiques));
2406
3220
  return 0;
2407
3221
  }
2408
3222
  function frontmatter(text, key) {
@@ -2464,6 +3278,8 @@ async function recordRelease(p) {
2464
3278
  throw err;
2465
3279
  if (err instanceof NotFreshHolderError)
2466
3280
  throw err;
3281
+ if (err instanceof RepoHeadMismatchError)
3282
+ throw err;
2467
3283
  process.stderr.write(`[record-release] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
2468
3284
  });
2469
3285
  return 0;
@@ -2777,6 +3593,40 @@ export class NotFreshHolderError extends Error {
2777
3593
  this.guidance = result.guidance;
2778
3594
  }
2779
3595
  }
3596
+ /**
3597
+ * Distinct, identifiable error for a publish attempt whose resolved `repoRoot` does not
3598
+ * positively confirm the session's sealed `trust.checkpoint.json` `commit_sha` — a FOURTH,
3599
+ * distinct fail-closed tier (#413 Facet A), never conflated with `InvalidBundleShapeError`
3600
+ * (bundle shape) or `NotFreshHolderError` (actor hold). This is the "refuse loudly when the cwd
3601
+ * repo does not match the sealed checkpoint commit_sha" fallback the issue itself proposed:
3602
+ * `publishDelivery`'s `repoRoot` is resolved by walking up from the ARTIFACT directory
3603
+ * (findRepoRootFromDirStrict), which always physically lives under the primary checkout even
3604
+ * when invoked from an unrelated worktree/repo — so a positively-confirmed mismatch here is the
3605
+ * signal that this resolved repoRoot is NOT the repo the sealed checkpoint was produced against,
3606
+ * and publishing into it would silently write to the wrong tree.
3607
+ *
3608
+ * #413 iteration-2 Fix 4: thrown for the ONE positively-determinable `mismatchKind` (see
3609
+ * checkpointHeadAncestry's doc comment) — `"positive"` (repoRoot's HEAD is confirmed NOT an
3610
+ * ancestor-or-equal of commit_sha; `headSha` is a real sha). It is intentionally NEVER thrown for
3611
+ * `"undeterminable-shallow"` (e.g. a shallow clone missing the commit object) — that case only
3612
+ * warns and allows the publish (see publishDelivery's own gate). A non-git `repoRoot` classifies
3613
+ * as `mismatchKind === "none"` (no opinion — allow; see checkpointHeadAncestry's doc comment for
3614
+ * why the iteration-2 Fix 5 refusal here was reverted in iteration-3) and never throws this error
3615
+ * either.
3616
+ */
3617
+ export class RepoHeadMismatchError extends Error {
3618
+ code = "PUBLISH_DELIVERY_REPO_HEAD_MISMATCH";
3619
+ repoRoot;
3620
+ commitSha;
3621
+ headSha;
3622
+ constructor(repoRoot, commitSha, headSha) {
3623
+ super(`publish-delivery refused — resolved repoRoot '${repoRoot}' HEAD '${headSha}' is not an ancestor-or-equal of the sealed trust.checkpoint.json commit_sha '${commitSha}'. This usually means repoRoot was resolved to the wrong checkout (e.g. invoked from a worktree whose artifact dir physically lives under a different primary checkout), or repoRoot is not a valid git working tree at all. Pass --repo-root explicitly naming the checkout whose HEAD matches '${commitSha}', or re-seal the checkpoint (seal-checkpoint) against this repoRoot's current HEAD if this really is the intended target.`);
3624
+ this.name = "RepoHeadMismatchError";
3625
+ this.repoRoot = repoRoot;
3626
+ this.commitSha = commitSha;
3627
+ this.headSha = headSha;
3628
+ }
3629
+ }
2780
3630
  /**
2781
3631
  * Human-actionable fix text per divergence type (Q2: unwaived-assumed's message always
2782
3632
  * carries the "waiver voided by a mixed record-evidence call" root-cause hint — shape #5 is
@@ -2805,34 +3655,139 @@ function preflightFixHint(type) {
2805
3655
  }
2806
3656
  }
2807
3657
  /**
2808
- * F4 (iteration-1): extracted from runReconcilePreflight so the main function stays under
2809
- * the 50-line guideline. Q3 (optional, non-blocking): warn if the checkpoint's commit_sha is
2810
- * not yet an ancestor of local HEAD never affects ok/exit code, best-effort only. Already a
2811
- * self-contained try/catch with no effect on the caller's `ok`/`issues`.
3658
+ * Callable core shared by BOTH the `reconcile-preflight` CLI handler and
3659
+ * publishDelivery()'s fail-closed gate (#356 Wave 3) one implementation, not two entry
3660
+ * points that could drift. SHAPE-only: never spawns a fresh manifest/CI command (AC5) the
3661
+ * only subprocess calls here are the already-cheap run-baseline.sh --manifest-json static
3662
+ * registry emit (inside resolveManifest, reused unchanged from trust-reconcile.js) and the
3663
+ * local-only derive-claim-status.mjs re-derivation (no CI command execution either).
3664
+ *
3665
+ * @param dir artifact/session directory containing trust.bundle
3666
+ * @param repoRoot repo root used for manifest resolution + the optional ancestor warning
3667
+ * @param manifestOverride optional --manifest JSON string (CLI passthrough)
2812
3668
  */
2813
- function checkpointStalenessWarning(dir, repoRoot, tr) {
2814
- const warnings = [];
3669
+ /**
3670
+ * #413 Facet A: best-effort single-direction ancestor check scoped to the specific needs of
3671
+ * checkpointHeadAncestry's hard-refuse gate — is `ancestorSha` positively determinable as an
3672
+ * ancestor of (or equal to) `descendantSha` in `repoRoot`, OR positively determinable as NOT an
3673
+ * ancestor, OR is the question simply UNDETERMINABLE here (shallow clone missing the object,
3674
+ * unknown/garbage sha, git itself unusable, etc.)? This is intentionally a SEPARATE, local
3675
+ * classification from `trust-reconcile.js`'s shared `isAncestorCommit()` (which the rest of the
3676
+ * repo, including checkpointStalenessWarning's own non-blocking warning below, correctly keeps
3677
+ * as a simple best-effort boolean that "fails toward false/stale" for every non-CI caller) —
3678
+ * that collapsed boolean is exactly right for a WARNING, but wrong for a HARD REFUSE gate: a
3679
+ * shallow clone's git-object-missing failure and a genuine cross-repo mismatch both surface as
3680
+ * `false` there, yet only the second is real harm (#413 iteration-2 Fix 4). `git merge-base
3681
+ * --is-ancestor` itself already distinguishes these at the exit-code level (0 = ancestor, 1 =
3682
+ * positively NOT an ancestor, anything else / a spawn failure = underivable), so this reuses
3683
+ * that primitive directly rather than duplicating trust-reconcile.js's collapsed wrapper.
3684
+ */
3685
+ function classifyAncestry(repoRoot, ancestorSha, descendantSha) {
3686
+ if (!ancestorSha || !descendantSha)
3687
+ return "undeterminable";
3688
+ try {
3689
+ const res = spawnSync("git", ["merge-base", "--is-ancestor", ancestorSha, descendantSha], {
3690
+ cwd: repoRoot,
3691
+ stdio: "ignore",
3692
+ });
3693
+ if (!res || res.error)
3694
+ return "undeterminable";
3695
+ if (res.status === 0)
3696
+ return "ancestor";
3697
+ if (res.status === 1)
3698
+ return "not-ancestor";
3699
+ return "undeterminable"; // e.g. 128 (unknown commit, shallow clone missing the object, etc.)
3700
+ }
3701
+ catch {
3702
+ return "undeterminable";
3703
+ }
3704
+ }
3705
+ /**
3706
+ * #413 Facet A: read `trust.checkpoint.json`'s `commit_sha` (if the checkpoint exists and
3707
+ * carries one) and resolve `git rev-parse HEAD` with an EXPLICIT `{ cwd: repoRoot }` (never the
3708
+ * bare no-cwd pattern `resolveCommitSha()` uses — see that function's own doc comment for why
3709
+ * that variant is intentionally cwd-inheriting for the seal-checkpoint path, and must not be
3710
+ * reused here). Returns `{ commitSha: null }` when there is no checkpoint or no commit_sha yet
3711
+ * (a session with nothing sealed has nothing to compare against — callers must treat this as
3712
+ * "no opinion", never as a mismatch). Returns `{ commitSha, headSha, isAncestor }` when both
3713
+ * shas are resolvable. This is the ONE shared comparison both `checkpointStalenessWarning`
3714
+ * (non-blocking) and `publishDelivery`'s hard refuse gate (fail-closed) call — never a second,
3715
+ * independently-drifting git shell-out.
3716
+ *
3717
+ * #413 iteration-2 Fix 4 (scoped to the hard-refuse tier ONLY — checkpointStalenessWarning's
3718
+ * non-blocking warning is intentionally unaffected, see its own doc comment): the returned
3719
+ * `isAncestor` stays a simple boolean (`true` unless POSITIVELY determined false) for backward
3720
+ * compatibility with that warning caller, but the `mismatchKind` field lets publishDelivery's
3721
+ * hard gate distinguish these cases precisely:
3722
+ * - "none": no checkpoint, no commit_sha, `repoRoot` is not a git working tree at all (or is a
3723
+ * freshly-initialized repo with no resolvable HEAD), or the commit_sha IS an
3724
+ * ancestor-or-equal of HEAD — never refuse. A non-git `repoRoot` is a SUPPORTED flow (e.g.
3725
+ * checkpoint-signing against a bare scratch dir — see evals/integration/test_checkpoint_signing.sh
3726
+ * TEST 2), not a mismatch signal: there is no HEAD to compare against, so this is "no
3727
+ * opinion", exactly like the no-checkpoint case above.
3728
+ * - "positive": repoRoot is a real git working tree, HEAD resolves, AND commit_sha is
3729
+ * POSITIVELY determined to NOT be an ancestor of HEAD (`git merge-base --is-ancestor` exits
3730
+ * 1, a real answer, not a failure) — the genuine wrong-tree harm case; hard refuse.
3731
+ * - "undeterminable-shallow": repoRoot is a real git working tree, HEAD resolves, but whether
3732
+ * commit_sha is an ancestor of HEAD could not be determined (e.g. a shallow clone missing
3733
+ * the commit_sha object) — must NOT hard-refuse a legitimate publish from a shallow clone;
3734
+ * warn loudly and allow (the shape + verify-hold gates still stand).
3735
+ *
3736
+ * #413 iteration-2 Fix 5 (reverted, iteration-3): a prior pass hard-refused here when `repoRoot`
3737
+ * was not a git working tree at all but a sealed commit_sha existed ("a sealed delivery implies
3738
+ * the target should be a real git repo"). That premise is false — publishing against a non-git
3739
+ * `repoRoot` is a supported scratch/checkpoint-signing flow, and the refusal regressed
3740
+ * `evals/integration/test_checkpoint_signing.sh` TEST 2. The accepted residual: a non-git
3741
+ * `repoRoot` with a sealed checkpoint is ALLOWED here; wrong-tree protection for a REAL git
3742
+ * repoRoot relies on this function's ancestry (HEAD-vs-commit_sha) check plus the shape-check and
3743
+ * verify-hold gates in publishDelivery, all of which correctly no-op (allow) when there is no git
3744
+ * HEAD to compare against.
3745
+ */
3746
+ function checkpointHeadAncestry(dir, repoRoot) {
2815
3747
  try {
2816
3748
  const checkpointPath = path.join(dir, "trust.checkpoint.json");
2817
- if (fs.existsSync(checkpointPath)) {
2818
- const checkpoint = loadJson(checkpointPath);
2819
- const commitSha = checkpoint && typeof checkpoint === "object" ? checkpoint.commit_sha : undefined;
2820
- if (typeof commitSha === "string" && commitSha) {
2821
- let headSha = "";
2822
- try {
2823
- headSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
2824
- }
2825
- catch {
2826
- headSha = "";
2827
- }
2828
- if (headSha && !tr.isAncestorCommit(repoRoot, commitSha, headSha)) {
2829
- warnings.push(`NON-BLOCKING: trust.checkpoint.json commit_sha '${commitSha}' is not an ancestor of local HEAD '${headSha}' — the checkpoint may be stale (e.g. after an amend/rebase). Consider re-sealing (seal-checkpoint) before publishing. This is a warning only — see #335 for the full checkpoint-staleness ownership check.`);
2830
- }
2831
- }
3749
+ if (!fs.existsSync(checkpointPath))
3750
+ return { commitSha: null, headSha: null, isAncestor: true, mismatchKind: "none" };
3751
+ const checkpoint = loadJson(checkpointPath);
3752
+ const commitSha = checkpoint && typeof checkpoint === "object" ? checkpoint.commit_sha : undefined;
3753
+ if (typeof commitSha !== "string" || !commitSha)
3754
+ return { commitSha: null, headSha: null, isAncestor: true, mismatchKind: "none" };
3755
+ let headSha = "";
3756
+ try {
3757
+ headSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
3758
+ }
3759
+ catch {
3760
+ headSha = "";
2832
3761
  }
3762
+ // repoRoot is not a git working tree at all (non-git scratch dir) or is a freshly-initialized
3763
+ // repo with no resolvable HEAD yet — "no opinion", never a mismatch (see doc comment above).
3764
+ if (!headSha)
3765
+ return { commitSha, headSha: null, isAncestor: true, mismatchKind: "none" };
3766
+ const classification = classifyAncestry(repoRoot, commitSha, headSha);
3767
+ const isAncestor = classification !== "not-ancestor";
3768
+ const mismatchKind = classification === "ancestor" ? "none" : classification === "not-ancestor" ? "positive" : "undeterminable-shallow";
3769
+ return { commitSha, headSha, isAncestor, mismatchKind };
2833
3770
  }
2834
3771
  catch {
2835
- // best-effort only — never affects ok/exit code.
3772
+ // best-effort only — a resolution failure must never be misread as a mismatch.
3773
+ return { commitSha: null, headSha: null, isAncestor: true, mismatchKind: "none" };
3774
+ }
3775
+ }
3776
+ /**
3777
+ * F4 (iteration-1): extracted from runReconcilePreflight so the main function stays under
3778
+ * the 50-line guideline. Q3 (optional, non-blocking): warn if the checkpoint's commit_sha is
3779
+ * not yet an ancestor of local HEAD — never affects ok/exit code, best-effort only. Delegates
3780
+ * to checkpointHeadAncestry (the shared comparison helper #413 Facet A's hard gate also uses)
3781
+ * so the warning path and the hard-refuse path can never independently drift. #413 iteration-2:
3782
+ * intentionally keeps using the plain `isAncestor` boolean (never `mismatchKind`) — a shallow
3783
+ * clone's undeterminable case still deserves this SAME staleness-flavored warning text, it just
3784
+ * must never become a hard refuse (see publishDelivery's own gate for that distinction).
3785
+ */
3786
+ function checkpointStalenessWarning(dir, repoRoot) {
3787
+ const warnings = [];
3788
+ const { commitSha, headSha, isAncestor } = checkpointHeadAncestry(dir, repoRoot);
3789
+ if (commitSha && headSha && !isAncestor) {
3790
+ warnings.push(`NON-BLOCKING: trust.checkpoint.json commit_sha '${commitSha}' is not an ancestor of local HEAD '${headSha}' — the checkpoint may be stale (e.g. after an amend/rebase). Consider re-sealing (seal-checkpoint) before publishing. This is a warning only — see #335 for the full checkpoint-staleness ownership check.`);
2836
3791
  }
2837
3792
  return warnings;
2838
3793
  }
@@ -2873,7 +3828,7 @@ export function runReconcilePreflight(dir, repoRoot, manifestOverride) {
2873
3828
  const { issues: sessionLocalIssues } = shape.sessionLocalShapeIssues(sessionLocal, derivedStatus, { onUnderivable: "reduce" });
2874
3829
  issues.push(...sessionLocalIssues);
2875
3830
  const report = issues.map((i) => `${i.message} — ${preflightFixHint(i.type)}`);
2876
- const warnings = checkpointStalenessWarning(dir, repoRoot, tr);
3831
+ const warnings = checkpointStalenessWarning(dir, repoRoot);
2877
3832
  return { ok: report.length === 0, issues: report, warnings };
2878
3833
  }
2879
3834
  /**
@@ -3329,52 +4284,47 @@ async function takeoverPreflightCmd(p) {
3329
4284
  // Called automatically from recordRelease and advanceState→delivered (best-effort).
3330
4285
  // Also exposed as the `publish-delivery <artifact-dir>` subcommand for explicit use.
3331
4286
  /**
3332
- * #379 supersede-on-publish cleanup: keep delivery/ bounded by pruning inherited PER-SESSION
3333
- * seal dirs (the growth vector), scoped to avoid any cross-PR conflict.
4287
+ * #413 Facet B: prune ONLY keepSlug's OWN prior seal — NEVER another session's
4288
+ * `delivery/<other-slug>/` directory.
3334
4289
  *
3335
- * An inherited per-session seal dir (`delivery/<other-slug>/`) is UNIQUELY named, so pruning
3336
- * it can never conflict with a concurrent PR: two branches deleting the same inherited dir is
3337
- * a delete/delete (auto-merges), and each new delivery adds its OWN distinct
3338
- * `delivery/<slug>/`. And it is HARMLESS to leave: trust-reconcile.js's prefer-newest
3339
- * ownership selection always picks THIS session's fresher bundle over an older inherited one.
3340
- * Pruning is therefore purely to stop unbounded accumulation of permanently-superseded dirs.
4290
+ * This function previously (#379) deleted ANY non-keepSlug directory under delivery/ that
4291
+ * "looked like a seal" (contained a trust.bundle or trust.checkpoint.json), on the theory that
4292
+ * an inherited per-session seal dir is harmless to leave and therefore harmless to delete too.
4293
+ * That theory was correct about harmlessness-if-left but did not follow that deletion was
4294
+ * therefore SAFE: issue #413's own corrected evidence reports this exact cross-slug loop
4295
+ * deleted a DIFFERENT, still-in-flight actor's active seal on a different branch
4296
+ * (`delivery/kontourai-flow-agents-349/`) — a real, observed destructive side effect with no
4297
+ * compensating correctness benefit. trust-reconcile.js's own PREFER-NEWEST doc comment
4298
+ * (resolveDeliveryCandidates, ~line 480-491) already establishes that leaving an
4299
+ * inherited/older seal in place is harmless — the reconciler always selects the newest owning
4300
+ * bundle regardless of what else is present — so there is no correctness reason to prune
4301
+ * ANOTHER session's seal at all, ever. The safest fix (and the one issue #413's own corrected
4302
+ * comment endorses — "never prune another session's active seal", no same-branch carve-out) is
4303
+ * to stop cross-slug pruning entirely, not merely narrow its blast radius.
3341
4304
  *
3342
- * The SHARED FLAT path (`delivery/trust.bundle` + checkpoints) is deliberately NOT pruned
3343
- * here. During the migration window other concurrent PRs may still seal to the flat path;
3344
- * deleting it would produce a modify/delete conflict a DIRTY PR → the no-CI failure this
3345
- * whole change fixes. The flat path is a single fixed location (not a growth vector) and the
3346
- * reconciler treats a stale flat bundle as non-owning / older-owning, so leaving it is safe.
3347
- * Removing the flat legacy seals is a one-time cleanup for a dedicated PR once no open PR
3348
- * still seals to it intentionally NOT bundled into every delivery.
4305
+ * The only pruning responsibility that legitimately remains here is keepSlug's OWN prior seal:
4306
+ * publishDelivery() is about to freshly (re)write `delivery/<keepSlug>/trust.bundle` and its
4307
+ * companions, but `fs.copyFileSync` only overwrites files that exist in the CURRENT session dir
4308
+ * a companion that existed in a PRIOR publish of this exact slug (e.g.
4309
+ * trust.checkpoint.sig.json) but is absent from this publish would otherwise linger stale
4310
+ * alongside the freshly-written files. Removing keepSlug's own existing directory before it is
4311
+ * recreated (by the caller, immediately after this call) avoids that same-slug staleness
4312
+ * without touching any OTHER slug's directory.
3349
4313
  *
3350
4314
  * Best-effort: a prune failure is logged, never fatal to the delivery. Never touches
3351
- * README.md, DECLARED, the flat seal files, or any subdir that is not itself a seal dir.
4315
+ * README.md, DECLARED, the flat seal files (delivery/trust.bundle at the shared flat path,
4316
+ * deliberately untouched — see the historical #379 rationale on the flat-path migration
4317
+ * window), or any directory other than keepSlug's own.
3352
4318
  */
3353
4319
  function pruneSupersededSeals(deliveryDir, keepSlug) {
3354
- let entries = [];
4320
+ const ownDir = path.join(deliveryDir, keepSlug);
4321
+ if (!fs.existsSync(ownDir))
4322
+ return; // nothing to prune yet — first publish for this slug.
3355
4323
  try {
3356
- entries = fs.readdirSync(deliveryDir, { withFileTypes: true });
4324
+ fs.rmSync(ownDir, { recursive: true, force: true });
3357
4325
  }
3358
- catch {
3359
- entries = [];
3360
- }
3361
- for (const entry of entries) {
3362
- if (!entry.isDirectory() || entry.name === keepSlug)
3363
- continue;
3364
- const subdir = path.join(deliveryDir, entry.name);
3365
- // Only prune dirs that actually look like a seal dir (contain a trust.bundle or
3366
- // trust.checkpoint.json) — never an unrelated directory a human placed under delivery/.
3367
- const looksLikeSeal = fs.existsSync(path.join(subdir, "trust.bundle")) ||
3368
- fs.existsSync(path.join(subdir, "trust.checkpoint.json"));
3369
- if (!looksLikeSeal)
3370
- continue;
3371
- try {
3372
- fs.rmSync(subdir, { recursive: true, force: true });
3373
- process.stderr.write(`[publish-delivery] #379: pruned superseded per-session seal delivery/${entry.name}/ (older-owning/stale; the reconciler selects the newest bundle regardless)\n`);
3374
- }
3375
- catch (err) {
3376
- process.stderr.write(`[publish-delivery] #379: could not prune per-session seal delivery/${entry.name}/ (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`);
3377
- }
4326
+ catch (err) {
4327
+ process.stderr.write(`[publish-delivery] #413: could not prune this session's own prior seal delivery/${keepSlug}/ before rewriting it (non-fatal, publish continues): ${err instanceof Error ? err.message : String(err)}\n`);
3378
4328
  }
3379
4329
  }
3380
4330
  /**
@@ -3413,17 +4363,44 @@ function pruneSupersededSeals(deliveryDir, keepSlug) {
3413
4363
  * which stay exactly as before (see each publishDelivery call site's catch handler for how
3414
4364
  * the distinction is preserved end-to-end).
3415
4365
  *
3416
- * #293 (SECOND, DISTINCT fail-closed gate): immediately after the shape check above and BEFORE
4366
+ * #293 (THIRD, DISTINCT fail-closed gate): immediately after the shape check above and BEFORE
3417
4367
  * writing anything into delivery/, runs `runVerifyHold()` — the assignment ⋈ liveness join
3418
4368
  * asking "is the calling actor the fresh, non-superseded holder of this subject (or is it
3419
4369
  * free/self-held)?" A not-fresh-holder result throws `NotFreshHolderError` (distinct from
3420
4370
  * `InvalidBundleShapeError` — a different `code`, a different failure mode: actor hold vs
3421
- * bundle shape). There are now THREE tiers here, and they must never be conflated in prose or
3422
- * in a call site's catch handler: (1) fail-SOFT bundle absence / repo-root resolution (silent
3423
- * no-op / visible warning, unchanged from before #356), (2) fail-CLOSED bundle shape (#356,
3424
- * `InvalidBundleShapeError`), (3) fail-CLOSED verify-hold (#293, `NotFreshHolderError`). The
3425
- * shape check runs first (per the plan's ordering) a bundle that is BOTH shape-invalid and
3426
- * not-held throws `InvalidBundleShapeError` specifically, never `NotFreshHolderError`.
4371
+ * bundle shape).
4372
+ *
4373
+ * #413 Facet A (FOURTH, DISTINCT fail-closed gate): immediately after the verify-hold gate and
4374
+ * BEFORE writing anything into delivery/, compares the resolved `repoRoot`'s `git rev-parse
4375
+ * HEAD` against the session's sealed `trust.checkpoint.json` `commit_sha` (via the shared
4376
+ * checkpointHeadAncestry() helper the SAME ancestor-check checkpointStalenessWarning already
4377
+ * used as a non-blocking warning). Throws `RepoHeadMismatchError` (distinct from both other
4378
+ * error tiers) instead of silently publishing into what is very likely the WRONG repo (e.g.
4379
+ * `repoRoot` resolved to the primary checkout via findRepoRootFromDirStrict's artifact-dir
4380
+ * walk, even though this session's checkpoint was sealed against a DIFFERENT worktree's HEAD).
4381
+ * A session with no checkpoint yet, or a checkpoint with no commit_sha, has nothing to compare
4382
+ * against and is unaffected (scoped strictly to the mismatch case, never a spurious refusal).
4383
+ *
4384
+ * #413 iteration-2 Fix 4 (see checkpointHeadAncestry's own doc comment for the full
4385
+ * `mismatchKind` classification this gate reads): only `mismatchKind === "positive"` (a
4386
+ * POSITIVELY-DETERMINABLE ancestry mismatch — the real wrong-tree harm case) hard refuses here.
4387
+ * `"undeterminable-shallow"` (e.g. a local shallow clone missing the commit_sha object) must
4388
+ * NEVER hard-refuse a legitimate publish — this branch only WARNS loudly and lets the shape +
4389
+ * verify-hold gates above continue to stand guard. A non-git `repoRoot` classifies as
4390
+ * `mismatchKind === "none"` (allow) — iteration-2's Fix 5 hard-refused this case too, but that
4391
+ * regressed the SUPPORTED non-git-repoRoot scratch/checkpoint-signing flow (see
4392
+ * evals/integration/test_checkpoint_signing.sh TEST 2) and was reverted in iteration-3. Wrong-tree
4393
+ * protection for that case relies on the shape-check and verify-hold gates above, which correctly
4394
+ * no-op (allow) when there is no git repo to inspect, same as this gate.
4395
+ *
4396
+ * There are now FOUR fail-closed/fail-soft TIERS in this function, and they must never be
4397
+ * conflated in prose or in a call site's catch handler: (1) fail-SOFT bundle absence /
4398
+ * repo-root resolution (silent no-op / visible warning, unchanged from before #356), (2)
4399
+ * fail-CLOSED bundle shape (#356, `InvalidBundleShapeError`), (3) fail-CLOSED verify-hold (#293,
4400
+ * `NotFreshHolderError`), (4) fail-CLOSED repo-HEAD-vs-checkpoint mismatch (#413 Facet A,
4401
+ * `RepoHeadMismatchError`) — see this tier's own gate below for the undeterminable-shallow
4402
+ * carve-out. Tiers run in this fixed order (shape, then verify-hold, then HEAD-mismatch); a
4403
+ * bundle that fails an earlier tier throws that tier's error specifically, never a later one's.
3427
4404
  */
3428
4405
  export async function publishDelivery(dir, repoRoot) {
3429
4406
  const bundleSrc = path.join(dir, "trust.bundle");
@@ -3433,10 +4410,8 @@ export async function publishDelivery(dir, repoRoot) {
3433
4410
  process.stderr.write(`[publish-delivery] WARNING: no kits/ ancestor found from ${dir}; skipping publish. Refusing to fall back to process.cwd() to avoid clobbering an unrelated repo's delivery/ seal. Pass --repo-root explicitly if this session dir is intentionally outside a repo checkout.\n`);
3434
4411
  return;
3435
4412
  }
3436
- // #356 AC6: fail-CLOSED on shape-invalidity — runs AFTER the fail-soft absence/repo-root
3437
- // guards above (both preserved unchanged) and BEFORE any copy into delivery/. Throws
3438
- // InvalidBundleShapeError (never a generic Error) so every call site can positively
3439
- // distinguish this from the failure modes they already tolerate as best-effort.
4413
+ // #356 AC6: fail-CLOSED on shape-invalidity — TIER 2 (see this function's doc comment above
4414
+ // for the full tier ordering). Throws InvalidBundleShapeError (never a generic Error).
3440
4415
  const preflight = runReconcilePreflight(dir, repoRoot);
3441
4416
  if (!preflight.ok) {
3442
4417
  process.stderr.write(`[publish-delivery] REFUSING to publish — trust.bundle failed the reconcile-preflight shape check (${preflight.issues.length} issue(s)):\n`);
@@ -3445,10 +4420,8 @@ export async function publishDelivery(dir, repoRoot) {
3445
4420
  }
3446
4421
  throw new InvalidBundleShapeError(preflight.issues);
3447
4422
  }
3448
- // #293: SECOND, DISTINCT fail-closed gateruns after the shape check above and before any
3449
- // copy into delivery/. Throws NotFreshHolderError (never conflated with InvalidBundleShapeError)
3450
- // so every call site can positively distinguish "not the fresh holder" from "bundle shape
3451
- // invalid".
4423
+ // #293: fail-CLOSED verify-holdTIER 3 (see this function's doc comment above). Throws
4424
+ // NotFreshHolderError (never conflated with InvalidBundleShapeError).
3452
4425
  const holdCheck = runVerifyHold(dir, repoRoot);
3453
4426
  if (!holdCheck.ok) {
3454
4427
  process.stderr.write(`[publish-delivery] REFUSING to publish — verify-hold gate: ${holdCheck.reason}\n`);
@@ -3456,6 +4429,20 @@ export async function publishDelivery(dir, repoRoot) {
3456
4429
  process.stderr.write(`[publish-delivery] - ${line}\n`);
3457
4430
  throw new NotFreshHolderError(holdCheck);
3458
4431
  }
4432
+ // #413 Facet A: FOURTH, DISTINCT fail-closed gate (see this function's doc comment above for
4433
+ // the full four-tier ordering, and checkpointHeadAncestry's doc comment for the
4434
+ // Fix 4 mismatchKind classification this reads).
4435
+ const ancestry = checkpointHeadAncestry(dir, repoRoot);
4436
+ if (ancestry.mismatchKind === "positive" && ancestry.commitSha && ancestry.headSha) {
4437
+ process.stderr.write(`[publish-delivery] REFUSING to publish — resolved repoRoot '${repoRoot}' HEAD '${ancestry.headSha}' is not an ancestor-or-equal of the sealed trust.checkpoint.json commit_sha '${ancestry.commitSha}'. This resolved repoRoot is very likely the WRONG checkout for this session (e.g. invoked from a worktree whose artifact dir physically lives under a different primary checkout). Pass --repo-root explicitly, or re-seal the checkpoint against this repoRoot if it really is the intended target.\n`);
4438
+ throw new RepoHeadMismatchError(repoRoot, ancestry.commitSha, ancestry.headSha);
4439
+ }
4440
+ if (ancestry.mismatchKind === "undeterminable-shallow" && ancestry.commitSha && ancestry.headSha) {
4441
+ // #413 iteration-2 Fix 4: NEVER hard-refuse here — a shallow clone (or any other case where
4442
+ // git cannot positively determine ancestry) must not spuriously block a legitimate publish.
4443
+ // The shape + verify-hold gates above already ran and passed; this is warn-and-allow only.
4444
+ process.stderr.write(`[publish-delivery] WARNING: could not determine whether trust.checkpoint.json commit_sha '${ancestry.commitSha}' is an ancestor of repoRoot '${repoRoot}' HEAD '${ancestry.headSha}' (e.g. a shallow clone missing the commit object) — allowing the publish since this is UNDETERMINABLE, not a positively-confirmed mismatch. The shape and verify-hold gates already passed.\n`);
4445
+ }
3459
4446
  const deliveryDir = path.join(repoRoot, "delivery");
3460
4447
  // #379: slug is the session artifact dir's basename — the same human-meaningful id used
3461
4448
  // throughout the session (.kontourai/flow-agents/<slug>/). The per-session dir NAME is only
@@ -3463,7 +4450,8 @@ export async function publishDelivery(dir, repoRoot) {
3463
4450
  const slug = path.basename(path.resolve(dir));
3464
4451
  const sessionDeliveryDir = path.join(deliveryDir, slug);
3465
4452
  fs.mkdirSync(deliveryDir, { recursive: true });
3466
- // Supersede inherited/flat seals BEFORE writing this session's dir (keepSlug = our own).
4453
+ // #413 Facet B: prune only THIS session's own prior seal (never another slug's) before
4454
+ // freshly rewriting delivery/<slug>/ below.
3467
4455
  pruneSupersededSeals(deliveryDir, slug);
3468
4456
  fs.mkdirSync(sessionDeliveryDir, { recursive: true });
3469
4457
  // Required: trust.bundle (the CI anchor)
@@ -3569,12 +4557,13 @@ async function recordLearning(p) {
3569
4557
  die("learning status learned requires every record to include correction.needed");
3570
4558
  writeJson(path.join(dir, "learning.json"), { ...sidecarBase(slug), status, updated_at: timestamp, records });
3571
4559
  writeState(dir, slug, "accepted", "learning", timestamp, opt(p, "summary"));
3572
- // Phase 4c: build bundle from raw inputs; read checks/critiques from trust.bundle (bespoke sidecars no longer written).
4560
+ // Phase 4c: build bundle from raw inputs; read checks/criteria/critiques via the shared
4561
+ // compose-safe readBundleState path (#270 LOW consolidation — this was the literal inline
4562
+ // pattern readBundleState was extracted FROM; using the shared helper directly keeps the two
4563
+ // from silently drifting apart in the future).
3573
4564
  // #268/#344: declared builder.* claims survive the round-trip via their authoritative origin stamp.
3574
- const _learningChecks = checksFromBundle(dir);
3575
- const _learningCriteria = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
3576
- const _learningCritiques = critiquesFromBundle(dir);
3577
- assertBundleWritten(await writeTrustBundle(dir, slug, timestamp, _learningChecks, _learningCriteria, _learningCritiques));
4565
+ const _learningState = readBundleState(dir);
4566
+ assertBundleWritten(await writeTrustBundle(dir, slug, timestamp, _learningState.checks, _learningState.criteria, _learningState.critiques));
3578
4567
  return 0;
3579
4568
  }
3580
4569
  function evidenceClean(dir) {
@@ -3656,7 +4645,16 @@ async function dogfoodPass(p) {
3656
4645
  assertExistingLearningValid(dir);
3657
4646
  const verdict = opt(p, "verdict");
3658
4647
  if (verdict === "pass") {
3659
- const checks = opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json")));
4648
+ // #270 follow-up fix (iteration 5, narrowed iteration 6): dogfood-pass's own pre-validation
4649
+ // normalizeCheck call (before delegating the actual write to recordEvidence below) must apply
4650
+ // the same narrowed existing-unstamped-id exemption recordEvidence/recordCheck do, or a
4651
+ // legitimate correction of a mis-recorded, UNSTAMPED gate-claim-* id would die HERE, before
4652
+ // ever reaching recordEvidence's own (already-fixed) guard — while supersession of a STAMPED
4653
+ // (live) gate claim id must still die here too. readBundleState is safe to call even when
4654
+ // trust.bundle does not yet exist (loadJson falls back to {}), exactly as
4655
+ // recordEvidence/recordCheck rely on.
4656
+ const _dogfoodExistingCheckStampById = existingCheckStampMap(readBundleState(dir).checks);
4657
+ const checks = opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"), false, _dogfoodExistingCheckStampById));
3660
4658
  if (checks.some((c) => c.status !== "pass" && c.status !== "skip"))
3661
4659
  die("clean evidence requires all non-skipped checks to pass");
3662
4660
  // Phase 4c: evidence check reads from trust.bundle (sole verification artifact); legacy evidence.json fallback in evidenceClean.
@@ -4672,9 +5670,27 @@ Available claim ids:
4672
5670
  }
4673
5671
  // ─────────────────────────────────────────────────────────────────────────────
4674
5672
  async function main() {
4675
- const p = parseArgs(process.argv.slice(2));
5673
+ const _rawArgv = process.argv.slice(2);
5674
+ // #380: `record-check <dir> -- <command...>` — argv after the FIRST literal `--` token is the
5675
+ // command to execute verbatim (never option-parsed: a command like `npm test -- --watch`
5676
+ // legitimately contains its OWN `--`, so only the record-check dispatcher's own separator, the
5677
+ // first one, is consumed here). parseArgs() cannot represent this (it treats every `--foo`-
5678
+ // shaped token as an option key, including a bare `--`), so the split happens here, before
5679
+ // parseArgs ever sees the command's own argv.
5680
+ let _commandArgv = null;
5681
+ let _argvForParse = _rawArgv;
5682
+ if (_rawArgv[0] === "record-check") {
5683
+ const sepIdx = _rawArgv.indexOf("--");
5684
+ if (sepIdx >= 0) {
5685
+ _argvForParse = _rawArgv.slice(0, sepIdx);
5686
+ _commandArgv = _rawArgv.slice(sepIdx + 1);
5687
+ }
5688
+ }
5689
+ const p = parseArgs(_argvForParse);
4676
5690
  if (!p.command)
4677
5691
  die("workflow-sidecar command is required");
5692
+ if (p.command === "ensure-session")
5693
+ preflightEnsureSession(p);
4678
5694
  // F1 (#166 fix iteration 1): `liveness whoami` is a read-only, lock-free, write-free advisory
4679
5695
  // surface (see the `action === "whoami"` branch inside `liveness()` above) — it must never
4680
5696
  // acquire the workflow-sidecar lock, regardless of whether the artifact root already exists on
@@ -4701,6 +5717,7 @@ async function main() {
4701
5717
  case "init-plan": return initPlan(p);
4702
5718
  case "record-evidence": return recordEvidence(p);
4703
5719
  case "record-gate-claim": return recordGateClaim(p);
5720
+ case "record-check": return recordCheck(p, _commandArgv);
4704
5721
  case "promote": return promote(p);
4705
5722
  case "advance-state": return advanceState(p);
4706
5723
  case "record-critique": return recordCritique(p);