@kontourai/flow-agents 2.4.0 → 3.1.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 (195) hide show
  1. package/.github/CODEOWNERS +8 -0
  2. package/.github/workflows/ci.yml +20 -0
  3. package/.github/workflows/trust-reconcile.yml +62 -4
  4. package/CHANGELOG.md +38 -0
  5. package/CONTEXT.md +88 -1
  6. package/README.md +4 -0
  7. package/build/src/cli/assignment-provider.d.ts +144 -0
  8. package/build/src/cli/assignment-provider.js +805 -0
  9. package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
  10. package/build/src/cli/effective-assignment-provider-settings.js +125 -0
  11. package/build/src/cli/validate-workflow-artifacts.js +5 -1
  12. package/build/src/cli/workflow-sidecar.d.ts +2 -2
  13. package/build/src/cli/workflow-sidecar.js +549 -145
  14. package/build/src/cli.js +6 -0
  15. package/build/src/lib/flow-resolver.d.ts +12 -6
  16. package/build/src/lib/flow-resolver.js +30 -14
  17. package/build/src/tools/validate-source-tree.js +2 -0
  18. package/context/contracts/artifact-contract.md +2 -0
  19. package/context/contracts/assignment-provider-contract.md +248 -0
  20. package/context/contracts/builder-kit-workflow-state-contract.md +2 -0
  21. package/context/contracts/decision-registry-contract.md +2 -0
  22. package/context/contracts/delivery-contract.md +2 -0
  23. package/context/contracts/execution-contract.md +25 -0
  24. package/context/contracts/governance-adapter-contract.md +2 -0
  25. package/context/contracts/knowledge-store-contract.md +197 -0
  26. package/context/contracts/planning-contract.md +2 -0
  27. package/context/contracts/probe-docs-write-contract.md +187 -0
  28. package/context/contracts/review-contract.md +2 -0
  29. package/context/contracts/sandbox-policy.md +2 -0
  30. package/context/contracts/standing-directives.md +13 -0
  31. package/context/contracts/verification-contract.md +2 -0
  32. package/context/contracts/work-item-contract.md +2 -0
  33. package/context/scripts/hooks/config-protection.js +14 -1
  34. package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
  35. package/context/scripts/hooks/stop-goal-fit.js +4 -2
  36. package/context/scripts/hooks/workflow-steering.js +42 -0
  37. package/context/settings/assignment-provider-settings.json +33 -0
  38. package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
  39. package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
  40. package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
  41. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  42. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  43. package/docs/adr/0006-typescript-first-source-policy.md +2 -0
  44. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  45. package/docs/adr/0007-skill-audit.md +2 -0
  46. package/docs/adr/0008-kit-operation-boundary.md +2 -0
  47. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
  48. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
  49. package/docs/adr/0011-mcp-posture.md +2 -0
  50. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
  51. package/docs/adr/0013-context-lifecycle.md +2 -0
  52. package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
  53. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
  54. package/docs/adr/0016-three-hard-boundary-model.md +2 -0
  55. package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
  56. package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
  57. package/docs/adr/0019-kit-dependency-ownership.md +2 -0
  58. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
  59. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
  60. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +182 -0
  61. package/docs/adr/README.md +49 -0
  62. package/docs/adr/index.md +34 -0
  63. package/docs/context-map.md +1 -0
  64. package/docs/decisions/agent-coordination.md +20 -0
  65. package/docs/decisions/anti-gaming-trust-security.md +20 -0
  66. package/docs/decisions/context-lifecycle.md +18 -0
  67. package/docs/decisions/core-domain-kit-boundary.md +18 -0
  68. package/docs/decisions/flow-flow-agents-boundary.md +18 -0
  69. package/docs/decisions/flow-kit.md +20 -0
  70. package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
  71. package/docs/decisions/graph-knowledge-provider.md +63 -0
  72. package/docs/decisions/hook-core-kit-boundary.md +18 -0
  73. package/docs/decisions/index.md +22 -0
  74. package/docs/decisions/kit-dependency-ownership.md +18 -0
  75. package/docs/decisions/kit-operation-boundary.md +18 -0
  76. package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
  77. package/docs/decisions/knowledge-store-provider.md +51 -0
  78. package/docs/decisions/kontour-resource-contract.md +18 -0
  79. package/docs/decisions/mcp-posture.md +18 -0
  80. package/docs/decisions/model-routing.md +63 -0
  81. package/docs/decisions/standing-directives.md +66 -0
  82. package/docs/decisions/three-hard-boundary-model.md +18 -0
  83. package/docs/decisions/trust-reconcile.md +20 -0
  84. package/docs/decisions/typescript-source-policy.md +48 -0
  85. package/docs/decisions/workflow-enforcement.md +18 -0
  86. package/docs/decisions/workflow-trust-state.md +20 -0
  87. package/docs/fixture-ownership.md +1 -0
  88. package/docs/workflow-shared-contracts.md +2 -1
  89. package/docs/workflow-usage-guide.md +7 -1
  90. package/evals/ci/run-baseline.sh +10 -0
  91. package/evals/fixtures/assignment-provider/actor-a.json +6 -0
  92. package/evals/fixtures/assignment-provider/actor-b.json +6 -0
  93. package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
  94. package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
  95. package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
  96. package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
  97. package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
  98. package/evals/integration/test_assignment_provider_github.sh +318 -0
  99. package/evals/integration/test_assignment_provider_local_file.sh +222 -0
  100. package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
  101. package/evals/integration/test_current_json_per_actor.sh +516 -0
  102. package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
  103. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  104. package/evals/integration/test_gate_lockdown.sh +10 -0
  105. package/evals/integration/test_publish_delivery.sh +21 -4
  106. package/evals/integration/test_pull_work_assignment_join.sh +132 -0
  107. package/evals/integration/test_pull_work_liveness_preflight.sh +20 -7
  108. package/evals/integration/test_reconcile_soundness.sh +33 -9
  109. package/evals/integration/test_trust_reconcile.sh +9 -8
  110. package/evals/integration/test_trust_reconcile_negatives.sh +608 -0
  111. package/evals/integration/test_workflow_sidecar_writer.sh +91 -3
  112. package/evals/run.sh +14 -0
  113. package/evals/static/test_flowdef_codeowners_coverage.sh +6 -0
  114. package/evals/static/test_knowledge_providers.sh +32 -0
  115. package/evals/static/test_workflow_skills.sh +15 -2
  116. package/kits/builder/skills/deliver/SKILL.md +36 -0
  117. package/kits/builder/skills/design-probe/SKILL.md +37 -0
  118. package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
  119. package/kits/builder/skills/pull-work/SKILL.md +78 -10
  120. package/kits/knowledge/adapters/default-store/index.js +92 -4
  121. package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
  122. package/kits/knowledge/adapters/shared/codec.js +141 -0
  123. package/kits/knowledge/docs/README.md +121 -2
  124. package/kits/knowledge/docs/store-contract.md +112 -4
  125. package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
  126. package/kits/knowledge/flows/promote.flow.json +84 -0
  127. package/kits/knowledge/kit.json +50 -0
  128. package/kits/knowledge/promote/distill.js +96 -0
  129. package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
  130. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
  131. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
  132. package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
  133. package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
  134. package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
  135. package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
  136. package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
  137. package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
  138. package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
  139. package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
  140. package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
  141. package/kits/knowledge/promote/fixtures/session/state.json +19 -0
  142. package/kits/knowledge/promote/health.js +137 -0
  143. package/kits/knowledge/promote/index.js +176 -0
  144. package/kits/knowledge/promote/ingest.js +103 -0
  145. package/kits/knowledge/promote/lib.js +132 -0
  146. package/kits/knowledge/promote/link.js +84 -0
  147. package/kits/knowledge/promote/promote.test.js +174 -0
  148. package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
  149. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
  150. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
  151. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
  152. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
  153. package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
  154. package/kits/knowledge/providers/conformance/suite.test.js +143 -0
  155. package/kits/knowledge/providers/git-repo/index.js +236 -0
  156. package/kits/knowledge/providers/health/health-pass.test.js +99 -0
  157. package/kits/knowledge/providers/health/index.js +153 -0
  158. package/kits/knowledge/providers/index.js +25 -0
  159. package/kits/knowledge/providers/lib/model.js +91 -0
  160. package/kits/knowledge/providers/lib/schema-validate.js +119 -0
  161. package/kits/knowledge/providers/markdown-vault/index.js +169 -0
  162. package/kits/knowledge/providers/neo4j/connection.js +121 -0
  163. package/kits/knowledge/providers/neo4j/cypher.js +190 -0
  164. package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
  165. package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
  166. package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
  167. package/kits/knowledge/providers/neo4j/index.js +280 -0
  168. package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
  169. package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
  170. package/kits/knowledge/providers/neo4j/sync.js +235 -0
  171. package/kits/knowledge/providers/work-item/index.js +204 -0
  172. package/package.json +7 -3
  173. package/schemas/assignment-provider-settings.schema.json +125 -0
  174. package/schemas/knowledge/edge.schema.json +54 -0
  175. package/schemas/knowledge/health-report.schema.json +45 -0
  176. package/schemas/knowledge/node.schema.json +49 -0
  177. package/schemas/knowledge/proposal.schema.json +53 -0
  178. package/scripts/README.md +1 -0
  179. package/scripts/ci/trust-reconcile.js +521 -24
  180. package/scripts/freeze-adrs.mjs +364 -0
  181. package/scripts/hooks/config-protection.js +14 -1
  182. package/scripts/hooks/evidence-capture.js +4 -1
  183. package/scripts/hooks/lib/config-protection-remedies.js +9 -0
  184. package/scripts/hooks/lib/current-pointer.js +123 -0
  185. package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
  186. package/scripts/hooks/stop-goal-fit.js +4 -2
  187. package/scripts/hooks/workflow-steering.js +42 -0
  188. package/scripts/statusline/flow-agents-statusline.js +3 -1
  189. package/src/cli/assignment-provider.ts +927 -0
  190. package/src/cli/effective-assignment-provider-settings.ts +112 -0
  191. package/src/cli/validate-workflow-artifacts.ts +5 -1
  192. package/src/cli/workflow-sidecar.ts +577 -139
  193. package/src/cli.ts +6 -0
  194. package/src/lib/flow-resolver.ts +35 -14
  195. package/src/tools/validate-source-tree.ts +2 -0
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import * as fs from "node:fs";
3
+ import * as os from "node:os";
3
4
  import * as path from "node:path";
4
5
  import { execFileSync } from "node:child_process";
5
6
  import { createHash } from "node:crypto";
@@ -8,6 +9,11 @@ import { fileURLToPath } from "node:url";
8
9
  // ADR 0016 Abstraction A: shared FlowDefinition resolver (P-a)
9
10
  import { resolveActiveFlowStep, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy, type ActiveFlowStep } from "../lib/flow-resolver.js";
