@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
@@ -13,7 +13,7 @@ type AnyObj = Record<string, any>;
13
13
 
14
14
  export const statuses = new Set(["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "accepted", "archived"]);
15
15
  export const phases = ["idea", "backlog", "pickup", "planning", "execution", "verification", "goal_fit", "evidence", "release", "learning", "done"];
16
- export const checkKinds = new Set(["build", "types", "lint", "test", "security", "diff", "browser", "runtime", "policy", "external"]);
16
+ export const checkKinds = new Set(["build", "types", "lint", "test", "command", "security", "diff", "browser", "runtime", "policy", "external"]);
17
17
  export const checkStatuses = new Set(["pass", "fail", "not_verified", "skip"]);
18
18
  export const verdicts = new Set(["pass", "partial", "fail", "not_verified"]);
19
19
 
@@ -50,6 +50,144 @@ function workItemSlug(ref: string): string {
50
50
  return slugify(`${owner}-${repo}-${id}`, "work-item");
51
51
  }
52
52
 
53
+ /** Pure, lock-free, side-effect-free CLI wrapper around workItemSlug() — the single source of
54
+ * truth for the deterministic subjectId/session-directory-name slug. Named resolveSlugCmd (not
55
+ * resolveSlug) to avoid colliding with any future export named resolveSlug. */
56
+ function resolveSlugCmd(p: ReturnType<typeof parseArgs>): number {
57
+ const ref = p.positional[0] || die("resolve-slug requires an owner/repo#id ref");
58
+ console.log(workItemSlug(ref));
59
+ return 0;
60
+ }
61
+
62
+ /** First 6 hex chars of sha256(raw) — a short deterministic disambiguator (#289 F4). Two
63
+ * different raw inputs that both collapse to a segment's "unknown" fallback (e.g. two distinct
64
+ * all-garbage --task-slug values, or two distinct raw actor strings that both resolve
65
+ * "unresolved") would otherwise derive the identical fallback branch — this makes them diverge
66
+ * while staying fully deterministic for a given raw input (same raw -> same hash every call),
67
+ * which is required to preserve resolveSessionBranch's no-rederive/resume-continuity semantics
68
+ * (a resumed session's already-recorded branch is never recomputed regardless of this helper). */
69
+ function unknownDisambiguator(raw: string): string {
70
+ return createHash("sha256").update(raw).digest("hex").slice(0, 6);
71
+ }
72
+
73
+ /** Encode a value (actor string or --task-slug, neither guaranteed git-ref-safe) into a
74
+ * single git-check-ref-format(1)-safe branch path component (#289). Reuses
75
+ * actor-identity.js's sanitizeSegment (consume, don't fork) as the base, then closes the
76
+ * git-specific gaps sanitizeSegment's [A-Za-z0-9_.-] charset does not: the `:` delimiter
77
+ * serializeActor() uses (disallowed in a git ref), consecutive dots, a leading dot, a trailing
78
+ * dot (fix-plan iteration 1 F1 — git-check-ref-format forbids a component ending in `.`, and
79
+ * the prior pass only handled a run of 2+ trailing dots via the `..`-collapse step, missing the
80
+ * single-trailing-dot case e.g. a `--task-slug` of `my-fix.`), and a trailing `.lock`. The
81
+ * trailing-dot strip runs BEFORE the `.lock` rewrite so a `.lock` suffix hidden behind trailing
82
+ * dots (e.g. `foo.lock.`) is exposed and still rewritten rather than left dangling. Whenever the
83
+ * FINAL sanitized segment equals the fallback token `"unknown"` — whether because the charset
84
+ * filter collapsed the raw input to nothing, OR because the leading-/trailing-dot stripping
85
+ * above collapsed a near-miss input like `"unknown."` or `".unknown"` down to the literal
86
+ * `"unknown"`, OR because the raw input genuinely WAS the literal string `"unknown"` — a
87
+ * deterministic disambiguator (see unknownDisambiguator) is ALWAYS appended (fix-plan iteration 2
88
+ * F4': no literal-input carve-out). Distinct raw inputs can never collide on the bare literal
89
+ * `"unknown"` segment; the same raw input always re-derives the same disambiguated segment. See
90
+ * Design Decision 2 in the plan. */
91
+ function sanitizeBranchSegment(value: string, helper: { sanitizeSegment: (v: unknown) => string }): string {
92
+ const raw = String(value ?? "");
93
+ const colonReplaced = raw.replace(/:/g, "-");
94
+ let seg = helper.sanitizeSegment(colonReplaced);
95
+ seg = seg.replace(/\.{2,}/g, "-").replace(/^\.+/, "").replace(/\.+$/, "").replace(/\.lock$/i, "-lock");
96
+ if (!seg || seg === "unknown") return `unknown-${unknownDisambiguator(raw)}`;
97
+ return seg;
98
+ }
99
+
100
+ /** Validate an explicit `--branch` value strictly rather than trusting it verbatim (fix-plan
101
+ * iteration 1 F2, tightened by iteration 2 F2'). Explicit `--branch` bypasses
102
+ * `sanitizeBranchSegment` entirely by design (it is caller intent, may legitimately contain `/`
103
+ * to nest under `agent/...`, etc.), so unlike the derived path it must be rejected outright — not
104
+ * silently sanitized — when it cannot be a valid git ref. Whole-string checks (iteration 1):
105
+ * any control character/newline, a space, a leading or trailing `/`, a `//` sequence, a leading
106
+ * `.`, any `..` sequence, a trailing `.`, a trailing `.lock`, or any character outside
107
+ * `[A-Za-z0-9_./-]`. Per-component checks (iteration 2 F2'): the whole-string checks above only
108
+ * examine the START/END of the full value, so a charset-legal value can still smuggle an invalid
109
+ * `/`-delimited component past them — e.g. `"-lead"` (a leading `-` breaks the whole git ref,
110
+ * applied uniformly to every component here — stricter than git strictly requires, which is fine
111
+ * for a caller-facing override flag), `"a/.b"` (a non-first component starting with `.`),
112
+ * `"foo.lock/bar"` (a non-last component ending in `.lock`), or `"a/./b"` (a component that is
113
+ * exactly `.`). Belt-and-braces (F2'): once the lexical checks above all pass, `git
114
+ * check-ref-format --branch <value>` (the real git binary) is run as the final authority — it can
115
+ * only ever REJECT a value the lexical checks let through (never re-legalize one they rejected),
116
+ * so it closes any residual gap in this hand-rolled lexical pass. When git cannot be spawned at
117
+ * all (e.g. not installed — `ENOENT`) or does not complete (e.g. the 5s timeout fires), the git
118
+ * check is skipped silently and the lexical checks above remain the sole authority. Dies with
119
+ * remediation, or with git's own rejection message; never mutates any artifact before this check
120
+ * runs (resolveSessionBranch calls this before any file write). */
121
+ function validateExplicitBranch(value: string): void {
122
+ const remediation = `Pass a --branch value matching [A-Za-z0-9_./-], with no leading/trailing "/", no "//", no leading ".", no ".." sequence, and no trailing "." or ".lock" (got: ${JSON.stringify(value)}).`;
123
+ const fail = (reason: string): never => die(`ensure-session --branch value is not a valid git ref: ${reason}. ${remediation}`);
124
+ if (/[\x00-\x1F\x7F]/.test(value)) fail("contains a control character or newline");
125
+ if (/ /.test(value)) fail("contains a space");
126
+ if (value.startsWith("/") || value.endsWith("/")) fail('must not start or end with "/"');
127
+ if (value.includes("//")) fail('must not contain "//"');
128
+ if (value.startsWith(".")) fail('must not start with "."');
129
+ if (value.includes("..")) fail('must not contain a ".." sequence');
130
+ if (value.endsWith(".")) fail('must not end with "."');
131
+ if (/\.lock$/i.test(value)) fail('must not end with ".lock"');
132
+ if (/[^A-Za-z0-9_./-]/.test(value)) fail("contains a character outside [A-Za-z0-9_./-]");
133
+
134
+ // F2' per-component checks: split on "/" and validate each path component individually. The
135
+ // whole-string checks above cannot catch a hostile component that is not at the very start or
136
+ // end of the full value.
137
+ for (const component of value.split("/")) {
138
+ if (!component) fail('must not contain an empty path component ("//" or a leading/trailing "/")');
139
+ if (component === ".") fail('must not contain a path component that is exactly "."');
140
+ if (component.startsWith(".")) fail('must not contain a path component starting with "."');
141
+ if (component.startsWith("-")) fail('must not contain a path component starting with "-"');
142
+ if (component.endsWith(".")) fail('must not contain a path component ending with "."');
143
+ if (/\.lock$/i.test(component)) fail('must not contain a path component ending with ".lock"');
144
+ }
145
+
146
+ // F2' belt-and-braces: the real `git check-ref-format --branch` binary is the final authority
147
+ // when git is available. This can only REJECT a value that already passed every lexical check
148
+ // above — it never re-legalizes a value the lexical checks rejected (those `fail()` calls
149
+ // above already threw). argv-array form (no shell) with a 5s timeout so a hung or missing git
150
+ // binary cannot hang or crash session creation.
151
+ let result: { status: number | null; stderr: Buffer | string } | undefined;
152
+ try {
153
+ execFileSync("git", ["check-ref-format", "--branch", value], { stdio: ["ignore", "ignore", "pipe"], timeout: 5000 });
154
+ return; // exit 0 — git accepts the value; nothing further to check.
155
+ } catch (err) {
156
+ const spawnError = err as NodeJS.ErrnoException & { status?: number | null; stderr?: Buffer | string };
157
+ if (spawnError && spawnError.code === "ENOENT") return; // git not installed — lexical checks stand alone.
158
+ if (spawnError && typeof spawnError.status === "number") { result = { status: spawnError.status, stderr: spawnError.stderr ?? "" }; }
159
+ else return; // Any other spawn failure (e.g. timeout) — skip silently; lexical checks already passed.
160
+ }
161
+ if (result && result.status !== 0) {
162
+ const gitMessage = String(result.stderr ?? "").trim();
163
+ fail(gitMessage ? `git check-ref-format rejected the value: ${gitMessage}` : "git check-ref-format rejected the value");
164
+ }
165
+ }
166
+
167
+ /** Resolve the branch to seed a brand-new session with. Only called from ensureSession's
168
+ * `if (!md)` fresh-creation branch — an existing session's already-recorded branch is never
169
+ * recomputed (ADR 0021 §5 takeover continuity; see Design Decision 3). Precedence: explicit
170
+ * --branch (strictly validated, then honored verbatim — see F2) > derived agent/<actor>/<slug>.
171
+ * Never hard-fails session creation on actor-resolution ambiguity (Design Decision 4) — only a
172
+ * garbage explicit --actor, or a garbage explicit --branch, dies. */
173
+ function resolveSessionBranch(p: ReturnType<typeof parseArgs>, slug: string): string {
174
+ // Deliberately NOT trimmed before validation (unlike the pre-F2 baseline): a leading/trailing
175
+ // space must be REJECTED, not silently trimmed away — silent trimming would let a
176
+ // caller-supplied value differ from what gets recorded without any diagnostic (F2).
177
+ const explicitBranch = opt(p, "branch");
178
+ if (explicitBranch) { validateExplicitBranch(explicitBranch); return explicitBranch; }
179
+ const helper = loadActorIdentityHelper();
180
+ const explicitActorRaw = opt(p, "actor", "").trim();
181
+ if (explicitActorRaw && !/[A-Za-z0-9_.-]/.test(explicitActorRaw)) {
182
+ die("ensure-session --actor value strips to empty under the allowed actor charset ([A-Za-z0-9_.-]) — pass a value containing at least one letter, digit, underscore, period, or hyphen.");
183
+ }
184
+ const actor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : helper.resolveActor(process.env).actor;
185
+ const unresolved = helper.isUnresolvedActor(actor);
186
+ const safeActor = unresolved ? `unknown-actor-${unknownDisambiguator(actor)}` : sanitizeBranchSegment(actor, helper);
187
+ if (unresolved) process.stderr.write("[ensure-session] actor unresolved; branch uses \"unknown-actor\" segment (set --actor or FLOW_AGENTS_ACTOR for a stable branch name)\n");
188
+ return `agent/${safeActor}/${sanitizeBranchSegment(slug, helper)}`;
189
+ }
190
+
53
191
  /**
54
192
  * Validate a Hachure trust.bundle using @kontourai/surface's canonical validator
55
193
  * (surface is the authoritative owner of trust-bundle schema validation per ADR 0010 / ADR 0015).
@@ -170,7 +308,7 @@ type SurfaceModule = {
170
308
  policies: Record<string, unknown>[];
171
309
  now?: Date;
172
310
  }) => { status: string; policyId: string | undefined };
173
- generateClaimId: (subjectId: string, surface: string, fieldOrBehavior: string) => string;
311
+ generateClaimId: (subjectId: string, facet: string, fieldOrBehavior: string) => string;
174
312
  statusFunctionVersion: string;
175
313
  resolveInquiry: (
176
314
  bundle: Record<string, unknown>,
@@ -241,6 +379,48 @@ function criterionStatusToEventStatus(status: string): string | null {
241
379
  if (status === "accepted_gap") return "assumed";
242
380
  return null; // pending / not_verified → no event → Surface returns "unknown"
243
381
  }
382
+ /**
383
+ * WS8 (ADR 0020): Derive Surface evidence classification (evidenceType + method)
384
+ * from a workflow check's kind, replacing the previous hardcoded `test_output`.
385
+ * Only command-backed re-runnable checks are `test_output` (CI-reconcilable);
386
+ * everything else is inherently session-local (manual attestation, provider/
387
+ * document citation, crawl observation, policy rule, source excerpt). The
388
+ * `reconcilable` flag is informational — the CI reconciler classifies purely by
389
+ * the emitted `evidenceType`. Every value is a member of Surface's own
390
+ * evidence.schema.json `evidenceType`/`method` enums (consume-never-fork).
391
+ */
392
+ function classifyEvidence(kind: string | undefined, hasCommand: boolean): { evidenceType: string; method: string; reconcilable: boolean } {
393
+ const k = String(kind ?? "external");
394
+ switch (k) {
395
+ case "build":
396
+ case "types":
397
+ case "lint":
398
+ case "test":
399
+ case "command":
400
+ return { evidenceType: "test_output", method: "validation", reconcilable: true };
401
+ case "security":
402
+ return hasCommand
403
+ ? { evidenceType: "test_output", method: "validation", reconcilable: true }
404
+ : { evidenceType: "attestation", method: "corroboration", reconcilable: false };
405
+ case "diff":
406
+ return { evidenceType: "source_excerpt", method: "extraction", reconcilable: false };
407
+ case "browser":
408
+ return hasCommand
409
+ ? { evidenceType: "test_output", method: "validation", reconcilable: true }
410
+ : { evidenceType: "crawl_observation", method: "observation", reconcilable: false };
411
+ case "runtime":
412
+ return hasCommand
413
+ ? { evidenceType: "test_output", method: "validation", reconcilable: true }
414
+ : { evidenceType: "attestation", method: "attestation", reconcilable: false };
415
+ case "policy":
416
+ return { evidenceType: "policy_rule", method: "auditability", reconcilable: false };
417
+ case "external":
418
+ return { evidenceType: "attestation", method: "corroboration", reconcilable: false };
419
+ default:
420
+ return { evidenceType: "test_output", method: "validation", reconcilable: true };
421
+ }
422
+ }
423
+
244
424
  /** Map a critique verdict to the Surface VerificationEvent status. */
245
425
  function critiqueToEventStatus(verdict: string, findings: AnyObj[]): string | null {
246
426
  if (verdict === "fail") return "disputed";
@@ -281,14 +461,29 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
281
461
  const events: AnyObj[] = [];
282
462
  const ts = timestamp || new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
283
463
 
284
- // One VerificationPolicy per distinct claimType, so status is policy-governed
285
- // (not derived against an empty policy set). Maximal-fidelity per ADR 0010.
464
+ // One VerificationPolicy per distinct (claimType, requiredEvidence) pair, so status is
465
+ // policy-governed (not derived against an empty policy set). Maximal-fidelity per ADR 0010.
466
+ //
467
+ // WS8 (AC1, iteration 2): the cache is keyed by claimType + the normalized requiredEvidence
468
+ // set, NOT by claimType alone. Two checks of the same legacy claimType that differ in
469
+ // command-presence (e.g. a command-backed browser check → requiredEvidence [test_output]
470
+ // vs a no-command browser check → [crawl_observation]) previously COLLIDED: the first-seen
471
+ // requiredEvidence won and corrupted the second claim's derived status (verified → proposed)
472
+ // in a record-order-dependent way. Keying by (claimType, requiredEvidence) makes policy
473
+ // construction order-independent — each distinct evidence signature gets its own policy, and
474
+ // each claim references its own via verificationPolicyId (Surface's resolvePolicyForClaim
475
+ // honors verificationPolicyId first, so same-claimType policies never cross-resolve).
476
+ // Merging is NOT used because Surface's requiredEvidence is all-of (`.every`), so a union
477
+ // would over-constrain both claims.
286
478
  const policies = new Map<string, AnyObj>();
287
479
  const ensurePolicy = (claimType: string, impactLevel: string, requiredEvidence: string[]): AnyObj => {
288
- let p = policies.get(claimType);
480
+ const reqSorted = [...new Set(requiredEvidence)].sort();
481
+ const key = `${claimType}::${reqSorted.join(",")}`;
482
+ let p = policies.get(key);
289
483
  if (!p) {
290
- p = { id: `policy:${claimType}`, claimType, requiredEvidence, acceptanceCriteria: [`A verified verification event must support a ${claimType} claim.`], reviewAuthority: "system", validityRule: { kind: "manual" }, stalenessTriggers: [], conflictRules: [], impactLevel };
291
- policies.set(claimType, p);
484
+ const id = reqSorted.length ? `policy:${claimType}:${reqSorted.join("+")}` : `policy:${claimType}`;
485
+ p = { id, claimType, requiredEvidence: reqSorted, acceptanceCriteria: [`A verified verification event must support a ${claimType} claim.`], reviewAuthority: "system", validityRule: { kind: "manual" }, stalenessTriggers: [], conflictRules: [], impactLevel };
486
+ policies.set(key, p);
292
487
  }
293
488
  return p;
294
489
  };
@@ -402,12 +597,28 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
402
597
  const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
403
598
  const evId = `ev:${claimId}`;
404
599
  const legacyClaimType = `workflow.check.${check.kind ?? "external"}`;
405
- const policy = ensurePolicy(legacyClaimType, "high", ["test_output"]);
406
600
 
407
601
  const cmd = typeof check.command === "string" ? check.command.replace(/\s+/g, " ").trim() : "";
408
602
  const captured = cmd ? captureByCommand.get(cmd) : undefined;
603
+ // WS8 (ADR 0020): classify evidence from check.kind instead of hardcoding test_output.
604
+ // A waived accepted-gap (check._waiver, set by record-evidence/record-gate-claim
605
+ // --accepted-gap-reason/--waived-by) is by definition session-local: it is an
606
+ // attested accepted gap, never a CI-re-runnable command, so it is classified
607
+ // attestation/attestation and its claim status is forced to `assumed` (reusing the
608
+ // existing accepted_gap -> assumed mapping — no new status value).
609
+ const waiver = (check._waiver && typeof check._waiver === "object") ? check._waiver as AnyObj : null;
610
+ const evClass = waiver
611
+ ? { evidenceType: "attestation", method: "attestation", reconcilable: false }
612
+ : classifyEvidence(check.kind, cmd.length > 0);
613
+ const policy = ensurePolicy(legacyClaimType, "high", [evClass.evidenceType]);
409
614
  const effectiveStatus = captured ? captured.observedResult : String(check.status ?? "");
410
- const evStatus = checkStatusToEventStatus(effectiveStatus);
615
+ const evStatus = waiver ? "assumed" : checkStatusToEventStatus(effectiveStatus);
616
+ // Promotion claim marker (issue #312): a `promote` check carries a session-local
617
+ // _promotion object that must survive onto claim.metadata.promotion so the archive gate
618
+ // (workflow-artifact-cleanup-audit) and validators can detect the promotion claim without a
619
+ // new manifest entry. It rides alongside any waiver in a single merged metadata object.
620
+ const promotionMeta = (check._promotion && typeof check._promotion === "object") ? check._promotion as AnyObj : null;
621
+ const claimMetadata: AnyObj | null = (waiver || promotionMeta) ? { ...(waiver ? { waiver } : {}), ...(promotionMeta ? { promotion: promotionMeta } : {}) } : null;
411
622
 
412
623
  const claimEvents: AnyObj[] = [];
413
624
  if (evStatus) {
@@ -415,11 +626,17 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
415
626
  events.push(evt);
416
627
  claimEvents.push(evt);
417
628
  }
418
- const evItem: AnyObj = { id: evId, claimId, evidenceType: "test_output", method: "validation", sourceRef: `${slug}/evidence.json`, excerptOrSummary: fieldOrBehavior, observedAt: ts, collectedBy: "flow-agents/workflow-sidecar", passing: effectiveStatus === "pass" };
629
+ const evItem: AnyObj = { id: evId, claimId, evidenceType: evClass.evidenceType, method: evClass.method, sourceRef: `${slug}/evidence.json`, excerptOrSummary: fieldOrBehavior, observedAt: ts, collectedBy: "flow-agents/workflow-sidecar", passing: effectiveStatus === "pass" };
419
630
  if (captured) {
420
631
  evItem.sourceRef = `${slug}/command-log.jsonl`;
421
632
  evItem.collectedBy = "flow-agents/evidence-capture";
422
633
  evItem.execution = { runner: "bash", label: cmd, isError: captured.observedResult === "fail", ...(captured.exitCode != null ? { exitCode: captured.exitCode } : {}) };
634
+ } else if (cmd && !waiver) {
635
+ // WS8 (ADR 0020): always stamp execution.label on command-backed checks so the CI
636
+ // reconciler has a stable key to match against the manifest, even when the local
637
+ // command-log capture did not happen to run this command. isError is derived from
638
+ // the check's own reported status (no captured exit code available in this path).
639
+ evItem.execution = { runner: "bash", label: cmd, isError: effectiveStatus !== "pass" };
423
640
  }
424
641
  evidenceItems.push(evItem);
425
642
 
@@ -428,13 +645,13 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
428
645
  const declared = matchExpectsEntry("check", check.kind, typeof check._gate_claim_expectation_id === "string" ? check._gate_claim_expectation_id : undefined);
429
646
  if (declared) {
430
647
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
431
- const declaredPolicy = ensurePolicy(declared.claimType, "high", ["test_output"]);
432
- const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, surface: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id };
648
+ const declaredPolicy = ensurePolicy(declared.claimType, "high", [evClass.evidenceType]);
649
+ const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, ...(claimMetadata ? { metadata: claimMetadata } : {}) };
433
650
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [evItem] as Record<string, unknown>[], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
434
651
  claims.push({ ...declaredClaimObj, status: declaredStatus });
435
652
  } else {
436
653
  // No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
437
- const claimObj: AnyObj = { id: claimId, subjectType: "workflow-check", subjectId, surface: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id };
654
+ const claimObj: AnyObj = { id: claimId, subjectType: "workflow-check", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: effectiveStatus, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id, ...(claimMetadata ? { metadata: claimMetadata } : {}) };
438
655
  const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [evItem] as Record<string, unknown>[], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
439
656
  claims.push({ ...claimObj, status: derivedStatus });
440
657
  }
@@ -461,12 +678,12 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
461
678
  if (declared) {
462
679
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
463
680
  const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
464
- const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, surface: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id };
681
+ const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id };
465
682
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
466
683
  claims.push({ ...declaredClaimObj, status: declaredStatus });
