@kontourai/flow-agents 2.3.0 → 3.0.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 (301) hide show
  1. package/.github/CODEOWNERS +16 -0
  2. package/.github/workflows/ci.yml +44 -0
  3. package/.github/workflows/trust-reconcile.yml +62 -4
  4. package/AGENTS.md +4 -4
  5. package/CHANGELOG.md +62 -0
  6. package/CONTEXT.md +35 -0
  7. package/README.md +9 -3
  8. package/agents/dev.json +1 -1
  9. package/agents/tool-code-reviewer.json +1 -1
  10. package/agents/tool-planner.json +3 -3
  11. package/agents/tool-verifier.json +3 -3
  12. package/build/src/cli/assignment-provider.d.ts +1 -0
  13. package/build/src/cli/assignment-provider.js +748 -0
  14. package/build/src/cli/console-learning-projection.js +3 -2
  15. package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
  16. package/build/src/cli/effective-assignment-provider-settings.js +125 -0
  17. package/build/src/cli/init.js +104 -21
  18. package/build/src/cli/kit.js +37 -6
  19. package/build/src/cli/pull-work-provider.js +1 -1
  20. package/build/src/cli/usage-feedback.js +3 -3
  21. package/build/src/cli/validate-hook-influence.js +1 -0
  22. package/build/src/cli/validate-workflow-artifacts.js +27 -7
  23. package/build/src/cli/workflow-artifact-cleanup-audit.js +36 -2
  24. package/build/src/cli/workflow-sidecar.d.ts +14 -7
  25. package/build/src/cli/workflow-sidecar.js +938 -168
  26. package/build/src/cli.js +6 -0
  27. package/build/src/flow-kit/validate.d.ts +23 -0
  28. package/build/src/flow-kit/validate.js +67 -1
  29. package/build/src/index.d.ts +1 -1
  30. package/build/src/index.js +1 -1
  31. package/build/src/lib/flow-resolver.d.ts +6 -4
  32. package/build/src/lib/flow-resolver.js +125 -58
  33. package/build/src/lib/local-artifact-root.d.ts +14 -2
  34. package/build/src/lib/local-artifact-root.js +22 -5
  35. package/build/src/lib/workflow-learning-projection.js +2 -2
  36. package/build/src/runtime-adapters.d.ts +12 -0
  37. package/build/src/runtime-adapters.js +56 -16
  38. package/build/src/tools/build-universal-bundles.js +49 -16
  39. package/build/src/tools/generate-context-map.js +10 -8
  40. package/build/src/tools/validate-source-tree.js +24 -1
  41. package/console.telemetry.json +22 -0
  42. package/context/contracts/artifact-contract.md +40 -7
  43. package/context/contracts/assignment-provider-contract.md +239 -0
  44. package/context/contracts/builder-kit-workflow-state-contract.md +5 -2
  45. package/context/contracts/decision-registry-contract.md +265 -0
  46. package/context/contracts/delivery-contract.md +3 -1
  47. package/context/contracts/execution-contract.md +25 -0
  48. package/context/contracts/governance-adapter-contract.md +2 -0
  49. package/context/contracts/knowledge-store-contract.md +197 -0
  50. package/context/contracts/planning-contract.md +4 -1
  51. package/context/contracts/review-contract.md +7 -3
  52. package/context/contracts/sandbox-policy.md +2 -0
  53. package/context/contracts/standing-directives.md +13 -0
  54. package/context/contracts/verification-contract.md +11 -1
  55. package/context/contracts/work-item-contract.md +3 -1
  56. package/context/deferred/parallelization.md +1 -1
  57. package/context/gate-awareness.md +4 -4
  58. package/context/scripts/git-status.sh +2 -2
  59. package/context/scripts/hooks/config-protection.js +502 -2
  60. package/context/scripts/hooks/lib/config-protection-remedies.js +69 -0
  61. package/context/scripts/hooks/run-hook.js +14 -1
  62. package/context/scripts/hooks/stop-goal-fit.js +1140 -99
  63. package/context/scripts/hooks/workflow-steering.js +212 -6
  64. package/context/settings/assignment-provider-settings.json +33 -0
  65. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  66. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +35 -0
  67. package/docs/adr/0019-kit-dependency-ownership.md +62 -0
  68. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +290 -0
  69. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +221 -0
  70. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +463 -0
  71. package/docs/agent-system-guidebook.md +3 -3
  72. package/docs/agent-usage-feedback-loop.md +6 -6
  73. package/docs/context-map.md +10 -7
  74. package/docs/decisions/decision-records.md +57 -0
  75. package/docs/decisions/index.md +17 -0
  76. package/docs/decisions/knowledge-store-provider.md +51 -0
  77. package/docs/decisions/model-routing.md +63 -0
  78. package/docs/decisions/promotion-gate.md +52 -0
  79. package/docs/decisions/standing-directives.md +66 -0
  80. package/docs/developer-architecture.md +1 -1
  81. package/docs/fixture-ownership.md +4 -0
  82. package/docs/flow-kit-repository-contract.md +5 -5
  83. package/docs/getting-started.md +12 -2
  84. package/docs/integrations/harness-install.md +2 -1
  85. package/docs/kit-authoring-guide.md +64 -5
  86. package/docs/knowledge-kit.md +5 -1
  87. package/docs/learnings/2026-07-improvement-program.md +203 -0
  88. package/docs/migrations.md +6 -5
  89. package/docs/north-star.md +2 -2
  90. package/docs/operating-layers.md +4 -3
  91. package/docs/repository-structure.md +5 -5
  92. package/docs/skills-map.md +8 -8
  93. package/docs/spec/runtime-hook-surface.md +19 -15
  94. package/docs/spikes/graph-provider-2026-07.md +155 -0
  95. package/docs/standards-register.md +7 -7
  96. package/docs/trust-anchor-adoption.md +105 -4
  97. package/docs/work-item-adapters.md +1 -1
  98. package/docs/workflow-artifact-lifecycle.md +72 -9
  99. package/docs/workflow-eval-strategy.md +3 -3
  100. package/docs/workflow-shared-contracts.md +3 -2
  101. package/docs/workflow-usage-guide.md +260 -21
  102. package/evals/acceptance/DEMO-false-completion.md +20 -11
  103. package/evals/acceptance/prove-capture-teeth-declared.sh +15 -15
  104. package/evals/acceptance/prove-capture-teeth.sh +11 -11
  105. package/evals/acceptance/test_kiro_harness.sh +1 -1
  106. package/evals/ci/antigaming-suite.sh +5 -0
  107. package/evals/ci/run-baseline.sh +71 -1
  108. package/evals/fixtures/assignment-provider/actor-a.json +6 -0
  109. package/evals/fixtures/assignment-provider/actor-b.json +6 -0
  110. package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
  111. package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
  112. package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
  113. package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
  114. package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/flows/review.flow.json +26 -0
  115. package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/kit.json +13 -0
  116. package/evals/fixtures/flow-kit-repository/valid-with-dependency/flows/review.flow.json +26 -0
  117. package/evals/fixtures/flow-kit-repository/valid-with-dependency/kit.json +13 -0
  118. package/evals/fixtures/hook-influence/cases.json +32 -0
  119. package/evals/fixtures/pull-work-provider/github-issues.json +6 -6
  120. package/evals/fixtures/trust-reconcile-exploits/fabricated-attestation.json +59 -0
  121. package/evals/fixtures/trust-reconcile-exploits/no-label-bypass.json +36 -0
  122. package/evals/fixtures/trust-reconcile-exploits/skip-assumed-bypass.json +59 -0
  123. package/evals/fixtures/trust-reconcile-exploits/status-misassertion.json +48 -0
  124. package/evals/fixtures/trust-reconcile-exploits/waived-command-check.json +44 -0
  125. package/evals/fixtures/trust-reconcile-mixed-bundle/mixed-bundle.json +159 -0
  126. package/evals/fixtures/trust-reconcile-ws3/ws3-bundle.json +920 -0
  127. package/evals/integration/test_actor_identity.sh +254 -0
  128. package/evals/integration/test_assignment_provider_github.sh +318 -0
  129. package/evals/integration/test_assignment_provider_local_file.sh +222 -0
  130. package/evals/integration/test_builder_step_producers.sh +33 -23
  131. package/evals/integration/test_bundle_install.sh +22 -16
  132. package/evals/integration/test_bundle_lifecycle.sh +11 -11
  133. package/evals/integration/test_captured_fail_reconciliation.sh +50 -50
  134. package/evals/integration/test_checkpoint_signing.sh +43 -0
  135. package/evals/integration/test_claim_lookup.sh +4 -4
  136. package/evals/integration/test_codex_hook_resolution.sh +114 -0
  137. package/evals/integration/test_command_log_concurrency.sh +8 -8
  138. package/evals/integration/test_command_log_fork_classification.sh +3 -3
  139. package/evals/integration/test_command_log_integrity.sh +16 -16
  140. package/evals/integration/test_console_learning_projection.sh +1 -1
  141. package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
  142. package/evals/integration/test_dual_emit_flow_step.sh +145 -0
  143. package/evals/integration/test_enforcer_expects_driven.sh +12 -12
  144. package/evals/integration/test_evidence_capture_hook.sh +14 -13
  145. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  146. package/evals/integration/test_flow_agents_statusline.sh +2 -2
  147. package/evals/integration/test_flow_kit_install_git.sh +52 -0
  148. package/evals/integration/test_flow_kit_repository.sh +2 -0
  149. package/evals/integration/test_flowdef_session_activation.sh +6 -6
  150. package/evals/integration/test_flowdef_union_floor_regression.sh +429 -0
  151. package/evals/integration/test_gate_bypass_chain.sh +52 -40
  152. package/evals/integration/test_gate_lockdown.sh +97 -73
  153. package/evals/integration/test_gate_review_inquiry_records.sh +3 -3
  154. package/evals/integration/test_goal_fit_escape_hatch.sh +10 -10
  155. package/evals/integration/test_goal_fit_ghost_session.sh +104 -0
  156. package/evals/integration/test_goal_fit_hook.sh +32 -32
  157. package/evals/integration/test_goal_fit_rederive.sh +9 -9
  158. package/evals/integration/test_hook_category_behaviors.sh +103 -0
  159. package/evals/integration/test_hook_influence_cases.sh +1 -0
  160. package/evals/integration/test_install_merge.sh +262 -1
  161. package/evals/integration/test_kit_identity_trust.sh +6 -6
  162. package/evals/integration/test_liveness_conflict_injection.sh +587 -0
  163. package/evals/integration/test_liveness_heartbeat.sh +677 -0
  164. package/evals/integration/test_liveness_verdict.sh +394 -0
  165. package/evals/integration/test_local_flow_kit_install.sh +15 -0
  166. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -11
  167. package/evals/integration/test_promote_gate.sh +178 -0
  168. package/evals/integration/test_publish_delivery.sh +23 -6
  169. package/evals/integration/test_pull_work_assignment_join.sh +132 -0
  170. package/evals/integration/test_pull_work_liveness_preflight.sh +394 -0
  171. package/evals/integration/test_pull_work_provider.sh +2 -2
  172. package/evals/integration/test_reconcile_soundness.sh +37 -13
  173. package/evals/integration/test_resolvefirststep_security.sh +22 -0
  174. package/evals/integration/test_runtime_adapter_activation.sh +87 -18
  175. package/evals/integration/test_session_resume_roundtrip.sh +147 -0
  176. package/evals/integration/test_sidecar_field_preservation.sh +249 -0
  177. package/evals/integration/test_trust_reconcile.sh +11 -10
  178. package/evals/integration/test_trust_reconcile_manifest.sh +79 -0
  179. package/evals/integration/test_trust_reconcile_mixed_bundle.sh +96 -0
  180. package/evals/integration/test_trust_reconcile_negatives.sh +759 -0
  181. package/evals/integration/test_usage_feedback_global.sh +5 -5
  182. package/evals/integration/test_validate_artifacts_portability.sh +60 -0
  183. package/evals/integration/test_verify_cli.sh +2 -2
  184. package/evals/integration/test_veritas_governance_kit.sh +117 -0
  185. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +26 -1
  186. package/evals/integration/test_workflow_artifacts.sh +33 -0
  187. package/evals/integration/test_workflow_sidecar_writer.sh +802 -74
  188. package/evals/integration/test_workflow_steering_hook.sh +175 -5
  189. package/evals/lib/codex-provider.sh +1 -1
  190. package/evals/lib/node.sh +19 -1
  191. package/evals/run.sh +32 -0
  192. package/evals/static/test_decisions.sh +150 -0
  193. package/evals/static/test_flowdef_codeowners_coverage.sh +62 -0
  194. package/evals/static/test_knowledge_providers.sh +23 -0
  195. package/evals/static/test_package.sh +19 -10
  196. package/evals/static/test_universal_bundles.sh +48 -3
  197. package/evals/static/test_workflow_skills.sh +41 -8
  198. package/kits/builder/flows/build.flow.json +3 -74
  199. package/kits/builder/flows/publish-learn.flow.json +90 -0
  200. package/kits/builder/kit.json +11 -0
  201. package/kits/builder/skills/builder-shape/SKILL.md +1 -1
  202. package/kits/builder/skills/deliver/SKILL.md +32 -11
  203. package/kits/builder/skills/design-probe/SKILL.md +4 -4
  204. package/kits/builder/skills/evidence-gate/SKILL.md +26 -1
  205. package/kits/builder/skills/execute-plan/SKILL.md +2 -2
  206. package/kits/builder/skills/fix-bug/SKILL.md +2 -0
  207. package/kits/builder/skills/gate-review/SKILL.md +6 -6
  208. package/kits/builder/skills/idea-to-backlog/SKILL.md +1 -1
  209. package/kits/builder/skills/learning-review/SKILL.md +7 -7
  210. package/kits/builder/skills/pickup-probe/SKILL.md +5 -4
  211. package/kits/builder/skills/plan-work/SKILL.md +27 -5
  212. package/kits/builder/skills/pull-work/SKILL.md +148 -5
  213. package/kits/builder/skills/release-readiness/SKILL.md +4 -4
  214. package/kits/builder/skills/review-work/SKILL.md +24 -1
  215. package/kits/builder/skills/tdd-workflow/SKILL.md +2 -0
  216. package/kits/builder/skills/verify-work/SKILL.md +8 -1
  217. package/kits/catalog.json +6 -0
  218. package/kits/knowledge/kit.json +35 -0
  219. package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
  220. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
  221. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
  222. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
  223. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
  224. package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
  225. package/kits/knowledge/providers/conformance/suite.test.js +125 -0
  226. package/kits/knowledge/providers/git-repo/index.js +236 -0
  227. package/kits/knowledge/providers/health/health-pass.test.js +99 -0
  228. package/kits/knowledge/providers/health/index.js +153 -0
  229. package/kits/knowledge/providers/index.js +24 -0
  230. package/kits/knowledge/providers/lib/model.js +91 -0
  231. package/kits/knowledge/providers/lib/schema-validate.js +119 -0
  232. package/kits/knowledge/providers/markdown-vault/index.js +169 -0
  233. package/kits/knowledge/providers/work-item/index.js +204 -0
  234. package/kits/veritas-governance/adapter/readiness-to-trust-bundle.mjs +178 -0
  235. package/kits/veritas-governance/docs/README.md +75 -0
  236. package/kits/veritas-governance/fixtures/readiness/not-ready.readiness-report.json +4645 -0
  237. package/kits/veritas-governance/fixtures/readiness/ready.readiness-report.json +4403 -0
  238. package/kits/veritas-governance/flows/readiness-check.flow.json +35 -0
  239. package/kits/veritas-governance/kit.json +16 -0
  240. package/package.json +11 -5
  241. package/packaging/README.md +2 -2
  242. package/packaging/conformance/fixtures/config-protection--allow-read-state-json.json +20 -0
  243. package/packaging/conformance/fixtures/config-protection--block-write-state-json.json +20 -0
  244. package/packaging/conformance/fixtures/stop-goal-fit--block-bundle-disputed-claim.json +1 -1
  245. package/packaging/conformance/fixtures/stop-goal-fit--block-capture-contradicts-claimed-pass.json +1 -1
  246. package/packaging/manifest.json +9 -10
  247. package/schemas/assignment-provider-settings.schema.json +125 -0
  248. package/schemas/decision-record.schema.json +109 -0
  249. package/schemas/knowledge/edge.schema.json +54 -0
  250. package/schemas/knowledge/health-report.schema.json +45 -0
  251. package/schemas/knowledge/node.schema.json +49 -0
  252. package/schemas/knowledge/proposal.schema.json +53 -0
  253. package/schemas/workflow-evidence.schema.json +1 -1
  254. package/schemas/workflow-state.schema.json +5 -0
  255. package/scripts/README.md +8 -3
  256. package/scripts/check-content-boundary.cjs +1 -1
  257. package/scripts/check-decisions.cjs +356 -0
  258. package/scripts/ci/derive-claim-status.mjs +74 -0
  259. package/scripts/ci/trust-reconcile.js +957 -103
  260. package/scripts/git-status.sh +2 -2
  261. package/scripts/hooks/claude-telemetry-hook.js +37 -3
  262. package/scripts/hooks/codex-telemetry-hook.js +36 -2
  263. package/scripts/hooks/config-protection.js +102 -30
  264. package/scripts/hooks/evidence-capture.js +3 -3
  265. package/scripts/hooks/lib/actor-identity.js +368 -0
  266. package/scripts/hooks/lib/config-protection-remedies.js +69 -0
  267. package/scripts/hooks/lib/liveness-heartbeat.js +361 -0
  268. package/scripts/hooks/lib/liveness-policy.js +127 -0
  269. package/scripts/hooks/lib/liveness-read.js +105 -12
  270. package/scripts/hooks/lib/liveness-write.js +46 -0
  271. package/scripts/hooks/lib/local-artifact-paths.js +12 -6
  272. package/scripts/hooks/opencode-telemetry-hook.js +27 -1
  273. package/scripts/hooks/pi-telemetry-hook.js +27 -1
  274. package/scripts/hooks/stop-goal-fit.js +157 -26
  275. package/scripts/hooks/workflow-steering.js +77 -3
  276. package/scripts/install-codex-home.sh +138 -38
  277. package/scripts/statusline/flow-agents-statusline.js +2 -2
  278. package/src/cli/assignment-provider.ts +845 -0
  279. package/src/cli/console-learning-projection.ts +3 -2
  280. package/src/cli/effective-assignment-provider-settings.ts +112 -0
  281. package/src/cli/init.ts +101 -21
  282. package/src/cli/kit.ts +37 -6
  283. package/src/cli/public-api.test.mjs +58 -6
  284. package/src/cli/pull-work-provider.ts +1 -1
  285. package/src/cli/trust-bundle-policy-order.test.mjs +87 -0
  286. package/src/cli/usage-feedback.ts +3 -3
  287. package/src/cli/validate-hook-influence.ts +1 -0
  288. package/src/cli/validate-workflow-artifacts.ts +27 -7
  289. package/src/cli/veritas-readiness-adapter.test.mjs +267 -0
  290. package/src/cli/workflow-artifact-cleanup-audit.ts +35 -2
  291. package/src/cli/workflow-sidecar.ts +933 -168
  292. package/src/cli.ts +6 -0
  293. package/src/flow-kit/validate.ts +74 -1
  294. package/src/index.ts +6 -2
  295. package/src/lib/flow-resolver.ts +123 -55
  296. package/src/lib/local-artifact-root.ts +24 -5
  297. package/src/lib/workflow-learning-projection.ts +2 -2
  298. package/src/runtime-adapters.ts +61 -15
  299. package/src/tools/build-universal-bundles.ts +49 -16
  300. package/src/tools/generate-context-map.ts +10 -8
  301. package/src/tools/validate-source-tree.ts +24 -1