10
11
  import { defaultArtifactRootForRead, flowAgentsArtifactRoot } from "../lib/local-artifact-root.js";
12
+ // #291 Wave 1 Task 1.1 exports: ensure-session's ownership guard reuses the EXACT same
13
+ // assignment ⋈ liveness join / claim / supersede logic #290 already ships for the
14
+ // `assignment-provider` CLI, rather than reimplementing a second, parallel join (static ESM
15
+ // import — same idiom already used above for ../lib/flow-resolver.js).
16
+ import { computeEffectiveState, performLocalClaim, performLocalSupersede, readLocalAssignmentStatus, type ActorStruct, type EffectiveState, type FreshHolder } from "./assignment-provider.js";
11
17
 
12
18
  type AnyObj = Record<string, any>;
13
19
 
@@ -164,6 +170,78 @@ function validateExplicitBranch(value: string): void {
164
170
  }
165
171
  }
166
172
 
173
+ /**
174
+ * #291 Wave 2 Task 2.1: the single shared actor resolver for ensure-session — used by BOTH
175
+ * resolveSessionBranch() (below) and the new ownership guard inside ensureSession(), so the
176
+ * branch-naming actor and the assignment-claim actor are ALWAYS derived from the same one
177
+ * resolution pass (never re-derived twice with any risk of divergence). Same explicit-actor
178
+ * validation/die behavior as before this refactor (garbage --actor still dies; ambiguous/
179
+ * unresolved actor never hard-fails session creation — Design Decision 4).
180
+ *
181
+ * Returns TWO distinct identity strings, not one, because this repo already has two genuinely
182
+ * different actor-identity conventions in play and reconciling them into a single string would
183
+ * either break existing branch-naming byte-for-byte (see test_workflow_sidecar_writer.sh AC4) or
184
+ * break self-recognition against a durable assignment-claim record:
185
+ * - `branchActorKey`: the flat, single-token-or-triple string this file has ALWAYS used for
186
+ * branch naming, liveness `--actor`, and (Wave 2 Task 2.1 §6) per-actor current.json
187
+ * partitioning — exactly `resolveActor(env).actor`, or the bare sanitized explicit --actor
188
+ * override, unchanged from before this refactor.
189
+ * - `actorStruct` / `claimActorKey` (`= serializeActor(actorStruct)`): a structured ActorStruct
190
+ * for the assignment-provider claim record's `actor` field (Wave 1's `performLocalClaim`/
191
+ * `performLocalSupersede` require one), and the identity string passed as `computeEffectiveState`'s
192
+ * `selfActor` param. For the common/default case (no explicit --actor override — the ambient
193
+ * runtime-session-id or process-ancestry derivation), this is reconstructed from the SAME
194
+ * exported primitives `resolveActor()` uses internally, so `claimActorKey` reproduces
195
+ * `branchActorKey` bit-for-bit. For an explicit --actor override, no ambient ActorStruct
196
+ * exists (resolveActor()'s override branch is a flat bypass, never a serializeActor() triple),
197
+ * so a synthetic-but-deterministic wrapper is used instead — self-recognition on reentry still
198
+ * holds (same override value always re-derives the same claimActorKey), but this synthetic key
199
+ * will not equal a DIFFERENT process's flat `liveness claim --actor <sameOverrideValue>` actor
200
+ * string (that command's own `--actor` bypass strips the same override value's ':' or wraps it
201
+ * no differently — a pre-existing seam between the liveness-actor and assignment-actor
202
+ * identity domains, not introduced by this task; see Wave 2 Task 2.1 plan Conflict #3).
203
+ */
204
+ function resolveEnsureSessionActor(p: ReturnType<typeof parseArgs>): { actorStruct: ActorStruct; actorKey: string; branchActorKey: string; unresolved: boolean } {
205
+ const helper = loadActorIdentityHelper();
206
+ const explicitActorRaw = opt(p, "actor", "").trim();
207
+ if (explicitActorRaw && !/[A-Za-z0-9_.-]/.test(explicitActorRaw)) {
208
+ 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.");
209
+ }
210
+ const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
211
+ const resolved = explicitActor ? { actor: explicitActor, source: "explicit-override" } : helper.resolveActor(process.env);
212
+ const unresolved = helper.isUnresolvedActor(resolved.actor);
213
+ const branchActorKey = unresolved ? `unknown-actor-${unknownDisambiguator(resolved.actor)}` : resolved.actor;
214
+
215
+ if (unresolved) {
216
+ // Design Decision 4 (unchanged): never hard-fail session creation on actor ambiguity. No real
217
+ // ActorStruct exists for "unresolved" — the guard (ensureSession) is responsible for skipping
218
+ // the ownership guard entirely when unresolved, rather than claiming under a synthetic identity.
219
+ return { actorStruct: { runtime: "unresolved", session_id: branchActorKey, host: os.hostname() }, actorKey: resolved.actor, branchActorKey, unresolved: true };
220
+ }
221
+
222
+ const actorStruct: ActorStruct = resolved.source === "explicit-override"
223
+ ? { runtime: "explicit-override", session_id: resolved.actor, host: os.hostname() }
224
+ : { runtime: helper.detectRuntime(process.env), session_id: helper.runtimeSessionId(process.env) || (() => { const seed = helper.ancestorActorSeed(); return seed ? `anc-${seed}` : ""; })(), host: os.hostname() };
225
+ const actorKey = helper.serializeActor(actorStruct);
226
+ return { actorStruct, actorKey, branchActorKey, unresolved: false };
227
+ }
228
+
229
+ /**
230
+ * Resolve an actor key for a READ-ONLY consumer (current(), currentDir(), recordAgentEvent(),
231
+ * writeTrustBundle()'s default) — deliberately lenient (never
232
+ * dies on a garbage --actor value; that enforcement belongs only to the write/claim path in
233
+ * resolveEnsureSessionActor above). Falls back to resolveActor(process.env) exactly like every
234
+ * write path already does. The returned value is passed straight into
235
+ * scripts/hooks/lib/current-pointer.js's readCurrentPointer(), which already tolerates an empty
236
+ * or unresolved actor by falling straight through to the legacy current.json branch — so an
237
+ * unresolved actor here reproduces exactly today's (pre-#291) legacy-only read behavior.
238
+ */
239
+ function resolveReadActorKey(p: ReturnType<typeof parseArgs>): string {
240
+ const helper = loadActorIdentityHelper();
241
+ const explicit = opt(p, "actor", "").trim();
242
+ return explicit ? helper.sanitizeSegment(explicit) : helper.resolveActor(process.env).actor;
243
+ }
244
+
167
245
  /** Resolve the branch to seed a brand-new session with. Only called from ensureSession's
168
246
  * `if (!md)` fresh-creation branch — an existing session's already-recorded branch is never
169
247
  * recomputed (ADR 0021 §5 takeover continuity; see Design Decision 3). Precedence: explicit
@@ -177,13 +255,12 @@ function resolveSessionBranch(p: ReturnType<typeof parseArgs>, slug: string): st
177
255
  const explicitBranch = opt(p, "branch");
178
256
  if (explicitBranch) { validateExplicitBranch(explicitBranch); return explicitBranch; }
179
257
  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);
258
+ // #291 Wave 2 Task 2.1: actor resolution is now single-sourced via resolveEnsureSessionActor()
259
+ // (shared with the new ownership guard) — branchActorKey reproduces EXACTLY what this function's
260
+ // own inline `actor` variable used to compute, so branch naming is byte-identical to before this
261
+ // refactor (proven by test_workflow_sidecar_writer.sh's AC2/AC4/AC5 branch-naming assertions).
262
+ const { branchActorKey, unresolved } = resolveEnsureSessionActor(p);
263
+ const safeActor = unresolved ? branchActorKey : sanitizeBranchSegment(branchActorKey, helper);
187
264
  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
265
  return `agent/${safeActor}/${sanitizeBranchSegment(slug, helper)}`;
189
266
  }
@@ -444,7 +521,7 @@ function critiqueToEventStatus(verdict: string, findings: AnyObj[]): string | nu
444
521
  * @param critiques Critique objects (from critique.json .critiques array)
445
522
  * @param commandLog Optional parsed command-log.jsonl entries (capture-authoritative fold)
446
523
  */
447
- export async function buildTrustBundle(slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], commandLog?: AnyObj[], flowAgentsDir?: string): Promise<AnyObj | null> {
524
+ export async function buildTrustBundle(slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], commandLog?: AnyObj[], flowAgentsDir?: string, actorKey?: string): Promise<AnyObj | null> {
448
525
  const surface = await tryLoadSurface();
449
526
  if (!surface) return null;
450
527
  const { deriveClaimStatus, generateClaimId, statusFunctionVersion } = surface;
@@ -454,7 +531,10 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
454
531
  // each produced claim gets a DECLARED primary claim (kit-typed) plus a legacy shadow
455
532
  // (workflow.* type, claimId suffix "-legacy") for backward compatibility. When null,
456
533
  // only the existing workflow.* claims are produced (zero behavior change).
457
- const activeStep: ActiveFlowStep | null = flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir) : null;
534
+ // #291 Wave 2 Task 2.1 (§7)/Task 2.2: actorKey (when the caller already resolved one — see
535
+ // writeTrustBundle below) threads through to resolveActiveFlowStep's per-actor-first,
536
+ // legacy-fallback current.json read; omitted, this is IDENTICAL to pre-#291 behavior.
537
+ const activeStep: ActiveFlowStep | null = flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir, actorKey) : null;
458
538
 
459
539
  const claims: AnyObj[] = [];
460
540
  const evidenceItems: AnyObj[] = [];
@@ -618,7 +698,11 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
618
698
  // (workflow-artifact-cleanup-audit) and validators can detect the promotion claim without a
619
699
  // new manifest entry. It rides alongside any waiver in a single merged metadata object.
620
700
  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;
701
+ // #268: stamp a stable origin discriminator so checksFromBundle / critiquesFromBundle can
702
+ // distinguish check vs critique vs acceptance claims across round-trips even under --flow-id,
703
+ // where all three collapse onto the same declared claimType (and a command-less critique claim
704
+ // would otherwise be re-absorbed as a test_output check → permanent [not-run] divergence).
705
+ const claimMetadata: AnyObj = { origin: "check", check_kind: String(check.kind ?? "external"), ...(waiver ? { waiver } : {}), ...(promotionMeta ? { promotion: promotionMeta } : {}) };
622
706
 
623
707
  const claimEvents: AnyObj[] = [];
624
708
  if (evStatus) {
@@ -678,12 +762,12 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
678
762
  if (declared) {
679
763
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
680
764
  const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
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 };
765
+ const declaredClaimObj: AnyObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, metadata: { origin: "acceptance" } };
682
766
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
683
767
  claims.push({ ...declaredClaimObj, status: declaredStatus });
