@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 } 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 } from "./assignment-provider.js";
11
17
  export const statuses = new Set(["new", "planning", "planned", "in_progress", "blocked", "verifying", "verified", "needs_decision", "not_verified", "failed", "delivered", "accepted", "archived"]);
12
18
  export const phases = ["idea", "backlog", "pickup", "planning", "execution", "verification", "goal_fit", "evidence", "release", "learning", "done"];
13
19
  export const checkKinds = new Set(["build", "types", "lint", "test", "command", "security", "diff", "browser", "runtime", "policy", "external"]);
@@ -177,6 +183,74 @@ function validateExplicitBranch(value) {
177
183
  fail(gitMessage ? `git check-ref-format rejected the value: ${gitMessage}` : "git check-ref-format rejected the value");
178
184
  }
179
185
  }
186
+ /**
187
+ * #291 Wave 2 Task 2.1: the single shared actor resolver for ensure-session — used by BOTH
188
+ * resolveSessionBranch() (below) and the new ownership guard inside ensureSession(), so the
189
+ * branch-naming actor and the assignment-claim actor are ALWAYS derived from the same one
190
+ * resolution pass (never re-derived twice with any risk of divergence). Same explicit-actor
191
+ * validation/die behavior as before this refactor (garbage --actor still dies; ambiguous/
192
+ * unresolved actor never hard-fails session creation — Design Decision 4).
193
+ *
194
+ * Returns TWO distinct identity strings, not one, because this repo already has two genuinely
195
+ * different actor-identity conventions in play and reconciling them into a single string would
196
+ * either break existing branch-naming byte-for-byte (see test_workflow_sidecar_writer.sh AC4) or
197
+ * break self-recognition against a durable assignment-claim record:
198
+ * - `branchActorKey`: the flat, single-token-or-triple string this file has ALWAYS used for
199
+ * branch naming, liveness `--actor`, and (Wave 2 Task 2.1 §6) per-actor current.json
200
+ * partitioning — exactly `resolveActor(env).actor`, or the bare sanitized explicit --actor
201
+ * override, unchanged from before this refactor.
202
+ * - `actorStruct` / `claimActorKey` (`= serializeActor(actorStruct)`): a structured ActorStruct
203
+ * for the assignment-provider claim record's `actor` field (Wave 1's `performLocalClaim`/
204
+ * `performLocalSupersede` require one), and the identity string passed as `computeEffectiveState`'s
205
+ * `selfActor` param. For the common/default case (no explicit --actor override — the ambient
206
+ * runtime-session-id or process-ancestry derivation), this is reconstructed from the SAME
207
+ * exported primitives `resolveActor()` uses internally, so `claimActorKey` reproduces
208
+ * `branchActorKey` bit-for-bit. For an explicit --actor override, no ambient ActorStruct
209
+ * exists (resolveActor()'s override branch is a flat bypass, never a serializeActor() triple),
210
+ * so a synthetic-but-deterministic wrapper is used instead — self-recognition on reentry still
211
+ * holds (same override value always re-derives the same claimActorKey), but this synthetic key
212
+ * will not equal a DIFFERENT process's flat `liveness claim --actor <sameOverrideValue>` actor
213
+ * string (that command's own `--actor` bypass strips the same override value's ':' or wraps it
214
+ * no differently — a pre-existing seam between the liveness-actor and assignment-actor
215
+ * identity domains, not introduced by this task; see Wave 2 Task 2.1 plan Conflict #3).
216
+ */
217
+ function resolveEnsureSessionActor(p) {
218
+ const helper = loadActorIdentityHelper();
219
+ const explicitActorRaw = opt(p, "actor", "").trim();
220
+ if (explicitActorRaw && !/[A-Za-z0-9_.-]/.test(explicitActorRaw)) {
221
+ 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.");
222
+ }
223
+ const explicitActor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : "";
224
+ const resolved = explicitActor ? { actor: explicitActor, source: "explicit-override" } : helper.resolveActor(process.env);
225
+ const unresolved = helper.isUnresolvedActor(resolved.actor);
226
+ const branchActorKey = unresolved ? `unknown-actor-${unknownDisambiguator(resolved.actor)}` : resolved.actor;
227
+ if (unresolved) {
228
+ // Design Decision 4 (unchanged): never hard-fail session creation on actor ambiguity. No real
229
+ // ActorStruct exists for "unresolved" — the guard (ensureSession) is responsible for skipping
230
+ // the ownership guard entirely when unresolved, rather than claiming under a synthetic identity.
231
+ return { actorStruct: { runtime: "unresolved", session_id: branchActorKey, host: os.hostname() }, actorKey: resolved.actor, branchActorKey, unresolved: true };
232
+ }
233
+ const actorStruct = resolved.source === "explicit-override"
234
+ ? { runtime: "explicit-override", session_id: resolved.actor, host: os.hostname() }
235
+ : { runtime: helper.detectRuntime(process.env), session_id: helper.runtimeSessionId(process.env) || (() => { const seed = helper.ancestorActorSeed(); return seed ? `anc-${seed}` : ""; })(), host: os.hostname() };
236
+ const actorKey = helper.serializeActor(actorStruct);
237
+ return { actorStruct, actorKey, branchActorKey, unresolved: false };
238
+ }
239
+ /**
240
+ * Resolve an actor key for a READ-ONLY consumer (current(), currentDir(), recordAgentEvent(),
241
+ * writeTrustBundle()'s default) — deliberately lenient (never
242
+ * dies on a garbage --actor value; that enforcement belongs only to the write/claim path in
243
+ * resolveEnsureSessionActor above). Falls back to resolveActor(process.env) exactly like every
244
+ * write path already does. The returned value is passed straight into
245
+ * scripts/hooks/lib/current-pointer.js's readCurrentPointer(), which already tolerates an empty
246
+ * or unresolved actor by falling straight through to the legacy current.json branch — so an
247
+ * unresolved actor here reproduces exactly today's (pre-#291) legacy-only read behavior.
248
+ */
249
+ function resolveReadActorKey(p) {
250
+ const helper = loadActorIdentityHelper();
251
+ const explicit = opt(p, "actor", "").trim();
252
+ return explicit ? helper.sanitizeSegment(explicit) : helper.resolveActor(process.env).actor;
253
+ }
180
254
  /** Resolve the branch to seed a brand-new session with. Only called from ensureSession's
181
255
  * `if (!md)` fresh-creation branch — an existing session's already-recorded branch is never
182
256
  * recomputed (ADR 0021 §5 takeover continuity; see Design Decision 3). Precedence: explicit
@@ -193,13 +267,12 @@ function resolveSessionBranch(p, slug) {
193
267
  return explicitBranch;
194
268
  }
195
269
  const helper = loadActorIdentityHelper();
196
- const explicitActorRaw = opt(p, "actor", "").trim();
197
- if (explicitActorRaw && !/[A-Za-z0-9_.-]/.test(explicitActorRaw)) {
198
- die("ensure-session --actor value strips to empty under the allowed actor charset ([A-Za-z0-9_.-]) pass a value containing at least one letter, digit, underscore, period, or hyphen.");
199
- }
200
- const actor = explicitActorRaw ? helper.sanitizeSegment(explicitActorRaw) : helper.resolveActor(process.env).actor;
201
- const unresolved = helper.isUnresolvedActor(actor);
202
- const safeActor = unresolved ? `unknown-actor-${unknownDisambiguator(actor)}` : sanitizeBranchSegment(actor, helper);
270
+ // #291 Wave 2 Task 2.1: actor resolution is now single-sourced via resolveEnsureSessionActor()
271
+ // (shared with the new ownership guard) — branchActorKey reproduces EXACTLY what this function's
272
+ // own inline `actor` variable used to compute, so branch naming is byte-identical to before this
273
+ // refactor (proven by test_workflow_sidecar_writer.sh's AC2/AC4/AC5 branch-naming assertions).
274
+ const { branchActorKey, unresolved } = resolveEnsureSessionActor(p);
275
+ const safeActor = unresolved ? branchActorKey : sanitizeBranchSegment(branchActorKey, helper);
203
276
  if (unresolved)
204
277
  process.stderr.write("[ensure-session] actor unresolved; branch uses \"unknown-actor\" segment (set --actor or FLOW_AGENTS_ACTOR for a stable branch name)\n");
205
278
  return `agent/${safeActor}/${sanitizeBranchSegment(slug, helper)}`;
@@ -403,7 +476,7 @@ function critiqueToEventStatus(verdict, findings) {
403
476
  * @param critiques Critique objects (from critique.json .critiques array)
404
477
  * @param commandLog Optional parsed command-log.jsonl entries (capture-authoritative fold)
405
478
  */
