@kontourai/flow-agents 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/.github/CODEOWNERS +8 -0
  2. package/.github/workflows/ci.yml +32 -0
  3. package/AGENTS.md +4 -4
  4. package/CHANGELOG.md +46 -0
  5. package/CONTEXT.md +14 -0
  6. package/README.md +9 -3
  7. package/agents/dev.json +1 -1
  8. package/agents/tool-code-reviewer.json +1 -1
  9. package/agents/tool-planner.json +3 -3
  10. package/agents/tool-verifier.json +3 -3
  11. package/build/src/cli/console-learning-projection.js +3 -2
  12. package/build/src/cli/init.js +104 -21
  13. package/build/src/cli/kit.js +37 -6
  14. package/build/src/cli/pull-work-provider.js +1 -1
  15. package/build/src/cli/usage-feedback.js +3 -3
  16. package/build/src/cli/validate-hook-influence.js +1 -0
  17. package/build/src/cli/validate-workflow-artifacts.js +22 -6
  18. package/build/src/cli/workflow-artifact-cleanup-audit.js +36 -2
  19. package/build/src/cli/workflow-sidecar.d.ts +14 -7
  20. package/build/src/cli/workflow-sidecar.js +792 -69
  21. package/build/src/flow-kit/validate.d.ts +23 -0
  22. package/build/src/flow-kit/validate.js +67 -1
  23. package/build/src/index.d.ts +1 -1
  24. package/build/src/index.js +1 -1
  25. package/build/src/lib/flow-resolver.d.ts +6 -4
  26. package/build/src/lib/flow-resolver.js +125 -58
  27. package/build/src/lib/local-artifact-root.d.ts +14 -2
  28. package/build/src/lib/local-artifact-root.js +22 -5
  29. package/build/src/lib/workflow-learning-projection.js +2 -2
  30. package/build/src/runtime-adapters.d.ts +12 -0
  31. package/build/src/runtime-adapters.js +56 -16
  32. package/build/src/tools/build-universal-bundles.js +49 -16
  33. package/build/src/tools/generate-context-map.js +10 -8
  34. package/build/src/tools/validate-source-tree.js +23 -1
  35. package/console.telemetry.json +22 -0
  36. package/context/contracts/artifact-contract.md +38 -7
  37. package/context/contracts/builder-kit-workflow-state-contract.md +3 -2
  38. package/context/contracts/decision-registry-contract.md +263 -0
  39. package/context/contracts/delivery-contract.md +1 -1
  40. package/context/contracts/planning-contract.md +2 -1
  41. package/context/contracts/review-contract.md +5 -3
  42. package/context/contracts/verification-contract.md +9 -1
  43. package/context/contracts/work-item-contract.md +1 -1
  44. package/context/deferred/parallelization.md +1 -1
  45. package/context/gate-awareness.md +4 -4
  46. package/context/scripts/git-status.sh +2 -2
  47. package/context/scripts/hooks/config-protection.js +502 -2
  48. package/context/scripts/hooks/lib/config-protection-remedies.js +69 -0
  49. package/context/scripts/hooks/run-hook.js +14 -1
  50. package/context/scripts/hooks/stop-goal-fit.js +1140 -99
  51. package/context/scripts/hooks/workflow-steering.js +212 -6
  52. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  53. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +35 -0
  54. package/docs/adr/0019-kit-dependency-ownership.md +62 -0
  55. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +290 -0
  56. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +221 -0
  57. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +283 -0
  58. package/docs/agent-system-guidebook.md +3 -3
  59. package/docs/agent-usage-feedback-loop.md +6 -6
  60. package/docs/context-map.md +9 -7
  61. package/docs/decisions/decision-records.md +57 -0
  62. package/docs/decisions/index.md +14 -0
  63. package/docs/decisions/promotion-gate.md +52 -0
  64. package/docs/developer-architecture.md +1 -1
  65. package/docs/fixture-ownership.md +3 -0
  66. package/docs/flow-kit-repository-contract.md +5 -5
  67. package/docs/getting-started.md +12 -2
  68. package/docs/integrations/harness-install.md +2 -1
  69. package/docs/kit-authoring-guide.md +64 -5
  70. package/docs/knowledge-kit.md +5 -1
  71. package/docs/learnings/2026-07-improvement-program.md +203 -0
  72. package/docs/migrations.md +6 -5
  73. package/docs/north-star.md +2 -2
  74. package/docs/operating-layers.md +4 -3
  75. package/docs/repository-structure.md +5 -5
  76. package/docs/skills-map.md +8 -8
  77. package/docs/spec/runtime-hook-surface.md +19 -15
  78. package/docs/spikes/graph-provider-2026-07.md +155 -0
  79. package/docs/standards-register.md +7 -7
  80. package/docs/trust-anchor-adoption.md +105 -4
  81. package/docs/work-item-adapters.md +1 -1
  82. package/docs/workflow-artifact-lifecycle.md +72 -9
  83. package/docs/workflow-eval-strategy.md +3 -3
  84. package/docs/workflow-shared-contracts.md +1 -1
  85. package/docs/workflow-usage-guide.md +254 -21
  86. package/evals/acceptance/DEMO-false-completion.md +20 -11
  87. package/evals/acceptance/prove-capture-teeth-declared.sh +15 -15
  88. package/evals/acceptance/prove-capture-teeth.sh +11 -11
  89. package/evals/acceptance/test_kiro_harness.sh +1 -1
  90. package/evals/ci/antigaming-suite.sh +5 -0
  91. package/evals/ci/run-baseline.sh +65 -1
  92. package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/flows/review.flow.json +26 -0
  93. package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/kit.json +13 -0
  94. package/evals/fixtures/flow-kit-repository/valid-with-dependency/flows/review.flow.json +26 -0
  95. package/evals/fixtures/flow-kit-repository/valid-with-dependency/kit.json +13 -0
  96. package/evals/fixtures/hook-influence/cases.json +32 -0
  97. package/evals/fixtures/pull-work-provider/github-issues.json +6 -6
  98. package/evals/fixtures/trust-reconcile-exploits/fabricated-attestation.json +59 -0
  99. package/evals/fixtures/trust-reconcile-exploits/no-label-bypass.json +36 -0
  100. package/evals/fixtures/trust-reconcile-exploits/skip-assumed-bypass.json +59 -0
  101. package/evals/fixtures/trust-reconcile-exploits/status-misassertion.json +48 -0
  102. package/evals/fixtures/trust-reconcile-exploits/waived-command-check.json +44 -0
  103. package/evals/fixtures/trust-reconcile-mixed-bundle/mixed-bundle.json +159 -0
  104. package/evals/fixtures/trust-reconcile-ws3/ws3-bundle.json +920 -0
  105. package/evals/integration/test_actor_identity.sh +254 -0
  106. package/evals/integration/test_builder_step_producers.sh +33 -23
  107. package/evals/integration/test_bundle_install.sh +22 -16
  108. package/evals/integration/test_bundle_lifecycle.sh +11 -11
  109. package/evals/integration/test_captured_fail_reconciliation.sh +50 -50
  110. package/evals/integration/test_checkpoint_signing.sh +43 -0
  111. package/evals/integration/test_claim_lookup.sh +4 -4
  112. package/evals/integration/test_codex_hook_resolution.sh +114 -0
  113. package/evals/integration/test_command_log_concurrency.sh +8 -8
  114. package/evals/integration/test_command_log_fork_classification.sh +3 -3
  115. package/evals/integration/test_command_log_integrity.sh +16 -16
  116. package/evals/integration/test_console_learning_projection.sh +1 -1
  117. package/evals/integration/test_dual_emit_flow_step.sh +145 -0
  118. package/evals/integration/test_enforcer_expects_driven.sh +12 -12
  119. package/evals/integration/test_evidence_capture_hook.sh +14 -13
  120. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  121. package/evals/integration/test_flow_agents_statusline.sh +2 -2
  122. package/evals/integration/test_flow_kit_install_git.sh +52 -0
  123. package/evals/integration/test_flow_kit_repository.sh +2 -0
  124. package/evals/integration/test_flowdef_session_activation.sh +6 -6
  125. package/evals/integration/test_flowdef_union_floor_regression.sh +429 -0
  126. package/evals/integration/test_gate_bypass_chain.sh +52 -40
  127. package/evals/integration/test_gate_lockdown.sh +97 -73
  128. package/evals/integration/test_gate_review_inquiry_records.sh +3 -3
  129. package/evals/integration/test_goal_fit_escape_hatch.sh +10 -10
  130. package/evals/integration/test_goal_fit_ghost_session.sh +104 -0
  131. package/evals/integration/test_goal_fit_hook.sh +32 -32
  132. package/evals/integration/test_goal_fit_rederive.sh +9 -9
  133. package/evals/integration/test_hook_category_behaviors.sh +103 -0
  134. package/evals/integration/test_hook_influence_cases.sh +1 -0
  135. package/evals/integration/test_install_merge.sh +262 -1
  136. package/evals/integration/test_kit_identity_trust.sh +6 -6
  137. package/evals/integration/test_liveness_conflict_injection.sh +587 -0
  138. package/evals/integration/test_liveness_heartbeat.sh +677 -0
  139. package/evals/integration/test_liveness_verdict.sh +394 -0
  140. package/evals/integration/test_local_flow_kit_install.sh +15 -0
  141. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -11
  142. package/evals/integration/test_promote_gate.sh +178 -0
  143. package/evals/integration/test_publish_delivery.sh +2 -2
  144. package/evals/integration/test_pull_work_liveness_preflight.sh +386 -0
  145. package/evals/integration/test_pull_work_provider.sh +2 -2
  146. package/evals/integration/test_reconcile_soundness.sh +4 -4
  147. package/evals/integration/test_resolvefirststep_security.sh +22 -0
  148. package/evals/integration/test_runtime_adapter_activation.sh +87 -18
  149. package/evals/integration/test_session_resume_roundtrip.sh +147 -0
  150. package/evals/integration/test_sidecar_field_preservation.sh +249 -0
  151. package/evals/integration/test_trust_reconcile.sh +2 -2
  152. package/evals/integration/test_trust_reconcile_manifest.sh +79 -0
  153. package/evals/integration/test_trust_reconcile_mixed_bundle.sh +96 -0
  154. package/evals/integration/test_trust_reconcile_negatives.sh +151 -0
  155. package/evals/integration/test_usage_feedback_global.sh +5 -5
  156. package/evals/integration/test_validate_artifacts_portability.sh +60 -0
  157. package/evals/integration/test_verify_cli.sh +2 -2
  158. package/evals/integration/test_veritas_governance_kit.sh +117 -0
  159. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +26 -1
  160. package/evals/integration/test_workflow_artifacts.sh +33 -0
  161. package/evals/integration/test_workflow_sidecar_writer.sh +723 -74
  162. package/evals/integration/test_workflow_steering_hook.sh +175 -5
  163. package/evals/lib/codex-provider.sh +1 -1
  164. package/evals/lib/node.sh +19 -1
  165. package/evals/run.sh +22 -0
  166. package/evals/static/test_decisions.sh +150 -0
  167. package/evals/static/test_flowdef_codeowners_coverage.sh +56 -0
  168. package/evals/static/test_package.sh +19 -10
  169. package/evals/static/test_universal_bundles.sh +48 -3
  170. package/evals/static/test_workflow_skills.sh +41 -8
  171. package/kits/builder/flows/build.flow.json +3 -74
  172. package/kits/builder/flows/publish-learn.flow.json +90 -0
  173. package/kits/builder/kit.json +11 -0
  174. package/kits/builder/skills/builder-shape/SKILL.md +1 -1
  175. package/kits/builder/skills/deliver/SKILL.md +13 -11
  176. package/kits/builder/skills/design-probe/SKILL.md +4 -4
  177. package/kits/builder/skills/evidence-gate/SKILL.md +26 -1
  178. package/kits/builder/skills/execute-plan/SKILL.md +2 -2
  179. package/kits/builder/skills/fix-bug/SKILL.md +2 -0
  180. package/kits/builder/skills/gate-review/SKILL.md +6 -6
  181. package/kits/builder/skills/idea-to-backlog/SKILL.md +1 -1
  182. package/kits/builder/skills/learning-review/SKILL.md +7 -7
  183. package/kits/builder/skills/pickup-probe/SKILL.md +5 -4
  184. package/kits/builder/skills/plan-work/SKILL.md +27 -5
  185. package/kits/builder/skills/pull-work/SKILL.md +80 -5
  186. package/kits/builder/skills/release-readiness/SKILL.md +4 -4
  187. package/kits/builder/skills/review-work/SKILL.md +24 -1
  188. package/kits/builder/skills/tdd-workflow/SKILL.md +2 -0
  189. package/kits/builder/skills/verify-work/SKILL.md +8 -1
  190. package/kits/catalog.json +6 -0
  191. package/kits/veritas-governance/adapter/readiness-to-trust-bundle.mjs +178 -0
  192. package/kits/veritas-governance/docs/README.md +75 -0
  193. package/kits/veritas-governance/fixtures/readiness/not-ready.readiness-report.json +4645 -0
  194. package/kits/veritas-governance/fixtures/readiness/ready.readiness-report.json +4403 -0
  195. package/kits/veritas-governance/flows/readiness-check.flow.json +35 -0
  196. package/kits/veritas-governance/kit.json +16 -0
  197. package/package.json +8 -4
  198. package/packaging/README.md +2 -2
  199. package/packaging/conformance/fixtures/config-protection--allow-read-state-json.json +20 -0
  200. package/packaging/conformance/fixtures/config-protection--block-write-state-json.json +20 -0
  201. package/packaging/conformance/fixtures/stop-goal-fit--block-bundle-disputed-claim.json +1 -1
  202. package/packaging/conformance/fixtures/stop-goal-fit--block-capture-contradicts-claimed-pass.json +1 -1
  203. package/packaging/manifest.json +9 -10
  204. package/schemas/decision-record.schema.json +109 -0
  205. package/schemas/workflow-evidence.schema.json +1 -1
  206. package/schemas/workflow-state.schema.json +5 -0
  207. package/scripts/README.md +8 -3
  208. package/scripts/check-content-boundary.cjs +1 -1
  209. package/scripts/check-decisions.cjs +356 -0
  210. package/scripts/ci/derive-claim-status.mjs +74 -0
  211. package/scripts/ci/trust-reconcile.js +436 -79
  212. package/scripts/git-status.sh +2 -2
  213. package/scripts/hooks/claude-telemetry-hook.js +37 -3
  214. package/scripts/hooks/codex-telemetry-hook.js +36 -2
  215. package/scripts/hooks/config-protection.js +102 -30
  216. package/scripts/hooks/evidence-capture.js +3 -3
  217. package/scripts/hooks/lib/actor-identity.js +368 -0
  218. package/scripts/hooks/lib/config-protection-remedies.js +69 -0
  219. package/scripts/hooks/lib/liveness-heartbeat.js +361 -0
  220. package/scripts/hooks/lib/liveness-policy.js +127 -0
  221. package/scripts/hooks/lib/liveness-read.js +105 -12
  222. package/scripts/hooks/lib/liveness-write.js +46 -0
  223. package/scripts/hooks/lib/local-artifact-paths.js +12 -6
  224. package/scripts/hooks/opencode-telemetry-hook.js +27 -1
  225. package/scripts/hooks/pi-telemetry-hook.js +27 -1
  226. package/scripts/hooks/stop-goal-fit.js +157 -26
  227. package/scripts/hooks/workflow-steering.js +77 -3
  228. package/scripts/install-codex-home.sh +138 -38
  229. package/scripts/statusline/flow-agents-statusline.js +2 -2
  230. package/src/cli/console-learning-projection.ts +3 -2
  231. package/src/cli/init.ts +101 -21
  232. package/src/cli/kit.ts +37 -6
  233. package/src/cli/public-api.test.mjs +58 -6
  234. package/src/cli/pull-work-provider.ts +1 -1
  235. package/src/cli/trust-bundle-policy-order.test.mjs +87 -0
  236. package/src/cli/usage-feedback.ts +3 -3
  237. package/src/cli/validate-hook-influence.ts +1 -0
  238. package/src/cli/validate-workflow-artifacts.ts +22 -6
  239. package/src/cli/veritas-readiness-adapter.test.mjs +267 -0
  240. package/src/cli/workflow-artifact-cleanup-audit.ts +35 -2
  241. package/src/cli/workflow-sidecar.ts +795 -71
  242. package/src/flow-kit/validate.ts +74 -1
  243. package/src/index.ts +6 -2
  244. package/src/lib/flow-resolver.ts +123 -55
  245. package/src/lib/local-artifact-root.ts +24 -5
  246. package/src/lib/workflow-learning-projection.ts +2 -2
  247. package/src/runtime-adapters.ts +61 -15
  248. package/src/tools/build-universal-bundles.ts +49 -16
  249. package/src/tools/generate-context-map.ts +10 -8
  250. package/src/tools/validate-source-tree.ts +23 -1