467
684
  } else {
468
685
  // No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
469
- const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, surface: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id };
686
+ const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id };
470
687
  const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
471
688
  claims.push({ ...claimObj, status: derivedStatus });
472
689
  }
@@ -493,19 +710,19 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
493
710
  if (declared) {
494
711
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
495
712
  const declaredPolicy = ensurePolicy(declared.claimType, "medium", []);
496
- const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, surface: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: declaredPolicy.id };
713
+ const declaredClaimObj: AnyObj = { 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 };
497
714
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
498
715
  claims.push({ ...declaredClaimObj, status: declaredStatus });
499
716
  } else {
500
717
  // No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
501
- const claimObj: AnyObj = { id: claimId, subjectType: "workflow-critique", subjectId, surface: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: policy.id };
718
+ const claimObj: AnyObj = { 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 };
502
719
  const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
503
720
  claims.push({ ...claimObj, status: derivedStatus });
504
721
  }
505
722
  }
506
723
 
507
724
  return {
508
- schemaVersion: 3,
725
+ schemaVersion: 5,
509
726
  source: `flow-agents/workflow-sidecar;statusFunctionVersion=${statusFunctionVersion}`,
510
727
  claims,
511
728
  evidence: evidenceItems,
@@ -536,7 +753,7 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
536
753
  const raw = fs.readFileSync(path.join(dir, "command-log.jsonl"), "utf8");
537
754
  commandLog = raw.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => { try { return JSON.parse(l) as AnyObj; } catch { return null; } }).filter((x): x is AnyObj => x !== null);