@@ -13,7 +13,7 @@ type AnyObj = Record<string, any>;
13
13
 
14
14
  export const statuses = new Set(["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "accepted", "archived"]);
15
15
  export const phases = ["idea", "backlog", "pickup", "planning", "execution", "verification", "goal_fit", "evidence", "release", "learning", "done"];
16
- export const checkKinds = new Set(["build", "types", "lint", "test", "security", "diff", "browser", "runtime", "policy", "external"]);
16
+ export const checkKinds = new Set(["build", "types", "lint", "test", "command", "security", "diff", "browser", "runtime", "policy", "external"]);
17
17
  export const checkStatuses = new Set(["pass", "fail", "not_verified", "skip"]);
18
18
  export const verdicts = new Set(["pass", "partial", "fail", "not_verified"]);
19
19
 
@@ -50,6 +50,144 @@ function workItemSlug(ref: string): string {
50
50
  return slugify(`${owner}-${repo}-${id}`, "work-item");
51
51
  }
52
52
 
53
+ /** Pure, lock-free, side-effect-free CLI wrapper around workItemSlug() — the single source of
54
+ * truth for the deterministic subjectId/session-directory-name slug. Named resolveSlugCmd (not
55
+ * resolveSlug) to avoid colliding with any future export named resolveSlug. */
56
+ function resolveSlugCmd(p: ReturnType<typeof parseArgs>): number {
57
+ const ref = p.positional[0] || die("resolve-slug requires an owner/repo#id ref");
58
+ console.log(workItemSlug(ref));
59
+ return 0;
60
+ }
61
+
62
+ /** First 6 hex chars of sha256(raw) — a short deterministic disambiguator (#289 F4). Two
63
+ * different raw inputs that both collapse to a segment's "unknown" fallback (e.g. two distinct
64
+ * all-garbage --task-slug values, or two distinct raw actor strings that both resolve
65
+ * "unresolved") would otherwise derive the identical fallback branch — this makes them diverge
66
+ * while staying fully deterministic for a given raw input (same raw -> same hash every call),
67
+ * which is required to preserve resolveSessionBranch's no-rederive/resume-continuity semantics
68
+ * (a resumed session's already-recorded branch is never recomputed regardless of this helper). */
69
+ function unknownDisambiguator(raw: string): string {
70
+ return createHash("sha256").update(raw).digest("hex").slice(0, 6);
71
+ }
72
+
73
+ /** Encode a value (actor string or --task-slug, neither guaranteed git-ref-safe) into a
74
+ * single git-check-ref-format(1)-safe branch path component (#289). Reuses
75
+ * actor-identity.js's sanitizeSegment (consume, don't fork) as the base, then closes the
76
+ * git-specific gaps sanitizeSegment's [A-Za-z0-9_.-] charset does not: the `:` delimiter
77
+ * serializeActor() uses (disallowed in a git ref), consecutive dots, a leading dot, a trailing
78
+ * dot (fix-plan iteration 1 F1 — git-check-ref-format forbids a component ending in `.`, and
79
+ * the prior pass only handled a run of 2+ trailing dots via the `..`-collapse step, missing the
80
+ * single-trailing-dot case e.g. a `--task-slug` of `my-fix.`), and a trailing `.lock`. The
81
+ * trailing-dot strip runs BEFORE the `.lock` rewrite so a `.lock` suffix hidden behind trailing
82
+ * dots (e.g. `foo.lock.`) is exposed and still rewritten rather than left dangling. Whenever the
83
+ * FINAL sanitized segment equals the fallback token `"unknown"` — whether because the charset
84
+ * filter collapsed the raw input to nothing, OR because the leading-/trailing-dot stripping
85
+ * above collapsed a near-miss input like `"unknown."` or `".unknown"` down to the literal
86
+ * `"unknown"`, OR because the raw input genuinely WAS the literal string `"unknown"` — a
87
+ * deterministic disambiguator (see unknownDisambiguator) is ALWAYS appended (fix-plan iteration 2
88
+ * F4': no literal-input carve-out). Distinct raw inputs can never collide on the bare literal
89
+ * `"unknown"` segment; the same raw input always re-derives the same disambiguated segment. See
90
+ * Design Decision 2 in the plan. */
91
+ function sanitizeBranchSegment(value: string, helper: { sanitizeSegment: (v: unknown) => string }): string {
92
+ const raw = String(value ?? "");
93
+ const colonReplaced = raw.replace(/:/g, "-");
94
+ let seg = helper.sanitizeSegment(colonReplaced);
95
+ seg = seg.replace(/\.{2,}/g, "-").replace(/^\.+/, "").replace(/\.+$/, "").replace(/\.lock$/i, "-lock");
96
+ if (!seg || seg === "unknown") return `unknown-${unknownDisambiguator(raw)}`;
97
+ return seg;
98
+ }
99
+
100
+ /** Validate an explicit `--branch` value strictly rather than trusting it verbatim (fix-plan
101
+ * iteration 1 F2, tightened by iteration 2 F2'). Explicit `--branch` bypasses
102
+ * `sanitizeBranchSegment` entirely by design (it is caller intent, may legitimately contain `/`
103
+ * to nest under `agent/...`, etc.), so unlike the derived path it must be rejected outright — not
104
+ * silently sanitized — when it cannot be a valid git ref. Whole-string checks (iteration 1):
105
+ * any control character/newline, a space, a leading or trailing `/`, a `//` sequence, a leading
106
+ * `.`, any `..` sequence, a trailing `.`, a trailing `.lock`, or any character outside
107
+ * `[A-Za-z0-9_./-]`. Per-component checks (iteration 2 F2'): the whole-string checks above only
108
+ * examine the START/END of the full value, so a charset-legal value can still smuggle an invalid
109
+ * `/`-delimited component past them — e.g. `"-lead"` (a leading `-` breaks the whole git ref,
110
+ * applied uniformly to every component here — stricter than git strictly requires, which is fine
111
+ * for a caller-facing override flag), `"a/.b"` (a non-first component starting with `.`),
112
+ * `"foo.lock/bar"` (a non-last component ending in `.lock`), or `"a/./b"` (a component that is
113
+ * exactly `.`). Belt-and-braces (F2'): once the lexical checks above all pass, `git
114
+ * check-ref-format --branch <value>` (the real git binary) is run as the final authority — it can
115
+ * only ever REJECT a value the lexical checks let through (never re-legalize one they rejected),
116
+ * so it closes any residual gap in this hand-rolled lexical pass. When git cannot be spawned at
117
+ * all (e.g. not installed — `ENOENT`) or does not complete (e.g. the 5s timeout fires), the git
118
+ * check is skipped silently and the lexical checks above remain the sole authority. Dies with
119
+ * remediation, or with git's own rejection message; never mutates any artifact before this check
120
+ * runs (resolveSessionBranch calls this before any file write). */
121
+ function validateExplicitBranch(value: string): void {
122
+ const remediation = `Pass a --branch value matching [A-Za-z0-9_./-], with no leading/trailing "/", no "//", no leading ".", no ".." sequence, and no trailing "." or ".lock" (got: ${JSON.stringify(value)}).`;
123
+ const fail = (reason: string): never => die(`ensure-session --branch value is not a valid git ref: ${reason}. ${remediation}`);
124
+ if (/[\x00-\x1F\x7F]/.test(value)) fail("contains a control character or newline");
125
+ if (/ /.test(value)) fail("contains a space");
126
+ if (value.startsWith("/") || value.endsWith("/")) fail('must not start or end with "/"');
127
+ if (value.includes("//")) fail('must not contain "//"');
128
+ if (value.startsWith(".")) fail('must not start with "."');
129
+ if (value.includes("..")) fail('must not contain a ".." sequence');
130
+ if (value.endsWith(".")) fail('must not end with "."');
131
+ if (/\.lock$/i.test(value)) fail('must not end with ".lock"');
132
+ if (/[^A-Za-z0-9_./-]/.test(value)) fail("contains a character outside [A-Za-z0-9_./-]");
133
+
134
+ // F2' per-component checks: split on "/" and validate each path component individually. The
135
+ // whole-string checks above cannot catch a hostile component that is not at the very start or
136
+ // end of the full value.
137
+ for (const component of value.split("/")) {
138
+ if (!component) fail('must not contain an empty path component ("//" or a leading/trailing "/")');
139
+ if (component === ".") fail('must not contain a path component that is exactly "."');
140
+ if (component.startsWith(".")) fail('must not contain a path component starting with "."');
141
+ if (component.startsWith("-")) fail('must not contain a path component starting with "-"');
142
+ if (component.endsWith(".")) fail('must not contain a path component ending with "."');
143
+ if (/\.lock$/i.test(component)) fail('must not contain a path component ending with ".lock"');
144
+ }
145
+
146
+ // F2' belt-and-braces: the real `git check-ref-format --branch` binary is the final authority
147
+ // when git is available. This can only REJECT a value that already passed every lexical check
148
+ // above — it never re-legalizes a value the lexical checks rejected (those `fail()` calls
149
+ // above already threw). argv-array form (no shell) with a 5s timeout so a hung or missing git
150
+ // binary cannot hang or crash session creation.
151
+ let result: { status: number | null; stderr: Buffer | string } | undefined;
152
+ try {
153
+ execFileSync("git", ["check-ref-format", "--branch", value], { stdio: ["ignore", "ignore", "pipe"], timeout: 5000 });
154
+ return; // exit 0 — git accepts the value; nothing further to check.
155
+ } catch (err) {
156
+ const spawnError = err as NodeJS.ErrnoException & { status?: number | null; stderr?: Buffer | string };
157
+ if (spawnError && spawnError.code === "ENOENT") return; // git not installed — lexical checks stand alone.
158
+ if (spawnError && typeof spawnError.status === "number") { result = { status: spawnError.status, stderr: spawnError.stderr ?? "" }; }
159
+ else return; // Any other spawn failure (e.g. timeout) — skip silently; lexical checks already passed.
160
+ }
161
+ if (result && result.status !== 0) {
162
+ const gitMessage = String(result.stderr ?? "").trim();
163
+ fail(gitMessage ? `git check-ref-format rejected the value: ${gitMessage}` : "git check-ref-format rejected the value");
164
+ }
165
+ }
166
+
167
+ /** Resolve the branch to seed a brand-new session with. Only called from ensureSession's
168
+ * `if (!md)` fresh-creation branch — an existing session's already-recorded branch is never
169
+ * recomputed (ADR 0021 §5 takeover continuity; see Design Decision 3). Precedence: explicit
170
+ * --branch (strictly validated, then honored verbatim — see F2) > derived agent/<actor>/<slug>.
171
+ * Never hard-fails session creation on actor-resolution ambiguity (Design Decision 4) — only a
172
+ * garbage explicit --actor, or a garbage explicit --branch, dies. */
173
+ function resolveSessionBranch(p: ReturnType<typeof parseArgs>, slug: string): string {
174
+ // Deliberately NOT trimmed before validation (unlike the pre-F2 baseline): a leading/trailing
175
+ // space must be REJECTED, not silently trimmed away — silent trimming would let a
176
+ // caller-supplied value differ from what gets recorded without any diagnostic (F2).
177
+ const explicitBranch = opt(p, "branch");
178
+ if (explicitBranch) { validateExplicitBranch(explicitBranch); return explicitBranch; }
179
+ const helper = loadActorIdentityHelper();
180
+ const explicitActorRaw = opt(p, "actor", "").trim();
181
+ if (explicitActorRaw && !/[A-Za-z0-9_.-]/.test(explicitActorRaw)) {
182
+ die("ensure-session --actor value strips to empty under the allowed actor charset ([A-Za-z0-9_.-]) — pass a value containing at least one letter, digit, underscore, period, or hyphen.");
183
+ }
184
+ const actor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : helper.resolveActor(process.env).actor;
185
+ const unresolved = helper.isUnresolvedActor(actor);
186
+ const safeActor = unresolved ? `unknown-actor-${unknownDisambiguator(actor)}` : sanitizeBranchSegment(actor, helper);
187
+ if (unresolved) process.stderr.write("[ensure-session] actor unresolved; branch uses \"unknown-actor\" segment (set --actor or FLOW_AGENTS_ACTOR for a stable branch name)\n");
188
+ return `agent/${safeActor}/${sanitizeBranchSegment(slug, helper)}`;
189
+ }
190
+
53
191
  /**
54
192
  * Validate a Hachure trust.bundle using @kontourai/surface's canonical validator
55
193
  * (surface is the authoritative owner of trust-bundle schema validation per ADR 0010 / ADR 0015).
@@ -170,7 +308,7 @@ type SurfaceModule = {
170
308
  policies: Record<string, unknown>[];
171
309
  now?: Date;
172
310
  }) => { status: string; policyId: string | undefined };
173
- generateClaimId: (subjectId: string, surface: string, fieldOrBehavior: string) => string;
311
+ generateClaimId: (subjectId: string, facet: string, fieldOrBehavior: string) => string;
174
312
  statusFunctionVersion: string;
175
313
  resolveInquiry: (
176
314
  bundle: Record<string, unknown>,
@@ -241,6 +379,48 @@ function criterionStatusToEventStatus(status: string): string | null {
241
379
  if (status === "accepted_gap") return "assumed";
242
380
  return null; // pending / not_verified → no event → Surface returns "unknown"
243
381
  }
382
+ /**
383
+ * WS8 (ADR 0020): Derive Surface evidence classification (evidenceType + method)
384
+ * from a workflow check's kind, replacing the previous hardcoded `test_output`.
385
+ * Only command-backed re-runnable checks are `test_output` (CI-reconcilable);
386
+ * everything else is inherently session-local (manual attestation, provider/
387
+ * document citation, crawl observation, policy rule, source excerpt). The
388
+ * `reconcilable` flag is informational — the CI reconciler classifies purely by
389
+ * the emitted `evidenceType`. Every value is a member of Surface's own
390
+ * evidence.schema.json `evidenceType`/`method` enums (consume-never-fork).
391
+ */
392
+ function classifyEvidence(kind: string | undefined, hasCommand: boolean): { evidenceType: string; method: string; reconcilable: boolean } {
393
+ const k = String(kind ?? "external");
394
+ switch (k) {
395
+ case "build":
396
+ case "types":
397
+ case "lint":
398
+ case "test":
399
+ case "command":
400
+ return { evidenceType: "test_output", method: "validation", reconcilable: true };
401
+ case "security":
402
+ return hasCommand
403
+ ? { evidenceType: "test_output", method: "validation", reconcilable: true }
404
+ : { evidenceType: "attestation", method: "corroboration", reconcilable: false };
405
+ case "diff":
406
+ return { evidenceType: "source_excerpt", method: "extraction", reconcilable: false };
407
+ case "browser":
408
+ return hasCommand
409
+ ? { evidenceType: "test_output", method: "validation", reconcilable: true }
410
+ : { evidenceType: "crawl_observation", method: "observation", reconcilable: false };
411
+ case "runtime":
412
+ return hasCommand
413
+ ? { evidenceType: "test_output", method: "validation", reconcilable: true }
414
+ : { evidenceType: "attestation", method: "attestation", reconcilable: false };
415
+ case "policy":
416
+ return { evidenceType: "policy_rule", method: "auditability", reconcilable: false };
417
+ case "external":
418
+ return { evidenceType: "attestation", method: "corroboration", reconcilable: false };
419
+ default:
420
+ return { evidenceType: "test_output", method: "validation", reconcilable: true };
421
+ }
422
+ }
423
+
244
424
  /** Map a critique verdict to the Surface VerificationEvent status. */
245
425
  function critiqueToEventStatus(verdict: string, findings: AnyObj[]): string | null {
246
426
  if (verdict === "fail") return "disputed";
@@ -281,14 +461,29 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
281
461
  const events: AnyObj[] = [];
282
462
  const ts = timestamp || new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
283
463
 
284
- // One VerificationPolicy per distinct claimType, so status is policy-governed
285
- // (not derived against an empty policy set). Maximal-fidelity per ADR 0010.
464
+ // One VerificationPolicy per distinct (claimType, requiredEvidence) pair, so status is
465
+ // policy-governed (not derived against an empty policy set). Maximal-fidelity per ADR 0010.
466
+ //
467
+ // WS8 (AC1, iteration 2): the cache is keyed by claimType + the normalized requiredEvidence
468
+ // set, NOT by claimType alone. Two checks of the same legacy claimType that differ in
469
+ // command-presence (e.g. a command-backed browser check → requiredEvidence [test_output]
470
+ // vs a no-command browser check → [crawl_observation]) previously COLLIDED: the first-seen
471
+ // requiredEvidence won and corrupted the second claim's derived status (verified → proposed)
472
+ // in a record-order-dependent way. Keying by (claimType, requiredEvidence) makes policy
473
+ // construction order-independent — each distinct evidence signature gets its own policy, and
474
+ // each claim references its own via verificationPolicyId (Surface's resolvePolicyForClaim
475
+ // honors verificationPolicyId first, so same-claimType policies never cross-resolve).
476
+ // Merging is NOT used because Surface's requiredEvidence is all-of (`.every`), so a union
477
+ // would over-constrain both claims.
286
478
  const policies = new Map<string, AnyObj>();
287
479
  const ensurePolicy = (claimType: string, impactLevel: string, requiredEvidence: string[]): AnyObj => {
288
- let p = policies.get(claimType);
480
+ const reqSorted = [...new Set(requiredEvidence)].sort();
481
+ const key = `${claimType}::${reqSorted.join(",")}`;
482
+ let p = policies.get(key);
289
483
  if (!p) {
290
- p = { id: `policy:${claimType}`, claimType, requiredEvidence, acceptanceCriteria: [`A verified verification event must support a ${claimType} claim.`], reviewAuthority: "system", validityRule: { kind: "manual" }, stalenessTriggers: [], conflictRules: [], impactLevel };
291
- policies.set(claimType, p);
484
+ const id = reqSorted.length ? `policy:${claimType}:${reqSorted.join("+")}` : `policy:${claimType}`;
485
+ p = { id, claimType, requiredEvidence: reqSorted, acceptanceCriteria: [`A verified verification event must support a ${claimType} claim.`], reviewAuthority: "system", validityRule: { kind: "manual" }, stalenessTriggers: [], conflictRules: [], impactLevel };
486
+ policies.set(key, p);
292
487
  }
293
488
  return p;
294
489
  };
@@ -402,12 +597,32 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
402
597
  const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
403
598
  const evId = `ev:${claimId}`;
404
599
  const legacyClaimType = `workflow.check.${check.kind ?? "external"}`;
405
- const policy = ensurePolicy(legacyClaimType, "high", ["test_output"]);
406
600
 
407
601
  const cmd = typeof check.command === "string" ? check.command.replace(/\s+/g, " ").trim() : "";
408
602
  const captured = cmd ? captureByCommand.get(cmd) : undefined;
603
+ // WS8 (ADR 0020): classify evidence from check.kind instead of hardcoding test_output.
604
+ // A waived accepted-gap (check._waiver, set by record-evidence/record-gate-claim
605
+ // --accepted-gap-reason/--waived-by) is by definition session-local: it is an
606
+ // attested accepted gap, never a CI-re-runnable command, so it is classified
607
+ // attestation/attestation and its claim status is forced to `assumed` (reusing the
608
+ // existing accepted_gap -> assumed mapping — no new status value).
609
+ const waiver = (check._waiver && typeof check._waiver === "object") ? check._waiver as AnyObj : null;
610
+ const evClass = waiver
611
+ ? { evidenceType: "attestation", method: "attestation", reconcilable: false }
612
+ : classifyEvidence(check.kind, cmd.length > 0);
613
+ const policy = ensurePolicy(legacyClaimType, "high", [evClass.evidenceType]);
409
614
  const effectiveStatus = captured ? captured.observedResult : String(check.status ?? "");
410
- const evStatus = checkStatusToEventStatus(effectiveStatus);
615
+ const evStatus = waiver ? "assumed" : checkStatusToEventStatus(effectiveStatus);
616
+ // Promotion claim marker (issue #312): a `promote` check carries a session-local
617
+ // _promotion object that must survive onto claim.metadata.promotion so the archive gate
618
+ // (workflow-artifact-cleanup-audit) and validators can detect the promotion claim without a
619
+ // new manifest entry. It rides alongside any waiver in a single merged metadata object.
620
+ const promotionMeta = (check._promotion && typeof check._promotion === "object") ? check._promotion as AnyObj : null;
621
+ // #268: stamp a stable origin discriminator so checksFromBundle / critiquesFromBundle can
622
+ // distinguish check vs critique vs acceptance claims across round-trips even under --flow-id,
623
+ // where all three collapse onto the same declared claimType (and a command-less critique claim
624
+ // would otherwise be re-absorbed as a test_output check → permanent [not-run] divergence).
625
+ const claimMetadata: AnyObj = { origin: "check", check_kind: String(check.kind ?? "external"), ...(waiver ? { waiver } : {}), ...(promotionMeta ? { promotion: promotionMeta } : {}) };
411
626
 
412
627
  const claimEvents: AnyObj[] = [];
413
628
  if (evStatus) {
@@ -415,11 +630,17 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
415
630
  events.push(evt);
416
631
  claimEvents.push(evt);
417
632
  }
418
- const evItem: AnyObj = { id: evId, claimId, evidenceType: "test_output", method: "validation", sourceRef: `${slug}/evidence.json`, excerptOrSummary: fieldOrBehavior, observedAt: ts, collectedBy: "flow-agents/workflow-sidecar", passing: effectiveStatus === "pass" };
633
+ const evItem: AnyObj = { id: evId, claimId, evidenceType: evClass.evidenceType, method: evClass.method, sourceRef: `${slug}/evidence.json`, excerptOrSummary: fieldOrBehavior, observedAt: ts, collectedBy: "flow-agents/workflow-sidecar", passing: effectiveStatus === "pass" };
419
634
  if (captured) {
420
635
  evItem.sourceRef = `${slug}/command-log.jsonl`;
421
636
  evItem.collectedBy = "flow-agents/evidence-capture";
422
637
  evItem.execution = { runner: "bash", label: cmd, isError: captured.observedResult === "fail", ...(captured.exitCode != null ? { exitCode: captured.exitCode } : {}) };
638
+ } else if (cmd && !waiver) {
639
+ // WS8 (ADR 0020): always stamp execution.label on command-backed checks so the CI
640
+ // reconciler has a stable key to match against the manifest, even when the local
641
+ // command-log capture did not happen to run this command. isError is derived from
642
+ // the check's own reported status (no captured exit code available in this path).
643
+ evItem.execution = { runner: "bash", label: cmd, isError: effectiveStatus !== "pass" };
423
644
  }
424
645
  evidenceItems.push(evItem);
425
646
 
@@ -428,13 +649,13 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
428
649
  const declared = matchExpectsEntry("check", check.kind, typeof check._gate_claim_expectation_id === "string" ? check._gate_claim_expectation_id : undefined);
429
650
  if (declared) {
430
651
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
431
- const declaredPolicy = ensurePolicy(declared.claimType, "high", ["test_output"]);
432
- const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, surface: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id };
652
+ const declaredPolicy = ensurePolicy(declared.claimType, "high", [evClass.evidenceType]);
653
+ 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 } : {}) };
433
654
  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>[] });