@@ -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,60 @@ 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
+ const claimMetadata = (waiver || promotionMeta) ? { ...(waiver ? { waiver } : {}), ...(promotionMeta ? { promotion: promotionMeta } : {}) } : null;
357
585
  const claimEvents = [];
358
586
  if (evStatus) {
359
587
  const evt = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [evId], createdAt: ts, verifiedAt: ts };
360
588
  events.push(evt);
361
589
  claimEvents.push(evt);
362
590
  }
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" };
591
+ 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
592
  if (captured) {
365
593
  evItem.sourceRef = `${slug}/command-log.jsonl`;
366
594
  evItem.collectedBy = "flow-agents/evidence-capture";
367
595
  evItem.execution = { runner: "bash", label: cmd, isError: captured.observedResult === "fail", ...(captured.exitCode != null ? { exitCode: captured.exitCode } : {}) };
368
596
  }
597
+ else if (cmd && !waiver) {
598
+ // WS8 (ADR 0020): always stamp execution.label on command-backed checks so the CI
599
+ // reconciler has a stable key to match against the manifest, even when the local
600
+ // command-log capture did not happen to run this command. isError is derived from
601
+ // the check's own reported status (no captured exit code available in this path).
602
+ evItem.execution = { runner: "bash", label: cmd, isError: effectiveStatus !== "pass" };
603
+ }
369
604
  evidenceItems.push(evItem);
370
605
  // P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
371
606
  // When record-gate-claim sets _gate_claim_expectation_id, pass it for exact lookup (ADR 0016 P-d Increment 2).
372
607
  const declared = matchExpectsEntry("check", check.kind, typeof check._gate_claim_expectation_id === "string" ? check._gate_claim_expectation_id : undefined);
373
608
  if (declared) {
374
609
  // 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 };
610
+ const declaredPolicy = ensurePolicy(declared.claimType, "high", [evClass.evidenceType]);
611
+ 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
612
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [evItem], events: claimEvents, policies: [declaredPolicy] });
378
613
  claims.push({ ...declaredClaimObj, status: declaredStatus });
