@kontourai/flow-agents 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/.github/workflows/add-to-project.yml +15 -0
  2. package/.github/workflows/ci.yml +173 -0
  3. package/CHANGELOG.md +54 -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/assignment-provider.js +10 -1
  11. package/build/src/cli/builder-run.d.ts +1 -0
  12. package/build/src/cli/builder-run.js +27 -0
  13. package/build/src/cli/effective-backlog-settings.js +70 -2
  14. package/build/src/cli/init.d.ts +34 -0
  15. package/build/src/cli/init.js +341 -61
  16. package/build/src/cli/kit.js +55 -12
  17. package/build/src/cli/pull-work-provider.js +346 -5
  18. package/build/src/cli/skill-drift-check.d.ts +1 -0
  19. package/build/src/cli/skill-drift-check.js +165 -0
  20. package/build/src/cli/telemetry-doctor.d.ts +37 -0
  21. package/build/src/cli/telemetry-doctor.js +53 -6
  22. package/build/src/cli/validate-hook-influence.js +37 -7
  23. package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
  24. package/build/src/cli/workflow-sidecar.d.ts +310 -1
  25. package/build/src/cli/workflow-sidecar.js +1914 -126
  26. package/build/src/cli.js +5 -0
  27. package/build/src/flow-kit/validate.d.ts +54 -34
  28. package/build/src/flow-kit/validate.js +237 -26
  29. package/build/src/index.d.ts +2 -0
  30. package/build/src/index.js +1 -0
  31. package/build/src/lib/console-connect-options.d.ts +97 -0
  32. package/build/src/lib/console-connect-options.js +199 -0
  33. package/build/src/lib/console-telemetry-validate.d.ts +49 -0
  34. package/build/src/lib/console-telemetry-validate.js +91 -0
  35. package/build/src/lib/flow-resolver.d.ts +54 -1
  36. package/build/src/lib/flow-resolver.js +112 -5
  37. package/build/src/lib/fs.d.ts +17 -0
  38. package/build/src/lib/fs.js +172 -0
  39. package/build/src/lib/local-artifact-root.d.ts +44 -1
  40. package/build/src/lib/local-artifact-root.js +131 -3
  41. package/build/src/runtime-adapters.d.ts +39 -3
  42. package/build/src/runtime-adapters.js +77 -31
  43. package/build/src/tools/build-universal-bundles.js +40 -2
  44. package/build/src/tools/codex-agent-routing.d.ts +2 -0
  45. package/build/src/tools/codex-agent-routing.js +49 -0
  46. package/build/src/tools/generate-context-map.js +1 -0
  47. package/build/src/tools/validate-source-tree.js +30 -3
  48. package/context/contracts/artifact-contract.md +16 -2
  49. package/context/scripts/hooks/lib/kit-catalog.js +235 -0
  50. package/context/scripts/hooks/lib/runnable-command.js +177 -0
  51. package/context/scripts/hooks/stop-goal-fit.js +278 -48
  52. package/context/scripts/hooks/workflow-steering.js +194 -22
  53. package/context/scripts/package.json +3 -0
  54. package/context/scripts/telemetry/install-console-config.sh +25 -4
  55. package/context/scripts/telemetry/lib/config.sh +102 -12
  56. package/context/scripts/telemetry/lib/pricing.sh +50 -0
  57. package/context/scripts/telemetry/lib/session.sh +3 -0
  58. package/context/scripts/telemetry/lib/transport.sh +87 -0
  59. package/context/scripts/telemetry/lib/usage.sh +205 -4
  60. package/context/scripts/telemetry/telemetry.conf +6 -0
  61. package/context/scripts/telemetry/telemetry.sh +48 -0
  62. package/context/settings/workspace-backlog-provider-settings.example.json +48 -0
  63. package/docs/agent-usage-feedback-loop.md +35 -0
  64. package/docs/architecture-engine-and-kits.md +110 -0
  65. package/docs/context-map.md +2 -0
  66. package/docs/coordination-guide.md +370 -0
  67. package/docs/decisions/agent-coordination.md +26 -9
  68. package/docs/decisions/embeddable-engine.md +152 -0
  69. package/docs/decisions/index.md +5 -3
  70. package/docs/decisions/trust-ledger-retention.md +88 -0
  71. package/docs/decisions/trust-reconcile.md +42 -9
  72. package/docs/decisions/workflow-enforcement.md +31 -9
  73. package/docs/fixture-ownership.md +6 -2
  74. package/docs/implementing-trust-reconciliation.md +129 -0
  75. package/docs/index.md +23 -9
  76. package/docs/integrations/flow-agents-console.md +275 -0
  77. package/docs/integrations/index.md +4 -0
  78. package/docs/kit-authoring-guide.md +52 -21
  79. package/docs/spec/builder-flow-runtime.md +80 -0
  80. package/docs/spec/runtime-hook-surface.md +45 -1
  81. package/docs/specs/economics-record-contract.md +270 -0
  82. package/docs/specs/harness-capability-matrix.md +74 -0
  83. package/docs/specs/learning-review-proposals-contract.md +340 -0
  84. package/docs/specs/routing-efficiency-review.md +59 -0
  85. package/docs/verifiable-trust.md +74 -25
  86. package/docs/workflow-artifact-lifecycle.md +38 -1
  87. package/docs/workflow-usage-guide.md +10 -0
  88. package/evals/acceptance/prove-capture-teeth.sh +132 -0
  89. package/evals/ci/antigaming-suite.sh +2 -0
  90. package/evals/ci/run-baseline.sh +78 -4
  91. package/evals/fixtures/economics/acceptance.json +12 -0
  92. package/evals/fixtures/economics/agents/tool-worker-1/events.jsonl +2 -0
  93. package/evals/fixtures/economics/agents/tool-worker-2/events.jsonl +2 -0
  94. package/evals/fixtures/economics/agents/tool-worker-3/events.jsonl +2 -0
  95. package/evals/fixtures/economics/agents/tool-worker-4/events.jsonl +1 -0
  96. package/evals/fixtures/economics/agents/tool-worker-5/events.jsonl +2 -0
  97. package/evals/fixtures/economics/critique.json +22 -0
  98. package/evals/fixtures/economics/expected-record.json +71 -0
  99. package/evals/fixtures/economics/session-usage-event.json +1 -0
  100. package/evals/fixtures/economics/state.json +11 -0
  101. package/evals/fixtures/economics/transcript.jsonl +3 -0
  102. package/evals/fixtures/hook-influence/cases.json +7 -7
  103. package/evals/fixtures/learning-review-proposals/balanced/economics.jsonl +6 -0
  104. package/evals/fixtures/learning-review-proposals/effect-follow-up/economics.jsonl +5 -0
  105. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-1/trust.bundle +21 -0
  106. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-2/trust.bundle +21 -0
  107. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-3/trust.bundle +21 -0
  108. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-4/trust.bundle +21 -0
  109. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-5/trust.bundle +21 -0
  110. package/evals/fixtures/learning-review-proposals/pattern-present/economics.jsonl +6 -0
  111. package/evals/fixtures/learning-review-proposals/pattern-present/expected-aggregates.json +30 -0
  112. package/evals/fixtures/learning-review-proposals/pattern-present/expected-aggregates.md +66 -0
  113. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-1/gate-review.inquiries.json +26 -0
  114. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-1/trust.bundle +21 -0
  115. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-2/gate-review.inquiries.json +26 -0
  116. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-2/trust.bundle +21 -0
  117. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-3/gate-review.inquiries.json +26 -0
  118. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-3/trust.bundle +21 -0
  119. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-4/gate-review.inquiries.json +26 -0
  120. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-4/trust.bundle +21 -0
  121. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-5/trust.bundle +21 -0
  122. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-6/trust.bundle +21 -0
  123. package/evals/fixtures/learning-review-proposals/repeat-window/economics.jsonl +6 -0
  124. package/evals/fixtures/learning-review-proposals/under-threshold/economics.jsonl +3 -0
  125. package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
  126. package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
  127. package/evals/fixtures/telemetry/usage-transcript-sample.jsonl +4 -0
  128. package/evals/fixtures/trust-reconcile-exploits/mcp-degrade.json +42 -0
  129. package/evals/integration/test_builder_entry_enforcement.sh +241 -0
  130. package/evals/integration/test_builder_step_producers.sh +18 -10
  131. package/evals/integration/test_bundle_install.sh +172 -0
  132. package/evals/integration/test_checkpoint_signing.sh +10 -2
  133. package/evals/integration/test_ci_actor_identity.sh +221 -0
  134. package/evals/integration/test_console_tenant_isolation.sh +167 -0
  135. package/evals/integration/test_critique_supersession_roundtrip.sh +4 -1
  136. package/evals/integration/test_dual_emit_flow_step.sh +10 -4
  137. package/evals/integration/test_economics_record.sh +674 -0
  138. package/evals/integration/test_effective_backlog_settings.sh +1 -1
  139. package/evals/integration/test_evidence_capture_hook.sh +17 -2
  140. package/evals/integration/test_exemption_usage_review.sh +198 -0
  141. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  142. package/evals/integration/test_flow_kit_install_git.sh +83 -0
  143. package/evals/integration/test_flowdef_session_activation.sh +0 -1
  144. package/evals/integration/test_flowdef_session_history_preservation.sh +13 -3
  145. package/evals/integration/test_gate_lockdown.sh +7 -0
  146. package/evals/integration/test_gate_review_inquiry_records.sh +9 -1
  147. package/evals/integration/test_goal_fit_hook.sh +2031 -0
  148. package/evals/integration/test_hook_category_behaviors.sh +8 -1
  149. package/evals/integration/test_hook_influence_cases.sh +25 -1
  150. package/evals/integration/test_install_merge.sh +227 -2
  151. package/evals/integration/test_kit_conformance_levels.sh +6 -6
  152. package/evals/integration/test_learning_review_proposals.sh +329 -0
  153. package/evals/integration/test_liveness_conflict_injection.sh +26 -22
  154. package/evals/integration/test_liveness_console_relay.sh +166 -0
  155. package/evals/integration/test_liveness_heartbeat.sh +17 -17
  156. package/evals/integration/test_liveness_worktree_root.sh +575 -0
  157. package/evals/integration/test_phase_map_and_gate_claim.sh +6 -1
  158. package/evals/integration/test_publish_delivery.sh +389 -2
  159. package/evals/integration/test_pull_work_board.sh +200 -0
  160. package/evals/integration/test_pull_work_provider.sh +1 -1
  161. package/evals/integration/test_reconcile_preflight.sh +304 -0
  162. package/evals/integration/test_record_check.sh +378 -0
  163. package/evals/integration/test_routing_efficiency.sh +71 -0
  164. package/evals/integration/test_runtime_adapter_activation.sh +28 -0
  165. package/evals/integration/test_session_resume_roundtrip.sh +16 -19
  166. package/evals/integration/test_skill_drift_check.sh +870 -0
  167. package/evals/integration/test_takeover_protocol.sh +340 -0
  168. package/evals/integration/test_telemetry.sh +445 -0
  169. package/evals/integration/test_telemetry_doctor.sh +66 -0
  170. package/evals/integration/test_telemetry_usage_pipeline.sh +228 -0
  171. package/evals/integration/test_trust_reconcile_negatives.sh +121 -13
  172. package/evals/integration/test_trust_reconcile_trailer_diagnostic.sh +247 -0
  173. package/evals/integration/test_usage_cost.sh +61 -0
  174. package/evals/integration/test_verify_hold.sh +910 -0
  175. package/evals/integration/test_veritas_governance_kit.sh +257 -0
  176. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
  177. package/evals/integration/test_workflow_sidecar_writer.sh +1395 -0
  178. package/evals/integration/test_workflow_steering_hook.sh +157 -16
  179. package/evals/integration/test_workspace_settings.sh +176 -0
  180. package/evals/lib/env.sh +26 -0
  181. package/evals/lib/node.sh +8 -0
  182. package/evals/run.sh +37 -0
  183. package/evals/static/test_ci_integration_coverage.sh +115 -0
  184. package/evals/static/test_declared_scope_forms_documented.sh +114 -0
  185. package/evals/static/test_universal_bundles.sh +34 -0
  186. package/evals/static/test_validate_source_kit_asset_scope.sh +259 -0
  187. package/evals/static/test_workflow_skills.sh +1 -1
  188. package/kits/builder/flows/build.flow.json +9 -18
  189. package/kits/builder/flows/publish-learn.flow.json +5 -1
  190. package/kits/builder/kit.json +120 -0
  191. package/kits/builder/skills/continue-work/SKILL.md +2 -0
  192. package/kits/builder/skills/deliver/SKILL.md +115 -0
  193. package/kits/builder/skills/evidence-gate/SKILL.md +12 -0
  194. package/kits/builder/skills/execute-plan/SKILL.md +9 -0
  195. package/kits/builder/skills/learning-review/SKILL.md +51 -0
  196. package/kits/builder/skills/plan-work/SKILL.md +17 -20
  197. package/kits/builder/skills/pull-work/SKILL.md +33 -2
  198. package/kits/builder/skills/release-readiness/SKILL.md +12 -0
  199. package/kits/knowledge/kit.json +9 -0
  200. package/kits/veritas-governance/docs/README.md +113 -7
  201. package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
  202. package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
  203. package/kits/veritas-governance/fixtures/exemption-review/mixed-fresh-stale.DECLARED.json +14 -0
  204. package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
  205. package/kits/veritas-governance/kit.json +19 -0
  206. package/kits/veritas-governance/skills/exemption-usage-review/SKILL.md +128 -0
  207. package/kits/veritas-governance/skills/exemption-usage-review/review-exemptions.mjs +231 -0
  208. package/package.json +2 -2
  209. package/packaging/manifest.json +29 -0
  210. package/schemas/backlog-provider-settings.schema.json +13 -0
  211. package/schemas/workflow-state.schema.json +44 -0
  212. package/scripts/README.md +4 -0
  213. package/scripts/check-content-boundary.cjs +8 -1
  214. package/scripts/ci/trust-reconcile.js +214 -253
  215. package/scripts/hooks/codex-hook-adapter.js +77 -2
  216. package/scripts/hooks/evidence-capture.js +38 -5
  217. package/scripts/hooks/lib/actor-identity.js +82 -0
  218. package/scripts/hooks/lib/codex-exit-code.js +316 -0
  219. package/scripts/hooks/lib/kit-catalog.js +235 -0
  220. package/scripts/hooks/lib/liveness-write.js +28 -1
  221. package/scripts/hooks/lib/local-artifact-paths.js +97 -1
  222. package/scripts/hooks/lib/runnable-command.js +177 -0
  223. package/scripts/hooks/lib/skill-drift.js +350 -0
  224. package/scripts/hooks/stop-goal-fit.js +278 -48
  225. package/scripts/hooks/workflow-steering.js +194 -22
  226. package/scripts/install-codex-home.sh +97 -47
  227. package/scripts/install-merge.js +72 -14
  228. package/scripts/install-owned-files.js +178 -0
  229. package/scripts/lib/reconcile-shape.js +381 -0
  230. package/scripts/liveness/relay.sh +84 -0
  231. package/scripts/telemetry/economics-record.schema.json +145 -0
  232. package/scripts/telemetry/economics-record.sh +331 -0
  233. package/scripts/telemetry/install-console-config.sh +25 -4
  234. package/scripts/telemetry/learning-review-decide.sh +124 -0
  235. package/scripts/telemetry/learning-review-proposals.schema.json +161 -0
  236. package/scripts/telemetry/learning-review-proposals.sh +484 -0
  237. package/scripts/telemetry/lib/config.sh +102 -12
  238. package/scripts/telemetry/lib/pricing.sh +14 -6
  239. package/scripts/telemetry/lib/session.sh +3 -0
  240. package/scripts/telemetry/lib/transport.sh +133 -15
  241. package/scripts/telemetry/lib/usage.sh +121 -28
  242. package/scripts/telemetry/routing-efficiency.sh +0 -0
  243. package/scripts/telemetry/telemetry.conf +6 -0
  244. package/scripts/telemetry/telemetry.sh +48 -0
  245. package/src/builder-flow-run-adapter.ts +357 -0
  246. package/src/builder-flow-runtime.ts +348 -0
  247. package/src/cli/assignment-provider.ts +12 -1
  248. package/src/cli/builder-flow-run-adapter.test.mjs +495 -0
  249. package/src/cli/builder-flow-runtime.test.mjs +213 -0
  250. package/src/cli/builder-run.ts +28 -0
  251. package/src/cli/codex-agent-routing.test.mjs +44 -0
  252. package/src/cli/codex-exit-code.test.mjs +207 -0
  253. package/src/cli/console-connect-options.test.mjs +329 -0
  254. package/src/cli/console-telemetry-validate.test.mjs +157 -0
  255. package/src/cli/effective-backlog-settings.ts +68 -2
  256. package/src/cli/flow-resolver-composition.test.mjs +72 -0
  257. package/src/cli/init.test.mjs +161 -0
  258. package/src/cli/init.ts +407 -62
  259. package/src/cli/kit-metadata-security.test.mjs +443 -0
  260. package/src/cli/kit.ts +50 -12
  261. package/src/cli/pull-work-provider.ts +377 -3
  262. package/src/cli/sidecar-pure-helpers.test.mjs +64 -0
  263. package/src/cli/skill-drift-check.ts +196 -0
  264. package/src/cli/telemetry-doctor.test.mjs +53 -0
  265. package/src/cli/telemetry-doctor.ts +50 -7
  266. package/src/cli/validate-hook-influence.ts +37 -6
  267. package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
  268. package/src/cli/workflow-sidecar.ts +1980 -119
  269. package/src/cli.ts +5 -0
  270. package/src/flow-kit/validate.ts +277 -38
  271. package/src/index.ts +19 -0
  272. package/src/lib/console-connect-options.ts +261 -0
  273. package/src/lib/console-telemetry-validate.ts +88 -0
  274. package/src/lib/flow-resolver.ts +117 -4
  275. package/src/lib/fs.ts +160 -0
  276. package/src/lib/local-artifact-root.ts +129 -3
  277. package/src/runtime-adapters.ts +113 -33
  278. package/src/tools/build-universal-bundles.ts +36 -2
  279. package/src/tools/codex-agent-routing.ts +48 -0
  280. package/src/tools/generate-context-map.ts +1 -0
  281. package/src/tools/validate-source-tree.ts +29 -3
@@ -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, type ActiveFlowStep } from "../lib/flow-resolver.js";
10
+ import { resolveActiveFlowStep, resolveAllFlowGateExpects, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy, type ActiveFlowStep } 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
@@ -56,6 +57,40 @@ function workItemSlug(ref: string): string {
56
57
  return slugify(`${owner}-${repo}-${id}`, "work-item");
57
58
  }
58
59
 
60
+ type SessionWorkItem = {
61
+ ref: string;
62
+ localRecord?: AnyObj;
63
+ };
64
+
65
+ function sessionWorkItem(p: ReturnType<typeof parseArgs>, slug: string, dir: string): SessionWorkItem {
66
+ const providerRef = opt(p, "work-item");
67
+ if (providerRef) {
68
+ return { ref: providerRef };
69
+ }
70
+
71
+ const existingState = loadJson(path.join(dir, "state.json"));
72
+ if (Array.isArray(existingState.work_item_refs) && typeof existingState.work_item_refs[0] === "string") {
73
+ return { ref: existingState.work_item_refs[0] };
74
+ }
75
+
76
+ const title = opt(p, "title", slug).trim() || slug;
77
+ const body = opt(p, "summary").trim();
78
+ return {
79
+ ref: `local:${slug}`,
80
+ localRecord: {
81
+ id: slug,
82
+ title,
83
+ ...(body ? { body } : {}),
84
+ status: "ready",
85
+ source_provider: {
86
+ kind: "local",
87
+ path: "work-item.json",
88
+ },
89
+ artifact_refs: ["state.json", "handoff.json"],
90
+ },
91
+ };
92
+ }
93
+
59
94
  /** Pure, lock-free, side-effect-free CLI wrapper around workItemSlug() — the single source of
60
95
  * truth for the deterministic subjectId/session-directory-name slug. Named resolveSlugCmd (not
61
96
  * resolveSlug) to avoid colliding with any future export named resolveSlug. */
@@ -219,9 +254,17 @@ function resolveEnsureSessionActor(p: ReturnType<typeof parseArgs>): { actorStru
219
254
  return { actorStruct: { runtime: "unresolved", session_id: branchActorKey, host: os.hostname() }, actorKey: resolved.actor, branchActorKey, unresolved: true };
220
255
  }
221
256
 
257
+ // #398: the CI-runtime tier must reconstruct the SAME struct resolveActor serialized, or
258
+ // `serializeActor(actorStruct)` would diverge from `resolved.actor` (the else-branch would rebuild
259
+ // an ANCESTRY struct — detectRuntime→unknown, runtimeSessionId→'' — so the claim's stored
260
+ // actor_key would not match the CI actor at publish → self not recognized → false-block, the exact
261
+ // bug this issue removes). Uses the SAME helper.detectCiActor as resolveActor, single-sourced.
262
+ const ciActor = resolved.source.startsWith("ci-runtime") ? helper.detectCiActor(process.env) : null;
222
263
  const actorStruct: ActorStruct = resolved.source === "explicit-override"
223
264
  ? { runtime: "explicit-override", session_id: resolved.actor, host: os.hostname() }
224
- : { runtime: helper.detectRuntime(process.env), session_id: helper.runtimeSessionId(process.env) || (() => { const seed = helper.ancestorActorSeed(); return seed ? `anc-${seed}` : ""; })(), host: os.hostname() };
265
+ : ciActor && ciActor.session_id
266
+ ? { runtime: ciActor.runtime, session_id: ciActor.session_id, host: os.hostname() }
267
+ : { runtime: helper.detectRuntime(process.env), session_id: helper.runtimeSessionId(process.env) || (() => { const seed = helper.ancestorActorSeed(); return seed ? `anc-${seed}` : ""; })(), host: os.hostname() };
225
268
  const actorKey = helper.serializeActor(actorStruct);
226
269
  return { actorStruct, actorKey, branchActorKey, unresolved: false };
227
270
  }
@@ -508,6 +551,62 @@ function critiqueToEventStatus(verdict: string, findings: AnyObj[]): string | nu
508
551
  return null; // not_verified or unknown → no event → Surface returns "unknown"
509
552
  }
510
553
 