684
768
  } else {
685
769
  // No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
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 };
770
+ const claimObj: AnyObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id, metadata: { origin: "acceptance" } };
687
771
  const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
688
772
  claims.push({ ...claimObj, status: derivedStatus });
689
773
  }
@@ -694,10 +778,23 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
694
778
  if (!c.id) continue;
695
779
  const subjectId = `${slug}/${c.id}`;
696
780
  const fieldOrBehavior = String(c.summary ?? c.verdict ?? c.id);
697
- const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
781
+ // #267/#282: a critique carrying `superseded_by` is retained as HISTORY — a prior write for
782
+ // this critique id that a later, same-reviewer critique resolved. It is preserved structurally
783
+ // (status "superseded" + first-class metadata.superseded_by), but is excluded from reconcile
784
+ // evaluation and from the "critique pass cannot include fail members" validator rule.
785
+ const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
786
+ const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
787
+ const critiqueReviewedAt = String(c.reviewed_at ?? ts);
788
+ const critMeta: AnyObj = { origin: "critique", reviewer: critiqueReviewer, reviewed_at: critiqueReviewedAt, ...(supersededBy ? { superseded_by: supersededBy } : {}) };
789
+ // A superseded historical write gets a distinct, stable claimId so it co-exists with the live
790
+ // claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
791
+ // across rebuilds because superseded_by + reviewed_at are preserved in metadata.
792
+ const claimIdSalt = supersededBy ? `${fieldOrBehavior}::superseded::${supersededBy}::${critiqueReviewedAt}` : fieldOrBehavior;
793
+ const claimId = generateClaimId(subjectId, "flow-agents.workflow", claimIdSalt);
698
794
  const legacyClaimType = "workflow.critique.review";
699
795
  const policy = ensurePolicy(legacyClaimType, "medium", []);
700
- const evStatus = critiqueToEventStatus(String(c.verdict ?? ""), c.findings ?? []);
796
+ // A superseded write emits NO verification event (its status is "superseded" directly).
797
+ const evStatus = supersededBy ? null : critiqueToEventStatus(String(c.verdict ?? ""), c.findings ?? []);
701
798
  const claimEvents: AnyObj[] = [];
702
799
  if (evStatus) {
703
800
  const evt: AnyObj = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [], createdAt: ts, verifiedAt: ts };
@@ -707,16 +804,15 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
707
804
 
708
805
  // P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
709
806
  const declared = matchExpectsEntry("critique");
710
- if (declared) {
711
- // Declared kit-typed claim only no legacy shadow (ADR 0016 P-d).
712
- const declaredPolicy = ensurePolicy(declared.claimType, "medium", []);
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 };
714
- const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [declaredPolicy] as Record<string, unknown>[] });
715
- claims.push({ ...declaredClaimObj, status: declaredStatus });
807
+ const claimType = declared ? declared.claimType : legacyClaimType;
808
+ const subjectType = declared ? declared.subjectType : "workflow-critique";
809
+ const claimPolicy = declared ? ensurePolicy(declared.claimType, "medium", []) : policy;
810
+ const claimObj: AnyObj = { id: claimId, subjectType, subjectId, facet: "flow-agents.workflow", claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: claimPolicy.id, metadata: critMeta };
811
+ if (supersededBy) {
812
+ // History: status is "superseded" directly (no verification event); excluded from evaluation.
813
+ claims.push({ ...claimObj, status: "superseded" });
716
814
  } else {
717
- // No active flow step only the workflow.* primary claim (legitimate no-flow fallback path).
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 };
719
- const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [policy] as Record<string, unknown>[] });
815
+ const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj as Record<string, unknown>, evidence: [], events: claimEvents as Record<string, unknown>[], policies: [claimPolicy] as Record<string, unknown>[] });
720
816
  claims.push({ ...claimObj, status: derivedStatus });
721
817
  }
722
818
  }
@@ -744,7 +840,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
744
840
  * @param criteria Acceptance criteria objects (same as buildTrustBundle)
745
841
  * @param critiques Critique objects (same as buildTrustBundle)
746
842
  */