434
655
  claims.push({ ...declaredClaimObj, status: declaredStatus });
435
656
  } else {
436
657
  // No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
437
- const claimObj: AnyObj = { id: claimId, subjectType: "workflow-check", subjectId, surface: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id };
658
+ const claimObj: AnyObj = { id: claimId, subjectType: "workflow-check", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id, ...(claimMetadata ? { metadata: claimMetadata } : {}) };
438
659
  const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [evItem] as Record<string, unknown>[], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
439
660
  claims.push({ ...claimObj, status: derivedStatus });
440
661
  }
@@ -461,12 +682,12 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
461
682
  if (declared) {
462
683
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
463
684
  const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
464
- const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, surface: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id };
685
+ 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" } };
465
686
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
466
687
  claims.push({ ...declaredClaimObj, status: declaredStatus });
467
688
  } else {
468
689
  // No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
469
- const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, surface: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id };
690
+ const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id, metadata: { origin: "acceptance" } };
470
691
  const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
471
692
  claims.push({ ...claimObj, status: derivedStatus });
472
693
  }
@@ -477,10 +698,23 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
477
698
  if (!c.id) continue;
478
699
  const subjectId = `${slug}/${c.id}`;
479
700
  const fieldOrBehavior = String(c.summary ?? c.verdict ?? c.id);
480
- const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
701
+ // #267/#282: a critique carrying `superseded_by` is retained as HISTORY — a prior write for
702
+ // this critique id that a later, same-reviewer critique resolved. It is preserved structurally
703
+ // (status "superseded" + first-class metadata.superseded_by), but is excluded from reconcile
704
+ // evaluation and from the "critique pass cannot include fail members" validator rule.
705
+ const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
706
+ const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
707
+ const critiqueReviewedAt = String(c.reviewed_at ?? ts);
708
+ const critMeta: AnyObj = { origin: "critique", reviewer: critiqueReviewer, reviewed_at: critiqueReviewedAt, ...(supersededBy ? { superseded_by: supersededBy } : {}) };
709
+ // A superseded historical write gets a distinct, stable claimId so it co-exists with the live
710
+ // claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
711
+ // across rebuilds because superseded_by + reviewed_at are preserved in metadata.
712
+ const claimIdSalt = supersededBy ? `${fieldOrBehavior}::superseded::${supersededBy}::${critiqueReviewedAt}` : fieldOrBehavior;
713
+ const claimId = generateClaimId(subjectId, "flow-agents.workflow", claimIdSalt);
481
714
  const legacyClaimType = "workflow.critique.review";
482
715
  const policy = ensurePolicy(legacyClaimType, "medium", []);
483
- const evStatus = critiqueToEventStatus(String(c.verdict ?? ""), c.findings ?? []);
716
+ // A superseded write emits NO verification event (its status is "superseded" directly).
717
+ const evStatus = supersededBy ? null : critiqueToEventStatus(String(c.verdict ?? ""), c.findings ?? []);
484
718
  const claimEvents: AnyObj[] = [];
485
719
  if (evStatus) {
486
720
  const evt: AnyObj = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [], createdAt: ts, verifiedAt: ts };
@@ -490,22 +724,21 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
490
724
 
491
725
  // P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
492
726
  const declared = matchExpectsEntry("critique");
493
- if (declared) {
494
- // Declared kit-typed claim only no legacy shadow (ADR 0016 P-d).
495
- const declaredPolicy = ensurePolicy(declared.claimType, "medium", []);
496
- const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, surface: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: declaredPolicy.id };
497
- const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
498
- claims.push({ ...declaredClaimObj, status: declaredStatus });
727
+ const claimType = declared ? declared.claimType : legacyClaimType;
728
+ const subjectType = declared ? declared.subjectType : "workflow-critique";
729
+ const claimPolicy = declared ? ensurePolicy(declared.claimType, "medium", []) : policy;
730
+ const claimObj: AnyObj = { id: claimId, subjectType, subjectId, facet: "flow-agents.workflow", claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: claimPolicy.id, metadata: critMeta };
731
+ if (supersededBy) {
732
+ // History: status is "superseded" directly (no verification event); excluded from evaluation.
733
+ claims.push({ ...claimObj, status: "superseded" });
499
734
  } else {
500
- // No active flow step only the workflow.* primary claim (legitimate no-flow fallback path).
501
- const claimObj: AnyObj = { id: claimId, subjectType: "workflow-critique", subjectId, surface: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: policy.id };
502
- const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
735
+ const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [claimPolicy] as Record<string, unknown>[] });
503
736
  claims.push({ ...claimObj, status: derivedStatus });
504
737
  }
505
738
  }
506
739
 
507
740
  return {
508
- schemaVersion: 3,
741
+ schemaVersion: 5,
509
742
  source: `flow-agents/workflow-sidecar;statusFunctionVersion=${statusFunctionVersion}`,
510
743
  claims,
511
744
  evidence: evidenceItems,
@@ -536,7 +769,7 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
536
769
  const raw = fs.readFileSync(path.join(dir, "command-log.jsonl"), "utf8");
537
770
  commandLog = raw.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => { try { return JSON.parse(l) as AnyObj; } catch { return null; } }).filter((x): x is AnyObj => x !== null);
538
771
  } catch { /* no capture log — fine */ }
539
- // ADR 0016 Abstraction A (P-d): pass the .flow-agents dir ONLY when current.json
772
+ // ADR 0016 Abstraction A (P-d): pass the runtime artifact root ONLY when current.json
540
773
  // points to this session (scoped active-flow guard). If current.json.artifact_dir
541
774
  // resolves to a different session, pass null — no active-flow claim mapping for this bundle.
542
775
  const _flowAgentsDir = path.dirname(dir);
@@ -743,6 +976,20 @@ function definitionAcceptanceLines(markdown: string): string[] {
743
976
  }
744
977
  return out;
745
978
  }