379
614
  }
380
615
  else {
381
616
  // 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 };
617
+ 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
618
  const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [evItem], events: claimEvents, policies: [policy] });
384
619
  claims.push({ ...claimObj, status: derivedStatus });
385
620
  }
@@ -405,13 +640,13 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
405
640
  if (declared) {
406
641
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
407
642
  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 };
643
+ 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 };
409
644
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [], events: claimEvents, policies: [declaredPolicy] });
410
645
  claims.push({ ...declaredClaimObj, status: declaredStatus });
411
646
  }
412
647
  else {
413
648
  // 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 };
649
+ 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 };
415
650
  const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [policy] });
416
651
  claims.push({ ...claimObj, status: derivedStatus });
417
652
  }
@@ -437,19 +672,19 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
437
672
  if (declared) {
438
673
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
439
674
  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 };
675
+ const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: declaredPolicy.id };
441
676
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [], events: claimEvents, policies: [declaredPolicy] });
442
677
  claims.push({ ...declaredClaimObj, status: declaredStatus });
443
678
  }
444
679
  else {
445
680
  // 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 };
681
+ const claimObj = { id: claimId, subjectType: "workflow-critique", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: policy.id };
447
682
  const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [policy] });
448
683
  claims.push({ ...claimObj, status: derivedStatus });
449
684
  }
450
685
  }
451
686
  return {
452
- schemaVersion: 3,
687
+ schemaVersion: 5,
453
688
  source: `flow-agents/workflow-sidecar;statusFunctionVersion=${statusFunctionVersion}`,
454
689
  claims,
455
690
  evidence: evidenceItems,
@@ -485,7 +720,7 @@ export async function writeTrustBundle(dir, slug, timestamp, checks, criteria, c
485
720
  } }).filter((x) => x !== null);
486
721
  }
487
722
  catch { /* no capture log — fine */ }
488
- // ADR 0016 Abstraction A (P-d): pass the .flow-agents dir ONLY when current.json
723
+ // ADR 0016 Abstraction A (P-d): pass the runtime artifact root ONLY when current.json
489
724
  // points to this session (scoped active-flow guard). If current.json.artifact_dir
490
725
  // resolves to a different session, pass null — no active-flow claim mapping for this bundle.
491
726
  const _flowAgentsDir = path.dirname(dir);
@@ -726,6 +961,19 @@ function definitionAcceptanceLines(markdown) {
726
961
  }
727
962
  return out;
728
963
  }
964
+ /** Extract a top-level `name: value` markdown field's value (e.g. `branch: agent/x/y`), mirroring
965
+ * this file's own local-regex-parsing convention for section()/definitionAcceptanceLines() (a
966
+ * near-duplicate of validate-workflow-artifacts.ts's field()/section() helpers — this file
967
+ * already keeps its own local copy of that pattern rather than sharing a module). `name` is
968
+ * regex-escaped before interpolation (fix-plan iteration 1 F3), mirroring
969
+ * validate-workflow-artifacts.ts's own field() escaping verbatim — every current call site
970
+ * passes a fixed literal (e.g. "branch"), but escaping keeps a future non-literal name from
971
+ * silently changing match semantics via unescaped regex metacharacters. Returns "" when the
972
+ * field is absent. */
973
+ function markdownField(markdown, name) {
974
+ const re = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*(.+)$`, "m");
975
+ return re.exec(markdown)?.[1]?.trim() ?? "";
976
+ }
729
977
  function parseCriterion(line, index) {
730
978
  let text = line.replace(/^-\s+\[[ xX]\]\s*/, "").trim();
731
979
  const m = /\s+-\s+Evidence:\s*(.+)$/i.exec(text);
@@ -770,14 +1018,20 @@ function validateAgentId(agent) {
770
1018
  }
771
1019
  /**
772
1020
  * 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.
1021
+ * the nearest ancestor containing a kits/ subdirectory, without falling back to
1022
+ * process.cwd(). Returns null when no kits/ ancestor is found within the walk
1023
+ * bound (e.g. a scratch/temp session directory with no repo ancestor at all).
776
1024
  *
777
- * ADR 0016 Abstraction A (P-d): used by advance-state and ensure-session to
778
- * derive repoRoot for resolvePhaseMap calls.
1025
+ * WS5 iteration-2 part 2: extracted so publishDelivery's repo-root resolution can
1026
+ * be fail-closed (see findRepoRootFromDir below and publishDelivery). A scratch
1027
+ * test session dir (mktemp -d, no kits/ ancestor) must never resolve to whatever
1028
+ * repo happens to be the current process's cwd — that previously let a throwaway
1029
+ * eval-local trust.bundle silently clobber this real repo's delivery/trust.bundle
1030
+ * when the eval was run from a checkout of this repo (see
1031
+ * evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings
1032
+ * at .kontourai/flow-agents/ws5-governance-kit-slice1).
779
1033
  */
780
- function findRepoRootFromDir(startDir) {
1034
+ function findRepoRootFromDirStrict(startDir) {
781
1035
  let dir = startDir;
782
1036
  for (let i = 0; i < 16; i++) {
783
1037
  if (fs.existsSync(path.join(dir, "kits")))
@@ -787,7 +1041,23 @@ function findRepoRootFromDir(startDir) {
787
1041
  break;
788
1042
  dir = parent;
789
1043
  }
790
- return process.cwd();
1044
+ return null;
1045
+ }
1046
+ /**
1047
+ * Find the repository root by walking upward from a starting directory to locate
1048
+ * the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
1049
+ * findRepoRoot, but callable from workflow-sidecar.ts without re-importing the
1050
+ * internal helper. Falls back to process.cwd() when no kits/ ancestor is found —
1051
+ * appropriate for phase-map/first-step resolution (ADR 0016 Abstraction A, P-d),
1052
+ * where the caller is always invoked from within a real repo checkout.
1053
+ *
1054
+ * Do NOT use this cwd-falling-back variant for publishDelivery's repo-root
1055
+ * resolution — use findRepoRootFromDirStrict there instead, so a scratch/test
1056
+ * session dir with no repo ancestor fails closed (skips publish) rather than
1057
+ * silently trusting process.cwd(), which could be an unrelated real repo.
1058
+ */
1059
+ function findRepoRootFromDir(startDir) {
1060
+ return findRepoRootFromDirStrict(startDir) ?? process.cwd();
791
1061
  }
792
1062
  /**
793
1063
  * Resolve the first step id from a FlowDefinition's steps[] list.
@@ -824,7 +1094,11 @@ function resolveFirstStep(flowId, repoRoot) {
824
1094
  return null;
825
1095
  }
826
1096
  }
827
- function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId) {
1097
+ function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId, adHocReason) {
1098
+ // #289: mirror the active session's already-recorded branch (state.json.branch) into
1099
+ // current.json so consumers of current.json (which has no schema of its own — not one of the
1100
+ // 9 schemas under schemas/) see the routing branch without re-reading state.json separately.
1101
+ const branch = loadJson(path.join(dir, "state.json")).branch;
828
1102
  writeJson(path.join(root, "current.json"), {
829
1103
  schema_version: "1.0",
830
1104
  active_slug: path.basename(dir),
@@ -833,11 +1107,17 @@ function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId) {
833
1107
  owner,
834
1108
  source,
835
1109
  active_agents: [],
1110
+ ...(branch ? { branch } : {}),
836
1111
  // ADR 0016 Abstraction A (P-a): optional FlowDefinition routing keys for the producer
837
1112
  // and enforcer. Both fields are optional and backward-compatible — sessions without a
838
1113
  // FlowDefinition omit them and fall through to the workflow.* claim type path.
839
1114
  ...(flowId ? { active_flow_id: flowId } : {}),
840
1115
  ...(stepId ? { active_step_id: stepId } : {}),
1116
+ // WS8 (AC12): sanctioned ad-hoc direct entry marker. Set when --step-id explicitly
1117
+ // targets a step other than the flow's resolved first step, so stop-goal-fit /
1118
+ // gate-review can distinguish an intentional direct entry (e.g. a planning-only
1119
+ // session that skips pull-work) from a stale/mis-stamped active_step_id.
1120
+ ...(adHocReason ? { ad_hoc_entry: true, ad_hoc_reason: adHocReason } : {}),
841
1121
  });
842
1122
  }
843
1123
  function loadCurrent(root) {
@@ -851,7 +1131,15 @@ function currentDir(root) {
851
1131
  if (!c)
852
1132
  return null;
853
1133
  const dir = path.resolve(root, c.artifact_dir ?? c.active_slug ?? "");
854
- return fs.existsSync(dir) ? dir : null;
1134
+ if (!fs.existsSync(dir))
1135
+ return null;
1136
+ try {
1137
+ requireArtifactDirUnderRoot(dir, root);
1138
+ }
1139
+ catch {
1140
+ return null;
1141
+ }
1142
+ return dir;
855
1143
  }
856
1144
  function updateCurrentAgent(root, dir, agentId, status, timestamp) {
857
1145
  const cur = loadCurrent(root);
@@ -866,8 +1154,38 @@ function updateCurrentAgent(root, dir, agentId, status, timestamp) {
866
1154
  }
867
1155
  function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp, markdown) {
868
1156
  const criteria = markdown ? definitionAcceptanceLines(markdown).map(parseCriterion) : [];
1157
+ // #289/#309: `markdown` here is NOT always the session `<slug>--deliver.md` that
1158
+ // ensureSession seeds the `branch:` line into — initPlan is called against the tool-planner's
1159
+ // PLAN artifact (`<slug>--plan-work.md` etc.), a different file that typically carries no
1160
+ // `branch:` line at all. Since this function fully rewrites state.json (no merge with the
1161
+ // prior contents), naively re-deriving branch from `markdown` alone would clobber whatever
1162
+ // ensure-session already recorded there moments earlier (#309 regression). Resolve branch with
1163
+ // a three-tier fallback, preferring the most durable source first:
1164
+ // 1. EXISTING state.json.branch — once recorded, it is never re-derived or clobbered.
1165
+ // 2. The session's OWN canonical `<dir>/<slug>--deliver.md` on disk — the file ensureSession
1166
+ // always seeds the `branch:` line into, independent of whatever `markdown` was passed in.
1167
+ // Reading it directly (rather than trusting `markdown`) is what makes a repaired/backfilled
1168
+ // init-plan call (re-run against the same branch-less plan artifact, after a #309-era
1169
+ // session lost its state.json branch) recover the branch without a direct file edit.
1170
+ // 3. The `markdown` param itself (covers ensureSession's fresh-creation call, before
1171
+ // `<slug>--deliver.md` differs from `markdown`, and any direct/legacy caller that passes
1172
+ // the deliver markdown itself as the init-plan artifact).
1173
+ // This makes the branch survive every subsequent initSidecars call (init-plan, or a resumed
1174
+ // ensure-session) at this single choke point, without patching every other writer.
1175
+ const existingState = loadJson(path.join(dir, "state.json"));
1176
+ const existingBranch = existingState.branch;
1177
+ const deliverMdPath = path.join(dir, `${slug}--deliver.md`);
1178
+ const deliverBranch = fs.existsSync(deliverMdPath) ? markdownField(read(deliverMdPath), "branch") : "";
1179
+ const branch = existingBranch || deliverBranch || (markdown ? markdownField(markdown, "branch") : "");
1180
+ // #309 (scope addition): created_at is write-once, same class as the branch-drop bug above —
1181
+ // initSidecars fully rewrites state.json on every call (including a repair/backfill re-run of
1182
+ // init-plan against an already-existing session), so naively re-stamping created_at from the
1183
+ // current call's timestamp silently rewrites a session's original creation time. Preserve the
1184
+ // existing state.json's created_at when present; stamp only on true first-creation.
1185
+ // updated_at still reflects "now" on every call — that field is intentionally mutable.
869
1186
  writeJson(path.join(dir, "state.json"), {
870
- ...sidecarBase(slug), status: "planned", phase: "planning", created_at: timestamp, updated_at: timestamp,
1187
+ ...sidecarBase(slug), status: "planned", phase: "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
1188
+ ...(branch ? { branch } : {}),
871
1189
  artifact_paths: relArtifacts(dir),
872
1190
  next_action: { status: "continue", summary: nextAction || summary },
873
1191
  });
@@ -888,7 +1206,12 @@ function ensureSession(p) {
888
1206
  const timestamp = opt(p, "timestamp", now());
889
1207
  let md = fs.existsSync(path.join(dir, `${slug}--deliver.md`)) ? read(path.join(dir, `${slug}--deliver.md`)) : "";
890
1208
  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`;
1209
+ // #289: derive the routing branch ONLY on fresh session creation (this `if (!md)` guard).
1210
+ // An existing session's already-recorded `branch:` line is never touched — that whole branch
1211
+ // of code is skipped on a resumed/taken-over session, which is what makes ADR 0021 §5
1212
+ // takeover continuity true by construction (see Design Decision 3 in the plan).
1213
+ const branch = resolveSessionBranch(p, slug);
1214
+ 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
1215
  fs.writeFileSync(path.join(dir, `${slug}--deliver.md`), md);
893
1216
  }