538
755
  } catch { /* no capture log — fine */ }
539
- // ADR 0016 Abstraction A (P-d): pass the .flow-agents dir ONLY when current.json
756
+ // ADR 0016 Abstraction A (P-d): pass the runtime artifact root ONLY when current.json
540
757
  // points to this session (scoped active-flow guard). If current.json.artifact_dir
541
758
  // resolves to a different session, pass null — no active-flow claim mapping for this bundle.
542
759
  const _flowAgentsDir = path.dirname(dir);
@@ -743,6 +960,20 @@ function definitionAcceptanceLines(markdown: string): string[] {
743
960
  }
744
961
  return out;
745
962
  }
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: string, name: string): string {
974
+ const re = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*(.+)$`, "m");
975
+ return re.exec(markdown)?.[1]?.trim() ?? "";
976
+ }
746
977
  function parseCriterion(line: string, index: number): AnyObj {
747
978
  let text = line.replace(/^-\s+\[[ xX]\]\s*/, "").trim();
748
979
  const m = /\s+-\s+Evidence:\s*(.+)$/i.exec(text);
@@ -777,14 +1008,20 @@ function validateAgentId(agent: string): string {
777
1008
 
778
1009
  /**
779
1010
  * Find the repository root by walking upward from a starting directory to locate
780
- * the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
781
- * findRepoRoot, but callable from workflow-sidecar.ts without re-importing the
782
- * internal helper.
1011
+ * the nearest ancestor containing a kits/ subdirectory, without falling back to
1012
+ * process.cwd(). Returns null when no kits/ ancestor is found within the walk
1013
+ * bound (e.g. a scratch/temp session directory with no repo ancestor at all).
783
1014
  *
784
- * ADR 0016 Abstraction A (P-d): used by advance-state and ensure-session to
785
- * derive repoRoot for resolvePhaseMap calls.
1015
+ * WS5 iteration-2 part 2: extracted so publishDelivery's repo-root resolution can
1016
+ * be fail-closed (see findRepoRootFromDir below and publishDelivery). A scratch
1017
+ * test session dir (mktemp -d, no kits/ ancestor) must never resolve to whatever
1018
+ * repo happens to be the current process's cwd — that previously let a throwaway
1019
+ * eval-local trust.bundle silently clobber this real repo's delivery/trust.bundle
1020
+ * when the eval was run from a checkout of this repo (see
1021
+ * evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings
1022
+ * at .kontourai/flow-agents/ws5-governance-kit-slice1).
786
1023
  */
787
- function findRepoRootFromDir(startDir: string): string {
1024
+ function findRepoRootFromDirStrict(startDir: string): string | null {
788
1025
  let dir = startDir;
789
1026
  for (let i = 0; i < 16; i++) {
790
1027
  if (fs.existsSync(path.join(dir, "kits"))) return dir;
@@ -792,7 +1029,24 @@ function findRepoRootFromDir(startDir: string): string {
792
1029
  if (parent === dir) break;
793
1030
  dir = parent;
794
1031
  }
795
- return process.cwd();
1032
+ return null;
1033
+ }
1034
+
1035
+ /**
1036
+ * Find the repository root by walking upward from a starting directory to locate
1037
+ * the nearest ancestor containing a kits/ subdirectory. Mirrors flow-resolver.ts
1038
+ * findRepoRoot, but callable from workflow-sidecar.ts without re-importing the
1039
+ * internal helper. Falls back to process.cwd() when no kits/ ancestor is found —
1040
+ * appropriate for phase-map/first-step resolution (ADR 0016 Abstraction A, P-d),
1041
+ * where the caller is always invoked from within a real repo checkout.
1042
+ *
1043
+ * Do NOT use this cwd-falling-back variant for publishDelivery's repo-root
1044
+ * resolution — use findRepoRootFromDirStrict there instead, so a scratch/test
1045
+ * session dir with no repo ancestor fails closed (skips publish) rather than
1046
+ * silently trusting process.cwd(), which could be an unrelated real repo.
1047
+ */
1048
+ function findRepoRootFromDir(startDir: string): string {
1049
+ return findRepoRootFromDirStrict(startDir) ?? process.cwd();
796
1050
  }
797
1051
 
798
1052
  /**
@@ -825,7 +1079,11 @@ function resolveFirstStep(flowId: string, repoRoot: string): string | null {
825
1079
  }
826
1080
  }
827
1081
 
828
- function writeCurrent(root: string, dir: string, timestamp: string, owner: string, source: string, flowId?: string, stepId?: string): void {
1082
+ function writeCurrent(root: string, dir: string, timestamp: string, owner: string, source: string, flowId?: string, stepId?: string, adHocReason?: string): void {
1083
+ // #289: mirror the active session's already-recorded branch (state.json.branch) into
1084
+ // current.json so consumers of current.json (which has no schema of its own — not one of the
1085
+ // 9 schemas under schemas/) see the routing branch without re-reading state.json separately.
1086
+ const branch = loadJson(path.join(dir, "state.json")).branch;
829
1087
  writeJson(path.join(root, "current.json"), {
830
1088
  schema_version: "1.0",
831
1089
  active_slug: path.basename(dir),
@@ -834,11 +1092,17 @@ function writeCurrent(root: string, dir: string, timestamp: string, owner: strin
834
1092
  owner,
835
1093
  source,
836
1094
  active_agents: [],
1095
+ ...(branch ? { branch } : {}),
837
1096
  // ADR 0016 Abstraction A (P-a): optional FlowDefinition routing keys for the producer
838
1097
  // and enforcer. Both fields are optional and backward-compatible — sessions without a
839
1098
  // FlowDefinition omit them and fall through to the workflow.* claim type path.
840
1099
  ...(flowId ? { active_flow_id: flowId } : {}),
841
1100
  ...(stepId ? { active_step_id: stepId } : {}),
1101
+ // WS8 (AC12): sanctioned ad-hoc direct entry marker. Set when --step-id explicitly
1102
+ // targets a step other than the flow's resolved first step, so stop-goal-fit /
1103
+ // gate-review can distinguish an intentional direct entry (e.g. a planning-only
1104
+ // session that skips pull-work) from a stale/mis-stamped active_step_id.
1105
+ ...(adHocReason ? { ad_hoc_entry: true, ad_hoc_reason: adHocReason } : {}),
842
1106
  });
843
1107
  }
844
1108
  function loadCurrent(root: string): AnyObj | null {
@@ -850,7 +1114,13 @@ function currentDir(root: string): string | null {
850
1114
  const c = loadCurrent(root);
851
1115
  if (!c) return null;
852
1116
  const dir = path.resolve(root, c.artifact_dir ?? c.active_slug ?? "");
853
- return fs.existsSync(dir) ? dir : null;
1117
+ if (!fs.existsSync(dir)) return null;
1118
+ try {
1119
+ requireArtifactDirUnderRoot(dir, root);
1120
+ } catch {
1121
+ return null;
1122
+ }
1123
+ return dir;
854
1124
  }
855
1125
  function updateCurrentAgent(root: string, dir: string, agentId: string, status: string, timestamp: string): void {
856
1126
  const cur = loadCurrent(root);
@@ -864,8 +1134,38 @@ function updateCurrentAgent(root: string, dir: string, agentId: string, status:
864
1134
 
865
1135
  function initSidecars(dir: string, slug: string, sourceRequest: string, summary: string, nextAction: string, timestamp: string, markdown?: string): void {
866
1136
  const criteria = markdown ? definitionAcceptanceLines(markdown).map(parseCriterion) : [];
1137
+ // #289/#309: `markdown` here is NOT always the session `<slug>--deliver.md` that
1138
+ // ensureSession seeds the `branch:` line into — initPlan is called against the tool-planner's
1139
+ // PLAN artifact (`<slug>--plan-work.md` etc.), a different file that typically carries no
1140
+ // `branch:` line at all. Since this function fully rewrites state.json (no merge with the
1141
+ // prior contents), naively re-deriving branch from `markdown` alone would clobber whatever
1142
+ // ensure-session already recorded there moments earlier (#309 regression). Resolve branch with
1143
+ // a three-tier fallback, preferring the most durable source first:
1144
+ // 1. EXISTING state.json.branch — once recorded, it is never re-derived or clobbered.
1145
+ // 2. The session's OWN canonical `<dir>/<slug>--deliver.md` on disk — the file ensureSession
1146
+ // always seeds the `branch:` line into, independent of whatever `markdown` was passed in.
1147
+ // Reading it directly (rather than trusting `markdown`) is what makes a repaired/backfilled
1148
+ // init-plan call (re-run against the same branch-less plan artifact, after a #309-era
1149
+ // session lost its state.json branch) recover the branch without a direct file edit.
1150
+ // 3. The `markdown` param itself (covers ensureSession's fresh-creation call, before
1151
+ // `<slug>--deliver.md` differs from `markdown`, and any direct/legacy caller that passes
1152
+ // the deliver markdown itself as the init-plan artifact).
1153
+ // This makes the branch survive every subsequent initSidecars call (init-plan, or a resumed
1154
+ // ensure-session) at this single choke point, without patching every other writer.
1155
+ const existingState = loadJson(path.join(dir, "state.json"));
1156
+ const existingBranch = existingState.branch;
1157
+ const deliverMdPath = path.join(dir, `${slug}--deliver.md`);
1158
+ const deliverBranch = fs.existsSync(deliverMdPath) ? markdownField(read(deliverMdPath), "branch") : "";
1159
+ const branch = existingBranch || deliverBranch || (markdown ? markdownField(markdown, "branch") : "");
1160
+ // #309 (scope addition): created_at is write-once, same class as the branch-drop bug above —
1161
+ // initSidecars fully rewrites state.json on every call (including a repair/backfill re-run of
1162
+ // init-plan against an already-existing session), so naively re-stamping created_at from the
1163
+ // current call's timestamp silently rewrites a session's original creation time. Preserve the
1164
+ // existing state.json's created_at when present; stamp only on true first-creation.
1165
+ // updated_at still reflects "now" on every call — that field is intentionally mutable.
867
1166
  writeJson(path.join(dir, "state.json"), {
868
- ...sidecarBase(slug), status: "planned", phase: "planning", created_at: timestamp, updated_at: timestamp,
1167
+ ...sidecarBase(slug), status: "planned", phase: "planning", created_at: existingState.created_at || timestamp, updated_at: timestamp,
1168
+ ...(branch ? { branch } : {}),
869
1169
  artifact_paths: relArtifacts(dir),
870
1170
  next_action: { status: "continue", summary: nextAction || summary },
871
1171
  });
@@ -887,7 +1187,12 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
887
1187
  const timestamp = opt(p, "timestamp", now());
888
1188
  let md = fs.existsSync(path.join(dir, `${slug}--deliver.md`)) ? read(path.join(dir, `${slug}--deliver.md`)) : "";
889
1189
  if (!md) {
890
- md = `# ${opt(p, "title", slug)}\n\nbranch: main\nworktree: main\ncreated: ${timestamp}\nstatus: planning\ntype: deliver\niteration: 1\n\n## Plan\n\n${opt(p, "summary", "")}\n\n## Definition Of Done\n\n- **User outcome:** ${opt(p, "summary", "Workflow session is durable.")}\n- **Scope:** Workflow session artifacts and sidecars.\n- **Acceptance criteria:**\n${opts(p, "criterion").map((c) => ` - [ ] ${c} - Evidence: pending.`).join("\n")}\n- **Usefulness checks:**\n - [ ] User-facing workflow is documented or discoverable\n- **Stop-short risks:** Workflow artifacts could drift.\n- **Durable docs target:** not needed\n- **Sandbox mode:** local-edit\n\n## Execution Progress\n\n- [ ] Session initialized.\n\n## Verification Report\n\nBuild: [NOT_VERIFIED] Verification has not run yet.\n\n### Acceptance Criteria\n- [NOT_VERIFIED] Verification has not run yet - Evidence: pending workflow execution and checks.\n\n### Verdict: NOT_VERIFIED\n\n## Goal Fit Gate\n\n- [ ] Original user goal restated\n\n## Final Acceptance\n\n- [ ] CI/relevant checks passed or local equivalent recorded\n`;
1190
+ // #289: derive the routing branch ONLY on fresh session creation (this `if (!md)` guard).
1191
+ // An existing session's already-recorded `branch:` line is never touched — that whole branch
1192
+ // of code is skipped on a resumed/taken-over session, which is what makes ADR 0021 §5
1193
+ // takeover continuity true by construction (see Design Decision 3 in the plan).
1194
+ const branch = resolveSessionBranch(p, slug);
1195
+ md = `# ${opt(p, "title", slug)}\n\nbranch: ${branch}\nworktree: main\ncreated: ${timestamp}\nstatus: planning\ntype: deliver\niteration: 1\n\n## Plan\n\n${opt(p, "summary", "")}\n\n## Definition Of Done\n\n- **User outcome:** ${opt(p, "summary", "Workflow session is durable.")}\n- **Scope:** Workflow session artifacts and sidecars.\n- **Acceptance criteria:**\n${opts(p, "criterion").map((c) => ` - [ ] ${c} - Evidence: pending.`).join("\n")}\n- **Usefulness checks:**\n - [ ] User-facing workflow is documented or discoverable\n- **Stop-short risks:** Workflow artifacts could drift.\n- **Durable docs target:** not needed\n- **Sandbox mode:** local-edit\n\n## Execution Progress\n\n- [ ] Session initialized.\n\n## Verification Report\n\nBuild: [NOT_VERIFIED] Verification has not run yet.\n\n### Acceptance Criteria\n- [NOT_VERIFIED] Verification has not run yet - Evidence: pending workflow execution and checks.\n\n### Verdict: NOT_VERIFIED\n\n## Goal Fit Gate\n\n- [ ] Original user goal restated\n\n## Final Acceptance\n\n- [ ] CI/relevant checks passed or local equivalent recorded\n`;
891
1196
  fs.writeFileSync(path.join(dir, `${slug}--deliver.md`), md);
892
1197
  }