554
+ /**
555
+ * Fold a raw command-log (command-log.jsonl entries) into a per-command classification,
556
+ * keyed by normalized command text (whitespace-collapsed, trimmed). Pure and side-effect-free
557
+ * so it is directly unit-testable (#470 iteration 2, finding #2 — HIGH: the prior inline reducer
558
+ * collapsed every non-"fail" `observedResult` — including "ambiguous" — to "pass", feeding a
559
+ * verified event + `passing:true` and reintroducing the #470 false-pass through the trust-bundle
560
+ * reconciliation path).
561
+ *
562
+ * Three-way classification, keyed on `observedResult` (never re-derived from `exitCode` alone,
563
+ * which would miscoerce the #362 grep/diff absence carve-out `ambiguous,exitCode:1` entry to
564
+ * `fail`):
565
+ * - "fail" when `observedResult==="fail"`, or (legacy, no observedResult) a nonzero
566
+ * integer `exitCode`.
567
+ * - "ambiguous" when `observedResult==="ambiguous"`, or (legacy) `exitCode` is `null` with no
568
+ * fail signal.
569
+ * - "pass" when `observedResult==="pass"`, or (legacy) `exitCode===0`.
570
+ *
571
+ * Precedence across repeated entries for the same command: fail > pass > ambiguous. A genuine
572
+ * exit-0 pass is positive evidence and confirms; ambiguous holds only when there is neither a
573
+ * fail nor a positive pass, since a "pass" always requires positive evidence.
574
+ *
575
+ * The caller (buildTrustBundle) maps "ambiguous" onto the existing canonical non-confirming
576
+ * status `not_verified` — consume-never-fork, mirroring record-check's identical mapping — so
577
+ * `checkStatusToEventStatus("not_verified")` returns null (no verification event emitted) and the
578
+ * evidence item is stamped `passing:false`. See Decision/finding #2 in the iteration-2 plan.
579
+ */
580
+ export function reduceCaptureLogByCommand(commandLog: AnyObj[] | undefined): Map<string, { observedResult: "pass" | "fail" | "ambiguous"; exitCode: number | null }> {
581
+ const captureByCommand = new Map<string, { observedResult: "pass" | "fail" | "ambiguous"; exitCode: number | null }>();
582
+ for (const entry of Array.isArray(commandLog) ? commandLog : []) {
583
+ if (!entry || typeof entry.command !== "string") continue;
584
+ const key = entry.command.replace(/\s+/g, " ").trim();
585
+ if (!key) continue;
586
+ const exitCode = Number.isInteger(entry.exitCode) ? (entry.exitCode as number) : null;
587
+ let result: "pass" | "fail" | "ambiguous";
588
+ if (entry.observedResult === "fail" || (entry.observedResult === undefined && exitCode !== null && exitCode !== 0)) {
589
+ result = "fail";
590
+ } else if (entry.observedResult === "pass" || (entry.observedResult === undefined && exitCode === 0)) {
591
+ result = "pass";
592
+ } else {
593
+ // Covers observedResult==="ambiguous" AND the legacy no-observedResult, exitCode:null case.
594
+ result = "ambiguous";
595
+ }
596
+ const prev = captureByCommand.get(key);
597
+ let merged: "pass" | "fail" | "ambiguous" = result;
598
+ if (prev) {
599
+ // fail > pass > ambiguous precedence.
600
+ if (prev.observedResult === "fail" || result === "fail") merged = "fail";
601
+ else if (prev.observedResult === "pass" || result === "pass") merged = "pass";
602
+ else merged = "ambiguous";
603
+ }
604
+ const mergedExitCode = exitCode !== null ? exitCode : (prev ? prev.exitCode : null);
605
+ captureByCommand.set(key, { observedResult: merged, exitCode: mergedExitCode });
606
+ }
607
+ return captureByCommand;
608
+ }
609
+
511
610
  /**
512
611
  * Build a Hachure trust.bundle from raw check/criterion/critique inputs.
513
612
  * trust.bundle is the PRIMARY artifact (ADR 0010 Phase 4a producer inversion).
@@ -536,6 +635,114 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
536
635
  // legacy-fallback current.json read; omitted, this is IDENTICAL to pre-#291 behavior.
537
636
  const activeStep: ActiveFlowStep | null = flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir, actorKey) : null;
538
637
 
638
+ // #270 CRITICAL/HIGH fix: resolve the session's active_flow_id independent of whether the
639
+ // CURRENTLY-active step resolves — a stamped gate claim names the STEP IT WAS ORIGINALLY
640
+ // RECORDED AT (frozen in metadata.gate_claim.step_id), which is routinely a DIFFERENT step than
641
+ // whatever is active now. Validating the stamp against only the currently-active step's
642
+ // expects[] would reject every honest round-trip the moment the session advances past the step
643
+ // the gate claim was recorded at — the validation must be against the FULL flow definition
644
+ // (every step's gate expects[], via resolveAllFlowGateExpects), not just the active one.
645
+ const sessionFlowId: string | null = (() => {
646
+ if (!flowAgentsDir) return null;
647
+ try {
648
+ const pointer = loadCurrentPointerHelper().readCurrentPointer(flowAgentsDir, actorKey);
649
+ const payload = pointer.payload;
650
+ const fid = payload && typeof payload["active_flow_id"] === "string" ? payload["active_flow_id"] : null;
651
+ return fid && fid.length > 0 ? fid : null;
652
+ } catch {
653
+ return null;
654
+ }
655
+ })();
656
+ const workflowSubjectRef: string | null = (() => {
657
+ if (!flowAgentsDir) return null;
658
+ try {
659
+ const state = loadJson(path.join(flowAgentsDir, slug, "state.json"));
660
+ const refs = Array.isArray(state.work_item_refs) ? state.work_item_refs : [];
661
+ return refs.length === 1 && typeof refs[0] === "string" && refs[0].length > 0 ? refs[0] : null;
662
+ } catch {
663
+ return null;
664
+ }
665
+ })();
666
+ // repoRoot resolution mirrors resolveActiveFlowStep's own internal findRepoRoot(path.dirname(
667
+ // flowAgentsDir)) call exactly (flow-resolver.ts) — findRepoRootFromDir is this file's
668
+ // fallback-to-cwd equivalent of that unexported helper. NOTE (#270 MEDIUM fix, iteration 3):
669
+ // findRepoRootFromDir ALWAYS returns a truthy string (it falls back to process.cwd() — see
670
+ // findRepoRootFromDir's own doc comment above) whenever flowAgentsDir is set, so `flowRepoRoot`
671
+ // is only ever null when `!flowAgentsDir`. It can therefore never by itself signal a FlowDefinition
672
+ // load failure; that signal now comes from resolveAllFlowGateExpects's own null return (below).
673
+ const flowRepoRoot: string | null = flowAgentsDir ? findRepoRootFromDir(path.dirname(flowAgentsDir)) : null;
674
+ // Built lazily (only when at least one check actually carries a metadata.gate_claim stamp or
675
+ // gate-claim shape to validate) and cached across every check in this call's loop — the
676
+ // FlowDefinition file is read once per buildTrustBundle call, not once per claim.
677
+ //
678
+ // #270 MEDIUM fix (iteration 3): tri-state cache — `undefined` (not yet built), `null` (the
679
+ // FlowDefinition could not be loaded/parsed — see resolveAllFlowGateExpects's doc comment),
680
+ // or an array (loaded; possibly genuinely empty). This lets assertStampedGateClaimValid tell
681
+ // "cannot load the flow definition" apart from "loaded fine, tuple just doesn't match" — the
682
+ // previous code collapsed both into `[]`, which always failed the `.some()` match below and
683
+ // was misreported as "forged or corrupt" even when the real cause was an unloadable
684
+ // FlowDefinition (e.g. a bogus/renamed active_flow_id, or a `kits/` path that no longer
685
+ // resolves) — a `!flowRepoRoot` check can never catch this because flowRepoRoot is always
686
+ // truthy here (see the note above), so that check was dead code against this failure mode.
687
+ let _allGateExpectsCache: import("../lib/flow-resolver.js").FlowGateExpectsEntry[] | null | undefined = undefined;
688
+ const allGateExpectsForSession = (): import("../lib/flow-resolver.js").FlowGateExpectsEntry[] | null => {
689
+ if (_allGateExpectsCache !== undefined) return _allGateExpectsCache;
690
+ _allGateExpectsCache = (sessionFlowId && flowRepoRoot) ? resolveAllFlowGateExpects(sessionFlowId, flowRepoRoot) : null;
691
+ return _allGateExpectsCache;
692
+ };
693
+ /**
694
+ * Validate a RESTORED metadata.gate_claim stamp's (expectation_id, claim_type, subject_type,
695
+ * step_id) tuple against the session's FULL flow definition (every step's gate expects[], not
696
+ * just the currently-active step). This is the honest round-trip check: a claim recorded by
697
+ * record-gate-claim/buildTrustBundle can only ever carry a tuple that matches some real
698
+ * expects[] entry, because that is the only place the stamp is ever written (see the
699
+ * declaredMetadata.gate_claim assembly below). A tuple that does NOT match a real expectation is
700
+ * either FORGED (a hand-edited/attacker-supplied metadata.gate_claim) or CORRUPT — never a
701
+ * legitimate state this code can produce — so it dies loudly naming the claim id and the
702
+ * invalid stamp. It must NEVER fall through to matchExpectsEntry: that silent fall-through
703
+ * IS the re-typing bug this fix closes (a forged/corrupt stamp would otherwise get a fresh,
704
+ * heuristic-derived typing instead of being rejected).
705
+ *
706
+ * Accepted residual (#270, documented not fixed — iteration 3): this check validates
707
+ * STRUCTURAL conformance of the stamp's tuple against the flow definition's declared
708
+ * expects[] shape — it does not (and cannot, at this layer) verify WHO produced the stamp. A
709
+ * stamp that reuses a REAL (expectation_id, claim_type, subject_type, step_id) tuple copied
710
+ * from a legitimately-earned claim will PASS this validation, because it is, structurally,
711
+ * indistinguishable from an honest one. This is an accepted floor equal to the pre-existing
712
+ * trust boundary: any local process with filesystem access to this session already has an
713
+ * equivalent capability by invoking record-gate-claim directly. This validator closes the
714
+ * STRICTLY WORSE case — a forged tuple that does NOT correspond to any real expects[] entry,
715
+ * or a stamp silently re-typed via matchExpectsEntry's heuristic fallback — not general
716
+ * authorship/provenance binding. Provenance binding (e.g. cryptographically tying a stamp to
717
+ * the actor/process that recorded it) is future work; see issue #270's review thread.
718
+ *
719
+ * #270 MEDIUM fix (iteration 3): a DIFFERENT failure class — the FlowDefinition genuinely
720
+ * cannot be loaded/parsed (missing kits/ file, unreadable, invalid JSON, or a since-renamed/
721
+ * bogus active_flow_id) — must NEVER be reported as "forged or corrupt"; that message asserts
722
+ * the stamp itself is untrustworthy, which is not what happened here. This case dies with its
723
+ * own dedicated, distinctly-worded message instead.
724
+ */
725
+ function assertStampedGateClaimValid(claimId: string, stamp: { expectationId: string; claimType: string; subjectType: string; stepId: string | null }): void {
726
+ if (!sessionFlowId) {
727
+ 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.`);
728
+ }
729
+ const allExpects = allGateExpectsForSession();
730
+ if (allExpects === null) {
731
+ 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.`);
732
+ }
733
+ const match = allExpects.some((entry) =>
734
+ entry.gateExpects.some((exp) =>
735
+ exp.id === stamp.expectationId
736
+ && exp.bundle_claim.claimType === stamp.claimType
737
+ && exp.bundle_claim.subjectType === stamp.subjectType
738
+ && (stamp.stepId === null || stamp.stepId === entry.stepId),
739
+ ),
740
+ );
741
+ if (!match) {
742
+ 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.`);
743
+ }
744
+ }
745
+
539
746
  const claims: AnyObj[] = [];
540
747
  const evidenceItems: AnyObj[] = [];
541
748
  const events: AnyObj[] = [];
@@ -568,17 +775,15 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
568
775
  return p;
569
776
  };
570
777
 
571
- // Index the deterministic capture log by normalized command (a single FAIL wins),
572
- // so a claimed-pass check whose command actually FAILED becomes authoritative here.
573
- const captureByCommand = new Map<string, { observedResult: string; exitCode: number | null }>();
574
- for (const entry of Array.isArray(commandLog) ? commandLog : []) {
575
- if (!entry || typeof entry.command !== "string") continue;
576
- const key = entry.command.replace(/\s+/g, " ").trim();
577
- if (!key) continue;
578
- const failed = entry.observedResult === "fail" || (Number.isInteger(entry.exitCode) && entry.exitCode !== 0);
579
- const prev = captureByCommand.get(key);
580
- captureByCommand.set(key, { observedResult: failed || (prev && prev.observedResult === "fail") ? "fail" : "pass", exitCode: Number.isInteger(entry.exitCode) ? entry.exitCode : (prev ? prev.exitCode : null) });
581
- }
778
+ // Index the deterministic capture log by normalized command (fail > pass > ambiguous
779
+ // precedence wins across repeated entries for the same command), so a claimed-pass check
780
+ // whose command actually FAILED (or never produced usable signal) becomes authoritative here.
781
+ // Extracted to reduceCaptureLogByCommand (below) for direct unit testing (#470 iteration 2,
782
+ // finding #2): the three-way classification is keyed on `observedResult`, NEVER re-derived
783
+ // from `exitCode` alone, so an `ambiguous,exitCode:1` entry (the #362 grep/diff absence
784
+ // carve-out) is never miscoerced to `fail` here, and a no-signal `ambiguous` (exitCode:null)
785
+ // is never coerced to `pass`.
786
+ const captureByCommand = reduceCaptureLogByCommand(commandLog);
582
787
 
583
788
  // ─── P-b dual-emit helper ──────────────────────────────────────────────────
584
789
  // Semantic matching table (ADR 0016 Abstraction A P-b):
@@ -691,18 +896,75 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
691
896
  ? { evidenceType: "attestation", method: "attestation", reconcilable: false }
692
897
  : classifyEvidence(check.kind, cmd.length > 0);
693
898
  const policy = ensurePolicy(legacyClaimType, "high", [evClass.evidenceType]);
694
- const effectiveStatus = captured ? captured.observedResult : String(check.status ?? "");
899
+ // #470 iteration 2, finding #2: an "ambiguous" capture (no positive pass/fail signal —
900
+ // e.g. the codex no-signal default, or the #362 grep/diff absence carve-out) is
901
+ // non-confirming and must never be surfaced as a claim value of "pass". Map it onto the
902
+ // EXISTING canonical non-confirming status "not_verified" (consume-never-fork; the same
903
+ // mapping record-check already applies at its `ambiguous` branch above) so
904
+ // checkStatusToEventStatus("not_verified") returns null — no verification event, and the
905
+ // evidence item below is stamped passing:false. `isError` stays fail-only (ambiguous is
906
+ // not an error).
907
+ const effectiveStatus = captured ? (captured.observedResult === "ambiguous" ? "not_verified" : captured.observedResult) : String(check.status ?? "");
695
908
  const evStatus = waiver ? "assumed" : checkStatusToEventStatus(effectiveStatus);
696
909
  // Promotion claim marker (issue #312): a `promote` check carries a session-local
697
910
  // _promotion object that must survive onto claim.metadata.promotion so the archive gate
698
911
  // (workflow-artifact-cleanup-audit) and validators can detect the promotion claim without a
699
912
  // new manifest entry. It rides alongside any waiver in a single merged metadata object.
700
913
  const promotionMeta = (check._promotion && typeof check._promotion === "object") ? check._promotion as AnyObj : null;
914
+ // #298: artifact_refs/standard_refs are validated on input by normalizeCheck but were
915
+ // previously never persisted onto the claim — silently lost on the very first write, not
916
+ // just on round-trip. Stamp them onto claim.metadata (additive, mirrors waiver/promotion
917
+ // above) so checksFromBundle can restore them for every subsequent writer.
918
+ const artifactRefsMeta = Array.isArray(check.artifact_refs) && check.artifact_refs.length > 0 ? check.artifact_refs : null;
919
+ const standardRefsMeta = Array.isArray(check.standard_refs) && check.standard_refs.length > 0 ? check.standard_refs : null;
920
+ // #270/#380 MEDIUM fix: record-check's captured-output sha256 digest (see recordCheck's
921
+ // outputSha256 computation) — hash ONLY, never the raw output text, so this is secret-safe by
922
+ // construction even though trust.bundle is often committed/shared. Stamped onto
923
+ // claim.metadata (additive, mirrors artifact_refs/standard_refs above) so checksFromBundle can
924
+ // restore it for every subsequent writer, and so it survives any later rebuild.
925
+ // NOTE (#270 LOW fix, iteration 3): this digest is over the CLAMPED capture (recordCheck's
926
+ // clampOutput, first RECORD_CHECK_MAX_OUTPUT / 64 KiB of combined stdout+stderr), not the full
927
+ // raw command output — a command producing >64KiB of output has its digest computed only over
928
+ // the retained prefix, so two runs whose outputs diverge only past the 64KiB boundary hash
929
+ // identically.
930
+ const outputDigestMeta = typeof check._output_sha256 === "string" && check._output_sha256.length > 0
931
+ ? { algorithm: "sha256", hex: check._output_sha256 }
932
+ : null;
933
+ // #270(a)/(c): a gate claim's declared claimType/subjectType, once resolved (either freshly
934
+ // via matchExpectsEntry below, or restored from a prior write's metadata.gate_claim stamp by
935
+ // checksFromBundle), is stamped here so it is frozen at record time — a later bundle rebuild
936
+ // (record-evidence/record-critique/record-learning) must never re-derive it from whatever
937
+ // step happens to be active THEN. See the matchExpectsEntry call site immediately below.
938
+ const gateClaimExpectationId = typeof check._gate_claim_expectation_id === "string" ? check._gate_claim_expectation_id : null;
939
+ const gateClaimDeclaredType = typeof check._gate_claim_declared_type === "string" ? check._gate_claim_declared_type : null;
940
+ const gateClaimDeclaredSubject = typeof check._gate_claim_declared_subject === "string" ? check._gate_claim_declared_subject : null;
941
+ const gateClaimDeclaredStepId = typeof check._gate_claim_declared_step_id === "string" ? check._gate_claim_declared_step_id : null;
942
+ const gateClaimRouteReason = typeof check._gate_claim_route_reason === "string" ? check._gate_claim_route_reason : null;
943
+ // #270 CRITICAL/HIGH fix: checksFromBundle stamps this when it read a claim that is
944
+ // gate-claim-SHAPED (origin:"check", check_kind:"external", kit-typed claimType) but carries
945
+ // NO metadata.gate_claim stamp — a claim this code could not have produced without also
946
+ // writing the stamp, so it predates cluster #270/#344. Die loudly with the same remedy
947
+ // pattern requireStampedClaim already uses elsewhere in this file, instead of silently
948
+ // falling through to matchExpectsEntry (which would re-derive a FRESH, possibly-wrong typing
949
+ // for what is actually a previously-recorded, now-untraceable gate claim — the re-typing bug).
950
+ const gateClaimShapeUnstampedClaimId = typeof check._gate_claim_shape_unstamped_claim_id === "string" ? check._gate_claim_shape_unstamped_claim_id : null;
951
+ if (gateClaimShapeUnstampedClaimId) {
952
+ 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.`);
953
+ }
701
954
  // #268: stamp a stable origin discriminator so checksFromBundle / critiquesFromBundle can
702
955
  // distinguish check vs critique vs acceptance claims across round-trips even under --flow-id,
703
956
  // where all three collapse onto the same declared claimType (and a command-less critique claim
704
957
  // would otherwise be re-absorbed as a test_output check → permanent [not-run] divergence).
705
- const claimMetadata: AnyObj = { origin: "check", check_kind: String(check.kind ?? "external"), ...(waiver ? { waiver } : {}), ...(promotionMeta ? { promotion: promotionMeta } : {}) };
958
+ const claimMetadata: AnyObj = {
959
+ origin: "check",
960
+ check_kind: String(check.kind ?? "external"),
961
+ ...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}),
962
+ ...(waiver ? { waiver } : {}),
963
+ ...(promotionMeta ? { promotion: promotionMeta } : {}),
964
+ ...(artifactRefsMeta ? { artifact_refs: artifactRefsMeta } : {}),
965
+ ...(standardRefsMeta ? { standard_refs: standardRefsMeta } : {}),
966
+ ...(outputDigestMeta ? { output_digest: outputDigestMeta } : {}),
967
+ };
706
968
 
707
969
  const claimEvents: AnyObj[] = [];
708
970
  if (evStatus) {
@@ -726,11 +988,47 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
726
988
 
727
989
  // P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
728
990
  // When record-gate-claim sets _gate_claim_expectation_id, pass it for exact lookup (ADR 0016 P-d Increment 2).
729
- const declared = matchExpectsEntry("check", check.kind, typeof check._gate_claim_expectation_id === "string" ? check._gate_claim_expectation_id : undefined);
991
+ // #270(c): a REBUILD of a previously-recorded gate claim (both the expectation id AND its
992
+ // originally-declared claimType/subjectType round-tripped via checksFromBundle's
993
+ // metadata.gate_claim restoration) uses the STAMPED typing directly instead of re-resolving
994
+ // matchExpectsEntry against whatever step is active NOW — that re-resolution is the exact
995
+ // defect (a claim recorded at step N silently re-typed as step N+1's claim on rebuild).
996
+ // matchExpectsEntry is still the resolver for the FIRST write (no prior stamp to trust).
997
+ //
998
+ // #270 CRITICAL/HIGH fix: a RESTORED stamp (gateClaimDeclaredType/gateClaimDeclaredSubject
999
+ // both present — meaning this is a REBUILD reading back a prior write's metadata.gate_claim,
1000
+ // not a fresh record-gate-claim call, which only ever sets gateClaimExpectationId) must be
1001
+ // VALIDATED against the session's full flow definition before being trusted — see
1002
+ // assertStampedGateClaimValid above. A stamp that fails validation dies loudly; it must NEVER
1003
+ // silently fall through to matchExpectsEntry (that fall-through was the exact #270 defect:
1004
+ // ANY invalid/forged/corrupt stamp got silently re-typed by the heuristic matcher instead of
1005
+ // being rejected). A FRESH write (only gateClaimExpectationId set, no declared type/subject
1006
+ // yet) has no stamp to validate — matchExpectsEntry is still the correct, and only, resolver
1007
+ // for that case, exactly as before.
1008
+ if (gateClaimExpectationId && gateClaimDeclaredType && gateClaimDeclaredSubject) {
1009
+ assertStampedGateClaimValid(claimId, {
1010
+ expectationId: gateClaimExpectationId,
1011
+ claimType: gateClaimDeclaredType,
1012
+ subjectType: gateClaimDeclaredSubject,
1013
+ stepId: gateClaimDeclaredStepId,
1014
+ });
1015
+ }
1016
+ const declared = (gateClaimExpectationId && gateClaimDeclaredType && gateClaimDeclaredSubject)
1017
+ ? { claimType: gateClaimDeclaredType, subjectType: gateClaimDeclaredSubject }
1018
+ : matchExpectsEntry("check", check.kind, gateClaimExpectationId ?? undefined);
730
1019
  if (declared) {
731
1020
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
732
1021
  const declaredPolicy = ensurePolicy(declared.claimType, "high", [evClass.evidenceType]);
733
- const declaredClaimObj: AnyObj = { 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 } : {}) };
1022
+ // Freeze the resolved typing (fresh or restored) into metadata.gate_claim so it survives
1023
+ // any subsequent rebuild regardless of the then-active step (#270a/c).
1024
+ // step_id prefers the ORIGINALLY-recorded step (restored from a prior stamp) on a
1025
+ // rebuild that has no active flow step of its own; only a genuinely first write (no
1026
+ // restored stamp) takes the currently-active step's id.
1027
+ const declaredStepId = gateClaimDeclaredStepId ?? (activeStep ? activeStep.stepId : null);
1028
+ const declaredMetadata: AnyObj = gateClaimExpectationId
1029
+ ? { ...claimMetadata, gate_claim: { expectation_id: gateClaimExpectationId, claim_type: declared.claimType, subject_type: declared.subjectType, step_id: declaredStepId, ...(gateClaimRouteReason ? { route_reason: gateClaimRouteReason } : {}) } }
1030
+ : claimMetadata;
1031
+ const declaredClaimObj: AnyObj = { 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 } : {}) };
734
1032
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [evItem] as Record<string, unknown>[], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
735
1033
  claims.push({ ...declaredClaimObj, status: declaredStatus });
736
1034
  } else {
@@ -762,7 +1060,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
762
1060
  if (declared) {
763
1061
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
764
1062
  const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
765
- const declaredClaimObj: AnyObj = { 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" } };
1063
+ const declaredClaimObj: AnyObj = { 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 } : {}) } };
766
1064
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
767
1065
  claims.push({ ...declaredClaimObj, status: declaredStatus });
768
1066
  } else {
@@ -785,7 +1083,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
785
1083
  const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
786
1084
  const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
787
1085
  const critiqueReviewedAt = String(c.reviewed_at ?? ts);
788
- const critMeta: AnyObj = { origin: "critique", reviewer: critiqueReviewer, reviewed_at: critiqueReviewedAt, ...(supersededBy ? { superseded_by: supersededBy } : {}) };
1086
+ const critMeta: AnyObj = { origin: "critique", reviewer: critiqueReviewer, reviewed_at: critiqueReviewedAt, ...(activeStep && workflowSubjectRef ? { workflow_subject_ref: workflowSubjectRef } : {}), ...(supersededBy ? { superseded_by: supersededBy } : {}) };
789
1087
  // A superseded historical write gets a distinct, stable claimId so it co-exists with the live
790
1088
  // claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
791
1089
  // across rebuilds because superseded_by + reviewed_at are preserved in metadata.
@@ -875,6 +1173,7 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
875
1173
  return { written: false, errors: result.errors };
876
1174
  }
877
1175
  writeJson(path.join(dir, "trust.bundle"), bundle);
1176
+ await syncBuilderFlowSessionIfPresent(dir);
878
1177
  return { written: true, errors: [] };
879
1178
  } catch (err) {
880
1179
  const message = err instanceof Error ? err.message : String(err);
@@ -1082,7 +1381,23 @@ function parseCriterion(line: string, index: number): AnyObj {
1082
1381
  const evidence = m?.[1]?.trim().replace(/\.$/, "");
1083
1382
  if (m) text = text.slice(0, m.index).trim().replace(/\.$/, "");
1084
1383
  const item: AnyObj = { id: slugify(text, `criterion-${index + 1}`), description: text, status: "pending" };
1085
- if (evidence) item.evidence_refs = [evidenceRef("command", { excerpt: evidence })];
1384
+ // #412 (AC8 interaction, planning-contract.md line ~46/84): the "- Evidence: <...>" Markdown
1385
+ // field is contractually a "test, command, screenshot, dashboard, doc, CI, or manual check" —
1386
+ // NOT always a runnable command. Unconditionally synthesizing kind:"command" with the raw text
1387
+ // in `excerpt` for EVERY Evidence line (the pre-existing behavior here) was already
1388
+ // contract-incorrect for prose evidence; it is now also a hard failure at record time
1389
+ // (validateAcceptanceEvidenceRefs's new runnability rejection targets kind:"command"'s
1390
+ // excerpt/url), so fixing the classification here is required, not optional polish. Use the
1391
+ // SAME isRunnableCommandText heuristic the record-time rejection itself uses (single-sourced):
1392
+ // literally-runnable text goes in `excerpt` (reconcilable, execution.label-eligible); anything
1393
+ // else (prose, or ensure-session's own "pending" scaffold placeholder) goes in `summary`
1394
+ // instead (never validated for runnability, never executed) — still kind:"command" (a
1395
+ // structured ref is still produced either way; only WHICH field carries the text changes),
1396
+ // per the contract's own "prose belongs in ref.summary" rule.
1397
+ if (evidence) {
1398
+ const { isRunnableCommandText } = loadRunnableCommandHelper();
1399
+ item.evidence_refs = [evidenceRef("command", isRunnableCommandText(evidence) ? { excerpt: evidence } : { summary: evidence })];
1400
+ }
1086
1401
  return item;
1087
1402
  }
1088
1403
  function artifactDirFrom(value: string): string { return path.extname(value) ? path.dirname(value) : value; }
@@ -1203,6 +1518,47 @@ function loadCurrentPointerHelper(): {
1203
1518
  };
1204
1519
  }