406
- export async function buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, flowAgentsDir) {
479
+ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, flowAgentsDir, actorKey) {
407
480
  const surface = await tryLoadSurface();
408
481
  if (!surface)
409
482
  return null;
@@ -413,7 +486,10 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
413
486
  // each produced claim gets a DECLARED primary claim (kit-typed) plus a legacy shadow
414
487
  // (workflow.* type, claimId suffix "-legacy") for backward compatibility. When null,
415
488
  // only the existing workflow.* claims are produced (zero behavior change).
416
- const activeStep = flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir) : null;
489
+ // #291 Wave 2 Task 2.1 (§7)/Task 2.2: actorKey (when the caller already resolved one — see
490
+ // writeTrustBundle below) threads through to resolveActiveFlowStep's per-actor-first,
491
+ // legacy-fallback current.json read; omitted, this is IDENTICAL to pre-#291 behavior.
492
+ const activeStep = flowAgentsDir ? resolveActiveFlowStep(flowAgentsDir, actorKey) : null;
417
493
  const claims = [];
418
494
  const evidenceItems = [];
419
495
  const events = [];
@@ -581,7 +657,11 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
581
657
  // (workflow-artifact-cleanup-audit) and validators can detect the promotion claim without a
582
658
  // new manifest entry. It rides alongside any waiver in a single merged metadata object.
583
659
  const promotionMeta = (check._promotion && typeof check._promotion === "object") ? check._promotion : null;
584
- const claimMetadata = (waiver || promotionMeta) ? { ...(waiver ? { waiver } : {}), ...(promotionMeta ? { promotion: promotionMeta } : {}) } : null;
660
+ // #268: stamp a stable origin discriminator so checksFromBundle / critiquesFromBundle can
661
+ // distinguish check vs critique vs acceptance claims across round-trips even under --flow-id,
662
+ // where all three collapse onto the same declared claimType (and a command-less critique claim
663
+ // would otherwise be re-absorbed as a test_output check → permanent [not-run] divergence).
664
+ const claimMetadata = { origin: "check", check_kind: String(check.kind ?? "external"), ...(waiver ? { waiver } : {}), ...(promotionMeta ? { promotion: promotionMeta } : {}) };
585
665
  const claimEvents = [];
586
666
  if (evStatus) {
587
667
  const evt = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [evId], createdAt: ts, verifiedAt: ts };
@@ -640,13 +720,13 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
640
720
  if (declared) {
641
721
  // Declared kit-typed claim only — no legacy shadow (ADR 0016 P-d).
642
722
  const declaredPolicy = ensurePolicy(declared.claimType, "high", []);
643
- const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id };
723
+ const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: declaredPolicy.id, metadata: { origin: "acceptance" } };
644
724
  const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [], events: claimEvents, policies: [declaredPolicy] });
645
725
  claims.push({ ...declaredClaimObj, status: declaredStatus });
646
726
  }
647
727
  else {
648
728
  // No active flow step — only the workflow.* primary claim (legitimate no-flow fallback path).
649
- const claimObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id };
729
+ const claimObj = { id: claimId, subjectType: "workflow-acceptance-criterion", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: criterion.status, createdAt: ts, updatedAt: ts, impactLevel: "high", verificationPolicyId: policy.id, metadata: { origin: "acceptance" } };
650
730
  const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [policy] });
651
731
  claims.push({ ...claimObj, status: derivedStatus });
652
732
  }
@@ -657,10 +737,23 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
657
737
  continue;
658
738
  const subjectId = `${slug}/${c.id}`;
659
739
  const fieldOrBehavior = String(c.summary ?? c.verdict ?? c.id);
660
- const claimId = generateClaimId(subjectId, "flow-agents.workflow", fieldOrBehavior);
740
+ // #267/#282: a critique carrying `superseded_by` is retained as HISTORY — a prior write for
741
+ // this critique id that a later, same-reviewer critique resolved. It is preserved structurally
742
+ // (status "superseded" + first-class metadata.superseded_by), but is excluded from reconcile
743
+ // evaluation and from the "critique pass cannot include fail members" validator rule.
744
+ const supersededBy = typeof c.superseded_by === "string" && c.superseded_by.length > 0 ? c.superseded_by : null;
745
+ const critiqueReviewer = String(c.reviewer ?? "tool-code-reviewer");
746
+ const critiqueReviewedAt = String(c.reviewed_at ?? ts);
747
+ const critMeta = { origin: "critique", reviewer: critiqueReviewer, reviewed_at: critiqueReviewedAt, ...(supersededBy ? { superseded_by: supersededBy } : {}) };
748
+ // A superseded historical write gets a distinct, stable claimId so it co-exists with the live
749
+ // claim of the same critique id (never overwrites or duplicates it). The salt is reproducible
750
+ // across rebuilds because superseded_by + reviewed_at are preserved in metadata.
751
+ const claimIdSalt = supersededBy ? `${fieldOrBehavior}::superseded::${supersededBy}::${critiqueReviewedAt}` : fieldOrBehavior;
752
+ const claimId = generateClaimId(subjectId, "flow-agents.workflow", claimIdSalt);
661
753
  const legacyClaimType = "workflow.critique.review";
662
754
  const policy = ensurePolicy(legacyClaimType, "medium", []);
663
- const evStatus = critiqueToEventStatus(String(c.verdict ?? ""), c.findings ?? []);
755
+ // A superseded write emits NO verification event (its status is "superseded" directly).
756
+ const evStatus = supersededBy ? null : critiqueToEventStatus(String(c.verdict ?? ""), c.findings ?? []);
664
757
  const claimEvents = [];
665
758
  if (evStatus) {
666
759
  const evt = { id: `evt:${claimId}`, claimId, status: evStatus, actor: "flow-agents/workflow-sidecar", method: "validation", evidenceIds: [], createdAt: ts, verifiedAt: ts };
@@ -669,17 +762,16 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
669
762
  }
670
763
  // P-d: declared-only when active flow/step present (shadow retired); no-flow path unchanged.
671
764
  const declared = matchExpectsEntry("critique");
672
- if (declared) {
673
- // Declared kit-typed claim only no legacy shadow (ADR 0016 P-d).
674
- const declaredPolicy = ensurePolicy(declared.claimType, "medium", []);
675
- const declaredClaimObj = { id: claimId, subjectType: declared.subjectType, subjectId, facet: "flow-agents.workflow", claimType: declared.claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: declaredPolicy.id };
676
- const { status: declaredStatus } = deriveClaimStatus({ claim: declaredClaimObj, evidence: [], events: claimEvents, policies: [declaredPolicy] });
677
- claims.push({ ...declaredClaimObj, status: declaredStatus });
765
+ const claimType = declared ? declared.claimType : legacyClaimType;
766
+ const subjectType = declared ? declared.subjectType : "workflow-critique";
767
+ const claimPolicy = declared ? ensurePolicy(declared.claimType, "medium", []) : policy;
768
+ const claimObj = { id: claimId, subjectType, subjectId, facet: "flow-agents.workflow", claimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: claimPolicy.id, metadata: critMeta };
769
+ if (supersededBy) {
770
+ // History: status is "superseded" directly (no verification event); excluded from evaluation.
771
+ claims.push({ ...claimObj, status: "superseded" });
678
772
  }
679
773
  else {
680
- // No active flow step only the workflow.* primary claim (legitimate no-flow fallback path).
681
- const claimObj = { id: claimId, subjectType: "workflow-critique", subjectId, facet: "flow-agents.workflow", claimType: legacyClaimType, fieldOrBehavior, value: c.verdict, createdAt: ts, updatedAt: ts, impactLevel: "medium", verificationPolicyId: policy.id };
682
- const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [policy] });
774
+ const { status: derivedStatus } = deriveClaimStatus({ claim: claimObj, evidence: [], events: claimEvents, policies: [claimPolicy] });
683
775
  claims.push({ ...claimObj, status: derivedStatus });
684
776
  }
685
777
  }