747
- export async function writeTrustBundle(dir: string, slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[]): Promise<{ written: boolean; errors: string[] }> {
843
+ export async function writeTrustBundle(dir: string, slug: string, timestamp: string, checks: AnyObj[], criteria: AnyObj[], critiques: AnyObj[], actorKey?: string): Promise<{ written: boolean; errors: string[] }> {
748
844
  try {
749
845
  // Fold the deterministic capture log (PostToolUse evidence-capture) into the
750
846
  // bundle so capture is authoritative over claimed status. Best-effort read.
@@ -756,16 +852,22 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
756
852
  // ADR 0016 Abstraction A (P-d): pass the runtime artifact root ONLY when current.json
757
853
  // points to this session (scoped active-flow guard). If current.json.artifact_dir
758
854
  // resolves to a different session, pass null — no active-flow claim mapping for this bundle.
855
+ // #291 Wave 2 Task 2.1 (§7): the SOURCE of "what does current.json say" now prefers the
856
+ // resolved actor's own per-actor projection (falling back to the legacy global file) via the
857
+ // shared readCurrentPointer() helper — the "does artifact_dir match dir?" comparison itself is
858
+ // UNCHANGED. actorKey defaults to resolveActor(process.env) when the caller (below) does not
859
+ // already have one resolved, exactly like every other read-path consumer in this file.
759
860
  const _flowAgentsDir = path.dirname(dir);
861
+ const _effectiveActorKey = actorKey ?? loadActorIdentityHelper().resolveActor(process.env).actor;
760
862
  let _scopedFlowAgentsDir: string | undefined = undefined;
761
863
  try {
762
- const _currentRaw = JSON.parse(fs.readFileSync(path.join(_flowAgentsDir, "current.json"), "utf8")) as Record<string, unknown>;
763
- const _artDir = typeof _currentRaw["artifact_dir"] === "string" ? _currentRaw["artifact_dir"] : null;
864
+ const _currentRaw = loadCurrentPointerHelper().readCurrentPointer(_flowAgentsDir, _effectiveActorKey).payload;
865
+ const _artDir = _currentRaw && typeof _currentRaw["artifact_dir"] === "string" ? (_currentRaw["artifact_dir"] as string) : null;
764
866
  if (_artDir && path.resolve(_flowAgentsDir, _artDir) === path.resolve(dir)) {
765
867
  _scopedFlowAgentsDir = _flowAgentsDir;
766
868
  }
767
869
  } catch { /* current.json absent or unreadable — no scoping */ }
768
- const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir);
870
+ const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir, _effectiveActorKey);
769
871
  if (!bundle) return { written: false, errors: [] }; // Surface unavailable — fail-open, skip write
770
872
  const result = await validateTrustBundle(bundle);
771
873
  if (result.available && !result.valid) {
@@ -1079,12 +1181,43 @@ function resolveFirstStep(flowId: string, repoRoot: string): string | null {
1079
1181
  }
1080
1182
  }
1081
1183
 
1082
- function writeCurrent(root: string, dir: string, timestamp: string, owner: string, source: string, flowId?: string, stepId?: string, adHocReason?: string): void {
1184
+ /**
1185
+ * Delegate to the shared pure-CJS per-actor current-pointer helper
1186
+ * (scripts/hooks/lib/current-pointer.js), mirroring the createRequire pattern already used above
1187
+ * by loadActorIdentityHelper()/loadLivenessWriteHelper() for cross-boundary CJS helper reuse
1188
+ * (#291 Wave 2 Task 2.1). This is the ONE place every current.json reader/writer in this file
1189
+ * goes through from here on — the per-actor-first/legacy-fallback compat-shim rule (and its
1190
+ * write-side counterpart) can never drift between call sites.
1191
+ */
1192
+ function loadCurrentPointerHelper(): {
1193
+ perActorCurrentFile: (flowAgentsDir: string, actorKey: string) => string;
1194
+ readCurrentPointer: (flowAgentsDir: string, actorKey?: string) => { payload: AnyObj | null; source: "per-actor" | "legacy" | "none"; file: string | null };
1195
+ writePerActorCurrent: (flowAgentsDir: string, actorKey: string, payload: AnyObj) => void;
1196
+ } {
1197
+ const _req = createRequire(import.meta.url);
1198
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/current-pointer.js");
1199
+ return _req(helperPath) as {
1200
+ perActorCurrentFile: (flowAgentsDir: string, actorKey: string) => string;
1201
+ readCurrentPointer: (flowAgentsDir: string, actorKey?: string) => { payload: AnyObj | null; source: "per-actor" | "legacy" | "none"; file: string | null };
1202
+ writePerActorCurrent: (flowAgentsDir: string, actorKey: string, payload: AnyObj) => void;
1203
+ };
1204
+ }
1205
+
1206
+ /**
1207
+ * #291 Wave 2 Task 2.1 (§5): writes the UNCHANGED legacy global `<root>/current.json` (the
1208
+ * compat-shim's write-side half — every existing consumer without an actorKey keeps reading
1209
+ * exactly this file, exactly as before this change), then ADDITIONALLY projects the SAME payload
1210
+ * into the caller's own `current/<actor>.json` when `actorKey` resolves to a real (not
1211
+ * unresolved) actor. When `actorKey` is omitted or unresolved, only the legacy file is written —
1212
+ * byte-identical to this function's pre-#291 behavior — with a stderr note mirroring the existing
1213
+ * unresolved-actor branch-naming diagnostic.
1214
+ */
1215
+ function writeCurrent(root: string, dir: string, timestamp: string, owner: string, source: string, flowId?: string, stepId?: string, adHocReason?: string, actorKey?: string): void {
1083
1216
  // #289: mirror the active session's already-recorded branch (state.json.branch) into
1084
1217
  // current.json so consumers of current.json (which has no schema of its own — not one of the
1085
1218
  // 9 schemas under schemas/) see the routing branch without re-reading state.json separately.
1086
1219
  const branch = loadJson(path.join(dir, "state.json")).branch;
1087
- writeJson(path.join(root, "current.json"), {
1220
+ const payload: AnyObj = {
1088
1221
  schema_version: "1.0",
1089
1222
  active_slug: path.basename(dir),
1090
1223
  artifact_dir: path.relative(root, dir) || ".",
@@ -1103,15 +1236,35 @@ function writeCurrent(root: string, dir: string, timestamp: string, owner: strin
1103
1236
  // gate-review can distinguish an intentional direct entry (e.g. a planning-only
1104
1237
  // session that skips pull-work) from a stale/mis-stamped active_step_id.
1105
1238
  ...(adHocReason ? { ad_hoc_entry: true, ad_hoc_reason: adHocReason } : {}),
1106
- });
1239
+ };
1240
+ writeJson(path.join(root, "current.json"), payload);
1241
+ if (actorKey && !loadActorIdentityHelper().isUnresolvedActor(actorKey)) {
1242
+ try {
1243
+ loadCurrentPointerHelper().writePerActorCurrent(root, actorKey, payload);
1244
+ } catch (err) {
1245
+ // Best-effort projection only — the legacy file above is already durable and authoritative;
1246
+ // a failure here must never affect ensure-session's own exit code (fail-open, visible).
1247
+ process.stderr.write(`[ensure-session] failed to write per-actor current pointer: ${err instanceof Error ? err.message : String(err)}\n`);
1248
+ }
1249
+ } else {
1250
+ process.stderr.write("[ensure-session] actor unresolved or not provided; per-actor current.json projection skipped (legacy current.json remains authoritative)\n");
1251
+ }
1107
1252
  }
1108
1253
  function loadCurrent(root: string): AnyObj | null {
1109
1254
  const file = path.join(root, "current.json");
1110
1255
  if (!fs.existsSync(file)) return null;
1111
1256
  return JSON.parse(read(file));
1112
1257
  }
1113
- function currentDir(root: string): string | null {
1114
- const c = loadCurrent(root);
1258
+ /**
1259
+ * #291 Wave 2 Task 2.1 (§6): resolves the active session directory via the shared
1260
+ * per-actor-first/legacy-fallback compat shim (readCurrentPointer) instead of the unconditional
1261
+ * legacy-only loadCurrent() this function used before #291. When `actorKey` is omitted, empty, or
1262
+ * unresolved, `readCurrentPointer` falls straight through to the legacy file — IDENTICAL to this
1263
+ * function's pre-#291 behavior.
1264
+ */
1265
+ function currentDir(root: string, actorKey?: string): string | null {
1266
+ const pointer = loadCurrentPointerHelper().readCurrentPointer(root, actorKey);
1267
+ const c = pointer.payload;
1115
1268
  if (!c) return null;
1116
1269
  const dir = path.resolve(root, c.artifact_dir ?? c.active_slug ?? "");
1117
1270
  if (!fs.existsSync(dir)) return null;
@@ -1122,14 +1275,34 @@ function currentDir(root: string): string | null {
1122
1275
  }
1123
1276
  return dir;
1124
1277
  }
1125
- function updateCurrentAgent(root: string, dir: string, agentId: string, status: string, timestamp: string): void {
1278
+ /**
1279
+ * #291 Wave 2 Task 2.1 (§6): updates BOTH the legacy current.json (when IT points at `dir` — the
1280
+ * exact, unchanged existing check/write) AND the resolved actor's own per-actor current.json
1281
+ * (independently, when IT points at `dir`) — never silently drops the legacy-file update path.
1282
+ * The two checks are independent: either, both, or neither may fire depending on which
1283
+ * pointer(s) currently reference `dir`.
1284
+ */
1285
+ function updateCurrentAgent(root: string, dir: string, agentId: string, status: string, timestamp: string, actorKey?: string): void {
1286
+ const applyAgentUpdate = (payload: AnyObj): AnyObj => {
1287
+ const active = Array.isArray(payload.active_agents) ? payload.active_agents.filter((a: AnyObj) => a.agent_id !== agentId) : [];
1288
+ if (status === "active" || status === "blocked") active.push({ agent_id: agentId, status, updated_at: timestamp });
1289
+ return { ...payload, active_agents: active, updated_at: timestamp };
1290
+ };
1291
+
1126
1292
  const cur = loadCurrent(root);
1127
- if (!cur || path.resolve(root, cur.artifact_dir ?? "") !== path.resolve(dir)) return;
1128
- const active = Array.isArray(cur.active_agents) ? cur.active_agents.filter((a: AnyObj) => a.agent_id !== agentId) : [];
1129
- if (status === "active" || status === "blocked") active.push({ agent_id: agentId, status, updated_at: timestamp });
1130
- cur.active_agents = active;
1131
- cur.updated_at = timestamp;
1132
- writeJson(path.join(root, "current.json"), cur);
1293
+ if (cur && path.resolve(root, cur.artifact_dir ?? "") === path.resolve(dir)) {
1294
+ writeJson(path.join(root, "current.json"), applyAgentUpdate(cur));
1295
+ }
1296
+
1297
+ if (actorKey && !loadActorIdentityHelper().isUnresolvedActor(actorKey)) {
1298
+ const helper = loadCurrentPointerHelper();
1299
+ const perActorFile = helper.perActorCurrentFile(root, actorKey);
1300
+ let perActor: AnyObj | null = null;
1301
+ try { perActor = fs.existsSync(perActorFile) ? (JSON.parse(fs.readFileSync(perActorFile, "utf8")) as AnyObj) : null; } catch { perActor = null; }
1302
+ if (perActor && path.resolve(root, perActor.artifact_dir ?? "") === path.resolve(dir)) {
1303
+ helper.writePerActorCurrent(root, actorKey, applyAgentUpdate(perActor));
1304
+ }
1305
+ }
1133
1306
  }
1134
1307
 
1135
1308
  function initSidecars(dir: string, slug: string, sourceRequest: string, summary: string, nextAction: string, timestamp: string, markdown?: string): void {
@@ -1179,10 +1352,201 @@ function initSidecars(dir: string, slug: string, sourceRequest: string, summary:
1179
1352
  });
1180
1353
  }
1181
1354
 
1355
+ /** Read a `--*-json` flag's value as a file path (or `-` for stdin), mirroring
1356
+ * assignment-provider.ts's own `loadJsonInput` convention — this file's OTHER `--*-json` flags
1357
+ * (e.g. `--check-json`) instead take a literal inline JSON string via parseJson(), a DIFFERENT
1358
+ * convention; `--effective-state-json` deliberately follows assignment-provider's file/stdin
1359
+ * convention instead, since its value is the literal JSON `assignment-provider status
1360
+ * --provider github ...` already prints to a file (or pipe), not something a caller would want to
1361
+ * inline as a shell argument. */
1362
+ function loadJsonInputFile(file: string): unknown {
1363
+ return file === "-" ? JSON.parse(fs.readFileSync(0, "utf8")) : JSON.parse(read(file));
1364
+ }
1365
+
1366
+ /**
1367
+ * #291 Wave 2 Task 2.1 (§4): ensure-session's pre-entry ownership guard. MUST be called before
1368
+ * any directory or file is created (a refusal must never leave a stray empty session dir) —
1369
+ * ensureSession() calls this immediately, before `fs.mkdirSync(dir, ...)`. Reuses #290's
1370
+ * assignment ⋈ liveness join (`computeEffectiveState`, Wave 1 export) so a fresh OTHER-actor claim
1371
+ * refuses entry, a human assignment always asks first (never auto-reclaims), a stale
1372
+ * (`reclaimable`) claim requires the explicit `--supersede-stale` takeover flag, and a `free`
1373
+ * subject establishes a durable claim for the entering actor — ensure-session becomes a SECOND
1374
+ * claim point, alongside pull-work (closing the loophole where a session entered without going
1375
+ * through pull-work never gets a durable claim at all).
1376
+ *
1377
+ * Runs INSIDE ensureSession's existing root-level `withLock` (main(), unchanged) — no second/
1378
+ * competing lock is introduced here. `performLocalClaim`/`performLocalSupersede`'s OWN internal
1379
+ * `withSubjectLock` (a DIFFERENT lockdir, under `<root>/assignment/.<subject>.lockdir`) is what
1380
+ * protects against a concurrent bare `assignment-provider claim` CLI invocation (e.g. from
1381
+ * pull-work) racing this guard — two independent, non-conflicting lock resources.
1382
+ *
1383
+ * Every interpolated actor/holder/assignee/reason field in this function's die() messages is run
1384
+ * through stripControlCharsForDisplay + a 64-char cap (AC9 — the #287/#320/#290 prompt-injection
1385
+ * mitigation class: a hostile liveness event or a hand-crafted --effective-state-json fixture must
1386
+ * never be able to smuggle raw control/ANSI bytes into this process's stderr/thrown message).
1387
+ */
1388
+ function enforceEnsureSessionOwnership(
1389
+ p: ReturnType<typeof parseArgs>,
1390
+ root: string,
1391
+ slug: string,
1392
+ dir: string,
1393
+ resolution: { actorStruct: ActorStruct; actorKey: string; branchActorKey: string; unresolved: boolean },
1394
+ ): void {
1395
+ if (p.flags.has("skip-ownership-guard")) {
1396
+ process.stderr.write("[ensure-session] ownership guard skipped via --skip-ownership-guard\n");
1397
+ return;
1398
+ }
1399
+ // Design Decision 4 (unchanged from resolveSessionBranch): actor-resolution ambiguity never
1400
+ // hard-fails session creation. Without a resolvable actor there is no safe identity to claim
1401
+ // under, so the guard is skipped entirely (documented scope boundary, not a silent hole) rather
1402
+ // than claiming under a synthetic/unstable identity.
1403
+ if (resolution.unresolved) {
1404
+ process.stderr.write("[ensure-session] ownership guard not evaluated: actor is unresolved (set --actor or FLOW_AGENTS_ACTOR, or run inside a supported runtime) — proceeding without a durable claim, exactly as ensure-session behaved before #291\n");
1405
+ return;
1406
+ }
1407
+
1408
+ // F5 fix (fix-plan iteration 1, LOW): match assignment-provider.ts's sanitizeDisplayField
1409
+ // two-tier convention (64 for id-like fields, 240 for free text) rather than asserting one
1410
+ // uniform cap applies to every field class regardless of content. `sanitize` below is 64 chars —
1411
+ // deliberately, not by uniform-cap default — because every value this function actually
1412
+ // interpolates into a die()/stderr message is id-like (holder/actor key, subject slug, assignee,
1413
+ // a claimed_at/last_at timestamp, the provider-kind enum); none of them is free text, so the
1414
+ // id-like tier is the correct (not merely convenient) cap here. sanitizeDisplayField's 240
1415
+ // free-text tier is for fields like a claim record's audit-trail `reason` (see
1416
+ // assignment-provider.ts's sanitizeAuditEntryForDisplay) — this guard never echoes `--reason`
1417
+ // into a die() message, so it has no free-text field requiring the 240 tier today.
1418
+ const sanitize = (value: unknown): string => stripControlCharsForDisplay(value).slice(0, 64);
1419
+ const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : Date.now();
1420
+ const assignmentProviderKind = opt(p, "assignment-provider", "local-file");
1421
+
1422
+ type EffectiveResult = { effective_state: EffectiveState; reason: string; holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string } };
1423
+ let effective: EffectiveResult;
1424
+
1425
+ const effectiveStateJsonFlag = opt(p, "effective-state-json");
1426
+ if (effectiveStateJsonFlag) {
1427
+ const parsed = loadJsonInputFile(effectiveStateJsonFlag) as AnyObj;
1428
+ const candidate = parsed && typeof parsed === "object" ? (parsed.effective as AnyObj | undefined) : undefined;
1429
+ const validStates = new Set(["held", "reclaimable", "human-held", "free"]);
1430
+ if (!candidate || typeof candidate.effective_state !== "string" || !validStates.has(candidate.effective_state)) {
1431
+ die(`ensure-session --effective-state-json must contain an .effective object with a recognized effective_state (held|reclaimable|human-held|free); got ${JSON.stringify(candidate ? candidate.effective_state : candidate)}`);
1432
+ }
1433
+ effective = candidate as EffectiveResult;
1434
+ } else if (assignmentProviderKind === "local-file") {
1435
+ // Conflict #3 (plan): subjectId for BOTH the assignment lookup and the liveness freshHolders
1436
+ // slug filter is `slug` — the ALREADY-COMPUTED session slug (workItemSlug()'s output), the
1437
+ // exact same identifier pull-work itself uses for both halves of this same join. Never a
1438
+ // second, independently-derived identifier.
1439
+ // F1 fix (fix-plan iteration 1, HIGH): the guard's own self-check and the liveness join must
1440
+ // key on `resolution.branchActorKey` — the canonical `resolveActor(env).actor` string — NOT
1441
+ // `resolution.actorKey` (`serializeActor(actorStruct)`). For an explicit-override actor
1442
+ // (FLOW_AGENTS_ACTOR) those two diverge: `actorKey` serializes to a triple
1443
+ // (`explicit-override:<value>:<host>`) while `branchActorKey` is the bare value every other
1444
+ // tool (`liveness whoami`, `liveness claim --actor`, per-actor current.json, pull-work's
1445
+ // --self-actor) already uses. freshHolders' `selfActor` param and computeEffectiveState's
1446
+ // `selfActor` param must both be the same canonical string the claim record's `actor_key` is
1447
+ // written as (see the performLocalClaim/performLocalSupersede calls below) — otherwise a
1448
+ // fresh heartbeat for this exact actor would never match the join, and
1449
+ // `assignment-provider status --self-actor <branchActorKey>` run by a DIFFERENT tool
1450
+ // afterward would never recognize this guard's own claim as self.
1451
+ const assignment = readLocalAssignmentStatus(root, slug);
1452
+ const events = readLivenessEvents(root);
1453
+ const freshList = loadLivenessReadHelper().freshHolders(events, slug, resolution.branchActorKey, nowMs);
1454
+ effective = computeEffectiveState(assignment, freshList, resolution.branchActorKey, nowMs);
1455
+ } else {
1456
+ // Conflict #5 (plan): GitHub-provider (and any other non-local-file) subjects get no LIVE
1457
+ // guard check here — assignment-provider.ts is deliberately render-don't-execute (no `gh`
1458
+ // calls from any CLI file). A precomputed --effective-state-json is the escape hatch; when
1459
+ // neither applies, this is a documented scope boundary (today's pre-#291 baseline behavior),
1460
+ // never a silent hole.
1461
+ process.stderr.write(`[ensure-session] ownership guard not evaluated: provider "${sanitize(assignmentProviderKind)}" requires --effective-state-json\n`);
1462
+ return;
1463
+ }
1464
+
1465
+ const resolveBranchForClaim = (): string => {
1466
+ const existingBranch = fs.existsSync(path.join(dir, "state.json")) ? (loadJson(path.join(dir, "state.json")).branch as string | undefined) : undefined;
1467
+ return existingBranch || resolveSessionBranch(p, slug);
1468
+ };
1469
+
1470
+ switch (effective.effective_state) {
1471
+ case "held": {
1472
+ // F1 fix (fix-plan iteration 1, HIGH): compare against branchActorKey — the same canonical
1473
+ // string just passed as computeEffectiveState's selfActor above — not actorKey (the wrapped
1474
+ // ActorStruct triple), so this redundant belt-and-suspenders check agrees with the
1475
+ // effective_state computation instead of silently using a different identity.
1476
+ const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === resolution.branchActorKey);
1477
+ if (isSelf) return; // resume own session — no refusal
1478
+ const holderActor = sanitize(effective.holder?.actor ?? "unknown");
1479
+ const lastAtSuffix = effective.holder?.last_at ? ` (last_at ${sanitize(effective.holder.last_at)})` : "";
1480
+ die(`ensure-session refused: subject ${sanitize(slug)} is currently held by a different, still-live actor (${holderActor}${lastAtSuffix}). Pick a different work item, or confirm the holder session is truly gone before considering a takeover.`);
1481
+ return;
1482
+ }
1483
+ case "human-held": {
1484
+ const assignee = effective.holder?.assignee ? sanitize(effective.holder.assignee) : "an assigned human";
1485
+ const idleSuffix = effective.holder?.idle_days != null ? ` (idle ${Number(effective.holder.idle_days)} day(s))` : "";
1486
+ die(`ensure-session refused: subject ${sanitize(slug)} is assigned to ${assignee}${idleSuffix}. This guard never auto-reclaims a human assignment — confirm with the user before proceeding.`);
1487
+ return;
1488
+ }
1489
+ case "reclaimable": {
1490
+ const holderActor = sanitize(effective.holder?.actor ?? "unknown");
1491
+ const claimedAtSuffix = effective.holder?.last_at ? ` (claimed_at ${sanitize(effective.holder.last_at)})` : "";
1492
+ if (!p.flags.has("supersede-stale")) {
1493
+ die(`ensure-session refused: subject ${sanitize(slug)}'s existing claim (held by ${holderActor}${claimedAtSuffix}) is stale. Pass --supersede-stale to take it over explicitly.`);
1494
+ }
1495
+ const assignment = readLocalAssignmentStatus(root, slug);
1496
+ const fromActor = assignment.record?.actor;
1497
+ if (!fromActor) die(`ensure-session --supersede-stale: no existing local-file claim record found for subject ${sanitize(slug)} to supersede`);
1498
+ performLocalSupersede(root, slug, fromActor, resolution.actorStruct, {
1499
+ branch: resolveBranchForClaim(),
1500
+ artifactDir: path.relative(root, dir) || ".",
1501
+ reason: opt(p, "reason", "ensure-session takeover: stale claim"),
1502
+ // F1 fix (fix-plan iteration 1, HIGH): persist the canonical actor_key on the record so
1503
+ // computeEffectiveState's holderActorKey (assignment-provider.ts) matches this same
1504
+ // branchActorKey string on the next status/guard check, cross-tool.
1505
+ actorKey: resolution.branchActorKey,
1506
+ });
1507
+ return;
1508
+ }
1509
+ case "free": {
1510
+ performLocalClaim(root, slug, resolution.actorStruct, {
1511
+ ttlSeconds: opt(p, "claim-ttl-seconds") ? Number(opt(p, "claim-ttl-seconds")) : loadLivenessPolicyHelper().resolveTtlSeconds(process.env),
1512
+ branch: resolveBranchForClaim(),
1513
+ artifactDir: path.relative(root, dir) || ".",
1514
+ reason: opt(p, "reason", "ensure-session entry"),
1515
+ // F1 fix (fix-plan iteration 1, HIGH): see the performLocalSupersede call above.
1516
+ actorKey: resolution.branchActorKey,
1517
+ });
1518
+ return;
1519
+ }
1520
+ default:
1521
+ die(`ensure-session ownership guard: unrecognized effective_state ${JSON.stringify(effective.effective_state)}`);
1522
+ }
1523
+ }
1524
+
1525
+ /**
1526
+ * ensure-session flags added by #291 Wave 2 Task 2.1 (no printed --help/usage text exists in this
1527
+ * file to update — this doc comment is the discoverable reference instead):
1528
+ * --skip-ownership-guard Skip the pre-entry ownership guard entirely (logged, not silent).
1529
+ * --effective-state-json <path|-> Precomputed `{ effective: {...} }` JSON (the exact shape
1530
+ * `assignment-provider status` prints) — required to evaluate the
1531
+ * guard for any --assignment-provider other than local-file.
1532
+ * --assignment-provider <kind> Defaults to "local-file"; see Conflict #5 in the plan for the
1533
+ * GitHub-provider scope boundary.
1534
+ * --now <iso> Deterministic "now" for freshness/idle-day computation (else
1535
+ * Date.now()).
1536
+ * --supersede-stale Required to take over a `reclaimable` (stale) claim.
1537
+ * --claim-ttl-seconds <n> Overrides the liveness-policy TTL default for a new claim.
1538
+ * --reason <text> Audit-trail reason recorded on the claim/supersede record.
1539
+ */
1182
1540
  function ensureSession(p: ReturnType<typeof parseArgs>): number {
1183
1541
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
1184
1542
  const slug = opt(p, "task-slug") || (opt(p, "work-item") ? workItemSlug(opt(p, "work-item")) : die("--task-slug is required (or pass --work-item to derive it)"));
1185
1543
  const dir = sessionDirFor(root, slug);
1544
+ // #291 Wave 2 Task 2.1 (§3, §4): resolve the actor ONCE, then run the ownership guard BEFORE
1545
+ // any directory/file is created — a refusal must never leave a stray empty session dir. Reused
1546
+ // below (writeCurrent's per-actor dual-write) so the branch-naming actor and the
1547
+ // assignment-claim actor are always the same identity.
1548
+ const actorResolution = resolveEnsureSessionActor(p);
1549
+ enforceEnsureSessionOwnership(p, root, slug, dir, actorResolution);
1186
1550
  fs.mkdirSync(dir, { recursive: true });
1187
1551
  const timestamp = opt(p, "timestamp", now());
1188
1552
  let md = fs.existsSync(path.join(dir, `${slug}--deliver.md`)) ? read(path.join(dir, `${slug}--deliver.md`)) : "";
@@ -1225,14 +1589,19 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
1225
1589
  adHocReason = opt(p, "ad-hoc-reason") || `direct entry at step "${explicitStep}" via --step-id (flow first step is "${firstStep}")`;
1226
1590
  }
1227
1591
  }
1228
- writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined, adHocReason);
1592
+ writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined, adHocReason, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
1229
1593
  console.log(dir);
