@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
@@ -10,7 +10,7 @@ import { resolveActiveFlowStep, resolveFlowFilePath, resolvePhaseMap, resolveRou
10
10
  import { defaultArtifactRootForRead, flowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
11
11
  export const statuses = new Set(["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "accepted", "archived"]);
12
12
  export const phases = ["idea", "backlog", "pickup", "planning", "execution", "verification", "goal_fit", "evidence", "release", "learning", "done"];
13
- export const checkKinds = new Set(["build", "types", "lint", "test", "security", "diff", "browser", "runtime", "policy", "external"]);
13
+ export const checkKinds = new Set(["build", "types", "lint", "test", "command", "security", "diff", "browser", "runtime", "policy", "external"]);
14
14
  export const checkStatuses = new Set(["pass", "fail", "not_verified", "skip"]);
15
15
  export const verdicts = new Set(["pass", "partial", "fail", "not_verified"]);
16
16
  function now() { return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); }
@@ -48,6 +48,162 @@ function workItemSlug(ref) {
48
48
  const [owner, repo] = parts;
49
49
  return slugify(`${owner}-${repo}-${id}`, "work-item");
50
50
  }
51
+ /** Pure, lock-free, side-effect-free CLI wrapper around workItemSlug() — the single source of
52
+ * truth for the deterministic subjectId/session-directory-name slug. Named resolveSlugCmd (not
53
+ * resolveSlug) to avoid colliding with any future export named resolveSlug. */
54
+ function resolveSlugCmd(p) {
55
+ const ref = p.positional[0] || die("resolve-slug requires an owner/repo#id ref");
56
+ console.log(workItemSlug(ref));
57
+ return 0;
58
+ }
59
+ /** First 6 hex chars of sha256(raw) — a short deterministic disambiguator (#289 F4). Two
60
+ * different raw inputs that both collapse to a segment's "unknown" fallback (e.g. two distinct
61
+ * all-garbage --task-slug values, or two distinct raw actor strings that both resolve
62
+ * "unresolved") would otherwise derive the identical fallback branch — this makes them diverge
63
+ * while staying fully deterministic for a given raw input (same raw -> same hash every call),
64
+ * which is required to preserve resolveSessionBranch's no-rederive/resume-continuity semantics
65
+ * (a resumed session's already-recorded branch is never recomputed regardless of this helper). */
66
+ function unknownDisambiguator(raw) {
67
+ return createHash("sha256").update(raw).digest("hex").slice(0, 6);
68
+ }
69
+ /** Encode a value (actor string or --task-slug, neither guaranteed git-ref-safe) into a
70
+ * single git-check-ref-format(1)-safe branch path component (#289). Reuses
71
+ * actor-identity.js's sanitizeSegment (consume, don't fork) as the base, then closes the
72
+ * git-specific gaps sanitizeSegment's [A-Za-z0-9_.-] charset does not: the `:` delimiter
73
+ * serializeActor() uses (disallowed in a git ref), consecutive dots, a leading dot, a trailing
74
+ * dot (fix-plan iteration 1 F1 — git-check-ref-format forbids a component ending in `.`, and
75
+ * the prior pass only handled a run of 2+ trailing dots via the `..`-collapse step, missing the
76
+ * single-trailing-dot case e.g. a `--task-slug` of `my-fix.`), and a trailing `.lock`. The
77
+ * trailing-dot strip runs BEFORE the `.lock` rewrite so a `.lock` suffix hidden behind trailing
78
+ * dots (e.g. `foo.lock.`) is exposed and still rewritten rather than left dangling. Whenever the
79
+ * FINAL sanitized segment equals the fallback token `"unknown"` — whether because the charset
80
+ * filter collapsed the raw input to nothing, OR because the leading-/trailing-dot stripping
81
+ * above collapsed a near-miss input like `"unknown."` or `".unknown"` down to the literal
82
+ * `"unknown"`, OR because the raw input genuinely WAS the literal string `"unknown"` — a
83
+ * deterministic disambiguator (see unknownDisambiguator) is ALWAYS appended (fix-plan iteration 2
84
+ * F4': no literal-input carve-out). Distinct raw inputs can never collide on the bare literal
85
+ * `"unknown"` segment; the same raw input always re-derives the same disambiguated segment. See
86
+ * Design Decision 2 in the plan. */
87
+ function sanitizeBranchSegment(value, helper) {
88
+ const raw = String(value ?? "");
89
+ const colonReplaced = raw.replace(/:/g, "-");
90
+ let seg = helper.sanitizeSegment(colonReplaced);
91
+ seg = seg.replace(/\.{2,}/g, "-").replace(/^\.+/, "").replace(/\.+$/, "").replace(/\.lock$/i, "-lock");
92
+ if (!seg || seg === "unknown")
93
+ return `unknown-${unknownDisambiguator(raw)}`;
94
+ return seg;
95
+ }
96
+ /** Validate an explicit `--branch` value strictly rather than trusting it verbatim (fix-plan
97
+ * iteration 1 F2, tightened by iteration 2 F2'). Explicit `--branch` bypasses
98
+ * `sanitizeBranchSegment` entirely by design (it is caller intent, may legitimately contain `/`
99
+ * to nest under `agent/...`, etc.), so unlike the derived path it must be rejected outright — not
100
+ * silently sanitized — when it cannot be a valid git ref. Whole-string checks (iteration 1):
101
+ * any control character/newline, a space, a leading or trailing `/`, a `//` sequence, a leading
102
+ * `.`, any `..` sequence, a trailing `.`, a trailing `.lock`, or any character outside
103
+ * `[A-Za-z0-9_./-]`. Per-component checks (iteration 2 F2'): the whole-string checks above only
104
+ * examine the START/END of the full value, so a charset-legal value can still smuggle an invalid
105
+ * `/`-delimited component past them — e.g. `"-lead"` (a leading `-` breaks the whole git ref,
106
+ * applied uniformly to every component here — stricter than git strictly requires, which is fine
107
+ * for a caller-facing override flag), `"a/.b"` (a non-first component starting with `.`),
108
+ * `"foo.lock/bar"` (a non-last component ending in `.lock`), or `"a/./b"` (a component that is
109
+ * exactly `.`). Belt-and-braces (F2'): once the lexical checks above all pass, `git
110
+ * check-ref-format --branch <value>` (the real git binary) is run as the final authority — it can
111
+ * only ever REJECT a value the lexical checks let through (never re-legalize one they rejected),
112
+ * so it closes any residual gap in this hand-rolled lexical pass. When git cannot be spawned at
113
+ * all (e.g. not installed — `ENOENT`) or does not complete (e.g. the 5s timeout fires), the git
114
+ * check is skipped silently and the lexical checks above remain the sole authority. Dies with
115
+ * remediation, or with git's own rejection message; never mutates any artifact before this check
116
+ * runs (resolveSessionBranch calls this before any file write). */
117
+ function validateExplicitBranch(value) {
118
+ 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)}).`;
119
+ const fail = (reason) => die(`ensure-session --branch value is not a valid git ref: ${reason}. ${remediation}`);
120
+ if (/[\x00-\x1F\x7F]/.test(value))
121
+ fail("contains a control character or newline");
122
+ if (/ /.test(value))
123
+ fail("contains a space");
124
+ if (value.startsWith("/") || value.endsWith("/"))
125
+ fail('must not start or end with "/"');
126
+ if (value.includes("//"))
127
+ fail('must not contain "//"');
128
+ if (value.startsWith("."))
129
+ fail('must not start with "."');
130
+ if (value.includes(".."))
131
+ fail('must not contain a ".." sequence');
132
+ if (value.endsWith("."))
133
+ fail('must not end with "."');
134
+ if (/\.lock$/i.test(value))
135
+ fail('must not end with ".lock"');
136
+ if (/[^A-Za-z0-9_./-]/.test(value))
137
+ fail("contains a character outside [A-Za-z0-9_./-]");
138
+ // F2' per-component checks: split on "/" and validate each path component individually. The
139
+ // whole-string checks above cannot catch a hostile component that is not at the very start or
140
+ // end of the full value.
141
+ for (const component of value.split("/")) {
142
+ if (!component)
143
+ fail('must not contain an empty path component ("//" or a leading/trailing "/")');
144
+ if (component === ".")
145
+ fail('must not contain a path component that is exactly "."');
146
+ if (component.startsWith("."))
147
+ fail('must not contain a path component starting with "."');
148
+ if (component.startsWith("-"))
149
+ fail('must not contain a path component starting with "-"');
150
+ if (component.endsWith("."))
151
+ fail('must not contain a path component ending with "."');
152
+ if (/\.lock$/i.test(component))
153
+ fail('must not contain a path component ending with ".lock"');
154
+ }
155
+ // F2' belt-and-braces: the real `git check-ref-format --branch` binary is the final authority
156
+ // when git is available. This can only REJECT a value that already passed every lexical check
157
+ // above — it never re-legalizes a value the lexical checks rejected (those `fail()` calls
158
+ // above already threw). argv-array form (no shell) with a 5s timeout so a hung or missing git
159
+ // binary cannot hang or crash session creation.
160
+ let result;
161
+ try {
162
+ execFileSync("git", ["check-ref-format", "--branch", value], { stdio: ["ignore", "ignore", "pipe"], timeout: 5000 });
163
+ return; // exit 0 — git accepts the value; nothing further to check.
164
+ }
165
+ catch (err) {
166
+ const spawnError = err;
167
+ if (spawnError && spawnError.code === "ENOENT")
168
+ return; // git not installed — lexical checks stand alone.
169
+ if (spawnError && typeof spawnError.status === "number") {
170
+ result = { status: spawnError.status, stderr: spawnError.stderr ?? "" };
171
+ }
172
+ else
173
+ return; // Any other spawn failure (e.g. timeout) — skip silently; lexical checks already passed.
174
+ }
175
+ if (result && result.status !== 0) {
176
+ const gitMessage = String(result.stderr ?? "").trim();
177
+ fail(gitMessage ? `git check-ref-format rejected the value: ${gitMessage}` : "git check-ref-format rejected the value");
178
+ }
179
+ }
180
+ /** Resolve the branch to seed a brand-new session with. Only called from ensureSession's
181
+ * `if (!md)` fresh-creation branch — an existing session's already-recorded branch is never
182
+ * recomputed (ADR 0021 §5 takeover continuity; see Design Decision 3). Precedence: explicit
183
+ * --branch (strictly validated, then honored verbatim — see F2) > derived agent/<actor>/<slug>.
184
+ * Never hard-fails session creation on actor-resolution ambiguity (Design Decision 4) — only a
185
+ * garbage explicit --actor, or a garbage explicit --branch, dies. */
186
+ function resolveSessionBranch(p, slug) {
187
+ // Deliberately NOT trimmed before validation (unlike the pre-F2 baseline): a leading/trailing
188
+ // space must be REJECTED, not silently trimmed away — silent trimming would let a
189
+ // caller-supplied value differ from what gets recorded without any diagnostic (F2).
190
+ const explicitBranch = opt(p, "branch");
191
+ if (explicitBranch) {
192
+ validateExplicitBranch(explicitBranch);
193
+ return explicitBranch;
194
+ }
195
+ const helper = loadActorIdentityHelper();
196
+ const explicitActorRaw = opt(p, "actor", "").trim();
197
+ if (explicitActorRaw && !/[A-Za-z0-9_.-]/.test(explicitActorRaw)) {
198
+ 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.");
199
+ }
200
+ const actor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : helper.resolveActor(process.env).actor;
201
+ const unresolved = helper.isUnresolvedActor(actor);
202
+ const safeActor = unresolved ? `unknown-actor-${unknownDisambiguator(actor)}` : sanitizeBranchSegment(actor, helper);
203
+ if (unresolved)
204
+ process.stderr.write("[ensure-session] actor unresolved; branch uses \"unknown-actor\" segment (set --actor or FLOW_AGENTS_ACTOR for a stable branch name)\n");
205
+ return `agent/${safeActor}/${sanitizeBranchSegment(slug, helper)}`;
206
+ }
51
207
  /**
52
208
  * Validate a Hachure trust.bundle using @kontourai/surface's canonical validator
53
209
  * (surface is the authoritative owner of trust-bundle schema validation per ADR 0010 / ADR 0015).
@@ -180,6 +336,47 @@ function criterionStatusToEventStatus(status) {
180
336
  return "assumed";
181
337
  return null; // pending / not_verified → no event → Surface returns "unknown"
182
338
  }
339
+ /**
340
+ * WS8 (ADR 0020): Derive Surface evidence classification (evidenceType + method)
341
+ * from a workflow check's kind, replacing the previous hardcoded `test_output`.
342
+ * Only command-backed re-runnable checks are `test_output` (CI-reconcilable);
343
+ * everything else is inherently session-local (manual attestation, provider/
344
+ * document citation, crawl observation, policy rule, source excerpt). The
345
+ * `reconcilable` flag is informational — the CI reconciler classifies purely by
346
+ * the emitted `evidenceType`. Every value is a member of Surface's own
347
+ * evidence.schema.json `evidenceType`/`method` enums (consume-never-fork).
348
+ */
349
+ function classifyEvidence(kind, hasCommand) {
350
+ const k = String(kind ?? "external");
351
+ switch (k) {
352
+ case "build":
353
+ case "types":
354
+ case "lint":
355
+ case "test":
356
+ case "command":
357
+ return { evidenceType: "test_output", method: "validation", reconcilable: true };
358
+ case "security":
359
+ return hasCommand
360
+ ? { evidenceType: "test_output", method: "validation", reconcilable: true }
361
+ : { evidenceType: "attestation", method: "corroboration", reconcilable: false };
362
+ case "diff":
363
+ return { evidenceType: "source_excerpt", method: "extraction", reconcilable: false };
364
+ case "browser":
365
+ return hasCommand
366
+ ? { evidenceType: "test_output", method: "validation", reconcilable: true }
367
+ : { evidenceType: "crawl_observation", method: "observation", reconcilable: false };
368
+ case "runtime":
369
+ return hasCommand
370
+ ? { evidenceType: "test_output", method: "validation", reconcilable: true }
371
+ : { evidenceType: "attestation", method: "attestation", reconcilable: false };
372
+ case "policy":
373
+ return { evidenceType: "policy_rule", method: "auditability", reconcilable: false };
374
+ case "external":
375
+ return { evidenceType: "attestation", method: "corroboration", reconcilable: false };
376
+ default:
377
+ return { evidenceType: "test_output", method: "validation", reconcilable: true };
378
+ }
379
+ }
183
380
  /** Map a critique verdict to the Surface VerificationEvent status. */
184
381
  function critiqueToEventStatus(verdict, findings) {
185
382
  if (verdict === "fail")
@@ -221,14 +418,29 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
221
418
  const evidenceItems = [];
222
419
  const events = [];
223
420
  const ts = timestamp || new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
224
- // One VerificationPolicy per distinct claimType, so status is policy-governed
225
- // (not derived against an empty policy set). Maximal-fidelity per ADR 0010.
421
+ // One VerificationPolicy per distinct (claimType, requiredEvidence) pair, so status is
422
+ // policy-governed (not derived against an empty policy set). Maximal-fidelity per ADR 0010.
423
+ //
424
+ // WS8 (AC1, iteration 2): the cache is keyed by claimType + the normalized requiredEvidence
425
+ // set, NOT by claimType alone. Two checks of the same legacy claimType that differ in
426
+ // command-presence (e.g. a command-backed browser check → requiredEvidence [test_output]
427
+ // vs a no-command browser check → [crawl_observation]) previously COLLIDED: the first-seen
428
+ // requiredEvidence won and corrupted the second claim's derived status (verified → proposed)
429
+ // in a record-order-dependent way. Keying by (claimType, requiredEvidence) makes policy
430
+ // construction order-independent — each distinct evidence signature gets its own policy, and
431
+ // each claim references its own via verificationPolicyId (Surface's resolvePolicyForClaim
432
+ // honors verificationPolicyId first, so same-claimType policies never cross-resolve).
433
+ // Merging is NOT used because Surface's requiredEvidence is all-of (`.every`), so a union
434
+ // would over-constrain both claims.
226
435
  const policies = new Map();
227
436
  const ensurePolicy = (claimType, impactLevel, requiredEvidence) => {
228
- let p = policies.get(claimType);
437
+ const reqSorted = [...new Set(requiredEvidence)].sort();
438
+ const key = `${claimType}::${reqSorted.join(",")}`;
439
+ let p = policies.get(key);
229
440
  if (!p) {
230
- p = { id: `policy:${claimType}`, claimType, requiredEvidence, acceptanceCriteria: [`A verified verification event must support a ${claimType} claim.`], reviewAuthority: "system", validityRule: { kind: "manual" }, stalenessTriggers: [], conflictRules: [], impactLevel };
231
- policies.set(claimType, p);
441
+ const id = reqSorted.length ? `policy:${claimType}:${reqSorted.join("+")}` : `policy:${claimType}`;
442
+ p = { id, claimType, requiredEvidence: reqSorted, acceptanceCriteria: [`A verified verification event must support a ${claimType} claim.`], reviewAuthority: "system", validityRule: { kind: "manual" }, stalenessTriggers: [], conflictRules: [], impactLevel };
443
+ policies.set(key, p);
232
444
  }
233
445
  return p;
234
446
  };
@@ -349,37 +561,64 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
349
561
  const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
350
562
  const evId = `ev:${claimId}`;
351
563
  const legacyClaimType = `workflow.check.${check.kind ?? "external"}`;
352
- const policy = ensurePolicy(legacyClaimType, "high", ["test_output"]);
353
564
  const cmd = typeof check.command === "string" ? check.command.replace(/\s+/g, " ").trim() : "";
354
565
  const captured = cmd ? captureByCommand.get(cmd) : undefined;
566
+ // WS8 (ADR 0020): classify evidence from check.kind instead of hardcoding test_output.
567
+ // A waived accepted-gap (check._waiver, set by record-evidence/record-gate-claim
568
+ // --accepted-gap-reason/--waived-by) is by definition session-local: it is an
569
+ // attested accepted gap, never a CI-re-runnable command, so it is classified
570
+ // attestation/attestation and its claim status is forced to `assumed` (reusing the
571
+ // existing accepted_gap -> assumed mapping — no new status value).
572
+ const waiver = (check._waiver && typeof check._waiver === "object") ? check._waiver : null;
573
+ const evClass = waiver
574
+ ? { evidenceType: "attestation", method: "attestation", reconcilable: false }
575
+ : classifyEvidence(check.kind, cmd.length > 0);
576
+ const policy = ensurePolicy(legacyClaimType, "high", [evClass.evidenceType]);
355
577
  const effectiveStatus = captured ? captured.observedResult : String(check.status ?? "");
356
- const evStatus = checkStatusToEventStatus(effectiveStatus);
578
+ const evStatus = waiver ? "assumed" : checkStatusToEventStatus(effectiveStatus);
579
+ // Promotion claim marker (issue #312): a `promote` check carries a session-local
580
+ // _promotion object that must survive onto claim.metadata.promotion so the archive gate
581
+ // (workflow-artifact-cleanup-audit) and validators can detect the promotion claim without a
582
+ // new manifest entry. It rides alongside any waiver in a single merged metadata object.
583
+ const promotionMeta = (check._promotion && typeof check._promotion === "object") ? check._promotion : null;
584
+ // #268: stamp a stable origin discriminator so checksFromBundle / critiquesFromBundle can
585
+ // distinguish check vs critique vs acceptance claims across round-trips even under --flow-id,
586
+ // where all three collapse onto the same declared claimType (and a command-less critique claim
587
+ // would otherwise be re-absorbed as a test_output check → permanent [not-run] divergence).
588
+ const claimMetadata = { origin: "check", check_kind: String(check.kind ?? "external"), ...(waiver ? { waiver } : {}), ...(promotionMeta ? { promotion: promotionMeta } : {}) };
357
589
  const claimEvents = [];
358
590
  if (evStatus) {
359
591
  const evt = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [evId], createdAt: ts, verifiedAt: ts };
360
592
  events.push(evt);
361
593
  claimEvents.push(evt);
362
594
  }
363
- const evItem = { id: evId, claimId, evidenceType: "test_output", method: "validation", sourceRef: `${slug}/evidence.json`, excerptOrSummary: fieldOrBehavior, observedAt: ts, collectedBy: "flow-agents/workflow-sidecar", passing: effectiveStatus === "pass" };
595
+ const evItem = { 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" };
364
596
  if (captured) {
365
597
  evItem.sourceRef = `${slug}/command-log.jsonl`;
366
598
  evItem.collectedBy = "flow-agents/evidence-capture";
367
599
  evItem.execution = { runner: "bash", label: cmd, isError: captured.observedResult === "fail", ...(captured.exitCode != null ? { exitCode: captured.exitCode } : {}) };
368
600
  }
601
+ else if (cmd && !waiver) {
602
+ // WS8 (ADR 0020): always stamp execution.label on command-backed checks so the CI
603
+ // reconciler has a stable key to match against the manifest, even when the local
604
+ // command-log capture did not happen to run this command. isError is derived from
605
+ // the check's own reported status (no captured exit code available in this path).
606
+ evItem.execution = { runner: "bash", label: cmd, isError: effectiveStatus !== "pass" };
607
+ }
369
608
  evidenceItems.push(evItem);
370
609
  // P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
371
610
  // When record-gate-claim sets _gate_claim_expectation_id, pass it for exact lookup (ADR 0016 P-d Increment 2).
372
611
  const declared = matchExpectsEntry("check", check.kind, typeof check._gate_claim_expectation_id === "string" ? check._gate_claim_expectation_id : undefined);
373
612
  if (declared) {
374
613
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
375
- const declaredPolicy = ensurePolicy(declared.claimType, "high", ["test_output"]);
376
- const declaredClaimObj = { 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 };
614
+ const declaredPolicy = ensurePolicy(declared.claimType, "high", [evClass.evidenceType]);
615
+ const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, ...(claimMetadata ? { metadata: claimMetadata } : {}) };
377
616
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [evItem], events: claimEvents, policies: [declaredPolicy] });
378
617
  claims.push({ ...declaredClaimObj, status: declaredStatus });
379
618
  }
380
619
  else {
381
620
  // No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
382
- const claimObj = { id: claimId, subjectType: "workflow-check", subjectId, surface: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id };
621
+ const claimObj = { 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 } : {}) };
383
622
  const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [evItem], events: claimEvents, policies: [policy] });
384
623
  claims.push({ ...claimObj, status: derivedStatus });
385
624
  }
@@ -405,13 +644,13 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
405
644
  if (declared) {
406
645
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
407
646
  const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
408
- const declaredClaimObj = { 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 };
647
+ const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, metadata: { origin: "acceptance" } };
409
648
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [], events: claimEvents, policies: [declaredPolicy] });
410
649
  claims.push({ ...declaredClaimObj, status: declaredStatus });
411
650
  }
412
651
  else {
413
652
  // No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
414
- const claimObj = { 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 };
653
+ const claimObj = { 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" } };
415
654
  const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [policy] });
416
655
  claims.push({ ...claimObj, status: derivedStatus });
417
656
  }
@@ -422,10 +661,23 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
422
661
  continue;
423
662
  const subjectId = `${slug}/${c.id}`;
424
663
  const fieldOrBehavior = String(c.summary ?? c.verdict ?? c.id);
425
- const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
664
+ // #267/#282: a critique carrying `superseded_by` is retained as HISTORY — a prior write for
665
+ // this critique id that a later, same-reviewer critique resolved. It is preserved structurally
666
+ // (status "superseded" + first-class metadata.superseded_by), but is excluded from reconcile
667
+ // evaluation and from the "critique pass cannot include fail members" validator rule.
668
+ const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
669
+ const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
670
+ const critiqueReviewedAt = String(c.reviewed_at ?? ts);
671
+ const critMeta = { origin: "critique", reviewer: critiqueReviewer, reviewed_at: critiqueReviewedAt, ...(supersededBy ? { superseded_by: supersededBy } : {}) };
672
+ // A superseded historical write gets a distinct, stable claimId so it co-exists with the live
673
+ // claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
674
+ // across rebuilds because superseded_by + reviewed_at are preserved in metadata.
675
+ const claimIdSalt = supersededBy ? `${fieldOrBehavior}::superseded::${supersededBy}::${critiqueReviewedAt}` : fieldOrBehavior;
676
+ const claimId = generateClaimId(subjectId, "flow-agents.workflow", claimIdSalt);
426
677
  const legacyClaimType = "workflow.critique.review";
427
678
  const policy = ensurePolicy(legacyClaimType, "medium", []);
428
- const evStatus = critiqueToEventStatus(String(c.verdict ?? ""), c.findings ?? []);
679
+ // A superseded write emits NO verification event (its status is "superseded" directly).
680
+ const evStatus = supersededBy ? null : critiqueToEventStatus(String(c.verdict ?? ""), c.findings ?? []);
429
681
  const claimEvents = [];
430
682
  if (evStatus) {
431
683
  const evt = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [], createdAt: ts, verifiedAt: ts };
@@ -434,22 +686,21 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
434
686
  }
435
687
  // P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
436
688
  const declared = matchExpectsEntry("critique");
437
- if (declared) {
438
- // Declared kit-typed claim only no legacy shadow (ADR 0016 P-d).
439
- const declaredPolicy = ensurePolicy(declared.claimType, "medium", []);
440
- const declaredClaimObj = { 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 };
441
- const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [], events: claimEvents, policies: [declaredPolicy] });
442
- claims.push({ ...declaredClaimObj, status: declaredStatus });
689
+ const claimType = declared ? declared.claimType : legacyClaimType;
690
+ const subjectType = declared ? declared.subjectType : "workflow-critique";
691
+ const claimPolicy = declared ? ensurePolicy(declared.claimType, "medium", []) : policy;
692
+ const claimObj = { id: claimId, subjectType, subjectId, facet: "flow-agents.workflow", claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: claimPolicy.id, metadata: critMeta };
693
+ if (supersededBy) {
694
+ // History: status is "superseded" directly (no verification event); excluded from evaluation.
695
+ claims.push({ ...claimObj, status: "superseded" });
443
696
  }
444
697
  else {
445
- // No active flow step only the workflow.* primary claim (legitimate no-flow fallback path).
446
- const claimObj = { 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 };
447
- const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [policy] });
698
+ const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [claimPolicy] });
448
699
  claims.push({ ...claimObj, status: derivedStatus });
449
700
  }
450
701
  }
451
702
  return {
452
- schemaVersion: 3,
703
+ schemaVersion: 5,
453
704
  source: `flow-agents/workflow-sidecar;statusFunctionVersion=${statusFunctionVersion}`,
454
705
  claims,
455
706
  evidence: evidenceItems,
@@ -485,7 +736,7 @@ export async function writeTrustBundle(dir, slug, timestamp, checks, criteria, c
485
736
  } }).filter((x) => x !== null);
486
737
  }
487
738
  catch { /* no capture log — fine */ }
488
- // ADR 0016 Abstraction A (P-d): pass the .flow-agents dir ONLY when current.json
739
+ // ADR 0016 Abstraction A (P-d): pass the runtime artifact root ONLY when current.json
489
740
  // points to this session (scoped active-flow guard). If current.json.artifact_dir
490
741
  // resolves to a different session, pass null — no active-flow claim mapping for this bundle.
491
742
  const _flowAgentsDir = path.dirname(dir);
@@ -726,6 +977,19 @@ function definitionAcceptanceLines(markdown) {
726
977
  }
727
978
  return out;
728
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, name) {
990
+ const re = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*(.+)$`, "m");
991
+ return re.exec(markdown)?.[1]?.trim() ?? "";
992
+ }
729
993
  function parseCriterion(line, index) {
730
994
  let text = line.replace(/^-\s+\[[ xX]\]\s*/, "").trim();
731
995
  const m = /\s+-\s+Evidence:\s*(.+)$/i.exec(text);
@@ -770,14 +1034,20 @@ function validateAgentId(agent) {
770
1034
  }
771
1035
  /**
772
1036
  * Find the repository root by walking upward from a starting directory to locate
773
- * the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
774
- * findRepoRoot, but callable from workflow-sidecar.ts without re-importing the
775
- * internal helper.
1037
+ * the nearest ancestor containing a kits/ subdirectory, without falling back to
1038
+ * process.cwd(). Returns null when no kits/ ancestor is found within the walk
1039
+ * bound (e.g. a scratch/temp session directory with no repo ancestor at all).
776
1040
  *
777
- * ADR 0016 Abstraction A (P-d): used by advance-state and ensure-session to
778
- * derive repoRoot for resolvePhaseMap calls.
1041
+ * WS5 iteration-2 part 2: extracted so publishDelivery's repo-root resolution can
1042
+ * be fail-closed (see findRepoRootFromDir below and publishDelivery). A scratch
1043
+ * test session dir (mktemp -d, no kits/ ancestor) must never resolve to whatever
1044
+ * repo happens to be the current process's cwd — that previously let a throwaway
1045
+ * eval-local trust.bundle silently clobber this real repo's delivery/trust.bundle
1046
+ * when the eval was run from a checkout of this repo (see
1047
+ * evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings
1048
+ * at .kontourai/flow-agents/ws5-governance-kit-slice1).
779
1049
  */
780
- function findRepoRootFromDir(startDir) {
1050
+ function findRepoRootFromDirStrict(startDir) {
781
1051
  let dir = startDir;
782
1052
  for (let i = 0; i < 16; i++) {
783
1053
  if (fs.existsSync(path.join(dir, "kits")))
@@ -787,7 +1057,23 @@ function findRepoRootFromDir(startDir) {
787
1057
  break;
788
1058
  dir = parent;
789
1059
  }
790
- return process.cwd();
1060
+ return null;
1061
+ }
1062
+ /**
1063
+ * Find the repository root by walking upward from a starting directory to locate
1064
+ * the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
1065
+ * findRepoRoot, but callable from workflow-sidecar.ts without re-importing the
1066
+ * internal helper. Falls back to process.cwd() when no kits/ ancestor is found —
1067
+ * appropriate for phase-map/first-step resolution (ADR 0016 Abstraction A, P-d),
1068
+ * where the caller is always invoked from within a real repo checkout.
1069
+ *
1070
+ * Do NOT use this cwd-falling-back variant for publishDelivery's repo-root
1071
+ * resolution — use findRepoRootFromDirStrict there instead, so a scratch/test
1072
+ * session dir with no repo ancestor fails closed (skips publish) rather than
1073
+ * silently trusting process.cwd(), which could be an unrelated real repo.
1074
+ */
1075
+ function findRepoRootFromDir(startDir) {
1076
+ return findRepoRootFromDirStrict(startDir) ?? process.cwd();
791
1077
  }
792
1078
  /**
793
1079
  * Resolve the first step id from a FlowDefinition's steps[] list.
@@ -824,7 +1110,11 @@ function resolveFirstStep(flowId, repoRoot) {
824
1110
  return null;
825
1111
  }
826
1112
  }
827
- function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId) {
1113
+ function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId, adHocReason) {
1114
+ // #289: mirror the active session's already-recorded branch (state.json.branch) into
1115
+ // current.json so consumers of current.json (which has no schema of its own — not one of the
1116
+ // 9 schemas under schemas/) see the routing branch without re-reading state.json separately.
1117
+ const branch = loadJson(path.join(dir, "state.json")).branch;
828
1118
  writeJson(path.join(root, "current.json"), {
829
1119
  schema_version: "1.0",
830
1120
  active_slug: path.basename(dir),
@@ -833,11 +1123,17 @@ function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId) {
833
1123
  owner,
834
1124
  source,
835
1125
  active_agents: [],
1126
+ ...(branch ? { branch } : {}),
836
1127
  // ADR 0016 Abstraction A (P-a): optional FlowDefinition routing keys for the producer
837
1128
  // and enforcer. Both fields are optional and backward-compatible — sessions without a
838
1129
  // FlowDefinition omit them and fall through to the workflow.* claim type path.
839
1130
  ...(flowId ? { active_flow_id: flowId } : {}),
840
1131
  ...(stepId ? { active_step_id: stepId } : {}),
1132
+ // WS8 (AC12): sanctioned ad-hoc direct entry marker. Set when --step-id explicitly
1133
+ // targets a step other than the flow's resolved first step, so stop-goal-fit /
1134
+ // gate-review can distinguish an intentional direct entry (e.g. a planning-only
1135
+ // session that skips pull-work) from a stale/mis-stamped active_step_id.
1136
+ ...(adHocReason ? { ad_hoc_entry: true, ad_hoc_reason: adHocReason } : {}),
841
1137
  });
842
1138
  }
843
1139
  function loadCurrent(root) {
@@ -851,7 +1147,15 @@ function currentDir(root) {
851
1147
  if (!c)
852
1148
  return null;
853
1149
  const dir = path.resolve(root, c.artifact_dir ?? c.active_slug ?? "");
854
- return fs.existsSync(dir) ? dir : null;
1150
+ if (!fs.existsSync(dir))
1151
+ return null;
1152
+ try {
1153
+ requireArtifactDirUnderRoot(dir, root);
1154
+ }
1155
+ catch {
1156
+ return null;
1157
+ }
1158
+ return dir;
855
1159
  }
856
1160
  function updateCurrentAgent(root, dir, agentId, status, timestamp) {
857
1161
  const cur = loadCurrent(root);
@@ -866,8 +1170,38 @@ function updateCurrentAgent(root, dir, agentId, status, timestamp) {
866
1170
  }
867
1171
  function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp, markdown) {
868
1172
  const criteria = markdown ? definitionAcceptanceLines(markdown).map(parseCriterion) : [];
1173
+ // #289/#309: `markdown` here is NOT always the session `<slug>--deliver.md` that
1174
+ // ensureSession seeds the `branch:` line into — initPlan is called against the tool-planner's
1175
+ // PLAN artifact (`<slug>--plan-work.md` etc.), a different file that typically carries no
1176
+ // `branch:` line at all. Since this function fully rewrites state.json (no merge with the
1177
+ // prior contents), naively re-deriving branch from `markdown` alone would clobber whatever
1178
+ // ensure-session already recorded there moments earlier (#309 regression). Resolve branch with
1179
+ // a three-tier fallback, preferring the most durable source first:
1180
+ // 1. EXISTING state.json.branch — once recorded, it is never re-derived or clobbered.
1181
+ // 2. The session's OWN canonical `<dir>/<slug>--deliver.md` on disk — the file ensureSession
1182
+ // always seeds the `branch:` line into, independent of whatever `markdown` was passed in.
1183
+ // Reading it directly (rather than trusting `markdown`) is what makes a repaired/backfilled
1184
+ // init-plan call (re-run against the same branch-less plan artifact, after a #309-era
1185
+ // session lost its state.json branch) recover the branch without a direct file edit.
1186
+ // 3. The `markdown` param itself (covers ensureSession's fresh-creation call, before
1187
+ // `<slug>--deliver.md` differs from `markdown`, and any direct/legacy caller that passes
1188
+ // the deliver markdown itself as the init-plan artifact).
1189
+ // This makes the branch survive every subsequent initSidecars call (init-plan, or a resumed
1190
+ // ensure-session) at this single choke point, without patching every other writer.
1191
+ const existingState = loadJson(path.join(dir, "state.json"));
1192
+ const existingBranch = existingState.branch;
1193
+ const deliverMdPath = path.join(dir, `${slug}--deliver.md`);
1194
+ const deliverBranch = fs.existsSync(deliverMdPath) ? markdownField(read(deliverMdPath), "branch") : "";
1195
+ const branch = existingBranch || deliverBranch || (markdown ? markdownField(markdown, "branch") : "");
1196
+ // #309 (scope addition): created_at is write-once, same class as the branch-drop bug above —
1197
+ // initSidecars fully rewrites state.json on every call (including a repair/backfill re-run of
1198
+ // init-plan against an already-existing session), so naively re-stamping created_at from the
1199
+ // current call's timestamp silently rewrites a session's original creation time. Preserve the
1200
+ // existing state.json's created_at when present; stamp only on true first-creation.
1201
+ // updated_at still reflects "now" on every call — that field is intentionally mutable.
869
1202
  writeJson(path.join(dir, "state.json"), {
870
- ...sidecarBase(slug), status: "planned", phase: "planning", created_at: timestamp, updated_at: timestamp,
1203
+ ...sidecarBase(slug), status: "planned", phase: "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
1204
+ ...(branch ? { branch } : {}),
871
1205
  artifact_paths: relArtifacts(dir),
872
1206
  next_action: { status: "continue", summary: nextAction || summary },
873
1207
  });
@@ -888,7 +1222,12 @@ function ensureSession(p) {
888
1222
  const timestamp = opt(p, "timestamp", now());
889
1223
  let md = fs.existsSync(path.join(dir, `${slug}--deliver.md`)) ? read(path.join(dir, `${slug}--deliver.md`)) : "";
890
1224
  if (!md) {
891
- 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`;
1225
+ // #289: derive the routing branch ONLY on fresh session creation (this `if (!md)` guard).
1226
+ // An existing session's already-recorded `branch:` line is never touched — that whole branch
1227
+ // of code is skipped on a resumed/taken-over session, which is what makes ADR 0021 §5
1228
+ // takeover continuity true by construction (see Design Decision 3 in the plan).
1229
+ const branch = resolveSessionBranch(p, slug);
1230
+ 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`;
892
1231
  fs.writeFileSync(path.join(dir, `${slug}--deliver.md`), md);
893
1232
  }
894
1233
  if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
@@ -902,14 +1241,28 @@ function ensureSession(p) {
902
1241
  // ensure-session --flow-id builder.build produces a FlowDefinition-driven session even
903
1242
  // before the first advance-state call.
904
1243
  const flowId = opt(p, "flow-id");
905
- let stepId = opt(p, "step-id");
1244
+ const explicitStep = opt(p, "step-id");
1245
+ let stepId = explicitStep;
1246
+ let adHocReason;
906
1247
  if (flowId && !stepId) {
907
1248
  const repoRoot = findRepoRootFromDir(dir);
908
1249
  const firstStep = resolveFirstStep(flowId, repoRoot);
909
1250
  if (firstStep)
910
1251
  stepId = firstStep;
911
1252
  }
912
- writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined);
1253
+ else if (flowId && explicitStep) {
1254
+ // WS8 (AC12): --step-id is the sanctioned ad-hoc direct-entry mechanism. When it names
1255
+ // a step other than the flow's resolved first step, record an explicit ad_hoc_entry
1256
+ // marker (with a reason) instead of silently letting the mis-stamp look like the
1257
+ // flow's normal first step. This is the root-cause fix for a planning-only session
1258
+ // whose active_step_id would otherwise default to builder.build's first step.
1259
+ const repoRoot = findRepoRootFromDir(dir);
1260
+ const firstStep = resolveFirstStep(flowId, repoRoot);
1261
+ if (firstStep && firstStep !== explicitStep) {
1262
+ adHocReason = opt(p, "ad-hoc-reason") || `direct entry at step "${explicitStep}" via --step-id (flow first step is "${firstStep}")`;
1263
+ }
1264
+ }
1265
+ writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined, adHocReason);
913
1266
  console.log(dir);
914
1267
  return 0;
915
1268
  }
@@ -931,7 +1284,7 @@ function recordAgentEvent(p) {
931
1284
  die("artifact directory does not exist");
932
1285
  if (explicit && fs.lstatSync(dir).isSymbolicLink())
933
1286
  die(`artifact directory must not be a symlink: ${dir}`);
934
- if (hasExplicitRoot)
1287
+ if (hasExplicitRoot || !explicit)
935
1288
  requireArtifactDirUnderRoot(dir, root);
936
1289
  const timestamp = opt(p, "timestamp", now());
937
1290
  const agent = validateAgentId(opt(p, "agent-id"));
@@ -1014,7 +1367,7 @@ export function normalizeCheck(raw) {
1014
1367
  if (!check.id || !check.kind || !check.status || !check.summary)
1015
1368
  die("check requires id, kind, status, and summary");
1016
1369
  if (!checkKinds.has(check.kind))
1017
- die("kind must be one of: build, types, lint, test, security, diff, browser, runtime, policy, external");
1370
+ die("kind must be one of: build, types, lint, test, command, security, diff, browser, runtime, policy, external");
1018
1371
  if (!checkStatuses.has(check.status))
1019
1372
  die("status must be one of: pass, fail, not_verified, skip");
1020
1373
  if (Array.isArray(check.standard_refs))
@@ -1167,67 +1520,70 @@ export function writeState(dir, slug, status, phase, timestamp, summary, next =
1167
1520
  // After 4c, evidence.json and critique.json are no longer written.
1168
1521
  // Extract checks and critiques from the existing trust.bundle for callers that
1169
1522
  // need to rebuild the bundle (e.g. record-critique, record-learning).
1170
- // ADR 0016 Abstraction A (Step 0 Q3 carry-forward): build the set of declared
1171
- // claimTypes from the active flow step for the session at `dir`. When no active
1172
- // flow is present (workflow.* sessions), returns an empty set so every existing
1173
- // predicate is unchanged. When a FlowDefinition-driven session (builder.build)
1174
- // is active, the set contains the kit-typed claimTypes (e.g. "builder.verify.tests",
1175
- // "builder.verify.policy-compliance") so round-trip helpers broaden their filters
1176
- // to include declared claims alongside the legacy workflow.* ones.
1523
+ // #268/#344: buildTrustBundle stamps a stable origin discriminator ("check" | "acceptance" |
1524
+ // "critique") plus check_kind (for origin "check") on EVERY claim it writes. These stamps are
1525
+ // AUTHORITATIVE and the ONLY way checksFromBundle/critiquesFromBundle (and evidenceClean/
1526
+ // critiqueClean below) classify a claim.
1177
1527
  //
1178
- // Safety guard: current.json in the .flow-agents dir records the CURRENTLY ACTIVE
1179
- // session via artifact_dir. If current.json points to a different session than `dir`
1180
- // (e.g. another session was the last to call advance-state --flow-definition), we
1181
- // return an empty set so declared-type predicates are NOT applied to the wrong session.
1182
- // This prevents a cross-session active_flow_id from broadening claim filters for
1183
- // unrelated sessions (which would cause spurious evidence/critique check behavior).
1184
- function declaredClaimTypesFor(dir) {
1185
- const flowAgentsDir = path.dirname(dir);
1186
- // Verify that current.json points to `dir` before reading active flow step.
1187
- // If it points to a different session, return empty set (zero behavior change).
1188
- const currentFile = path.join(flowAgentsDir, "current.json");
1189
- try {
1190
- const current = JSON.parse(fs.readFileSync(currentFile, "utf8"));
1191
- const artDir = typeof current["artifact_dir"] === "string" ? current["artifact_dir"] : null;
1192
- if (!artDir)
1193
- return new Set();
1194
- const resolvedCurrent = path.resolve(flowAgentsDir, artDir);
1195
- if (path.resolve(dir) !== resolvedCurrent)
1196
- return new Set();
1197
- }
1198
- catch {
1199
- return new Set();
1528
+ // Hard cutover (owner directive, no legacy fallbacks): there is deliberately no claimType-
1529
+ // derivation fallback for unstamped claims. A prior version of this file fell back to
1530
+ // classifying an unstamped claim by claimType heuristic that fallback WAS the #268 defect
1531
+ // kept reachable (a no-evidence declared claim silently re-absorbed as a command-less
1532
+ // test_output check, and a critique claim silently re-absorbed as a check, corrupting the
1533
+ // round-trip catastrophically under --flow-id). An unstamped claim means the bundle predates
1534
+ // #344 and must be regenerated, not silently reclassified — see requireStampedClaim below.
1535
+ function claimOrigin(claim) {
1536
+ const md = claim && claim.metadata;
1537
+ return md && typeof md === "object" && typeof md.origin === "string" && md.origin.length > 0 ? String(md.origin) : null;
1538
+ }
1539
+ // Fails loud — never silent, never a heuristic reclassification — when a claim in `dir`'s
1540
+ // trust.bundle lacks its metadata.origin stamp, or (for an origin==="check" claim) its
1541
+ // metadata.check_kind stamp. Names the session dir and the remedy so the caller can regenerate
1542
+ // a fresh, fully-stamped bundle instead of reading a pre-supersession one.
1543
+ function requireStampedClaim(claim, dir) {
1544
+ if (!claim || typeof claim !== "object")
1545
+ die(`trust.bundle in ${dir} contains a malformed claim entry — cannot read.`);
1546
+ const remedy = `re-record evidence to regenerate: npm run workflow:sidecar -- record-evidence ${dir} --verdict <verdict> --check-json <...>`;
1547
+ const origin = claimOrigin(claim);
1548
+ if (!origin) {
1549
+ 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}`);
1550
+ }
1551
+ if (origin === "check") {
1552
+ const md = (claim.metadata && typeof claim.metadata === "object") ? claim.metadata : {};
1553
+ if (typeof md.check_kind !== "string" || md.check_kind.length === 0) {
1554
+ 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}`);
1555
+ }
1200
1556
  }
1201
- const activeStep = resolveActiveFlowStep(flowAgentsDir);
1202
- if (!activeStep || activeStep.gateExpects.length === 0)
1203
- return new Set();
1204
- return new Set(activeStep.gateExpects.map((e) => e.bundle_claim.claimType));
1557
+ return origin;
1205
1558
  }
1206
- function checksFromBundle(dir, declaredClaimTypes = new Set()) {
1559
+ function checksFromBundle(dir) {
1207
1560
  const bundle = loadJson(path.join(dir, "trust.bundle"));
1561
+ const allClaims = Array.isArray(bundle.claims) ? bundle.claims : [];
1562
+ // Validate stamps on every claim up front — any unstamped claim anywhere in the bundle marks
1563
+ // it pre-supersession, regardless of whether it is check/acceptance/critique-typed.
1564
+ for (const claim of allClaims)
1565
+ requireStampedClaim(claim, dir);
1208
1566
  if (!Array.isArray(bundle.evidence))
1209
1567
  return [];
1210
- const allClaims = Array.isArray(bundle.claims) ? bundle.claims : [];
1211
1568
  const claimById = new Map();
1212
1569
  for (const c of allClaims)
1213
1570
  if (c && c.id)
1214
1571
  claimById.set(c.id, c);
1215
1572
  const seen = new Set();
1216
1573
  const checks = [];
1574
+ const kindOf = (claim) => String(claim.metadata.check_kind);
1217
1575
  for (const ev of bundle.evidence) {
1218
1576
  if (!ev || !ev.claimId)
1219
1577
  continue;
1220
1578
  const claim = claimById.get(ev.claimId);
1221
1579
  if (!claim)
1222
1580
  continue;
1223
- const ct = String(claim.claimType || "");
1224
- // ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
1225
- if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct))
1581
+ if (claimOrigin(claim) !== "check")
1226
1582
  continue;
1227
1583
  if (seen.has(ev.claimId))
1228
1584
  continue;
1229
1585
  seen.add(ev.claimId);
1230
- const kind = ct.startsWith("workflow.check.") ? (ct.replace("workflow.check.", "") || "external") : (ct.split(".").pop() || "external");
1586
+ const kind = kindOf(claim);
1231
1587
  const status = claim.value ?? "not_verified";
1232
1588
  const check = { id: String(claim.subjectId || "").split("/").pop() || ev.claimId, kind, status, summary: claim.fieldOrBehavior || "" };
1233
1589
  if (ev.execution && typeof ev.execution.label === "string")
@@ -1236,50 +1592,88 @@ function checksFromBundle(dir, declaredClaimTypes = new Set()) {
1236
1592
  check.evidenceType = ev.evidenceType;
1237
1593
  checks.push(check);
1238
1594
  }
1239
- // Also include check claims that have no evidence item (surface_trust_refs style)
1595
+ // Also include check claims that have no evidence item (surface_trust_refs style).
1240
1596
  for (const claim of allClaims) {
1241
1597
  if (!claim)
1242
1598
  continue;
1243
- const ct = String(claim.claimType || "");
1244
- // ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
1245
- if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct))
1599
+ if (claimOrigin(claim) !== "check")
1246
1600
  continue;
1247
1601
  if (seen.has(claim.id))
1248
1602
  continue;
1249
1603
  seen.add(claim.id);
1250
- const kind = ct.startsWith("workflow.check.") ? (ct.replace("workflow.check.", "") || "external") : (ct.split(".").pop() || "external");
1604
+ const kind = kindOf(claim);
1251
1605
  checks.push({ id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" });
1252
1606
  }
1253
1607
  return checks;
1254
1608
  }
1255
- function critiquesFromBundle(dir, declaredClaimTypes = new Set()) {
1609
+ function critiquesFromBundle(dir) {
1256
1610
  const bundle = loadJson(path.join(dir, "trust.bundle"));
1257
1611
  if (!Array.isArray(bundle.claims))
1258
1612
  return [];
1259
- // ADR 0016 Step 0: broaden to include declared kit-typed critique claims alongside workflow.critique.review.
1260
- // P-d: exclude claims that have evidence items (evidence = check claims, not critique claims).
1261
- // This prevents check-type declared claims (e.g. builder.verify.tests) from being read back
1262
- // as critiques when declaredClaimTypes includes all gate expects[] types.
1263
- const evidenceClaimIds = new Set(Array.isArray(bundle.evidence) ? bundle.evidence.map((e) => e?.claimId).filter((id) => typeof id === "string") : []);
1264
- const critiqueClaims = bundle.claims.filter((c) => c && (c.claimType === "workflow.critique.review" || declaredClaimTypes.has(c.claimType)) && !evidenceClaimIds.has(c.id));
1265
- return critiqueClaims.map((c) => ({
1266
- id: String(c.subjectId || "").split("/").pop() || c.id,
1267
- verdict: c.value ?? "not_verified",
1268
- summary: c.fieldOrBehavior || "",
1269
- findings: [],
1270
- reviewer: "tool-code-reviewer",
1271
- reviewed_at: c.updatedAt || c.createdAt || now(),
1272
- artifact_refs: [],
1273
- }));
1613
+ for (const c of bundle.claims)
1614
+ requireStampedClaim(c, dir);
1615
+ // A claim is a CRITIQUE when its origin is "critique" (authoritative — see requireStampedClaim
1616
+ // above). reviewer / reviewed_at / superseded_by are read back from metadata so supersession
1617
+ // (#267/#282) round-trips losslessly.
1618
+ const critiqueClaims = bundle.claims.filter((c) => c && claimOrigin(c) === "critique");
1619
+ return critiqueClaims.map((c) => {
1620
+ const md = (c.metadata && typeof c.metadata === "object") ? c.metadata : {};
1621
+ return {
1622
+ id: String(c.subjectId || "").split("/").pop() || c.id,
1623
+ verdict: c.value ?? "not_verified",
1624
+ summary: c.fieldOrBehavior || "",
1625
+ findings: [],
1626
+ reviewer: typeof md.reviewer === "string" ? md.reviewer : "tool-code-reviewer",
1627
+ reviewed_at: typeof md.reviewed_at === "string" ? md.reviewed_at : (c.updatedAt || c.createdAt || now()),
1628
+ artifact_refs: [],
1629
+ ...(typeof md.superseded_by === "string" && md.superseded_by.length > 0 ? { superseded_by: md.superseded_by } : {}),
1630
+ };
1631
+ });
1274
1632
  }
1275
1633
  // ─────────────────────────────────────────────────────────────────────────────
1634
+ /**
1635
+ * WS8 (ADR 0020): parse the accepted-gap waiver flags. Both --accepted-gap-reason and
1636
+ * --waived-by are required together (an accepted gap with no justification or no
1637
+ * approver is refused — no silent/default waiver). Returns the waiver record to stamp
1638
+ * onto claim.metadata.waiver, or null when neither flag is present. Reuses the existing
1639
+ * accepted_gap -> assumed status mapping; adds no new canonical status value.
1640
+ */
1641
+ function parseWaiver(p, ts) {
1642
+ const reason = opt(p, "accepted-gap-reason");
1643
+ const waivedBy = opt(p, "waived-by");
1644
+ if (!reason && !waivedBy)
1645
+ return null;
1646
+ if (!reason)
1647
+ die("--accepted-gap-reason is required when --waived-by is set (an accepted gap must carry its justification)");
1648
+ if (!waivedBy)
1649
+ die("--waived-by is required when --accepted-gap-reason is set (an accepted gap must name its approver)");
1650
+ return { reason, approved_by: waivedBy, approved_at: ts };
1651
+ }
1276
1652
  async function recordEvidence(p) {
1277
1653
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1278
1654
  const verdict = opt(p, "verdict") || die("--verdict is required");
1279
1655
  if (!verdicts.has(verdict))
1280
1656
  die("verdict must be one of: pass, partial, fail, not_verified");
1281
1657
  const slug = taskSlugFor(dir, opt(p, "task-slug"));
1282
- const checks = [...opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"))), ...opts(p, "surface-trust-json").map(surfaceCheckFromArtifact)];
1658
+ const _ts0 = opt(p, "timestamp", now());
1659
+ const _waiver = parseWaiver(p, _ts0);
1660
+ const _checksRaw = [...opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"))), ...opts(p, "surface-trust-json").map(surfaceCheckFromArtifact)];
1661
+ // WS8 (AC4, iteration 2): a command-backed check reconciles against CI or fails — it can
1662
+ // NEVER be waived. Reject --accepted-gap-reason/--waived-by on any check whose evidence
1663
+ // classifies as test_output (build/types/lint/test/command, and security/browser/runtime
1664
+ // WHEN a command is present). Only session-local checks (attestation/observation/citation/
1665
+ // diff/policy) are waivable. The CI reconciler enforces the same rule server-side (a waiver
1666
+ // on test_output evidence is a divergence), so this producer-side guard is defense-in-depth,
1667
+ // not the sole gate. (ADR 0020)
1668
+ if (_waiver) {
1669
+ for (const c of _checksRaw) {
1670
+ const hasCmd = typeof c.command === "string" && String(c.command).trim().length > 0;
1671
+ if (classifyEvidence(c.kind, hasCmd).evidenceType === "test_output") {
1672
+ die(`--accepted-gap-reason/--waived-by cannot be applied to a command-backed check (kind='${String(c.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)`);
1673
+ }
1674
+ }
1675
+ }
1676
+ const checks = _checksRaw.map((c) => _waiver ? { ...c, _waiver } : c);
1283
1677
  if (!checks.length && opts(p, "surface-trust-json").length === 0)
1284
1678
  die("record-evidence requires at least one --check-json or --surface-trust-json");
1285
1679
  validateAcceptanceEvidenceRefs(dir);
@@ -1289,7 +1683,11 @@ async function recordEvidence(p) {
1289
1683
  const _existingCriteria = Array.isArray(_existingAcceptance.criteria) ? _existingAcceptance.criteria : [];
1290
1684
  const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
1291
1685
  const _criteriaForBundle = _existingCriteria.map((c) => ({ ...c, status: _criteriaStatus }));
1292
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, []));
1686
+ // #268: preserve any existing critique claims (including superseded history) instead of dropping
1687
+ // them — record-evidence previously hardcoded critiques:[] here, silently erasing finding history
1688
+ // whenever it ran after a critique existed.
1689
+ const _existingCritiques = critiquesFromBundle(dir);
1690
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, _existingCritiques));
1293
1691
  const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
1294
1692
  writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
1295
1693
  return 0;
@@ -1377,11 +1775,100 @@ async function recordGateClaim(p) {
1377
1775
  check.artifact_refs = evidenceRefs;
1378
1776
  }
1379
1777
  const checkNormalized = normalizeCheck(check);
1778
+ // WS8 (ADR 0020): honor the accepted-gap waiver flags for a gate claim too.
1779
+ const gateWaiver = parseWaiver(p, ts);
1780
+ if (gateWaiver)
1781
+ checkNormalized._waiver = gateWaiver;
1380
1782
  // Log the targeted gate expectation for transparency (goes to stderr only)
1381
- process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}\n`);
1783
+ process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}${gateWaiver ? " (WAIVED accepted_gap)" : ""}\n`);
1382
1784
  assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], []));