979
+
980
+ /** Extract a top-level `name: value` markdown field's value (e.g. `branch: agent/x/y`), mirroring
981
+ * this file's own local-regex-parsing convention for section()/definitionAcceptanceLines() (a
982
+ * near-duplicate of validate-workflow-artifacts.ts's field()/section() helpers — this file
983
+ * already keeps its own local copy of that pattern rather than sharing a module). `name` is
984
+ * regex-escaped before interpolation (fix-plan iteration 1 F3), mirroring
985
+ * validate-workflow-artifacts.ts's own field() escaping verbatim — every current call site
986
+ * passes a fixed literal (e.g. "branch"), but escaping keeps a future non-literal name from
987
+ * silently changing match semantics via unescaped regex metacharacters. Returns "" when the
988
+ * field is absent. */
989
+ function markdownField(markdown: string, name: string): string {
990
+ const re = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*(.+)$`, "m");
991
+ return re.exec(markdown)?.[1]?.trim() ?? "";
992
+ }
746
993
  function parseCriterion(line: string, index: number): AnyObj {
747
994
  let text = line.replace(/^-\s+\[[ xX]\]\s*/, "").trim();
748
995
  const m = /\s+-\s+Evidence:\s*(.+)$/i.exec(text);
@@ -777,14 +1024,20 @@ function validateAgentId(agent: string): string {
777
1024
 
778
1025
  /**
779
1026
  * Find the repository root by walking upward from a starting directory to locate
780
- * the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
781
- * findRepoRoot, but callable from workflow-sidecar.ts without re-importing the
782
- * internal helper.
1027
+ * the nearest ancestor containing a kits/ subdirectory, without falling back to
1028
+ * process.cwd(). Returns null when no kits/ ancestor is found within the walk
1029
+ * bound (e.g. a scratch/temp session directory with no repo ancestor at all).
783
1030
  *
784
- * ADR 0016 Abstraction A (P-d): used by advance-state and ensure-session to
785
- * derive repoRoot for resolvePhaseMap calls.
1031
+ * WS5 iteration-2 part 2: extracted so publishDelivery's repo-root resolution can
1032
+ * be fail-closed (see findRepoRootFromDir below and publishDelivery). A scratch
1033
+ * test session dir (mktemp -d, no kits/ ancestor) must never resolve to whatever
1034
+ * repo happens to be the current process's cwd — that previously let a throwaway
1035
+ * eval-local trust.bundle silently clobber this real repo's delivery/trust.bundle
1036
+ * when the eval was run from a checkout of this repo (see
1037
+ * evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings
1038
+ * at .kontourai/flow-agents/ws5-governance-kit-slice1).
786
1039
  */
787
- function findRepoRootFromDir(startDir: string): string {
1040
+ function findRepoRootFromDirStrict(startDir: string): string | null {
788
1041
  let dir = startDir;
789
1042
  for (let i = 0; i < 16; i++) {
790
1043
  if (fs.existsSync(path.join(dir, "kits"))) return dir;
@@ -792,7 +1045,24 @@ function findRepoRootFromDir(startDir: string): string {
792
1045
  if (parent === dir) break;
793
1046
  dir = parent;
794
1047
  }
795
- return process.cwd();
1048
+ return null;
1049
+ }
1050
+
1051
+ /**
1052
+ * Find the repository root by walking upward from a starting directory to locate
1053
+ * the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
1054
+ * findRepoRoot, but callable from workflow-sidecar.ts without re-importing the
1055
+ * internal helper. Falls back to process.cwd() when no kits/ ancestor is found —
1056
+ * appropriate for phase-map/first-step resolution (ADR 0016 Abstraction A, P-d),
1057
+ * where the caller is always invoked from within a real repo checkout.
1058
+ *
1059
+ * Do NOT use this cwd-falling-back variant for publishDelivery's repo-root
1060
+ * resolution — use findRepoRootFromDirStrict there instead, so a scratch/test
1061
+ * session dir with no repo ancestor fails closed (skips publish) rather than
1062
+ * silently trusting process.cwd(), which could be an unrelated real repo.
1063
+ */
1064
+ function findRepoRootFromDir(startDir: string): string {
1065
+ return findRepoRootFromDirStrict(startDir) ?? process.cwd();
796
1066
  }
797
1067
 
798
1068
  /**
@@ -825,7 +1095,11 @@ function resolveFirstStep(flowId: string, repoRoot: string): string | null {
825
1095
  }
826
1096
  }
827
1097
 
828
- function writeCurrent(root: string, dir: string, timestamp: string, owner: string, source: string, flowId?: string, stepId?: string): void {
1098
+ function writeCurrent(root: string, dir: string, timestamp: string, owner: string, source: string, flowId?: string, stepId?: string, adHocReason?: string): void {
1099
+ // #289: mirror the active session's already-recorded branch (state.json.branch) into
1100
+ // current.json so consumers of current.json (which has no schema of its own — not one of the
1101
+ // 9 schemas under schemas/) see the routing branch without re-reading state.json separately.
1102
+ const branch = loadJson(path.join(dir, "state.json")).branch;
829
1103
  writeJson(path.join(root, "current.json"), {
830
1104
  schema_version: "1.0",
831
1105
  active_slug: path.basename(dir),
@@ -834,11 +1108,17 @@ function writeCurrent(root: string, dir: string, timestamp: string, owner: strin
834
1108
  owner,
835
1109
  source,
836
1110
  active_agents: [],
1111
+ ...(branch ? { branch } : {}),
837
1112
  // ADR 0016 Abstraction A (P-a): optional FlowDefinition routing keys for the producer
838
1113
  // and enforcer. Both fields are optional and backward-compatible — sessions without a
839
1114
  // FlowDefinition omit them and fall through to the workflow.* claim type path.
840
1115
  ...(flowId ? { active_flow_id: flowId } : {}),
841
1116
  ...(stepId ? { active_step_id: stepId } : {}),
1117
+ // WS8 (AC12): sanctioned ad-hoc direct entry marker. Set when --step-id explicitly
1118
+ // targets a step other than the flow's resolved first step, so stop-goal-fit /
1119
+ // gate-review can distinguish an intentional direct entry (e.g. a planning-only
1120
+ // session that skips pull-work) from a stale/mis-stamped active_step_id.
1121
+ ...(adHocReason ? { ad_hoc_entry: true, ad_hoc_reason: adHocReason } : {}),
842
1122
  });
843
1123
  }
844
1124
  function loadCurrent(root: string): AnyObj | null {
@@ -850,7 +1130,13 @@ function currentDir(root: string): string | null {
850
1130
  const c = loadCurrent(root);
851
1131
  if (!c) return null;
852
1132
  const dir = path.resolve(root, c.artifact_dir ?? c.active_slug ?? "");
853
- return fs.existsSync(dir) ? dir : null;
1133
+ if (!fs.existsSync(dir)) return null;
1134
+ try {
1135
+ requireArtifactDirUnderRoot(dir, root);
1136
+ } catch {
1137
+ return null;
1138
+ }
1139
+ return dir;
854
1140
  }
855
1141
  function updateCurrentAgent(root: string, dir: string, agentId: string, status: string, timestamp: string): void {
856
1142
  const cur = loadCurrent(root);
@@ -864,8 +1150,38 @@ function updateCurrentAgent(root: string, dir: string, agentId: string, status:
864
1150
 
865
1151
  function initSidecars(dir: string, slug: string, sourceRequest: string, summary: string, nextAction: string, timestamp: string, markdown?: string): void {
866
1152
  const criteria = markdown ? definitionAcceptanceLines(markdown).map(parseCriterion) : [];
1153
+ // #289/#309: `markdown` here is NOT always the session `<slug>--deliver.md` that
1154
+ // ensureSession seeds the `branch:` line into — initPlan is called against the tool-planner's
1155
+ // PLAN artifact (`<slug>--plan-work.md` etc.), a different file that typically carries no
1156
+ // `branch:` line at all. Since this function fully rewrites state.json (no merge with the
1157
+ // prior contents), naively re-deriving branch from `markdown` alone would clobber whatever
1158
+ // ensure-session already recorded there moments earlier (#309 regression). Resolve branch with
1159
+ // a three-tier fallback, preferring the most durable source first:
1160
+ // 1. EXISTING state.json.branch — once recorded, it is never re-derived or clobbered.
1161
+ // 2. The session's OWN canonical `<dir>/<slug>--deliver.md` on disk — the file ensureSession
1162
+ // always seeds the `branch:` line into, independent of whatever `markdown` was passed in.
1163
+ // Reading it directly (rather than trusting `markdown`) is what makes a repaired/backfilled
1164
+ // init-plan call (re-run against the same branch-less plan artifact, after a #309-era
1165
+ // session lost its state.json branch) recover the branch without a direct file edit.
1166
+ // 3. The `markdown` param itself (covers ensureSession's fresh-creation call, before
1167
+ // `<slug>--deliver.md` differs from `markdown`, and any direct/legacy caller that passes
1168
+ // the deliver markdown itself as the init-plan artifact).
1169
+ // This makes the branch survive every subsequent initSidecars call (init-plan, or a resumed
1170
+ // ensure-session) at this single choke point, without patching every other writer.
1171
+ const existingState = loadJson(path.join(dir, "state.json"));
1172
+ const existingBranch = existingState.branch;
1173
+ const deliverMdPath = path.join(dir, `${slug}--deliver.md`);
1174
+ const deliverBranch = fs.existsSync(deliverMdPath) ? markdownField(read(deliverMdPath), "branch") : "";
1175
+ const branch = existingBranch || deliverBranch || (markdown ? markdownField(markdown, "branch") : "");
1176
+ // #309 (scope addition): created_at is write-once, same class as the branch-drop bug above —
1177
+ // initSidecars fully rewrites state.json on every call (including a repair/backfill re-run of
1178
+ // init-plan against an already-existing session), so naively re-stamping created_at from the
1179
+ // current call's timestamp silently rewrites a session's original creation time. Preserve the
1180
+ // existing state.json's created_at when present; stamp only on true first-creation.
1181
+ // updated_at still reflects "now" on every call — that field is intentionally mutable.
867
1182
  writeJson(path.join(dir, "state.json"), {
868
- ...sidecarBase(slug), status: "planned", phase: "planning", created_at: timestamp, updated_at: timestamp,
1183
+ ...sidecarBase(slug), status: "planned", phase: "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
1184
+ ...(branch ? { branch } : {}),
869
1185
  artifact_paths: relArtifacts(dir),
870
1186
  next_action: { status: "continue", summary: nextAction || summary },
871
1187
  });
@@ -887,7 +1203,12 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
887
1203
  const timestamp = opt(p, "timestamp", now());
888
1204
  let md = fs.existsSync(path.join(dir, `${slug}--deliver.md`)) ? read(path.join(dir, `${slug}--deliver.md`)) : "";
889
1205
  if (!md) {
890
- md = `# ${opt(p, "title", slug)}\n\nbranch: main\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`;
1206
+ // #289: derive the routing branch ONLY on fresh session creation (this `if (!md)` guard).
1207
+ // An existing session's already-recorded `branch:` line is never touched — that whole branch
1208
+ // of code is skipped on a resumed/taken-over session, which is what makes ADR 0021 §5
1209
+ // takeover continuity true by construction (see Design Decision 3 in the plan).
1210
+ const branch = resolveSessionBranch(p, slug);
1211
+ 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`;
891
1212
  fs.writeFileSync(path.join(dir, `${slug}--deliver.md`), md);
892
1213
  }
893
1214
  if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
@@ -901,13 +1222,26 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
901
1222
  // ensure-session --flow-id builder.build produces a FlowDefinition-driven session even
902
1223
  // before the first advance-state call.
903
1224
  const flowId = opt(p, "flow-id");
904
- let stepId = opt(p, "step-id");
1225
+ const explicitStep = opt(p, "step-id");
1226
+ let stepId = explicitStep;
1227
+ let adHocReason: string | undefined;
905
1228
  if (flowId && !stepId) {
906
1229
  const repoRoot = findRepoRootFromDir(dir);
907
1230
  const firstStep = resolveFirstStep(flowId, repoRoot);
908
1231
  if (firstStep) stepId = firstStep;
1232
+ } else if (flowId && explicitStep) {
1233
+ // WS8 (AC12): --step-id is the sanctioned ad-hoc direct-entry mechanism. When it names
1234
+ // a step other than the flow's resolved first step, record an explicit ad_hoc_entry
1235
+ // marker (with a reason) instead of silently letting the mis-stamp look like the
1236
+ // flow's normal first step. This is the root-cause fix for a planning-only session
1237
+ // whose active_step_id would otherwise default to builder.build's first step.
1238
+ const repoRoot = findRepoRootFromDir(dir);
1239
+ const firstStep = resolveFirstStep(flowId, repoRoot);
1240
+ if (firstStep && firstStep !== explicitStep) {
1241
+ adHocReason = opt(p, "ad-hoc-reason") || `direct entry at step "${explicitStep}" via --step-id (flow first step is "${firstStep}")`;
1242
+ }
909
1243
  }
910
- writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined);
1244
+ writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined, adHocReason);
911
1245
  console.log(dir);
912
1246
  return 0;
913
1247
  }
@@ -928,7 +1262,7 @@ function recordAgentEvent(p: ReturnType<typeof parseArgs>): number {
928
1262
  const dir = explicit ? path.resolve(explicit) : currentDir(root);
929
1263
  if (!dir || !fs.existsSync(dir)) die("artifact directory does not exist");
930
1264
  if (explicit && fs.lstatSync(dir).isSymbolicLink()) die(`artifact directory must not be a symlink: ${dir}`);
931
- if (hasExplicitRoot) requireArtifactDirUnderRoot(dir, root);
1265
+ if (hasExplicitRoot || !explicit) requireArtifactDirUnderRoot(dir, root);
932
1266
  const timestamp = opt(p, "timestamp", now());
933
1267
  const agent = validateAgentId(opt(p, "agent-id"));
934
1268
  const event = { timestamp, agent_id: agent, kind: opt(p, "kind", "note"), status: opt(p, "status", "info"), summary: opt(p, "summary"), ...(opt(p, "ref") ? { ref: opt(p, "ref") } : {}) };
@@ -986,7 +1320,7 @@ export function normalizeEvidenceRefs(raw: unknown, label: string): AnyObj[] {
986
1320
  export function normalizeCheck(raw: AnyObj): AnyObj {
987
1321
  const check = { ...raw };
988
1322
  if (!check.id || !check.kind || !check.status || !check.summary) die("check requires id, kind, status, and summary");
989
- if (!checkKinds.has(check.kind)) die("kind must be one of: build, types, lint, test, security, diff, browser, runtime, policy, external");
1323
+ if (!checkKinds.has(check.kind)) die("kind must be one of: build, types, lint, test, command, security, diff, browser, runtime, policy, external");
990
1324
  if (!checkStatuses.has(check.status)) die("status must be one of: pass, fail, not_verified, skip");
991
1325
  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");
992
1326
  if (check.artifact_refs) check.artifact_refs = normalizeEvidenceRefs(check.artifact_refs, "artifact_refs");
@@ -1121,104 +1455,141 @@ export function writeState(dir: string, slug: string, status: string, phase: str
1121
1455
  // Extract checks and critiques from the existing trust.bundle for callers that
1122
1456
  // need to rebuild the bundle (e.g. record-critique, record-learning).
1123
1457
 
1124
- // ADR 0016 Abstraction A (Step 0 Q3 carry-forward): build the set of declared
1125
- // claimTypes from the active flow step for the session at `dir`. When no active
1126
- // flow is present (workflow.* sessions), returns an empty set so every existing
1127
- // predicate is unchanged. When a FlowDefinition-driven session (builder.build)
1128
- // is active, the set contains the kit-typed claimTypes (e.g. "builder.verify.tests",
1129
- // "builder.verify.policy-compliance") so round-trip helpers broaden their filters
1130
- // to include declared claims alongside the legacy workflow.* ones.
1458
+ // #268/#344: buildTrustBundle stamps a stable origin discriminator ("check" | "acceptance" |
1459
+ // "critique") plus check_kind (for origin "check") on EVERY claim it writes. These stamps are
1460
+ // AUTHORITATIVE and the ONLY way checksFromBundle/critiquesFromBundle (and evidenceClean/
1461
+ // critiqueClean below) classify a claim.
1131
1462
  //
1132
- // Safety guard: current.json in the .flow-agents dir records the CURRENTLY ACTIVE
1133
- // session via artifact_dir. If current.json points to a different session than `dir`
1134
- // (e.g. another session was the last to call advance-state --flow-definition), we
1135
- // return an empty set so declared-type predicates are NOT applied to the wrong session.
1136
- // This prevents a cross-session active_flow_id from broadening claim filters for
1137
- // unrelated sessions (which would cause spurious evidence/critique check behavior).
1138
- function declaredClaimTypesFor(dir: string): Set<string> {
1139
- const flowAgentsDir = path.dirname(dir);
1140
- // Verify that current.json points to `dir` before reading active flow step.
1141
- // If it points to a different session, return empty set (zero behavior change).
1142
- const currentFile = path.join(flowAgentsDir, "current.json");
1143
- try {
1144
- const current = JSON.parse(fs.readFileSync(currentFile, "utf8")) as Record<string, unknown>;
1145
- const artDir = typeof current["artifact_dir"] === "string" ? current["artifact_dir"] : null;
1146
- if (!artDir) return new Set<string>();
1147
- const resolvedCurrent = path.resolve(flowAgentsDir, artDir);
1148
- if (path.resolve(dir) !== resolvedCurrent) return new Set<string>();
1149
- } catch {
1150
- return new Set<string>();
1463
+ // Hard cutover (owner directive, no legacy fallbacks): there is deliberately no claimType-
1464
+ // derivation fallback for unstamped claims. A prior version of this file fell back to
1465
+ // classifying an unstamped claim by claimType heuristic that fallback WAS the #268 defect
1466
+ // kept reachable (a no-evidence declared claim silently re-absorbed as a command-less
1467
+ // test_output check, and a critique claim silently re-absorbed as a check, corrupting the
1468
+ // round-trip catastrophically under --flow-id). An unstamped claim means the bundle predates
1469
+ // #344 and must be regenerated, not silently reclassified — see requireStampedClaim below.
1470
+ function claimOrigin(claim: AnyObj): string | null {
1471
+ const md = claim && (claim as AnyObj).metadata;
1472
+ return md && typeof md === "object" && typeof (md as AnyObj).origin === "string" && (md as AnyObj).origin.length > 0 ? String((md as AnyObj).origin) : null;
1473
+ }
1474
+ // Fails loud — never silent, never a heuristic reclassification — when a claim in `dir`'s
1475
+ // trust.bundle lacks its metadata.origin stamp, or (for an origin==="check" claim) its
1476
+ // metadata.check_kind stamp. Names the session dir and the remedy so the caller can regenerate
1477
+ // a fresh, fully-stamped bundle instead of reading a pre-supersession one.
1478
+ function requireStampedClaim(claim: AnyObj, dir: string): string {
1479
+ if (!claim || typeof claim !== "object") die(`trust.bundle in ${dir} contains a malformed claim entry — cannot read.`);
1480
+ const remedy = `re-record evidence to regenerate: npm run workflow:sidecar -- record-evidence ${dir} --verdict <verdict> --check-json <...>`;
1481
+ const origin = claimOrigin(claim);
1482
+ if (!origin) {
1483
+ die(`pre-supersession trust.bundle in ${dir}: claim '${claim.id ?? "<unknown>"}' has no metadata.origin stamp (this bundle predates #344's origin/check_kind stamping and cannot be read authoritatively) — ${remedy}`);
1484
+ }
1485
+ if (origin === "check") {
1486
+ const md = (claim.metadata && typeof claim.metadata === "object") ? claim.metadata as AnyObj : {};
1487
+ if (typeof md.check_kind !== "string" || md.check_kind.length === 0) {
1488
+ die(`pre-supersession trust.bundle in ${dir}: check claim '${claim.id ?? "<unknown>"}' has metadata.origin but no metadata.check_kind stamp (this bundle predates #344's stamping and cannot be read authoritatively) — ${remedy}`);
1489
+ }
1151
1490
  }
1152
- const activeStep = resolveActiveFlowStep(flowAgentsDir);
1153
- if (!activeStep || activeStep.gateExpects.length === 0) return new Set<string>();
1154
- return new Set<string>(activeStep.gateExpects.map((e) => e.bundle_claim.claimType));
1491
+ return origin;
1155
1492
  }