894
1217
  if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
@@ -902,14 +1225,28 @@ function ensureSession(p) {
902
1225
  // ensure-session --flow-id builder.build produces a FlowDefinition-driven session even
903
1226
  // before the first advance-state call.
904
1227
  const flowId = opt(p, "flow-id");
905
- let stepId = opt(p, "step-id");
1228
+ const explicitStep = opt(p, "step-id");
1229
+ let stepId = explicitStep;
1230
+ let adHocReason;
906
1231
  if (flowId && !stepId) {
907
1232
  const repoRoot = findRepoRootFromDir(dir);
908
1233
  const firstStep = resolveFirstStep(flowId, repoRoot);
909
1234
  if (firstStep)
910
1235
  stepId = firstStep;
911
1236
  }
912
- writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined);
1237
+ else if (flowId && explicitStep) {
1238
+ // WS8 (AC12): --step-id is the sanctioned ad-hoc direct-entry mechanism. When it names
1239
+ // a step other than the flow's resolved first step, record an explicit ad_hoc_entry
1240
+ // marker (with a reason) instead of silently letting the mis-stamp look like the
1241
+ // flow's normal first step. This is the root-cause fix for a planning-only session
1242
+ // whose active_step_id would otherwise default to builder.build's first step.
1243
+ const repoRoot = findRepoRootFromDir(dir);
1244
+ const firstStep = resolveFirstStep(flowId, repoRoot);
1245
+ if (firstStep && firstStep !== explicitStep) {
1246
+ adHocReason = opt(p, "ad-hoc-reason") || `direct entry at step "${explicitStep}" via --step-id (flow first step is "${firstStep}")`;
1247
+ }
1248
+ }
1249
+ writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined, adHocReason);
913
1250
  console.log(dir);
914
1251
  return 0;
915
1252
  }
@@ -931,7 +1268,7 @@ function recordAgentEvent(p) {
931
1268
  die("artifact directory does not exist");
932
1269
  if (explicit && fs.lstatSync(dir).isSymbolicLink())
933
1270
  die(`artifact directory must not be a symlink: ${dir}`);
934
- if (hasExplicitRoot)
1271
+ if (hasExplicitRoot || !explicit)
935
1272
  requireArtifactDirUnderRoot(dir, root);
936
1273
  const timestamp = opt(p, "timestamp", now());
937
1274
  const agent = validateAgentId(opt(p, "agent-id"));
@@ -1014,7 +1351,7 @@ export function normalizeCheck(raw) {
1014
1351
  if (!check.id || !check.kind || !check.status || !check.summary)
1015
1352
  die("check requires id, kind, status, and summary");
1016
1353
  if (!checkKinds.has(check.kind))
1017
- die("kind must be one of: build, types, lint, test, security, diff, browser, runtime, policy, external");
1354
+ die("kind must be one of: build, types, lint, test, command, security, diff, browser, runtime, policy, external");
1018
1355
  if (!checkStatuses.has(check.status))
1019
1356
  die("status must be one of: pass, fail, not_verified, skip");
1020
1357
  if (Array.isArray(check.standard_refs))
@@ -1175,7 +1512,7 @@ export function writeState(dir, slug, status, phase, timestamp, summary, next =
1175
1512
  // "builder.verify.policy-compliance") so round-trip helpers broaden their filters
1176
1513
  // to include declared claims alongside the legacy workflow.* ones.
1177
1514
  //
1178
- // Safety guard: current.json in the .flow-agents dir records the CURRENTLY ACTIVE
1515
+ // Safety guard: current.json in the runtime artifact root records the CURRENTLY ACTIVE
1179
1516
  // session via artifact_dir. If current.json points to a different session than `dir`
1180
1517
  // (e.g. another session was the last to call advance-state --flow-definition), we
1181
1518
  // return an empty set so declared-type predicates are NOT applied to the wrong session.
@@ -1273,13 +1610,49 @@ function critiquesFromBundle(dir, declaredClaimTypes = new Set()) {
1273
1610
  }));
1274
1611
  }
1275
1612
  // ─────────────────────────────────────────────────────────────────────────────
1613
+ /**
1614
+ * WS8 (ADR 0020): parse the accepted-gap waiver flags. Both --accepted-gap-reason and
1615
+ * --waived-by are required together (an accepted gap with no justification or no
1616
+ * approver is refused — no silent/default waiver). Returns the waiver record to stamp
1617
+ * onto claim.metadata.waiver, or null when neither flag is present. Reuses the existing
1618
+ * accepted_gap -> assumed status mapping; adds no new canonical status value.
1619
+ */
1620
+ function parseWaiver(p, ts) {
1621
+ const reason = opt(p, "accepted-gap-reason");
1622
+ const waivedBy = opt(p, "waived-by");
1623
+ if (!reason && !waivedBy)
1624
+ return null;
1625
+ if (!reason)
1626
+ die("--accepted-gap-reason is required when --waived-by is set (an accepted gap must carry its justification)");
1627
+ if (!waivedBy)
1628
+ die("--waived-by is required when --accepted-gap-reason is set (an accepted gap must name its approver)");
1629
+ return { reason, approved_by: waivedBy, approved_at: ts };
1630
+ }
1276
1631
  async function recordEvidence(p) {
1277
1632
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1278
1633
  const verdict = opt(p, "verdict") || die("--verdict is required");
1279
1634
  if (!verdicts.has(verdict))
1280
1635
  die("verdict must be one of: pass, partial, fail, not_verified");
1281
1636
  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)];