1383
1785
  return 0;
1384
1786
  }
1787
+ /**
1788
+ * promote — the promote-then-archive gate (issue #312). Durable-residue extraction is
1789
+ * the archival act: this records WHAT durable residue was promoted WHERE and writes a
1790
+ * PROMOTION CLAIM into the session trust.bundle.
1791
+ *
1792
+ * Claim shape (reconcile-safe by construction): the promotion check is kind="policy",
1793
+ * so classifyEvidence maps it to evidenceType "policy_rule" (session-local, method
1794
+ * "auditability"). It carries NO command / execution.label, so it can NEVER require a
1795
+ * reconcile-manifest entry and can NEVER become a [not-run] / unbacked-command
1796
+ * divergence at CI trust-reconcile. Its status derives to `verified` from that
1797
+ * session-local policy_rule evidence item, so the reconciler classifies it session-local
1798
+ * and accepts it as an ATTESTED claim (exit 0) — never as a test_output claim that must
1799
+ * match the manifest. The _promotion marker rides onto claim.metadata.promotion (see
1800
+ * buildTrustBundle) so the archive gate and validators can detect it without new manifest
1801
+ * entries (R1).
1802
+ *
1803
+ * Modes:
1804
+ * promote <dir> --evidence-path <p> [--evidence-path <p> ...]
1805
+ * Records the durable doc paths written (docs/decisions/<slug>.md, CONTEXT.md,
1806
+ * docs/learnings/*, …). Each path MUST exist on disk at record time — a missing
1807
+ * path fails loud (no silent empty promotion).
1808
+ * promote <dir> --none --reason "<why nothing durable>"
1809
+ * An explicit, auditable no-residue promotion (R3).
1810
+ */
1811
+ async function promote(p) {
1812
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1813
+ const slug = taskSlugFor(dir, opt(p, "task-slug"));
1814
+ const ts = opt(p, "timestamp", now());
1815
+ const none = p.flags.has("none");
1816
+ const reason = opt(p, "reason");
1817
+ const repoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDir(dir);
1818
+ const rawPaths = opts(p, "evidence-path");
1819
+ if (none) {
1820
+ if (rawPaths.length)
1821
+ die("promote --none records a no-residue claim; do not also pass --evidence-path");
1822
+ if (!reason.trim())
1823
+ die("promote --none requires --reason \"<why nothing durable was promoted>\"");
1824
+ }
1825
+ else {
1826
+ if (!rawPaths.length)
1827
+ die("promote requires at least one --evidence-path <durable-doc-path> (or --none --reason \"<why>\" for an explicit no-residue promotion)");
1828
+ }
1829
+ // Every evidence ref MUST exist on disk at record time (fail loud otherwise). Store
1830
+ // repo-relative paths when the ref lives under the repo, so the claim is portable.
1831
+ const targets = [];
1832
+ for (const raw of rawPaths) {
1833
+ const abs = path.isAbsolute(raw) ? raw : path.resolve(repoRoot, raw);
1834
+ if (!fs.existsSync(abs))
1835
+ die(`promote --evidence-path does not exist on disk: ${raw} (resolved: ${abs}). Promotion evidence refs must point at durable docs that were actually written.`);
1836
+ const rel = path.relative(repoRoot, abs);
1837
+ targets.push(rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : raw);
1838
+ }
1839
+ const promotionMarker = {
1840
+ schema_version: "1.0",
1841
+ none,
1842
+ ...(none ? { reason } : {}),
1843
+ targets,
1844
+ promoted_at: ts,
1845
+ };
1846
+ const summary = opt(p, "summary") || (none
1847
+ ? `Promotion (no durable residue): ${reason}`
1848
+ : `Promoted durable residue: ${targets.join(", ")}`);
1849
+ // Session-local promotion check: kind="policy" -> policy_rule evidence, no command.
1850
+ const promotionCheck = { id: "promotion", kind: "policy", status: "pass", summary, _promotion: promotionMarker };
1851
+ // Add the promotion claim WITHOUT dropping the session's existing verification
1852
+ // evidence/criteria/critiques (mirror record-critique's merge pattern). Drop any prior
1853
+ // "promotion" check so re-running promote is idempotent rather than duplicating.
1854
+ const existingChecks = checksFromBundle(dir).filter((c) => c.id !== "promotion");
1855
+ const _acc = loadJson(path.join(dir, "acceptance.json"));
1856
+ const criteria = Array.isArray(_acc.criteria) ? _acc.criteria : [];
1857
+ const critiques = critiquesFromBundle(dir);
1858
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, [...existingChecks, promotionCheck], criteria, critiques));
1859
+ // Auditable record of what was promoted where (companion to the trust.bundle claim).
1860
+ writeJson(path.join(dir, "promotion.json"), { ...sidecarBase(slug), ...promotionMarker, summary });
1861
+ // Optionally republish so delivery/trust.bundle carries the promotion claim for CI.
1862
+ if (p.flags.has("publish")) {
1863
+ const publishRepoRoot = opt(p, "publish-repo-root") ? path.resolve(opt(p, "publish-repo-root")) : findRepoRootFromDirStrict(dir);
1864
+ await publishDelivery(dir, publishRepoRoot).catch((err) => {
1865
+ process.stderr.write(`[promote] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
1866
+ });
1867
+ }
1868
+ process.stderr.write(`[promote] recorded ${none ? "no-residue" : targets.length + " durable ref(s)"} promotion claim for ${slug}\n`);
1869
+ printJson({ ok: true, slug, none, targets, promotion_claim: "trust.bundle" });
1870
+ return 0;
1871
+ }
1385
1872
  async function advanceState(p) {
1386
1873
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1387
1874
  const status = opt(p, "status");
@@ -1438,7 +1925,14 @@ async function advanceState(p) {
1438
1925
  if (status === "delivered") {
1439
1926
  await sealTrustCheckpoint(dir, slug, timestamp, status, "release").catch(() => { });
1440
1927
  // Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
1441
- await publishDelivery(dir, findRepoRootFromDir(dir)).catch(() => { });
1928
+ // Fail-closed repo-root resolution (findRepoRootFromDirStrict, no cwd fallback) see
1929
+ // publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir
1930
+ // with no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures
1931
+ // are visible (stderr warning), not silently swallowed.
1932
+ const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
1933
+ await publishDelivery(dir, publishRepoRoot).catch((err) => {
1934
+ process.stderr.write(`[advance-state] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
1935
+ });
1442
1936
  }
1443
1937
  return 0;
1444
1938
  }