@@ -705,7 +797,7 @@ export async function buildTrustBundle(slug, timestamp, checks, criteria, critiq
705
797
  * @param criteria Acceptance criteria objects (same as buildTrustBundle)
706
798
  * @param critiques Critique objects (same as buildTrustBundle)
707
799
  */
708
- export async function writeTrustBundle(dir, slug, timestamp, checks, criteria, critiques) {
800
+ export async function writeTrustBundle(dir, slug, timestamp, checks, criteria, critiques, actorKey) {
709
801
  try {
710
802
  // Fold the deterministic capture log (PostToolUse evidence-capture) into the
711
803
  // bundle so capture is authoritative over claimed status. Best-effort read.
@@ -723,17 +815,23 @@ export async function writeTrustBundle(dir, slug, timestamp, checks, criteria, c
723
815
  // ADR 0016 Abstraction A (P-d): pass the runtime artifact root ONLY when current.json
724
816
  // points to this session (scoped active-flow guard). If current.json.artifact_dir
725
817
  // resolves to a different session, pass null — no active-flow claim mapping for this bundle.
818
+ // #291 Wave 2 Task 2.1 (§7): the SOURCE of "what does current.json say" now prefers the
819
+ // resolved actor's own per-actor projection (falling back to the legacy global file) via the
820
+ // shared readCurrentPointer() helper — the "does artifact_dir match dir?" comparison itself is
821
+ // UNCHANGED. actorKey defaults to resolveActor(process.env) when the caller (below) does not
822
+ // already have one resolved, exactly like every other read-path consumer in this file.
726
823
  const _flowAgentsDir = path.dirname(dir);
824
+ const _effectiveActorKey = actorKey ?? loadActorIdentityHelper().resolveActor(process.env).actor;
727
825
  let _scopedFlowAgentsDir = undefined;
728
826
  try {
729
- const _currentRaw = JSON.parse(fs.readFileSync(path.join(_flowAgentsDir, "current.json"), "utf8"));
730
- const _artDir = typeof _currentRaw["artifact_dir"] === "string" ? _currentRaw["artifact_dir"] : null;
827
+ const _currentRaw = loadCurrentPointerHelper().readCurrentPointer(_flowAgentsDir, _effectiveActorKey).payload;
828
+ const _artDir = _currentRaw && typeof _currentRaw["artifact_dir"] === "string" ? _currentRaw["artifact_dir"] : null;
731
829
  if (_artDir && path.resolve(_flowAgentsDir, _artDir) === path.resolve(dir)) {
732
830
  _scopedFlowAgentsDir = _flowAgentsDir;
733
831
  }
734
832
  }
735
833
  catch { /* current.json absent or unreadable — no scoping */ }
736
- const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir);
834
+ const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir, _effectiveActorKey);
737
835
  if (!bundle)
738
836
  return { written: false, errors: [] }; // Surface unavailable — fail-open, skip write
739
837
  const result = await validateTrustBundle(bundle);
@@ -1094,12 +1192,34 @@ function resolveFirstStep(flowId, repoRoot) {
1094
1192
  return null;
1095
1193
  }
1096
1194
  }
1097
- function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId, adHocReason) {
1195
+ /**
1196
+ * Delegate to the shared pure-CJS per-actor current-pointer helper
1197
+ * (scripts/hooks/lib/current-pointer.js), mirroring the createRequire pattern already used above
1198
+ * by loadActorIdentityHelper()/loadLivenessWriteHelper() for cross-boundary CJS helper reuse
1199
+ * (#291 Wave 2 Task 2.1). This is the ONE place every current.json reader/writer in this file
1200
+ * goes through from here on — the per-actor-first/legacy-fallback compat-shim rule (and its
1201
+ * write-side counterpart) can never drift between call sites.
1202
+ */
1203
+ function loadCurrentPointerHelper() {
1204
+ const _req = createRequire(import.meta.url);
1205
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/current-pointer.js");
1206
+ return _req(helperPath);
1207
+ }
1208
+ /**
1209
+ * #291 Wave 2 Task 2.1 (§5): writes the UNCHANGED legacy global `<root>/current.json` (the
1210
+ * compat-shim's write-side half — every existing consumer without an actorKey keeps reading
1211
+ * exactly this file, exactly as before this change), then ADDITIONALLY projects the SAME payload
1212
+ * into the caller's own `current/<actor>.json` when `actorKey` resolves to a real (not
1213
+ * unresolved) actor. When `actorKey` is omitted or unresolved, only the legacy file is written —
1214
+ * byte-identical to this function's pre-#291 behavior — with a stderr note mirroring the existing
1215
+ * unresolved-actor branch-naming diagnostic.
1216
+ */
1217
+ function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId, adHocReason, actorKey) {
1098
1218
  // #289: mirror the active session's already-recorded branch (state.json.branch) into
1099
1219
  // current.json so consumers of current.json (which has no schema of its own — not one of the
1100
1220
  // 9 schemas under schemas/) see the routing branch without re-reading state.json separately.
1101
1221
  const branch = loadJson(path.join(dir, "state.json")).branch;
1102
- writeJson(path.join(root, "current.json"), {
1222
+ const payload = {
1103
1223
  schema_version: "1.0",
1104
1224
  active_slug: path.basename(dir),
1105
1225
  artifact_dir: path.relative(root, dir) || ".",
@@ -1118,7 +1238,21 @@ function writeCurrent(root, dir, timestamp, owner, source, flowId, stepId, adHoc
1118
1238
  // gate-review can distinguish an intentional direct entry (e.g. a planning-only
1119
1239
  // session that skips pull-work) from a stale/mis-stamped active_step_id.
1120
1240
  ...(adHocReason ? { ad_hoc_entry: true, ad_hoc_reason: adHocReason } : {}),
1121
- });
1241
+ };
1242
+ writeJson(path.join(root, "current.json"), payload);
1243
+ if (actorKey && !loadActorIdentityHelper().isUnresolvedActor(actorKey)) {
1244
+ try {
1245
+ loadCurrentPointerHelper().writePerActorCurrent(root, actorKey, payload);
1246
+ }
1247
+ catch (err) {
1248
+ // Best-effort projection only — the legacy file above is already durable and authoritative;
1249
+ // a failure here must never affect ensure-session's own exit code (fail-open, visible).
1250
+ process.stderr.write(`[ensure-session] failed to write per-actor current pointer: ${err instanceof Error ? err.message : String(err)}\n`);
1251
+ }
1252
+ }
1253
+ else {
1254
+ process.stderr.write("[ensure-session] actor unresolved or not provided; per-actor current.json projection skipped (legacy current.json remains authoritative)\n");
1255
+ }
1122
1256
  }
1123
1257
  function loadCurrent(root) {
1124
1258
  const file = path.join(root, "current.json");
@@ -1126,8 +1260,16 @@ function loadCurrent(root) {
1126
1260
  return null;
1127
1261
  return JSON.parse(read(file));
1128
1262
  }
1129
- function currentDir(root) {
1130
- const c = loadCurrent(root);
1263
+ /**
1264
+ * #291 Wave 2 Task 2.1 (§6): resolves the active session directory via the shared
1265
+ * per-actor-first/legacy-fallback compat shim (readCurrentPointer) instead of the unconditional
1266
+ * legacy-only loadCurrent() this function used before #291. When `actorKey` is omitted, empty, or
1267
+ * unresolved, `readCurrentPointer` falls straight through to the legacy file — IDENTICAL to this
1268
+ * function's pre-#291 behavior.
1269
+ */
1270
+ function currentDir(root, actorKey) {
1271
+ const pointer = loadCurrentPointerHelper().readCurrentPointer(root, actorKey);
1272
+ const c = pointer.payload;
1131
1273
  if (!c)
1132
1274
  return null;
1133
1275
  const dir = path.resolve(root, c.artifact_dir ?? c.active_slug ?? "");
@@ -1141,16 +1283,38 @@ function currentDir(root) {
1141
1283
  }
1142
1284
  return dir;
1143
1285
  }
1144
- function updateCurrentAgent(root, dir, agentId, status, timestamp) {
1286
+ /**
1287
+ * #291 Wave 2 Task 2.1 (§6): updates BOTH the legacy current.json (when IT points at `dir` — the
1288
+ * exact, unchanged existing check/write) AND the resolved actor's own per-actor current.json
1289
+ * (independently, when IT points at `dir`) — never silently drops the legacy-file update path.
1290
+ * The two checks are independent: either, both, or neither may fire depending on which
1291
+ * pointer(s) currently reference `dir`.
1292
+ */
1293
+ function updateCurrentAgent(root, dir, agentId, status, timestamp, actorKey) {
1294
+ const applyAgentUpdate = (payload) => {
1295
+ const active = Array.isArray(payload.active_agents) ? payload.active_agents.filter((a) => a.agent_id !== agentId) : [];
1296
+ if (status === "active" || status === "blocked")
1297
+ active.push({ agent_id: agentId, status, updated_at: timestamp });
1298
+ return { ...payload, active_agents: active, updated_at: timestamp };
1299
+ };
1145
1300
  const cur = loadCurrent(root);
1146
- if (!cur || path.resolve(root, cur.artifact_dir ?? "") !== path.resolve(dir))
1147
- return;
1148
- const active = Array.isArray(cur.active_agents) ? cur.active_agents.filter((a) => a.agent_id !== agentId) : [];
1149
- if (status === "active" || status === "blocked")
1150
- active.push({ agent_id: agentId, status, updated_at: timestamp });
1151
- cur.active_agents = active;
1152
- cur.updated_at = timestamp;
1153
- writeJson(path.join(root, "current.json"), cur);
1301
+ if (cur && path.resolve(root, cur.artifact_dir ?? "") === path.resolve(dir)) {
1302
+ writeJson(path.join(root, "current.json"), applyAgentUpdate(cur));
1303
+ }
1304
+ if (actorKey && !loadActorIdentityHelper().isUnresolvedActor(actorKey)) {
1305
+ const helper = loadCurrentPointerHelper();
1306
+ const perActorFile = helper.perActorCurrentFile(root, actorKey);
1307
+ let perActor = null;
1308
+ try {
1309
+ perActor = fs.existsSync(perActorFile) ? JSON.parse(fs.readFileSync(perActorFile, "utf8")) : null;
1310
+ }
1311
+ catch {
1312
+ perActor = null;
1313
+ }
1314
+ if (perActor && path.resolve(root, perActor.artifact_dir ?? "") === path.resolve(dir)) {
1315
+ helper.writePerActorCurrent(root, actorKey, applyAgentUpdate(perActor));
1316
+ }
1317
+ }
1154
1318
  }
1155
1319
  function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp, markdown) {
1156
1320
  const criteria = markdown ? definitionAcceptanceLines(markdown).map(parseCriterion) : [];
@@ -1198,10 +1362,191 @@ function initSidecars(dir, slug, sourceRequest, summary, nextAction, timestamp,
1198
1362
  ...sidecarBase(slug), summary, current_state_ref: "state.json", next_steps: nextAction ? [nextAction] : [], blockers: [], warnings: [],
1199
1363
  });
1200
1364
  }
1365
+ /** Read a `--*-json` flag's value as a file path (or `-` for stdin), mirroring
1366
+ * assignment-provider.ts's own `loadJsonInput` convention — this file's OTHER `--*-json` flags
1367
+ * (e.g. `--check-json`) instead take a literal inline JSON string via parseJson(), a DIFFERENT
1368
+ * convention; `--effective-state-json` deliberately follows assignment-provider's file/stdin
1369
+ * convention instead, since its value is the literal JSON `assignment-provider status
1370
+ * --provider github ...` already prints to a file (or pipe), not something a caller would want to
1371
+ * inline as a shell argument. */
1372
+ function loadJsonInputFile(file) {
1373
+ return file === "-" ? JSON.parse(fs.readFileSync(0, "utf8")) : JSON.parse(read(file));
1374
+ }
1375
+ /**
1376
+ * #291 Wave 2 Task 2.1 (§4): ensure-session's pre-entry ownership guard. MUST be called before
1377
+ * any directory or file is created (a refusal must never leave a stray empty session dir) —
1378
+ * ensureSession() calls this immediately, before `fs.mkdirSync(dir, ...)`. Reuses #290's
1379
+ * assignment ⋈ liveness join (`computeEffectiveState`, Wave 1 export) so a fresh OTHER-actor claim
1380
+ * refuses entry, a human assignment always asks first (never auto-reclaims), a stale
1381
+ * (`reclaimable`) claim requires the explicit `--supersede-stale` takeover flag, and a `free`
1382
+ * subject establishes a durable claim for the entering actor — ensure-session becomes a SECOND
1383
+ * claim point, alongside pull-work (closing the loophole where a session entered without going
1384
+ * through pull-work never gets a durable claim at all).
1385
+ *
1386
+ * Runs INSIDE ensureSession's existing root-level `withLock` (main(), unchanged) — no second/
1387
+ * competing lock is introduced here. `performLocalClaim`/`performLocalSupersede`'s OWN internal
1388
+ * `withSubjectLock` (a DIFFERENT lockdir, under `<root>/assignment/.<subject>.lockdir`) is what
1389
+ * protects against a concurrent bare `assignment-provider claim` CLI invocation (e.g. from
1390
+ * pull-work) racing this guard — two independent, non-conflicting lock resources.
1391
+ *
1392
+ * Every interpolated actor/holder/assignee/reason field in this function's die() messages is run
1393
+ * through stripControlCharsForDisplay + a 64-char cap (AC9 — the #287/#320/#290 prompt-injection
1394
+ * mitigation class: a hostile liveness event or a hand-crafted --effective-state-json fixture must
1395
+ * never be able to smuggle raw control/ANSI bytes into this process's stderr/thrown message).
1396
+ */
1397
+ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
1398
+ if (p.flags.has("skip-ownership-guard")) {
1399
+ process.stderr.write("[ensure-session] ownership guard skipped via --skip-ownership-guard\n");
1400
+ return;
1401
+ }
1402
+ // Design Decision 4 (unchanged from resolveSessionBranch): actor-resolution ambiguity never
1403
+ // hard-fails session creation. Without a resolvable actor there is no safe identity to claim
1404
+ // under, so the guard is skipped entirely (documented scope boundary, not a silent hole) rather
1405
+ // than claiming under a synthetic/unstable identity.
1406
+ if (resolution.unresolved) {
1407
+ 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");
1408
+ return;
1409
+ }
1410
+ // F5 fix (fix-plan iteration 1, LOW): match assignment-provider.ts's sanitizeDisplayField
1411
+ // two-tier convention (64 for id-like fields, 240 for free text) rather than asserting one
1412
+ // uniform cap applies to every field class regardless of content. `sanitize` below is 64 chars —
1413
+ // deliberately, not by uniform-cap default — because every value this function actually
1414
+ // interpolates into a die()/stderr message is id-like (holder/actor key, subject slug, assignee,
1415
+ // a claimed_at/last_at timestamp, the provider-kind enum); none of them is free text, so the
1416
+ // id-like tier is the correct (not merely convenient) cap here. sanitizeDisplayField's 240
1417
+ // free-text tier is for fields like a claim record's audit-trail `reason` (see
1418
+ // assignment-provider.ts's sanitizeAuditEntryForDisplay) — this guard never echoes `--reason`
1419
+ // into a die() message, so it has no free-text field requiring the 240 tier today.
1420
+ const sanitize = (value) => stripControlCharsForDisplay(value).slice(0, 64);
1421
+ const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : Date.now();
1422
+ const assignmentProviderKind = opt(p, "assignment-provider", "local-file");
1423
+ let effective;
1424
+ const effectiveStateJsonFlag = opt(p, "effective-state-json");
1425
+ if (effectiveStateJsonFlag) {
1426
+ const parsed = loadJsonInputFile(effectiveStateJsonFlag);
1427
+ const candidate = parsed && typeof parsed === "object" ? parsed.effective : undefined;
1428
+ const validStates = new Set(["held", "reclaimable", "human-held", "free"]);
1429
+ if (!candidate || typeof candidate.effective_state !== "string" || !validStates.has(candidate.effective_state)) {
1430
+ 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)}`);
1431
+ }
1432
+ effective = candidate;
1433
+ }
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
+ }
1456
+ else {
1457
+ // Conflict #5 (plan): GitHub-provider (and any other non-local-file) subjects get no LIVE
1458
+ // guard check here — assignment-provider.ts is deliberately render-don't-execute (no `gh`
1459
+ // calls from any CLI file). A precomputed --effective-state-json is the escape hatch; when
1460
+ // neither applies, this is a documented scope boundary (today's pre-#291 baseline behavior),
1461
+ // never a silent hole.
1462
+ process.stderr.write(`[ensure-session] ownership guard not evaluated: provider "${sanitize(assignmentProviderKind)}" requires --effective-state-json\n`);
1463
+ return;
1464
+ }
1465
+ const resolveBranchForClaim = () => {
1466
+ const existingBranch = fs.existsSync(path.join(dir, "state.json")) ? loadJson(path.join(dir, "state.json")).branch : undefined;
1467
+ return existingBranch || resolveSessionBranch(p, slug);
1468
+ };
1469
+ switch (effective.effective_state) {
1470
+ case "held": {
1471
+ // F1 fix (fix-plan iteration 1, HIGH): compare against branchActorKey — the same canonical
1472
+ // string just passed as computeEffectiveState's selfActor above — not actorKey (the wrapped
1473
+ // ActorStruct triple), so this redundant belt-and-suspenders check agrees with the
1474
+ // effective_state computation instead of silently using a different identity.
1475
+ const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === resolution.branchActorKey);
1476
+ if (isSelf)
1477
+ 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)
1498
+ die(`ensure-session --supersede-stale: no existing local-file claim record found for subject ${sanitize(slug)} to supersede`);
1499
+ performLocalSupersede(root, slug, fromActor, resolution.actorStruct, {
1500
+ branch: resolveBranchForClaim(),
1501
+ artifactDir: path.relative(root, dir) || ".",
1502
+ reason: opt(p, "reason", "ensure-session takeover: stale claim"),
1503
+ // F1 fix (fix-plan iteration 1, HIGH): persist the canonical actor_key on the record so
1504
+ // computeEffectiveState's holderActorKey (assignment-provider.ts) matches this same
1505
+ // branchActorKey string on the next status/guard check, cross-tool.
1506
+ actorKey: resolution.branchActorKey,
1507
+ });
1508
+ return;
1509
+ }
1510
+ case "free": {
1511
+ performLocalClaim(root, slug, resolution.actorStruct, {
1512
+ ttlSeconds: opt(p, "claim-ttl-seconds") ? Number(opt(p, "claim-ttl-seconds")) : loadLivenessPolicyHelper().resolveTtlSeconds(process.env),
1513
+ branch: resolveBranchForClaim(),
1514
+ artifactDir: path.relative(root, dir) || ".",
1515
+ reason: opt(p, "reason", "ensure-session entry"),
1516
+ // F1 fix (fix-plan iteration 1, HIGH): see the performLocalSupersede call above.
1517
+ actorKey: resolution.branchActorKey,
1518
+ });
1519
+ return;
1520
+ }
1521
+ default:
1522
+ die(`ensure-session ownership guard: unrecognized effective_state ${JSON.stringify(effective.effective_state)}`);
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
+ */
1201
1540
  function ensureSession(p) {
1202
1541
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
1203
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)"));
1204
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);
1205
1550
  fs.mkdirSync(dir, { recursive: true });
1206
1551
  const timestamp = opt(p, "timestamp", now());
1207
1552
  let md = fs.existsSync(path.join(dir, `${slug}--deliver.md`)) ? read(path.join(dir, `${slug}--deliver.md`)) : "";
@@ -1246,13 +1591,18 @@ function ensureSession(p) {
1246
1591
  adHocReason = opt(p, "ad-hoc-reason") || `direct entry at step "${explicitStep}" via --step-id (flow first step is "${firstStep}")`;
1247
1592
  }
1248
1593
  }
1249
- writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined, adHocReason);
1594
+ writeCurrent(root, dir, timestamp, "workflow-sidecar", "ensure-session", flowId || undefined, stepId || undefined, adHocReason, actorResolution.unresolved ? undefined : actorResolution.branchActorKey);
1250
1595
  console.log(dir);
1251
1596
  return 0;
1252
1597
  }
1253
1598
  function current(p) {
1254
1599
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : defaultArtifactRootForRead();
1255
- const dir = currentDir(root);
1600
+ // #291 Wave 2 Task 2.1 (§6): new optional --actor override (same convention as
1601
+ // resolveSessionBranch's existing --actor), falling back to resolveActor(process.env). Only the
1602
+ // SOURCE of "what does current.json say" changes (per-actor-first, legacy-fallback) — this
1603
+ // command's existing --format slug|path output is unchanged.
1604
+ const actorKey = resolveReadActorKey(p);
1605
+ const dir = currentDir(root, actorKey);
1256
1606
  if (!dir)
1257
1607
  die("no current workflow session is recorded");
1258
1608
  const format = opt(p, "format", "path");
@@ -1263,7 +1613,8 @@ function recordAgentEvent(p) {
1263
1613
  const hasExplicitRoot = !!opt(p, "artifact-root");
1264
1614
  const root = explicitArtifactRoot(p);
1265
1615
  const explicit = opt(p, "artifact-dir");
1266
- const dir = explicit ? path.resolve(explicit) : currentDir(root);
1616
+ const actorKey = resolveReadActorKey(p);
1617
+ const dir = explicit ? path.resolve(explicit) : currentDir(root, actorKey);
1267
1618
  if (!dir || !fs.existsSync(dir))
1268
1619
  die("artifact directory does not exist");
1269
1620
  if (explicit && fs.lstatSync(dir).isSymbolicLink())
@@ -1274,7 +1625,7 @@ function recordAgentEvent(p) {
1274
1625
  const agent = validateAgentId(opt(p, "agent-id"));
1275
1626
  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") } : {}) };
1276
1627
  appendJsonl(path.join(dir, "agents", agent, "events.jsonl"), event);
1277
- updateCurrentAgent(root, dir, agent, event.status, timestamp);
1628
+ updateCurrentAgent(root, dir, agent, event.status, timestamp, actorKey);
1278
1629
  return 0;
1279
1630
  }
1280
1631
  function initPlan(p) {
@@ -1504,67 +1855,70 @@ export function writeState(dir, slug, status, phase, timestamp, summary, next =
1504
1855
  // After 4c, evidence.json and critique.json are no longer written.
1505
1856
  // Extract checks and critiques from the existing trust.bundle for callers that
1506
1857
  // need to rebuild the bundle (e.g. record-critique, record-learning).
1507
- // ADR 0016 Abstraction A (Step 0 Q3 carry-forward): build the set of declared
1508
- // claimTypes from the active flow step for the session at `dir`. When no active
1509
- // flow is present (workflow.* sessions), returns an empty set so every existing
1510
- // predicate is unchanged. When a FlowDefinition-driven session (builder.build)
1511
- // is active, the set contains the kit-typed claimTypes (e.g. "builder.verify.tests",
1512
- // "builder.verify.policy-compliance") so round-trip helpers broaden their filters
1513
- // to include declared claims alongside the legacy workflow.* ones.
1858
+ // #268/#344: buildTrustBundle stamps a stable origin discriminator ("check" | "acceptance" |
1859
+ // "critique") plus check_kind (for origin "check") on EVERY claim it writes. These stamps are
1860
+ // AUTHORITATIVE and the ONLY way checksFromBundle/critiquesFromBundle (and evidenceClean/
1861
+ // critiqueClean below) classify a claim.
1514
1862
  //
1515
- // Safety guard: current.json in the runtime artifact root records the CURRENTLY ACTIVE
1516
- // session via artifact_dir. If current.json points to a different session than `dir`
1517
- // (e.g. another session was the last to call advance-state --flow-definition), we
1518
- // return an empty set so declared-type predicates are NOT applied to the wrong session.
1519
- // This prevents a cross-session active_flow_id from broadening claim filters for
1520
- // unrelated sessions (which would cause spurious evidence/critique check behavior).
1521
- function declaredClaimTypesFor(dir) {
1522
- const flowAgentsDir = path.dirname(dir);
1523
- // Verify that current.json points to `dir` before reading active flow step.
1524
- // If it points to a different session, return empty set (zero behavior change).
1525
- const currentFile = path.join(flowAgentsDir, "current.json");
1526
- try {
1527
- const current = JSON.parse(fs.readFileSync(currentFile, "utf8"));
1528
- const artDir = typeof current["artifact_dir"] === "string" ? current["artifact_dir"] : null;
1529
- if (!artDir)
1530
- return new Set();
1531
- const resolvedCurrent = path.resolve(flowAgentsDir, artDir);
1532
- if (path.resolve(dir) !== resolvedCurrent)
1533
- return new Set();
1534
- }
1535
- catch {
1536
- return new Set();
1537
- }
1538
- const activeStep = resolveActiveFlowStep(flowAgentsDir);
1539
- if (!activeStep || activeStep.gateExpects.length === 0)
1540
- return new Set();
1541
- return new Set(activeStep.gateExpects.map((e) => e.bundle_claim.claimType));
1542
- }
1543
- function checksFromBundle(dir, declaredClaimTypes = new Set()) {
1863
+ // Hard cutover (owner directive, no legacy fallbacks): there is deliberately no claimType-
1864
+ // derivation fallback for unstamped claims. A prior version of this file fell back to
1865
+ // classifying an unstamped claim by claimType heuristic that fallback WAS the #268 defect
1866
+ // kept reachable (a no-evidence declared claim silently re-absorbed as a command-less
1867
+ // test_output check, and a critique claim silently re-absorbed as a check, corrupting the
1868
+ // round-trip catastrophically under --flow-id). An unstamped claim means the bundle predates
1869
+ // #344 and must be regenerated, not silently reclassified — see requireStampedClaim below.
1870
+ function claimOrigin(claim) {
1871
+ const md = claim && claim.metadata;
1872
+ return md && typeof md === "object" && typeof md.origin === "string" && md.origin.length > 0 ? String(md.origin) : null;
1873
+ }
1874
+ // Fails loud — never silent, never a heuristic reclassification — when a claim in `dir`'s
1875
+ // trust.bundle lacks its metadata.origin stamp, or (for an origin==="check" claim) its
1876
+ // metadata.check_kind stamp. Names the session dir and the remedy so the caller can regenerate
1877
+ // a fresh, fully-stamped bundle instead of reading a pre-supersession one.
1878
+ function requireStampedClaim(claim, dir) {
1879
+ if (!claim || typeof claim !== "object")
1880
+ die(`trust.bundle in ${dir} contains a malformed claim entry — cannot read.`);
1881
+ const remedy = `re-record evidence to regenerate: npm run workflow:sidecar -- record-evidence ${dir} --verdict <verdict> --check-json <...>`;
1882
+ const origin = claimOrigin(claim);
1883
+ if (!origin) {
1884
+ 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}`);
1885
+ }
1886
+ if (origin === "check") {
1887
+ const md = (claim.metadata && typeof claim.metadata === "object") ? claim.metadata : {};
1888
+ if (typeof md.check_kind !== "string" || md.check_kind.length === 0) {
1889
+ 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}`);
1890
+ }
1891
+ }
1892
+ return origin;
1893
+ }
1894
+ function checksFromBundle(dir) {
1544
1895
  const bundle = loadJson(path.join(dir, "trust.bundle"));
1896
+ const allClaims = Array.isArray(bundle.claims) ? bundle.claims : [];
1897
+ // Validate stamps on every claim up front — any unstamped claim anywhere in the bundle marks
1898
+ // it pre-supersession, regardless of whether it is check/acceptance/critique-typed.
1899
+ for (const claim of allClaims)
1900
+ requireStampedClaim(claim, dir);
1545
1901
  if (!Array.isArray(bundle.evidence))
1546
1902
  return [];
1547
- const allClaims = Array.isArray(bundle.claims) ? bundle.claims : [];
1548
1903
  const claimById = new Map();
1549
1904
  for (const c of allClaims)
1550
1905
  if (c && c.id)
1551
1906
  claimById.set(c.id, c);
1552
1907
  const seen = new Set();
1553
1908
  const checks = [];
1909
+ const kindOf = (claim) => String(claim.metadata.check_kind);
1554
1910
  for (const ev of bundle.evidence) {
1555
1911
  if (!ev || !ev.claimId)
1556
1912
  continue;
1557
1913
  const claim = claimById.get(ev.claimId);
1558
1914
  if (!claim)
1559
1915
  continue;
1560
- const ct = String(claim.claimType || "");
1561
- // ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
1562
- if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct))
1916
+ if (claimOrigin(claim) !== "check")
1563
1917
  continue;
1564
1918
  if (seen.has(ev.claimId))
1565
1919
  continue;
1566
1920
  seen.add(ev.claimId);
1567
- const kind = ct.startsWith("workflow.check.") ? (ct.replace("workflow.check.", "") || "external") : (ct.split(".").pop() || "external");
1921
+ const kind = kindOf(claim);
1568
1922
  const status = claim.value ?? "not_verified";
1569
1923
  const check = { id: String(claim.subjectId || "").split("/").pop() || ev.claimId, kind, status, summary: claim.fieldOrBehavior || "" };
1570
1924
  if (ev.execution && typeof ev.execution.label === "string")
@@ -1573,41 +1927,43 @@ function checksFromBundle(dir, declaredClaimTypes = new Set()) {
1573
1927
  check.evidenceType = ev.evidenceType;
1574
1928
  checks.push(check);
1575
1929
  }
1576
- // Also include check claims that have no evidence item (surface_trust_refs style)
1930
+ // Also include check claims that have no evidence item (surface_trust_refs style).
1577
1931
  for (const claim of allClaims) {
1578
1932
  if (!claim)
1579
1933
  continue;
1580
- const ct = String(claim.claimType || "");
1581
- // ADR 0016 Step 0: broaden to include declared kit-typed claims alongside workflow.check.*
1582
- if (!ct.startsWith("workflow.check.") && !declaredClaimTypes.has(ct))
1934
+ if (claimOrigin(claim) !== "check")
1583
1935
  continue;
1584
1936
  if (seen.has(claim.id))
1585
1937
  continue;
1586
1938
  seen.add(claim.id);
1587
- const kind = ct.startsWith("workflow.check.") ? (ct.replace("workflow.check.", "") || "external") : (ct.split(".").pop() || "external");
1939
+ const kind = kindOf(claim);
1588
1940
  checks.push({ id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" });
1589
1941
  }
1590
1942
  return checks;
1591
1943
  }
1592
- function critiquesFromBundle(dir, declaredClaimTypes = new Set()) {
1944
+ function critiquesFromBundle(dir) {
1593
1945
  const bundle = loadJson(path.join(dir, "trust.bundle"));
1594
1946
  if (!Array.isArray(bundle.claims))
1595
1947
  return [];
1596
- // ADR 0016 Step 0: broaden to include declared kit-typed critique claims alongside workflow.critique.review.
1597
- // P-d: exclude claims that have evidence items (evidence = check claims, not critique claims).
1598
- // This prevents check-type declared claims (e.g. builder.verify.tests) from being read back
1599
- // as critiques when declaredClaimTypes includes all gate expects[] types.
1600
- const evidenceClaimIds = new Set(Array.isArray(bundle.evidence) ? bundle.evidence.map((e) => e?.claimId).filter((id) => typeof id === "string") : []);
1601
- const critiqueClaims = bundle.claims.filter((c) => c && (c.claimType === "workflow.critique.review" || declaredClaimTypes.has(c.claimType)) && !evidenceClaimIds.has(c.id));
1602
- return critiqueClaims.map((c) => ({
1603
- id: String(c.subjectId || "").split("/").pop() || c.id,
1604
- verdict: c.value ?? "not_verified",
1605
- summary: c.fieldOrBehavior || "",
1606
- findings: [],
1607
- reviewer: "tool-code-reviewer",
1608
- reviewed_at: c.updatedAt || c.createdAt || now(),
1609
- artifact_refs: [],
1610
- }));
1948
+ for (const c of bundle.claims)
1949
+ requireStampedClaim(c, dir);
1950
+ // A claim is a CRITIQUE when its origin is "critique" (authoritative — see requireStampedClaim
1951
+ // above). reviewer / reviewed_at / superseded_by are read back from metadata so supersession
1952
+ // (#267/#282) round-trips losslessly.
1953
+ const critiqueClaims = bundle.claims.filter((c) => c && claimOrigin(c) === "critique");
1954
+ return critiqueClaims.map((c) => {
1955
+ const md = (c.metadata && typeof c.metadata === "object") ? c.metadata : {};
1956
+ return {
1957
+ id: String(c.subjectId || "").split("/").pop() || c.id,
1958
+ verdict: c.value ?? "not_verified",
1959
+ summary: c.fieldOrBehavior || "",
1960
+ findings: [],
1961
+ reviewer: typeof md.reviewer === "string" ? md.reviewer : "tool-code-reviewer",
1962
+ reviewed_at: typeof md.reviewed_at === "string" ? md.reviewed_at : (c.updatedAt || c.createdAt || now()),
1963
+ artifact_refs: [],
1964
+ ...(typeof md.superseded_by === "string" && md.superseded_by.length > 0 ? { superseded_by: md.superseded_by } : {}),
1965
+ };
1966
+ });
1611
1967
  }
1612
1968
  // ─────────────────────────────────────────────────────────────────────────────
1613
1969
  /**
@@ -1662,7 +2018,11 @@ async function recordEvidence(p) {
1662
2018
  const _existingCriteria = Array.isArray(_existingAcceptance.criteria) ? _existingAcceptance.criteria : [];
1663
2019
  const _criteriaStatus = verdict === "pass" ? "pass" : verdict === "fail" ? "fail" : "not_verified";
1664
2020
  const _criteriaForBundle = _existingCriteria.map((c) => ({ ...c, status: _criteriaStatus }));
1665
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, []));
2021
+ // #268: preserve any existing critique claims (including superseded history) instead of dropping
2022
+ // them — record-evidence previously hardcoded critiques:[] here, silently erasing finding history
2023
+ // whenever it ran after a critique existed.
2024
+ const _existingCritiques = critiquesFromBundle(dir);
2025
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, checks, _criteriaForBundle, _existingCritiques));
1666
2026
  const stateStatus = verdict === "pass" ? "verified" : verdict === "fail" ? "failed" : "not_verified";
1667
2027
  writeState(dir, slug, stateStatus, "verification", ts, "Evidence recorded.");
1668
2028
  return 0;
@@ -1707,9 +2067,14 @@ async function recordGateClaim(p) {
1707
2067
  die("--status must be one of: pass, fail, not_verified");
1708
2068
  const summary = opt(p, "summary") || die("--summary is required");
1709
2069
  const expectationId = opt(p, "expectation");
1710
- // Resolve the active flow step from current.json
2070
+ // Resolve the active flow step from current.json. #291 Wave 2 Task 2.1 (§7)/Task 2.2: resolve
2071
+ // the CALLING actor's own current-pointer (per-actor-first, legacy-fallback) rather than an
2072
+ // unconditional legacy-only read — this is the fix for record-gate-claim's pre-existing (#291
2073
+ // Stop-short risk) lack of a dir-scoping guard: it now resolves ITS OWN actor's current.json
2074
+ // view, not a different actor's more-recently-written legacy pointer.
1711
2075
  const flowAgentsDir = path.dirname(dir);
1712
- const activeStep = resolveActiveFlowStep(flowAgentsDir);
2076
+ const gateClaimActorKey = resolveReadActorKey(p);
2077
+ const activeStep = resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
1713
2078
  if (!activeStep)
1714
2079
  die("record-gate-claim requires an active flow step in current.json (set via ensure-session --flow-id or advance-state --flow-definition)");
1715
2080
  const expects = activeStep.gateExpects;
@@ -1756,7 +2121,7 @@ async function recordGateClaim(p) {
1756
2121
  checkNormalized._waiver = gateWaiver;
1757
2122
  // Log the targeted gate expectation for transparency (goes to stderr only)
1758
2123
  process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}${gateWaiver ? " (WAIVED accepted_gap)" : ""}\n`);
1759
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], []));
2124
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], [], gateClaimActorKey));
1760
2125
  return 0;