1230
1594
  return 0;
1231
1595
  }
1232
1596
 
1233
1597
  function current(p: ReturnType<typeof parseArgs>): number {
1234
1598
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : defaultArtifactRootForRead();
1235
- const dir = currentDir(root);
1599
+ // #291 Wave 2 Task 2.1 (§6): new optional --actor override (same convention as
1600
+ // resolveSessionBranch's existing --actor), falling back to resolveActor(process.env). Only the
1601
+ // SOURCE of "what does current.json say" changes (per-actor-first, legacy-fallback) — this
1602
+ // command's existing --format slug|path output is unchanged.
1603
+ const actorKey = resolveReadActorKey(p);
1604
+ const dir = currentDir(root, actorKey);
1236
1605
  if (!dir) die("no current workflow session is recorded");
1237
1606
  const format = opt(p, "format", "path");
1238
1607
  console.log(format === "slug" ? path.basename(dir) : dir);
@@ -1243,7 +1612,8 @@ function recordAgentEvent(p: ReturnType<typeof parseArgs>): number {
1243
1612
  const hasExplicitRoot = !!opt(p, "artifact-root");
1244
1613
  const root = explicitArtifactRoot(p);
1245
1614
  const explicit = opt(p, "artifact-dir");
1246
- const dir = explicit ? path.resolve(explicit) : currentDir(root);
1615
+ const actorKey = resolveReadActorKey(p);
1616
+ const dir = explicit ? path.resolve(explicit) : currentDir(root, actorKey);
1247
1617
  if (!dir || !fs.existsSync(dir)) die("artifact directory does not exist");
1248
1618
  if (explicit && fs.lstatSync(dir).isSymbolicLink()) die(`artifact directory must not be a symlink: ${dir}`);
1249
1619
  if (hasExplicitRoot || !explicit) requireArtifactDirUnderRoot(dir, root);
@@ -1251,7 +1621,7 @@ function recordAgentEvent(p: ReturnType<typeof parseArgs>): number {
1251
1621
  const agent = validateAgentId(opt(p, "agent-id"));
1252
1622
  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") } : {}) };
1253
1623
  appendJsonl(path.join(dir, "agents", agent, "events.jsonl"), event);
1254
- updateCurrentAgent(root, dir, agent, event.status, timestamp);
1624
+ updateCurrentAgent(root, dir, agent, event.status, timestamp, actorKey);
1255
1625
  return 0;
1256
1626
  }
1257
1627
 
@@ -1439,96 +1809,99 @@ export function writeState(dir: string, slug: string, status: string, phase: str
1439
1809
  // Extract checks and critiques from the existing trust.bundle for callers that
1440
1810
  // need to rebuild the bundle (e.g. record-critique, record-learning).
1441
1811
 
1442
- // ADR 0016 Abstraction A (Step 0 Q3 carry-forward): build the set of declared
1443
- // claimTypes from the active flow step for the session at `dir`. When no active
1444
- // flow is present (workflow.* sessions), returns an empty set so every existing
1445
- // predicate is unchanged. When a FlowDefinition-driven session (builder.build)
1446
- // is active, the set contains the kit-typed claimTypes (e.g. "builder.verify.tests",
1447
- // "builder.verify.policy-compliance") so round-trip helpers broaden their filters
1448
- // to include declared claims alongside the legacy workflow.* ones.
1812
+ // #268/#344: buildTrustBundle stamps a stable origin discriminator ("check" | "acceptance" |
1813
+ // "critique") plus check_kind (for origin "check") on EVERY claim it writes. These stamps are
1814
+ // AUTHORITATIVE and the ONLY way checksFromBundle/critiquesFromBundle (and evidenceClean/
1815
+ // critiqueClean below) classify a claim.
1449
1816
  //
1450
- // Safety guard: current.json in the runtime artifact root records the CURRENTLY ACTIVE
1451
- // session via artifact_dir. If current.json points to a different session than `dir`
1452
- // (e.g. another session was the last to call advance-state --flow-definition), we
1453
- // return an empty set so declared-type predicates are NOT applied to the wrong session.
1454
- // This prevents a cross-session active_flow_id from broadening claim filters for
1455
- // unrelated sessions (which would cause spurious evidence/critique check behavior).
1456
- function declaredClaimTypesFor(dir: string): Set<string> {
1457
- const flowAgentsDir = path.dirname(dir);
1458
- // Verify that current.json points to `dir` before reading active flow step.
1459
- // If it points to a different session, return empty set (zero behavior change).
1460
- const currentFile = path.join(flowAgentsDir, "current.json");
1461
- try {
1462
- const current = JSON.parse(fs.readFileSync(currentFile, "utf8")) as Record<string, unknown>;
1463
- const artDir = typeof current["artifact_dir"] === "string" ? current["artifact_dir"] : null;
1464
- if (!artDir) return new Set<string>();
1465
- const resolvedCurrent = path.resolve(flowAgentsDir, artDir);
1466
- if (path.resolve(dir) !== resolvedCurrent) return new Set<string>();
1467
- } catch {
1468
- return new Set<string>();
1817
+ // Hard cutover (owner directive, no legacy fallbacks): there is deliberately no claimType-
1818
+ // derivation fallback for unstamped claims. A prior version of this file fell back to
1819
+ // classifying an unstamped claim by claimType heuristic that fallback WAS the #268 defect
1820
+ // kept reachable (a no-evidence declared claim silently re-absorbed as a command-less
1821
+ // test_output check, and a critique claim silently re-absorbed as a check, corrupting the
1822
+ // round-trip catastrophically under --flow-id). An unstamped claim means the bundle predates
1823
+ // #344 and must be regenerated, not silently reclassified — see requireStampedClaim below.
1824
+ function claimOrigin(claim: AnyObj): string | null {
1825
+ const md = claim && (claim as AnyObj).metadata;
1826
+ return md && typeof md === "object" && typeof (md as AnyObj).origin === "string" && (md as AnyObj).origin.length > 0 ? String((md as AnyObj).origin) : null;
1827
+ }
1828
+ // Fails loud — never silent, never a heuristic reclassification — when a claim in `dir`'s
1829
+ // trust.bundle lacks its metadata.origin stamp, or (for an origin==="check" claim) its
1830
+ // metadata.check_kind stamp. Names the session dir and the remedy so the caller can regenerate
1831
+ // a fresh, fully-stamped bundle instead of reading a pre-supersession one.
1832
+ function requireStampedClaim(claim: AnyObj, dir: string): string {
1833
+ if (!claim || typeof claim !== "object") die(`trust.bundle in ${dir} contains a malformed claim entry — cannot read.`);
1834
+ const remedy = `re-record evidence to regenerate: npm run workflow:sidecar -- record-evidence ${dir} --verdict <verdict> --check-json <...>`;
1835
+ const origin = claimOrigin(claim);
1836
+ if (!origin) {
1837
+ die(`pre-supersession trust.bundle in ${dir}: claim '${claim.id ?? "<unknown>"}' has no metadata.origin stamp (this bundle predates #344's origin/check_kind stamping and cannot be read authoritatively) — ${remedy}`);
1838
+ }
1839
+ if (origin === "check") {
1840
+ const md = (claim.metadata && typeof claim.metadata === "object") ? claim.metadata as AnyObj : {};
1841
+ if (typeof md.check_kind !== "string" || md.check_kind.length === 0) {
1842
+ die(`pre-supersession trust.bundle in ${dir}: check claim '${claim.id ?? "<unknown>"}' has metadata.origin but no metadata.check_kind stamp (this bundle predates #344's stamping and cannot be read authoritatively) — ${remedy}`);
1843
+ }
1469
1844
  }
1470
- const activeStep = resolveActiveFlowStep(flowAgentsDir);
1471
- if (!activeStep || activeStep.gateExpects.length === 0) return new Set<string>();
1472
- return new Set<string>(activeStep.gateExpects.map((e) => e.bundle_claim.claimType));
1845
+ return origin;
1473
1846
  }
1474
-
1475
- function checksFromBundle(dir: string, declaredClaimTypes: Set<string> = new Set()): AnyObj[] {
1847
+ function checksFromBundle(dir: string): AnyObj[] {
1476
1848
  const bundle = loadJson(path.join(dir, "trust.bundle"));
1477
- if (!Array.isArray(bundle.evidence)) return [];
1478
1849
  const allClaims: AnyObj[] = Array.isArray(bundle.claims) ? bundle.claims : [];
1850
+ // Validate stamps on every claim up front — any unstamped claim anywhere in the bundle marks
1851
+ // it pre-supersession, regardless of whether it is check/acceptance/critique-typed.
1852
+ for (const claim of allClaims) requireStampedClaim(claim, dir);
1853
+ if (!Array.isArray(bundle.evidence)) return [];
1479
1854
  const claimById = new Map<string, AnyObj>();
1480
1855
  for (const c of allClaims) if (c && c.id) claimById.set(c.id, c);
1481
1856
  const seen = new Set<string>();
1482
1857
  const checks: AnyObj[] = [];
1858
+ const kindOf = (claim: AnyObj): string => String((claim.metadata as AnyObj).check_kind);
1483
1859
  for (const ev of bundle.evidence) {
1484
1860
  if (!ev || !ev.claimId) continue;
1485
1861
  const claim = claimById.get(ev.claimId);
1486
1862
  if (!claim) continue;
1487
- const ct = String(claim.claimType || "");
1488
- // ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
1489
- if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct)) continue;
1863
+ if (claimOrigin(claim) !== "check") continue;
1490
1864
  if (seen.has(ev.claimId)) continue;
1491
1865
  seen.add(ev.claimId);
1492
- const kind = ct.startsWith("workflow.check.") ? (ct.replace("workflow.check.", "") || "external") : (ct.split(".").pop() || "external");
1866
+ const kind = kindOf(claim);
1493
1867
  const status = claim.value ?? "not_verified";
1494
1868
  const check: AnyObj = { id: String(claim.subjectId || "").split("/").pop() || ev.claimId, kind, status, summary: claim.fieldOrBehavior || "" };
1495
1869
  if (ev.execution && typeof ev.execution.label === "string") check.command = ev.execution.label;
1496
1870
  if (ev.evidenceType) check.evidenceType = ev.evidenceType;
1497
1871
  checks.push(check);
1498
1872
  }
1499
- // Also include check claims that have no evidence item (surface_trust_refs style)
1873
+ // Also include check claims that have no evidence item (surface_trust_refs style).
1500
1874
  for (const claim of allClaims) {
1501
1875
  if (!claim) continue;
1502
- const ct = String(claim.claimType || "");
1503
- // ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
1504
- if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct)) continue;
1876
+ if (claimOrigin(claim) !== "check") continue;
1505
1877
  if (seen.has(claim.id)) continue;
1506
1878
  seen.add(claim.id);
1507
- const kind = ct.startsWith("workflow.check.") ? (ct.replace("workflow.check.", "") || "external") : (ct.split(".").pop() || "external");
1879
+ const kind = kindOf(claim);
1508
1880
  checks.push({ id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" });
1509
1881
  }
1510
1882
  return checks;
1511
1883
  }
1512
- function critiquesFromBundle(dir: string, declaredClaimTypes: Set<string> = new Set()): AnyObj[] {
1884
+ function critiquesFromBundle(dir: string): AnyObj[] {
1513
1885
  const bundle = loadJson(path.join(dir, "trust.bundle"));
1514
1886
  if (!Array.isArray(bundle.claims)) return [];
1515
- // ADR 0016 Step 0: broaden to include declared kit-typed critique claims alongside workflow.critique.review.
1516
- // P-d: exclude claims that have evidence items (evidence = check claims, not critique claims).
1517
- // This prevents check-type declared claims (e.g. builder.verify.tests) from being read back
1518
- // as critiques when declaredClaimTypes includes all gate expects[] types.
1519
- const evidenceClaimIds = new Set<string>(
1520
- Array.isArray(bundle.evidence) ? bundle.evidence.map((e: AnyObj) => e?.claimId).filter((id: unknown): id is string => typeof id === "string") : []
1521
- );
1522
- const critiqueClaims = bundle.claims.filter((c: AnyObj) => c && (c.claimType === "workflow.critique.review" || declaredClaimTypes.has(c.claimType)) && !evidenceClaimIds.has(c.id));
1523
- return critiqueClaims.map((c: AnyObj) => ({
1524
- id: String(c.subjectId || "").split("/").pop() || c.id,
1525
- verdict: c.value ?? "not_verified",
1526
- summary: c.fieldOrBehavior || "",
1527
- findings: [],
1528
- reviewer: "tool-code-reviewer",
1529
- reviewed_at: c.updatedAt || c.createdAt || now(),
1530
- artifact_refs: [],
1531
- }));
1887
+ for (const c of bundle.claims) requireStampedClaim(c, dir);
1888
+ // A claim is a CRITIQUE when its origin is "critique" (authoritative see requireStampedClaim
1889
+ // above). reviewer / reviewed_at / superseded_by are read back from metadata so supersession
1890
+ // (#267/#282) round-trips losslessly.
1891
+ const critiqueClaims = bundle.claims.filter((c: AnyObj) => c && claimOrigin(c) === "critique");
1892
+ return critiqueClaims.map((c: AnyObj) => {
1893
+ const md = (c.metadata && typeof c.metadata === "object") ? c.metadata as AnyObj : {};
1894
+ return {
1895
+ id: String(c.subjectId || "").split("/").pop() || c.id,
1896
+ verdict: c.value ?? "not_verified",
1897
+ summary: c.fieldOrBehavior || "",
1898
+ findings: [],
1899
+ reviewer: typeof md.reviewer === "string" ? md.reviewer : "tool-code-reviewer",
1900
+ reviewed_at: typeof md.reviewed_at === "string" ? md.reviewed_at : (c.updatedAt || c.createdAt || now()),
1901
+ artifact_refs: [],
1902
+ ...(typeof md.superseded_by === "string" && md.superseded_by.length > 0 ? { superseded_by: md.superseded_by } : {}),
1903
+ };
1904
+ });
1532
1905
  }
1533
1906
  // ─────────────────────────────────────────────────────────────────────────────
1534
1907
  /**
@@ -1579,7 +1952,11 @@ async function recordEvidence(p: ReturnType<typeof parseArgs>): Promise<number>
1579
1952
  const _existingCriteria: AnyObj[] = Array.isArray(_existingAcceptance.criteria) ? _existingAcceptance.criteria : [];
1580
1953
  const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
1581
1954
  const _criteriaForBundle: AnyObj[] = _existingCriteria.map((c: AnyObj) => ({ ...c, status: _criteriaStatus }));
1582
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, []));
1955
+ // #268: preserve any existing critique claims (including superseded history) instead of dropping
1956
+ // them — record-evidence previously hardcoded critiques:[] here, silently erasing finding history
1957
+ // whenever it ran after a critique existed.
1958
+ const _existingCritiques: AnyObj[] = critiquesFromBundle(dir);
1959
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, _existingCritiques));
1583
1960
  const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
1584
1961
  writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
1585
1962
  return 0;
@@ -1626,9 +2003,14 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
1626
2003
  const summary = opt(p, "summary") || die("--summary is required");
1627
2004
  const expectationId = opt(p, "expectation");
1628
2005
 
1629
- // Resolve the active flow step from current.json
2006
+ // Resolve the active flow step from current.json. #291 Wave 2 Task 2.1 (§7)/Task 2.2: resolve
2007
+ // the CALLING actor's own current-pointer (per-actor-first, legacy-fallback) rather than an
2008
+ // unconditional legacy-only read — this is the fix for record-gate-claim's pre-existing (#291
2009
+ // Stop-short risk) lack of a dir-scoping guard: it now resolves ITS OWN actor's current.json
2010
+ // view, not a different actor's more-recently-written legacy pointer.
1630
2011
  const flowAgentsDir = path.dirname(dir);
1631
- const activeStep = resolveActiveFlowStep(flowAgentsDir);
2012
+ const gateClaimActorKey = resolveReadActorKey(p);
2013
+ const activeStep = resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
1632
2014
  if (!activeStep) die("record-gate-claim requires an active flow step in current.json (set via ensure-session --flow-id or advance-state --flow-definition)");
1633
2015
 
1634
2016
  const expects = activeStep.gateExpects;
@@ -1676,7 +2058,7 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
1676
2058
  if (gateWaiver) checkNormalized._waiver = gateWaiver;
1677
2059
  // Log the targeted gate expectation for transparency (goes to stderr only)
1678
2060
  process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}${gateWaiver ? " (WAIVED accepted_gap)" : ""}\n`);
1679
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], []));
2061
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], [], gateClaimActorKey));
1680
2062
  return 0;
1681
2063
  }
1682
2064
 
@@ -1748,11 +2130,10 @@ async function promote(p: ReturnType<typeof parseArgs>): Promise<number> {
1748
2130
  // Add the promotion claim WITHOUT dropping the session's existing verification
1749
2131
  // evidence/criteria/critiques (mirror record-critique's merge pattern). Drop any prior
1750
2132
  // "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");
2133
+ const existingChecks = checksFromBundle(dir).filter((c) => c.id !== "promotion");
1753
2134
  const _acc = loadJson(path.join(dir, "acceptance.json"));
1754
2135
  const criteria: AnyObj[] = Array.isArray(_acc.criteria) ? _acc.criteria : [];
1755
- const critiques = critiquesFromBundle(dir, _dct);
2136
+ const critiques = critiquesFromBundle(dir);
1756
2137
  assertBundleWritten(await writeTrustBundle(dir, slug, ts, [...existingChecks, promotionCheck], criteria, critiques));
1757
2138
 
1758
2139
  // Auditable record of what was promoted where (companion to the trust.bundle claim).
@@ -1813,7 +2194,11 @@ async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
1813
2194
  const phaseMap = resolvePhaseMap(flow, repoRoot);
1814
2195
  const stepId = phaseMap?.[phase] ?? undefined;
1815
2196
  if (stepId) {
1816
- writeCurrent(root, dir, timestamp, "workflow-sidecar", "advance-state", flow, stepId);
2197
+ // #291 Wave 2 Task 2.1 (§5): thread the calling actor through so this second writeCurrent()
2198
+ // call site ALSO dual-writes the per-actor projection, not only ensure-session's call site —
2199
+ // otherwise a session that only ever calls advance-state (never re-running ensure-session)
2200
+ // would never get a per-actor current.json mirror for its own FlowDefinition routing keys.
2201
+ writeCurrent(root, dir, timestamp, "workflow-sidecar", "advance-state", flow, stepId, undefined, resolveReadActorKey(p));
1817
2202
  }
1818
2203
  }
1819
2204
  livenessLifecycle(dir, slug, LIVENESS_TERMINAL.has(status) ? "release" : "heartbeat", timestamp);
@@ -1845,13 +2230,29 @@ async function recordCritique(p: ReturnType<typeof parseArgs>): Promise<number>
1845
2230
  // Fall back to critique.json for legacy sessions that still have it on disk.
1846
2231
  const existingCritiqueJson = loadJson(path.join(dir, "critique.json"), { critiques: [] });
1847
2232
  const legacyCritiques: AnyObj[] = Array.isArray(existingCritiqueJson.critiques) ? existingCritiqueJson.critiques : [];
1848
- const _dctCritique = declaredClaimTypesFor(dir);
1849
- const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir, _dctCritique) : legacyCritiques;
2233
+ const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir) : legacyCritiques;
1850
2234
  const critique = { id: opt(p, "id") || "review", reviewer: opt(p, "reviewer", "tool-code-reviewer"), reviewed_at: opt(p, "timestamp", now()), verdict: opt(p, "verdict", "pass"), summary: opt(p, "summary"), artifact_refs: opts(p, "artifact-ref"), findings: opts(p, "finding-json").map((v) => normalizeFinding(parseJson(v, "--finding-json"))) };
1851
- const critiques = [...bundleCritiques, critique];
1852
2235
  if (critique.verdict === "pass" && critique.findings.some((f: AnyObj) => f.status === "open")) die("required critique must pass");
2236
+ // #267/#282: supersede-by-critique-id. The latest write for a critique id wins for
2237
+ // reconcile / status / validator purposes; each prior LIVE write for the same id is RETAINED as
2238
+ // history (status "superseded", first-class metadata.superseded_by) but excluded from evaluation.
2239
+ // Supersession is REVIEWER-SCOPED (anti-gaming): a write may only supersede a prior live critique
2240
+ // of the same id written by the SAME reviewer — a different reviewer's disputed finding is never
2241
+ // buried, so it stays live and continues to block. DOCUMENTED GAP: reviewer identity is the
2242
+ // free-form --reviewer string (with a default); there is no cryptographic worker-vs-reviewer
2243
+ // distinction yet — that lands with the runtime actor-identity slice (#287/#290). Same-reviewer-
2244
+ // string scoping is the strongest honest enforcement available today and matches the granularity
2245
+ // the critique record already has.
2246
+ const _supersedeMarker = `${critique.id}@${critique.reviewed_at}`;
2247
+ const _mergedCritiques = bundleCritiques.map((e: AnyObj) => {
2248
+ const eSuperseded = typeof e.superseded_by === "string" && e.superseded_by.length > 0;
2249
+ const eReviewer = String(e.reviewer ?? "tool-code-reviewer");
2250
+ if (e.id === critique.id && !eSuperseded && eReviewer === critique.reviewer) return { ...e, superseded_by: _supersedeMarker };
2251
+ return e;
2252
+ });
2253
+ const critiques = [..._mergedCritiques, critique];
1853
2254
  // Phase 4c: build bundle from raw inputs; read checks from trust.bundle (evidence.json no longer written).
1854
- const _critiqueEvChecks: AnyObj[] = checksFromBundle(dir, _dctCritique);
2255
+ const _critiqueEvChecks: AnyObj[] = checksFromBundle(dir);
1855
2256
  const _critiqueAccCriteria: AnyObj[] = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
1856
2257
  assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueEvChecks, _critiqueAccCriteria, critiques));
1857
2258
  return 0;
@@ -2242,45 +2643,49 @@ async function recordLearning(p: ReturnType<typeof parseArgs>): Promise<number>
2242
2643
  writeJson(path.join(dir, "learning.json"), { ...sidecarBase(slug), status, updated_at: timestamp, records });
2243
2644
  writeState(dir, slug, "accepted", "learning", timestamp, opt(p, "summary"));
2244
2645
  // Phase 4c: build bundle from raw inputs; read checks/critiques from trust.bundle (bespoke sidecars no longer written).
2245
- // ADR 0016 Step 0: pass declaredClaimTypes so declared builder.* claims survive the round-trip.
2246
- const _dctLearning = declaredClaimTypesFor(dir);
2247
- const _learningChecks: AnyObj[] = checksFromBundle(dir, _dctLearning);
2646
+ // #268/#344: declared builder.* claims survive the round-trip via their authoritative origin stamp.
2647
+ const _learningChecks: AnyObj[] = checksFromBundle(dir);
2248
2648
  const _learningCriteria: AnyObj[] = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
2249
- const _learningCritiques: AnyObj[] = critiquesFromBundle(dir, _dctLearning);
2649
+ const _learningCritiques: AnyObj[] = critiquesFromBundle(dir);
2250
2650
  assertBundleWritten(await writeTrustBundle(dir, slug, timestamp, _learningChecks, _learningCriteria, _learningCritiques));
2251
2651
  return 0;
2252
2652
  }
2253
- function evidenceClean(dir: string, declaredClaimTypes: Set<string> = new Set()): boolean {
2254
- // Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for legacy sessions.
2255
- // ADR 0016 Step 0: declaredClaimTypes broadens the filter to include kit-typed check claims
2256
- // (e.g. builder.verify.tests) in addition to workflow.check.* for FlowDefinition-driven sessions.
2653
+ function evidenceClean(dir: string): boolean {
2654
+ // Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for
2655
+ // legacy (pre-bundle-era) sessions that never wrote a trust.bundle at all unrelated to origin
2656
+ // stamping. When a trust.bundle IS present, every claim must be stamped (requireStampedClaim);
2657
+ // there is no claimType-derivation fallback for an unstamped claim (#268/#344).
2257
2658
  const bundle = loadJson(path.join(dir, "trust.bundle"));
2258
2659
  if (Array.isArray(bundle.claims)) {
2259
- const checkClaims = (bundle.claims as AnyObj[]).filter((c: AnyObj) => {
2260
- if (!c) return false;
2261
- const ct = String(c.claimType || "");
2262
- return ct.startsWith("workflow.check.") || declaredClaimTypes.has(ct);
2263
- });
2660
+ for (const c of bundle.claims) requireStampedClaim(c, dir);
2661
+ const checkClaims = (bundle.claims as AnyObj[]).filter((c: AnyObj) => c && claimOrigin(c) === "check");
2264
2662
  if (checkClaims.length === 0) return false;
2265
2663
  return checkClaims.every((c: AnyObj) => {
2266
2664
  const v = String(c.value || "");
2267
2665
  return v === "pass" || v === "skip";
2268
2666
  });
2269
2667
  }
2270
- // Legacy fallback: evidence.json
2668
+ // Legacy fallback: evidence.json (pre-bundle-era sessions with no trust.bundle at all)
2271
2669
  const e = loadJson(path.join(dir, "evidence.json"), {});
2272
2670
  return e.verdict === "pass" && Array.isArray(e.checks) && e.checks.length > 0 && e.checks.every((c: AnyObj) => {
2273
2671
  if (!(c.status === "pass" || c.status === "skip")) return false;
2274
2672
  return !Array.isArray(c.standard_refs) || c.standard_refs.every((r: AnyObj) => ["junit", "sarif", "coverage", "veritas"].includes(r.standard));
2275
2673
  });
2276
2674
  }
2277
- function critiqueClean(dir: string, declaredClaimTypes: Set<string> = new Set()): boolean {
2278
- // Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for legacy sessions.
2279
- // ADR 0016 Step 0: declaredClaimTypes broadens the filter to include kit-typed critique claims
2280
- // (e.g. builder.verify.policy-compliance) in addition to workflow.critique.review.
2675
+ function critiqueClean(dir: string): boolean {
2676
+ // Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for
2677
+ // legacy (pre-bundle-era) sessions unrelated to origin stamping. When a trust.bundle IS
2678
+ // present, every claim must be stamped (requireStampedClaim); no claimType-derivation fallback
2679
+ // for an unstamped claim (#268/#344).
2281
2680
  const bundle = loadJson(path.join(dir, "trust.bundle"));
2282
2681
  if (Array.isArray(bundle.claims)) {
2283
- const critiqueClaims = (bundle.claims as AnyObj[]).filter((c: AnyObj) => c && (c.claimType === "workflow.critique.review" || declaredClaimTypes.has(c.claimType)));
2682
+ for (const c of bundle.claims) requireStampedClaim(c, dir);
2683
+ const critiqueClaims = (bundle.claims as AnyObj[]).filter((c: AnyObj) => {
2684
+ if (!c) return false;
2685
+ // #267/#282: superseded history is not evaluated for cleanliness.
2686
+ if (c.metadata && typeof c.metadata === "object" && (c.metadata as AnyObj).superseded_by) return false;
2687
+ return claimOrigin(c) === "critique";
2688
+ });
2284
2689
  if (critiqueClaims.length === 0) return false; // no critique written yet
2285
2690
  return critiqueClaims.every((c: AnyObj) => {
2286
2691
  const v = String(c.value || "");
@@ -2314,10 +2719,9 @@ async function dogfoodPass(p: ReturnType<typeof parseArgs>): Promise<number> {
2314
2719
  const checks = opts(p, "check-json").map((v) => normalizeCheck(parseJson(v, "--check-json")));
2315
2720
  if (checks.some((c) => c.status !== "pass" && c.status !== "skip")) die("clean evidence requires all non-skipped checks to pass");
2316
2721
  // Phase 4c: evidence check reads from trust.bundle (sole verification artifact); legacy evidence.json fallback in evidenceClean.
2317
- // ADR 0016 Step 0: pass declaredClaimTypes so builder.* check/critique claims count as clean evidence.
2318
- const _dctDogfood = declaredClaimTypesFor(dir);
2319
- const _hasBundleEvidence = fs.existsSync(path.join(dir, "trust.bundle")) && evidenceClean(dir, _dctDogfood);
2320
- const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir, _dctDogfood);
2722
+ // #268/#344: builder.* check/critique claims count as clean evidence via their authoritative origin stamp.
2723
+ const _hasBundleEvidence = fs.existsSync(path.join(dir, "trust.bundle")) && evidenceClean(dir);
2724
+ const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir);
2321
2725
  if (!_hasBundleEvidence && !_hasLegacyEvidence && fs.existsSync(path.join(dir, "trust.bundle"))) die("cannot mark clean without passing evidence");
2322
2726
  if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && fs.existsSync(path.join(dir, "evidence.json"))) die("cannot mark clean without passing evidence");
2323
2727
  if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && !fs.existsSync(path.join(dir, "evidence.json")) && checks.length === 0) die("cannot mark clean without passing evidence");
@@ -2325,9 +2729,9 @@ async function dogfoodPass(p: ReturnType<typeof parseArgs>): Promise<number> {
2325
2729
  const newCritiqueVerdict = opt(p, "critique-verdict", "pass");
2326
2730
  for (const value of opts(p, "finding-json")) normalizeFinding(parseJson(value, "--finding-json"));
2327
2731
  if (newCritiqueVerdict !== "pass") die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
2328
- if (!opt(p, "critique-id") && !critiqueClean(dir, _dctDogfood)) die("requires passing critique");
2732
+ if (!opt(p, "critique-id") && !critiqueClean(dir)) die("requires passing critique");
2329
2733
  // Phase 4c: if existing state has a dirty critique (in bundle or legacy critique.json), block even when adding a new critique-id.
2330
- if (!critiqueClean(dir, _dctDogfood) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json")))) die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
2734
+ if (!critiqueClean(dir) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json")))) die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
2331
2735
  }
2332
2736
  }
2333
2737
  const learningRecords = opts(p, "learning-record-json").map((v) => normalizeLearning(parseJson(v, "--learning-record-json"), opt(p, "timestamp", now())));
@@ -2902,6 +3306,26 @@ function livenessLabel(status: string): string {
2902
3306
  return status;
2903
3307
  }
2904
3308
 
3309
+ /**
3310
+ * Delegate to the shared pure-CJS liveness reader's freshHolders() (scripts/hooks/lib/liveness-read.js),
3311
+ * mirroring the exact loader shape assignment-provider.ts already uses for the same helper (#291
3312
+ * Wave 2 Task 2.1) — so ensure-session's ownership guard computes freshness identically to
3313
+ * `assignment-provider status`'s own join, a single implementation. `readLivenessEvents` above
3314
+ * already loads this same module inline for its own narrower need (just the events reader); this
3315
+ * loader additionally exposes `freshHolders` for the guard's `computeEffectiveState` call.
3316
+ */
3317
+ function loadLivenessReadHelper(): {
3318
+ readLivenessEvents: (streamPath: string) => AnyObj[];
3319
+ freshHolders: (events: AnyObj[], slug: string, selfActor: string, nowMs: number) => FreshHolder[];
3320
+ } {
3321
+ const _req = createRequire(import.meta.url);
3322
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
3323
+ return _req(helperPath) as {
3324
+ readLivenessEvents: (streamPath: string) => AnyObj[];
3325
+ freshHolders: (events: AnyObj[], slug: string, selfActor: string, nowMs: number) => FreshHolder[];
3326
+ };
3327
+ }
3328
+
2905
3329
  // ─── ADR 0012 lifecycle-driven liveness (default-on; opt-out via FLOW_AGENTS_LIVENESS) ──
2906
3330
  // init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
2907
3331
  // so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
@@ -2917,6 +3341,16 @@ function loadActorIdentityHelper(): {
2917
3341
  resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
2918
3342
  sanitizeSegment: (value: unknown) => string;
2919
3343
  isUnresolvedActor: (actor: string) => boolean;
3344
+ // #291 Wave 2 Task 2.1: widened (additive only — every existing caller above keeps using only
3345
+ // the three fields already destructured) so resolveEnsureSessionActor() can reconstruct a real
3346
+ // ActorStruct {runtime, session_id, host} from the SAME exported primitives resolveActor()
3347
+ // already uses internally, and so serializeActor() is available for the ownership guard's
3348
+ // assignment-claim identity (a DIFFERENT identity concept from the flat actorKey used for
3349
+ // branch-naming/liveness — see resolveEnsureSessionActor's doc comment for why both exist).
3350
+ serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
3351
+ detectRuntime: (env: NodeJS.ProcessEnv) => string;
3352
+ runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
3353
+ ancestorActorSeed: () => string;
2920
3354
  } {
2921
3355
  const _req = createRequire(import.meta.url);
2922
3356
  const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
@@ -2924,6 +3358,10 @@ function loadActorIdentityHelper(): {
2924
3358
  resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
2925
3359
  sanitizeSegment: (value: unknown) => string;
2926
3360
  isUnresolvedActor: (actor: string) => boolean;
3361
+ serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
3362
+ detectRuntime: (env: NodeJS.ProcessEnv) => string;
3363
+ runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
3364
+ ancestorActorSeed: () => string;
2927
3365
  };
2928
3366
  }
2929
3367
  function resolveLivenessActor(): string {