@@ -1454,14 +1948,31 @@ async function recordCritique(p) {
1454
1948
  // Fall back to critique.json for legacy sessions that still have it on disk.
1455
1949
  const existingCritiqueJson = loadJson(path.join(dir, "critique.json"), { critiques: [] });
1456
1950
  const legacyCritiques = Array.isArray(existingCritiqueJson.critiques) ? existingCritiqueJson.critiques : [];
1457
- const _dctCritique = declaredClaimTypesFor(dir);
1458
- const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir, _dctCritique) : legacyCritiques;
1951
+ const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir) : legacyCritiques;
1459
1952
  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"))) };
1460
- const critiques = [...bundleCritiques, critique];
1461
1953
  if (critique.verdict === "pass" && critique.findings.some((f) => f.status === "open"))
1462
1954
  die("required critique must pass");
1955
+ // #267/#282: supersede-by-critique-id. The latest write for a critique id wins for
1956
+ // reconcile / status / validator purposes; each prior LIVE write for the same id is RETAINED as
1957
+ // history (status "superseded", first-class metadata.superseded_by) but excluded from evaluation.
1958
+ // Supersession is REVIEWER-SCOPED (anti-gaming): a write may only supersede a prior live critique
1959
+ // of the same id written by the SAME reviewer — a different reviewer's disputed finding is never
1960
+ // buried, so it stays live and continues to block. DOCUMENTED GAP: reviewer identity is the
1961
+ // free-form --reviewer string (with a default); there is no cryptographic worker-vs-reviewer
1962
+ // distinction yet — that lands with the runtime actor-identity slice (#287/#290). Same-reviewer-
1963
+ // string scoping is the strongest honest enforcement available today and matches the granularity
1964
+ // the critique record already has.
1965
+ const _supersedeMarker = `${critique.id}@${critique.reviewed_at}`;
1966
+ const _mergedCritiques = bundleCritiques.map((e) => {
1967
+ const eSuperseded = typeof e.superseded_by === "string" && e.superseded_by.length > 0;
1968
+ const eReviewer = String(e.reviewer ?? "tool-code-reviewer");
1969
+ if (e.id === critique.id && !eSuperseded && eReviewer === critique.reviewer)
1970
+ return { ...e, superseded_by: _supersedeMarker };
1971
+ return e;
1972
+ });
1973
+ const critiques = [..._mergedCritiques, critique];
1463
1974
  // Phase 4c: build bundle from raw inputs; read checks from trust.bundle (evidence.json no longer written).