1156
-
1157
- function checksFromBundle(dir: string, declaredClaimTypes: Set<string> = new Set()): AnyObj[] {
1493
+ function checksFromBundle(dir: string): AnyObj[] {
1158
1494
  const bundle = loadJson(path.join(dir, "trust.bundle"));
1159
- if (!Array.isArray(bundle.evidence)) return [];
1160
1495
  const allClaims: AnyObj[] = Array.isArray(bundle.claims) ? bundle.claims : [];
1496
+ // Validate stamps on every claim up front — any unstamped claim anywhere in the bundle marks
1497
+ // it pre-supersession, regardless of whether it is check/acceptance/critique-typed.
1498
+ for (const claim of allClaims) requireStampedClaim(claim, dir);
1499
+ if (!Array.isArray(bundle.evidence)) return [];
1161
1500
  const claimById = new Map<string, AnyObj>();
1162
1501
  for (const c of allClaims) if (c && c.id) claimById.set(c.id, c);
1163
1502
  const seen = new Set<string>();
1164
1503
  const checks: AnyObj[] = [];
1504
+ const kindOf = (claim: AnyObj): string => String((claim.metadata as AnyObj).check_kind);
1165
1505
  for (const ev of bundle.evidence) {
1166
1506
  if (!ev || !ev.claimId) continue;
1167
1507
  const claim = claimById.get(ev.claimId);
1168
1508
  if (!claim) continue;
1169
- const ct = String(claim.claimType || "");
1170
- // ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
1171
- if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct)) continue;
1509
+ if (claimOrigin(claim) !== "check") continue;
1172
1510
  if (seen.has(ev.claimId)) continue;
1173
1511
  seen.add(ev.claimId);
1174
- const kind = ct.startsWith("workflow.check.") ? (ct.replace("workflow.check.", "") || "external") : (ct.split(".").pop() || "external");
1512
+ const kind = kindOf(claim);
1175
1513
  const status = claim.value ?? "not_verified";
1176
1514
  const check: AnyObj = { id: String(claim.subjectId || "").split("/").pop() || ev.claimId, kind, status, summary: claim.fieldOrBehavior || "" };
1177
1515
  if (ev.execution && typeof ev.execution.label === "string") check.command = ev.execution.label;
1178
1516
  if (ev.evidenceType) check.evidenceType = ev.evidenceType;
1179
1517
  checks.push(check);
1180
1518
  }
1181
- // Also include check claims that have no evidence item (surface_trust_refs style)
1519
+ // Also include check claims that have no evidence item (surface_trust_refs style).
1182
1520
  for (const claim of allClaims) {
1183
1521
  if (!claim) continue;
1184
- const ct = String(claim.claimType || "");
1185
- // ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
1186
- if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct)) continue;
1522
+ if (claimOrigin(claim) !== "check") continue;
1187
1523
  if (seen.has(claim.id)) continue;
1188
1524
  seen.add(claim.id);
1189
- const kind = ct.startsWith("workflow.check.") ? (ct.replace("workflow.check.", "") || "external") : (ct.split(".").pop() || "external");
1525
+ const kind = kindOf(claim);
1190
1526
  checks.push({ id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" });
1191
1527
  }
1192
1528
  return checks;
1193
1529
  }
1194
- function critiquesFromBundle(dir: string, declaredClaimTypes: Set<string> = new Set()): AnyObj[] {
1530
+ function critiquesFromBundle(dir: string): AnyObj[] {
1195
1531
  const bundle = loadJson(path.join(dir, "trust.bundle"));
1196
1532
  if (!Array.isArray(bundle.claims)) return [];
1197
- // ADR 0016 Step 0: broaden to include declared kit-typed critique claims alongside workflow.critique.review.
1198
- // P-d: exclude claims that have evidence items (evidence = check claims, not critique claims).
1199
- // This prevents check-type declared claims (e.g. builder.verify.tests) from being read back
1200
- // as critiques when declaredClaimTypes includes all gate expects[] types.
1201
- const evidenceClaimIds = new Set<string>(
1202
- Array.isArray(bundle.evidence) ? bundle.evidence.map((e: AnyObj) => e?.claimId).filter((id: unknown): id is string => typeof id === "string") : []
1203
- );
1204
- const critiqueClaims = bundle.claims.filter((c: AnyObj) => c && (c.claimType === "workflow.critique.review" || declaredClaimTypes.has(c.claimType)) && !evidenceClaimIds.has(c.id));
1205
- return critiqueClaims.map((c: AnyObj) => ({
1206
- id: String(c.subjectId || "").split("/").pop() || c.id,
1207
- verdict: c.value ?? "not_verified",
1208
- summary: c.fieldOrBehavior || "",
1209
- findings: [],
1210
- reviewer: "tool-code-reviewer",
1211
- reviewed_at: c.updatedAt || c.createdAt || now(),
1212
- artifact_refs: [],
1213
- }));
1533
+ for (const c of bundle.claims) requireStampedClaim(c, dir);
1534
+ // A claim is a CRITIQUE when its origin is "critique" (authoritative see requireStampedClaim
1535
+ // above). reviewer / reviewed_at / superseded_by are read back from metadata so supersession
1536
+ // (#267/#282) round-trips losslessly.
1537
+ const critiqueClaims = bundle.claims.filter((c: AnyObj) => c && claimOrigin(c) === "critique");
1538
+ return critiqueClaims.map((c: AnyObj) => {
1539
+ const md = (c.metadata && typeof c.metadata === "object") ? c.metadata as AnyObj : {};
1540
+ return {
1541
+ id: String(c.subjectId || "").split("/").pop() || c.id,
1542
+ verdict: c.value ?? "not_verified",
1543
+ summary: c.fieldOrBehavior || "",
1544
+ findings: [],
1545
+ reviewer: typeof md.reviewer === "string" ? md.reviewer : "tool-code-reviewer",
1546
+ reviewed_at: typeof md.reviewed_at === "string" ? md.reviewed_at : (c.updatedAt || c.createdAt || now()),
1547
+ artifact_refs: [],
1548
+ ...(typeof md.superseded_by === "string" && md.superseded_by.length > 0 ? { superseded_by: md.superseded_by } : {}),
1549
+ };
1550
+ });
1214
1551
  }
1215
1552
  // ─────────────────────────────────────────────────────────────────────────────
1553
+ /**
1554
+ * WS8 (ADR 0020): parse the accepted-gap waiver flags. Both --accepted-gap-reason and
1555
+ * --waived-by are required together (an accepted gap with no justification or no
1556
+ * approver is refused — no silent/default waiver). Returns the waiver record to stamp
1557
+ * onto claim.metadata.waiver, or null when neither flag is present. Reuses the existing
1558
+ * accepted_gap -> assumed status mapping; adds no new canonical status value.
1559
+ */
1560
+ function parseWaiver(p: ReturnType<typeof parseArgs>, ts: string): AnyObj | null {
1561
+ const reason = opt(p, "accepted-gap-reason");
1562
+ const waivedBy = opt(p, "waived-by");
1563
+ if (!reason && !waivedBy) return null;
1564
+ if (!reason) die("--accepted-gap-reason is required when --waived-by is set (an accepted gap must carry its justification)");
1565
+ if (!waivedBy) die("--waived-by is required when --accepted-gap-reason is set (an accepted gap must name its approver)");
1566
+ return { reason, approved_by: waivedBy, approved_at: ts };
1567
+ }
1568
+
1216
1569
  async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number> {
1217
1570
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1218
1571
  const verdict = opt(p, "verdict") || die("--verdict is required");
1219
1572
  if (!verdicts.has(verdict)) die("verdict must be one of: pass, partial, fail, not_verified");
1220
1573
  const slug = taskSlugFor(dir, opt(p, "task-slug"));
1221
- const checks = [...opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"))), ...opts(p, "surface-trust-json").map(surfaceCheckFromArtifact)];
1574
+ const _ts0 = opt(p, "timestamp", now());
1575
+ const _waiver = parseWaiver(p, _ts0);
1576
+ const _checksRaw = [...opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"))), ...opts(p, "surface-trust-json").map(surfaceCheckFromArtifact)];
1577
+ // WS8 (AC4, iteration 2): a command-backed check reconciles against CI or fails — it can
1578
+ // NEVER be waived. Reject --accepted-gap-reason/--waived-by on any check whose evidence
1579
+ // classifies as test_output (build/types/lint/test/command, and security/browser/runtime
1580
+ // WHEN a command is present). Only session-local checks (attestation/observation/citation/
1581
+ // diff/policy) are waivable. The CI reconciler enforces the same rule server-side (a waiver
1582
+ // on test_output evidence is a divergence), so this producer-side guard is defense-in-depth,
1583
+ // not the sole gate. (ADR 0020)
1584
+ if (_waiver) {
1585
+ for (const c of _checksRaw) {
1586
+ const hasCmd = typeof (c as AnyObj).command === "string" && String((c as AnyObj).command).trim().length > 0;
1587
+ if (classifyEvidence((c as AnyObj).kind as string | undefined, hasCmd).evidenceType === "test_output") {
1588
+ die(`--accepted-gap-reason/--waived-by cannot be applied to a command-backed check (kind='${String((c as AnyObj).kind)}'${hasCmd ? " with a command" : ""}): a command-backed check reconciles against CI or fails and cannot be waived. Waive only session-local checks (attestation/observation/citation). (ADR 0020)`);
1589
+ }
1590
+ }
1591
+ }
1592
+ const checks = _checksRaw.map((c) => _waiver ? { ...c, _waiver } : c);
1222
1593
  if (!checks.length && opts(p, "surface-trust-json").length === 0) die("record-evidence requires at least one --check-json or --surface-trust-json");
1223
1594
  validateAcceptanceEvidenceRefs(dir);
1224
1595
  // Phase 4c: bundle is the sole verification artifact — stop writing evidence.json and acceptance.json update.
@@ -1227,7 +1598,11 @@ async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number>
1227
1598
  const _existingCriteria: AnyObj[] = Array.isArray(_existingAcceptance.criteria) ? _existingAcceptance.criteria : [];
1228
1599
  const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
1229
1600
  const _criteriaForBundle: AnyObj[] = _existingCriteria.map((c: AnyObj) => ({ ...c, status: _criteriaStatus }));
1230
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, []));
1601
+ // #268: preserve any existing critique claims (including superseded history) instead of dropping
1602
+ // them — record-evidence previously hardcoded critiques:[] here, silently erasing finding history
1603
+ // whenever it ran after a critique existed.
1604
+ const _existingCritiques: AnyObj[] = critiquesFromBundle(dir);
1605
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, _existingCritiques));
1231
1606
  const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
1232
1607
  writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
1233
1608
  return 0;
@@ -1319,12 +1694,105 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
1319
1694
  }
1320
1695
 
1321
1696
  const checkNormalized = normalizeCheck(check);
1697
+ // WS8 (ADR 0020): honor the accepted-gap waiver flags for a gate claim too.
1698
+ const gateWaiver = parseWaiver(p, ts);
1699
+ if (gateWaiver) checkNormalized._waiver = gateWaiver;
1322
1700
  // Log the targeted gate expectation for transparency (goes to stderr only)
1323
- process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}\n`);
1701
+ process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}${gateWaiver ? " (WAIVED accepted_gap)" : ""}\n`);
1324
1702
  assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], []));
1325
1703
  return 0;
1326
1704
  }
1327
1705
 
1706
+ /**
1707
+ * promote — the promote-then-archive gate (issue #312). Durable-residue extraction is
1708
+ * the archival act: this records WHAT durable residue was promoted WHERE and writes a
1709
+ * PROMOTION CLAIM into the session trust.bundle.
1710
+ *
1711
+ * Claim shape (reconcile-safe by construction): the promotion check is kind="policy",
1712
+ * so classifyEvidence maps it to evidenceType "policy_rule" (session-local, method
1713
+ * "auditability"). It carries NO command / execution.label, so it can NEVER require a
1714
+ * reconcile-manifest entry and can NEVER become a [not-run] / unbacked-command
1715
+ * divergence at CI trust-reconcile. Its status derives to `verified` from that
1716
+ * session-local policy_rule evidence item, so the reconciler classifies it session-local
1717
+ * and accepts it as an ATTESTED claim (exit 0) — never as a test_output claim that must
1718
+ * match the manifest. The _promotion marker rides onto claim.metadata.promotion (see
1719
+ * buildTrustBundle) so the archive gate and validators can detect it without new manifest
1720
+ * entries (R1).
1721
+ *
1722
+ * Modes:
1723
+ * promote <dir> --evidence-path <p> [--evidence-path <p> ...]
1724
+ * Records the durable doc paths written (docs/decisions/<slug>.md, CONTEXT.md,
1725
+ * docs/learnings/*, …). Each path MUST exist on disk at record time — a missing
1726
+ * path fails loud (no silent empty promotion).
1727
+ * promote <dir> --none --reason "<why nothing durable>"
1728
+ * An explicit, auditable no-residue promotion (R3).
1729
+ */
1730
+ async function promote(p: ReturnType<typeof parseArgs>): Promise<number> {
1731
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1732
+ const slug = taskSlugFor(dir, opt(p, "task-slug"));
1733
+ const ts = opt(p, "timestamp", now());
1734
+ const none = p.flags.has("none");
1735
+ const reason = opt(p, "reason");
1736
+ const repoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDir(dir);
1737
+ const rawPaths = opts(p, "evidence-path");
1738
+
1739
+ if (none) {
1740
+ if (rawPaths.length) die("promote --none records a no-residue claim; do not also pass --evidence-path");
1741
+ if (!reason.trim()) die("promote --none requires --reason \"<why nothing durable was promoted>\"");
1742
+ } else {
1743
+ if (!rawPaths.length) die("promote requires at least one --evidence-path <durable-doc-path> (or --none --reason \"<why>\" for an explicit no-residue promotion)");
1744
+ }
1745
+
1746
+ // Every evidence ref MUST exist on disk at record time (fail loud otherwise). Store
1747
+ // repo-relative paths when the ref lives under the repo, so the claim is portable.
1748
+ const targets: string[] = [];
1749
+ for (const raw of rawPaths) {
1750
+ const abs = path.isAbsolute(raw) ? raw : path.resolve(repoRoot, raw);
1751
+ if (!fs.existsSync(abs)) die(`promote --evidence-path does not exist on disk: ${raw} (resolved: ${abs}). Promotion evidence refs must point at durable docs that were actually written.`);
1752
+ const rel = path.relative(repoRoot, abs);
1753
+ targets.push(rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : raw);
1754
+ }
1755
+
1756
+ const promotionMarker: AnyObj = {
1757
+ schema_version: "1.0",
1758
+ none,
1759
+ ...(none ? { reason } : {}),
1760
+ targets,
1761
+ promoted_at: ts,
1762
+ };
1763
+
1764
+ const summary = opt(p, "summary") || (none
1765
+ ? `Promotion (no durable residue): ${reason}`
1766
+ : `Promoted durable residue: ${targets.join(", ")}`);
1767
+
1768
+ // Session-local promotion check: kind="policy" -> policy_rule evidence, no command.
1769
+ const promotionCheck: AnyObj = { id: "promotion", kind: "policy", status: "pass", summary, _promotion: promotionMarker };
1770
+
1771
+ // Add the promotion claim WITHOUT dropping the session's existing verification
1772
+ // evidence/criteria/critiques (mirror record-critique's merge pattern). Drop any prior
1773
+ // "promotion" check so re-running promote is idempotent rather than duplicating.
1774
+ const existingChecks = checksFromBundle(dir).filter((c) => c.id !== "promotion");
1775
+ const _acc = loadJson(path.join(dir, "acceptance.json"));
1776
+ const criteria: AnyObj[] = Array.isArray(_acc.criteria) ? _acc.criteria : [];
1777
+ const critiques = critiquesFromBundle(dir);
1778
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, [...existingChecks, promotionCheck], criteria, critiques));
1779
+
1780
+ // Auditable record of what was promoted where (companion to the trust.bundle claim).
1781
+ writeJson(path.join(dir, "promotion.json"), { ...sidecarBase(slug), ...promotionMarker, summary });
1782
+
1783
+ // Optionally republish so delivery/trust.bundle carries the promotion claim for CI.
1784
+ if (p.flags.has("publish")) {
1785
+ const publishRepoRoot = opt(p, "publish-repo-root") ? path.resolve(opt(p, "publish-repo-root")) : findRepoRootFromDirStrict(dir);
1786
+ await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
1787
+ process.stderr.write(`[promote] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
1788
+ });
1789
+ }
1790
+
1791
+ process.stderr.write(`[promote] recorded ${none ? "no-residue" : targets.length + " durable ref(s)"} promotion claim for ${slug}\n`);
1792
+ printJson({ ok: true, slug, none, targets, promotion_claim: "trust.bundle" });
1793
+ return 0;
1794
+ }
1795
+
1328
1796
  async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
1329
1797
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1330
1798
  const status = opt(p, "status");
@@ -1375,7 +1843,14 @@ async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
1375
1843
  if (status === "delivered") {
1376
1844
  await sealTrustCheckpoint(dir, slug, timestamp, status, "release").catch(() => { /* best-effort; checkpoint seal must not break advance-state */ });
1377
1845
  // Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
1378
- await publishDelivery(dir, findRepoRootFromDir(dir)).catch(() => { /* best-effort; must not break advance-state */ });
1846
+ // Fail-closed repo-root resolution (findRepoRootFromDirStrict, no cwd fallback) see
1847
+ // publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir
1848
+ // with no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures
1849
+ // are visible (stderr warning), not silently swallowed.
1850
+ const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
1851
+ await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
1852
+ process.stderr.write(`[advance-state] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
1853
+ });
1379
1854
  }