893
1198
  if (!fs.existsSync(path.join(dir, "state.json")) || !fs.existsSync(path.join(dir, "acceptance.json")) || !fs.existsSync(path.join(dir, "handoff.json"))) {
@@ -901,13 +1206,26 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
901
1206
  // ensure-session --flow-id builder.build produces a FlowDefinition-driven session even
902
1207
  // before the first advance-state call.
903
1208
  const flowId = opt(p, "flow-id");
904
- let stepId = opt(p, "step-id");
1209
+ const explicitStep = opt(p, "step-id");
1210
+ let stepId = explicitStep;
1211
+ let adHocReason: string | undefined;
905
1212
  if (flowId && !stepId) {
906
1213
  const repoRoot = findRepoRootFromDir(dir);
907
1214
  const firstStep = resolveFirstStep(flowId, repoRoot);
908
1215
  if (firstStep) stepId = firstStep;
1216
+ } else if (flowId && explicitStep) {
1217
+ // WS8 (AC12): --step-id is the sanctioned ad-hoc direct-entry mechanism. When it names
1218
+ // a step other than the flow's resolved first step, record an explicit ad_hoc_entry
1219
+ // marker (with a reason) instead of silently letting the mis-stamp look like the
1220
+ // flow's normal first step. This is the root-cause fix for a planning-only session
1221
+ // whose active_step_id would otherwise default to builder.build's first step.
1222
+ const repoRoot = findRepoRootFromDir(dir);
1223
+ const firstStep = resolveFirstStep(flowId, repoRoot);
1224
+ if (firstStep && firstStep !== explicitStep) {
1225
+ adHocReason = opt(p, "ad-hoc-reason") || `direct entry at step "${explicitStep}" via --step-id (flow first step is "${firstStep}")`;
1226
+ }
909
1227
  }
910
- writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined);
1228
+ writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined, adHocReason);
911
1229
  console.log(dir);
912
1230
  return 0;
913
1231
  }
@@ -928,7 +1246,7 @@ function recordAgentEvent(p: ReturnType<typeof parseArgs>): number {
928
1246
  const dir = explicit ? path.resolve(explicit) : currentDir(root);
929
1247
  if (!dir || !fs.existsSync(dir)) die("artifact directory does not exist");
930
1248
  if (explicit && fs.lstatSync(dir).isSymbolicLink()) die(`artifact directory must not be a symlink: ${dir}`);
931
- if (hasExplicitRoot) requireArtifactDirUnderRoot(dir, root);
1249
+ if (hasExplicitRoot || !explicit) requireArtifactDirUnderRoot(dir, root);
932
1250
  const timestamp = opt(p, "timestamp", now());
933
1251
  const agent = validateAgentId(opt(p, "agent-id"));
934
1252
  const event = { timestamp, agent_id: agent, kind: opt(p, "kind", "note"), status: opt(p, "status", "info"), summary: opt(p, "summary"), ...(opt(p, "ref") ? { ref: opt(p, "ref") } : {}) };
@@ -986,7 +1304,7 @@ export function normalizeEvidenceRefs(raw: unknown, label: string): AnyObj[] {
986
1304
  export function normalizeCheck(raw: AnyObj): AnyObj {
987
1305
  const check = { ...raw };
988
1306
  if (!check.id || !check.kind || !check.status || !check.summary) die("check requires id, kind, status, and summary");
989
- if (!checkKinds.has(check.kind)) die("kind must be one of: build, types, lint, test, security, diff, browser, runtime, policy, external");
1307
+ if (!checkKinds.has(check.kind)) die("kind must be one of: build, types, lint, test, command, security, diff, browser, runtime, policy, external");
990
1308
  if (!checkStatuses.has(check.status)) die("status must be one of: pass, fail, not_verified, skip");
991
1309
  if (Array.isArray(check.standard_refs)) for (const ref of check.standard_refs) if (!["junit", "sarif", "coverage", "veritas"].includes(ref.standard)) die("standard must be one of");
992
1310
  if (check.artifact_refs) check.artifact_refs = normalizeEvidenceRefs(check.artifact_refs, "artifact_refs");
@@ -1129,7 +1447,7 @@ export function writeState(dir: string, slug: string, status: string, phase: str
1129
1447
  // "builder.verify.policy-compliance") so round-trip helpers broaden their filters
1130
1448
  // to include declared claims alongside the legacy workflow.* ones.
1131
1449
  //
1132
- // Safety guard: current.json in the .flow-agents dir records the CURRENTLY ACTIVE
1450
+ // Safety guard: current.json in the runtime artifact root records the CURRENTLY ACTIVE
1133
1451
  // session via artifact_dir. If current.json points to a different session than `dir`
1134
1452
  // (e.g. another session was the last to call advance-state --flow-definition), we
1135
1453
  // return an empty set so declared-type predicates are NOT applied to the wrong session.
@@ -1213,12 +1531,46 @@ function critiquesFromBundle(dir: string, declaredClaimTypes: Set<string> = new
1213
1531
  }));
1214
1532
  }
1215
1533
  // ─────────────────────────────────────────────────────────────────────────────
1534
+ /**
1535
+ * WS8 (ADR 0020): parse the accepted-gap waiver flags. Both --accepted-gap-reason and
1536
+ * --waived-by are required together (an accepted gap with no justification or no
1537
+ * approver is refused — no silent/default waiver). Returns the waiver record to stamp
1538
+ * onto claim.metadata.waiver, or null when neither flag is present. Reuses the existing
1539
+ * accepted_gap -> assumed status mapping; adds no new canonical status value.
1540
+ */
1541
+ function parseWaiver(p: ReturnType<typeof parseArgs>, ts: string): AnyObj | null {
1542
+ const reason = opt(p, "accepted-gap-reason");
1543
+ const waivedBy = opt(p, "waived-by");
1544
+ if (!reason && !waivedBy) return null;
1545
+ if (!reason) die("--accepted-gap-reason is required when --waived-by is set (an accepted gap must carry its justification)");
1546
+ if (!waivedBy) die("--waived-by is required when --accepted-gap-reason is set (an accepted gap must name its approver)");
1547
+ return { reason, approved_by: waivedBy, approved_at: ts };
1548
+ }
1549
+
1216
1550
  async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number> {
1217
1551
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1218
1552
  const verdict = opt(p, "verdict") || die("--verdict is required");
1219
1553
  if (!verdicts.has(verdict)) die("verdict must be one of: pass, partial, fail, not_verified");
1220
1554
  const slug = taskSlugFor(dir, opt(p, "task-slug"));
1221
- const checks = [...opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"))), ...opts(p, "surface-trust-json").map(surfaceCheckFromArtifact)];
1555
+ const _ts0 = opt(p, "timestamp", now());
1556
+ const _waiver = parseWaiver(p, _ts0);
1557
+ const _checksRaw = [...opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json"))), ...opts(p, "surface-trust-json").map(surfaceCheckFromArtifact)];
1558
+ // WS8 (AC4, iteration 2): a command-backed check reconciles against CI or fails — it can
1559
+ // NEVER be waived. Reject --accepted-gap-reason/--waived-by on any check whose evidence
1560
+ // classifies as test_output (build/types/lint/test/command, and security/browser/runtime
1561
+ // WHEN a command is present). Only session-local checks (attestation/observation/citation/
1562
+ // diff/policy) are waivable. The CI reconciler enforces the same rule server-side (a waiver
1563
+ // on test_output evidence is a divergence), so this producer-side guard is defense-in-depth,
1564
+ // not the sole gate. (ADR 0020)
1565
+ if (_waiver) {
1566
+ for (const c of _checksRaw) {
1567
+ const hasCmd = typeof (c as AnyObj).command === "string" && String((c as AnyObj).command).trim().length > 0;
1568
+ if (classifyEvidence((c as AnyObj).kind as string | undefined, hasCmd).evidenceType === "test_output") {
1569
+ die(`--accepted-gap-reason/--waived-by cannot be applied to a command-backed check (kind='${String((c as AnyObj).kind)}'${hasCmd ? " with a command" : ""}): a command-backed check reconciles against CI or fails and cannot be waived. Waive only session-local checks (attestation/observation/citation). (ADR 0020)`);
1570
+ }
1571
+ }
1572
+ }
1573
+ const checks = _checksRaw.map((c) => _waiver ? { ...c, _waiver } : c);
1222
1574
  if (!checks.length && opts(p, "surface-trust-json").length === 0) die("record-evidence requires at least one --check-json or --surface-trust-json");
1223
1575
  validateAcceptanceEvidenceRefs(dir);
1224
1576
  // Phase 4c: bundle is the sole verification artifact — stop writing evidence.json and acceptance.json update.
@@ -1319,12 +1671,106 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
1319
1671
  }
1320
1672
 
1321
1673
  const checkNormalized = normalizeCheck(check);
1674
+ // WS8 (ADR 0020): honor the accepted-gap waiver flags for a gate claim too.
1675
+ const gateWaiver = parseWaiver(p, ts);
1676
+ if (gateWaiver) checkNormalized._waiver = gateWaiver;
1322
1677
  // Log the targeted gate expectation for transparency (goes to stderr only)
1323
- process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}\n`);
1678
+ process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}${gateWaiver ? " (WAIVED accepted_gap)" : ""}\n`);
1324
1679
  assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], []));