1464
- const _critiqueEvChecks = checksFromBundle(dir, _dctCritique);
1975
+ const _critiqueEvChecks = checksFromBundle(dir);
1465
1976
  const _critiqueAccCriteria = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
1466
1977
  assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueEvChecks, _critiqueAccCriteria, critiques));
1467
1978
  return 0;
@@ -1511,7 +2022,14 @@ async function recordRelease(p) {
1511
2022
  // Trust checkpoint: seal at the "delivered" moment (the natural terminal mark for record-release).
1512
2023
  await sealTrustCheckpoint(dir, slug, payload.updated_at, "delivered", "release").catch(() => { });
1513
2024
  // Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
1514
- await publishDelivery(dir, findRepoRootFromDir(dir)).catch(() => { });
2025
+ // Fail-closed repo-root resolution (findRepoRootFromDirStrict, no cwd fallback) see
2026
+ // publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir with
2027
+ // no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures are
2028
+ // visible (stderr warning), not silently swallowed.
2029
+ const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
2030
+ await publishDelivery(dir, publishRepoRoot).catch((err) => {
2031
+ process.stderr.write(`[record-release] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
2032
+ });
1515
2033
  return 0;
1516
2034
  }
1517
2035
  // ─── Trust Checkpoint (Increment A) ──────────────────────────────────────────
@@ -1713,7 +2231,7 @@ async function sealCheckpoint(p) {
1713
2231
  }
1714
2232
  // ─── Publish Delivery Bundle ──────────────────────────────────────────────────
1715
2233
  // Copies the session's trust.bundle (+ checkpoint companions) from the gitignored
1716
- // session artifact dir (.flow-agents/<slug>/) to the committed delivery/ transport
2234
+ // session artifact dir (.kontourai/flow-agents/<slug>/) to the committed delivery/ transport
1717
2235
  // path so the CI trust-reconcile job can reconcile it against fresh CI results.
1718
2236
  //
1719
2237
  // Fail-soft: if trust.bundle is absent (no evidence recorded yet), does nothing.
@@ -1727,13 +2245,24 @@ async function sealCheckpoint(p) {
1727
2245
  * trust.checkpoint.intoto.json / trust.checkpoint.sig.json from the
1728
2246
  * session artifact dir to <repoRoot>/delivery/.
1729
2247
  *
1730
- * Fail-soft: if trust.bundle is absent, returns without throwing.
2248
+ * Fail-soft on a missing bundle: if trust.bundle is absent, returns without throwing.
2249
+ * Fail-CLOSED on repo-root resolution: repoRoot must be a real, resolved kits/ ancestor
2250
+ * (see findRepoRootFromDirStrict) — null (no ancestor found) skips the publish with a
2251
+ * visible warning instead of writing to whatever process.cwd() happens to be. This
2252
+ * prevents a scratch/test session dir (no kits/ ancestor) from silently clobbering an
2253
+ * unrelated real repo's delivery/trust.bundle when invoked with that repo as cwd (see
2254
+ * evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings at
2255
+ * .kontourai/flow-agents/ws5-governance-kit-slice1 for the root cause this fixes).
1731
2256
  * Idempotent: overwrites on re-delivery.
1732
2257
  */
1733
2258
  export async function publishDelivery(dir, repoRoot) {
1734
2259
  const bundleSrc = path.join(dir, "trust.bundle");
1735
2260
  if (!fs.existsSync(bundleSrc))
1736
2261
  return; // no bundle — skip gracefully
2262
+ if (!repoRoot) {
2263
+ 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`);
2264
+ return;
2265
+ }
1737
2266
  const deliveryDir = path.join(repoRoot, "delivery");
1738
2267
  fs.mkdirSync(deliveryDir, { recursive: true });
1739
2268
  // Required: trust.bundle (the CI anchor)
@@ -1764,7 +2293,10 @@ export async function publishDelivery(dir, repoRoot) {
1764
2293
  */
1765
2294
  async function publishDeliveryCmd(p) {
1766
2295
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1767
- const repoRoot = opt(p, "repo-root") || findRepoRootFromDir(dir);
2296
+ // Fail-closed: an explicit --repo-root always wins; otherwise resolve strictly (no
2297
+ // process.cwd() fallback) so a scratch/test artifact dir cannot accidentally publish
2298
+ // into whichever repo happens to be the current working directory.
2299
+ const repoRoot = opt(p, "repo-root") || findRepoRootFromDirStrict(dir);
1768
2300
  await publishDelivery(dir, repoRoot);
1769
2301
  return 0;
1770
2302
  }
@@ -1836,26 +2368,23 @@ async function recordLearning(p) {
1836
2368
  writeJson(path.join(dir, "learning.json"), { ...sidecarBase(slug), status, updated_at: timestamp, records });
1837
2369
  writeState(dir, slug, "accepted", "learning", timestamp, opt(p, "summary"));
1838
2370
  // Phase 4c: build bundle from raw inputs; read checks/critiques from trust.bundle (bespoke sidecars no longer written).
1839
- // ADR 0016 Step 0: pass declaredClaimTypes so declared builder.* claims survive the round-trip.
1840
- const _dctLearning = declaredClaimTypesFor(dir);
1841
- const _learningChecks = checksFromBundle(dir, _dctLearning);
2371
+ // #268/#344: declared builder.* claims survive the round-trip via their authoritative origin stamp.
2372
+ const _learningChecks = checksFromBundle(dir);
1842
2373
  const _learningCriteria = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
1843
- const _learningCritiques = critiquesFromBundle(dir, _dctLearning);
2374
+ const _learningCritiques = critiquesFromBundle(dir);
1844
2375
  assertBundleWritten(await writeTrustBundle(dir, slug, timestamp, _learningChecks, _learningCriteria, _learningCritiques));
1845
2376
  return 0;
1846
2377
  }
1847
- function evidenceClean(dir, declaredClaimTypes = new Set()) {
1848
- // Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for legacy sessions.
1849
- // ADR 0016 Step 0: declaredClaimTypes broadens the filter to include kit-typed check claims
1850
- // (e.g. builder.verify.tests) in addition to workflow.check.* for FlowDefinition-driven sessions.
2378
+ function evidenceClean(dir) {
2379
+ // Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for
2380
+ // legacy (pre-bundle-era) sessions that never wrote a trust.bundle at all unrelated to origin
2381
+ // stamping. When a trust.bundle IS present, every claim must be stamped (requireStampedClaim);
2382
+ // there is no claimType-derivation fallback for an unstamped claim (#268/#344).
1851
2383
  const bundle = loadJson(path.join(dir, "trust.bundle"));
1852
2384
  if (Array.isArray(bundle.claims)) {
1853
- const checkClaims = bundle.claims.filter((c) => {
1854
- if (!c)
1855
- return false;
1856
- const ct = String(c.claimType || "");
1857
- return ct.startsWith("workflow.check.") || declaredClaimTypes.has(ct);
1858
- });
2385
+ for (const c of bundle.claims)
2386
+ requireStampedClaim(c, dir);
2387
+ const checkClaims = bundle.claims.filter((c) => c && claimOrigin(c) === "check");
1859
2388
  if (checkClaims.length === 0)
1860
2389
  return false;
1861
2390
  return checkClaims.every((c) => {
@@ -1863,7 +2392,7 @@ function evidenceClean(dir, declaredClaimTypes = new Set()) {
1863
2392
  return v === "pass" || v === "skip";
1864
2393
  });
1865
2394
  }
1866
- // Legacy fallback: evidence.json
2395
+ // Legacy fallback: evidence.json (pre-bundle-era sessions with no trust.bundle at all)
1867
2396
  const e = loadJson(path.join(dir, "evidence.json"), {});
1868
2397
  return e.verdict === "pass" && Array.isArray(e.checks) && e.checks.length > 0 && e.checks.every((c) => {
1869
2398
  if (!(c.status === "pass" || c.status === "skip"))
@@ -1871,13 +2400,23 @@ function evidenceClean(dir, declaredClaimTypes = new Set()) {
1871
2400
  return !Array.isArray(c.standard_refs) || c.standard_refs.every((r) => ["junit", "sarif", "coverage", "veritas"].includes(r.standard));
1872
2401
  });
1873
2402
  }
1874
- function critiqueClean(dir, declaredClaimTypes = new Set()) {
1875
- // Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for legacy sessions.
1876
- // ADR 0016 Step 0: declaredClaimTypes broadens the filter to include kit-typed critique claims
1877
- // (e.g. builder.verify.policy-compliance) in addition to workflow.critique.review.
2403
+ function critiqueClean(dir) {
2404
+ // Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for
2405
+ // legacy (pre-bundle-era) sessions unrelated to origin stamping. When a trust.bundle IS
2406
+ // present, every claim must be stamped (requireStampedClaim); no claimType-derivation fallback
2407
+ // for an unstamped claim (#268/#344).
1878
2408
  const bundle = loadJson(path.join(dir, "trust.bundle"));
1879
2409
  if (Array.isArray(bundle.claims)) {
1880
- const critiqueClaims = bundle.claims.filter((c) => c && (c.claimType === "workflow.critique.review" || declaredClaimTypes.has(c.claimType)));
2410
+ for (const c of bundle.claims)
2411
+ requireStampedClaim(c, dir);
2412
+ const critiqueClaims = bundle.claims.filter((c) => {
2413
+ if (!c)
2414
+ return false;
2415
+ // #267/#282: superseded history is not evaluated for cleanliness.
2416
+ if (c.metadata && typeof c.metadata === "object" && c.metadata.superseded_by)
2417
+ return false;
2418
+ return claimOrigin(c) === "critique";
2419
+ });
1881
2420
  if (critiqueClaims.length === 0)
1882
2421
  return false; // no critique written yet
1883
2422
  return critiqueClaims.every((c) => {
@@ -1919,10 +2458,9 @@ async function dogfoodPass(p) {
1919
2458
  if (checks.some((c) => c.status !== "pass" && c.status !== "skip"))
1920
2459
  die("clean evidence requires all non-skipped checks to pass");
1921
2460
  // Phase 4c: evidence check reads from trust.bundle (sole verification artifact); legacy evidence.json fallback in evidenceClean.
1922
- // ADR 0016 Step 0: pass declaredClaimTypes so builder.* check/critique claims count as clean evidence.
1923
- const _dctDogfood = declaredClaimTypesFor(dir);
1924
- const _hasBundleEvidence = fs.existsSync(path.join(dir, "trust.bundle")) && evidenceClean(dir, _dctDogfood);
1925
- const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir, _dctDogfood);
2461
+ // #268/#344: builder.* check/critique claims count as clean evidence via their authoritative origin stamp.
2462
+ const _hasBundleEvidence = fs.existsSync(path.join(dir, "trust.bundle")) && evidenceClean(dir);
2463
+ const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir);
1926
2464
  if (!_hasBundleEvidence && !_hasLegacyEvidence && fs.existsSync(path.join(dir, "trust.bundle")))
1927
2465
  die("cannot mark clean without passing evidence");
1928
2466
  if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && fs.existsSync(path.join(dir, "evidence.json")))
@@ -1935,10 +2473,10 @@ async function dogfoodPass(p) {
1935
2473
  normalizeFinding(parseJson(value, "--finding-json"));
1936
2474
  if (newCritiqueVerdict !== "pass")
1937
2475
  die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
1938
- if (!opt(p, "critique-id") && !critiqueClean(dir, _dctDogfood))
2476
+ if (!opt(p, "critique-id") && !critiqueClean(dir))
1939
2477
  die("requires passing critique");
1940
2478
  // Phase 4c: if existing state has a dirty critique (in bundle or legacy critique.json), block even when adding a new critique-id.
1941
- if (!critiqueClean(dir, _dctDogfood) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json"))))
2479
+ if (!critiqueClean(dir) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json"))))
1942
2480
  die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
1943
2481
  }
1944
2482
  }
@@ -1971,13 +2509,13 @@ async function dogfoodPass(p) {
1971
2509
  return 0;
1972
2510
  }
1973
2511
  /**
1974
- * Read the gate block signal from .flow-agents/.goal-fit-block-streak.json
2512
+ * Read the gate block signal from .kontourai/flow-agents/.goal-fit-block-streak.json
1975
2513
  * (written by scripts/hooks/stop-goal-fit.js when block mode fires).
1976
2514
  * The file sits at <artifact-root>/.goal-fit-block-streak.json — one level
1977
2515
  * above the session artifact dir. Fail-open: returns { blocked: false } when
1978
2516
  * the file is absent or unreadable.
1979
2517
  *
1980
- * @param artifactRoot The .flow-agents root dir (parent of session slug dir).
2518
+ * @param artifactRoot The runtime artifact root dir (parent of session slug dir).
1981
2519
  */
1982
2520
  export function readGateBlockSignal(artifactRoot) {
1983
2521
  const streakFile = path.join(artifactRoot, ".goal-fit-block-streak.json");
@@ -2183,7 +2721,7 @@ export function buildGateInquiryRecords(bundle, blockSignal, slug, expectedCrite
2183
2721
  *
2184
2722
  * The block signal is read from <artifact-root>/.goal-fit-block-streak.json,
2185
2723
  * written by scripts/hooks/stop-goal-fit.js when block mode fires. The file
2186
- * lives one level above the session slug dir (the .flow-agents root).
2724
+ * lives one level above the session slug dir (the runtime artifact root).
2187
2725
  *
2188
2726
  * If @kontourai/surface is unavailable, logs a warning and returns 0
2189
2727
  * (fail-open — no bespoke fork fallback).
@@ -2206,7 +2744,7 @@ async function gateReview(p) {
2206
2744
  return 0;
2207
2745
  }
2208
2746
  const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
2209
- // Read gate block signal from .flow-agents root (one level above session dir)
2747
+ // Read gate block signal from the runtime artifact root (one level above session dir)
2210
2748
  const artifactRoot = path.dirname(dir);
2211
2749
  const blockSignal = readGateBlockSignal(artifactRoot);
2212
2750
  // Enumerate expected criterion IDs: primary = bundle claims (workflow.acceptance.criterion),
@@ -2370,7 +2908,7 @@ function trustMcp(p) {
2370
2908
  if (mode === "print") {
2371
2909
  console.log(JSON.stringify({ mcpServers: { [TRUST_MCP_SERVER]: trustMcpRegistration() } }, null, 2));
2372
2910
  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`);
2373
- process.stderr.write(`# To view a task's trust inline, call surface_summary with path=<.flow-agents/<slug>/trust.bundle>.\n`);
2911
+ process.stderr.write(`# To view a task's trust inline, call surface_summary with path=<.kontourai/flow-agents/<slug>/trust.bundle>.\n`);
2374
2912
  return 0;
2375
2913
  }
2376
2914
  if (mode !== "enable" && mode !== "disable")
@@ -2422,12 +2960,19 @@ const LIVENESS_POLICY = {
2422
2960
  conflictRules: [],
2423
2961
  impactLevel: "medium",
2424
2962
  };
2425
- function livenessStreamFile(root) { return path.join(root, "liveness", "events.jsonl"); }
2426
- function appendLivenessEvent(root, evt) {
2427
- const file = livenessStreamFile(root);
2428
- fs.mkdirSync(path.dirname(file), { recursive: true });
2429
- fs.appendFileSync(file, `${JSON.stringify(evt)}\n`);
2963
+ /**
2964
+ * Delegate to the shared pure-CJS writer (scripts/hooks/lib/liveness-write.js), mirroring the
2965
+ * createRequire pattern used by loadActorIdentityHelper() below. Deliberately NO inline duplicate
2966
+ * fallback — the whole point of #288 Wave 2 Task 2.1 is one writer shared by the CLI and the
2967
+ * hook wrappers' tool-activity heartbeat, not two copies of the append shape that can drift.
2968
+ */
2969
+ function loadLivenessWriteHelper() {
2970
+ const _req = createRequire(import.meta.url);
2971
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-write.js");
2972
+ return _req(helperPath);
2430
2973
  }
2974
+ function livenessStreamFile(root) { return loadLivenessWriteHelper().livenessStreamFile(root); }
2975
+ function appendLivenessEvent(root, evt) { loadLivenessWriteHelper().appendLivenessEvent(root, evt); }
2431
2976
  function readLivenessEvents(root) {
2432
2977
  // Delegate to the shared pure-CJS helper (scripts/hooks/lib/liveness-read.js).
2433
2978
  // Using createRequire so the ESM sidecar can load a CJS module without bundling it.
@@ -2463,39 +3008,158 @@ function livenessLabel(status) {
2463
3008
  return "superseded";
2464
3009
  return status;
2465
3010
  }
2466
- // ─── ADR 0012 lifecycle-driven liveness (opt-in via FLOW_AGENTS_LIVENESS) ──────
3011
+ // ─── ADR 0012 lifecycle-driven liveness (default-on; opt-out via FLOW_AGENTS_LIVENESS) ──
2467
3012
  // init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
2468
3013
  // so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
2469
3014
  // Additive + fail-open: a liveness-emit failure never affects the workflow command.
2470
3015
  const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
2471
- function resolveLivenessActor() { return (process.env.FLOW_AGENTS_ACTOR || "").trim() || "local"; }
2472
- function livenessEnabled() { const v = String(process.env.FLOW_AGENTS_LIVENESS || "").trim().toLowerCase(); return v === "on" || v === "1" || v === "true"; }
3016
+ /**
3017
+ * Delegate to the shared pure-CJS resolver (scripts/hooks/lib/actor-identity.js), mirroring the
3018
+ * createRequire pattern used by readLivenessEvents() above. Deliberately NO inline duplicate
3019
+ * fallback: if the module fails to load, that failure itself must surface as an unresolved actor
3020
+ * ("") — never silently degrade back to the retired "local" default (issue #287).
3021
+ */
3022
+ function loadActorIdentityHelper() {
3023
+ const _req = createRequire(import.meta.url);
3024
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
3025
+ return _req(helperPath);
3026
+ }
3027
+ function resolveLivenessActor() {
3028
+ return loadActorIdentityHelper().resolveActor(process.env).actor;
3029
+ }
3030
+ // isUnresolvedActor is no longer defined locally — it is single-sourced in
3031
+ // scripts/hooks/lib/actor-identity.js (loadActorIdentityHelper().isUnresolvedActor) so the
3032
+ // lifecycle auto-emit path, the direct CLI liveness path, and the tool-activity heartbeat path
3033
+ // all consume the same predicate rather than forking their own copy (#287 re-review MEDIUM; #288
3034
+ // Wave 1 Task 1.1 single-sources it there).
3035
+ /**
3036
+ * Delegate to the shared pure-CJS policy predicates (scripts/hooks/lib/liveness-policy.js) — the
3037
+ * one definition of "enabled" (default-on/opt-out) and the claim TTL default, consumed by both the
3038
+ * lifecycle auto-emit path and the manual `liveness claim --ttl` default so they can never disagree
3039
+ * (#288).
3040
+ */
3041
+ function loadLivenessPolicyHelper() {
3042
+ const _req = createRequire(import.meta.url);
3043
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-policy.js");
3044
+ return _req(helperPath);
3045
+ }
3046
+ function livenessEnabled() { return loadLivenessPolicyHelper().isLivenessEnabled(process.env); }
3047
+ /**
3048
+ * F1 (#288 fix iteration 1, cr-HIGH fail-open violation): the `livenessEnabled()`
3049
+ * guard (and therefore its `loadLivenessPolicyHelper()` module load) must sit
3050
+ * INSIDE this function's own fail-open try/catch — previously it sat outside,
3051
+ * so a missing/broken scripts/hooks/lib/liveness-policy.js module made
3052
+ * `init-plan`/`advance-state` exit 1 instead of degrading gracefully
3053
+ * (repro-verified). Now: any failure here — including a failed helper load —
3054
+ * is caught, produces one stderr diagnostic, and the lifecycle auto-emit is
3055
+ * skipped; the workflow command's own exit code is never affected. This
3056
+ * mirrors the #287 fail-open convention already used elsewhere in this file.
3057
+ * The direct CLI write path (`async function liveness`, actions
3058
+ * claim|heartbeat|release) is deliberately NOT wrapped this way — it stays
3059
+ * fail-loud on a missing helper module, per the plan's explicit instruction
3060
+ * that only the convenience lifecycle wiring is flag-gated/fail-open.
3061
+ */
2473
3062
  function livenessLifecycle(taskDir, slug, kind, timestamp) {
2474
- if (!livenessEnabled())
2475
- return;
2476
3063
  try {
2477
- const root = path.dirname(taskDir); // .flow-agents/<slug> → .flow-agents (the shared liveness stream lives here)
2478
- const evt = { type: kind, subjectId: slug, actor: resolveLivenessActor(), at: timestamp, source: "lifecycle" };
3064
+ if (!livenessEnabled())
3065
+ return;
3066
+ const actor = resolveLivenessActor();
3067
+ if (loadActorIdentityHelper().isUnresolvedActor(actor)) {
3068
+ process.stderr.write("[liveness] skipped auto-emit: actor unresolved (set FLOW_AGENTS_ACTOR or run inside a supported runtime)\n");
3069
+ return;
3070
+ }
3071
+ const root = path.dirname(taskDir); // .kontourai/flow-agents/<slug> → .kontourai/flow-agents (the shared liveness stream lives here)
3072
+ const evt = { type: kind, subjectId: slug, actor, at: timestamp, source: "lifecycle" };
2479
3073
  if (kind === "claim")
2480
- evt.ttlSeconds = 1800;
3074
+ evt.ttlSeconds = loadLivenessPolicyHelper().resolveTtlSeconds(process.env);
2481
3075
  appendLivenessEvent(root, evt);
2482
3076
  }
2483
- catch { /* best-effort; liveness is advisory and must never break the workflow */ }
3077
+ catch (err) {
3078
+ // best-effort; liveness is advisory and must never break the workflow — but the failure
3079
+ // itself must be visible (F1), not silently absorbed.
3080
+ try {
3081
+ const detail = err instanceof Error ? err.message : String(err);
3082
+ process.stderr.write(`[liveness] skipped auto-emit: ${detail}\n`);
3083
+ }
3084
+ catch { /* best-effort diagnostic only */ }
3085
+ }
3086
+ }
3087
+ /**
3088
+ * F5 (#288 fix iteration 1, sec-LOW): strip control/escape characters before echoing a
3089
+ * subjectId/actor to the terminal. subjectId is a raw CLI positional argument on the write path
3090
+ * (never sanitized before this point — it is stored as-is in the event for data fidelity) and,
3091
+ * on the `status` read path, both subjectId and actor may originate from a hand-edited or
3092
+ * otherwise hostile liveness/events.jsonl file rather than this process's own writes. Strips the
3093
+ * C0 control range (0x00-0x1F), DEL (0x7F), and the C1 range (0x80-0x9F, which includes
3094
+ * ANSI-CSI-adjacent bytes) — display-only; the persisted event itself is never mutated.
3095
+ */
3096
+ function stripControlCharsForDisplay(value) {
3097
+ return String(value ?? "").replace(/[\u0000-\u001F\u007F-\u009F]/g, "");
2484
3098
  }
2485
3099
  async function liveness(p) {
2486
3100
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
2487
3101
  const action = p.positional[0] || "";
2488
3102
  const subjectId = p.positional[1] || "";
2489
- const actor = opt(p, "actor", process.env.FLOW_AGENTS_ACTOR || "unknown");
2490
3103
  const nowIso = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
3104
+ if (action === "whoami") {
3105
+ // Read-only, lock-free, write-free advisory surface: reuses the identical resolution chain
3106
+ // as the write paths (loadActorIdentityHelper().resolveActor) but deliberately never dies on
3107
+ // an unresolved actor — the enforcement point stays at `liveness claim`, which already dies
3108
+ // loudly (see below). This lets a skill learn "who am I" during a read-only preflight without
3109
+ // emitting a bogus claim first.
3110
+ const helper = loadActorIdentityHelper();
3111
+ const explicitActorRaw = opt(p, "actor", "");
3112
+ const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
3113
+ const resolved = explicitActor
3114
+ ? { actor: explicitActor, source: "explicit-override" }
3115
+ : helper.resolveActor(process.env);
3116
+ if (p.flags.has("json")) {
3117
+ console.log(JSON.stringify(resolved));
3118
+ return 0;
3119
+ }
3120
+ console.log(`${stripControlCharsForDisplay(resolved.actor || "unresolved")}\t${stripControlCharsForDisplay(resolved.source)}`);
3121
+ return 0;
3122
+ }
2491
3123
  if (action === "claim" || action === "heartbeat" || action === "release") {
3124
+ // Actor resolution happens only for write actions (F5, #287 fix iteration 1) — "status" is a
3125
+ // read path and must not shell out via resolveLivenessActor()/resolveActor().
3126
+ const helper = loadActorIdentityHelper();
3127
+ const explicitActorRaw = opt(p, "actor", "");
3128
+ // F7 (#287 fix iteration 2): an explicit --actor value that strips to empty under the allowed
3129
+ // [A-Za-z0-9_.-] charset (e.g. "--actor ':::'") is a hard error on this write path, not a
3130
+ // silent fallback to sanitizeSegment's shared "unknown" sentinel — garbage on an explicit flag
3131
+ // is an authoring mistake the caller must fix, unlike the env-override seam (which falls
3132
+ // through to derivation instead, since there is no flag to correct).
3133
+ if (explicitActorRaw && !/[A-Za-z0-9_.-]/.test(explicitActorRaw)) {
3134
+ 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.`);
3135
+ }
3136
+ const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
3137
+ const actor = explicitActor || helper.resolveActor(process.env).actor;
3138
+ if (helper.isUnresolvedActor(actor)) {
3139
+ 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.`);
3140
+ }
2492
3141
  if (!subjectId)
2493
3142
  die(`liveness ${action} requires a subjectId`);
3143
+ // F8(i) (#288 fix iteration 2, orphan-heartbeat invariant): heartbeat/release on the direct CLI
3144
+ // write path must never be the FIRST liveness event ever written for a (subjectId, actor) pair —
3145
+ // that is exactly how the reviewer-reproduced orphan-heartbeat bug arises (a bare heartbeat with
3146
+ // no claim behind it, later mistaken for claim evidence). A full stream read here is fine: this
3147
+ // is a rare, human/CLI-driven call, not the hot tool-activity path. `claim` itself is exempt (it
3148
+ // is the event that ESTABLISHES the pair in the first place).
3149
+ if (action === "heartbeat" || action === "release") {
3150
+ const priorEvents = readLivenessEvents(root);
3151
+ const hasPriorClaim = priorEvents.some((e) => e && e.type === "claim" && e.subjectId === subjectId && e.actor === actor);
3152
+ if (!hasPriorClaim) {
3153
+ 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}.`);
3154
+ }
3155
+ }
2494
3156
  const evt = { type: action, subjectId, actor, at: opt(p, "at") || nowIso };
2495
- if (action === "claim")
2496
- evt.ttlSeconds = Number.parseInt(opt(p, "ttl", "1800"), 10) || 1800;
3157
+ if (action === "claim") {
3158
+ const defaultTtl = loadLivenessPolicyHelper().resolveTtlSeconds(process.env);
3159
+ evt.ttlSeconds = Number.parseInt(opt(p, "ttl", String(defaultTtl)), 10) || defaultTtl;
3160
+ }
2497
3161
  appendLivenessEvent(root, evt);
2498
- console.log(`liveness ${action}: ${subjectId} by ${actor}`);
3162
+ console.log(`liveness ${action}: ${stripControlCharsForDisplay(subjectId)} by ${stripControlCharsForDisplay(actor)}`);
2499
3163
  return 0;
2500
3164
  }
2501
3165
  if (action === "status") {
@@ -2531,7 +3195,7 @@ async function liveness(p) {
2531
3195
  for (const g of groups.values()) {
2532
3196
  if (subjectFilter && g.subjectId !== subjectFilter)
2533
3197
  continue;
2534
- const claim = { 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 };
3198
+ const claim = { 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 };
2535
3199
  const status = surface.deriveTrustStatus({ claim, evidence: [], policy: LIVENESS_POLICY, events: g.events, now });
2536
3200
  rows.push({ subjectId: g.subjectId, actor: g.actor, status, label: livenessLabel(status) });
2537
3201
  }
@@ -2540,10 +3204,99 @@ async function liveness(p) {
2540
3204
  return 0;
2541
3205
  }
2542
3206
  for (const r of rows)
2543
- console.log(`${r.subjectId}\t${r.actor}\t${r.label}`);
3207
+ console.log(`${stripControlCharsForDisplay(r.subjectId)}\t${stripControlCharsForDisplay(r.actor)}\t${r.label}`);
3208
+ return 0;
3209
+ }
3210
+ if (action === "verdict") {
3211
+ // Read-only, lock-free CLI helper (#320 AC1) — mirrors `whoami`'s lock-bypass (see
3212
+ // isLivenessVerdict in main() below): computes {subjectId, winner, losers, reason, holders}
3213
+ // as a PURE function of the shared liveness stream. Among the subject's currently-fresh
3214
+ // claim holders (via the shared `freshHolders` helper — the exact canonical
3215
+ // freshness/grouping/release-handling logic `workflow-steering.js`'s ambient digest already
3216
+ // uses, not a re-derived rule), the holder whose most recent `claim` event has the earliest
3217
+ // `at` wins; an exact-timestamp tie breaks by ascending plain string comparison of actor id
3218
+ // (never locale-aware collation, for cross-machine determinism). Same stream state ⇒ same
3219
+ // verdict, regardless of which actor invokes it — no actor-specific input is read here.
3220
+ if (!subjectId)
3221
+ die("liveness verdict requires a subjectId");
3222
+ const events = readLivenessEvents(root);
3223
+ const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : Date.now();
3224
+ const _req = createRequire(import.meta.url);
3225
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
3226
+ const { freshHolders } = _req(helperPath);
3227
+ // Impossible-sentinel selfActor: real actors are sanitized to [A-Za-z0-9_.-]+ and can never be
3228
+ // empty (enforced on every write path above), so passing "" excludes nothing.
3229
+ const fresh = freshHolders(events, subjectId, "", nowMs);
3230
+ // freshHolders' `lastAt` tracks the group's latest event of ANY type (heartbeat-updated); the
3231
+ // tiebreak instead operates on each holder's most recent `claim`-type event `at` — the
3232
+ // "current claim timestamp", distinct from lastAt.
3233
+ const latestClaimAt = (actor) => {
3234
+ let best = "";
3235
+ for (const e of events) {
3236
+ if (e && e.subjectId === subjectId && e.actor === actor && e.type === "claim" && typeof e.at === "string") {
3237
+ if (!best || e.at > best)
3238
+ best = e.at;
3239
+ }
3240
+ }
3241
+ return best;
3242
+ };
3243
+ const holders = fresh
3244
+ .map((h) => ({ actor: String(h.actor), claimAt: latestClaimAt(String(h.actor)) || String(h.lastAt), lastAt: String(h.lastAt), ttlSeconds: Number(h.ttlSeconds) }))
3245
+ .sort((a, b) => (a.claimAt < b.claimAt ? -1 : a.claimAt > b.claimAt ? 1 : a.actor < b.actor ? -1 : a.actor > b.actor ? 1 : 0));
3246
+ let winner = null;
3247
+ let losers = [];
3248
+ let reason = "no-conflict";
3249
+ if (holders.length >= 2) {
3250
+ winner = holders[0];
3251
+ const tie = holders.filter((h) => h.claimAt === winner.claimAt).length > 1;
3252
+ reason = tie ? "tie-actor-lexicographic" : "earlier-claim";
3253
+ losers = holders.filter((h) => h.actor !== winner.actor);
3254
+ }
3255
+ if (p.flags.has("json")) {
3256
+ // #320 fix iteration 1 (sec-HIGH F3): `winner`/`losers`/`holders` carry raw actor/claimAt/
3257
+ // lastAt strings sourced straight from the multi-writer append-only liveness/events.jsonl
3258
+ // stream (see stripControlCharsForDisplay's header above — any process can append a
3259
+ // hostile line, bypassing the CLI write-side sanitizeSegment entirely). The pull-work
3260
+ // SKILL.md's Post-Claim Conflict Re-check instructs an LLM to read `winner.actor` /
3261
+ // `losers[].actor` straight out of THIS `--json` output into its own context — a second,
3262
+ // LLM-facing injection path alongside the mid-turn conflict hook (liveness-heartbeat.js's
3263
+ // computeConflict). Build a sanitized COPY for JSON emission only: control-char strip
3264
+ // (matching the text branch's existing stripControlCharsForDisplay treatment below) plus a
3265
+ // 64-char cap (matching sanitizeSegment's cap on the write side) on actor/claimAt/lastAt.
3266
+ // `reason`/`subjectId`/`ttlSeconds` are left as-is (enum/number, never free text). Never
3267
+ // mutates `result`/`holders`/`winner`/`losers` themselves — the text branch below still
3268
+ // needs the untouched originals for its own per-line rendering, and winner/loser identity
3269
+ // (already sanitizeSegment-clean for legitimate actors) is unaffected, so the loser-release
3270
+ // contract still works.
3271
+ const sanitizeVerdictHolderForJson = (h) => h
3272
+ ? {
3273
+ ...h,
3274
+ actor: stripControlCharsForDisplay(h.actor).slice(0, 64),
3275
+ claimAt: stripControlCharsForDisplay(h.claimAt).slice(0, 64),
3276
+ lastAt: stripControlCharsForDisplay(h.lastAt).slice(0, 64),
3277
+ }
3278
+ : h;
3279
+ const jsonResult = {
3280
+ subjectId,
3281
+ winner: sanitizeVerdictHolderForJson(winner),
3282
+ losers: losers.map((l) => sanitizeVerdictHolderForJson(l)),
3283
+ reason,
3284
+ holders: holders.map((h) => sanitizeVerdictHolderForJson(h)),
3285
+ };
3286
+ console.log(JSON.stringify(jsonResult));
3287
+ return 0;
3288
+ }
3289
+ for (const h of holders) {
3290
+ const tag = winner && h.actor === winner.actor ? "WINNER" : (holders.length >= 2 ? "LOSER" : "HOLDER");
3291
+ console.log(`${stripControlCharsForDisplay(h.actor)}\tclaimAt=${stripControlCharsForDisplay(h.claimAt)}\t${tag}`);
3292
+ }
3293
+ if (winner)
3294
+ console.log(`WINNER: ${stripControlCharsForDisplay(winner.actor)}`);
3295
+ for (const l of losers)
3296
+ console.log(`LOSER: ${stripControlCharsForDisplay(l.actor)}`);
2544
3297
  return 0;
2545
3298
  }
2546
- die("liveness action must be one of: claim | heartbeat | release | status");
3299
+ die("liveness action must be one of: claim | heartbeat | release | status | whoami | verdict");
2547
3300
  return 1;
2548
3301
  }
2549
3302
  // ─────────────────────────────────────────────────────────────────────────────
@@ -2707,9 +3460,24 @@ async function main() {
2707
3460
  const p = parseArgs(process.argv.slice(2));
2708
3461
  if (!p.command)
2709
3462
  die("workflow-sidecar command is required");
2710
- const lockRoot = ["ensure-session", "current", "dogfood-pass", "liveness"].includes(p.command)
3463
+ // F1 (#166 fix iteration 1): `liveness whoami` is a read-only, lock-free, write-free advisory
3464
+ // surface (see the `action === "whoami"` branch inside `liveness()` above) — it must never
3465
+ // acquire the workflow-sidecar lock, regardless of whether the artifact root already exists on
3466
+ // disk. Without this action-level bypass, `liveness` was blanket-included in the lock-routing
3467
+ // branch below, so `whoami` against an EXISTING artifact root would still resolve a real
3468
+ // lockRoot and go through `withLock`'s mkdir/lockdir path — the opposite of "genuinely
3469
+ // lock-free". This bypass mirrors `resolve-slug`'s existing empty-lockRoot special case
3470
+ // immediately below and is scoped to the `whoami` action only: `liveness status` (a read path)
3471
+ // keeps its pre-existing lock behavior unchanged (out of scope for this fix — see fix-plan
3472
+ // iteration 1, F1), and `liveness claim` / `heartbeat` / `release` (write paths) are untouched.
3473
+ const isLivenessWhoami = p.command === "liveness" && p.positional[0] === "whoami";
3474
+ // #320 AC1: `liveness verdict` is read-only and lock-free, exactly like `whoami` above — it
3475
+ // must never acquire the workflow-sidecar lock. Same bypass shape, scoped to the `verdict`
3476
+ // action only; `liveness status` (also a read path) keeps its pre-existing lock behavior.
3477
+ const isLivenessVerdict = p.command === "liveness" && p.positional[0] === "verdict";
3478
+ const lockRoot = (["ensure-session", "current", "dogfood-pass", "liveness"].includes(p.command) && !isLivenessWhoami && !isLivenessVerdict)
2711
3479
  ? (opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : (p.command === "ensure-session" ? flowAgentsArtifactRoot() : defaultArtifactRootForRead()))
2712
- : 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]) : "";
3480
+ : 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]) : "";
2713
3481
  return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
2714
3482
  switch (p.command) {
2715
3483
  case "ensure-session": return ensureSession(p);
@@ -2718,6 +3486,7 @@ async function main() {
2718
3486
  case "init-plan": return initPlan(p);
2719
3487
  case "record-evidence": return recordEvidence(p);
2720
3488
  case "record-gate-claim": return recordGateClaim(p);
3489
+ case "promote": return promote(p);
2721
3490
  case "advance-state": return advanceState(p);
2722
3491
  case "record-critique": return recordCritique(p);
2723
3492
  case "import-critique": return importCritique(p);
@@ -2729,6 +3498,7 @@ async function main() {
2729
3498
  case "trust-mcp": return trustMcp(p);
2730
3499
  case "liveness": return liveness(p);
2731
3500
  case "claim": return claimLookup(p);
3501
+ case "resolve-slug": return resolveSlugCmd(p);
2732
3502
  case "seal-checkpoint": return sealCheckpoint(p);
2733
3503
  case "publish-delivery": return publishDeliveryCmd(p);
2734
3504
  default: die(`unknown command: ${p.command}`);