1637
+ const _ts0 = opt(p, "timestamp", now());
1638
+ const _waiver = parseWaiver(p, _ts0);
1639
+ const _checksRaw = [...opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"))), ...opts(p, "surface-trust-json").map(surfaceCheckFromArtifact)];
1640
+ // WS8 (AC4, iteration 2): a command-backed check reconciles against CI or fails — it can
1641
+ // NEVER be waived. Reject --accepted-gap-reason/--waived-by on any check whose evidence
1642
+ // classifies as test_output (build/types/lint/test/command, and security/browser/runtime
1643
+ // WHEN a command is present). Only session-local checks (attestation/observation/citation/
1644
+ // diff/policy) are waivable. The CI reconciler enforces the same rule server-side (a waiver
1645
+ // on test_output evidence is a divergence), so this producer-side guard is defense-in-depth,
1646
+ // not the sole gate. (ADR 0020)
1647
+ if (_waiver) {
1648
+ for (const c of _checksRaw) {
1649
+ const hasCmd = typeof c.command === "string" && String(c.command).trim().length > 0;
1650
+ if (classifyEvidence(c.kind, hasCmd).evidenceType === "test_output") {
1651
+ 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)`);
1652
+ }
1653
+ }
1654
+ }
1655
+ const checks = _checksRaw.map((c) => _waiver ? { ...c, _waiver } : c);
1283
1656
  if (!checks.length && opts(p, "surface-trust-json").length === 0)
1284
1657
  die("record-evidence requires at least one --check-json or --surface-trust-json");
1285
1658
  validateAcceptanceEvidenceRefs(dir);
@@ -1377,11 +1750,101 @@ async function recordGateClaim(p) {
1377
1750
  check.artifact_refs = evidenceRefs;
1378
1751
  }
1379
1752
  const checkNormalized = normalizeCheck(check);
1753
+ // WS8 (ADR 0020): honor the accepted-gap waiver flags for a gate claim too.
1754
+ const gateWaiver = parseWaiver(p, ts);
1755
+ if (gateWaiver)
1756
+ checkNormalized._waiver = gateWaiver;
1380
1757
  // 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`);
1758
+ process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}${gateWaiver ? " (WAIVED accepted_gap)" : ""}\n`);
1382
1759
  assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], []));
1383
1760
  return 0;
1384
1761
  }
1762
+ /**
1763
+ * promote — the promote-then-archive gate (issue #312). Durable-residue extraction is
1764
+ * the archival act: this records WHAT durable residue was promoted WHERE and writes a
1765
+ * PROMOTION CLAIM into the session trust.bundle.
1766
+ *
1767
+ * Claim shape (reconcile-safe by construction): the promotion check is kind="policy",
1768
+ * so classifyEvidence maps it to evidenceType "policy_rule" (session-local, method
1769
+ * "auditability"). It carries NO command / execution.label, so it can NEVER require a
1770
+ * reconcile-manifest entry and can NEVER become a [not-run] / unbacked-command
1771
+ * divergence at CI trust-reconcile. Its status derives to `verified` from that
1772
+ * session-local policy_rule evidence item, so the reconciler classifies it session-local
1773
+ * and accepts it as an ATTESTED claim (exit 0) — never as a test_output claim that must
1774
+ * match the manifest. The _promotion marker rides onto claim.metadata.promotion (see
1775
+ * buildTrustBundle) so the archive gate and validators can detect it without new manifest
1776
+ * entries (R1).
1777
+ *
1778
+ * Modes:
1779
+ * promote <dir> --evidence-path <p> [--evidence-path <p> ...]
1780
+ * Records the durable doc paths written (docs/decisions/<slug>.md, CONTEXT.md,
1781
+ * docs/learnings/*, …). Each path MUST exist on disk at record time — a missing
1782
+ * path fails loud (no silent empty promotion).
1783
+ * promote <dir> --none --reason "<why nothing durable>"
1784
+ * An explicit, auditable no-residue promotion (R3).
1785
+ */
1786
+ async function promote(p) {
1787
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1788
+ const slug = taskSlugFor(dir, opt(p, "task-slug"));
1789
+ const ts = opt(p, "timestamp", now());
1790
+ const none = p.flags.has("none");
1791
+ const reason = opt(p, "reason");
1792
+ const repoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDir(dir);
1793
+ const rawPaths = opts(p, "evidence-path");
1794
+ if (none) {
1795
+ if (rawPaths.length)
1796
+ die("promote --none records a no-residue claim; do not also pass --evidence-path");
1797
+ if (!reason.trim())
1798
+ die("promote --none requires --reason \"<why nothing durable was promoted>\"");
1799
+ }
1800
+ else {
1801
+ if (!rawPaths.length)
1802
+ die("promote requires at least one --evidence-path <durable-doc-path> (or --none --reason \"<why>\" for an explicit no-residue promotion)");
1803
+ }
1804
+ // Every evidence ref MUST exist on disk at record time (fail loud otherwise). Store
1805
+ // repo-relative paths when the ref lives under the repo, so the claim is portable.
1806
+ const targets = [];
1807
+ for (const raw of rawPaths) {
1808
+ const abs = path.isAbsolute(raw) ? raw : path.resolve(repoRoot, raw);
1809
+ if (!fs.existsSync(abs))
1810
+ die(`promote --evidence-path does not exist on disk: ${raw} (resolved: ${abs}). Promotion evidence refs must point at durable docs that were actually written.`);
1811
+ const rel = path.relative(repoRoot, abs);
1812
+ targets.push(rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : raw);
1813
+ }
1814
+ const promotionMarker = {
1815
+ schema_version: "1.0",
1816
+ none,
1817
+ ...(none ? { reason } : {}),
1818
+ targets,
1819
+ promoted_at: ts,
1820
+ };
1821
+ const summary = opt(p, "summary") || (none
1822
+ ? `Promotion (no durable residue): ${reason}`
1823
+ : `Promoted durable residue: ${targets.join(", ")}`);
1824
+ // Session-local promotion check: kind="policy" -> policy_rule evidence, no command.
1825
+ const promotionCheck = { id: "promotion", kind: "policy", status: "pass", summary, _promotion: promotionMarker };
1826
+ // Add the promotion claim WITHOUT dropping the session's existing verification
1827
+ // evidence/criteria/critiques (mirror record-critique's merge pattern). Drop any prior
1828
+ // "promotion" check so re-running promote is idempotent rather than duplicating.
1829
+ const _dct = declaredClaimTypesFor(dir);
1830
+ const existingChecks = checksFromBundle(dir, _dct).filter((c) => c.id !== "promotion");
1831
+ const _acc = loadJson(path.join(dir, "acceptance.json"));
1832
+ const criteria = Array.isArray(_acc.criteria) ? _acc.criteria : [];
1833
+ const critiques = critiquesFromBundle(dir, _dct);
1834
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, [...existingChecks, promotionCheck], criteria, critiques));
1835
+ // Auditable record of what was promoted where (companion to the trust.bundle claim).
1836
+ writeJson(path.join(dir, "promotion.json"), { ...sidecarBase(slug), ...promotionMarker, summary });
1837
+ // Optionally republish so delivery/trust.bundle carries the promotion claim for CI.
1838
+ if (p.flags.has("publish")) {
1839
+ const publishRepoRoot = opt(p, "publish-repo-root") ? path.resolve(opt(p, "publish-repo-root")) : findRepoRootFromDirStrict(dir);
1840
+ await publishDelivery(dir, publishRepoRoot).catch((err) => {
1841
+ process.stderr.write(`[promote] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
1842
+ });
1843
+ }
1844
+ process.stderr.write(`[promote] recorded ${none ? "no-residue" : targets.length + " durable ref(s)"} promotion claim for ${slug}\n`);
1845
+ printJson({ ok: true, slug, none, targets, promotion_claim: "trust.bundle" });
1846
+ return 0;
1847
+ }
1385
1848
  async function advanceState(p) {
1386
1849
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1387
1850
  const status = opt(p, "status");
@@ -1438,7 +1901,14 @@ async function advanceState(p) {
1438
1901
  if (status === "delivered") {
1439
1902
  await sealTrustCheckpoint(dir, slug, timestamp, status, "release").catch(() => { });
1440
1903
  // Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
1441
- await publishDelivery(dir, findRepoRootFromDir(dir)).catch(() => { });
1904
+ // Fail-closed repo-root resolution (findRepoRootFromDirStrict, no cwd fallback) see
1905
+ // publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir
1906
+ // with no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures
1907
+ // are visible (stderr warning), not silently swallowed.
1908
+ const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
1909
+ await publishDelivery(dir, publishRepoRoot).catch((err) => {
1910
+ process.stderr.write(`[advance-state] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
1911
+ });
1442
1912
  }
1443
1913
  return 0;
1444
1914
  }
@@ -1511,7 +1981,14 @@ async function recordRelease(p) {
1511
1981
  // Trust checkpoint: seal at the "delivered" moment (the natural terminal mark for record-release).
1512
1982
  await sealTrustCheckpoint(dir, slug, payload.updated_at, "delivered", "release").catch(() => { });
1513
1983
  // Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
1514
- await publishDelivery(dir, findRepoRootFromDir(dir)).catch(() => { });
1984
+ // Fail-closed repo-root resolution (findRepoRootFromDirStrict, no cwd fallback) see
1985
+ // publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir with
1986
+ // no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures are
1987
+ // visible (stderr warning), not silently swallowed.
1988
+ const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
1989
+ await publishDelivery(dir, publishRepoRoot).catch((err) => {
1990
+ process.stderr.write(`[record-release] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
1991
+ });
1515
1992
  return 0;
1516
1993
  }
1517
1994
  // ─── Trust Checkpoint (Increment A) ──────────────────────────────────────────
@@ -1713,7 +2190,7 @@ async function sealCheckpoint(p) {
1713
2190
  }
1714
2191
  // ─── Publish Delivery Bundle ──────────────────────────────────────────────────
1715
2192
  // Copies the session's trust.bundle (+ checkpoint companions) from the gitignored
1716
- // session artifact dir (.flow-agents/<slug>/) to the committed delivery/ transport
2193
+ // session artifact dir (.kontourai/flow-agents/<slug>/) to the committed delivery/ transport
1717
2194
  // path so the CI trust-reconcile job can reconcile it against fresh CI results.
1718
2195
  //
1719
2196
  // Fail-soft: if trust.bundle is absent (no evidence recorded yet), does nothing.
@@ -1727,13 +2204,24 @@ async function sealCheckpoint(p) {
1727
2204
  * trust.checkpoint.intoto.json / trust.checkpoint.sig.json from the
1728
2205
  * session artifact dir to <repoRoot>/delivery/.
1729
2206
  *
1730
- * Fail-soft: if trust.bundle is absent, returns without throwing.
2207
+ * Fail-soft on a missing bundle: if trust.bundle is absent, returns without throwing.
2208
+ * Fail-CLOSED on repo-root resolution: repoRoot must be a real, resolved kits/ ancestor
2209
+ * (see findRepoRootFromDirStrict) — null (no ancestor found) skips the publish with a
2210
+ * visible warning instead of writing to whatever process.cwd() happens to be. This
2211
+ * prevents a scratch/test session dir (no kits/ ancestor) from silently clobbering an
2212
+ * unrelated real repo's delivery/trust.bundle when invoked with that repo as cwd (see
2213
+ * evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings at
2214
+ * .kontourai/flow-agents/ws5-governance-kit-slice1 for the root cause this fixes).
1731
2215
  * Idempotent: overwrites on re-delivery.
1732
2216
  */
1733
2217
  export async function publishDelivery(dir, repoRoot) {
1734
2218
  const bundleSrc = path.join(dir, "trust.bundle");
1735
2219
  if (!fs.existsSync(bundleSrc))
1736
2220
  return; // no bundle — skip gracefully
2221
+ if (!repoRoot) {
2222
+ 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`);
2223
+ return;
2224
+ }
1737
2225
  const deliveryDir = path.join(repoRoot, "delivery");
1738
2226
  fs.mkdirSync(deliveryDir, { recursive: true });
1739
2227
  // Required: trust.bundle (the CI anchor)
@@ -1764,7 +2252,10 @@ export async function publishDelivery(dir, repoRoot) {
1764
2252
  */
1765
2253
  async function publishDeliveryCmd(p) {
1766
2254
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1767
- const repoRoot = opt(p, "repo-root") || findRepoRootFromDir(dir);
2255
+ // Fail-closed: an explicit --repo-root always wins; otherwise resolve strictly (no
2256
+ // process.cwd() fallback) so a scratch/test artifact dir cannot accidentally publish
2257
+ // into whichever repo happens to be the current working directory.
2258
+ const repoRoot = opt(p, "repo-root") || findRepoRootFromDirStrict(dir);
1768
2259
  await publishDelivery(dir, repoRoot);
1769
2260
  return 0;
1770
2261
  }
@@ -1971,13 +2462,13 @@ async function dogfoodPass(p) {
1971
2462
  return 0;
1972
2463
  }
1973
2464
  /**
1974
- * Read the gate block signal from .flow-agents/.goal-fit-block-streak.json
2465
+ * Read the gate block signal from .kontourai/flow-agents/.goal-fit-block-streak.json
1975
2466
  * (written by scripts/hooks/stop-goal-fit.js when block mode fires).
1976
2467
  * The file sits at <artifact-root>/.goal-fit-block-streak.json — one level
1977
2468
  * above the session artifact dir. Fail-open: returns { blocked: false } when
1978
2469
  * the file is absent or unreadable.
1979
2470
  *
1980
- * @param artifactRoot The .flow-agents root dir (parent of session slug dir).
2471
+ * @param artifactRoot The runtime artifact root dir (parent of session slug dir).
1981
2472
  */
1982
2473
  export function readGateBlockSignal(artifactRoot) {
1983
2474
  const streakFile = path.join(artifactRoot, ".goal-fit-block-streak.json");
@@ -2183,7 +2674,7 @@ export function buildGateInquiryRecords(bundle, blockSignal, slug, expectedCrite
2183
2674
  *
2184
2675
  * The block signal is read from <artifact-root>/.goal-fit-block-streak.json,
2185
2676
  * 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).
2677
+ * lives one level above the session slug dir (the runtime artifact root).
2187
2678
  *
2188
2679
  * If @kontourai/surface is unavailable, logs a warning and returns 0
2189
2680
  * (fail-open — no bespoke fork fallback).
@@ -2206,7 +2697,7 @@ async function gateReview(p) {
2206
2697
  return 0;
2207
2698
  }
2208
2699
  const bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
2209
- // Read gate block signal from .flow-agents root (one level above session dir)
2700
+ // Read gate block signal from the runtime artifact root (one level above session dir)
2210
2701
  const artifactRoot = path.dirname(dir);
2211
2702
  const blockSignal = readGateBlockSignal(artifactRoot);
2212
2703
  // Enumerate expected criterion IDs: primary = bundle claims (workflow.acceptance.criterion),
@@ -2370,7 +2861,7 @@ function trustMcp(p) {
2370
2861
  if (mode === "print") {
2371
2862
  console.log(JSON.stringify({ mcpServers: { [TRUST_MCP_SERVER]: trustMcpRegistration() } }, null, 2));
2372
2863
  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`);
2864
+ process.stderr.write(`# To view a task's trust inline, call surface_summary with path=<.kontourai/flow-agents/<slug>/trust.bundle>.\n`);
2374
2865
  return 0;
2375
2866
  }
2376
2867
  if (mode !== "enable" && mode !== "disable")
@@ -2422,12 +2913,19 @@ const LIVENESS_POLICY = {
2422
2913
  conflictRules: [],
2423
2914
  impactLevel: "medium",
2424
2915
  };
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`);
2916
+ /**
2917
+ * Delegate to the shared pure-CJS writer (scripts/hooks/lib/liveness-write.js), mirroring the
2918
+ * createRequire pattern used by loadActorIdentityHelper() below. Deliberately NO inline duplicate
2919
+ * fallback — the whole point of #288 Wave 2 Task 2.1 is one writer shared by the CLI and the
2920
+ * hook wrappers' tool-activity heartbeat, not two copies of the append shape that can drift.
2921
+ */
2922
+ function loadLivenessWriteHelper() {
2923
+ const _req = createRequire(import.meta.url);
2924
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-write.js");
2925
+ return _req(helperPath);
2430
2926
  }
2927
+ function livenessStreamFile(root) { return loadLivenessWriteHelper().livenessStreamFile(root); }
2928
+ function appendLivenessEvent(root, evt) { loadLivenessWriteHelper().appendLivenessEvent(root, evt); }
2431
2929
  function readLivenessEvents(root) {
2432
2930
  // Delegate to the shared pure-CJS helper (scripts/hooks/lib/liveness-read.js).
2433
2931
  // Using createRequire so the ESM sidecar can load a CJS module without bundling it.
@@ -2463,39 +2961,158 @@ function livenessLabel(status) {
2463
2961
  return "superseded";
2464
2962
  return status;
2465
2963
  }
2466
- // ─── ADR 0012 lifecycle-driven liveness (opt-in via FLOW_AGENTS_LIVENESS) ──────
2964
+ // ─── ADR 0012 lifecycle-driven liveness (default-on; opt-out via FLOW_AGENTS_LIVENESS) ──
2467
2965
  // init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
2468
2966
  // so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
2469
2967
  // Additive + fail-open: a liveness-emit failure never affects the workflow command.
2470
2968
  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"; }
2969
+ /**
2970
+ * Delegate to the shared pure-CJS resolver (scripts/hooks/lib/actor-identity.js), mirroring the
2971
+ * createRequire pattern used by readLivenessEvents() above. Deliberately NO inline duplicate
2972
+ * fallback: if the module fails to load, that failure itself must surface as an unresolved actor
2973
+ * ("") — never silently degrade back to the retired "local" default (issue #287).
2974
+ */
2975
+ function loadActorIdentityHelper() {
2976
+ const _req = createRequire(import.meta.url);
2977
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
2978
+ return _req(helperPath);
2979
+ }
2980
+ function resolveLivenessActor() {
2981
+ return loadActorIdentityHelper().resolveActor(process.env).actor;
2982
+ }
2983
+ // isUnresolvedActor is no longer defined locally — it is single-sourced in
2984
+ // scripts/hooks/lib/actor-identity.js (loadActorIdentityHelper().isUnresolvedActor) so the
2985
+ // lifecycle auto-emit path, the direct CLI liveness path, and the tool-activity heartbeat path
2986
+ // all consume the same predicate rather than forking their own copy (#287 re-review MEDIUM; #288
2987
+ // Wave 1 Task 1.1 single-sources it there).
2988
+ /**
2989
+ * Delegate to the shared pure-CJS policy predicates (scripts/hooks/lib/liveness-policy.js) — the
2990
+ * one definition of "enabled" (default-on/opt-out) and the claim TTL default, consumed by both the
2991
+ * lifecycle auto-emit path and the manual `liveness claim --ttl` default so they can never disagree
2992
+ * (#288).
2993
+ */
2994
+ function loadLivenessPolicyHelper() {
2995
+ const _req = createRequire(import.meta.url);
2996
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-policy.js");
2997
+ return _req(helperPath);
2998
+ }
2999
+ function livenessEnabled() { return loadLivenessPolicyHelper().isLivenessEnabled(process.env); }
3000
+ /**
3001
+ * F1 (#288 fix iteration 1, cr-HIGH fail-open violation): the `livenessEnabled()`
3002
+ * guard (and therefore its `loadLivenessPolicyHelper()` module load) must sit
3003
+ * INSIDE this function's own fail-open try/catch — previously it sat outside,
3004
+ * so a missing/broken scripts/hooks/lib/liveness-policy.js module made
3005
+ * `init-plan`/`advance-state` exit 1 instead of degrading gracefully
3006
+ * (repro-verified). Now: any failure here — including a failed helper load —
3007
+ * is caught, produces one stderr diagnostic, and the lifecycle auto-emit is
3008
+ * skipped; the workflow command's own exit code is never affected. This
3009
+ * mirrors the #287 fail-open convention already used elsewhere in this file.
3010
+ * The direct CLI write path (`async function liveness`, actions
3011
+ * claim|heartbeat|release) is deliberately NOT wrapped this way — it stays
3012
+ * fail-loud on a missing helper module, per the plan's explicit instruction
3013
+ * that only the convenience lifecycle wiring is flag-gated/fail-open.
3014
+ */
2473
3015
  function livenessLifecycle(taskDir, slug, kind, timestamp) {
2474
- if (!livenessEnabled())
2475
- return;
2476
3016
  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" };
3017
+ if (!livenessEnabled())
3018
+ return;
3019
+ const actor = resolveLivenessActor();
3020
+ if (loadActorIdentityHelper().isUnresolvedActor(actor)) {
3021
+ process.stderr.write("[liveness] skipped auto-emit: actor unresolved (set FLOW_AGENTS_ACTOR or run inside a supported runtime)\n");
3022
+ return;
3023
+ }
3024
+ const root = path.dirname(taskDir); // .kontourai/flow-agents/<slug> → .kontourai/flow-agents (the shared liveness stream lives here)
3025
+ const evt = { type: kind, subjectId: slug, actor, at: timestamp, source: "lifecycle" };
2479
3026
  if (kind === "claim")
2480
- evt.ttlSeconds = 1800;
3027
+ evt.ttlSeconds = loadLivenessPolicyHelper().resolveTtlSeconds(process.env);
2481
3028
  appendLivenessEvent(root, evt);
2482
3029
  }
2483
- catch { /* best-effort; liveness is advisory and must never break the workflow */ }
3030
+ catch (err) {
3031
+ // best-effort; liveness is advisory and must never break the workflow — but the failure
3032
+ // itself must be visible (F1), not silently absorbed.
3033
+ try {
3034
+ const detail = err instanceof Error ? err.message : String(err);
3035
+ process.stderr.write(`[liveness] skipped auto-emit: ${detail}\n`);
3036
+ }
3037
+ catch { /* best-effort diagnostic only */ }
3038
+ }
3039
+ }
3040
+ /**
3041
+ * F5 (#288 fix iteration 1, sec-LOW): strip control/escape characters before echoing a
3042
+ * subjectId/actor to the terminal. subjectId is a raw CLI positional argument on the write path
3043
+ * (never sanitized before this point — it is stored as-is in the event for data fidelity) and,
3044
+ * on the `status` read path, both subjectId and actor may originate from a hand-edited or
3045
+ * otherwise hostile liveness/events.jsonl file rather than this process's own writes. Strips the
3046
+ * C0 control range (0x00-0x1F), DEL (0x7F), and the C1 range (0x80-0x9F, which includes
3047
+ * ANSI-CSI-adjacent bytes) — display-only; the persisted event itself is never mutated.
3048
+ */
3049
+ function stripControlCharsForDisplay(value) {
3050
+ return String(value ?? "").replace(/[\u0000-\u001F\u007F-\u009F]/g, "");
2484
3051
  }
2485
3052
  async function liveness(p) {
2486
3053
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
2487
3054
  const action = p.positional[0] || "";
2488
3055
  const subjectId = p.positional[1] || "";
2489
- const actor = opt(p, "actor", process.env.FLOW_AGENTS_ACTOR || "unknown");
2490
3056
  const nowIso = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
3057
+ if (action === "whoami") {
3058
+ // Read-only, lock-free, write-free advisory surface: reuses the identical resolution chain
3059
+ // as the write paths (loadActorIdentityHelper().resolveActor) but deliberately never dies on
3060
+ // an unresolved actor — the enforcement point stays at `liveness claim`, which already dies
3061
+ // loudly (see below). This lets a skill learn "who am I" during a read-only preflight without
3062
+ // emitting a bogus claim first.
3063
+ const helper = loadActorIdentityHelper();
3064
+ const explicitActorRaw = opt(p, "actor", "");
3065
+ const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
3066
+ const resolved = explicitActor
3067
+ ? { actor: explicitActor, source: "explicit-override" }
3068
+ : helper.resolveActor(process.env);
3069
+ if (p.flags.has("json")) {
3070
+ console.log(JSON.stringify(resolved));
3071
+ return 0;
3072
+ }
3073
+ console.log(`${stripControlCharsForDisplay(resolved.actor || "unresolved")}\t${stripControlCharsForDisplay(resolved.source)}`);
3074
+ return 0;
3075
+ }
2491
3076
  if (action === "claim" || action === "heartbeat" || action === "release") {
3077
+ // Actor resolution happens only for write actions (F5, #287 fix iteration 1) — "status" is a
3078
+ // read path and must not shell out via resolveLivenessActor()/resolveActor().
3079
+ const helper = loadActorIdentityHelper();
3080
+ const explicitActorRaw = opt(p, "actor", "");
3081
+ // F7 (#287 fix iteration 2): an explicit --actor value that strips to empty under the allowed
3082
+ // [A-Za-z0-9_.-] charset (e.g. "--actor ':::'") is a hard error on this write path, not a
3083
+ // silent fallback to sanitizeSegment's shared "unknown" sentinel — garbage on an explicit flag
3084
+ // is an authoring mistake the caller must fix, unlike the env-override seam (which falls
3085
+ // through to derivation instead, since there is no flag to correct).
3086
+ if (explicitActorRaw && !/[A-Za-z0-9_.-]/.test(explicitActorRaw)) {
3087
+ 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.`);
3088
+ }
3089
+ const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
3090
+ const actor = explicitActor || helper.resolveActor(process.env).actor;
3091
+ if (helper.isUnresolvedActor(actor)) {
3092
+ 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.`);
3093
+ }
2492
3094
  if (!subjectId)
2493
3095
  die(`liveness ${action} requires a subjectId`);
3096
+ // F8(i) (#288 fix iteration 2, orphan-heartbeat invariant): heartbeat/release on the direct CLI
3097
+ // write path must never be the FIRST liveness event ever written for a (subjectId, actor) pair —
3098
+ // that is exactly how the reviewer-reproduced orphan-heartbeat bug arises (a bare heartbeat with
3099
+ // no claim behind it, later mistaken for claim evidence). A full stream read here is fine: this
3100
+ // is a rare, human/CLI-driven call, not the hot tool-activity path. `claim` itself is exempt (it
3101
+ // is the event that ESTABLISHES the pair in the first place).
3102
+ if (action === "heartbeat" || action === "release") {
3103
+ const priorEvents = readLivenessEvents(root);
3104
+ const hasPriorClaim = priorEvents.some((e) => e && e.type === "claim" && e.subjectId === subjectId && e.actor === actor);
3105
+ if (!hasPriorClaim) {
3106
+ 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}.`);
3107
+ }
3108
+ }
2494
3109
  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;