1325
1680
  return 0;
1326
1681
  }
1327
1682
 
1683
+ /**
1684
+ * promote — the promote-then-archive gate (issue #312). Durable-residue extraction is
1685
+ * the archival act: this records WHAT durable residue was promoted WHERE and writes a
1686
+ * PROMOTION CLAIM into the session trust.bundle.
1687
+ *
1688
+ * Claim shape (reconcile-safe by construction): the promotion check is kind="policy",
1689
+ * so classifyEvidence maps it to evidenceType "policy_rule" (session-local, method
1690
+ * "auditability"). It carries NO command / execution.label, so it can NEVER require a
1691
+ * reconcile-manifest entry and can NEVER become a [not-run] / unbacked-command
1692
+ * divergence at CI trust-reconcile. Its status derives to `verified` from that
1693
+ * session-local policy_rule evidence item, so the reconciler classifies it session-local
1694
+ * and accepts it as an ATTESTED claim (exit 0) — never as a test_output claim that must
1695
+ * match the manifest. The _promotion marker rides onto claim.metadata.promotion (see
1696
+ * buildTrustBundle) so the archive gate and validators can detect it without new manifest
1697
+ * entries (R1).
1698
+ *
1699
+ * Modes:
1700
+ * promote <dir> --evidence-path <p> [--evidence-path <p> ...]
1701
+ * Records the durable doc paths written (docs/decisions/<slug>.md, CONTEXT.md,
1702
+ * docs/learnings/*, …). Each path MUST exist on disk at record time — a missing
1703
+ * path fails loud (no silent empty promotion).
1704
+ * promote <dir> --none --reason "<why nothing durable>"
1705
+ * An explicit, auditable no-residue promotion (R3).
1706
+ */
1707
+ async function promote(p: ReturnType<typeof parseArgs>): Promise<number> {
1708
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1709
+ const slug = taskSlugFor(dir, opt(p, "task-slug"));
1710
+ const ts = opt(p, "timestamp", now());
1711
+ const none = p.flags.has("none");
1712
+ const reason = opt(p, "reason");
1713
+ const repoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDir(dir);
1714
+ const rawPaths = opts(p, "evidence-path");
1715
+
1716
+ if (none) {
1717
+ if (rawPaths.length) die("promote --none records a no-residue claim; do not also pass --evidence-path");
1718
+ if (!reason.trim()) die("promote --none requires --reason \"<why nothing durable was promoted>\"");
1719
+ } else {
1720
+ if (!rawPaths.length) die("promote requires at least one --evidence-path <durable-doc-path> (or --none --reason \"<why>\" for an explicit no-residue promotion)");
1721
+ }
1722
+
1723
+ // Every evidence ref MUST exist on disk at record time (fail loud otherwise). Store
1724
+ // repo-relative paths when the ref lives under the repo, so the claim is portable.
1725
+ const targets: string[] = [];
1726
+ for (const raw of rawPaths) {
1727
+ const abs = path.isAbsolute(raw) ? raw : path.resolve(repoRoot, raw);
1728
+ if (!fs.existsSync(abs)) die(`promote --evidence-path does not exist on disk: ${raw} (resolved: ${abs}). Promotion evidence refs must point at durable docs that were actually written.`);
1729
+ const rel = path.relative(repoRoot, abs);
1730
+ targets.push(rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : raw);
1731
+ }
1732
+
1733
+ const promotionMarker: AnyObj = {
1734
+ schema_version: "1.0",
1735
+ none,
1736
+ ...(none ? { reason } : {}),
1737
+ targets,
1738
+ promoted_at: ts,
1739
+ };
1740
+
1741
+ const summary = opt(p, "summary") || (none
1742
+ ? `Promotion (no durable residue): ${reason}`
1743
+ : `Promoted durable residue: ${targets.join(", ")}`);
1744
+
1745
+ // Session-local promotion check: kind="policy" -> policy_rule evidence, no command.
1746
+ const promotionCheck: AnyObj = { id: "promotion", kind: "policy", status: "pass", summary, _promotion: promotionMarker };
1747
+
1748
+ // Add the promotion claim WITHOUT dropping the session's existing verification
1749
+ // evidence/criteria/critiques (mirror record-critique's merge pattern). Drop any prior
1750
+ // "promotion" check so re-running promote is idempotent rather than duplicating.
1751
+ const _dct = declaredClaimTypesFor(dir);
1752
+ const existingChecks = checksFromBundle(dir, _dct).filter((c) => c.id !== "promotion");
1753
+ const _acc = loadJson(path.join(dir, "acceptance.json"));
1754
+ const criteria: AnyObj[] = Array.isArray(_acc.criteria) ? _acc.criteria : [];
1755
+ const critiques = critiquesFromBundle(dir, _dct);
1756
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, [...existingChecks, promotionCheck], criteria, critiques));
1757
+
1758
+ // Auditable record of what was promoted where (companion to the trust.bundle claim).
1759
+ writeJson(path.join(dir, "promotion.json"), { ...sidecarBase(slug), ...promotionMarker, summary });
1760
+
1761
+ // Optionally republish so delivery/trust.bundle carries the promotion claim for CI.
1762
+ if (p.flags.has("publish")) {
1763
+ const publishRepoRoot = opt(p, "publish-repo-root") ? path.resolve(opt(p, "publish-repo-root")) : findRepoRootFromDirStrict(dir);
1764
+ await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
1765
+ process.stderr.write(`[promote] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
1766
+ });
1767
+ }
1768
+
1769
+ process.stderr.write(`[promote] recorded ${none ? "no-residue" : targets.length + " durable ref(s)"} promotion claim for ${slug}\n`);
1770
+ printJson({ ok: true, slug, none, targets, promotion_claim: "trust.bundle" });
1771
+ return 0;
1772
+ }
1773
+
1328
1774
  async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
1329
1775
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1330
1776
  const status = opt(p, "status");
@@ -1375,7 +1821,14 @@ async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
1375
1821
  if (status === "delivered") {
1376
1822
  await sealTrustCheckpoint(dir, slug, timestamp, status, "release").catch(() => { /* best-effort; checkpoint seal must not break advance-state */ });
1377
1823
  // Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
1378
- await publishDelivery(dir, findRepoRootFromDir(dir)).catch(() => { /* best-effort; must not break advance-state */ });
1824
+ // Fail-closed repo-root resolution (findRepoRootFromDirStrict, no cwd fallback) see
1825
+ // publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir
1826
+ // with no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures
1827
+ // are visible (stderr warning), not silently swallowed.
1828
+ const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
1829
+ await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
1830
+ process.stderr.write(`[advance-state] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
1831
+ });
1379
1832
  }
1380
1833
  return 0;
1381
1834
  }