1205
1520
 
1521
+ /**
1522
+ * #412: delegate to the shared pure-CJS runnable-command-text heuristic
1523
+ * (scripts/hooks/lib/runnable-command.js), mirroring the createRequire idiom used by
1524
+ * loadCurrentPointerHelper()/loadActorIdentityHelper() above. Single-sources the heuristic
1525
+ * between stop-goal-fit.js's Stop-time backstop and this file's record-time rejection
1526
+ * (validateAcceptanceEvidenceRefs, recordGateClaim --command, recordCheck --command) — see
1527
+ * AC9.
1528
+ *
1529
+ * #362 (Wave 3, AC6/AC7): extended (not a new sibling loader — same module, same
1530
+ * createRequire call, single require() of runnable-command.js) to also return
1531
+ * `isAmbiguousAbsenceCommand`, landed in that module by Wave 1's Task 1.2. This keeps
1532
+ * `recordCheck`'s record-time ambiguous-status stamp and `validateAcceptanceEvidenceRefs`'s
1533
+ * record-time advisory single-sourced against the SAME heuristic `runBackstop`/
1534
+ * `readCommandLog`/`evidence-capture.js`'s `observeResult` already consume (Waves 1-2) —
1535
+ * never a second, divergent implementation of "what counts as ambiguous."
1536
+ */
1537
+ function loadRunnableCommandHelper(): { isRunnableCommandText: (text: string) => boolean; isAmbiguousAbsenceCommand: (text: string) => boolean } {
1538
+ const _req = createRequire(import.meta.url);
1539
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/runnable-command.js");
1540
+ return _req(helperPath) as { isRunnableCommandText: (text: string) => boolean; isAmbiguousAbsenceCommand: (text: string) => boolean };
1541
+ }
1542
+
1543
+ /**
1544
+ * #362 (iteration-2 fix item 4/LOW): single-sourced human-facing remediation fragment shared by
1545
+ * this file's two ambiguous-absence-command emission sites (`validateAcceptanceEvidenceRefs`'s
1546
+ * record-time advisory and `recordCheck`'s ambiguous-status stderr note) — mirrors how
1547
+ * `isAmbiguousAbsenceCommand` itself is single-sourced above. Cross-ref: scripts/hooks/
1548
+ * stop-goal-fit.js keeps its OWN copy of the equivalent shared string (`AMBIGUOUS_REMEDIATION`)
1549
+ * for its three Stop-hook emission sites — the two files do not share a module for this string,
1550
+ * so each file single-sources independently.
1551
+ */
1552
+ const AMBIGUOUS_REMEDIATION_ADVICE = "self-asserting command ('! grep ...' or 'grep -c ... | grep -qx 0')";
1553
+
1554
+ function validateRunnableCheckCommand(check: AnyObj, context: string): void {
1555
+ if (check.kind !== "command" || !hasNonEmptyString(check.command)) return;
1556
+ const { isRunnableCommandText } = loadRunnableCommandHelper();
1557
+ if (!isRunnableCommandText(check.command)) {
1558
+ 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".`);
1559
+ }
1560
+ }
1561
+
1206
1562
  /**
1207
1563
  * #291 Wave 2 Task 2.1 (§5): writes the UNCHANGED legacy global `<root>/current.json` (the
1208
1564
  * compat-shim's write-side half — every existing consumer without an actorKey keeps reading
@@ -1212,7 +1568,7 @@ function loadCurrentPointerHelper(): {
1212
1568
  * byte-identical to this function's pre-#291 behavior — with a stderr note mirroring the existing
1213
1569
  * unresolved-actor branch-naming diagnostic.
1214
1570
  */