1380
1855
  return 0;
1381
1856
  }
@@ -1392,13 +1867,29 @@ async function recordCritique(p: ReturnType<typeof parseArgs>): Promise<number>
1392
1867
  // Fall back to critique.json for legacy sessions that still have it on disk.
1393
1868
  const existingCritiqueJson = loadJson(path.join(dir, "critique.json"), { critiques: [] });
1394
1869
  const legacyCritiques: AnyObj[] = Array.isArray(existingCritiqueJson.critiques) ? existingCritiqueJson.critiques : [];
1395
- const _dctCritique = declaredClaimTypesFor(dir);
1396
- const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir, _dctCritique) : legacyCritiques;
1870
+ const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir) : legacyCritiques;
1397
1871
  const critique = { id: opt(p, "id") || "review", reviewer: opt(p, "reviewer", "tool-code-reviewer"), reviewed_at: opt(p, "timestamp", now()), verdict: opt(p, "verdict", "pass"), summary: opt(p, "summary"), artifact_refs: opts(p, "artifact-ref"), findings: opts(p, "finding-json").map((v) => normalizeFinding(parseJson(v, "--finding-json"))) };
1398
- const critiques = [...bundleCritiques, critique];
1399
1872
  if (critique.verdict === "pass" && critique.findings.some((f: AnyObj) => f.status === "open")) die("required critique must pass");
1873
+ // #267/#282: supersede-by-critique-id. The latest write for a critique id wins for
1874
+ // reconcile / status / validator purposes; each prior LIVE write for the same id is RETAINED as
1875
+ // history (status "superseded", first-class metadata.superseded_by) but excluded from evaluation.
1876
+ // Supersession is REVIEWER-SCOPED (anti-gaming): a write may only supersede a prior live critique
1877
+ // of the same id written by the SAME reviewer — a different reviewer's disputed finding is never
1878
+ // buried, so it stays live and continues to block. DOCUMENTED GAP: reviewer identity is the
1879
+ // free-form --reviewer string (with a default); there is no cryptographic worker-vs-reviewer
1880
+ // distinction yet — that lands with the runtime actor-identity slice (#287/#290). Same-reviewer-
1881
+ // string scoping is the strongest honest enforcement available today and matches the granularity
1882
+ // the critique record already has.
1883
+ const _supersedeMarker = `${critique.id}@${critique.reviewed_at}`;
1884
+ const _mergedCritiques = bundleCritiques.map((e: AnyObj) => {
1885
+ const eSuperseded = typeof e.superseded_by === "string" && e.superseded_by.length > 0;
1886
+ const eReviewer = String(e.reviewer ?? "tool-code-reviewer");
1887
+ if (e.id === critique.id && !eSuperseded && eReviewer === critique.reviewer) return { ...e, superseded_by: _supersedeMarker };
1888
+ return e;
1889
+ });
1890
+ const critiques = [..._mergedCritiques, critique];
1400
1891
  // Phase 4c: build bundle from raw inputs; read checks from trust.bundle (evidence.json no longer written).
1401
- const _critiqueEvChecks: AnyObj[] = checksFromBundle(dir, _dctCritique);
1892
+ const _critiqueEvChecks: AnyObj[] = checksFromBundle(dir);
1402
1893
  const _critiqueAccCriteria: AnyObj[] = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
1403
1894
  assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueEvChecks, _critiqueAccCriteria, critiques));
1404
1895
  return 0;
@@ -1442,7 +1933,14 @@ async function recordRelease(p: ReturnType<typeof parseArgs>): Promise<number> {
1442
1933
  // Trust checkpoint: seal at the "delivered" moment (the natural terminal mark for record-release).
1443
1934
  await sealTrustCheckpoint(dir, slug, payload.updated_at, "delivered", "release").catch(() => { /* best-effort; checkpoint seal must not break record-release */ });
1444
1935
  // Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
1445
- await publishDelivery(dir, findRepoRootFromDir(dir)).catch(() => { /* best-effort; must not break record-release */ });
1936
+ // Fail-closed repo-root resolution (findRepoRootFromDirStrict, no cwd fallback) see
1937
+ // publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir with
1938
+ // no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures are
1939
+ // visible (stderr warning), not silently swallowed.
1940
+ const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
1941
+ await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
1942
+ process.stderr.write(`[record-release] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
1943
+ });
1446
1944
  return 0;
1447
1945
  }
1448
1946
 
@@ -1657,7 +2155,7 @@ async function sealCheckpoint(p: ReturnType<typeof parseArgs>): Promise<number>
1657
2155
 
1658
2156
  // ─── Publish Delivery Bundle ──────────────────────────────────────────────────
1659
2157
  // Copies the session's trust.bundle (+ checkpoint companions) from the gitignored
1660
- // session artifact dir (.flow-agents/<slug>/) to the committed delivery/ transport
2158
+ // session artifact dir (.kontourai/flow-agents/<slug>/) to the committed delivery/ transport
1661
2159
  // path so the CI trust-reconcile job can reconcile it against fresh CI results.
1662
2160
  //
1663
2161
  // Fail-soft: if trust.bundle is absent (no evidence recorded yet), does nothing.
@@ -1672,13 +2170,25 @@ async function sealCheckpoint(p: ReturnType<typeof parseArgs>): Promise<number>
1672
2170
  * trust.checkpoint.intoto.json / trust.checkpoint.sig.json from the
1673
2171
  * session artifact dir to <repoRoot>/delivery/.
1674
2172
  *
1675
- * Fail-soft: if trust.bundle is absent, returns without throwing.
2173
+ * Fail-soft on a missing bundle: if trust.bundle is absent, returns without throwing.
2174
+ * Fail-CLOSED on repo-root resolution: repoRoot must be a real, resolved kits/ ancestor
2175
+ * (see findRepoRootFromDirStrict) — null (no ancestor found) skips the publish with a
2176
+ * visible warning instead of writing to whatever process.cwd() happens to be. This
2177
+ * prevents a scratch/test session dir (no kits/ ancestor) from silently clobbering an
2178
+ * unrelated real repo's delivery/trust.bundle when invoked with that repo as cwd (see
2179
+ * evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings at
2180
+ * .kontourai/flow-agents/ws5-governance-kit-slice1 for the root cause this fixes).
1676
2181
  * Idempotent: overwrites on re-delivery.
1677
2182
  */
1678
- export async function publishDelivery(dir: string, repoRoot: string): Promise<void> {
2183
+ export async function publishDelivery(dir: string, repoRoot: string | null): Promise<void> {
1679
2184
  const bundleSrc = path.join(dir, "trust.bundle");
1680
2185
  if (!fs.existsSync(bundleSrc)) return; // no bundle — skip gracefully
1681
2186
 
2187
+ if (!repoRoot) {
2188
+ process.stderr.write(`[publish-delivery] WARNING: no kits/ ancestor found from ${dir}; skipping publish. Refusing to fall back to process.cwd() to avoid clobbering an unrelated repo's delivery/trust.bundle. Pass --repo-root explicitly if this session dir is intentionally outside a repo checkout.\n`);
2189
+ return;
2190
+ }
2191
+
1682
2192
  const deliveryDir = path.join(repoRoot, "delivery");
1683
2193
  fs.mkdirSync(deliveryDir, { recursive: true });
1684
2194
 
@@ -1713,7 +2223,10 @@ export async function publishDelivery(dir: string, repoRoot: string): Promise<vo
1713
2223
  */
1714
2224
  async function publishDeliveryCmd(p: ReturnType<typeof parseArgs>): Promise<number> {
1715
2225
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1716
- const repoRoot = opt(p, "repo-root") || findRepoRootFromDir(dir);
2226
+ // Fail-closed: an explicit --repo-root always wins; otherwise resolve strictly (no
2227
+ // process.cwd() fallback) so a scratch/test artifact dir cannot accidentally publish
2228
+ // into whichever repo happens to be the current working directory.
2229
+ const repoRoot = opt(p, "repo-root") || findRepoRootFromDirStrict(dir);
1717
2230
  await publishDelivery(dir, repoRoot);
1718
2231
  return 0;
1719
2232
  }
@@ -1767,45 +2280,49 @@ async function recordLearning(p: ReturnType<typeof parseArgs>): Promise<number>
1767
2280
  writeJson(path.join(dir, "learning.json"), { ...sidecarBase(slug), status, updated_at: timestamp, records });
1768
2281
  writeState(dir, slug, "accepted", "learning", timestamp, opt(p, "summary"));
1769
2282
  // Phase 4c: build bundle from raw inputs; read checks/critiques from trust.bundle (bespoke sidecars no longer written).
1770
- // ADR 0016 Step 0: pass declaredClaimTypes so declared builder.* claims survive the round-trip.
1771
- const _dctLearning = declaredClaimTypesFor(dir);
1772
- const _learningChecks: AnyObj[] = checksFromBundle(dir, _dctLearning);
2283
+ // #268/#344: declared builder.* claims survive the round-trip via their authoritative origin stamp.
2284
+ const _learningChecks: AnyObj[] = checksFromBundle(dir);
1773
2285
  const _learningCriteria: AnyObj[] = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
1774
- const _learningCritiques: AnyObj[] = critiquesFromBundle(dir, _dctLearning);
2286
+ const _learningCritiques: AnyObj[] = critiquesFromBundle(dir);
1775
2287
  assertBundleWritten(await writeTrustBundle(dir, slug, timestamp, _learningChecks, _learningCriteria, _learningCritiques));
1776
2288
  return 0;
1777
2289
  }