@@ -1442,7 +1895,14 @@ async function recordRelease(p: ReturnType<typeof parseArgs>): Promise<number> {
1442
1895
  // Trust checkpoint: seal at the "delivered" moment (the natural terminal mark for record-release).
1443
1896
  await sealTrustCheckpoint(dir, slug, payload.updated_at, "delivered", "release").catch(() => { /* best-effort; checkpoint seal must not break record-release */ });
1444
1897
  // Publish delivery bundle: best-effort copy to delivery/ for CI trust-reconcile.
1445
- await publishDelivery(dir, findRepoRootFromDir(dir)).catch(() => { /* best-effort; must not break record-release */ });
1898
+ // Fail-closed repo-root resolution (findRepoRootFromDirStrict, no cwd fallback) see
1899
+ // publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir with
1900
+ // no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures are
1901
+ // visible (stderr warning), not silently swallowed.
1902
+ const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
1903
+ await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
1904
+ process.stderr.write(`[record-release] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
1905
+ });
1446
1906
  return 0;
1447
1907
  }
1448
1908
 
@@ -1657,7 +2117,7 @@ async function sealCheckpoint(p: ReturnType<typeof parseArgs>): Promise<number>
1657
2117
 
1658
2118
  // ─── Publish Delivery Bundle ──────────────────────────────────────────────────
1659
2119
  // Copies the session's trust.bundle (+ checkpoint companions) from the gitignored
1660
- // session artifact dir (.flow-agents/<slug>/) to the committed delivery/ transport
2120
+ // session artifact dir (.kontourai/flow-agents/<slug>/) to the committed delivery/ transport
1661
2121
  // path so the CI trust-reconcile job can reconcile it against fresh CI results.
1662
2122
  //
1663
2123
  // Fail-soft: if trust.bundle is absent (no evidence recorded yet), does nothing.
@@ -1672,13 +2132,25 @@ async function sealCheckpoint(p: ReturnType<typeof parseArgs>): Promise<number>
1672
2132
  * trust.checkpoint.intoto.json / trust.checkpoint.sig.json from the
1673
2133
  * session artifact dir to <repoRoot>/delivery/.
1674
2134
  *
1675
- * Fail-soft: if trust.bundle is absent, returns without throwing.
2135
+ * Fail-soft on a missing bundle: if trust.bundle is absent, returns without throwing.
2136
+ * Fail-CLOSED on repo-root resolution: repoRoot must be a real, resolved kits/ ancestor
2137
+ * (see findRepoRootFromDirStrict) — null (no ancestor found) skips the publish with a
2138
+ * visible warning instead of writing to whatever process.cwd() happens to be. This
2139
+ * prevents a scratch/test session dir (no kits/ ancestor) from silently clobbering an
2140
+ * unrelated real repo's delivery/trust.bundle when invoked with that repo as cwd (see
2141
+ * evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings at
2142
+ * .kontourai/flow-agents/ws5-governance-kit-slice1 for the root cause this fixes).
1676
2143
  * Idempotent: overwrites on re-delivery.
1677
2144
  */
1678
- export async function publishDelivery(dir: string, repoRoot: string): Promise<void> {
2145
+ export async function publishDelivery(dir: string, repoRoot: string | null): Promise<void> {
1679
2146
  const bundleSrc = path.join(dir, "trust.bundle");
1680
2147
  if (!fs.existsSync(bundleSrc)) return; // no bundle — skip gracefully
1681
2148
 
2149
+ if (!repoRoot) {
2150
+ 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`);
2151
+ return;
2152
+ }
2153
+
1682
2154
  const deliveryDir = path.join(repoRoot, "delivery");
1683
2155
  fs.mkdirSync(deliveryDir, { recursive: true });
1684
2156
 
@@ -1713,7 +2185,10 @@ export async function publishDelivery(dir: string, repoRoot: string): Promise<vo
1713
2185
  */
1714
2186
  async function publishDeliveryCmd(p: ReturnType<typeof parseArgs>): Promise<number> {
1715
2187
  const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
1716
- const repoRoot = opt(p, "repo-root") || findRepoRootFromDir(dir);
2188
+ // Fail-closed: an explicit --repo-root always wins; otherwise resolve strictly (no
2189
+ // process.cwd() fallback) so a scratch/test artifact dir cannot accidentally publish
2190
+ // into whichever repo happens to be the current working directory.
2191
+ const repoRoot = opt(p, "repo-root") || findRepoRootFromDirStrict(dir);
1717
2192
  await publishDelivery(dir, repoRoot);
1718
2193
  return 0;
1719
2194
  }