1215
- function writeCurrent(root: string, dir: string, timestamp: string, owner: string, source: string, flowId?: string, stepId?: string, adHocReason?: string, actorKey?: string): void {
1571
+ function writeCurrent(root: string, dir: string, timestamp: string, owner: string, source: string, flowId?: string, stepId?: string, actorKey?: string): void {
1216
1572
  // #289: mirror the active session's already-recorded branch (state.json.branch) into
1217
1573
  // current.json so consumers of current.json (which has no schema of its own — not one of the
1218
1574
  // 9 schemas under schemas/) see the routing branch without re-reading state.json separately.
@@ -1231,11 +1587,6 @@ function writeCurrent(root: string, dir: string, timestamp: string, owner: strin
1231
1587
  // FlowDefinition omit them and fall through to the workflow.* claim type path.
1232
1588
  ...(flowId ? { active_flow_id: flowId } : {}),
1233
1589
  ...(stepId ? { active_step_id: stepId } : {}),
1234
- // WS8 (AC12): sanctioned ad-hoc direct entry marker. Set when --step-id explicitly
1235
- // targets a step other than the flow's resolved first step, so stop-goal-fit /
1236
- // gate-review can distinguish an intentional direct entry (e.g. a planning-only
1237
- // session that skips pull-work) from a stale/mis-stamped active_step_id.
1238
- ...(adHocReason ? { ad_hoc_entry: true, ad_hoc_reason: adHocReason } : {}),
1239
1590
  };
1240
1591
  writeJson(path.join(root, "current.json"), payload);
1241
1592
  if (actorKey && !loadActorIdentityHelper().isUnresolvedActor(actorKey)) {
@@ -1305,7 +1656,17 @@ function updateCurrentAgent(root: string, dir: string, agentId: string, status:
1305
1656
  }
1306
1657
  }
1307
1658
 
1308
- function initSidecars(dir: string, slug: string, sourceRequest: string, summary: string, nextAction: string, timestamp: string, markdown?: string): void {
1659
+ function initSidecars(
1660
+ dir: string,
1661
+ slug: string,
1662
+ sourceRequest: string,
1663
+ summary: string,
1664
+ nextAction: string,
1665
+ timestamp: string,
1666
+ markdown?: string,
1667
+ workItemRefs: string[] = [],
1668
+ initialLifecycle?: { status: string; phase: string },
1669
+ ): void {
1309
1670
  const criteria = markdown ? definitionAcceptanceLines(markdown).map(parseCriterion) : [];
1310
1671
  // #289/#309: `markdown` here is NOT always the session `<slug>--deliver.md` that
1311
1672
  // ensureSession seeds the `branch:` line into — initPlan is called against the tool-planner's
@@ -1336,9 +1697,11 @@ function initSidecars(dir: string, slug: string, sourceRequest: string, summary:
1336
1697
  // current call's timestamp silently rewrites a session's original creation time. Preserve the
1337
1698
  // existing state.json's created_at when present; stamp only on true first-creation.
1338
1699
  // updated_at still reflects "now" on every call — that field is intentionally mutable.
1700
+ const retainedWorkItemRefs = workItemRefs.length > 0 ? workItemRefs : (Array.isArray(existingState.work_item_refs) ? existingState.work_item_refs : []);
1339
1701
  writeJson(path.join(dir, "state.json"), {
1340
- ...sidecarBase(slug), status: "planned", phase: "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
1702
+ ...sidecarBase(slug), status: initialLifecycle?.status ?? "planned", phase: initialLifecycle?.phase ?? "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
1341
1703
  ...(branch ? { branch } : {}),
1704
+ ...(retainedWorkItemRefs.length > 0 ? { work_item_refs: retainedWorkItemRefs } : {}),
1342
1705
  artifact_paths: relArtifacts(dir),
1343
1706
  next_action: { status: "continue", summary: nextAction || summary },
1344
1707
  });
@@ -1416,6 +1779,11 @@ function enforceEnsureSessionOwnership(
1416
1779
  // assignment-provider.ts's sanitizeAuditEntryForDisplay) — this guard never echoes `--reason`
1417
1780
  // into a die() message, so it has no free-text field requiring the 240 tier today.
1418
1781
  const sanitize = (value: unknown): string => stripControlCharsForDisplay(value).slice(0, 64);
1782
+ // #294: the 240-char free-text tier (repo convention: 64 for id-like, 240 for free text — cf.
1783
+ // assignment-provider.ts sanitizeDisplayField(record.branch/reason, 240)). Used for the takeover
1784
+ // `resumed_branch` (a realistic agent/<actor>/<slug> branch exceeds 64 — truncation would produce a
1785
+ // bad `git checkout` target) and the audit `reason` (else "resuming from trust bundle" is cut off).
1786
+ const sanitizeWide = (value: unknown): string => stripControlCharsForDisplay(value).slice(0, 240);
1419
1787
  const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : Date.now();
1420
1788
  const assignmentProviderKind = opt(p, "assignment-provider", "local-file");
1421
1789
 
@@ -1495,15 +1863,32 @@ function enforceEnsureSessionOwnership(
1495
1863
  const assignment = readLocalAssignmentStatus(root, slug);
1496
1864
  const fromActor = assignment.record?.actor;
1497
1865
  if (!fromActor) die(`ensure-session --supersede-stale: no existing local-file claim record found for subject ${sanitize(slug)} to supersede`);
1866
+ // #294 (ADR 0021 §5): takeover is resumption — the successor CONTINUES the incumbent's branch,
1867
+ // never a parallel one. Capture the incumbent's branch (from its pre-supersede record) and
1868
+ // surface it as `resumed_branch` so the skill can `git checkout` it; default the audit reason to
1869
+ // the ADR §5 wording (superseded actor X, last seen T, resuming from trust bundle).
1870
+ const incumbentBranch = assignment.record?.branch;
1871
+ const incumbentLastSeen = effective.holder?.last_at ?? assignment.record?.claimed_at;
1872
+ const takeoverReason = opt(p, "reason") || `takeover: superseded ${holderActor}${incumbentLastSeen ? `, last seen ${sanitize(incumbentLastSeen)}` : ""}, resuming from trust bundle`;
1498
1873
  performLocalSupersede(root, slug, fromActor, resolution.actorStruct, {
1499
- branch: resolveBranchForClaim(),
1874
+ // #294 (ADR 0021 §5): takeover is RESUMPTION — the successor continues the incumbent's branch,
1875
+ // so the record must keep pointing at that branch, NOT be overwritten with the successor's
1876
+ // current branch (`resolveBranchForClaim()`). At supersede time the successor has not yet
1877
+ // `git checkout`ed the resume branch (the skill claims ownership first, then checks out), so
1878
+ // resolveBranchForClaim() would otherwise clobber the record with a parallel branch and make a
1879
+ // later reader (resume surface / verify-hold guidance) see the wrong branch. Preserve the
1880
+ // incumbent's branch; fall back to the successor's only if the incumbent record had none.
1881
+ branch: incumbentBranch || resolveBranchForClaim(),
1500
1882
  artifactDir: path.relative(root, dir) || ".",
1501
- reason: opt(p, "reason", "ensure-session takeover: stale claim"),
1883
+ reason: takeoverReason,
1502
1884
  // F1 fix (fix-plan iteration 1, HIGH): persist the canonical actor_key on the record so
1503
1885
  // computeEffectiveState's holderActorKey (assignment-provider.ts) matches this same
1504
1886
  // branchActorKey string on the next status/guard check, cross-tool.
1505
1887
  actorKey: resolution.branchActorKey,
1506
1888
  });
1889
+ // Render-don't-execute: emit the incumbent's branch so the skill continues it (never a new
1890
+ // branch). The successor re-enters the SAME artifact dir (deterministic slug) by construction.
1891
+ printJson({ role: "SupersedeTakeover", subject: sanitize(slug), superseded_actor: holderActor, ...(incumbentBranch ? { resumed_branch: sanitizeWide(incumbentBranch) } : {}), reason: sanitizeWide(takeoverReason) });
1507
1892
  return;
1508
1893
  }
1509
1894
  case "free": {
@@ -1537,10 +1922,41 @@ function enforceEnsureSessionOwnership(
1537
1922
  * --claim-ttl-seconds <n> Overrides the liveness-policy TTL default for a new claim.
1538
1923
  * --reason <text> Audit-trail reason recorded on the claim/supersede record.
1539
1924
  */
1925
+ function resolveEnsureSessionEntry(p: ReturnType<typeof parseArgs>, dir: string): { flowId: string; stepId: string; firstStep: string } {
1926
+ const flowId = opt(p, "flow-id");
1927
+ const explicitStep = opt(p, "step-id");
1928
+ if (!flowId) {
1929
+ if (explicitStep) die("ensure-session --step-id requires --flow-id");
1930
+ if (opt(p, "ad-hoc-reason")) die("ensure-session --ad-hoc-reason is no longer supported");
1931
+ return { flowId: "", stepId: "", firstStep: "" };
1932
+ }
1933
+
1934
+ const firstStep = resolveFirstStep(flowId, findRepoRootFromDir(dir));
1935
+ if (!firstStep) die(`ensure-session could not resolve the first step for Flow Definition ${JSON.stringify(flowId)}`);
1936
+ if (opt(p, "ad-hoc-reason")) {
1937
+ die("ensure-session --ad-hoc-reason cannot authorize workflow entry; start at the Flow Definition's first step or resume persisted run state");
1938
+ }
1939
+ if (explicitStep && explicitStep !== firstStep) {
1940
+ 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`);
1941
+ }
1942
+
1943
+ return { flowId, stepId: explicitStep || firstStep, firstStep };
1944
+ }
1945
+
1946
+ function preflightEnsureSession(p: ReturnType<typeof parseArgs>): void {
1947
+ const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
1948
+ 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)"));
1949
+ const dir = sessionDirFor(root, slug);
1950
+ resolveEnsureSessionEntry(p, dir);
1951
+ sessionWorkItem(p, slug, dir);
1952
+ }
1953
+
1540
1954
  function ensureSession(p: ReturnType<typeof parseArgs>): number {
1541
1955
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
1542
1956
  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)"));
1543
1957
  const dir = sessionDirFor(root, slug);
1958
+ const entry = resolveEnsureSessionEntry(p, dir);
1959
+ const workItem = sessionWorkItem(p, slug, dir);
1544
1960
  // #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
1545
1961
  // any directory/file is created — a refusal must never leave a stray empty session dir. Reused
1546
1962
  // below (writeCurrent's per-actor dual-write) so the branch-naming actor and the
@@ -1549,6 +1965,9 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
1549
1965
  enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution);
1550
1966
  fs.mkdirSync(dir, { recursive: true });
1551
1967
  const timestamp = opt(p, "timestamp", now());
1968
+ if (workItem.localRecord && !fs.existsSync(path.join(dir, "work-item.json"))) {
1969
+ writeJson(path.join(dir, "work-item.json"), workItem.localRecord);
1970
+ }
1552
1971
  let md = fs.existsSync(path.join(dir, `${slug}--deliver.md`)) ? read(path.join(dir, `${slug}--deliver.md`)) : "";
1553
1972
  if (!md) {
1554
1973
  // #289: derive the routing branch ONLY on fresh session creation (this `if (!md)` guard).
@@ -1556,11 +1975,29 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
1556
1975
  // of code is skipped on a resumed/taken-over session, which is what makes ADR 0021 §5
1557
1976
  // takeover continuity true by construction (see Design Decision 3 in the plan).
1558
1977
  const branch = resolveSessionBranch(p, slug);
1559
- 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`;
1978
+ const initialMarkdownStatus = entry.flowId ? "new" : "planning";
1979
+ 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`;
1560
1980
  fs.writeFileSync(path.join(dir, `${slug}--deliver.md`), md);
1561
1981
  }
1562
1982
  if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
1563
- initSidecars(dir, slug, opt(p, "source-request"), opt(p, "summary"), opt(p, "next-action", "Continue."), timestamp, md);
1983
+ const phaseMap = entry.flowId ? resolvePhaseMap(entry.flowId, findRepoRootFromDir(dir)) : null;
1984
+ const initialPhase = Object.entries(phaseMap ?? {}).find(([, step]) => step === entry.firstStep)?.[0];
1985
+ const nextAction = entry.flowId
1986
+ ? entry.flowId === "builder.build"
1987
+ ? `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.`
1988
+ : `Continue at Flow step ${JSON.stringify(entry.stepId)} for work item ${JSON.stringify(workItem.ref)}; satisfy its declared gate before advancing.`
1989
+ : opt(p, "next-action", "Continue.");
1990
+ initSidecars(
1991
+ dir,
1992
+ slug,
1993
+ opt(p, "source-request"),
1994
+ opt(p, "summary"),
1995
+ nextAction,
1996
+ timestamp,
1997
+ md,
1998
+ [workItem.ref],
1999
+ initialPhase ? { status: "new", phase: initialPhase } : undefined,
2000
+ );
1564
2001
  }
1565
2002
  // ADR 0016 Abstraction A (P-a): optional --flow-id / --step-id flags persist FlowDefinition
1566
2003
  // routing keys into current.json for the producer (P-b) and enforcer (P-c) to consume.
@@ -1569,27 +2006,14 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
1569
2006
  // active_step_id to the FIRST step in the FlowDefinition's steps[] list. This ensures
1570
2007
  // ensure-session --flow-id builder.build produces a FlowDefinition-driven session even
1571
2008
  // before the first advance-state call.
1572
- const flowId = opt(p, "flow-id");
1573
- const explicitStep = opt(p, "step-id");
1574
- let stepId = explicitStep;
1575
- let adHocReason: string | undefined;
1576
- if (flowId && !stepId) {
1577
- const repoRoot = findRepoRootFromDir(dir);
1578
- const firstStep = resolveFirstStep(flowId, repoRoot);
1579
- if (firstStep) stepId = firstStep;
1580
- } else if (flowId && explicitStep) {
1581
- // WS8 (AC12): --step-id is the sanctioned ad-hoc direct-entry mechanism. When it names
1582
- // a step other than the flow's resolved first step, record an explicit ad_hoc_entry
1583
- // marker (with a reason) instead of silently letting the mis-stamp look like the
1584
- // flow's normal first step. This is the root-cause fix for a planning-only session
1585
- // whose active_step_id would otherwise default to builder.build's first step.
1586
- const repoRoot = findRepoRootFromDir(dir);
1587
- const firstStep = resolveFirstStep(flowId, repoRoot);
1588
- if (firstStep && firstStep !== explicitStep) {
1589
- adHocReason = opt(p, "ad-hoc-reason") || `direct entry at step "${explicitStep}" via --step-id (flow first step is "${firstStep}")`;
1590
- }
1591
- }
1592
- writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined, adHocReason, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
2009
+ const persistedCurrent = loadCurrent(root);
2010
+ const resumedStep = !opt(p, "step-id")
2011
+ && persistedCurrent?.active_slug === slug
2012
+ && persistedCurrent?.active_flow_id === entry.flowId
2013
+ && typeof persistedCurrent?.active_step_id === "string"
2014
+ ? persistedCurrent.active_step_id
2015
+ : entry.stepId;
2016
+ writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", entry.flowId || undefined, resumedStep || undefined, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
1593
2017
  console.log(dir);
1594
2018
  return 0;
1595
2019
  }
@@ -1694,11 +2118,57 @@ export function normalizeEvidenceRefs(raw: unknown, label: string): AnyObj[] {
1694
2118
  return validateEvidenceRef({ ...ref as AnyObj }, label);
1695
2119
  });
1696
2120
  }
1697
- export function normalizeCheck(raw: AnyObj): AnyObj {
2121
+ // #270 HIGH fix (iteration 3): the `gate-claim-` check-id prefix is RESERVED for
2122
+ // record-gate-claim's own internally-constructed ids (`id: \`gate-claim-${checkId}\`` — see
2123
+ // recordGateClaim below). Every OTHER writer of check ids (record-evidence --check-json,
2124
+ // record-check, dogfood-pass --check-json) flows through this same normalizeCheck, and until
2125
+ // this fix any of them could ALSO hand-supply an id starting with "gate-claim-". That collision
2126
+ // silently satisfies the id-shape half of gateClaimShapeUnstampedId's four-signal detector (see
2127
+ // its comment above) without ever having gone through record-gate-claim, so a later rebuild
2128
+ // misclassifies the caller's check as an unstamped pre-cluster-270 gate claim and dies, losing
2129
+ // the write. Rejecting the prefix HERE, at record time, for every caller-supplied id makes the
2130
+ // id-shape signal sound again: only record-gate-claim's own path can ever produce that shape.
2131
+ // record-gate-claim opts in via allowGateClaimPrefix=true since it is the sole legitimate
2132
+ // producer of that id shape; every other caller uses the default (reject).
2133
+ //
2134
+ // #270 follow-up fix (publish-preflight, iteration 5): the rejection above must apply ONLY to
2135
+ // NEW mints, not to a correction of an id that already exists as a check claim id in the
2136
+ // session's CURRENT trust.bundle. A mis-recorded claim that already carries a `gate-claim-`
2137
+ // prefixed id (e.g. one that slipped through before this guard shipped, or was recorded via a
2138
+ // binary predating it) can otherwise NEVER be corrected: every attempt to re-record that exact
2139
+ // id — the only way to supersede/fix it — is itself rejected by this same guard, permanently
2140
+ // wedging that id.
2141
+ //
2142
+ // #270 CRITICAL fix (iteration 6, narrowing the above): "the id's identity already exists in
2143
+ // the bundle" is NOT by itself a safe supersession signal — an id that already exists MIGHT be
2144
+ // a REAL, properly-stamped claim produced by record-gate-claim itself (its own generated
2145
+ // `gate-claim-${checkId}` ids always already "exist" once minted). Exempting every existing id
2146
+ // unconditionally let record-evidence/record-check/dogfood-pass silently overwrite a live,
2147
+ // correctly-stamped gate claim — destroying its metadata.gate_claim stamp — and the caller
2148
+ // fully controls check_kind, so the replacement claim can trivially be shaped to evade
2149
+ // gateClaimShapeUnstampedId's detector (that detector requires check_kind==="external", which
2150
+ // only fires the alarm when the caller happens to pick that kind). The narrowed rule: an
2151
+ // existing id is supersedable via this path ONLY when the EXISTING claim for that id carries NO
2152
+ // metadata.gate_claim stamp — i.e. only the mis-recorded/wedged shape (the legitimate
2153
+ // correction target this exemption exists for). An existing id whose claim IS stamped is a live
2154
+ // gate claim; only record-gate-claim (allowGateClaimPrefix=true, its own unconditional opt-in)
2155
+ // may ever supersede it. Callers therefore pass `existingCheckStampById` (a Map from the
2156
+ // bundle's CURRENT check ids to whether that check's claim already carries a metadata.gate_claim
2157
+ // stamp, read via readBundleState/checksFromBundle's `_gate_claim_expectation_id` restoration
2158
+ // BEFORE normalizeCheck runs — see applyGateClaimStamp). This does not weaken new-mint
2159
+ // enforcement: a NOVEL gate-claim-* id (not already present at all) is still rejected exactly as
2160
+ // before, and superseding a STAMPED existing id is now rejected too.
2161
+ export function normalizeCheck(raw: AnyObj, allowGateClaimPrefix = false, existingCheckStampById?: ReadonlyMap<string, boolean>): AnyObj {
1698
2162
  const check = { ...raw };
1699
2163
  if (!check.id || !check.kind || !check.status || !check.summary) die("check requires id, kind, status, and summary");
1700
- if (!checkKinds.has(check.kind)) die("kind must be one of: build, types, lint, test, command, security, diff, browser, runtime, policy, external");
2164
+ if (!allowGateClaimPrefix && typeof check.id === "string" && check.id.startsWith("gate-claim-")) {
2165
+ const existingHasStamp = existingCheckStampById?.get(check.id);
2166
+ if (existingHasStamp === true) 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.`);
2167
+ if (existingHasStamp === undefined) 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.`);
2168
+ }
2169
+ if (!checkKinds.has(check.kind)) die("kind must be one of: build, types, lint, test, command, security, diff, browser, runtime, policy, external");
1701
2170
  if (!checkStatuses.has(check.status)) die("status must be one of: pass, fail, not_verified, skip");
2171
+ validateRunnableCheckCommand(check, `check ${String(check.id)}`);
1702
2172
  if (Array.isArray(check.standard_refs)) for (const ref of check.standard_refs) if (!["junit", "sarif", "coverage", "veritas"].includes(ref.standard)) die("standard must be one of");
1703
2173
  if (check.artifact_refs) check.artifact_refs = normalizeEvidenceRefs(check.artifact_refs, "artifact_refs");
1704
2174
  if (check.surface_trust_refs) check.surface_trust_refs = normalizeSurfaceRefs(check.surface_trust_refs);
@@ -1815,13 +2285,54 @@ function surfaceCheckFromArtifact(file: string, index: number): AnyObj {
1815
2285
  return { id: `surface-trust-${index + 1}`, kind: "policy", status: ref.status, summary: ref.summary, surface_trust_refs: [ref] };
1816
2286
  }
1817
2287
 
1818
- function validateAcceptanceEvidenceRefs(dir: string): void {
2288
+ /**
2289
+ * #270 MEDIUM security fix: a `kind:"command"` evidence_refs entry whose command source is
2290
+ * genuinely non-runnable-by-design (e.g. a screenshot/dashboard reference the record-time
2291
+ * heuristic misclassifies) previously had NO escape hatch — this validator's die() was an
2292
+ * unconditional lockout. Mirrors --skip-ownership-guard's existing pattern in this file exactly:
2293
+ * a named, logged (never silent) bypass flag, not a silent fallback. `--skip-evidence-ref-
2294
+ * runnability-guard` is intentionally verbose/unambiguous so it is never reached for
2295
+ * accidentally — the die() message itself names the concrete remediations FIRST (move the prose
2296
+ * to ref.summary, or reclassify as kind:"external"/"artifact"), so the bypass is a last resort,
2297
+ * not the first thing an agent reaches for.
2298
+ */
2299
+ function validateAcceptanceEvidenceRefs(dir: string, p?: ReturnType<typeof parseArgs>): void {
2300
+ if (p?.flags.has("skip-evidence-ref-runnability-guard")) {
2301
+ process.stderr.write("[record-evidence] evidence-ref runnability guard skipped via --skip-evidence-ref-runnability-guard\n");
2302
+ return;
2303
+ }
1819
2304
  const file = path.join(dir, "acceptance.json");
1820
2305
  if (!fs.existsSync(file)) return;
1821
2306
  const data = loadJson(file);
1822
2307
  if (!Array.isArray(data.criteria)) return;
2308
+ const { isRunnableCommandText, isAmbiguousAbsenceCommand } = loadRunnableCommandHelper();
1823
2309
  data.criteria.forEach((criterion: AnyObj, index: number) => {
1824
- if (criterion.evidence_refs !== undefined) normalizeEvidenceRefs(criterion.evidence_refs, `acceptance.criteria[${index}].evidence_refs`);
2310
+ if (criterion.evidence_refs === undefined) return;
2311
+ const refs = normalizeEvidenceRefs(criterion.evidence_refs, `acceptance.criteria[${index}].evidence_refs`);
2312
+ // #412 (AC8): a kind:"command" ref's command source (excerpt, falling back to url when that
2313
+ // is where the command string lives) must be a literally runnable shell command, not prose
2314
+ // describing a manual verification step — reject at RECORD time instead of letting it reach
2315
+ // the Stop-hook backstop (stop-goal-fit.js's bundleClaimedPassCommandChecks section B), which
2316
+ // would otherwise spawn `bash -lc "<a sentence>"` and misreport the resulting shell error as
2317
+ // a caught false-completion. Prose belongs in `summary` (never executed).
2318
+ refs.forEach((ref: AnyObj, refIndex: number) => {
2319
+ if (ref.kind !== "command") return;
2320
+ const commandSource = hasNonEmptyString(ref.excerpt) ? ref.excerpt : (hasNonEmptyString(ref.url) ? ref.url : "");
2321
+ if (!commandSource) return; // command refs may carry only `summary` — nothing to validate
2322
+ if (!isRunnableCommandText(commandSource)) {
2323
+ 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).`);
2324
+ }
2325
+ // #362 (Wave 3, AC7): ADVISORY ONLY — never die(), never rejects/alters the ref. This is
2326
+ // deliberately the opposite posture from the runnability guard immediately above (which
2327
+ // stays fatal, unchanged). Guidance, not enforcement, per the plan's item 2: nudge a
2328
+ // newly-recorded bare (non-negated, non-count-asserted, non-chained) grep/diff evidence
2329
+ // ref toward a self-asserting form, without breaking back-compat with any already-recorded
2330
+ // evidence ref (#412) — an already-recorded ref reaching this validator on every subsequent
2331
+ // record-evidence call must keep passing, just with a repeated nudge, never a new failure.
2332
+ if (isAmbiguousAbsenceCommand(commandSource)) {
2333
+ 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`);
2334
+ }
2335
+ });
1825
2336
  });
1826
2337
  }
1827
2338
  export function writeState(dir: string, slug: string, status: string, phase: string, timestamp: string, summary: string, next = "continue"): void {
@@ -1879,6 +2390,91 @@ function checksFromBundle(dir: string): AnyObj[] {
1879
2390
  const seen = new Set<string>();
1880
2391
  const checks: AnyObj[] = [];
1881
2392
  const kindOf = (claim: AnyObj): string => String((claim.metadata as AnyObj).check_kind);
2393
+ // Read side of the buildTrustBundle waiver round-trip (write side: buildTrustBundle reads
2394
+ // check._waiver at line ~689 and stamps it onto claimMetadata.waiver at line ~705). Without
2395
+ // this, any caller that rebuilds checks via checksFromBundle() (recordCritique/recordLearning)
2396
+ // silently drops a previously-recorded waiver on the next bundle write.
2397
+ const waiverOf = (claim: AnyObj): AnyObj | undefined => {
2398
+ const md = claim.metadata as AnyObj;
2399
+ return md && typeof md === "object" && md.waiver && typeof md.waiver === "object" ? md.waiver as AnyObj : undefined;
2400
+ };
2401
+ // #298: read side of the artifact_refs/standard_refs stamp (write side: buildTrustBundle's
2402
+ // claimMetadata.artifact_refs/.standard_refs, above). Previously these were validated on input
2403
+ // but never persisted at all — restoring them here is what makes them round-trip through a
2404
+ // second writer's rebuild instead of vanishing on the very first write.
2405
+ const refsOf = (claim: AnyObj): { artifact_refs?: AnyObj[]; standard_refs?: AnyObj[] } => {
2406
+ const md = claim.metadata as AnyObj;
2407
+ if (!md || typeof md !== "object") return {};
2408
+ const out: { artifact_refs?: AnyObj[]; standard_refs?: AnyObj[] } = {};
2409
+ if (Array.isArray(md.artifact_refs) && md.artifact_refs.length > 0) out.artifact_refs = md.artifact_refs;
2410
+ if (Array.isArray(md.standard_refs) && md.standard_refs.length > 0) out.standard_refs = md.standard_refs;
2411
+ return out;
2412
+ };
2413
+ // #270/#380: read side of the record-check output-digest stamp (write side: buildTrustBundle's
2414
+ // claimMetadata.output_digest, above). Restoring it here is what makes the digest survive a
2415
+ // subsequent writer's rebuild instead of vanishing on the very next record-evidence/
2416
+ // record-critique/record-learning call.
2417
+ const outputSha256Of = (claim: AnyObj): string | undefined => {
2418
+ const md = claim.metadata as AnyObj;
2419
+ const od = md && typeof md === "object" ? md.output_digest as AnyObj : undefined;
2420
+ return od && typeof od === "object" && od.algorithm === "sha256" && typeof od.hex === "string" && od.hex.length > 0 ? od.hex : undefined;
2421
+ };
2422
+ // #270(a)/(c): read side of the gate_claim stamp (write side: buildTrustBundle's
2423
+ // claimMetadata.gate_claim, above). Restoring expectation_id/claim_type/subject_type/step_id
2424
+ // is what lets a REBUILD (record-evidence/record-critique/record-learning, after the recorded
2425
+ // gate claim) recognize this as a previously-typed gate claim and reuse its frozen typing
2426
+ // instead of re-deriving via matchExpectsEntry against whatever step is active at rebuild time.
2427
+ const gateClaimOf = (claim: AnyObj): AnyObj | undefined => {
2428
+ const md = claim.metadata as AnyObj;
2429
+ return md && typeof md === "object" && md.gate_claim && typeof md.gate_claim === "object" ? md.gate_claim as AnyObj : undefined;
2430
+ };
2431
+ const applyGateClaimStamp = (check: AnyObj, claim: AnyObj): void => {
2432
+ const gc = gateClaimOf(claim);
2433
+ if (!gc) return;
2434
+ if (typeof gc.expectation_id === "string") check._gate_claim_expectation_id = gc.expectation_id;
2435
+ if (typeof gc.claim_type === "string") check._gate_claim_declared_type = gc.claim_type;
2436
+ if (typeof gc.subject_type === "string") check._gate_claim_declared_subject = gc.subject_type;
2437
+ if (typeof gc.step_id === "string") check._gate_claim_declared_step_id = gc.step_id;
2438
+ if (typeof gc.route_reason === "string") check._gate_claim_route_reason = gc.route_reason;
2439
+ };
2440
+ // #270 CRITICAL/HIGH fix: a claim that is gate-claim-SHAPED but carries NO metadata.gate_claim
2441
+ // stamp predates this cluster (#270/#344): buildTrustBundle could not have produced this shape
2442
+ // without ALSO writing the stamp, once the stamping code shipped, EXCEPT for one legitimate,
2443
+ // longstanding case that must NOT be flagged: a plain record-evidence check (kind:"external" or
2444
+ // otherwise) that simply happens to auto-match a declared kit-typed claim via matchExpectsEntry's
2445
+ // existing P-d fallback (ADR 0016) while a flow step is active — that path has ALWAYS existed,
2446
+ // never goes through record-gate-claim, and correctly has no gate_claim stamp (there is no
2447
+ // expectation id to freeze; matchExpectsEntry's heuristic result is expected to re-derive on
2448
+ // every rebuild for a plain check). The ONLY structurally reliable signal that a claim was
2449
+ // ACTUALLY produced by record-gate-claim (as opposed to a plain check that merely resembles one)
2450
+ // is record-gate-claim's own check-id-generation convention: `id: \`gate-claim-${checkId}\``
2451
+ // (see recordGateClaim) — no other producer in this file ever creates that id shape. Requiring
2452
+ // ALL FOUR signals (origin:"check", check_kind:"external", a kit-typed claimType — i.e. does NOT
2453
+ // start with "workflow." — AND a subjectId whose last path segment starts with "gate-claim-")
2454
+ // narrows detection to exactly the pre-cluster-270 defect class, without misclassifying an
2455
+ // ordinary declared check. Detected here (not in buildTrustBundle) because the claim's OWN
2456
+ // claimType/origin/check_kind/subjectId — the fields the detection needs — live on the bundle
2457
+ // claim, not on the reconstructed `check` object; buildTrustBundle only ever sees the `check`,
2458
+ // so the detection result is threaded through as a stamp on the check object itself, exactly
2459
+ // like every other gate_claim field above. buildTrustBundle's job is then only to die() loudly
2460
+ // on this stamp — never to re-derive or silently re-type it (that silent re-typing IS the
2461
+ // #268/#270 defect class).
2462
+ const gateClaimShapeUnstampedId = (claim: AnyObj): string | null => {
2463
+ if (claimOrigin(claim) !== "check") return null;
2464
+ const md = (claim.metadata && typeof claim.metadata === "object") ? claim.metadata as AnyObj : {};
2465
+ if (md.check_kind !== "external") return null;
2466
+ const claimType = typeof claim.claimType === "string" ? claim.claimType : "";
2467
+ if (!claimType || claimType.startsWith("workflow.")) return null;
2468
+ const subjectId = typeof claim.subjectId === "string" ? claim.subjectId : "";
2469
+ const lastSegment = subjectId.split("/").pop() ?? "";
2470
+ if (!lastSegment.startsWith("gate-claim-")) return null; // not record-gate-claim-shaped at all
2471
+ if (gateClaimOf(claim)) return null; // properly stamped — not this defect class
2472
+ return typeof claim.id === "string" ? claim.id : "<unknown>";
2473
+ };
2474
+ const applyGateClaimShapeUnstamped = (check: AnyObj, claim: AnyObj): void => {
2475
+ const unstampedId = gateClaimShapeUnstampedId(claim);
2476
+ if (unstampedId) check._gate_claim_shape_unstamped_claim_id = unstampedId;
2477
+ };
1882
2478
  for (const ev of bundle.evidence) {
1883
2479
  if (!ev || !ev.claimId) continue;
1884
2480
  const claim = claimById.get(ev.claimId);
@@ -1891,6 +2487,13 @@ function checksFromBundle(dir: string): AnyObj[] {
1891
2487
  const check: AnyObj = { id: String(claim.subjectId || "").split("/").pop() || ev.claimId, kind, status, summary: claim.fieldOrBehavior || "" };
1892
2488
  if (ev.execution && typeof ev.execution.label === "string") check.command = ev.execution.label;
1893
2489
  if (ev.evidenceType) check.evidenceType = ev.evidenceType;
2490
+ const waiver = waiverOf(claim);
2491
+ if (waiver) check._waiver = waiver;
2492
+ Object.assign(check, refsOf(claim));
2493
+ const outputSha256 = outputSha256Of(claim);
2494
+ if (outputSha256) check._output_sha256 = outputSha256;
2495
+ applyGateClaimStamp(check, claim);
2496
+ applyGateClaimShapeUnstamped(check, claim);
1894
2497
  checks.push(check);
1895
2498
  }
1896
2499
  // Also include check claims that have no evidence item (surface_trust_refs style).
@@ -1900,10 +2503,65 @@ function checksFromBundle(dir: string): AnyObj[] {
1900
2503
  if (seen.has(claim.id)) continue;
1901
2504
  seen.add(claim.id);
1902
2505
  const kind = kindOf(claim);
1903
- checks.push({ id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" });
2506
+ const check: AnyObj = { id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" };
2507
+ const waiver = waiverOf(claim);
2508
+ if (waiver) check._waiver = waiver;
2509
+ Object.assign(check, refsOf(claim));
2510
+ const outputSha256 = outputSha256Of(claim);
2511
+ if (outputSha256) check._output_sha256 = outputSha256;
2512
+ applyGateClaimStamp(check, claim);
2513
+ applyGateClaimShapeUnstamped(check, claim);
2514
+ checks.push(check);
1904
2515
  }
1905
2516
  return checks;
1906
2517
  }
2518
+
2519
+ /**
2520
+ * #270 CRITICAL fix (iteration 6): build the id -> hasStamp map normalizeCheck's narrowed
2521
+ * reserved-prefix exemption needs. A check's reconstructed `_gate_claim_expectation_id` (set by
2522
+ * applyGateClaimStamp above, from the claim's metadata.gate_claim) is present if and only if the
2523
+ * bundle claim backing this check id currently carries a live stamp. Every caller of
2524
+ * normalizeCheck (record-evidence, record-check, dogfood-pass) must derive this from the SAME
2525
+ * readBundleState/checksFromBundle snapshot used for the compose-safe merge, taken BEFORE
2526
+ * normalizeCheck runs, so the exemption sees the bundle state as it stands right now — not a
2527
+ * stale or partial view.
2528
+ */
2529
+ function existingCheckStampMap(checks: AnyObj[]): Map<string, boolean> {
2530
+ const byId = new Map<string, boolean>();
2531
+ for (const c of checks) if (c && typeof c.id === "string") byId.set(c.id, typeof c._gate_claim_expectation_id === "string");
2532
+ return byId;
2533
+ }
2534
+
2535
+ /**
2536
+ * #298/#270: the shared compose-safe read path every trust-bundle writer should use instead of
2537
+ * hand-assembling its own partial slice. Reconstructs ALL THREE families losslessly from the
2538
+ * existing bundle (checks via checksFromBundle, extended per above; criteria from
2539
+ * acceptance.json; critiques via critiquesFromBundle) so a writer that only intends to touch ONE
2540
+ * family never has to pass `[]` for the other two — that `[]`-for-untouched-slices pattern is
2541
+ * exactly what caused #270's 21-claims-to-1 wipe. Generalizes the pattern recordLearning already
2542
+ * used inline; behavior-neutral for recordLearning itself.
2543
+ */
2544
+ function readBundleState(dir: string): { checks: AnyObj[]; criteria: AnyObj[]; critiques: AnyObj[] } {
2545
+ const acceptance = loadJson(path.join(dir, "acceptance.json"));
2546
+ return {
2547
+ checks: checksFromBundle(dir),
2548
+ criteria: Array.isArray(acceptance.criteria) ? acceptance.criteria : [],
2549
+ critiques: critiquesFromBundle(dir),
2550
+ };
2551
+ }
2552
+ /**
2553
+ * #298: compose-safe merge-by-id for the `checks` slice of readBundleState — a later check
2554
+ * with the same `id` supersedes/replaces the earlier one (same-id resupply is a legitimate
2555
+ * update, e.g. re-recording the same gate claim or the same named check after a fix); a check
2556
+ * with a new `id` is additive. Order is preserved (existing order, then newly-introduced ids
2557
+ * appended) so unrelated diagnostics (e.g. CI reconcile output) do not reorder churn.
2558
+ */
2559
+ function mergeChecksById(existing: AnyObj[], incoming: AnyObj[]): AnyObj[] {
2560
+ const byId = new Map<string, AnyObj>();
2561
+ for (const c of existing) if (c && c.id) byId.set(c.id, c);
2562
+ for (const c of incoming) if (c && c.id) byId.set(c.id, c);
2563
+ return [...byId.values()];
2564
+ }
1907
2565
  function critiquesFromBundle(dir: string): AnyObj[] {
1908
2566
  const bundle = loadJson(path.join(dir, "trust.bundle"));
1909
2567
  if (!Array.isArray(bundle.claims)) return [];
@@ -1950,7 +2608,14 @@ async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number>
1950
2608
  const slug = taskSlugFor(dir, opt(p, "task-slug"));
1951
2609
  const _ts0 = opt(p, "timestamp", now());
1952
2610
  const _waiver = parseWaiver(p, _ts0);
1953
- const _checksRaw = [...opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"))), ...opts(p, "surface-trust-json").map(surfaceCheckFromArtifact)];
2611
+ // #270 follow-up fix (iteration 5, narrowed iteration 6): read the bundle's CURRENT check ids
2612
+ // (and stamp status) before normalizeCheck runs so a correction of an UNSTAMPED wedged id is
2613
+ // exempted from the reserved gate-claim- prefix rejection below, while a brand-new mint of
2614
+ // that shape, OR supersession of a STAMPED (live) gate claim, is still rejected. Reused below
2615
+ // (not re-read) as _existingState for the compose-safe merge — one readBundleState call.
2616
+ const _existingState = readBundleState(dir);
2617
+ const _existingCheckStampById = existingCheckStampMap(_existingState.checks);
2618
+ const _checksRaw = [...opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"), false, _existingCheckStampById)), ...opts(p, "surface-trust-json").map(surfaceCheckFromArtifact)];
1954
2619
  // WS8 (AC4, iteration 2): a command-backed check reconciles against CI or fails — it can
1955
2620
  // NEVER be waived. Reject --accepted-gap-reason/--waived-by on any check whose evidence
1956
2621
  // classifies as test_output (build/types/lint/test/command, and security/browser/runtime
@@ -1968,23 +2633,181 @@ async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number>
1968
2633
  }
1969
2634
  const checks = _checksRaw.map((c) => _waiver ? { ...c, _waiver } : c);
1970
2635
  if (!checks.length && opts(p, "surface-trust-json").length === 0) die("record-evidence requires at least one --check-json or --surface-trust-json");
1971
- validateAcceptanceEvidenceRefs(dir);
2636
+ validateAcceptanceEvidenceRefs(dir, p);
1972
2637
  // Phase 4c: bundle is the sole verification artifact — stop writing evidence.json and acceptance.json update.
1973
2638
  const ts = opt(p, "timestamp", now());
1974
- const _existingAcceptance = loadJson(path.join(dir, "acceptance.json"));
1975
- const _existingCriteria: AnyObj[] = Array.isArray(_existingAcceptance.criteria) ? _existingAcceptance.criteria : [];
2639
+ // #298: readBundleState + merge-by-id instead of unconditionally replacing every prior check —
2640
+ // record-evidence previously never called checksFromBundle(dir), so it dropped every check
2641
+ // recorded by an earlier record-evidence/record-check/record-gate-claim call. A later check
2642
+ // with the same id supersedes the earlier one (same-id resupply); a new id is additive.
2643
+ // (_existingState was already read above, before normalizeCheck ran, so the reserved-prefix
2644
+ // exists-check sees the SAME bundle snapshot the merge below uses — no second read needed.)
1976
2645
  const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
1977
- const _criteriaForBundle: AnyObj[] = _existingCriteria.map((c: AnyObj) => ({ ...c, status: _criteriaStatus }));
2646
+ const _criteriaForBundle: AnyObj[] = _existingState.criteria.map((c: AnyObj) => ({ ...c, status: _criteriaStatus }));
2647
+ const _mergedChecks = mergeChecksById(_existingState.checks, checks);
1978
2648
  // #268: preserve any existing critique claims (including superseded history) instead of dropping
1979
2649
  // them — record-evidence previously hardcoded critiques:[] here, silently erasing finding history
1980
2650
  // whenever it ran after a critique existed.
1981
- const _existingCritiques: AnyObj[] = critiquesFromBundle(dir);
1982
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, _existingCritiques));
2651
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _criteriaForBundle, _existingState.critiques));
1983
2652
  const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
1984
2653
  writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
1985
2654
  return 0;
1986
2655
  }
1987
2656
 
2657
+ // #380: bounded stdout+stderr digest for a record-check execution, mirroring
2658
+ // scripts/hooks/evidence-capture.js's MAX_OUTPUT_SCAN bound (64 KiB) so the two capture code
2659
+ // paths (host-observed vs. self-executed) stay size-disciplined the same way, even though this
2660
+ // one runs the command itself rather than observing a host tool_response.
2661
+ const RECORD_CHECK_MAX_OUTPUT = 64 * 1024;
2662
+ function clampOutput(text: string): string {
2663
+ return text.length > RECORD_CHECK_MAX_OUTPUT ? text.slice(0, RECORD_CHECK_MAX_OUTPUT) : text;
2664
+ }
2665
+
2666
+ /**
2667
+ * record-check — run a command and record a capture-backed check in one step (#380).
2668
+ *
2669
+ * Trust posture (#270 MEDIUM fix — was previously misdescribed): record-check EXECUTES a command
2670
+ * ON THE AGENT'S BEHALF, in-process, right here — it is not an observer. This is a materially
2671
+ * DIFFERENT trust posture than scripts/hooks/evidence-capture.js, which never executes anything
2672
+ * itself; it only OBSERVES a host tool_response that some other, already-executed tool call
2673
+ * produced (a PostToolUse hook). record-check's exitCode/observedResult classification mirrors
2674
+ * evidence-capture.js's observeResult() heuristic (a clean integer exit code is authoritative),
2675
+ * but the EXECUTION itself is this function's own doing, under this process's privileges — an
2676
+ * agent invoking record-check is asking this CLI to run a command for it, not merely to attest to
2677
+ * something that already ran. Callers and reviewers should weigh that accordingly: record-check
2678
+ * is a convenience that collapses "run a command" + "record its result" into one step, not a
2679
+ * passive-observation capture path.
2680
+ *
2681
+ * Syntax: `workflow-sidecar record-check <artifact-dir> -- <command...>` (argv after the FIRST
2682
+ * literal `--` token is the command to execute verbatim, split by main() before parseArgs — see
2683
+ * main()'s pre-split below) or `--command "<shell string>"` for parity with record-gate-claim
2684
+ * when the caller already has a single shell string rather than an argv array.
2685
+ *
2686
+ * Captures { exitCode, observedResult } the same way evidence-capture.js's observeResult() does:
2687
+ * a clean integer exit code drives observedResult (0 => pass, nonzero => fail). Records a
2688
+ * kind:"command" check with a real execution.label (the executed command), composed losslessly
2689
+ * through the same readBundleState + mergeChecksById + writeTrustBundle path every other writer
2690
+ * in this file uses — record-check is the compose-safe pattern PLUS an execution step in front
2691
+ * of it, not a parallel bundle-writing implementation.
2692
+ *
2693
+ * The captured stdout+stderr is hashed (sha256) and the digest — never the raw output — is
2694
+ * persisted onto the resulting claim's metadata.output_digest (see buildTrustBundle's
2695
+ * outputDigestMeta) so a later reviewer can confirm two independent runs produced byte-identical
2696
+ * output without the trust.bundle itself ever carrying raw command output (secret-safe by
2697
+ * construction).
2698
+ *
2699
+ * A non-zero exit records status "fail" (never silently swallowed) AND this command itself exits
2700
+ * non-zero — a check that failed to run correctly must be loud, not silently recorded as if
2701
+ * nothing happened.
2702
+ */
2703
+ async function recordCheck(p: ReturnType<typeof parseArgs>, commandArgv: string[] | null): Promise<number> {
2704
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
2705
+ const slug = taskSlugFor(dir, opt(p, "task-slug"));
2706
+ const ts = opt(p, "timestamp", now());
2707
+ const commandString = opt(p, "command");
2708
+ if (!commandArgv?.length && !commandString) die('record-check requires a command: either `record-check <dir> -- <command...>` or `--command "<shell string>"`');
2709
+ if (commandArgv?.length && commandString) die("record-check: pass the command via EITHER `-- <command...>` OR --command, not both");
2710
+
2711
+ const repoRoot = findRepoRootFromDir(dir);
2712
+ let displayCommand: string;
2713
+ let exitCode: number | null;
2714
+ let stdout = "";
2715
+ let stderr = "";
2716
+ try {
2717
+ if (commandArgv?.length) {
2718
+ displayCommand = commandArgv.join(" ");
2719
+ const out = execFileSync(commandArgv[0]!, commandArgv.slice(1), { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 600000 });
2720
+ stdout = out;
2721
+ exitCode = 0;
2722
+ } else {
2723
+ // #412: a --command shell string is validated the same way record-gate-claim's --command
2724
+ // is (single-sourced isRunnableCommandText) — prose does not belong here either.
2725
+ const { isRunnableCommandText } = loadRunnableCommandHelper();
2726
+ if (!isRunnableCommandText(commandString)) die(`record-check --command "${commandString}" is not a runnable shell command.`);
2727
+ displayCommand = commandString;
2728
+ const out = execFileSync("bash", ["-lc", commandString], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 600000 });
2729
+ stdout = out;
2730
+ exitCode = 0;
2731
+ }
2732
+ } catch (err) {
2733
+ const e = err as { status?: number | null; stdout?: string; stderr?: string; message?: string };
2734
+ exitCode = typeof e.status === "number" ? e.status : null;
2735
+ stdout = typeof e.stdout === "string" ? e.stdout : "";
2736
+ stderr = typeof e.stderr === "string" ? e.stderr : (e.message ?? "");
2737
+ displayCommand = commandArgv?.length ? commandArgv.join(" ") : commandString;
2738
+ }
2739
+ // Mirror evidence-capture.js's observeResult(): a clean integer exit code is authoritative
2740
+ // (0 => pass, nonzero => fail); this path always HAS a clean integer exit code because it ran
2741
+ // the command itself (unlike the host-observation path, which sometimes only has failure
2742
+ // indicators). See scripts/hooks/evidence-capture.js's observeResult()/cleanExitCode().
2743
+ //
2744
+ // #362 (Wave 3, AC6): the SAME narrow, two-binary carve-out `runBackstop`/`readCommandLog`
2745
+ // (stop-goal-fit.js) and `observeResult` (evidence-capture.js) already apply — a bare
2746
+ // (non-negated, non-count-asserted, non-chained) `grep`/`diff` invocation that exits EXACTLY
2747
+ // 1 is exit-code-ambiguous (zero matches/no diff could be the author's intended PASS for an
2748
+ // absence check, or an unintended miss for a presence check) — is applied HERE at record time
2749
+ // too, since record-check is the most direct match to the issue's own AC12 field evidence: it
2750
+ // executes the command itself and stamps the resulting status. Exit codes >= 2 are untouched
2751
+ // (still "fail", a real tool error) — the carve-out is exit-code-1-only, never widened.
2752
+ //
2753
+ // There is no "ambiguous" value in the check-status enum (`checkStatuses`,
2754
+ // schemas/workflow-acceptance.schema.json's criteria[].status enum) and none is added here —
2755
+ // per the plan (and ADR 0008/0010 consume-never-fork), the ambiguous case maps onto the
2756
+ // EXISTING "not_verified" status rather than inventing a new enum value that every consumer of
2757
+ // that enum would need auditing for. The distinction from a hard "fail" is carried in the
2758
+ // stderr note and the check summary text, not a new status value.
2759
+ const { isAmbiguousAbsenceCommand } = loadRunnableCommandHelper();
2760
+ const ambiguous = exitCode === 1 && isAmbiguousAbsenceCommand(displayCommand ?? "");
2761
+ const status = exitCode === 0 ? "pass" : (ambiguous ? "not_verified" : "fail");
2762
+ // #270 MEDIUM fix: hash the captured (clamped) output — the digest itself is what gets
2763
+ // persisted onto claim.metadata (below, via buildTrustBundle), NEVER the raw output. This is
2764
+ // secret-safe by construction: even if the executed command's stdout/stderr contained a
2765
+ // credential or token, only its sha256 hex digest ever reaches the trust.bundle (which is
2766
+ // frequently committed/shared), not the text itself. The previous `_output_digest` field held
2767
+ // the raw clamped text under a misleading "digest" name and was never actually persisted
2768
+ // anywhere (dead code) — this replaces it with a REAL digest that IS persisted.
2769
+ const capturedOutput = clampOutput(`${stdout}${stderr ? `
2770
+ ${stderr}` : ""}`.trim());
2771
+ const outputSha256 = capturedOutput ? createHash("sha256").update(capturedOutput, "utf8").digest("hex") : null;
2772
+ const checkIdRaw = opt(p, "id") || displayCommand;
2773
+ const checkId = checkIdRaw.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "record-check";
2774
+ 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" : ""}`);
2775
+ // #270 follow-up fix (iteration 5, narrowed iteration 6): read the bundle's CURRENT check ids
2776
+ // (and stamp status) BEFORE normalizeCheck runs so a correction of an UNSTAMPED wedged id is
2777
+ // exempted from the reserved gate-claim- prefix rejection, while a brand-new mint of that
2778
+ // shape, OR supersession of a STAMPED (live) gate claim, is still rejected. Reused below (not
2779
+ // re-read) for the compose-safe merge — one readBundleState call.
2780
+ const _existingState = readBundleState(dir);
2781
+ const _existingCheckStampById = existingCheckStampMap(_existingState.checks);
2782
+ // kind:"command" + a real `command` string already derives evidenceType "test_output" via
2783
+ // classifyEvidence in buildTrustBundle — no separate evidenceType input is consumed there.
2784
+ const check: AnyObj = normalizeCheck({
2785
+ id: checkId,
2786
+ kind: "command",
2787
+ status,
2788
+ summary,
2789
+ command: displayCommand,
2790
+ }, false, _existingCheckStampById);
2791
+ if (outputSha256) check._output_sha256 = outputSha256;
2792
+
2793
+ const _mergedChecks = mergeChecksById(_existingState.checks, [check]);
2794
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _existingState.criteria, _existingState.critiques));
2795
+ if (ambiguous) {
2796
+ // #362 (AC6): never silent-pass, never hard-fail — a bare grep/diff exit 1 is recorded
2797
+ // "not_verified" (above) and this is surfaced loudly on stderr, but record-check itself
2798
+ // still exits 0: the command DID run (this is not a tool/execution error), and the operator
2799
+ // needs an actionable nudge, not a blocked pipeline. Exit codes >= 2 for grep/diff remain a
2800
+ // hard "fail" via the branch below, unchanged.
2801
+ 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`);
2802
+ return 0;
2803
+ }
2804
+ if (status === "fail") {
2805
+ process.stderr.write(`[record-check] command failed (exit ${exitCode ?? "unknown"}): ${displayCommand}\n${stderr ? `${stderr}\n` : ""}`);
2806
+ return 1;
2807
+ }
2808
+ return 0;
2809
+ }
2810
+
1988
2811
  function diagnostic(dir: string, code: string, summary: string): never {
1989
2812
  const payload = { timestamp: now(), code, summary };
1990
2813
  appendJsonl(path.join(dir, "transition-diagnostics.jsonl"), payload);
@@ -1999,6 +2822,7 @@ function diagnostic(dir: string, code: string, summary: string): never {
1999
2822
  * --status <pass|fail|not_verified> (required)
2000
2823
  * --summary <text> (required)
2001
2824
  * --expectation <id> (optional; auto-resolved when the gate has one entry)
2825
+ * --route-reason <classifier> (optional; fail only, interpreted by Flow)
2002
2826
  * --evidence-json <json> (optional; structured evidence refs)
2003
2827
  *
2004
2828
  * The producer emits a check of kind="external" targeting the gate expectation's declared
@@ -2025,6 +2849,9 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
2025
2849
  if (!["pass", "fail", "not_verified"].includes(statusVal)) die("--status must be one of: pass, fail, not_verified");
2026
2850
  const summary = opt(p, "summary") || die("--summary is required");
2027
2851
  const expectationId = opt(p, "expectation");
2852
+ const routeReason = opt(p, "route-reason");
2853
+ if (routeReason && statusVal !== "fail") die("--route-reason is only valid with --status fail");
2854
+ if (routeReason && !/^[a-z][a-z0-9_-]*$/.test(routeReason)) die("--route-reason must be a lowercase classifier identifier");
2028
2855
 
2029
2856
  // Resolve the active flow step from current.json. #291 Wave 2 Task 2.1 (§7)/Task 2.2: resolve
2030
2857
  // the CALLING actor's own current-pointer (per-actor-first, legacy-fallback) rather than an
@@ -2035,6 +2862,9 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
2035
2862
  const gateClaimActorKey = resolveReadActorKey(p);
2036
2863
  const activeStep = resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
2037
2864
  if (!activeStep) die("record-gate-claim requires an active flow step in current.json (set via ensure-session --flow-id or advance-state --flow-definition)");
2865
+ if (routeReason && !activeStep.routeBackReasons.includes(routeReason)) {
2866
+ die(`--route-reason "${routeReason}" is not declared by gate "${activeStep.gateId}". Available: ${activeStep.routeBackReasons.join(", ") || "none"}`);
2867
+ }
2038
2868
 
2039
2869
  const expects = activeStep.gateExpects;
2040
2870
  if (expects.length === 0) die(`record-gate-claim: active step "${activeStep.stepId}" gate "${activeStep.gateId}" has no expects[] entries`);
@@ -2066,6 +2896,7 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
2066
2896
  status: statusVal,
2067
2897
  summary,
2068
2898
  _gate_claim_expectation_id: targetExpectation.id,
2899
+ ...(routeReason ? { _gate_claim_route_reason: routeReason } : {}),
2069
2900
  };
2070
2901
 
2071
2902
  // Include structured evidence refs if provided
@@ -2075,13 +2906,33 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
2075
2906
  check.artifact_refs = evidenceRefs;
2076
2907
  }
2077
2908
 
2078
- const checkNormalized = normalizeCheck(check);
2909
+ // #270(b)/#412: --command gives a gate claim a real, runnable execution.label distinct from
2910
+ // the human --summary prose, so stop-goal-fit.js's bundleClaimedPassCommandChecks section (B)
2911
+ // finds a real command to re-run instead of falling back to fieldOrBehavior (the --summary
2912
+ // prose itself), which it would otherwise execute as a shell command under
2913
+ // FLOW_AGENTS_GOAL_FIT_RECHECK. Validated at record time with the same isRunnableCommandText
2914
+ // heuristic the Stop-hook backstop uses (single-sourced — see loadRunnableCommandHelper).
2915
+ const gateCommand = opt(p, "command");
2916
+ if (gateCommand) {
2917
+ const { isRunnableCommandText } = loadRunnableCommandHelper();
2918
+ if (!isRunnableCommandText(gateCommand)) die(`record-gate-claim --command "${gateCommand}" is not a runnable shell command — prose belongs in --summary, which is never executed.`);
2919
+ check.command = gateCommand;
2920
+ }
2921
+
2922
+ const checkNormalized = normalizeCheck(check, /* allowGateClaimPrefix */ true);
2079
2923
  // WS8 (ADR 0020): honor the accepted-gap waiver flags for a gate claim too.
2080
2924
  const gateWaiver = parseWaiver(p, ts);
2081
2925
  if (gateWaiver) checkNormalized._waiver = gateWaiver;
2082
2926
  // Log the targeted gate expectation for transparency (goes to stderr only)
2083
2927
  process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}${gateWaiver ? " (WAIVED accepted_gap)" : ""}\n`);
2084
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], [], gateClaimActorKey));
2928
+ // #270: accumulate into the FULL existing bundle state (checks/criteria/critiques) instead of
2929
+ // the prior [checkNormalized], [], [] call, which clobbered every other check/criterion/critique
2930
+ // in the bundle on every gate-claim write (the 21-claims-to-1 wipe). A gate claim against the
2931
+ // SAME expectation id supersedes the earlier check for that expectation (mergeChecksById); a
2932
+ // gate claim against a different expectation is additive.
2933
+ const _existingState = readBundleState(dir);
2934
+ const _mergedChecks = mergeChecksById(_existingState.checks, [checkNormalized]);
2935
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, _mergedChecks, _existingState.criteria, _existingState.critiques, gateClaimActorKey));
2085
2936
  return 0;
2086
2937
  }
2087
2938
 
@@ -2165,7 +3016,14 @@ async function promote(p: ReturnType<typeof parseArgs>): Promise<number> {
2165
3016
  // Optionally republish so delivery/trust.bundle carries the promotion claim for CI.
2166
3017
  if (p.flags.has("publish")) {
2167
3018
  const publishRepoRoot = opt(p, "publish-repo-root") ? path.resolve(opt(p, "publish-repo-root")) : findRepoRootFromDirStrict(dir);
3019
+ // #356 AC6: an InvalidBundleShapeError refusal must be LOUD (rethrown, so `promote`
3020
+ // itself fails/exits non-zero) — it is NOT one of the best-effort failure modes (missing
3021
+ // kits/ ancestor, I/O) this catch otherwise tolerates. A `.catch(() => {})`-style swallow
3022
+ // here would silently defeat the whole preflight for the --publish path.
2168
3023
  await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
3024
+ if (err instanceof InvalidBundleShapeError) throw err;
3025
+ if (err instanceof NotFreshHolderError) throw err;
3026
+ if (err instanceof RepoHeadMismatchError) throw err;
2169
3027
  process.stderr.write(`[promote] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
2170
3028
  });
2171
3029
  }
@@ -2221,7 +3079,7 @@ async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
2221
3079
  // call site ALSO dual-writes the per-actor projection, not only ensure-session's call site —
2222
3080
  // otherwise a session that only ever calls advance-state (never re-running ensure-session)
2223
3081
  // would never get a per-actor current.json mirror for its own FlowDefinition routing keys.
2224
- writeCurrent(root, dir, timestamp, "workflow-sidecar", "advance-state", flow, stepId, undefined, resolveReadActorKey(p));
3082
+ writeCurrent(root, dir, timestamp, "workflow-sidecar", "advance-state", flow, stepId, resolveReadActorKey(p));
2225
3083
  }
2226
3084
  }
2227
3085
  livenessLifecycle(dir, slug, LIVENESS_TERMINAL.has(status) ? "release" : "heartbeat", timestamp);
@@ -2233,8 +3091,15 @@ async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
2233
3091
  // publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir
2234
3092
  // with no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures
2235
3093
  // are visible (stderr warning), not silently swallowed.
3094
+ // #356 AC6: an InvalidBundleShapeError refusal is NOT one of those best-effort failure
3095
+ // modes — it must be LOUD and cause advance-state itself to fail (rethrown here so the
3096
+ // outer command surfaces a non-zero exit), never silently swallowed alongside a genuine
3097
+ // repo-root-resolution/I-O failure.
2236
3098
  const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
2237
3099
  await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
3100
+ if (err instanceof InvalidBundleShapeError) throw err;
3101
+ if (err instanceof NotFreshHolderError) throw err;
3102
+ if (err instanceof RepoHeadMismatchError) throw err;
2238
3103
  process.stderr.write(`[advance-state] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
2239
3104
  });
2240
3105
  }
@@ -2274,10 +3139,13 @@ async function recordCritique(p: ReturnType<typeof parseArgs>): Promise<number>
2274
3139
  return e;
2275
3140
  });
2276
3141
  const critiques = [..._mergedCritiques, critique];
2277
- // Phase 4c: build bundle from raw inputs; read checks from trust.bundle (evidence.json no longer written).
2278
- const _critiqueEvChecks: AnyObj[] = checksFromBundle(dir);
2279
- const _critiqueAccCriteria: AnyObj[] = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
2280
- assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueEvChecks, _critiqueAccCriteria, critiques));
3142
+ // Phase 4c: build bundle from raw inputs; read checks/criteria via the shared compose-safe
3143
+ // readBundleState path (#270 LOW consolidation) instead of hand-rolling the identical
3144
+ // checksFromBundle + acceptance.json read inline this is the exact pattern readBundleState
3145
+ // already exists to share; recordLearning uses it directly (see below) and recordCritique
3146
+ // previously duplicated it by hand for no reason.
3147
+ const _critiqueState = readBundleState(dir);
3148
+ assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueState.checks, _critiqueState.criteria, critiques));
2281
3149
  return 0;