1778
- function evidenceClean(dir: string, declaredClaimTypes: Set<string> = new Set()): boolean {
1779
- // Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for legacy sessions.
1780
- // ADR 0016 Step 0: declaredClaimTypes broadens the filter to include kit-typed check claims
1781
- // (e.g. builder.verify.tests) in addition to workflow.check.* for FlowDefinition-driven sessions.
2290
+ function evidenceClean(dir: string): boolean {
2291
+ // Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for
2292
+ // legacy (pre-bundle-era) sessions that never wrote a trust.bundle at all unrelated to origin
2293
+ // stamping. When a trust.bundle IS present, every claim must be stamped (requireStampedClaim);
2294
+ // there is no claimType-derivation fallback for an unstamped claim (#268/#344).
1782
2295
  const bundle = loadJson(path.join(dir, "trust.bundle"));
1783
2296
  if (Array.isArray(bundle.claims)) {
1784
- const checkClaims = (bundle.claims as AnyObj[]).filter((c: AnyObj) => {
1785
- if (!c) return false;
1786
- const ct = String(c.claimType || "");
1787
- return ct.startsWith("workflow.check.") || declaredClaimTypes.has(ct);
1788
- });
2297
+ for (const c of bundle.claims) requireStampedClaim(c, dir);
2298
+ const checkClaims = (bundle.claims as AnyObj[]).filter((c: AnyObj) => c && claimOrigin(c) === "check");
1789
2299
  if (checkClaims.length === 0) return false;
1790
2300
  return checkClaims.every((c: AnyObj) => {
1791
2301
  const v = String(c.value || "");
1792
2302
  return v === "pass" || v === "skip";
1793
2303
  });
1794
2304
  }
1795
- // Legacy fallback: evidence.json
2305
+ // Legacy fallback: evidence.json (pre-bundle-era sessions with no trust.bundle at all)
1796
2306
  const e = loadJson(path.join(dir, "evidence.json"), {});
1797
2307
  return e.verdict === "pass" && Array.isArray(e.checks) && e.checks.length > 0 && e.checks.every((c: AnyObj) => {
1798
2308
  if (!(c.status === "pass" || c.status === "skip")) return false;
1799
2309
  return !Array.isArray(c.standard_refs) || c.standard_refs.every((r: AnyObj) => ["junit", "sarif", "coverage", "veritas"].includes(r.standard));
1800
2310
  });
1801
2311
  }
1802
- function critiqueClean(dir: string, declaredClaimTypes: Set<string> = new Set()): boolean {
1803
- // Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for legacy sessions.
1804
- // ADR 0016 Step 0: declaredClaimTypes broadens the filter to include kit-typed critique claims
1805
- // (e.g. builder.verify.policy-compliance) in addition to workflow.critique.review.
2312
+ function critiqueClean(dir: string): boolean {
2313
+ // Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for
2314
+ // legacy (pre-bundle-era) sessions unrelated to origin stamping. When a trust.bundle IS
2315
+ // present, every claim must be stamped (requireStampedClaim); no claimType-derivation fallback
2316
+ // for an unstamped claim (#268/#344).
1806
2317
  const bundle = loadJson(path.join(dir, "trust.bundle"));
1807
2318
  if (Array.isArray(bundle.claims)) {
1808
- const critiqueClaims = (bundle.claims as AnyObj[]).filter((c: AnyObj) => c && (c.claimType === "workflow.critique.review" || declaredClaimTypes.has(c.claimType)));
2319
+ for (const c of bundle.claims) requireStampedClaim(c, dir);
2320
+ const critiqueClaims = (bundle.claims as AnyObj[]).filter((c: AnyObj) => {
2321
+ if (!c) return false;
2322
+ // #267/#282: superseded history is not evaluated for cleanliness.
2323
+ if (c.metadata && typeof c.metadata === "object" && (c.metadata as AnyObj).superseded_by) return false;
2324
+ return claimOrigin(c) === "critique";
2325
+ });
1809
2326
  if (critiqueClaims.length === 0) return false; // no critique written yet
1810
2327
  return critiqueClaims.every((c: AnyObj) => {
1811
2328
  const v = String(c.value || "");
@@ -1839,10 +2356,9 @@ async function dogfoodPass(p: ReturnType<typeof parseArgs>): Promise<number> {
1839
2356
  const checks = opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json")));
1840
2357
  if (checks.some((c) => c.status !== "pass" && c.status !== "skip")) die("clean evidence requires all non-skipped checks to pass");
1841
2358
  // Phase 4c: evidence check reads from trust.bundle (sole verification artifact); legacy evidence.json fallback in evidenceClean.
1842
- // ADR 0016 Step 0: pass declaredClaimTypes so builder.* check/critique claims count as clean evidence.
1843
- const _dctDogfood = declaredClaimTypesFor(dir);
1844
- const _hasBundleEvidence = fs.existsSync(path.join(dir, "trust.bundle")) && evidenceClean(dir, _dctDogfood);
1845
- const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir, _dctDogfood);
2359
+ // #268/#344: builder.* check/critique claims count as clean evidence via their authoritative origin stamp.
2360
+ const _hasBundleEvidence = fs.existsSync(path.join(dir, "trust.bundle")) && evidenceClean(dir);
2361
+ const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir);
1846
2362
  if (!_hasBundleEvidence && !_hasLegacyEvidence && fs.existsSync(path.join(dir, "trust.bundle"))) die("cannot mark clean without passing evidence");
1847
2363
  if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && fs.existsSync(path.join(dir, "evidence.json"))) die("cannot mark clean without passing evidence");
1848
2364
  if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && !fs.existsSync(path.join(dir, "evidence.json")) && checks.length === 0) die("cannot mark clean without passing evidence");
@@ -1850,9 +2366,9 @@ async function dogfoodPass(p: ReturnType<typeof parseArgs>): Promise<number> {
1850
2366
  const newCritiqueVerdict = opt(p, "critique-verdict", "pass");
1851
2367
  for (const value of opts(p, "finding-json")) normalizeFinding(parseJson(value, "--finding-json"));
1852
2368
  if (newCritiqueVerdict !== "pass") die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
1853
- if (!opt(p, "critique-id") && !critiqueClean(dir, _dctDogfood)) die("requires passing critique");
2369
+ if (!opt(p, "critique-id") && !critiqueClean(dir)) die("requires passing critique");
1854
2370
  // Phase 4c: if existing state has a dirty critique (in bundle or legacy critique.json), block even when adding a new critique-id.
1855
- if (!critiqueClean(dir, _dctDogfood) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json")))) die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
2371
+ if (!critiqueClean(dir) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json")))) die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
1856
2372
  }
1857
2373
  }
1858
2374
  const learningRecords = opts(p, "learning-record-json").map((v) => normalizeLearning(parseJson(v, "--learning-record-json"), opt(p, "timestamp", now())));
@@ -1890,7 +2406,7 @@ export interface TrustClaim {
1890
2406
  id: string;
1891
2407
  subjectType: string;
1892
2408
  subjectId: string;
1893
- surface: string;
2409
+ facet?: string;
1894
2410
  claimType: string;
1895
2411
  fieldOrBehavior: string;
1896
2412
  value: string;
@@ -1909,7 +2425,7 @@ export interface BundleFile {
1909
2425
  policies: AnyObj[];
1910
2426
  }
1911
2427
 
1912
- /** The gate block signal read from .flow-agents/.goal-fit-block-streak.json */
2428
+ /** The gate block signal read from .kontourai/flow-agents/.goal-fit-block-streak.json */
1913
2429
  export interface GateBlockSignal {
1914
2430
  /** True when the streak file exists AND count >= 1 */
1915
2431
  blocked: boolean;
@@ -1926,13 +2442,13 @@ export interface GateBlockSignal {
1926
2442
  export type GateCalibration = "correct" | "false_block" | "missed_block";
1927
2443
 
1928
2444
  /**
1929
- * Read the gate block signal from .flow-agents/.goal-fit-block-streak.json
2445
+ * Read the gate block signal from .kontourai/flow-agents/.goal-fit-block-streak.json
1930
2446
  * (written by scripts/hooks/stop-goal-fit.js when block mode fires).
1931
2447
  * The file sits at <artifact-root>/.goal-fit-block-streak.json — one level
1932
2448
  * above the session artifact dir. Fail-open: returns { blocked: false } when
1933
2449
  * the file is absent or unreadable.
1934
2450
  *
1935
- * @param artifactRoot The .flow-agents root dir (parent of session slug dir).
2451
+ * @param artifactRoot The runtime artifact root dir (parent of session slug dir).
1936
2452
  */
1937
2453
  export function readGateBlockSignal(artifactRoot: string): GateBlockSignal {
1938
2454
  const streakFile = path.join(artifactRoot, ".goal-fit-block-streak.json");
@@ -2159,7 +2675,7 @@ export function buildGateInquiryRecords(
2159
2675
  *
2160
2676
  * The block signal is read from <artifact-root>/.goal-fit-block-streak.json,
2161
2677
  * written by scripts/hooks/stop-goal-fit.js when block mode fires. The file
2162
- * lives one level above the session slug dir (the .flow-agents root).
2678
+ * lives one level above the session slug dir (the runtime artifact root).
2163
2679
  *
2164
2680
  * If @kontourai/surface is unavailable, logs a warning and returns 0
2165
2681
  * (fail-open — no bespoke fork fallback).
@@ -2185,7 +2701,7 @@ async function gateReview(p: ReturnType<typeof parseArgs>): Promise<number> {
2185
2701
 
2186
2702
  const bundle: BundleFile = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
2187
2703
 
2188
- // Read gate block signal from .flow-agents root (one level above session dir)
2704
+ // Read gate block signal from the runtime artifact root (one level above session dir)
2189
2705
  const artifactRoot = path.dirname(dir);
2190
2706
  const blockSignal = readGateBlockSignal(artifactRoot);
2191
2707
 
@@ -2342,7 +2858,7 @@ function trustMcp(p: ReturnType<typeof parseArgs>): number {
2342
2858
  if (mode === "print") {
2343
2859
  console.log(JSON.stringify({ mcpServers: { [TRUST_MCP_SERVER]: trustMcpRegistration() } }, null, 2));
2344
2860
  process.stderr.write(`\n# Paste the above into your runtime MCP config (e.g. .mcp.json). Flow Agents does NOT write it for you unless you run: trust-mcp --mode enable\n`);
2345
- process.stderr.write(`# To view a task's trust inline, call surface_summary with path=<.flow-agents/<slug>/trust.bundle>.\n`);
2861
+ process.stderr.write(`# To view a task's trust inline, call surface_summary with path=<.kontourai/flow-agents/<slug>/trust.bundle>.\n`);
2346
2862
  return 0;
2347
2863
  }
2348
2864
  if (mode !== "enable" && mode !== "disable") die("trust-mcp --mode must be print|enable|disable");
@@ -2386,12 +2902,25 @@ const LIVENESS_POLICY = {
2386
2902
  impactLevel: "medium",
2387
2903
  };
2388
2904
 
2389
- function livenessStreamFile(root: string): string { return path.join(root, "liveness", "events.jsonl"); }
2390
- function appendLivenessEvent(root: string, evt: AnyObj): void {
2391
- const file = livenessStreamFile(root);
2392
- fs.mkdirSync(path.dirname(file), { recursive: true });
2393
- fs.appendFileSync(file, `${JSON.stringify(evt)}\n`);
2905
+ /**
2906
+ * Delegate to the shared pure-CJS writer (scripts/hooks/lib/liveness-write.js), mirroring the
2907
+ * createRequire pattern used by loadActorIdentityHelper() below. Deliberately NO inline duplicate
2908
+ * fallback — the whole point of #288 Wave 2 Task 2.1 is one writer shared by the CLI and the
2909
+ * hook wrappers' tool-activity heartbeat, not two copies of the append shape that can drift.
2910
+ */
2911
+ function loadLivenessWriteHelper(): {
2912
+ livenessStreamFile: (root: string) => string;
2913
+ appendLivenessEvent: (root: string, evt: AnyObj) => void;
2914
+ } {
2915
+ const _req = createRequire(import.meta.url);
2916
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-write.js");
2917
+ return _req(helperPath) as {
2918
+ livenessStreamFile: (root: string) => string;
2919
+ appendLivenessEvent: (root: string, evt: AnyObj) => void;
2920
+ };
2394
2921
  }
2922
+ function livenessStreamFile(root: string): string { return loadLivenessWriteHelper().livenessStreamFile(root); }
2923
+ function appendLivenessEvent(root: string, evt: AnyObj): void { loadLivenessWriteHelper().appendLivenessEvent(root, evt); }
2395
2924
  function readLivenessEvents(root: string): AnyObj[] {
2396
2925
  // Delegate to the shared pure-CJS helper (scripts/hooks/lib/liveness-read.js).
2397
2926
  // Using createRequire so the ESM sidecar can load a CJS module without bundling it.
@@ -2414,36 +2943,168 @@ function livenessLabel(status: string): string {
2414
2943
  return status;
2415
2944
  }
2416
2945
 
2417
- // ─── ADR 0012 lifecycle-driven liveness (opt-in via FLOW_AGENTS_LIVENESS) ──────
2946
+ // ─── ADR 0012 lifecycle-driven liveness (default-on; opt-out via FLOW_AGENTS_LIVENESS) ──
2418
2947
  // init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
2419
2948
  // so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
2420
2949
  // Additive + fail-open: a liveness-emit failure never affects the workflow command.
2421
2950
  const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
2422
- function resolveLivenessActor(): string { return (process.env.FLOW_AGENTS_ACTOR || "").trim() || "local"; }
2423
- function livenessEnabled(): boolean { const v = String(process.env.FLOW_AGENTS_LIVENESS || "").trim().toLowerCase(); return v === "on" || v === "1" || v === "true"; }
2951
+ /**
2952
+ * Delegate to the shared pure-CJS resolver (scripts/hooks/lib/actor-identity.js), mirroring the
2953
+ * createRequire pattern used by readLivenessEvents() above. Deliberately NO inline duplicate
2954
+ * fallback: if the module fails to load, that failure itself must surface as an unresolved actor
2955
+ * ("") — never silently degrade back to the retired "local" default (issue #287).
2956
+ */
2957
+ function loadActorIdentityHelper(): {
2958
+ resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
2959
+ sanitizeSegment: (value: unknown) => string;
2960
+ isUnresolvedActor: (actor: string) => boolean;
2961
+ } {
2962
+ const _req = createRequire(import.meta.url);
2963
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
2964
+ return _req(helperPath) as {
2965
+ resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
2966
+ sanitizeSegment: (value: unknown) => string;
2967
+ isUnresolvedActor: (actor: string) => boolean;
2968
+ };
2969
+ }
2970
+ function resolveLivenessActor(): string {
2971
+ return loadActorIdentityHelper().resolveActor(process.env).actor;
2972
+ }
2973
+ // isUnresolvedActor is no longer defined locally — it is single-sourced in
2974
+ // scripts/hooks/lib/actor-identity.js (loadActorIdentityHelper().isUnresolvedActor) so the
2975
+ // lifecycle auto-emit path, the direct CLI liveness path, and the tool-activity heartbeat path
2976
+ // all consume the same predicate rather than forking their own copy (#287 re-review MEDIUM; #288
2977
+ // Wave 1 Task 1.1 single-sources it there).
2978
+ /**
2979
+ * Delegate to the shared pure-CJS policy predicates (scripts/hooks/lib/liveness-policy.js) — the
2980
+ * one definition of "enabled" (default-on/opt-out) and the claim TTL default, consumed by both the
2981
+ * lifecycle auto-emit path and the manual `liveness claim --ttl` default so they can never disagree
2982
+ * (#288).
2983
+ */
2984
+ function loadLivenessPolicyHelper(): {
2985
+ isLivenessEnabled: (env: NodeJS.ProcessEnv) => boolean;
2986
+ resolveTtlSeconds: (env: NodeJS.ProcessEnv) => number;
2987
+ } {
2988
+ const _req = createRequire(import.meta.url);
2989
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-policy.js");
2990
+ return _req(helperPath) as {
2991
+ isLivenessEnabled: (env: NodeJS.ProcessEnv) => boolean;
2992
+ resolveTtlSeconds: (env: NodeJS.ProcessEnv) => number;
2993
+ };
2994
+ }
2995
+ function livenessEnabled(): boolean { return loadLivenessPolicyHelper().isLivenessEnabled(process.env); }
2996
+ /**
2997
+ * F1 (#288 fix iteration 1, cr-HIGH fail-open violation): the `livenessEnabled()`
2998
+ * guard (and therefore its `loadLivenessPolicyHelper()` module load) must sit
2999
+ * INSIDE this function's own fail-open try/catch — previously it sat outside,
3000
+ * so a missing/broken scripts/hooks/lib/liveness-policy.js module made
3001
+ * `init-plan`/`advance-state` exit 1 instead of degrading gracefully
3002
+ * (repro-verified). Now: any failure here — including a failed helper load —
3003
+ * is caught, produces one stderr diagnostic, and the lifecycle auto-emit is
3004
+ * skipped; the workflow command's own exit code is never affected. This
3005
+ * mirrors the #287 fail-open convention already used elsewhere in this file.
3006
+ * The direct CLI write path (`async function liveness`, actions
3007
+ * claim|heartbeat|release) is deliberately NOT wrapped this way — it stays
3008
+ * fail-loud on a missing helper module, per the plan's explicit instruction
3009
+ * that only the convenience lifecycle wiring is flag-gated/fail-open.
3010
+ */
2424
3011
  function livenessLifecycle(taskDir: string, slug: string, kind: "claim" | "heartbeat" | "release", timestamp: string): void {
2425
- if (!livenessEnabled()) return;
2426
3012
  try {
2427
- const root = path.dirname(taskDir); // .flow-agents/<slug> → .flow-agents (the shared liveness stream lives here)
2428
- const evt: AnyObj = { type: kind, subjectId: slug, actor: resolveLivenessActor(), at: timestamp, source: "lifecycle" };
2429
- if (kind === "claim") evt.ttlSeconds = 1800;
3013
+ if (!livenessEnabled()) return;
3014
+ const actor = resolveLivenessActor();
3015
+ if (loadActorIdentityHelper().isUnresolvedActor(actor)) {
3016
+ process.stderr.write("[liveness] skipped auto-emit: actor unresolved (set FLOW_AGENTS_ACTOR or run inside a supported runtime)\n");
3017
+ return;
3018
+ }
3019
+ const root = path.dirname(taskDir); // .kontourai/flow-agents/<slug> → .kontourai/flow-agents (the shared liveness stream lives here)
3020
+ const evt: AnyObj = { type: kind, subjectId: slug, actor, at: timestamp, source: "lifecycle" };
3021
+ if (kind === "claim") evt.ttlSeconds = loadLivenessPolicyHelper().resolveTtlSeconds(process.env);
2430
3022
  appendLivenessEvent(root, evt);
2431
- } catch { /* best-effort; liveness is advisory and must never break the workflow */ }
3023
+ } catch (err) {
3024
+ // best-effort; liveness is advisory and must never break the workflow — but the failure
3025
+ // itself must be visible (F1), not silently absorbed.
3026
+ try {
3027
+ const detail = err instanceof Error ? err.message : String(err);
3028
+ process.stderr.write(`[liveness] skipped auto-emit: ${detail}\n`);
3029
+ } catch { /* best-effort diagnostic only */ }
3030
+ }
3031
+ }
3032
+
3033
+ /**
3034
+ * F5 (#288 fix iteration 1, sec-LOW): strip control/escape characters before echoing a
3035
+ * subjectId/actor to the terminal. subjectId is a raw CLI positional argument on the write path
3036
+ * (never sanitized before this point — it is stored as-is in the event for data fidelity) and,
3037
+ * on the `status` read path, both subjectId and actor may originate from a hand-edited or
3038
+ * otherwise hostile liveness/events.jsonl file rather than this process's own writes. Strips the
3039
+ * C0 control range (0x00-0x1F), DEL (0x7F), and the C1 range (0x80-0x9F, which includes
3040
+ * ANSI-CSI-adjacent bytes) — display-only; the persisted event itself is never mutated.
3041
+ */
3042
+ function stripControlCharsForDisplay(value: unknown): string {
3043
+ return String(value ?? "").replace(/[\u0000-\u001F\u007F-\u009F]/g, "");
2432
3044
  }
2433
3045
 
2434
3046
  async function liveness(p: ReturnType<typeof parseArgs>): Promise<number> {
2435
3047
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
2436
3048
  const action = p.positional[0] || "";
2437
3049
  const subjectId = p.positional[1] || "";
2438
- const actor = opt(p, "actor", process.env.FLOW_AGENTS_ACTOR || "unknown");
2439
3050
  const nowIso = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
2440
3051
 
3052
+ if (action === "whoami") {
3053
+ // Read-only, lock-free, write-free advisory surface: reuses the identical resolution chain
3054
+ // as the write paths (loadActorIdentityHelper().resolveActor) but deliberately never dies on
3055
+ // an unresolved actor — the enforcement point stays at `liveness claim`, which already dies
3056
+ // loudly (see below). This lets a skill learn "who am I" during a read-only preflight without
3057
+ // emitting a bogus claim first.
3058
+ const helper = loadActorIdentityHelper();
3059
+ const explicitActorRaw = opt(p, "actor", "");
3060
+ const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
3061
+ const resolved = explicitActor
3062
+ ? { actor: explicitActor, source: "explicit-override" }
3063
+ : helper.resolveActor(process.env);
3064
+ if (p.flags.has("json")) { console.log(JSON.stringify(resolved)); return 0; }
3065
+ console.log(`${stripControlCharsForDisplay(resolved.actor || "unresolved")}\t${stripControlCharsForDisplay(resolved.source)}`);
3066
+ return 0;
3067
+ }
3068
+
2441
3069
  if (action === "claim" || action === "heartbeat" || action === "release") {
3070
+ // Actor resolution happens only for write actions (F5, #287 fix iteration 1) — "status" is a
3071
+ // read path and must not shell out via resolveLivenessActor()/resolveActor().
3072
+ const helper = loadActorIdentityHelper();
3073
+ const explicitActorRaw = opt(p, "actor", "");
3074
+ // F7 (#287 fix iteration 2): an explicit --actor value that strips to empty under the allowed
3075
+ // [A-Za-z0-9_.-] charset (e.g. "--actor ':::'") is a hard error on this write path, not a
3076
+ // silent fallback to sanitizeSegment's shared "unknown" sentinel — garbage on an explicit flag
3077
+ // is an authoring mistake the caller must fix, unlike the env-override seam (which falls
3078
+ // through to derivation instead, since there is no flag to correct).
3079
+ if (explicitActorRaw && !/[A-Za-z0-9_.-]/.test(explicitActorRaw)) {
3080
+ die(`liveness ${action} --actor value strips to empty under the allowed actor charset ([A-Za-z0-9_.-]) — pass a --actor value containing at least one letter, digit, underscore, period, or hyphen.`);
3081
+ }
3082
+ const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
3083
+ const actor = explicitActor || helper.resolveActor(process.env).actor;
3084
+ if (helper.isUnresolvedActor(actor)) {
3085
+ die(`liveness ${action} requires a resolvable actor — no explicit --actor flag, no FLOW_AGENTS_ACTOR override, and runtime/ancestry resolution failed. Fix: pass --actor <id>, or set FLOW_AGENTS_ACTOR=<id>, or run inside a supported runtime.`);
3086
+ }
2442
3087
  if (!subjectId) die(`liveness ${action} requires a subjectId`);
3088
+ // F8(i) (#288 fix iteration 2, orphan-heartbeat invariant): heartbeat/release on the direct CLI
3089
+ // write path must never be the FIRST liveness event ever written for a (subjectId, actor) pair —
3090
+ // that is exactly how the reviewer-reproduced orphan-heartbeat bug arises (a bare heartbeat with
3091
+ // no claim behind it, later mistaken for claim evidence). A full stream read here is fine: this
3092
+ // is a rare, human/CLI-driven call, not the hot tool-activity path. `claim` itself is exempt (it
3093
+ // is the event that ESTABLISHES the pair in the first place).
3094
+ if (action === "heartbeat" || action === "release") {
3095
+ const priorEvents = readLivenessEvents(root);
3096
+ const hasPriorClaim = priorEvents.some((e) => e && e.type === "claim" && e.subjectId === subjectId && e.actor === actor);
3097
+ if (!hasPriorClaim) {
3098
+ die(`liveness ${action} requires a prior claim event for subjectId ${JSON.stringify(subjectId)} and actor ${JSON.stringify(actor)} — none was found in the liveness stream. Fix: run \`liveness claim ${subjectId} --actor ${actor}\` first, then retry ${action}.`);
3099
+ }
3100
+ }
2443
3101
  const evt: AnyObj = { type: action, subjectId, actor, at: opt(p, "at") || nowIso };
2444
- if (action === "claim") evt.ttlSeconds = Number.parseInt(opt(p, "ttl", "1800"), 10) || 1800;
3102
+ if (action === "claim") {
3103
+ const defaultTtl = loadLivenessPolicyHelper().resolveTtlSeconds(process.env);
3104
+ evt.ttlSeconds = Number.parseInt(opt(p, "ttl", String(defaultTtl)), 10) || defaultTtl;
3105
+ }
2445
3106
  appendLivenessEvent(root, evt);
2446
- console.log(`liveness ${action}: ${subjectId} by ${actor}`);
3107
+ console.log(`liveness ${action}: ${stripControlCharsForDisplay(subjectId)} by ${stripControlCharsForDisplay(actor)}`);
2447
3108
  return 0;
2448
3109
  }
2449
3110
 
@@ -2467,16 +3128,103 @@ async function liveness(p: ReturnType<typeof parseArgs>): Promise<number> {
2467
3128
  const rows: AnyObj[] = [];
2468
3129
  for (const g of groups.values()) {
2469
3130
  if (subjectFilter && g.subjectId !== subjectFilter) continue;
2470
- const claim: AnyObj = { id: `${g.subjectId}::${g.actor}`, subjectType: "work-item", subjectId: g.subjectId, surface: "flow.liveness", claimType: "liveness.hold", fieldOrBehavior: "held-by", value: g.actor, createdAt: g.created, updatedAt: g.updated, ttlSeconds: g.ttlSeconds, verificationPolicyId: LIVENESS_POLICY.id };
3131
+ const claim: AnyObj = { id: `${g.subjectId}::${g.actor}`, subjectType: "work-item", subjectId: g.subjectId, facet: "flow.liveness", claimType: "liveness.hold", fieldOrBehavior: "held-by", value: g.actor, createdAt: g.created, updatedAt: g.updated, ttlSeconds: g.ttlSeconds, verificationPolicyId: LIVENESS_POLICY.id };
2471
3132
  const status = surface.deriveTrustStatus!({ claim, evidence: [], policy: LIVENESS_POLICY, events: g.events, now });
2472
3133
  rows.push({ subjectId: g.subjectId, actor: g.actor, status, label: livenessLabel(status) });
2473
3134
  }
2474
3135
  if (p.flags.has("json")) { console.log(JSON.stringify(rows, null, 2)); return 0; }
2475
- for (const r of rows) console.log(`${r.subjectId}\t${r.actor}\t${r.label}`);
3136
+ for (const r of rows) console.log(`${stripControlCharsForDisplay(r.subjectId)}\t${stripControlCharsForDisplay(r.actor)}\t${r.label}`);
3137
+ return 0;
3138
+ }
3139
+
3140
+ if (action === "verdict") {
3141
+ // Read-only, lock-free CLI helper (#320 AC1) — mirrors `whoami`'s lock-bypass (see
3142
+ // isLivenessVerdict in main() below): computes {subjectId, winner, losers, reason, holders}
3143
+ // as a PURE function of the shared liveness stream. Among the subject's currently-fresh
3144
+ // claim holders (via the shared `freshHolders` helper — the exact canonical
3145
+ // freshness/grouping/release-handling logic `workflow-steering.js`'s ambient digest already
3146
+ // uses, not a re-derived rule), the holder whose most recent `claim` event has the earliest
3147
+ // `at` wins; an exact-timestamp tie breaks by ascending plain string comparison of actor id
3148
+ // (never locale-aware collation, for cross-machine determinism). Same stream state ⇒ same
3149
+ // verdict, regardless of which actor invokes it — no actor-specific input is read here.
3150
+ if (!subjectId) die("liveness verdict requires a subjectId");
3151
+ const events = readLivenessEvents(root);
3152
+ const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : Date.now();
3153
+ const _req = createRequire(import.meta.url);
3154
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
3155
+ const { freshHolders } = _req(helperPath) as { freshHolders: (events: AnyObj[], slug: string, selfActor: string, nowMs: number) => AnyObj[] };
3156
+ // Impossible-sentinel selfActor: real actors are sanitized to [A-Za-z0-9_.-]+ and can never be
3157
+ // empty (enforced on every write path above), so passing "" excludes nothing.
3158
+ const fresh = freshHolders(events, subjectId, "", nowMs);
3159
+ // freshHolders' `lastAt` tracks the group's latest event of ANY type (heartbeat-updated); the
3160
+ // tiebreak instead operates on each holder's most recent `claim`-type event `at` — the
3161
+ // "current claim timestamp", distinct from lastAt.
3162
+ const latestClaimAt = (actor: string): string => {
3163
+ let best = "";
3164
+ for (const e of events) {
3165
+ if (e && e.subjectId === subjectId && e.actor === actor && e.type === "claim" && typeof e.at === "string") {
3166
+ if (!best || e.at > best) best = e.at;
3167
+ }
3168
+ }
3169
+ return best;
3170
+ };
3171
+ const holders: AnyObj[] = fresh
3172
+ .map((h) => ({ actor: String(h.actor), claimAt: latestClaimAt(String(h.actor)) || String(h.lastAt), lastAt: String(h.lastAt), ttlSeconds: Number(h.ttlSeconds) }))
3173
+ .sort((a, b) => (a.claimAt < b.claimAt ? -1 : a.claimAt > b.claimAt ? 1 : a.actor < b.actor ? -1 : a.actor > b.actor ? 1 : 0));
3174
+ let winner: AnyObj | null = null;
3175
+ let losers: AnyObj[] = [];
3176
+ let reason = "no-conflict";
3177
+ if (holders.length >= 2) {
3178
+ winner = holders[0];
3179
+ const tie = holders.filter((h) => h.claimAt === winner!.claimAt).length > 1;
3180
+ reason = tie ? "tie-actor-lexicographic" : "earlier-claim";
3181
+ losers = holders.filter((h) => h.actor !== winner!.actor);
3182
+ }
3183
+ if (p.flags.has("json")) {
3184
+ // #320 fix iteration 1 (sec-HIGH F3): `winner`/`losers`/`holders` carry raw actor/claimAt/
3185
+ // lastAt strings sourced straight from the multi-writer append-only liveness/events.jsonl
3186
+ // stream (see stripControlCharsForDisplay's header above — any process can append a
3187
+ // hostile line, bypassing the CLI write-side sanitizeSegment entirely). The pull-work
3188
+ // SKILL.md's Post-Claim Conflict Re-check instructs an LLM to read `winner.actor` /
3189
+ // `losers[].actor` straight out of THIS `--json` output into its own context — a second,
3190
+ // LLM-facing injection path alongside the mid-turn conflict hook (liveness-heartbeat.js's
3191
+ // computeConflict). Build a sanitized COPY for JSON emission only: control-char strip
3192
+ // (matching the text branch's existing stripControlCharsForDisplay treatment below) plus a
3193
+ // 64-char cap (matching sanitizeSegment's cap on the write side) on actor/claimAt/lastAt.
3194
+ // `reason`/`subjectId`/`ttlSeconds` are left as-is (enum/number, never free text). Never
3195
+ // mutates `result`/`holders`/`winner`/`losers` themselves — the text branch below still
3196
+ // needs the untouched originals for its own per-line rendering, and winner/loser identity
3197
+ // (already sanitizeSegment-clean for legitimate actors) is unaffected, so the loser-release
3198
+ // contract still works.
3199
+ const sanitizeVerdictHolderForJson = (h: AnyObj | null): AnyObj | null =>
3200
+ h
3201
+ ? {
3202
+ ...h,
3203
+ actor: stripControlCharsForDisplay(h.actor).slice(0, 64),
3204
+ claimAt: stripControlCharsForDisplay(h.claimAt).slice(0, 64),
3205
+ lastAt: stripControlCharsForDisplay(h.lastAt).slice(0, 64),
3206
+ }
3207
+ : h;
3208
+ const jsonResult = {
3209
+ subjectId,
3210
+ winner: sanitizeVerdictHolderForJson(winner),
3211
+ losers: losers.map((l) => sanitizeVerdictHolderForJson(l)),
3212
+ reason,
3213
+ holders: holders.map((h) => sanitizeVerdictHolderForJson(h)),
3214
+ };
3215
+ console.log(JSON.stringify(jsonResult));
3216
+ return 0;
3217
+ }
3218
+ for (const h of holders) {
3219
+ const tag = winner && h.actor === (winner as AnyObj).actor ? "WINNER" : (holders.length >= 2 ? "LOSER" : "HOLDER");
3220
+ console.log(`${stripControlCharsForDisplay(h.actor)}\tclaimAt=${stripControlCharsForDisplay(h.claimAt)}\t${tag}`);
3221
+ }
3222
+ if (winner) console.log(`WINNER: ${stripControlCharsForDisplay((winner as AnyObj).actor)}`);
3223
+ for (const l of losers) console.log(`LOSER: ${stripControlCharsForDisplay(l.actor)}`);
2476
3224
  return 0;
2477
3225
  }
2478
3226
 
2479
- die("liveness action must be one of: claim | heartbeat | release | status");
3227
+ die("liveness action must be one of: claim | heartbeat | release | status | whoami | verdict");
2480
3228
  return 1;
2481
3229
  }
2482
3230
  // ─────────────────────────────────────────────────────────────────────────────
@@ -2652,9 +3400,24 @@ Available claim ids:
2652
3400
  async function main(): Promise<number> {
2653
3401
  const p = parseArgs(process.argv.slice(2));
2654
3402
  if (!p.command) die("workflow-sidecar command is required");
2655
- const lockRoot = ["ensure-session", "current", "dogfood-pass", "liveness"].includes(p.command)
3403
+ // F1 (#166 fix iteration 1): `liveness whoami` is a read-only, lock-free, write-free advisory
3404
+ // surface (see the `action === "whoami"` branch inside `liveness()` above) — it must never
3405
+ // acquire the workflow-sidecar lock, regardless of whether the artifact root already exists on
3406
+ // disk. Without this action-level bypass, `liveness` was blanket-included in the lock-routing
3407
+ // branch below, so `whoami` against an EXISTING artifact root would still resolve a real
3408
+ // lockRoot and go through `withLock`'s mkdir/lockdir path — the opposite of "genuinely
3409
+ // lock-free". This bypass mirrors `resolve-slug`'s existing empty-lockRoot special case
3410
+ // immediately below and is scoped to the `whoami` action only: `liveness status` (a read path)
3411
+ // keeps its pre-existing lock behavior unchanged (out of scope for this fix — see fix-plan
3412
+ // iteration 1, F1), and `liveness claim` / `heartbeat` / `release` (write paths) are untouched.
3413
+ const isLivenessWhoami = p.command === "liveness" && p.positional[0] === "whoami";
3414
+ // #320 AC1: `liveness verdict` is read-only and lock-free, exactly like `whoami` above — it
3415
+ // must never acquire the workflow-sidecar lock. Same bypass shape, scoped to the `verdict`
3416
+ // action only; `liveness status` (also a read path) keeps its pre-existing lock behavior.
3417
+ const isLivenessVerdict = p.command === "liveness" && p.positional[0] === "verdict";
3418
+ const lockRoot = (["ensure-session", "current", "dogfood-pass", "liveness"].includes(p.command) && !isLivenessWhoami && !isLivenessVerdict)
2656
3419
  ? (opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : (p.command === "ensure-session" ? flowAgentsArtifactRoot() : defaultArtifactRootForRead()))
2657
- : p.command === "record-agent-event" ? explicitArtifactRoot(p) : p.command === "claim" ? (p.positional[1] ? path.resolve(p.positional[1]) : "") : p.positional[0] ? artifactDirFrom(p.positional[0]) : "";
3420
+ : p.command === "record-agent-event" ? explicitArtifactRoot(p) : p.command === "claim" ? (p.positional[1] ? path.resolve(p.positional[1]) : "") : p.command === "resolve-slug" ? "" : (isLivenessWhoami || isLivenessVerdict) ? "" : p.positional[0] ? artifactDirFrom(p.positional[0]) : "";
2658
3421
  return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
2659
3422
  switch (p.command) {
2660
3423
  case "ensure-session": return ensureSession(p);
@@ -2663,6 +3426,7 @@ async function main(): Promise<number> {
2663
3426
  case "init-plan": return initPlan(p);
2664
3427
  case "record-evidence": return recordEvidence(p);
2665
3428
  case "record-gate-claim": return recordGateClaim(p);
3429
+ case "promote": return promote(p);
2666
3430
  case "advance-state": return advanceState(p);
2667
3431
  case "record-critique": return recordCritique(p);
2668
3432
  case "import-critique": return importCritique(p);
@@ -2674,6 +3438,7 @@ async function main(): Promise<number> {
2674
3438
  case "trust-mcp": return trustMcp(p);
2675
3439
  case "liveness": return liveness(p);
2676
3440
  case "claim": return claimLookup(p);
3441
+ case "resolve-slug": return resolveSlugCmd(p);
2677
3442
  case "seal-checkpoint": return sealCheckpoint(p);
2678
3443
  case "publish-delivery": return publishDeliveryCmd(p);
2679
3444
  default: die(`unknown command: ${p.command}`);