@@ -1890,7 +2365,7 @@ export interface TrustClaim {
1890
2365
  id: string;
1891
2366
  subjectType: string;
1892
2367
  subjectId: string;
1893
- surface: string;
2368
+ facet?: string;
1894
2369
  claimType: string;
1895
2370
  fieldOrBehavior: string;
1896
2371
  value: string;
@@ -1909,7 +2384,7 @@ export interface BundleFile {
1909
2384
  policies: AnyObj[];
1910
2385
  }
1911
2386
 
1912
- /** The gate block signal read from .flow-agents/.goal-fit-block-streak.json */
2387
+ /** The gate block signal read from .kontourai/flow-agents/.goal-fit-block-streak.json */
1913
2388
  export interface GateBlockSignal {
1914
2389
  /** True when the streak file exists AND count >= 1 */
1915
2390
  blocked: boolean;
@@ -1926,13 +2401,13 @@ export interface GateBlockSignal {
1926
2401
  export type GateCalibration = "correct" | "false_block" | "missed_block";
1927
2402
 
1928
2403
  /**
1929
- * Read the gate block signal from .flow-agents/.goal-fit-block-streak.json
2404
+ * Read the gate block signal from .kontourai/flow-agents/.goal-fit-block-streak.json
1930
2405
  * (written by scripts/hooks/stop-goal-fit.js when block mode fires).
1931
2406
  * The file sits at <artifact-root>/.goal-fit-block-streak.json — one level
1932
2407
  * above the session artifact dir. Fail-open: returns { blocked: false } when
1933
2408
  * the file is absent or unreadable.
1934
2409
  *
1935
- * @param artifactRoot The .flow-agents root dir (parent of session slug dir).
2410
+ * @param artifactRoot The runtime artifact root dir (parent of session slug dir).
1936
2411
  */
1937
2412
  export function readGateBlockSignal(artifactRoot: string): GateBlockSignal {
1938
2413
  const streakFile = path.join(artifactRoot, ".goal-fit-block-streak.json");
@@ -2159,7 +2634,7 @@ export function buildGateInquiryRecords(
2159
2634
  *
2160
2635
  * The block signal is read from <artifact-root>/.goal-fit-block-streak.json,
2161
2636
  * written by scripts/hooks/stop-goal-fit.js when block mode fires. The file
2162
- * lives one level above the session slug dir (the .flow-agents root).
2637
+ * lives one level above the session slug dir (the runtime artifact root).
2163
2638
  *
2164
2639
  * If @kontourai/surface is unavailable, logs a warning and returns 0
2165
2640
  * (fail-open — no bespoke fork fallback).
@@ -2185,7 +2660,7 @@ async function gateReview(p: ReturnType<typeof parseArgs>): Promise<number> {
2185
2660
 
2186
2661
  const bundle: BundleFile = JSON.parse(fs.readFileSync(bundlePath, "utf8"));
2187
2662
 
2188
- // Read gate block signal from .flow-agents root (one level above session dir)
2663
+ // Read gate block signal from the runtime artifact root (one level above session dir)
2189
2664
  const artifactRoot = path.dirname(dir);
2190
2665
  const blockSignal = readGateBlockSignal(artifactRoot);
2191
2666
 
@@ -2342,7 +2817,7 @@ function trustMcp(p: ReturnType<typeof parseArgs>): number {
2342
2817
  if (mode === "print") {
2343
2818
  console.log(JSON.stringify({ mcpServers: { [TRUST_MCP_SERVER]: trustMcpRegistration() } }, null, 2));
2344
2819
  process.stderr.write(`\n# Paste the above into your runtime MCP config (e.g. .mcp.json). Flow Agents does NOT write it for you unless you run: trust-mcp --mode enable\n`);
2345
- process.stderr.write(`# To view a task's trust inline, call surface_summary with path=<.flow-agents/<slug>/trust.bundle>.\n`);
2820
+ process.stderr.write(`# To view a task's trust inline, call surface_summary with path=<.kontourai/flow-agents/<slug>/trust.bundle>.\n`);
2346
2821
  return 0;
2347
2822
  }
2348
2823
  if (mode !== "enable" && mode !== "disable") die("trust-mcp --mode must be print|enable|disable");
@@ -2386,12 +2861,25 @@ const LIVENESS_POLICY = {
2386
2861
  impactLevel: "medium",
2387
2862
  };
2388
2863
 
2389
- function livenessStreamFile(root: string): string { return path.join(root, "liveness", "events.jsonl"); }
2390
- function appendLivenessEvent(root: string, evt: AnyObj): void {
2391
- const file = livenessStreamFile(root);
2392
- fs.mkdirSync(path.dirname(file), { recursive: true });
2393
- fs.appendFileSync(file, `${JSON.stringify(evt)}\n`);
2864
+ /**
2865
+ * Delegate to the shared pure-CJS writer (scripts/hooks/lib/liveness-write.js), mirroring the
2866
+ * createRequire pattern used by loadActorIdentityHelper() below. Deliberately NO inline duplicate
2867
+ * fallback — the whole point of #288 Wave 2 Task 2.1 is one writer shared by the CLI and the
2868
+ * hook wrappers' tool-activity heartbeat, not two copies of the append shape that can drift.
2869
+ */
2870
+ function loadLivenessWriteHelper(): {
2871
+ livenessStreamFile: (root: string) => string;
2872
+ appendLivenessEvent: (root: string, evt: AnyObj) => void;
2873
+ } {
2874
+ const _req = createRequire(import.meta.url);
2875
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-write.js");
2876
+ return _req(helperPath) as {
2877
+ livenessStreamFile: (root: string) => string;
2878
+ appendLivenessEvent: (root: string, evt: AnyObj) => void;
2879
+ };
2394
2880
  }
2881
+ function livenessStreamFile(root: string): string { return loadLivenessWriteHelper().livenessStreamFile(root); }
2882
+ function appendLivenessEvent(root: string, evt: AnyObj): void { loadLivenessWriteHelper().appendLivenessEvent(root, evt); }
2395
2883
  function readLivenessEvents(root: string): AnyObj[] {
2396
2884
  // Delegate to the shared pure-CJS helper (scripts/hooks/lib/liveness-read.js).
2397
2885
  // Using createRequire so the ESM sidecar can load a CJS module without bundling it.
@@ -2414,36 +2902,168 @@ function livenessLabel(status: string): string {
2414
2902
  return status;
2415
2903
  }
2416
2904
 
2417
- // ─── ADR 0012 lifecycle-driven liveness (opt-in via FLOW_AGENTS_LIVENESS) ──────
2905
+ // ─── ADR 0012 lifecycle-driven liveness (default-on; opt-out via FLOW_AGENTS_LIVENESS) ──
2418
2906
  // init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
2419
2907
  // so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
2420
2908
  // Additive + fail-open: a liveness-emit failure never affects the workflow command.
2421
2909
  const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
2422
- function resolveLivenessActor(): string { return (process.env.FLOW_AGENTS_ACTOR || "").trim() || "local"; }
2423
- function livenessEnabled(): boolean { const v = String(process.env.FLOW_AGENTS_LIVENESS || "").trim().toLowerCase(); return v === "on" || v === "1" || v === "true"; }
2910
+ /**
2911
+ * Delegate to the shared pure-CJS resolver (scripts/hooks/lib/actor-identity.js), mirroring the
2912
+ * createRequire pattern used by readLivenessEvents() above. Deliberately NO inline duplicate
2913
+ * fallback: if the module fails to load, that failure itself must surface as an unresolved actor
2914
+ * ("") — never silently degrade back to the retired "local" default (issue #287).
2915
+ */
2916
+ function loadActorIdentityHelper(): {
2917
+ resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
2918
+ sanitizeSegment: (value: unknown) => string;
2919
+ isUnresolvedActor: (actor: string) => boolean;
2920
+ } {
2921
+ const _req = createRequire(import.meta.url);
2922
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
2923
+ return _req(helperPath) as {
2924
+ resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
2925
+ sanitizeSegment: (value: unknown) => string;
2926
+ isUnresolvedActor: (actor: string) => boolean;
2927
+ };
2928
+ }
2929
+ function resolveLivenessActor(): string {
2930
+ return loadActorIdentityHelper().resolveActor(process.env).actor;
2931
+ }
2932
+ // isUnresolvedActor is no longer defined locally — it is single-sourced in
2933
+ // scripts/hooks/lib/actor-identity.js (loadActorIdentityHelper().isUnresolvedActor) so the
2934
+ // lifecycle auto-emit path, the direct CLI liveness path, and the tool-activity heartbeat path
2935
+ // all consume the same predicate rather than forking their own copy (#287 re-review MEDIUM; #288
2936
+ // Wave 1 Task 1.1 single-sources it there).
2937
+ /**
2938
+ * Delegate to the shared pure-CJS policy predicates (scripts/hooks/lib/liveness-policy.js) — the
2939
+ * one definition of "enabled" (default-on/opt-out) and the claim TTL default, consumed by both the
2940
+ * lifecycle auto-emit path and the manual `liveness claim --ttl` default so they can never disagree
2941
+ * (#288).
2942
+ */
2943
+ function loadLivenessPolicyHelper(): {
2944
+ isLivenessEnabled: (env: NodeJS.ProcessEnv) => boolean;
2945
+ resolveTtlSeconds: (env: NodeJS.ProcessEnv) => number;
2946
+ } {
2947
+ const _req = createRequire(import.meta.url);
2948
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-policy.js");
2949
+ return _req(helperPath) as {
2950
+ isLivenessEnabled: (env: NodeJS.ProcessEnv) => boolean;
2951
+ resolveTtlSeconds: (env: NodeJS.ProcessEnv) => number;
2952
+ };
2953
+ }
2954
+ function livenessEnabled(): boolean { return loadLivenessPolicyHelper().isLivenessEnabled(process.env); }
2955
+ /**
2956
+ * F1 (#288 fix iteration 1, cr-HIGH fail-open violation): the `livenessEnabled()`
2957
+ * guard (and therefore its `loadLivenessPolicyHelper()` module load) must sit
2958
+ * INSIDE this function's own fail-open try/catch — previously it sat outside,
2959
+ * so a missing/broken scripts/hooks/lib/liveness-policy.js module made
2960
+ * `init-plan`/`advance-state` exit 1 instead of degrading gracefully
2961
+ * (repro-verified). Now: any failure here — including a failed helper load —
2962
+ * is caught, produces one stderr diagnostic, and the lifecycle auto-emit is
2963
+ * skipped; the workflow command's own exit code is never affected. This
2964
+ * mirrors the #287 fail-open convention already used elsewhere in this file.
2965
+ * The direct CLI write path (`async function liveness`, actions
2966
+ * claim|heartbeat|release) is deliberately NOT wrapped this way — it stays
2967
+ * fail-loud on a missing helper module, per the plan's explicit instruction
2968
+ * that only the convenience lifecycle wiring is flag-gated/fail-open.
2969
+ */
2424
2970
  function livenessLifecycle(taskDir: string, slug: string, kind: "claim" | "heartbeat" | "release", timestamp: string): void {
2425
- if (!livenessEnabled()) return;
2426
2971
  try {
2427
- const root = path.dirname(taskDir); // .flow-agents/<slug> → .flow-agents (the shared liveness stream lives here)
2428
- const evt: AnyObj = { type: kind, subjectId: slug, actor: resolveLivenessActor(), at: timestamp, source: "lifecycle" };
2429
- if (kind === "claim") evt.ttlSeconds = 1800;
2972
+ if (!livenessEnabled()) return;
2973
+ const actor = resolveLivenessActor();
2974
+ if (loadActorIdentityHelper().isUnresolvedActor(actor)) {
2975
+ process.stderr.write("[liveness] skipped auto-emit: actor unresolved (set FLOW_AGENTS_ACTOR or run inside a supported runtime)\n");
2976
+ return;
2977
+ }
2978
+ const root = path.dirname(taskDir); // .kontourai/flow-agents/<slug> → .kontourai/flow-agents (the shared liveness stream lives here)
2979
+ const evt: AnyObj = { type: kind, subjectId: slug, actor, at: timestamp, source: "lifecycle" };
2980
+ if (kind === "claim") evt.ttlSeconds = loadLivenessPolicyHelper().resolveTtlSeconds(process.env);
2430
2981
  appendLivenessEvent(root, evt);
2431
- } catch { /* best-effort; liveness is advisory and must never break the workflow */ }
2982
+ } catch (err) {
2983
+ // best-effort; liveness is advisory and must never break the workflow — but the failure
2984
+ // itself must be visible (F1), not silently absorbed.
2985
+ try {
2986
+ const detail = err instanceof Error ? err.message : String(err);
2987
+ process.stderr.write(`[liveness] skipped auto-emit: ${detail}\n`);
2988
+ } catch { /* best-effort diagnostic only */ }
2989
+ }
2990
+ }
2991
+
2992
+ /**
2993
+ * F5 (#288 fix iteration 1, sec-LOW): strip control/escape characters before echoing a
2994
+ * subjectId/actor to the terminal. subjectId is a raw CLI positional argument on the write path
2995
+ * (never sanitized before this point — it is stored as-is in the event for data fidelity) and,
2996
+ * on the `status` read path, both subjectId and actor may originate from a hand-edited or
2997
+ * otherwise hostile liveness/events.jsonl file rather than this process's own writes. Strips the
2998
+ * C0 control range (0x00-0x1F), DEL (0x7F), and the C1 range (0x80-0x9F, which includes
2999
+ * ANSI-CSI-adjacent bytes) — display-only; the persisted event itself is never mutated.
3000
+ */
3001
+ function stripControlCharsForDisplay(value: unknown): string {
3002
+ return String(value ?? "").replace(/[\u0000-\u001F\u007F-\u009F]/g, "");
2432
3003
  }
2433
3004
 
2434
3005
  async function liveness(p: ReturnType<typeof parseArgs>): Promise<number> {
2435
3006
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
2436
3007
  const action = p.positional[0] || "";
2437
3008
  const subjectId = p.positional[1] || "";
2438
- const actor = opt(p, "actor", process.env.FLOW_AGENTS_ACTOR || "unknown");
2439
3009
  const nowIso = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
2440
3010
 
3011
+ if (action === "whoami") {
3012
+ // Read-only, lock-free, write-free advisory surface: reuses the identical resolution chain
3013
+ // as the write paths (loadActorIdentityHelper().resolveActor) but deliberately never dies on
3014
+ // an unresolved actor — the enforcement point stays at `liveness claim`, which already dies
3015
+ // loudly (see below). This lets a skill learn "who am I" during a read-only preflight without
3016
+ // emitting a bogus claim first.
3017
+ const helper = loadActorIdentityHelper();
3018
+ const explicitActorRaw = opt(p, "actor", "");
3019
+ const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
3020
+ const resolved = explicitActor
3021
+ ? { actor: explicitActor, source: "explicit-override" }
3022
+ : helper.resolveActor(process.env);
3023
+ if (p.flags.has("json")) { console.log(JSON.stringify(resolved)); return 0; }
3024
+ console.log(`${stripControlCharsForDisplay(resolved.actor || "unresolved")}\t${stripControlCharsForDisplay(resolved.source)}`);
3025
+ return 0;
3026
+ }
3027
+
2441
3028
  if (action === "claim" || action === "heartbeat" || action === "release") {
3029
+ // Actor resolution happens only for write actions (F5, #287 fix iteration 1) — "status" is a
3030
+ // read path and must not shell out via resolveLivenessActor()/resolveActor().
3031
+ const helper = loadActorIdentityHelper();
3032
+ const explicitActorRaw = opt(p, "actor", "");
3033
+ // F7 (#287 fix iteration 2): an explicit --actor value that strips to empty under the allowed
3034
+ // [A-Za-z0-9_.-] charset (e.g. "--actor ':::'") is a hard error on this write path, not a
3035
+ // silent fallback to sanitizeSegment's shared "unknown" sentinel — garbage on an explicit flag
3036
+ // is an authoring mistake the caller must fix, unlike the env-override seam (which falls
3037
+ // through to derivation instead, since there is no flag to correct).
3038
+ if (explicitActorRaw && !/[A-Za-z0-9_.-]/.test(explicitActorRaw)) {
3039
+ 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.`);
3040
+ }
3041
+ const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
3042
+ const actor = explicitActor || helper.resolveActor(process.env).actor;
3043
+ if (helper.isUnresolvedActor(actor)) {
3044
+ 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.`);
3045
+ }
2442
3046
  if (!subjectId) die(`liveness ${action} requires a subjectId`);
3047
+ // F8(i) (#288 fix iteration 2, orphan-heartbeat invariant): heartbeat/release on the direct CLI
3048
+ // write path must never be the FIRST liveness event ever written for a (subjectId, actor) pair —
3049
+ // that is exactly how the reviewer-reproduced orphan-heartbeat bug arises (a bare heartbeat with
3050
+ // no claim behind it, later mistaken for claim evidence). A full stream read here is fine: this
3051
+ // is a rare, human/CLI-driven call, not the hot tool-activity path. `claim` itself is exempt (it
3052
+ // is the event that ESTABLISHES the pair in the first place).
3053
+ if (action === "heartbeat" || action === "release") {
3054
+ const priorEvents = readLivenessEvents(root);
3055
+ const hasPriorClaim = priorEvents.some((e) => e && e.type === "claim" && e.subjectId === subjectId && e.actor === actor);
3056
+ if (!hasPriorClaim) {
3057
+ 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}.`);
3058
+ }
3059
+ }
2443
3060
  const evt: AnyObj = { type: action, subjectId, actor, at: opt(p, "at") || nowIso };
2444
- if (action === "claim") evt.ttlSeconds = Number.parseInt(opt(p, "ttl", "1800"), 10) || 1800;
3061
+ if (action === "claim") {
3062
+ const defaultTtl = loadLivenessPolicyHelper().resolveTtlSeconds(process.env);
3063
+ evt.ttlSeconds = Number.parseInt(opt(p, "ttl", String(defaultTtl)), 10) || defaultTtl;
3064
+ }
2445
3065
  appendLivenessEvent(root, evt);
2446
- console.log(`liveness ${action}: ${subjectId} by ${actor}`);
3066
+ console.log(`liveness ${action}: ${stripControlCharsForDisplay(subjectId)} by ${stripControlCharsForDisplay(actor)}`);
2447
3067
  return 0;
2448
3068
  }
2449
3069
 
@@ -2467,16 +3087,103 @@ async function liveness(p: ReturnType<typeof parseArgs>): Promise<number> {
2467
3087
  const rows: AnyObj[] = [];
2468
3088
  for (const g of groups.values()) {
2469
3089
  if (subjectFilter && g.subjectId !== subjectFilter) continue;
2470
- const claim: AnyObj = { id: `${g.subjectId}::${g.actor}`, subjectType: "work-item", subjectId: g.subjectId, surface: "flow.liveness", claimType: "liveness.hold", fieldOrBehavior: "held-by", value: g.actor, createdAt: g.created, updatedAt: g.updated, ttlSeconds: g.ttlSeconds, verificationPolicyId: LIVENESS_POLICY.id };
3090
+ const claim: AnyObj = { id: `${g.subjectId}::${g.actor}`, subjectType: "work-item", subjectId: g.subjectId, facet: "flow.liveness", claimType: "liveness.hold", fieldOrBehavior: "held-by", value: g.actor, createdAt: g.created, updatedAt: g.updated, ttlSeconds: g.ttlSeconds, verificationPolicyId: LIVENESS_POLICY.id };
2471
3091
  const status = surface.deriveTrustStatus!({ claim, evidence: [], policy: LIVENESS_POLICY, events: g.events, now });
2472
3092
  rows.push({ subjectId: g.subjectId, actor: g.actor, status, label: livenessLabel(status) });
2473
3093
  }
2474
3094
  if (p.flags.has("json")) { console.log(JSON.stringify(rows, null, 2)); return 0; }
2475
- for (const r of rows) console.log(`${r.subjectId}\t${r.actor}\t${r.label}`);
3095
+ for (const r of rows) console.log(`${stripControlCharsForDisplay(r.subjectId)}\t${stripControlCharsForDisplay(r.actor)}\t${r.label}`);
3096
+ return 0;
3097
+ }
3098
+
3099
+ if (action === "verdict") {
3100
+ // Read-only, lock-free CLI helper (#320 AC1) — mirrors `whoami`'s lock-bypass (see
3101
+ // isLivenessVerdict in main() below): computes {subjectId, winner, losers, reason, holders}
3102
+ // as a PURE function of the shared liveness stream. Among the subject's currently-fresh
3103
+ // claim holders (via the shared `freshHolders` helper — the exact canonical
3104
+ // freshness/grouping/release-handling logic `workflow-steering.js`'s ambient digest already
3105
+ // uses, not a re-derived rule), the holder whose most recent `claim` event has the earliest
3106
+ // `at` wins; an exact-timestamp tie breaks by ascending plain string comparison of actor id
3107
+ // (never locale-aware collation, for cross-machine determinism). Same stream state ⇒ same
3108
+ // verdict, regardless of which actor invokes it — no actor-specific input is read here.
3109
+ if (!subjectId) die("liveness verdict requires a subjectId");
3110
+ const events = readLivenessEvents(root);
3111
+ const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : Date.now();
3112
+ const _req = createRequire(import.meta.url);
3113
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
3114
+ const { freshHolders } = _req(helperPath) as { freshHolders: (events: AnyObj[], slug: string, selfActor: string, nowMs: number) => AnyObj[] };
3115
+ // Impossible-sentinel selfActor: real actors are sanitized to [A-Za-z0-9_.-]+ and can never be
3116
+ // empty (enforced on every write path above), so passing "" excludes nothing.
3117
+ const fresh = freshHolders(events, subjectId, "", nowMs);
3118
+ // freshHolders' `lastAt` tracks the group's latest event of ANY type (heartbeat-updated); the
3119
+ // tiebreak instead operates on each holder's most recent `claim`-type event `at` — the
3120
+ // "current claim timestamp", distinct from lastAt.
3121
+ const latestClaimAt = (actor: string): string => {
3122
+ let best = "";
3123
+ for (const e of events) {
3124
+ if (e && e.subjectId === subjectId && e.actor === actor && e.type === "claim" && typeof e.at === "string") {
3125
+ if (!best || e.at > best) best = e.at;
3126
+ }
3127
+ }
3128
+ return best;
3129
+ };
3130
+ const holders: AnyObj[] = fresh
3131
+ .map((h) => ({ actor: String(h.actor), claimAt: latestClaimAt(String(h.actor)) || String(h.lastAt), lastAt: String(h.lastAt), ttlSeconds: Number(h.ttlSeconds) }))
3132
+ .sort((a, b) => (a.claimAt < b.claimAt ? -1 : a.claimAt > b.claimAt ? 1 : a.actor < b.actor ? -1 : a.actor > b.actor ? 1 : 0));
3133
+ let winner: AnyObj | null = null;
3134
+ let losers: AnyObj[] = [];
3135
+ let reason = "no-conflict";
3136
+ if (holders.length >= 2) {
3137
+ winner = holders[0];
3138
+ const tie = holders.filter((h) => h.claimAt === winner!.claimAt).length > 1;
3139
+ reason = tie ? "tie-actor-lexicographic" : "earlier-claim";
3140
+ losers = holders.filter((h) => h.actor !== winner!.actor);
3141
+ }
3142
+ if (p.flags.has("json")) {
3143
+ // #320 fix iteration 1 (sec-HIGH F3): `winner`/`losers`/`holders` carry raw actor/claimAt/
3144
+ // lastAt strings sourced straight from the multi-writer append-only liveness/events.jsonl
3145
+ // stream (see stripControlCharsForDisplay's header above — any process can append a
3146
+ // hostile line, bypassing the CLI write-side sanitizeSegment entirely). The pull-work
3147
+ // SKILL.md's Post-Claim Conflict Re-check instructs an LLM to read `winner.actor` /
3148
+ // `losers[].actor` straight out of THIS `--json` output into its own context — a second,
3149
+ // LLM-facing injection path alongside the mid-turn conflict hook (liveness-heartbeat.js's
3150
+ // computeConflict). Build a sanitized COPY for JSON emission only: control-char strip
3151
+ // (matching the text branch's existing stripControlCharsForDisplay treatment below) plus a
3152
+ // 64-char cap (matching sanitizeSegment's cap on the write side) on actor/claimAt/lastAt.
3153
+ // `reason`/`subjectId`/`ttlSeconds` are left as-is (enum/number, never free text). Never
3154
+ // mutates `result`/`holders`/`winner`/`losers` themselves — the text branch below still
3155
+ // needs the untouched originals for its own per-line rendering, and winner/loser identity
3156
+ // (already sanitizeSegment-clean for legitimate actors) is unaffected, so the loser-release
3157
+ // contract still works.
3158
+ const sanitizeVerdictHolderForJson = (h: AnyObj | null): AnyObj | null =>
3159
+ h
3160
+ ? {
3161
+ ...h,
3162
+ actor: stripControlCharsForDisplay(h.actor).slice(0, 64),
3163
+ claimAt: stripControlCharsForDisplay(h.claimAt).slice(0, 64),
3164
+ lastAt: stripControlCharsForDisplay(h.lastAt).slice(0, 64),
3165
+ }
3166
+ : h;
3167
+ const jsonResult = {
3168
+ subjectId,
3169
+ winner: sanitizeVerdictHolderForJson(winner),
3170
+ losers: losers.map((l) => sanitizeVerdictHolderForJson(l)),
3171
+ reason,
3172
+ holders: holders.map((h) => sanitizeVerdictHolderForJson(h)),
3173
+ };
3174
+ console.log(JSON.stringify(jsonResult));
3175
+ return 0;
3176
+ }
3177
+ for (const h of holders) {
3178
+ const tag = winner && h.actor === (winner as AnyObj).actor ? "WINNER" : (holders.length >= 2 ? "LOSER" : "HOLDER");
3179
+ console.log(`${stripControlCharsForDisplay(h.actor)}\tclaimAt=${stripControlCharsForDisplay(h.claimAt)}\t${tag}`);
3180
+ }
3181
+ if (winner) console.log(`WINNER: ${stripControlCharsForDisplay((winner as AnyObj).actor)}`);
3182
+ for (const l of losers) console.log(`LOSER: ${stripControlCharsForDisplay(l.actor)}`);
2476
3183
  return 0;
2477
3184
  }
2478
3185
 
2479
- die("liveness action must be one of: claim | heartbeat | release | status");
3186
+ die("liveness action must be one of: claim | heartbeat | release | status | whoami | verdict");
2480
3187
  return 1;
2481
3188
  }
2482
3189
  // ─────────────────────────────────────────────────────────────────────────────
@@ -2652,9 +3359,24 @@ Available claim ids:
2652
3359
  async function main(): Promise<number> {
2653
3360
  const p = parseArgs(process.argv.slice(2));
2654
3361
  if (!p.command) die("workflow-sidecar command is required");
2655
- const lockRoot = ["ensure-session", "current", "dogfood-pass", "liveness"].includes(p.command)
3362
+ // F1 (#166 fix iteration 1): `liveness whoami` is a read-only, lock-free, write-free advisory
3363
+ // surface (see the `action === "whoami"` branch inside `liveness()` above) — it must never
3364
+ // acquire the workflow-sidecar lock, regardless of whether the artifact root already exists on
3365
+ // disk. Without this action-level bypass, `liveness` was blanket-included in the lock-routing
3366
+ // branch below, so `whoami` against an EXISTING artifact root would still resolve a real
3367
+ // lockRoot and go through `withLock`'s mkdir/lockdir path — the opposite of "genuinely
3368
+ // lock-free". This bypass mirrors `resolve-slug`'s existing empty-lockRoot special case
3369
+ // immediately below and is scoped to the `whoami` action only: `liveness status` (a read path)
3370
+ // keeps its pre-existing lock behavior unchanged (out of scope for this fix — see fix-plan
3371
+ // iteration 1, F1), and `liveness claim` / `heartbeat` / `release` (write paths) are untouched.
3372
+ const isLivenessWhoami = p.command === "liveness" && p.positional[0] === "whoami";
3373
+ // #320 AC1: `liveness verdict` is read-only and lock-free, exactly like `whoami` above — it
3374
+ // must never acquire the workflow-sidecar lock. Same bypass shape, scoped to the `verdict`
3375
+ // action only; `liveness status` (also a read path) keeps its pre-existing lock behavior.
3376
+ const isLivenessVerdict = p.command === "liveness" && p.positional[0] === "verdict";
3377
+ const lockRoot = (["ensure-session", "current", "dogfood-pass", "liveness"].includes(p.command) && !isLivenessWhoami && !isLivenessVerdict)
2656
3378
  ? (opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : (p.command === "ensure-session" ? flowAgentsArtifactRoot() : defaultArtifactRootForRead()))
2657
- : p.command === "record-agent-event" ? explicitArtifactRoot(p) : p.command === "claim" ? (p.positional[1] ? path.resolve(p.positional[1]) : "") : p.positional[0] ? artifactDirFrom(p.positional[0]) : "";
3379
+ : p.command === "record-agent-event" ? explicitArtifactRoot(p) : p.command === "claim" ? (p.positional[1] ? path.resolve(p.positional[1]) : "") : p.command === "resolve-slug" ? "" : (isLivenessWhoami || isLivenessVerdict) ? "" : p.positional[0] ? artifactDirFrom(p.positional[0]) : "";
2658
3380
  return withLock(lockRoot, ["ensure-session", "record-agent-event", "dogfood-pass"].includes(p.command), p.command, () => {
2659
3381
  switch (p.command) {
2660
3382
  case "ensure-session": return ensureSession(p);
@@ -2663,6 +3385,7 @@ async function main(): Promise<number> {
2663
3385
  case "init-plan": return initPlan(p);
2664
3386
  case "record-evidence": return recordEvidence(p);
2665
3387
  case "record-gate-claim": return recordGateClaim(p);
3388
+ case "promote": return promote(p);
2666
3389
  case "advance-state": return advanceState(p);
2667
3390
  case "record-critique": return recordCritique(p);
2668
3391
  case "import-critique": return importCritique(p);
@@ -2674,6 +3397,7 @@ async function main(): Promise<number> {
2674
3397
  case "trust-mcp": return trustMcp(p);
2675
3398
  case "liveness": return liveness(p);
2676
3399
  case "claim": return claimLookup(p);
3400
+ case "resolve-slug": return resolveSlugCmd(p);
2677
3401
  case "seal-checkpoint": return sealCheckpoint(p);
2678
3402
  case "publish-delivery": return publishDeliveryCmd(p);
2679
3403
  default: die(`unknown command: ${p.command}`);