2282
3150
  }
2283
3151
  function frontmatter(text: string, key: string): string {
@@ -2323,8 +3191,15 @@ async function recordRelease(p: ReturnType<typeof parseArgs>): Promise<number> {
2323
3191
  // publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir with
2324
3192
  // no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures are
2325
3193
  // visible (stderr warning), not silently swallowed.
3194
+ // #356 AC6: an InvalidBundleShapeError refusal is NOT best-effort — rethrow so record-release
3195
+ // itself fails loudly (non-zero exit) rather than silently publishing nothing while reporting
3196
+ // success. This is the crux of AC6: record-release is one of the auto-publish paths that must
3197
+ // never let a shape-invalid bundle slip past unnoticed.
2326
3198
  const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
2327
3199
  await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
3200
+ if (err instanceof InvalidBundleShapeError) throw err;
3201
+ if (err instanceof NotFreshHolderError) throw err;
3202
+ if (err instanceof RepoHeadMismatchError) throw err;
2328
3203
  process.stderr.write(`[record-release] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
2329
3204
  });
2330
3205
  return 0;
@@ -2539,6 +3414,876 @@ async function sealCheckpoint(p: ReturnType<typeof parseArgs>): Promise<number>
2539
3414
  return 0;
2540
3415
  }
2541
3416
 
3417
+
3418
+ // ─── Reconcile Preflight (#356) ───────────────────────────────────────────────
3419
+ // Local, pre-push shape-only preflight for a session's trust.bundle, reusing
3420
+ // scripts/lib/reconcile-shape.js (WS8/#356 extraction) and scripts/ci/trust-reconcile.js's
3421
+ // own exported manifest resolver — never a forked reimplementation, so this can never
3422
+ // silently drift from what the CI trust-reconcile job actually enforces. Deliberately
3423
+ // shape-only: it never spawns a fresh manifest/CI command (AC5) — only the already-cheap
3424
+ // `run-baseline.sh --manifest-json` static registry emit, which prints the manifest, not
3425
+ // test results.
3426
+
3427
+ /**
3428
+ * Delegate to the shared pure-CJS bundle-shape module (scripts/lib/reconcile-shape.js),
3429
+ * mirroring the createRequire pattern used by loadActorIdentityHelper()/
3430
+ * loadLivenessWriteHelper() above — the one repo/runtime boundary #356's plan flagged as
3431
+ * worth double-checking (workflow-sidecar.ts is TS→ESM-compiled-to-CJS-compatible-output;
3432
+ * scripts/lib/reconcile-shape.js is plain CommonJS). Verified clean via `npm run build`
3433
+ * + a require() smoke against build/src/cli/workflow-sidecar.js before this was wired in.
3434
+ */
3435
+ function loadReconcileShapeHelper(): {
3436
+ classifyBundleClaims: (bundle: AnyObj) => {
3437
+ reconcilable: AnyObj[];
3438
+ sessionLocal: AnyObj[];
3439
+ noEvidenceCommand: AnyObj[];
3440
+ waiverOnCommand: AnyObj[];
3441
+ };
3442
+ normalizeCmd: (cmd: string) => string;
3443
+ waiverOnCommandIssues: (waiverOnCommand: AnyObj[]) => AnyObj[];
3444
+ noEvidenceCommandIssues: (noEvidenceCommand: AnyObj[]) => AnyObj[];
3445
+ reconcilableManifestIssues: (reconcilable: AnyObj[], manifestByCmd: Map<string, AnyObj>) => { issues: AnyObj[]; unresolved: AnyObj[] };
3446
+ sessionLocalShapeIssues: (
3447
+ sessionLocal: AnyObj[],
3448
+ derivedStatus: Map<string, string | null> | null,
3449
+ opts?: { onUnderivable?: "fail" | "reduce" }
3450
+ ) => { issues: AnyObj[]; attestedCount: number; logEvents: AnyObj[] };
3451
+ } {
3452
+ const _req = createRequire(import.meta.url);
3453
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/lib/reconcile-shape.js");
3454
+ return _req(helperPath);
3455
+ }
3456
+
3457
+ /**
3458
+ * Delegate to scripts/ci/trust-reconcile.js's own EXPORTED pure helpers (manifest
3459
+ * resolution + the git-ancestor primitive) — same createRequire idiom as
3460
+ * loadReconcileShapeHelper() above. trust-reconcile.js is CommonJS; these exports were
3461
+ * added alongside `runTrustReconcile` specifically so a local caller (this preflight)
3462
+ * never needs a second implementation of "how the manifest is resolved" (Q1/AC5).
3463
+ */
3464
+ function loadTrustReconcileHelper(): {
3465
+ resolveManifest: (args: { manifest?: string | null }, repoRoot: string, canonicalCommands: string[]) => { entries: Array<{ id: string; command: string }>; source: string };
3466
+ runBaselineManifest: (repoRoot: string) => Array<{ id: string; command: string }> | null;
3467
+ normalizeManifestEntries: (raw: unknown) => Array<{ id: string; command: string }> | null;
3468
+ slugifyLabel: (s: string) => string;
3469
+ normalizeCmd: (cmd: string) => string;
3470
+ isAncestorCommit: (repoRoot: string, ancestorSha: string, descendantSha: string) => boolean;
3471
+ resolveCanonicalCommands: (args: { commands: string[] }, repoRoot: string) => string[] | null;
3472
+ } {
3473
+ const _req = createRequire(import.meta.url);
3474
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/ci/trust-reconcile.js");
3475
+ return _req(helperPath);
3476
+ }
3477
+
3478
+ /**
3479
+ * Shell to scripts/ci/derive-claim-status.mjs exactly as trust-reconcile.js's own
3480
+ * deriveClaimStatuses() does (Q1's recommendation: full status-misassertion/unwaived-assumed
3481
+ * parity with CI, at the cost of one cheap local-only spawn — no CI command execution, just
3482
+ * Surface's pure deriveClaimStatus over the bundle's own evidence/events/policies). Returns
3483
+ * null when re-derivation is unavailable (Surface could not load / helper failed) — callers
3484
+ * degrade to reconcile-shape.js's documented reduced-coverage mode, they do not fail the
3485
+ * whole preflight over this.
3486
+ */
3487
+ function derivePreflightClaimStatuses(bundlePath: string, repoRoot: string): Map<string, string | null> | null {
3488
+ const helper = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/ci/derive-claim-status.mjs");
3489
+ if (!fs.existsSync(helper)) return null;
3490
+ let stdout: string;
3491
+ try {
3492
+ stdout = execFileSync(process.execPath, [helper, bundlePath], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60000 });
3493
+ } catch {
3494
+ return null;
3495
+ }
3496
+ if (!stdout) return null;
3497
+ try {
3498
+ const obj = JSON.parse(stdout);
3499
+ const m = new Map<string, string | null>();
3500
+ for (const [k, v] of Object.entries(obj)) m.set(k, v as string | null);
3501
+ return m;
3502
+ } catch {
3503
+ return null;
3504
+ }
3505
+ }
3506
+
3507
+ /**
3508
+ * Distinct, identifiable error for a shape-invalid trust.bundle — NOT a generic Error, so
3509
+ * every publishDelivery() call site (advanceState/recordRelease/promote/publishDeliveryCmd)
3510
+ * can positively distinguish "refuses to publish an invalid bundle" (must be LOUD, fail
3511
+ * closed, AC6) from the other failure modes those call sites already tolerate as best-effort
3512
+ * (missing kits/ ancestor for a scratch dir, I/O errors, etc — see each catch's own comment).
3513
+ * A bare `instanceof Error` check would not suffice since every thrown failure in this file is
3514
+ * already an Error; `code` is the recognizable, grep-stable discriminator.
3515
+ */
3516
+ export class InvalidBundleShapeError extends Error {
3517
+ readonly code = "RECONCILE_PREFLIGHT_INVALID_SHAPE" as const;
3518
+ readonly issues: string[];
3519
+ constructor(issues: string[]) {
3520
+ super(`trust.bundle failed the reconcile-preflight shape check (${issues.length} issue(s)) — see .issues for the full report`);
3521
+ this.name = "InvalidBundleShapeError";
3522
+ this.issues = issues;
3523
+ }
3524
+ }
3525
+
3526
+ /**
3527
+ * Distinct, identifiable error for a publish attempt by an actor who is NOT the fresh,
3528
+ * non-superseded holder of the subject (issue #293, ADR 0021 §3) — NOT a generic Error and NOT
3529
+ * `InvalidBundleShapeError` (#356), so every publishDelivery() call site can positively
3530
+ * distinguish "refuses to publish because the bundle shape is invalid" from "refuses to publish
3531
+ * because this actor no longer holds the subject" — two genuinely distinct fail-closed tiers
3532
+ * that must never be conflated in a catch handler (a worker fixing a shape issue must not be
3533
+ * told to "re-record evidence" when the real problem is a stale/superseded claim, and vice
3534
+ * versa). Mirrors `InvalidBundleShapeError`'s shape exactly: same `extends Error` base, same
3535
+ * `readonly code` discriminator convention, same doc-comment structure — only the payload
3536
+ * differs (the effective-state/holder/reason/guidance `runVerifyHold()` computed, rather than a
3537
+ * shape-issue list).
3538
+ */
3539
+ export class NotFreshHolderError extends Error {
3540
+ readonly code = "VERIFY_HOLD_NOT_FRESH_HOLDER" as const;
3541
+ readonly effective_state: EffectiveState | "not_evaluated";
3542
+ readonly holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string };
3543
+ readonly reason: string;
3544
+ readonly guidance: string[];
3545
+ constructor(result: { effective_state: EffectiveState | "not_evaluated"; holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string }; reason: string; guidance: string[] }) {
3546
+ super(`verify-hold refused publish — not the fresh holder of this subject (${result.reason}) — see .guidance for reconcile steps`);
3547
+ this.name = "NotFreshHolderError";
3548
+ this.effective_state = result.effective_state;
3549
+ this.holder = result.holder;
3550
+ this.reason = result.reason;
3551
+ this.guidance = result.guidance;
3552
+ }
3553
+ }
3554
+
3555
+ /**
3556
+ * Distinct, identifiable error for a publish attempt whose resolved `repoRoot` does not
3557
+ * positively confirm the session's sealed `trust.checkpoint.json` `commit_sha` — a FOURTH,
3558
+ * distinct fail-closed tier (#413 Facet A), never conflated with `InvalidBundleShapeError`
3559
+ * (bundle shape) or `NotFreshHolderError` (actor hold). This is the "refuse loudly when the cwd
3560
+ * repo does not match the sealed checkpoint commit_sha" fallback the issue itself proposed:
3561
+ * `publishDelivery`'s `repoRoot` is resolved by walking up from the ARTIFACT directory
3562
+ * (findRepoRootFromDirStrict), which always physically lives under the primary checkout even
3563
+ * when invoked from an unrelated worktree/repo — so a positively-confirmed mismatch here is the
3564
+ * signal that this resolved repoRoot is NOT the repo the sealed checkpoint was produced against,
3565
+ * and publishing into it would silently write to the wrong tree.
3566
+ *
3567
+ * #413 iteration-2 Fix 4: thrown for the ONE positively-determinable `mismatchKind` (see
3568
+ * checkpointHeadAncestry's doc comment) — `"positive"` (repoRoot's HEAD is confirmed NOT an
3569
+ * ancestor-or-equal of commit_sha; `headSha` is a real sha). It is intentionally NEVER thrown for
3570
+ * `"undeterminable-shallow"` (e.g. a shallow clone missing the commit object) — that case only
3571
+ * warns and allows the publish (see publishDelivery's own gate). A non-git `repoRoot` classifies
3572
+ * as `mismatchKind === "none"` (no opinion — allow; see checkpointHeadAncestry's doc comment for
3573
+ * why the iteration-2 Fix 5 refusal here was reverted in iteration-3) and never throws this error
3574
+ * either.
3575
+ */
3576
+ export class RepoHeadMismatchError extends Error {
3577
+ readonly code = "PUBLISH_DELIVERY_REPO_HEAD_MISMATCH" as const;
3578
+ readonly repoRoot: string;
3579
+ readonly commitSha: string;
3580
+ readonly headSha: string;
3581
+ constructor(repoRoot: string, commitSha: string, headSha: string) {
3582
+ 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.`);
3583
+ this.name = "RepoHeadMismatchError";
3584
+ this.repoRoot = repoRoot;
3585
+ this.commitSha = commitSha;
3586
+ this.headSha = headSha;
3587
+ }
3588
+ }
3589
+
3590
+ /**
3591
+ * Human-actionable fix text per divergence type (Q2: unwaived-assumed's message always
3592
+ * carries the "waiver voided by a mixed record-evidence call" root-cause hint — shape #5 is
3593
+ * NOT a distinct predicate, it is #2 enriched, per the plan's Q2 resolution).
3594
+ */
3595
+ function preflightFixHint(type: string): string {
3596
+ switch (type) {
3597
+ case "unwaived-assumed":
3598
+ return "FIX: make it pass for real, or re-record with --accepted-gap-reason/--waived-by on a SEPARATE record-evidence call — a command-backed check in the SAME call voids/rejects the waiver (see the command-backed-waiver guard in workflow-sidecar.ts's recordEvidence).";
3599
+ case "waiver-on-command-check":
3600
+ return "FIX: a command-backed (test_output) check cannot be waived — either drop the waiver metadata and let it reconcile against the manifest for real, or record it as a session-local (non-command) claim if it genuinely cannot be re-run.";
3601
+ case "not-run":
3602
+ return "FIX: fold this into a non-command summary (session-local claim, no execution.label), or name the EXACT verbatim manifest command in evidence.execution.label so it reconciles.";
3603
+ case "laundering":
3604
+ return "FIX: remove the exit-code-laundering operator (||, ; true, ; exit 0, etc.) from the command — a laundered command cannot be trusted to reconcile.";
3605
+ case "session-local-failed":
3606
+ return "FIX: a disputed/failing claim always blocks reconcile. Document a disjoint pre-existing failure as prose in a WAIVED non-command summary, not as a standalone claim.";
3607
+ case "status-misassertion":
3608
+ return "FIX: the claim's asserted status does not match what Surface re-derives from the bundle's own evidence/events/policies — re-record evidence so the bundle's own data supports the asserted status; do not hand-edit status.";
3609
+ case "status-underivable":
3610
+ return "FIX: CI-side status re-derivation failed for this claim — ensure @kontourai/surface is installed/resolvable and the claim's evidence/events are well-formed, then re-record.";
3611
+ case "unwaived-session-local":
3612
+ return "FIX: this claim asserts pass but has neither a waiver nor a CI-re-derived 'verified' status — add a waiver (--accepted-gap-reason/--waived-by) or resolve it so Surface derives 'verified'.";
3613
+ default:
3614
+ return "FIX: see the divergence message above for the specific shape defect.";
3615
+ }
3616
+ }
3617
+
3618
+ /**
3619
+ * Callable core shared by BOTH the `reconcile-preflight` CLI handler and
3620
+ * publishDelivery()'s fail-closed gate (#356 Wave 3) — one implementation, not two entry
3621
+ * points that could drift. SHAPE-only: never spawns a fresh manifest/CI command (AC5) — the
3622
+ * only subprocess calls here are the already-cheap run-baseline.sh --manifest-json static
3623
+ * registry emit (inside resolveManifest, reused unchanged from trust-reconcile.js) and the
3624
+ * local-only derive-claim-status.mjs re-derivation (no CI command execution either).
3625
+ *
3626
+ * @param dir artifact/session directory containing trust.bundle
3627
+ * @param repoRoot repo root used for manifest resolution + the optional ancestor warning
3628
+ * @param manifestOverride optional --manifest JSON string (CLI passthrough)
3629
+ */
3630
+
3631
+ /**
3632
+ * #413 Facet A: best-effort single-direction ancestor check scoped to the specific needs of
3633
+ * checkpointHeadAncestry's hard-refuse gate — is `ancestorSha` positively determinable as an
3634
+ * ancestor of (or equal to) `descendantSha` in `repoRoot`, OR positively determinable as NOT an
3635
+ * ancestor, OR is the question simply UNDETERMINABLE here (shallow clone missing the object,
3636
+ * unknown/garbage sha, git itself unusable, etc.)? This is intentionally a SEPARATE, local
3637
+ * classification from `trust-reconcile.js`'s shared `isAncestorCommit()` (which the rest of the
3638
+ * repo, including checkpointStalenessWarning's own non-blocking warning below, correctly keeps
3639
+ * as a simple best-effort boolean that "fails toward false/stale" for every non-CI caller) —
3640
+ * that collapsed boolean is exactly right for a WARNING, but wrong for a HARD REFUSE gate: a
3641
+ * shallow clone's git-object-missing failure and a genuine cross-repo mismatch both surface as
3642
+ * `false` there, yet only the second is real harm (#413 iteration-2 Fix 4). `git merge-base
3643
+ * --is-ancestor` itself already distinguishes these at the exit-code level (0 = ancestor, 1 =
3644
+ * positively NOT an ancestor, anything else / a spawn failure = underivable), so this reuses
3645
+ * that primitive directly rather than duplicating trust-reconcile.js's collapsed wrapper.
3646
+ */
3647
+ function classifyAncestry(repoRoot: string, ancestorSha: string, descendantSha: string): "ancestor" | "not-ancestor" | "undeterminable" {
3648
+ if (!ancestorSha || !descendantSha) return "undeterminable";
3649
+ try {
3650
+ const res = spawnSync("git", ["merge-base", "--is-ancestor", ancestorSha, descendantSha], {
3651
+ cwd: repoRoot,
3652
+ stdio: "ignore",
3653
+ });
3654
+ if (!res || res.error) return "undeterminable";
3655
+ if (res.status === 0) return "ancestor";
3656
+ if (res.status === 1) return "not-ancestor";
3657
+ return "undeterminable"; // e.g. 128 (unknown commit, shallow clone missing the object, etc.)
3658
+ } catch {
3659
+ return "undeterminable";
3660
+ }
3661
+ }
3662
+
3663
+ /**
3664
+ * #413 Facet A: read `trust.checkpoint.json`'s `commit_sha` (if the checkpoint exists and
3665
+ * carries one) and resolve `git rev-parse HEAD` with an EXPLICIT `{ cwd: repoRoot }` (never the
3666
+ * bare no-cwd pattern `resolveCommitSha()` uses — see that function's own doc comment for why
3667
+ * that variant is intentionally cwd-inheriting for the seal-checkpoint path, and must not be
3668
+ * reused here). Returns `{ commitSha: null }` when there is no checkpoint or no commit_sha yet
3669
+ * (a session with nothing sealed has nothing to compare against — callers must treat this as
3670
+ * "no opinion", never as a mismatch). Returns `{ commitSha, headSha, isAncestor }` when both
3671
+ * shas are resolvable. This is the ONE shared comparison both `checkpointStalenessWarning`
3672
+ * (non-blocking) and `publishDelivery`'s hard refuse gate (fail-closed) call — never a second,
3673
+ * independently-drifting git shell-out.
3674
+ *
3675
+ * #413 iteration-2 Fix 4 (scoped to the hard-refuse tier ONLY — checkpointStalenessWarning's
3676
+ * non-blocking warning is intentionally unaffected, see its own doc comment): the returned
3677
+ * `isAncestor` stays a simple boolean (`true` unless POSITIVELY determined false) for backward
3678
+ * compatibility with that warning caller, but the `mismatchKind` field lets publishDelivery's
3679
+ * hard gate distinguish these cases precisely:
3680
+ * - "none": no checkpoint, no commit_sha, `repoRoot` is not a git working tree at all (or is a
3681
+ * freshly-initialized repo with no resolvable HEAD), or the commit_sha IS an
3682
+ * ancestor-or-equal of HEAD — never refuse. A non-git `repoRoot` is a SUPPORTED flow (e.g.
3683
+ * checkpoint-signing against a bare scratch dir — see evals/integration/test_checkpoint_signing.sh
3684
+ * TEST 2), not a mismatch signal: there is no HEAD to compare against, so this is "no
3685
+ * opinion", exactly like the no-checkpoint case above.
3686
+ * - "positive": repoRoot is a real git working tree, HEAD resolves, AND commit_sha is
3687
+ * POSITIVELY determined to NOT be an ancestor of HEAD (`git merge-base --is-ancestor` exits
3688
+ * 1, a real answer, not a failure) — the genuine wrong-tree harm case; hard refuse.
3689
+ * - "undeterminable-shallow": repoRoot is a real git working tree, HEAD resolves, but whether
3690
+ * commit_sha is an ancestor of HEAD could not be determined (e.g. a shallow clone missing
3691
+ * the commit_sha object) — must NOT hard-refuse a legitimate publish from a shallow clone;
3692
+ * warn loudly and allow (the shape + verify-hold gates still stand).
3693
+ *
3694
+ * #413 iteration-2 Fix 5 (reverted, iteration-3): a prior pass hard-refused here when `repoRoot`
3695
+ * was not a git working tree at all but a sealed commit_sha existed ("a sealed delivery implies
3696
+ * the target should be a real git repo"). That premise is false — publishing against a non-git
3697
+ * `repoRoot` is a supported scratch/checkpoint-signing flow, and the refusal regressed
3698
+ * `evals/integration/test_checkpoint_signing.sh` TEST 2. The accepted residual: a non-git
3699
+ * `repoRoot` with a sealed checkpoint is ALLOWED here; wrong-tree protection for a REAL git
3700
+ * repoRoot relies on this function's ancestry (HEAD-vs-commit_sha) check plus the shape-check and
3701
+ * verify-hold gates in publishDelivery, all of which correctly no-op (allow) when there is no git
3702
+ * HEAD to compare against.
3703
+ */
3704
+ function checkpointHeadAncestry(dir: string, repoRoot: string): { commitSha: string | null; headSha: string | null; isAncestor: boolean; mismatchKind: "none" | "positive" | "undeterminable-shallow" } {
3705
+ try {
3706
+ const checkpointPath = path.join(dir, "trust.checkpoint.json");
3707
+ if (!fs.existsSync(checkpointPath)) return { commitSha: null, headSha: null, isAncestor: true, mismatchKind: "none" };
3708
+ const checkpoint = loadJson(checkpointPath);
3709
+ const commitSha = checkpoint && typeof checkpoint === "object" ? (checkpoint as AnyObj).commit_sha : undefined;
3710
+ if (typeof commitSha !== "string" || !commitSha) return { commitSha: null, headSha: null, isAncestor: true, mismatchKind: "none" };
3711
+ let headSha = "";
3712
+ try {
3713
+ headSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
3714
+ } catch {
3715
+ headSha = "";
3716
+ }
3717
+ // repoRoot is not a git working tree at all (non-git scratch dir) or is a freshly-initialized
3718
+ // repo with no resolvable HEAD yet — "no opinion", never a mismatch (see doc comment above).
3719
+ if (!headSha) return { commitSha, headSha: null, isAncestor: true, mismatchKind: "none" };
3720
+ const classification = classifyAncestry(repoRoot, commitSha, headSha);
3721
+ const isAncestor = classification !== "not-ancestor";
3722
+ const mismatchKind: "none" | "positive" | "undeterminable-shallow" =
3723
+ classification === "ancestor" ? "none" : classification === "not-ancestor" ? "positive" : "undeterminable-shallow";
3724
+ return { commitSha, headSha, isAncestor, mismatchKind };
3725
+ } catch {
3726
+ // best-effort only — a resolution failure must never be misread as a mismatch.
3727
+ return { commitSha: null, headSha: null, isAncestor: true, mismatchKind: "none" };
3728
+ }
3729
+ }
3730
+
3731
+ /**
3732
+ * F4 (iteration-1): extracted from runReconcilePreflight so the main function stays under
3733
+ * the 50-line guideline. Q3 (optional, non-blocking): warn if the checkpoint's commit_sha is
3734
+ * not yet an ancestor of local HEAD — never affects ok/exit code, best-effort only. Delegates
3735
+ * to checkpointHeadAncestry (the shared comparison helper #413 Facet A's hard gate also uses)
3736
+ * so the warning path and the hard-refuse path can never independently drift. #413 iteration-2:
3737
+ * intentionally keeps using the plain `isAncestor` boolean (never `mismatchKind`) — a shallow
3738
+ * clone's undeterminable case still deserves this SAME staleness-flavored warning text, it just
3739
+ * must never become a hard refuse (see publishDelivery's own gate for that distinction).
3740
+ */
3741
+ function checkpointStalenessWarning(dir: string, repoRoot: string): string[] {
3742
+ const warnings: string[] = [];
3743
+ const { commitSha, headSha, isAncestor } = checkpointHeadAncestry(dir, repoRoot);
3744
+ if (commitSha && headSha && !isAncestor) {
3745
+ 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.`);
3746
+ }
3747
+ return warnings;
3748
+ }
3749
+
3750
+ export function runReconcilePreflight(
3751
+ dir: string,
3752
+ repoRoot: string,
3753
+ manifestOverride?: string | null
3754
+ ): { ok: boolean; issues: string[]; warnings: string[] } {
3755
+ const bundlePath = path.join(dir, "trust.bundle");
3756
+ if (!fs.existsSync(bundlePath)) {
3757
+ // A preflight with nothing to check is a usage error, not a soft pass — an agent must
3758
+ // never read "no bundle" as "bundle is valid" (see reconcilePreflightCmd for the CLI-side
3759
+ // die()). The library entrypoint throws too, since publishDelivery() itself already has
3760
+ // its OWN, separate, pre-existing fail-soft branch for bundle-absence (guarded before this
3761
+ // function is ever called there) — this function is never invoked without a bundle.
3762
+ throw new Error(`reconcile-preflight: no trust.bundle found at ${bundlePath} — nothing to check. This is a usage error, not a soft pass.`);
3763
+ }
3764
+
3765
+ const bundle = loadJson(bundlePath);
3766
+ const shape = loadReconcileShapeHelper();
3767
+ const tr = loadTrustReconcileHelper();
3768
+
3769
+ // Resolve the SAME canonical (fresh-verify) commands CI would fall back to feeding into
3770
+ // resolveManifest's legacy tier (tier 5), for genuine parity on that fallback path too —
3771
+ // CLI --commands is not a preflight concept, so only the TRUST_RECONCILE_COMMANDS env /
3772
+ // package.json trust-reconcile-verify tiers apply locally; a repo with neither and no
3773
+ // manifest source resolves to the same empty legacy fallback CI itself would in that case.
3774
+ const canonicalCommands = tr.resolveCanonicalCommands({ commands: [] }, repoRoot) ?? [];
3775
+ const manifestResolution = tr.resolveManifest({ manifest: manifestOverride ?? null }, repoRoot, canonicalCommands);
3776
+ const manifestByCmd = new Map<string, AnyObj>();
3777
+ for (const e of manifestResolution.entries) manifestByCmd.set(tr.normalizeCmd(e.command), e);
3778
+
3779
+ const derivedStatus = derivePreflightClaimStatuses(bundlePath, repoRoot);
3780
+
3781
+ const { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand } = shape.classifyBundleClaims(bundle);
3782
+
3783
+ const issues: AnyObj[] = [];
3784
+ issues.push(...shape.waiverOnCommandIssues(waiverOnCommand));
3785
+ issues.push(...shape.noEvidenceCommandIssues(noEvidenceCommand));
3786
+ const { issues: manifestIssues } = shape.reconcilableManifestIssues(reconcilable, manifestByCmd);
3787
+ issues.push(...manifestIssues);
3788
+ // iteration-1 F1: the local preflight explicitly opts into reduced-coverage degradation
3789
+ // when re-derivation is unavailable (Surface not installed, spawn failure, etc.) — CI's
3790
+ // trust-reconcile.js does NOT opt in and stays fail-closed on the same condition (see
3791
+ // reconcile-shape.js's sessionLocalShapeIssues docstring for the full mode contract).
3792
+ const { issues: sessionLocalIssues } = shape.sessionLocalShapeIssues(sessionLocal, derivedStatus, { onUnderivable: "reduce" });
3793
+ issues.push(...sessionLocalIssues);
3794
+
3795
+ const report = issues.map((i) => `${i.message} — ${preflightFixHint(i.type)}`);
3796
+ const warnings = checkpointStalenessWarning(dir, repoRoot);
3797
+
3798
+ return { ok: report.length === 0, issues: report, warnings };
3799
+ }
3800
+
3801
+ /**
3802
+ * reconcile-preflight <artifact-dir> [--manifest <json>] [--repo-root <path>]
3803
+ *
3804
+ * Local, pre-push shape-only check of the session's trust.bundle — reuses
3805
+ * scripts/lib/reconcile-shape.js and scripts/ci/trust-reconcile.js's own exported
3806
+ * classification/manifest-resolution so this can never silently drift from what CI's
3807
+ * Trust Reconcile job enforces. Prints every divergence with the claim id, divergence
3808
+ * type, and a human fix instruction; exits 0 with zero issues, 1 otherwise. Never spawns a
3809
+ * fresh manifest/CI command re-run (AC5) — resolves the manifest and re-derives status
3810
+ * (both local-only, no CI command execution) but never re-runs a manifest command itself.
3811
+ *
3812
+ * Usage: workflow-sidecar reconcile-preflight <artifactDir> [--manifest <json>] [--repo-root <path>]
3813
+ */
3814
+ async function reconcilePreflightCmd(p: ReturnType<typeof parseArgs>): Promise<number> {
3815
+ if (p.flags.has("help") || (!p.positional[0] && !p.opts["help"])) {
3816
+ if (p.flags.has("help")) {
3817
+ console.log("Usage: workflow-sidecar reconcile-preflight <artifactDir> [--manifest <json>] [--repo-root <path>]");
3818
+ console.log("Local, pre-push shape-only check of <artifactDir>/trust.bundle. Exits 0 with no issues, 1 otherwise.");
3819
+ return 0;
3820
+ }
3821
+ }
3822
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
3823
+ const repoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
3824
+ if (!repoRoot) die(`reconcile-preflight: no kits/ ancestor found from ${dir}; pass --repo-root explicitly.`);
3825
+ const manifestOverride = p.opts["manifest"]?.at(-1) ?? null;
3826
+
3827
+ const bundlePath = path.join(dir, "trust.bundle");
3828
+ if (!fs.existsSync(bundlePath)) {
3829
+ die(`reconcile-preflight: no trust.bundle at ${bundlePath} — a preflight with nothing to check is a usage error, not a soft pass. Record evidence first (record-evidence) before running reconcile-preflight.`);
3830
+ }
3831
+
3832
+ const result = runReconcilePreflight(dir, repoRoot, manifestOverride);
3833
+
3834
+ for (const w of result.warnings) {
3835
+ process.stderr.write(`[reconcile-preflight] ${w}\n`);
3836
+ }
3837
+
3838
+ if (result.ok) {
3839
+ console.log(`[reconcile-preflight] OK — no shape issues found in ${bundlePath}`);
3840
+ return 0;
3841
+ }
3842
+
3843
+ process.stderr.write(`[reconcile-preflight] FAILED — ${result.issues.length} shape issue(s) found in ${bundlePath}:\n`);
3844
+ for (const issue of result.issues) {
3845
+ process.stderr.write(`[reconcile-preflight] - ${issue}\n`);
3846
+ }
3847
+ return 1;
3848
+ }
3849
+
3850
+ /**
3851
+ * The ONE hard-stop gate (issue #293, ADR 0021 §3): "is my actor the fresh, non-superseded
3852
+ * holder of this subject (or is the subject free/self-held)?" Reuses the SAME assignment ⋈
3853
+ * liveness join #290/#291 already built (`computeEffectiveState` + `readLocalAssignmentStatus`
3854
+ * + `readLivenessEvents`/`freshHolders`) — no second computation is invented here. This is the
3855
+ * mirror-image check, at the OTHER end of the session lifecycle, of
3856
+ * `enforceEnsureSessionOwnership`'s pre-entry guard above: that guard decides whether entry
3857
+ * should claim/supersede/refuse; this gate decides whether PUBLISH should proceed, with a
3858
+ * different (new) per-effective-state mapping — the ownership guard's "claim"/"supersede"
3859
+ * actions are wrong at publish time, so this is new interpretation of the existing join output,
3860
+ * not a new join and not a new `EffectiveState` value (there is no literal `"superseded"` state
3861
+ * — a superseded-away actor's own re-check naturally resolves to `held`(holder=successor) or
3862
+ * `reclaimable`, never `self_is_holder`; see assignment-provider.ts's `computeEffectiveState`).
3863
+ *
3864
+ * Effective-state -> publish-decision mapping (the operative spec, from the plan's table).
3865
+ * IMPORTANT (bug fix, #397): this gate fences the durable ASSIGNMENT hold, not ambient
3866
+ * liveness — liveness is advisory everywhere else in the system, and this is the ONE hard
3867
+ * gate, so it must never false-block on liveness alone. A subject with fresh liveness by
3868
+ * another actor but NO durable assignment record (`held`, reason
3869
+ * `liveness_claim_present_assignment_lagging`) therefore PASSES, not BLOCKs — there is no
3870
+ * durable ownership conflict to protect, only ambient presence. This does NOT weaken zombie
3871
+ * protection: a superseded zombie always has an assignment record (either its own stale
3872
+ * assignment — `reclaimable` — or the successor's assignment-backed `held`/
3873
+ * `fresh_liveness_heartbeat`), both of which still BLOCK below.
3874
+ * - `free` (no assignment ever recorded) -> PASS (never false-block an untracked subject)
3875
+ * - `held`, reason `self_is_holder` -> PASS (the caller IS the current holder)
3876
+ * - `held`, reason `liveness_claim_present_assignment_lagging`
3877
+ * (fresh liveness by another actor, NO durable assignment record) -> PASS (liveness alone
3878
+ * is never a durable ownership conflict — see #397 fix note above)
3879
+ * - `held`, reason `fresh_liveness_heartbeat`
3880
+ * (durable assignment held by another actor + fresh liveness) -> BLOCK (a different
3881
+ * actor durably holds this subject and is demonstrably still live)
3882
+ * - `reclaimable` (assignment present, stale/absent liveness) -> BLOCK (not proof of
3883
+ * self — safe default blocks; a durable assignment record exists)
3884
+ * - `human-held` -> BLOCK (never publish over a human assignee)
3885
+ * - no resolvable join at all (non-local-file provider
3886
+ * with no --effective-state-json) -> `not_evaluated`, PASS (documented scope
3887
+ * boundary, loud stderr note, never a silent block)
3888
+ *
3889
+ * Actor resolution mirrors `enforceEnsureSessionOwnership` EXACTLY: the bare
3890
+ * `resolveActor(env).actor` / branchActorKey string, NEVER `serializeActor(actorStruct)`'s
3891
+ * triple — the #291 flat-vs-triple seam. `opts.actorKey` (the CLI's --actor / FLOW_AGENTS_ACTOR
3892
+ * override, already sanitized by the caller) takes precedence when provided.
3893
+ *
3894
+ * Slug/artifactRoot derivation matches `publishDelivery()`'s own `slug =
3895
+ * path.basename(path.resolve(dir))` byte-for-byte (both must resolve the SAME assignment
3896
+ * record) and the `artifactRoot = path.dirname(dir)` pattern already used elsewhere in this
3897
+ * file for a `dir`-only consumer.
3898
+ *
3899
+ * github provider: read-only precomputed `--effective-state-json` escape hatch, matching
3900
+ * `enforceEnsureSessionOwnership`'s existing github branch precedent exactly (render-don't-
3901
+ * execute — assignment-provider.ts never calls `gh` directly from this CLI).
3902
+ *
3903
+ * Every interpolated actor/holder/reason string is sanitized via
3904
+ * `stripControlCharsForDisplay(...).slice(0, 64)` — reusing the exact top-level helper, never a
3905
+ * second sanitizer (AC7).
3906
+ *
3907
+ * SECOND CI-blocking false-block fix (this iteration): the hard gate above enforces ONLY for a
3908
+ * STABLY-identified actor. A caller's identity is STABLE when it comes from an explicit
3909
+ * `opts.actorKey` (a caller that hands in an actor key is asserting a stable identity — e.g. the
3910
+ * CLI's --actor / FLOW_AGENTS_ACTOR override, or the github skill's precomputed path), from
3911
+ * `FLOW_AGENTS_ACTOR` (`resolveActor` source `"explicit-override"`), or from a runtime-native
3912
+ * session id (`resolveActor` source `` `runtime-session-id:${runtime}` ``, e.g. Claude Code's
3913
+ * `CLAUDE_CODE_SESSION_ID`). It is UNSTABLE when `resolveActor` falls all the way through to the
3914
+ * process-ancestry fallback (source `"process-ancestry"`) or fails to resolve at all (source
3915
+ * `"unresolved"` / `isUnresolvedActor(actor)`). This mirrors `enforceEnsureSessionOwnership`'s
3916
+ * Design Decision 4 (never hard-fail on actor ambiguity): a session that cannot stably
3917
+ * self-identify cannot be meaningfully fenced against a durable assignment record, so hard-
3918
+ * blocking it produces exactly the false-block CI hit (no `FLOW_AGENTS_ACTOR`/runtime session id
3919
+ * in CI -> ancestry fallback -> a DIFFERENT ancestry-derived actor string than the one that
3920
+ * created the claim -> `reclaimable`/not-self -> hard BLOCK of a legitimate self-publish). A
3921
+ * real coordination participant always has a stable identity (explicit override or runtime
3922
+ * session id), so zombie protection under a stable actor is completely unaffected — this only
3923
+ * changes behavior for a caller that was never fenceable in the first place. When the resolved
3924
+ * actor identity is UNSTABLE, this function short-circuits to `{ ok: true, effective_state:
3925
+ * "not_evaluated", reason: "actor-identity-unstable-advisory-only" }` (holder included, sanitized,
3926
+ * when one exists) BEFORE running the assignment ⋈ liveness join at all, with one loud stderr
3927
+ * note — the gate degrades to advisory-only rather than ever false-blocking an unstable identity.
3928
+ * When the actor identity IS stable, the effective-state -> publish-decision mapping above
3929
+ * applies exactly as documented, unchanged.
3930
+ */
3931
+ export function runVerifyHold(
3932
+ dir: string,
3933
+ repoRoot: string | null,
3934
+ opts?: { actorKey?: string; now?: number; assignmentProviderKind?: string; effectiveStateJson?: unknown },
3935
+ ): {
3936
+ ok: boolean;
3937
+ effective_state: EffectiveState | "not_evaluated";
3938
+ holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string };
3939
+ reason: string;
3940
+ guidance: string[];
3941
+ } {
3942
+ // repoRoot is accepted (not derived) for signature symmetry with runReconcilePreflight(dir,
3943
+ // repoRoot, ...) / publishDelivery(dir, repoRoot) — the local-file join below only needs
3944
+ // artifactRoot/slug, but a future non-local-file provider branch that resolves a live
3945
+ // repo-relative fixture would need it, so the parameter stays part of the public contract.
3946
+ void repoRoot;
3947
+ const sanitize = (value: unknown): string => stripControlCharsForDisplay(value).slice(0, 64);
3948
+ const slug = path.basename(path.resolve(dir));
3949
+ const artifactRoot = path.dirname(path.resolve(dir));
3950
+ const nowMs = opts?.now ?? Date.now();
3951
+ const assignmentProviderKind = opts?.assignmentProviderKind || "local-file";
3952
+ const helper = loadActorIdentityHelper();
3953
+ // Stability check (SECOND CI-blocking false-block fix, this iteration): an explicitly-provided
3954
+ // opts.actorKey is treated as stable outright (the caller is asserting a stable identity — see
3955
+ // doc comment above). Otherwise resolve BOTH the actor and its source so we can tell an
3956
+ // explicit-override / runtime-session-id actor (stable, enforce as today) apart from a
3957
+ // process-ancestry / unresolved one (unstable, advisory-only — never hard-block).
3958
+ const resolved = opts?.actorKey ? { actor: opts.actorKey, source: "explicit-override" } : helper.resolveActor(process.env);
3959
+ const actorKey = resolved.actor;
3960
+ const isStableActor = !!opts?.actorKey
3961
+ || resolved.source === "explicit-override"
3962
+ || resolved.source.startsWith("runtime-session-id")
3963
+ // #398: a CI-runtime actor is STABLE across every subprocess in a CI job (derived from the
3964
+ // provider's published run/job identifiers), so the verify-hold gate ENFORCES for CI sessions
3965
+ // instead of degrading to advisory — the root fix for the #293 CI false-block workaround.
3966
+ || resolved.source.startsWith("ci-runtime");
3967
+
3968
+ const guidanceLines = (holderActor?: string): string[] => [
3969
+ "Re-run pull-work/pickup-probe to discover the current holder of this subject and hand off cleanly (learning-review/handoff) rather than publishing over it.",
3970
+ holderActor
3971
+ ? `The current holder appears to be ${sanitize(holderActor)} — confirm with them before proceeding.`
3972
+ : "Confirm with the assigned human before proceeding.",
3973
+ "If a human confirms this session should resume ownership, run `ensure-session --supersede-stale` to explicitly re-claim the subject before retrying publish.",
3974
+ ];
3975
+
3976
+ type EffectiveResult = { effective_state: EffectiveState; reason: string; holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string } };
3977
+ let effective: EffectiveResult | null = null;
3978
+
3979
+ if (opts?.effectiveStateJson !== undefined) {
3980
+ const parsed = opts.effectiveStateJson as AnyObj;
3981
+ const candidate = parsed && typeof parsed === "object" ? (parsed.effective as AnyObj | undefined) : undefined;
3982
+ const validStates = new Set(["held", "reclaimable", "human-held", "free"]);
3983
+ if (candidate && typeof candidate.effective_state === "string" && validStates.has(candidate.effective_state)) {
3984
+ effective = candidate as EffectiveResult;
3985
+ }
3986
+ } else if (assignmentProviderKind === "local-file") {
3987
+ // Same call shape as enforceEnsureSessionOwnership's local-file branch — no second
3988
+ // freshness/ownership computation is invented (see that function's F1 fix comment for why
3989
+ // branchActorKey, not the serialized ActorStruct triple, is the correct selfActor here).
3990
+ const assignment = readLocalAssignmentStatus(artifactRoot, slug);
3991
+ const events = readLivenessEvents(artifactRoot);
3992
+ const freshList = loadLivenessReadHelper().freshHolders(events, slug, actorKey, nowMs);
3993
+ effective = computeEffectiveState(assignment, freshList, actorKey, nowMs);
3994
+ }
3995
+
3996
+ if (!effective) {
3997
+ // Documented scope boundary (Design Constraint): a non-local-file provider with no
3998
+ // precomputed --effective-state-json cannot be evaluated in-CLI (render-don't-execute — no
3999
+ // live `gh` call from workflow-sidecar.ts). PASS-through, never a silent block, with a loud
4000
+ // stderr note — mirroring enforceEnsureSessionOwnership's existing github branch precedent.
4001
+ process.stderr.write(`[verify-hold] not evaluated: provider "${sanitize(assignmentProviderKind)}" requires --effective-state-json (or use --assignment-provider local-file)\n`);
4002
+ return { ok: true, effective_state: "not_evaluated", reason: "provider_not_evaluated", guidance: [] };
4003
+ }
4004
+
4005
+ // F1 fix (fix-plan iteration 1, HIGH): sanitize EVERY untrusted string field of
4006
+ // effective.holder before it reaches the returned JSON / NotFreshHolderError.holder — never
4007
+ // spread the raw holder and override only the discriminated field. `last_at` (sourced from
4008
+ // record.claimed_at or a liveness event's `at`, both attacker-writable in the shared
4009
+ // multi-writer liveness/assignment stream) and `actor`/`assignee` are all display strings and
4010
+ // go through the SAME `sanitize` helper (stripControlCharsForDisplay(...).slice(0, 64)) as
4011
+ // enforceEnsureSessionOwnership's equivalent call sites (AC7 injection-discipline parity).
4012
+ // `idle_days` is a `number | null` computed by computeEffectiveState from Date.parse/Math.floor
4013
+ // arithmetic (assignment-provider.ts) — never an attacker-controlled string — so it is passed
4014
+ // through as-is, but defensively re-coerced with `typeof === "number"` so a future shape change
4015
+ // upstream can never smuggle a string through this field.
4016
+ const sanitizeHolder = (
4017
+ holder: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string } | undefined,
4018
+ ): { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string } | undefined => {
4019
+ if (!holder) return undefined;
4020
+ return {
4021
+ actor: holder.actor ? sanitize(holder.actor) : undefined,
4022
+ assignee: holder.assignee ? sanitize(holder.assignee) : holder.assignee,
4023
+ idle_days: typeof holder.idle_days === "number" ? holder.idle_days : undefined,
4024
+ last_at: holder.last_at ? sanitize(holder.last_at) : undefined,
4025
+ };
4026
+ };
4027
+
4028
+ // SECOND CI-blocking false-block fix (this iteration): an actor identity that cannot stably
4029
+ // self-identify (process-ancestry fallback or unresolved) cannot be meaningfully fenced against
4030
+ // a durable assignment record — hard-blocking it is exactly the CI false-block this fix targets
4031
+ // (see doc comment above). Degrade to advisory-only: never hard-block, regardless of what the
4032
+ // join above computed. This check runs AFTER the join so an existing holder (if any) can still
4033
+ // be surfaced for visibility, but strictly BEFORE the effective-state switch below so no
4034
+ // unstable-actor request can ever reach a `case` that returns `ok: false`.
4035
+ if (!isStableActor) {
4036
+ process.stderr.write(
4037
+ "[verify-hold] actor identity is ancestry-derived/unresolved — gate is advisory only for unstable identities; not hard-blocking publish\n"
4038
+ );
4039
+ return {
4040
+ ok: true,
4041
+ effective_state: "not_evaluated",
4042
+ reason: "actor-identity-unstable-advisory-only",
4043
+ guidance: [],
4044
+ ...(effective.holder ? { holder: sanitizeHolder(effective.holder) } : {}),
4045
+ };
4046
+ }
4047
+
4048
+ switch (effective.effective_state) {
4049
+ case "free":
4050
+ return { ok: true, effective_state: "free", reason: effective.reason, guidance: [] };
4051
+ case "held": {
4052
+ const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === actorKey);
4053
+ if (isSelf) return { ok: true, effective_state: "held", holder: sanitizeHolder(effective.holder), reason: effective.reason, guidance: [] };
4054
+ // Bug fix (#397): `liveness_claim_present_assignment_lagging` means computeEffectiveState
4055
+ // found NO durable assignment record at all — only ambient liveness by another actor
4056
+ // (assignment-provider.ts's `if (!isAssigned) { if (freshHoldersList.length > 0) return
4057
+ // held/liveness_claim_present_assignment_lagging }`). Liveness alone is advisory
4058
+ // everywhere else in this system; this ONE hard gate must fence the durable ASSIGNMENT
4059
+ // hold, not ambient presence, so this case PASSES rather than false-blocking. Every other
4060
+ // `held` reason (`fresh_liveness_heartbeat`) is reached only when an assignment record
4061
+ // DOES exist (assignment-provider.ts's `isAssigned` branch) and the recorded holder is
4062
+ // still fresh-live — a genuine durable-ownership conflict, which still BLOCKs below.
4063
+ if (effective.reason === "liveness_claim_present_assignment_lagging") {
4064
+ return { ok: true, effective_state: "held", holder: sanitizeHolder(effective.holder), reason: effective.reason, guidance: [] };
4065
+ }
4066
+ const holderActor = effective.holder?.actor;
4067
+ return {
4068
+ ok: false,
4069
+ effective_state: "held",
4070
+ holder: sanitizeHolder(effective.holder),
4071
+ reason: `subject ${sanitize(slug)} is currently held by a different, still-live actor (${sanitize(holderActor ?? "unknown")})`,
4072
+ guidance: guidanceLines(holderActor),
4073
+ };
4074
+ }
4075
+ case "reclaimable": {
4076
+ // Stop-short risk (plan): reclaimable is NEVER treated as PASS. A lapsed self-claim looks
4077
+ // reclaimable, not self_is_holder, to a woken zombie's own re-check — the safe default
4078
+ // blocks and asks for reconcile guidance rather than auto-passing a stale self-claim.
4079
+ const holderActor = effective.holder?.actor;
4080
+ return {
4081
+ ok: false,
4082
+ effective_state: "reclaimable",
4083
+ holder: sanitizeHolder(effective.holder),
4084
+ reason: `subject ${sanitize(slug)}'s existing claim (held by ${sanitize(holderActor ?? "unknown")}) is stale/unverified as self — not a confirmed fresh hold`,
4085
+ guidance: guidanceLines(holderActor),
4086
+ };
4087
+ }
4088
+ case "human-held": {
4089
+ const assignee = effective.holder?.assignee;
4090
+ return {
4091
+ ok: false,
4092
+ effective_state: "human-held",
4093
+ holder: sanitizeHolder(effective.holder),
4094
+ reason: `subject ${sanitize(slug)} is assigned to ${assignee ? sanitize(assignee) : "an assigned human"} — never publish over a human assignment without confirmation`,
4095
+ guidance: guidanceLines(undefined),
4096
+ };
4097
+ }
4098
+ default:
4099
+ return { ok: true, effective_state: "not_evaluated", reason: "unrecognized_effective_state", guidance: [] };
4100
+ }
4101
+ }
4102
+
4103
+ /**
4104
+ * verify-hold <artifact-dir> [--actor <key>] [--now <iso>] [--assignment-provider <kind>]
4105
+ * [--effective-state-json <path|->]
4106
+ *
4107
+ * CLI surface for runVerifyHold() (issue #293). Prints `{ role: "VerifyHoldResult", ... }` JSON
4108
+ * and exits 0 when `ok` (including `not_evaluated` — a documented scope boundary is never a
4109
+ * failure exit), 1 otherwise. NEVER throws at the CLI boundary — matches
4110
+ * `reconcilePreflightCmd`'s existing convention of printing then exiting rather than throwing;
4111
+ * the thrown `NotFreshHolderError` variant is reserved for the LIBRARY call inside
4112
+ * `publishDelivery()`.
4113
+ *
4114
+ * Usage: workflow-sidecar verify-hold <artifactDir> [--actor <key>] [--now <iso>]
4115
+ * [--assignment-provider <kind>] [--effective-state-json <path|->]
4116
+ */
4117
+ async function verifyHoldCmd(p: ReturnType<typeof parseArgs>): Promise<number> {
4118
+ if (p.flags.has("help") || (!p.positional[0] && !p.opts["help"])) {
4119
+ if (p.flags.has("help")) {
4120
+ console.log("Usage: workflow-sidecar verify-hold <artifactDir> [--actor <key>] [--now <iso>] [--assignment-provider <kind>] [--effective-state-json <path|->]");
4121
+ console.log("Checks whether the calling actor is the fresh, non-superseded holder of <artifactDir>'s subject. Exits 0 if ok (including not_evaluated), 1 otherwise.");
4122
+ return 0;
4123
+ }
4124
+ }
4125
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
4126
+ const repoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
4127
+ // SECOND CI-blocking false-block fix (this iteration): only pass an explicit actorKey through
4128
+ // to runVerifyHold when the CALLER explicitly passed --actor — that is the one case where an
4129
+ // actorKey should be treated as an asserted-stable identity (see runVerifyHold's doc comment).
4130
+ // When --actor is omitted, actorKey is left undefined so runVerifyHold performs its OWN
4131
+ // resolveActor(process.env) call internally and can see the real `source` (explicit-override /
4132
+ // runtime-session-id / process-ancestry / unresolved) to decide stability — calling
4133
+ // resolveReadActorKey(p) here first would discard that source and wrongly force every ambient
4134
+ // resolution (including an ancestry fallback) to be treated as an explicit/stable actorKey.
4135
+ const actorKey = opt(p, "actor") ? loadActorIdentityHelper().sanitizeSegment(opt(p, "actor")) : undefined;
4136
+ const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : undefined;
4137
+ const assignmentProviderKind = opt(p, "assignment-provider", "local-file");
4138
+ const effectiveStateJsonFlag = opt(p, "effective-state-json");
4139
+ const effectiveStateJson = effectiveStateJsonFlag ? loadJsonInputFile(effectiveStateJsonFlag) : undefined;
4140
+
4141
+ const result = runVerifyHold(dir, repoRoot, { actorKey, now: nowMs, assignmentProviderKind, effectiveStateJson });
4142
+ printJson({ role: "VerifyHoldResult", ...result });
4143
+ return result.ok ? 0 : 1;
4144
+ }
4145
+
4146
+ // ─── Takeover Preflight (#294, ADR 0021 §5) ───────────────────────────────────
4147
+ /**
4148
+ * Render (never execute) the takeover protocol for a candidate the skill has selected. Takeover is
4149
+ * RESUMPTION, not restart: when a subject is `reclaimable` (a durable assignment whose incumbent is
4150
+ * no longer heartbeating), a successor must (1) grace-beat — wait one heartbeat interval and re-check,
4151
+ * backing off if the incumbent revives — (2) supersede via `ensure-session --supersede-stale`, and
4152
+ * (3) CONTINUE THE INCUMBENT'S BRANCH (from the incumbent's claim record), never a parallel one, and
4153
+ * re-enter the SAME artifact dir (deterministic slug). This function computes the effective state
4154
+ * (the same assignment ⋈ liveness join verify-hold/ensure-session use) and emits the appropriate
4155
+ * action + the incumbent's `resume_branch`; the skill executes the beat/supersede/git steps.
4156
+ *
4157
+ * Race-safety is CLI-enforced at supersede time, not here: `ensure-session --supersede-stale`
4158
+ * re-computes the state and refuses (dies on `held`) if the incumbent revived during the beat, so a
4159
+ * successor that skipped or lost the grace race still cannot supersede a live incumbent (AC2a).
4160
+ *
4161
+ * ADVISORY, not a gate: the assignment ⋈ liveness JOIN is identical to verify-hold's, but this render
4162
+ * only advises the skill which action to take — the ACTUAL enforcement is `ensure-session
4163
+ * --supersede-stale` (re-check under a subject lock). So it deliberately does NOT replicate
4164
+ * verify-hold's #398 `isStableActor` tiering (which exists there only because verify-hold is a hard
4165
+ * publish BLOCK that must not false-block an unstable/ancestry identity): a wrong self-identification
4166
+ * here yields only a wrong advisory string, never a wrong mutation.
4167
+ *
4168
+ * Ordering note: ADR 0021 §5's literal wording is supersede→grace→maybe-undo; this implements
4169
+ * grace→recheck→supersede (never write until the beat confirms the incumbent stayed stale) — a
4170
+ * deliberate, safer variant that preserves the ADR's intent ("laptop just woke resolves in the
4171
+ * incumbent's favor") without a premature write to walk back.
4172
+ *
4173
+ * All untrusted fields (incumbent actor / branch / last_at, sourced from the shared multi-writer
4174
+ * assignment+liveness stream) pass through `sanitize`/`sanitizeWide` (stripControlCharsForDisplay +
4175
+ * the repo's 64/240 cap tiers — branch uses the 240 free-text tier so a valid long branch is never
4176
+ * truncated into a bad checkout target) — the #287/#320/#293 injection class.
4177
+ */
4178
+ export function runTakeoverPreflight(
4179
+ dir: string,
4180
+ opts?: { actorKey?: string; now?: number; graceSeconds?: number },
4181
+ ): {
4182
+ ok: boolean;
4183
+ action: "grace-then-supersede" | "back-off" | "claim" | "ask-first" | "proceed";
4184
+ effective_state: EffectiveState;
4185
+ reason: string;
4186
+ holder?: { actor?: string; last_at?: string };
4187
+ resume_branch?: string;
4188
+ grace_seconds?: number;
4189
+ next_steps: string[];
4190
+ } {
4191
+ const sanitize = (value: unknown): string => stripControlCharsForDisplay(value).slice(0, 64);
4192
+ // Branch names use the repo's 240-char free-text tier (matching assignment-provider.ts's
4193
+ // sanitizeDisplayField(record.branch, 240)), NOT the 64-char id tier: a realistic
4194
+ // `agent/<actor>/<slug>` branch can exceed 64 chars, and truncating `resume_branch` would hand the
4195
+ // skill a `git checkout <truncated>` target that does not exist — defeating the resume-the-branch
4196
+ // guarantee. Control chars are still stripped (injection), only the cap widens.
4197
+ const sanitizeWide = (value: unknown): string => stripControlCharsForDisplay(value).slice(0, 240);
4198
+ const slug = path.basename(path.resolve(dir));
4199
+ const artifactRoot = path.dirname(path.resolve(dir));
4200
+ const nowMs = opts?.now ?? Date.now();
4201
+ const helper = loadActorIdentityHelper();
4202
+ const actorKey = opts?.actorKey ? helper.sanitizeSegment(opts.actorKey) : helper.resolveActor(process.env).actor;
4203
+ const graceSeconds = opts?.graceSeconds ?? loadLivenessPolicyHelper().resolveHeartbeatThrottleSeconds(process.env);
4204
+
4205
+ const assignment = readLocalAssignmentStatus(artifactRoot, slug);
4206
+ const events = readLivenessEvents(artifactRoot);
4207
+ const freshList = loadLivenessReadHelper().freshHolders(events, slug, actorKey, nowMs);
4208
+ const effective = computeEffectiveState(assignment, freshList, actorKey, nowMs);
4209
+ const reason = sanitize(effective.reason);
4210
+ const holderActor = effective.holder?.actor ? sanitize(effective.holder.actor) : undefined;
4211
+ const holderLastAt = effective.holder?.last_at ? sanitize(effective.holder.last_at) : undefined;
4212
+
4213
+ switch (effective.effective_state) {
4214
+ case "reclaimable": {
4215
+ // The incumbent's branch is the resume target — read it from the pre-supersede record, never
4216
+ // derive a new one (AC1: takeover continues the incumbent's branch, no parallel branch).
4217
+ const resumeBranch = assignment.record?.branch ? sanitizeWide(assignment.record.branch) : undefined;
4218
+ return {
4219
+ ok: true,
4220
+ action: "grace-then-supersede",
4221
+ effective_state: "reclaimable",
4222
+ reason,
4223
+ holder: { actor: holderActor, last_at: holderLastAt },
4224
+ ...(resumeBranch ? { resume_branch: resumeBranch } : {}),
4225
+ grace_seconds: graceSeconds,
4226
+ next_steps: [
4227
+ `Grace beat: wait ${graceSeconds}s (one heartbeat interval) for the incumbent to revive.`,
4228
+ `Re-run \`workflow-sidecar takeover-preflight ${slug}\`; if it now reports action "back-off" the incumbent revived — STOP and reselect.`,
4229
+ `If still "grace-then-supersede", run \`ensure-session --supersede-stale\` to take over (it re-checks and refuses if the incumbent revived), recording the audit trail.`,
4230
+ resumeBranch
4231
+ ? `Continue the incumbent's branch: \`git fetch origin ${resumeBranch} && git checkout ${resumeBranch}\` — NEVER create a new branch. Re-enter the existing artifact dir (deterministic slug); do not restart the plan.`
4232
+ : `Re-enter the existing artifact dir (deterministic slug) and continue the incumbent's branch from state.json.branch — never a new branch; do not restart the plan.`,
4233
+ ],
4234
+ };
4235
+ }
4236
+ case "held": {
4237
+ const selfIsHolder = effective.reason === "self_is_holder";
4238
+ return {
4239
+ ok: selfIsHolder,
4240
+ action: selfIsHolder ? "proceed" : "back-off",
4241
+ effective_state: "held",
4242
+ reason,
4243
+ holder: { actor: holderActor, last_at: holderLastAt },
4244
+ next_steps: selfIsHolder
4245
+ ? ["This subject is already held by you — proceed with your existing session; no takeover needed."]
4246
+ : [`The incumbent is live/revived (held by ${holderActor ?? "another actor"}). Back off — do NOT supersede a live holder — and reselect a different item.`],
4247
+ };
4248
+ }
4249
+ case "human-held": {
4250
+ return {
4251
+ ok: false,
4252
+ action: "ask-first",
4253
+ effective_state: "human-held",
4254
+ reason,
4255
+ holder: { actor: holderActor, last_at: holderLastAt },
4256
+ next_steps: [`A human is assigned (${holderActor ?? "assignee"}). Ask first — never auto-supersede a human assignment (ADR 0021 §6).`],
4257
+ };
4258
+ }
4259
+ case "free":
4260
+ default: {
4261
+ return {
4262
+ ok: true,
4263
+ action: "claim",
4264
+ effective_state: "free",
4265
+ reason,
4266
+ next_steps: ["No durable claim to supersede — this is a normal claim, not a takeover. Run `ensure-session` to claim it."],
4267
+ };
4268
+ }
4269
+ }
4270
+ }
4271
+
4272
+ async function takeoverPreflightCmd(p: ReturnType<typeof parseArgs>): Promise<number> {
4273
+ if (p.flags.has("help")) {
4274
+ console.log("Usage: workflow-sidecar takeover-preflight <artifactDir> [--actor <key>] [--now <iso>] [--grace-seconds <n>]");
4275
+ console.log("Renders the ADR 0021 §5 takeover protocol (grace-then-supersede | back-off | claim | ask-first | proceed) for a reclaimable candidate. Read-only; never mutates.");
4276
+ return 0;
4277
+ }
4278
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
4279
+ const actorKey = opt(p, "actor") ? loadActorIdentityHelper().sanitizeSegment(opt(p, "actor")) : undefined;
4280
+ const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : undefined;
4281
+ const graceSeconds = opt(p, "grace-seconds") ? Number(opt(p, "grace-seconds")) : undefined;
4282
+ const result = runTakeoverPreflight(dir, { actorKey, now: nowMs, graceSeconds });
4283
+ printJson({ role: "TakeoverPreflight", ...result });
4284
+ return result.ok ? 0 : 1;
4285
+ }
4286
+
2542
4287
  // ─── Publish Delivery Bundle ──────────────────────────────────────────────────