3110
+ if (action === "claim") {
3111
+ const defaultTtl = loadLivenessPolicyHelper().resolveTtlSeconds(process.env);
3112
+ evt.ttlSeconds = Number.parseInt(opt(p, "ttl", String(defaultTtl)), 10) || defaultTtl;
3113
+ }
2497
3114
  appendLivenessEvent(root, evt);
2498
- console.log(`liveness ${action}: ${subjectId} by ${actor}`);
3115
+ console.log(`liveness ${action}: ${stripControlCharsForDisplay(subjectId)} by ${stripControlCharsForDisplay(actor)}`);
2499
3116
  return 0;
2500
3117
  }
2501
3118
  if (action === "status") {
@@ -2531,7 +3148,7 @@ async function liveness(p) {
2531
3148
  for (const g of groups.values()) {
2532
3149
  if (subjectFilter && g.subjectId !== subjectFilter)
2533
3150
  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 };
3151
+ 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
3152
  const status = surface.deriveTrustStatus({ claim, evidence: [], policy: LIVENESS_POLICY, events: g.events, now });
2536
3153
  rows.push({ subjectId: g.subjectId, actor: g.actor, status, label: livenessLabel(status) });
2537
3154
  }
@@ -2540,10 +3157,99 @@ async function liveness(p) {
2540
3157
  return 0;
2541
3158
  }
2542
3159
  for (const r of rows)
2543
- console.log(`${r.subjectId}\t${r.actor}\t${r.label}`);
3160
+ console.log(`${stripControlCharsForDisplay(r.subjectId)}\t${stripControlCharsForDisplay(r.actor)}\t${r.label}`);
3161
+ return 0;
3162
+ }
3163
+ if (action === "verdict") {
3164
+ // Read-only, lock-free CLI helper (#320 AC1) — mirrors `whoami`'s lock-bypass (see
3165
+ // isLivenessVerdict in main() below): computes {subjectId, winner, losers, reason, holders}
3166
+ // as a PURE function of the shared liveness stream. Among the subject's currently-fresh
3167
+ // claim holders (via the shared `freshHolders` helper — the exact canonical
3168
+ // freshness/grouping/release-handling logic `workflow-steering.js`'s ambient digest already
3169
+ // uses, not a re-derived rule), the holder whose most recent `claim` event has the earliest
3170
+ // `at` wins; an exact-timestamp tie breaks by ascending plain string comparison of actor id
3171
+ // (never locale-aware collation, for cross-machine determinism). Same stream state ⇒ same
3172
+ // verdict, regardless of which actor invokes it — no actor-specific input is read here.
3173
+ if (!subjectId)
3174
+ die("liveness verdict requires a subjectId");
3175
+ const events = readLivenessEvents(root);
3176
+ const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : Date.now();
3177
+ const _req = createRequire(import.meta.url);
3178
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
3179
+ const { freshHolders } = _req(helperPath);
3180
+ // Impossible-sentinel selfActor: real actors are sanitized to [A-Za-z0-9_.-]+ and can never be
3181
+ // empty (enforced on every write path above), so passing "" excludes nothing.
3182
+ const fresh = freshHolders(events, subjectId, "", nowMs);
3183
+ // freshHolders' `lastAt` tracks the group's latest event of ANY type (heartbeat-updated); the
3184
+ // tiebreak instead operates on each holder's most recent `claim`-type event `at` — the
3185
+ // "current claim timestamp", distinct from lastAt.
3186
+ const latestClaimAt = (actor) => {
3187
+ let best = "";
3188
+ for (const e of events) {
3189
+ if (e && e.subjectId === subjectId && e.actor === actor && e.type === "claim" && typeof e.at === "string") {
3190
+ if (!best || e.at > best)
3191
+ best = e.at;
3192
+ }
3193
+ }
3194
+ return best;
3195
+ };
3196
+ const holders = fresh
3197
+ .map((h) => ({ actor: String(h.actor), claimAt: latestClaimAt(String(h.actor)) || String(h.lastAt), lastAt: String(h.lastAt), ttlSeconds: Number(h.ttlSeconds) }))
3198
+ .sort((a, b) => (a.claimAt < b.claimAt ? -1 : a.claimAt > b.claimAt ? 1 : a.actor < b.actor ? -1 : a.actor > b.actor ? 1 : 0));
3199
+ let winner = null;
3200
+ let losers = [];
3201
+ let reason = "no-conflict";
3202
+ if (holders.length >= 2) {
3203
+ winner = holders[0];
3204
+ const tie = holders.filter((h) => h.claimAt === winner.claimAt).length > 1;
3205
+ reason = tie ? "tie-actor-lexicographic" : "earlier-claim";
3206
+ losers = holders.filter((h) => h.actor !== winner.actor);
3207
+ }
3208
+ if (p.flags.has("json")) {
3209
+ // #320 fix iteration 1 (sec-HIGH F3): `winner`/`losers`/`holders` carry raw actor/claimAt/
3210
+ // lastAt strings sourced straight from the multi-writer append-only liveness/events.jsonl
3211
+ // stream (see stripControlCharsForDisplay's header above — any process can append a
3212
+ // hostile line, bypassing the CLI write-side sanitizeSegment entirely). The pull-work
3213
+ // SKILL.md's Post-Claim Conflict Re-check instructs an LLM to read `winner.actor` /
3214
+ // `losers[].actor` straight out of THIS `--json` output into its own context — a second,
3215
+ // LLM-facing injection path alongside the mid-turn conflict hook (liveness-heartbeat.js's
3216
+ // computeConflict). Build a sanitized COPY for JSON emission only: control-char strip
3217
+ // (matching the text branch's existing stripControlCharsForDisplay treatment below) plus a
3218
+ // 64-char cap (matching sanitizeSegment's cap on the write side) on actor/claimAt/lastAt.
3219
+ // `reason`/`subjectId`/`ttlSeconds` are left as-is (enum/number, never free text). Never
3220
+ // mutates `result`/`holders`/`winner`/`losers` themselves — the text branch below still
3221
+ // needs the untouched originals for its own per-line rendering, and winner/loser identity
3222
+ // (already sanitizeSegment-clean for legitimate actors) is unaffected, so the loser-release
3223
+ // contract still works.
3224
+ const sanitizeVerdictHolderForJson = (h) => h
3225
+ ? {
3226
+ ...h,
3227
+ actor: stripControlCharsForDisplay(h.actor).slice(0, 64),
3228
+ claimAt: stripControlCharsForDisplay(h.claimAt).slice(0, 64),
3229
+ lastAt: stripControlCharsForDisplay(h.lastAt).slice(0, 64),
3230
+ }
3231
+ : h;
3232
+ const jsonResult = {
3233
+ subjectId,
3234
+ winner: sanitizeVerdictHolderForJson(winner),
3235
+ losers: losers.map((l) => sanitizeVerdictHolderForJson(l)),
3236
+ reason,
3237
+ holders: holders.map((h) => sanitizeVerdictHolderForJson(h)),
3238
+ };
3239
+ console.log(JSON.stringify(jsonResult));
3240
+ return 0;
3241
+ }
3242
+ for (const h of holders) {
3243
+ const tag = winner && h.actor === winner.actor ? "WINNER" : (holders.length >= 2 ? "LOSER" : "HOLDER");
3244
+ console.log(`${stripControlCharsForDisplay(h.actor)}\tclaimAt=${stripControlCharsForDisplay(h.claimAt)}\t${tag}`);
3245
+ }
3246
+ if (winner)
3247
+ console.log(`WINNER: ${stripControlCharsForDisplay(winner.actor)}`);
3248
+ for (const l of losers)
3249
+ console.log(`LOSER: ${stripControlCharsForDisplay(l.actor)}`);
2544
3250
  return 0;
2545
3251
  }