1761
2126
  }
1762
2127
  /**
@@ -1826,11 +2191,10 @@ async function promote(p) {
1826
2191
  // Add the promotion claim WITHOUT dropping the session's existing verification
1827
2192
  // evidence/criteria/critiques (mirror record-critique's merge pattern). Drop any prior
1828
2193
  // "promotion" check so re-running promote is idempotent rather than duplicating.
1829
- const _dct = declaredClaimTypesFor(dir);
1830
- const existingChecks = checksFromBundle(dir, _dct).filter((c) => c.id !== "promotion");
2194
+ const existingChecks = checksFromBundle(dir).filter((c) => c.id !== "promotion");
1831
2195
  const _acc = loadJson(path.join(dir, "acceptance.json"));
1832
2196
  const criteria = Array.isArray(_acc.criteria) ? _acc.criteria : [];
1833
- const critiques = critiquesFromBundle(dir, _dct);
2197
+ const critiques = critiquesFromBundle(dir);
1834
2198
  assertBundleWritten(await writeTrustBundle(dir, slug, ts, [...existingChecks, promotionCheck], criteria, critiques));
1835
2199
  // Auditable record of what was promoted where (companion to the trust.bundle claim).
1836
2200
  writeJson(path.join(dir, "promotion.json"), { ...sidecarBase(slug), ...promotionMarker, summary });
@@ -1893,7 +2257,11 @@ async function advanceState(p) {
1893
2257
  const phaseMap = resolvePhaseMap(flow, repoRoot);
1894
2258
  const stepId = phaseMap?.[phase] ?? undefined;
1895
2259
  if (stepId) {
1896
- writeCurrent(root, dir, timestamp, "workflow-sidecar", "advance-state", flow, stepId);
2260
+ // #291 Wave 2 Task 2.1 (§5): thread the calling actor through so this second writeCurrent()
2261
+ // call site ALSO dual-writes the per-actor projection, not only ensure-session's call site —
2262
+ // otherwise a session that only ever calls advance-state (never re-running ensure-session)
2263
+ // would never get a per-actor current.json mirror for its own FlowDefinition routing keys.
2264
+ writeCurrent(root, dir, timestamp, "workflow-sidecar", "advance-state", flow, stepId, undefined, resolveReadActorKey(p));
1897
2265
  }
1898
2266
  }
1899
2267
  livenessLifecycle(dir, slug, LIVENESS_TERMINAL.has(status) ? "release" : "heartbeat", timestamp);
@@ -1924,14 +2292,31 @@ async function recordCritique(p) {
1924
2292
  // Fall back to critique.json for legacy sessions that still have it on disk.
1925
2293
  const existingCritiqueJson = loadJson(path.join(dir, "critique.json"), { critiques: [] });
1926
2294
  const legacyCritiques = Array.isArray(existingCritiqueJson.critiques) ? existingCritiqueJson.critiques : [];
1927
- const _dctCritique = declaredClaimTypesFor(dir);
1928
- const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir, _dctCritique) : legacyCritiques;
2295
+ const bundleCritiques = legacyCritiques.length === 0 ? critiquesFromBundle(dir) : legacyCritiques;
1929
2296
  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"))) };
1930
- const critiques = [...bundleCritiques, critique];
1931
2297
  if (critique.verdict === "pass" && critique.findings.some((f) => f.status === "open"))
1932
2298
  die("required critique must pass");
2299
+ // #267/#282: supersede-by-critique-id. The latest write for a critique id wins for
2300
+ // reconcile / status / validator purposes; each prior LIVE write for the same id is RETAINED as
2301
+ // history (status "superseded", first-class metadata.superseded_by) but excluded from evaluation.
2302
+ // Supersession is REVIEWER-SCOPED (anti-gaming): a write may only supersede a prior live critique
2303
+ // of the same id written by the SAME reviewer — a different reviewer's disputed finding is never
2304
+ // buried, so it stays live and continues to block. DOCUMENTED GAP: reviewer identity is the
2305
+ // free-form --reviewer string (with a default); there is no cryptographic worker-vs-reviewer
2306
+ // distinction yet — that lands with the runtime actor-identity slice (#287/#290). Same-reviewer-
2307
+ // string scoping is the strongest honest enforcement available today and matches the granularity
2308
+ // the critique record already has.
2309
+ const _supersedeMarker = `${critique.id}@${critique.reviewed_at}`;
2310
+ const _mergedCritiques = bundleCritiques.map((e) => {
2311
+ const eSuperseded = typeof e.superseded_by === "string" && e.superseded_by.length > 0;
2312
+ const eReviewer = String(e.reviewer ?? "tool-code-reviewer");
2313
+ if (e.id === critique.id && !eSuperseded && eReviewer === critique.reviewer)
2314
+ return { ...e, superseded_by: _supersedeMarker };
2315
+ return e;
2316
+ });
2317
+ const critiques = [..._mergedCritiques, critique];
1933
2318
  // Phase 4c: build bundle from raw inputs; read checks from trust.bundle (evidence.json no longer written).
1934
- const _critiqueEvChecks = checksFromBundle(dir, _dctCritique);
2319
+ const _critiqueEvChecks = checksFromBundle(dir);
1935
2320
  const _critiqueAccCriteria = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
1936
2321
  assertBundleWritten(await writeTrustBundle(dir, slug, critique.reviewed_at, _critiqueEvChecks, _critiqueAccCriteria, critiques));
1937
2322
  return 0;
@@ -2327,26 +2712,23 @@ async function recordLearning(p) {
2327
2712
  writeJson(path.join(dir, "learning.json"), { ...sidecarBase(slug), status, updated_at: timestamp, records });
2328
2713
  writeState(dir, slug, "accepted", "learning", timestamp, opt(p, "summary"));
2329
2714
  // Phase 4c: build bundle from raw inputs; read checks/critiques from trust.bundle (bespoke sidecars no longer written).
2330
- // ADR 0016 Step 0: pass declaredClaimTypes so declared builder.* claims survive the round-trip.
2331
- const _dctLearning = declaredClaimTypesFor(dir);
2332
- const _learningChecks = checksFromBundle(dir, _dctLearning);
2715
+ // #268/#344: declared builder.* claims survive the round-trip via their authoritative origin stamp.
2716
+ const _learningChecks = checksFromBundle(dir);
2333
2717
  const _learningCriteria = Array.isArray(loadJson(path.join(dir, "acceptance.json")).criteria) ? loadJson(path.join(dir, "acceptance.json")).criteria : [];
2334
- const _learningCritiques = critiquesFromBundle(dir, _dctLearning);
2718
+ const _learningCritiques = critiquesFromBundle(dir);
2335
2719
  assertBundleWritten(await writeTrustBundle(dir, slug, timestamp, _learningChecks, _learningCriteria, _learningCritiques));
2336
2720
  return 0;
2337
2721
  }
2338
- function evidenceClean(dir, declaredClaimTypes = new Set()) {
2339
- // Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for legacy sessions.
2340
- // ADR 0016 Step 0: declaredClaimTypes broadens the filter to include kit-typed check claims
2341
- // (e.g. builder.verify.tests) in addition to workflow.check.* for FlowDefinition-driven sessions.
2722
+ function evidenceClean(dir) {
2723
+ // Phase 4c: read from trust.bundle (sole verification artifact); fall back to evidence.json for
2724
+ // legacy (pre-bundle-era) sessions that never wrote a trust.bundle at all unrelated to origin
2725
+ // stamping. When a trust.bundle IS present, every claim must be stamped (requireStampedClaim);
2726
+ // there is no claimType-derivation fallback for an unstamped claim (#268/#344).
2342
2727
  const bundle = loadJson(path.join(dir, "trust.bundle"));
2343
2728
  if (Array.isArray(bundle.claims)) {
2344
- const checkClaims = bundle.claims.filter((c) => {
2345
- if (!c)
2346
- return false;
2347
- const ct = String(c.claimType || "");
2348
- return ct.startsWith("workflow.check.") || declaredClaimTypes.has(ct);
2349
- });
2729
+ for (const c of bundle.claims)
2730
+ requireStampedClaim(c, dir);
2731
+ const checkClaims = bundle.claims.filter((c) => c && claimOrigin(c) === "check");
2350
2732
  if (checkClaims.length === 0)
2351
2733
  return false;
2352
2734
  return checkClaims.every((c) => {
@@ -2354,7 +2736,7 @@ function evidenceClean(dir, declaredClaimTypes = new Set()) {
2354
2736
  return v === "pass" || v === "skip";
2355
2737
  });
2356
2738
  }
2357
- // Legacy fallback: evidence.json
2739
+ // Legacy fallback: evidence.json (pre-bundle-era sessions with no trust.bundle at all)
2358
2740
  const e = loadJson(path.join(dir, "evidence.json"), {});
2359
2741
  return e.verdict === "pass" && Array.isArray(e.checks) && e.checks.length > 0 && e.checks.every((c) => {
2360
2742
  if (!(c.status === "pass" || c.status === "skip"))
@@ -2362,13 +2744,23 @@ function evidenceClean(dir, declaredClaimTypes = new Set()) {
2362
2744
  return !Array.isArray(c.standard_refs) || c.standard_refs.every((r) => ["junit", "sarif", "coverage", "veritas"].includes(r.standard));
2363
2745
  });
2364
2746
  }
2365
- function critiqueClean(dir, declaredClaimTypes = new Set()) {
2366
- // Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for legacy sessions.
2367
- // ADR 0016 Step 0: declaredClaimTypes broadens the filter to include kit-typed critique claims
2368
- // (e.g. builder.verify.policy-compliance) in addition to workflow.critique.review.
2747
+ function critiqueClean(dir) {
2748
+ // Phase 4c: read from trust.bundle (sole verification artifact); fall back to critique.json for
2749
+ // legacy (pre-bundle-era) sessions unrelated to origin stamping. When a trust.bundle IS
2750
+ // present, every claim must be stamped (requireStampedClaim); no claimType-derivation fallback
2751
+ // for an unstamped claim (#268/#344).
2369
2752
  const bundle = loadJson(path.join(dir, "trust.bundle"));
2370
2753
  if (Array.isArray(bundle.claims)) {
2371
- const critiqueClaims = bundle.claims.filter((c) => c && (c.claimType === "workflow.critique.review" || declaredClaimTypes.has(c.claimType)));
2754
+ for (const c of bundle.claims)
2755
+ requireStampedClaim(c, dir);
2756
+ const critiqueClaims = bundle.claims.filter((c) => {
2757
+ if (!c)
2758
+ return false;
2759
+ // #267/#282: superseded history is not evaluated for cleanliness.
2760
+ if (c.metadata && typeof c.metadata === "object" && c.metadata.superseded_by)
2761
+ return false;
2762
+ return claimOrigin(c) === "critique";
2763
+ });
2372
2764
  if (critiqueClaims.length === 0)
2373
2765
  return false; // no critique written yet
2374
2766
  return critiqueClaims.every((c) => {
@@ -2410,10 +2802,9 @@ async function dogfoodPass(p) {
2410
2802
  if (checks.some((c) => c.status !== "pass" && c.status !== "skip"))
2411
2803
  die("clean evidence requires all non-skipped checks to pass");
2412
2804
  // Phase 4c: evidence check reads from trust.bundle (sole verification artifact); legacy evidence.json fallback in evidenceClean.
2413
- // ADR 0016 Step 0: pass declaredClaimTypes so builder.* check/critique claims count as clean evidence.
2414
- const _dctDogfood = declaredClaimTypesFor(dir);
2415
- const _hasBundleEvidence = fs.existsSync(path.join(dir, "trust.bundle")) && evidenceClean(dir, _dctDogfood);
2416
- const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir, _dctDogfood);
2805
+ // #268/#344: builder.* check/critique claims count as clean evidence via their authoritative origin stamp.
2806
+ const _hasBundleEvidence = fs.existsSync(path.join(dir, "trust.bundle")) && evidenceClean(dir);
2807
+ const _hasLegacyEvidence = fs.existsSync(path.join(dir, "evidence.json")) && evidenceClean(dir);
2417
2808
  if (!_hasBundleEvidence && !_hasLegacyEvidence && fs.existsSync(path.join(dir, "trust.bundle")))
2418
2809
  die("cannot mark clean without passing evidence");
2419
2810
  if (!_hasBundleEvidence && !_hasLegacyEvidence && !fs.existsSync(path.join(dir, "trust.bundle")) && fs.existsSync(path.join(dir, "evidence.json")))
@@ -2426,10 +2817,10 @@ async function dogfoodPass(p) {
2426
2817
  normalizeFinding(parseJson(value, "--finding-json"));
2427
2818
  if (newCritiqueVerdict !== "pass")
2428
2819
  die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
2429
- if (!opt(p, "critique-id") && !critiqueClean(dir, _dctDogfood))
2820
+ if (!opt(p, "critique-id") && !critiqueClean(dir))
2430
2821
  die("requires passing critique");
2431
2822
  // Phase 4c: if existing state has a dirty critique (in bundle or legacy critique.json), block even when adding a new critique-id.
2432
- if (!critiqueClean(dir, _dctDogfood) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json"))))
2823
+ if (!critiqueClean(dir) && (fs.existsSync(path.join(dir, "trust.bundle")) || fs.existsSync(path.join(dir, "critique.json"))))
2433
2824
  die(opt(p, "release-decision") ? "requires clean critique" : "requires clean critique before recording pass evidence");
2434
2825
  }
2435
2826
  }
@@ -2961,6 +3352,19 @@ function livenessLabel(status) {
2961
3352
  return "superseded";
2962
3353
  return status;
2963
3354
  }
3355
+ /**
3356
+ * Delegate to the shared pure-CJS liveness reader's freshHolders() (scripts/hooks/lib/liveness-read.js),
3357
+ * mirroring the exact loader shape assignment-provider.ts already uses for the same helper (#291
3358
+ * Wave 2 Task 2.1) — so ensure-session's ownership guard computes freshness identically to
3359
+ * `assignment-provider status`'s own join, a single implementation. `readLivenessEvents` above
3360
+ * already loads this same module inline for its own narrower need (just the events reader); this
3361
+ * loader additionally exposes `freshHolders` for the guard's `computeEffectiveState` call.
3362
+ */
3363
+ function loadLivenessReadHelper() {
3364
+ const _req = createRequire(import.meta.url);
3365
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
3366
+ return _req(helperPath);
3367
+ }
2964
3368
  // ─── ADR 0012 lifecycle-driven liveness (default-on; opt-out via FLOW_AGENTS_LIVENESS) ──
2965
3369
  // init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
2966
3370
  // so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.