2543
4288
  // Copies the session's trust.bundle (+ checkpoint companions) from the gitignored
2544
4289
  // session artifact dir (.kontourai/flow-agents/<slug>/) to the committed delivery/ transport
@@ -2550,49 +4295,45 @@ async function sealCheckpoint(p: ReturnType<typeof parseArgs>): Promise<number>
2550
4295
  // Also exposed as the `publish-delivery <artifact-dir>` subcommand for explicit use.
2551
4296
 
2552
4297
  /**
2553
- * #379 supersede-on-publish cleanup: keep delivery/ bounded by pruning inherited PER-SESSION
2554
- * seal dirs (the growth vector), scoped to avoid any cross-PR conflict.
2555
- *
2556
- * An inherited per-session seal dir (`delivery/<other-slug>/`) is UNIQUELY named, so pruning
2557
- * it can never conflict with a concurrent PR: two branches deleting the same inherited dir is
2558
- * a delete/delete (auto-merges), and each new delivery adds its OWN distinct
2559
- * `delivery/<slug>/`. And it is HARMLESS to leave: trust-reconcile.js's prefer-newest
2560
- * ownership selection always picks THIS session's fresher bundle over an older inherited one.
2561
- * Pruning is therefore purely to stop unbounded accumulation of permanently-superseded dirs.
2562
- *
2563
- * The SHARED FLAT path (`delivery/trust.bundle` + checkpoints) is deliberately NOT pruned
2564
- * here. During the migration window other concurrent PRs may still seal to the flat path;
2565
- * deleting it would produce a modify/delete conflict a DIRTY PR the no-CI failure this
2566
- * whole change fixes. The flat path is a single fixed location (not a growth vector) and the
2567
- * reconciler treats a stale flat bundle as non-owning / older-owning, so leaving it is safe.
2568
- * Removing the flat legacy seals is a one-time cleanup for a dedicated PR once no open PR
2569
- * still seals to it intentionally NOT bundled into every delivery.
4298
+ * #413 Facet B: prune ONLY keepSlug's OWN prior seal — NEVER another session's
4299
+ * `delivery/<other-slug>/` directory.
4300
+ *
4301
+ * This function previously (#379) deleted ANY non-keepSlug directory under delivery/ that
4302
+ * "looked like a seal" (contained a trust.bundle or trust.checkpoint.json), on the theory that
4303
+ * an inherited per-session seal dir is harmless to leave and therefore harmless to delete too.
4304
+ * That theory was correct about harmlessness-if-left but did not follow that deletion was
4305
+ * therefore SAFE: issue #413's own corrected evidence reports this exact cross-slug loop
4306
+ * deleted a DIFFERENT, still-in-flight actor's active seal on a different branch
4307
+ * (`delivery/kontourai-flow-agents-349/`) — a real, observed destructive side effect with no
4308
+ * compensating correctness benefit. trust-reconcile.js's own PREFER-NEWEST doc comment
4309
+ * (resolveDeliveryCandidates, ~line 480-491) already establishes that leaving an
4310
+ * inherited/older seal in place is harmless the reconciler always selects the newest owning
4311
+ * bundle regardless of what else is present so there is no correctness reason to prune
4312
+ * ANOTHER session's seal at all, ever. The safest fix (and the one issue #413's own corrected
4313
+ * comment endorses "never prune another session's active seal", no same-branch carve-out) is
4314
+ * to stop cross-slug pruning entirely, not merely narrow its blast radius.
4315
+ *
4316
+ * The only pruning responsibility that legitimately remains here is keepSlug's OWN prior seal:
4317
+ * publishDelivery() is about to freshly (re)write `delivery/<keepSlug>/trust.bundle` and its
4318
+ * companions, but `fs.copyFileSync` only overwrites files that exist in the CURRENT session dir
4319
+ * — a companion that existed in a PRIOR publish of this exact slug (e.g.
4320
+ * trust.checkpoint.sig.json) but is absent from this publish would otherwise linger stale
4321
+ * alongside the freshly-written files. Removing keepSlug's own existing directory before it is
4322
+ * recreated (by the caller, immediately after this call) avoids that same-slug staleness
4323
+ * without touching any OTHER slug's directory.
2570
4324
  *
2571
4325
  * Best-effort: a prune failure is logged, never fatal to the delivery. Never touches
2572
- * README.md, DECLARED, the flat seal files, or any subdir that is not itself a seal dir.
4326
+ * README.md, DECLARED, the flat seal files (delivery/trust.bundle at the shared flat path,
4327
+ * deliberately untouched — see the historical #379 rationale on the flat-path migration
4328
+ * window), or any directory other than keepSlug's own.
2573
4329
  */