2546
- die("liveness action must be one of: claim | heartbeat | release | status");
3252
+ die("liveness action must be one of: claim | heartbeat | release | status | whoami | verdict");
2547
3253
  return 1;
2548
3254
  }
2549
3255
  // ─────────────────────────────────────────────────────────────────────────────
@@ -2707,9 +3413,24 @@ async function main() {
2707
3413
  const p = parseArgs(process.argv.slice(2));
2708
3414
  if (!p.command)
2709
3415
  die("workflow-sidecar command is required");
2710
- const lockRoot = ["ensure-session", "current", "dogfood-pass", "liveness"].includes(p.command)
3416
+ // F1 (#166 fix iteration 1): `liveness whoami` is a read-only, lock-free, write-free advisory
3417
+ // surface (see the `action === "whoami"` branch inside `liveness()` above) — it must never
3418
+ // acquire the workflow-sidecar lock, regardless of whether the artifact root already exists on
3419
+ // disk. Without this action-level bypass, `liveness` was blanket-included in the lock-routing
3420
+ // branch below, so `whoami` against an EXISTING artifact root would still resolve a real
3421
+ // lockRoot and go through `withLock`'s mkdir/lockdir path — the opposite of "genuinely
3422
+ // lock-free". This bypass mirrors `resolve-slug`'s existing empty-lockRoot special case
3423
+ // immediately below and is scoped to the `whoami` action only: `liveness status` (a read path)
3424
+ // keeps its pre-existing lock behavior unchanged (out of scope for this fix — see fix-plan
3425
+ // iteration 1, F1), and `liveness claim` / `heartbeat` / `release` (write paths) are untouched.
3426
+ const isLivenessWhoami = p.command === "liveness" && p.positional[0] === "whoami";
3427
+ // #320 AC1: `liveness verdict` is read-only and lock-free, exactly like `whoami` above — it
3428
+ // must never acquire the workflow-sidecar lock. Same bypass shape, scoped to the `verdict`
3429
+ // action only; `liveness status` (also a read path) keeps its pre-existing lock behavior.
3430
+ const isLivenessVerdict = p.command === "liveness" && p.positional[0] === "verdict";
3431
+ const lockRoot = (["ensure-session", "current", "dogfood-pass", "liveness"].includes(p.command) && !isLivenessWhoami && !isLivenessVerdict)
2711
3432
  ? (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]) : "";
3433
+ : 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
3434
  return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
2714
3435
  switch (p.command) {
2715
3436
  case "ensure-session": return ensureSession(p);
@@ -2718,6 +3439,7 @@ async function main() {
2718
3439
  case "init-plan": return initPlan(p);
2719
3440
  case "record-evidence": return recordEvidence(p);
2720
3441
  case "record-gate-claim": return recordGateClaim(p);
3442
+ case "promote": return promote(p);
2721
3443
  case "advance-state": return advanceState(p);
2722
3444
  case "record-critique": return recordCritique(p);
2723
3445
  case "import-critique": return importCritique(p);
@@ -2729,6 +3451,7 @@ async function main() {
2729
3451
  case "trust-mcp": return trustMcp(p);
2730
3452
  case "liveness": return liveness(p);
2731
3453
  case "claim": return claimLookup(p);
3454
+ case "resolve-slug": return resolveSlugCmd(p);
2732
3455
  case "seal-checkpoint": return sealCheckpoint(p);
2733
3456
  case "publish-delivery": return publishDeliveryCmd(p);
2734
3457
  default: die(`unknown command: ${p.command}`);