2574
4330
  function pruneSupersededSeals(deliveryDir: string, keepSlug: string): void {
2575
- let entries: fs.Dirent[] = [];
4331
+ const ownDir = path.join(deliveryDir, keepSlug);
4332
+ if (!fs.existsSync(ownDir)) return; // nothing to prune yet — first publish for this slug.
2576
4333
  try {
2577
- entries = fs.readdirSync(deliveryDir, { withFileTypes: true });
2578
- } catch {
2579
- entries = [];
2580
- }
2581
- for (const entry of entries) {
2582
- if (!entry.isDirectory() || entry.name === keepSlug) continue;
2583
- const subdir = path.join(deliveryDir, entry.name);
2584
- // Only prune dirs that actually look like a seal dir (contain a trust.bundle or
2585
- // trust.checkpoint.json) — never an unrelated directory a human placed under delivery/.
2586
- const looksLikeSeal =
2587
- fs.existsSync(path.join(subdir, "trust.bundle")) ||
2588
- fs.existsSync(path.join(subdir, "trust.checkpoint.json"));
2589
- if (!looksLikeSeal) continue;
2590
- try {
2591
- fs.rmSync(subdir, { recursive: true, force: true });
2592
- 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`);
2593
- } catch (err) {
2594
- 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`);
2595
- }
4334
+ fs.rmSync(ownDir, { recursive: true, force: true });
4335
+ } catch (err) {
4336
+ 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`);
2596
4337
  }
2597
4338
  }
2598
4339
 
@@ -2621,6 +4362,55 @@ function pruneSupersededSeals(deliveryDir: string, keepSlug: string): void {
2621
4362
  * evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings at
2622
4363
  * .kontourai/flow-agents/ws5-governance-kit-slice1 for the root cause this fixes).
2623
4364
  * Idempotent: overwrites on re-delivery to the same slug.
4365
+ *
4366
+ * #356 Wave 3 (AC6): Fail-CLOSED on bundle SHAPE. After the fail-soft absence guard below,
4367
+ * runs the SAME reconcile-preflight shape check (runReconcilePreflight) the
4368
+ * `reconcile-preflight` CLI subcommand exposes, BEFORE copying anything into delivery/. A
4369
+ * shape-invalid bundle throws InvalidBundleShapeError (a distinct, identifiable error — see
4370
+ * its own doc comment) rather than silently publishing a bundle CI's Trust Reconcile job
4371
+ * would reject anyway. This is intentionally a NEW, additive fail-closed branch — it must
4372
+ * never be conflated with the pre-existing fail-soft absence/repo-root branches above/below,
4373
+ * which stay exactly as before (see each publishDelivery call site's catch handler for how
4374
+ * the distinction is preserved end-to-end).
4375
+ *
4376
+ * #293 (THIRD, DISTINCT fail-closed gate): immediately after the shape check above and BEFORE
4377
+ * writing anything into delivery/, runs `runVerifyHold()` — the assignment ⋈ liveness join
4378
+ * asking "is the calling actor the fresh, non-superseded holder of this subject (or is it
4379
+ * free/self-held)?" A not-fresh-holder result throws `NotFreshHolderError` (distinct from
4380
+ * `InvalidBundleShapeError` — a different `code`, a different failure mode: actor hold vs
4381
+ * bundle shape).
4382
+ *
4383
+ * #413 Facet A (FOURTH, DISTINCT fail-closed gate): immediately after the verify-hold gate and
4384
+ * BEFORE writing anything into delivery/, compares the resolved `repoRoot`'s `git rev-parse
4385
+ * HEAD` against the session's sealed `trust.checkpoint.json` `commit_sha` (via the shared
4386
+ * checkpointHeadAncestry() helper — the SAME ancestor-check checkpointStalenessWarning already
4387
+ * used as a non-blocking warning). Throws `RepoHeadMismatchError` (distinct from both other
4388
+ * error tiers) instead of silently publishing into what is very likely the WRONG repo (e.g.
4389
+ * `repoRoot` resolved to the primary checkout via findRepoRootFromDirStrict's artifact-dir
4390
+ * walk, even though this session's checkpoint was sealed against a DIFFERENT worktree's HEAD).
4391
+ * A session with no checkpoint yet, or a checkpoint with no commit_sha, has nothing to compare
4392
+ * against and is unaffected (scoped strictly to the mismatch case, never a spurious refusal).
4393
+ *
4394
+ * #413 iteration-2 Fix 4 (see checkpointHeadAncestry's own doc comment for the full
4395
+ * `mismatchKind` classification this gate reads): only `mismatchKind === "positive"` (a
4396
+ * POSITIVELY-DETERMINABLE ancestry mismatch — the real wrong-tree harm case) hard refuses here.
4397
+ * `"undeterminable-shallow"` (e.g. a local shallow clone missing the commit_sha object) must
4398
+ * NEVER hard-refuse a legitimate publish — this branch only WARNS loudly and lets the shape +
4399
+ * verify-hold gates above continue to stand guard. A non-git `repoRoot` classifies as
4400
+ * `mismatchKind === "none"` (allow) — iteration-2's Fix 5 hard-refused this case too, but that
4401
+ * regressed the SUPPORTED non-git-repoRoot scratch/checkpoint-signing flow (see
4402
+ * evals/integration/test_checkpoint_signing.sh TEST 2) and was reverted in iteration-3. Wrong-tree
4403
+ * protection for that case relies on the shape-check and verify-hold gates above, which correctly
4404
+ * no-op (allow) when there is no git repo to inspect, same as this gate.
4405
+ *
4406
+ * There are now FOUR fail-closed/fail-soft TIERS in this function, and they must never be
4407
+ * conflated in prose or in a call site's catch handler: (1) fail-SOFT bundle absence /
4408
+ * repo-root resolution (silent no-op / visible warning, unchanged from before #356), (2)
4409
+ * fail-CLOSED bundle shape (#356, `InvalidBundleShapeError`), (3) fail-CLOSED verify-hold (#293,
4410
+ * `NotFreshHolderError`), (4) fail-CLOSED repo-HEAD-vs-checkpoint mismatch (#413 Facet A,
4411
+ * `RepoHeadMismatchError`) — see this tier's own gate below for the undeterminable-shallow
4412
+ * carve-out. Tiers run in this fixed order (shape, then verify-hold, then HEAD-mismatch); a
4413
+ * bundle that fails an earlier tier throws that tier's error specifically, never a later one's.
2624
4414
  */
2625
4415
  export async function publishDelivery(dir: string, repoRoot: string | null): Promise<void> {
2626
4416
  const bundleSrc = path.join(dir, "trust.bundle");
@@ -2631,6 +4421,41 @@ export async function publishDelivery(dir: string, repoRoot: string | null): Pro
2631
4421
  return;
2632
4422
  }
2633
4423
 
4424
+ // #356 AC6: fail-CLOSED on shape-invalidity — TIER 2 (see this function's doc comment above
4425
+ // for the full tier ordering). Throws InvalidBundleShapeError (never a generic Error).
4426
+ const preflight = runReconcilePreflight(dir, repoRoot);
4427
+ if (!preflight.ok) {
4428
+ process.stderr.write(`[publish-delivery] REFUSING to publish — trust.bundle failed the reconcile-preflight shape check (${preflight.issues.length} issue(s)):\n`);
4429
+ for (const issue of preflight.issues) {
4430
+ process.stderr.write(`[publish-delivery] - ${issue}\n`);
4431
+ }
4432
+ throw new InvalidBundleShapeError(preflight.issues);
4433
+ }
4434
+
4435
+ // #293: fail-CLOSED verify-hold — TIER 3 (see this function's doc comment above). Throws
4436
+ // NotFreshHolderError (never conflated with InvalidBundleShapeError).
4437
+ const holdCheck = runVerifyHold(dir, repoRoot);
4438
+ if (!holdCheck.ok) {
4439
+ process.stderr.write(`[publish-delivery] REFUSING to publish — verify-hold gate: ${holdCheck.reason}\n`);
4440
+ for (const line of holdCheck.guidance) process.stderr.write(`[publish-delivery] - ${line}\n`);
4441
+ throw new NotFreshHolderError(holdCheck);
4442
+ }
4443
+
4444
+ // #413 Facet A: FOURTH, DISTINCT fail-closed gate (see this function's doc comment above for
4445
+ // the full four-tier ordering, and checkpointHeadAncestry's doc comment for the
4446
+ // Fix 4 mismatchKind classification this reads).
4447
+ const ancestry = checkpointHeadAncestry(dir, repoRoot);
4448
+ if (ancestry.mismatchKind === "positive" && ancestry.commitSha && ancestry.headSha) {
4449
+ 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`);
4450
+ throw new RepoHeadMismatchError(repoRoot, ancestry.commitSha, ancestry.headSha);
4451
+ }
4452
+ if (ancestry.mismatchKind === "undeterminable-shallow" && ancestry.commitSha && ancestry.headSha) {
4453
+ // #413 iteration-2 Fix 4: NEVER hard-refuse here — a shallow clone (or any other case where
4454
+ // git cannot positively determine ancestry) must not spuriously block a legitimate publish.
4455
+ // The shape + verify-hold gates above already ran and passed; this is warn-and-allow only.
4456
+ 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`);
4457
+ }
4458
+
2634
4459
  const deliveryDir = path.join(repoRoot, "delivery");
2635
4460
  // #379: slug is the session artifact dir's basename — the same human-meaningful id used
2636
4461
  // throughout the session (.kontourai/flow-agents/<slug>/). The per-session dir NAME is only
@@ -2639,7 +4464,8 @@ export async function publishDelivery(dir: string, repoRoot: string | null): Pro
2639
4464
  const sessionDeliveryDir = path.join(deliveryDir, slug);
2640
4465
 
2641
4466
  fs.mkdirSync(deliveryDir, { recursive: true });
2642
- // Supersede inherited/flat seals BEFORE writing this session's dir (keepSlug = our own).
4467
+ // #413 Facet B: prune only THIS session's own prior seal (never another slug's) before
4468
+ // freshly rewriting delivery/<slug>/ below.
2643
4469
  pruneSupersededSeals(deliveryDir, slug);
2644
4470
  fs.mkdirSync(sessionDeliveryDir, { recursive: true });
2645
4471
 
@@ -2731,12 +4557,13 @@ async function recordLearning(p: ReturnType<typeof parseArgs>): Promise<number>
2731
4557
  if (status === "learned" && records.some((r) => r.correction === undefined)) die("learning status learned requires every record to include correction.needed");
2732
4558
  writeJson(path.join(dir, "learning.json"), { ...sidecarBase(slug), status, updated_at: timestamp, records });
2733
4559
  writeState(dir, slug, "accepted", "learning", timestamp, opt(p, "summary"));
2734
- // 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).
2735
4564
  // #268/#344: declared builder.* claims survive the round-trip via their authoritative origin stamp.
2736
- const _learningChecks: AnyObj[] = checksFromBundle(dir);
2737
- const _learningCriteria: AnyObj[] = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
2738
- const _learningCritiques: AnyObj[] = critiquesFromBundle(dir);
2739
- 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));
2740
4567
  return 0;
2741
4568
  }
2742
4569
  function evidenceClean(dir: string): boolean {
@@ -2805,7 +4632,16 @@ async function dogfoodPass(p: ReturnType<typeof parseArgs>): Promise<number> {
2805
4632
  assertExistingLearningValid(dir);
2806
4633
  const verdict = opt(p, "verdict");
2807
4634
  if (verdict === "pass") {
2808
- const checks = opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json")));
4635
+ // #270 follow-up fix (iteration 5, narrowed iteration 6): dogfood-pass's own pre-validation
4636
+ // normalizeCheck call (before delegating the actual write to recordEvidence below) must apply
4637
+ // the same narrowed existing-unstamped-id exemption recordEvidence/recordCheck do, or a
4638
+ // legitimate correction of a mis-recorded, UNSTAMPED gate-claim-* id would die HERE, before
4639
+ // ever reaching recordEvidence's own (already-fixed) guard — while supersession of a STAMPED
4640
+ // (live) gate claim id must still die here too. readBundleState is safe to call even when
4641
+ // trust.bundle does not yet exist (loadJson falls back to {}), exactly as
4642
+ // recordEvidence/recordCheck rely on.
4643
+ const _dogfoodExistingCheckStampById = existingCheckStampMap(readBundleState(dir).checks);
4644
+ const checks = opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"), false, _dogfoodExistingCheckStampById));
2809
4645
  if (checks.some((c) => c.status !== "pass" && c.status !== "skip")) die("clean evidence requires all non-skipped checks to pass");
2810
4646
  // Phase 4c: evidence check reads from trust.bundle (sole verification artifact); legacy evidence.json fallback in evidenceClean.
2811
4647
  // #268/#344: builder.* check/critique claims count as clean evidence via their authoritative origin stamp.
@@ -3439,6 +5275,7 @@ function loadActorIdentityHelper(): {
3439
5275
  serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
3440
5276
  detectRuntime: (env: NodeJS.ProcessEnv) => string;
3441
5277
  runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
5278
+ detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
3442
5279
  ancestorActorSeed: () => string;
3443
5280
  } {
3444
5281
  const _req = createRequire(import.meta.url);
@@ -3450,6 +5287,7 @@ function loadActorIdentityHelper(): {
3450
5287
  serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
3451
5288
  detectRuntime: (env: NodeJS.ProcessEnv) => string;
3452
5289
  runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
5290
+ detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
3453
5291
  ancestorActorSeed: () => string;
3454
5292
  };
3455
5293
  }
@@ -3470,12 +5308,14 @@ function resolveLivenessActor(): string {
3470
5308
  function loadLivenessPolicyHelper(): {
3471
5309
  isLivenessEnabled: (env: NodeJS.ProcessEnv) => boolean;
3472
5310
  resolveTtlSeconds: (env: NodeJS.ProcessEnv) => number;
5311
+ resolveHeartbeatThrottleSeconds: (env: NodeJS.ProcessEnv) => number;
3473
5312
  } {
3474
5313
  const _req = createRequire(import.meta.url);
3475
5314
  const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-policy.js");
3476
5315
  return _req(helperPath) as {
3477
5316
  isLivenessEnabled: (env: NodeJS.ProcessEnv) => boolean;
3478
5317
  resolveTtlSeconds: (env: NodeJS.ProcessEnv) => number;
5318
+ resolveHeartbeatThrottleSeconds: (env: NodeJS.ProcessEnv) => number;
3479
5319
  };
3480
5320
  }
3481
5321
  function livenessEnabled(): boolean { return loadLivenessPolicyHelper().isLivenessEnabled(process.env); }
@@ -3884,8 +5724,25 @@ Available claim ids:
3884
5724
 
3885
5725
 
3886
5726
  async function main(): Promise<number> {
3887
- const p = parseArgs(process.argv.slice(2));
5727
+ const _rawArgv = process.argv.slice(2);
5728
+ // #380: `record-check <dir> -- <command...>` — argv after the FIRST literal `--` token is the
5729
+ // command to execute verbatim (never option-parsed: a command like `npm test -- --watch`
5730
+ // legitimately contains its OWN `--`, so only the record-check dispatcher's own separator, the
5731
+ // first one, is consumed here). parseArgs() cannot represent this (it treats every `--foo`-
5732
+ // shaped token as an option key, including a bare `--`), so the split happens here, before
5733
+ // parseArgs ever sees the command's own argv.
5734
+ let _commandArgv: string[] | null = null;
5735
+ let _argvForParse = _rawArgv;
5736
+ if (_rawArgv[0] === "record-check") {
5737
+ const sepIdx = _rawArgv.indexOf("--");
5738
+ if (sepIdx >= 0) {
5739
+ _argvForParse = _rawArgv.slice(0, sepIdx);
5740
+ _commandArgv = _rawArgv.slice(sepIdx + 1);
5741
+ }
5742
+ }
5743
+ const p = parseArgs(_argvForParse);
3888
5744
  if (!p.command) die("workflow-sidecar command is required");
5745
+ if (p.command === "ensure-session") preflightEnsureSession(p);
3889
5746
  // F1 (#166 fix iteration 1): `liveness whoami` is a read-only, lock-free, write-free advisory
3890
5747
  // surface (see the `action === "whoami"` branch inside `liveness()` above) — it must never
3891
5748
  // acquire the workflow-sidecar lock, regardless of whether the artifact root already exists on
@@ -3912,6 +5769,7 @@ async function main(): Promise<number> {
3912
5769
  case "init-plan": return initPlan(p);
3913
5770
  case "record-evidence": return recordEvidence(p);
3914
5771
  case "record-gate-claim": return recordGateClaim(p);
5772
+ case "record-check": return recordCheck(p, _commandArgv);
3915
5773
  case "promote": return promote(p);
3916
5774
  case "advance-state": return advanceState(p);
3917
5775
  case "record-critique": return recordCritique(p);
@@ -3927,6 +5785,9 @@ async function main(): Promise<number> {
3927
5785
  case "resolve-slug": return resolveSlugCmd(p);
3928
5786
  case "seal-checkpoint": return sealCheckpoint(p);
3929
5787
  case "publish-delivery": return publishDeliveryCmd(p);
5788
+ case "reconcile-preflight": return reconcilePreflightCmd(p);
5789
+ case "verify-hold": return verifyHoldCmd(p);
5790
+ case "takeover-preflight": return takeoverPreflightCmd(p);
3930
5791
  default: die(`unknown command: ${p.command}`);
3931
5792
  }
3932
5793
  });