@kontourai/flow-agents 3.0.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 (131) hide show
  1. package/.github/workflows/ci.yml +8 -0
  2. package/CHANGELOG.md +15 -0
  3. package/CONTEXT.md +67 -1
  4. package/README.md +4 -0
  5. package/build/src/cli/assignment-provider.d.ts +143 -0
  6. package/build/src/cli/assignment-provider.js +106 -49
  7. package/build/src/cli/workflow-sidecar.d.ts +2 -2
  8. package/build/src/cli/workflow-sidecar.js +392 -35
  9. package/build/src/lib/flow-resolver.d.ts +12 -6
  10. package/build/src/lib/flow-resolver.js +30 -14
  11. package/build/src/tools/validate-source-tree.js +2 -1
  12. package/context/contracts/assignment-provider-contract.md +9 -0
  13. package/context/contracts/probe-docs-write-contract.md +187 -0
  14. package/context/scripts/hooks/config-protection.js +14 -1
  15. package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
  16. package/context/scripts/hooks/stop-goal-fit.js +4 -2
  17. package/context/scripts/hooks/workflow-steering.js +42 -0
  18. package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
  19. package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
  20. package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
  21. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  22. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  23. package/docs/adr/0006-typescript-first-source-policy.md +2 -0
  24. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  25. package/docs/adr/0007-skill-audit.md +2 -0
  26. package/docs/adr/0008-kit-operation-boundary.md +2 -0
  27. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
  28. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
  29. package/docs/adr/0011-mcp-posture.md +2 -0
  30. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
  31. package/docs/adr/0013-context-lifecycle.md +2 -0
  32. package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
  33. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
  34. package/docs/adr/0016-three-hard-boundary-model.md +2 -0
  35. package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
  36. package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
  37. package/docs/adr/0019-kit-dependency-ownership.md +2 -0
  38. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
  39. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
  40. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +2 -0
  41. package/docs/adr/README.md +49 -0
  42. package/docs/adr/index.md +34 -0
  43. package/docs/decisions/agent-coordination.md +20 -0
  44. package/docs/decisions/anti-gaming-trust-security.md +20 -0
  45. package/docs/decisions/context-lifecycle.md +18 -0
  46. package/docs/decisions/core-domain-kit-boundary.md +18 -0
  47. package/docs/decisions/flow-flow-agents-boundary.md +18 -0
  48. package/docs/decisions/flow-kit.md +20 -0
  49. package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
  50. package/docs/decisions/graph-knowledge-provider.md +63 -0
  51. package/docs/decisions/hook-core-kit-boundary.md +18 -0
  52. package/docs/decisions/index.md +19 -0
  53. package/docs/decisions/kit-dependency-ownership.md +18 -0
  54. package/docs/decisions/kit-operation-boundary.md +18 -0
  55. package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
  56. package/docs/decisions/kontour-resource-contract.md +18 -0
  57. package/docs/decisions/mcp-posture.md +18 -0
  58. package/docs/decisions/three-hard-boundary-model.md +18 -0
  59. package/docs/decisions/trust-reconcile.md +20 -0
  60. package/docs/decisions/typescript-source-policy.md +48 -0
  61. package/docs/decisions/workflow-enforcement.md +18 -0
  62. package/docs/decisions/workflow-trust-state.md +20 -0
  63. package/docs/fixture-ownership.md +1 -1
  64. package/docs/workflow-usage-guide.md +1 -1
  65. package/evals/ci/run-baseline.sh +4 -0
  66. package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
  67. package/evals/integration/test_current_json_per_actor.sh +516 -0
  68. package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
  69. package/evals/integration/test_gate_lockdown.sh +10 -0
  70. package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
  71. package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
  72. package/evals/run.sh +4 -0
  73. package/evals/static/test_knowledge_providers.sh +13 -4
  74. package/evals/static/test_workflow_skills.sh +15 -2
  75. package/kits/builder/skills/deliver/SKILL.md +17 -0
  76. package/kits/builder/skills/design-probe/SKILL.md +37 -0
  77. package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
  78. package/kits/knowledge/adapters/default-store/index.js +92 -4
  79. package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
  80. package/kits/knowledge/adapters/shared/codec.js +141 -0
  81. package/kits/knowledge/docs/README.md +121 -2
  82. package/kits/knowledge/docs/store-contract.md +112 -4
  83. package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
  84. package/kits/knowledge/flows/promote.flow.json +84 -0
  85. package/kits/knowledge/kit.json +15 -0
  86. package/kits/knowledge/promote/distill.js +96 -0
  87. package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
  88. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
  89. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
  90. package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
  91. package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
  92. package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
  93. package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
  94. package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
  95. package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
  96. package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
  97. package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
  98. package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
  99. package/kits/knowledge/promote/fixtures/session/state.json +19 -0
  100. package/kits/knowledge/promote/health.js +137 -0
  101. package/kits/knowledge/promote/index.js +176 -0
  102. package/kits/knowledge/promote/ingest.js +103 -0
  103. package/kits/knowledge/promote/lib.js +132 -0
  104. package/kits/knowledge/promote/link.js +84 -0
  105. package/kits/knowledge/promote/promote.test.js +174 -0
  106. package/kits/knowledge/providers/conformance/suite.test.js +18 -0
  107. package/kits/knowledge/providers/index.js +1 -0
  108. package/kits/knowledge/providers/neo4j/connection.js +121 -0
  109. package/kits/knowledge/providers/neo4j/cypher.js +190 -0
  110. package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
  111. package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
  112. package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
  113. package/kits/knowledge/providers/neo4j/index.js +280 -0
  114. package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
  115. package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
  116. package/kits/knowledge/providers/neo4j/sync.js +235 -0
  117. package/package.json +4 -2
  118. package/scripts/README.md +1 -0
  119. package/scripts/freeze-adrs.mjs +364 -0
  120. package/scripts/hooks/config-protection.js +14 -1
  121. package/scripts/hooks/evidence-capture.js +4 -1
  122. package/scripts/hooks/lib/config-protection-remedies.js +9 -0
  123. package/scripts/hooks/lib/current-pointer.js +123 -0
  124. package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
  125. package/scripts/hooks/stop-goal-fit.js +4 -2
  126. package/scripts/hooks/workflow-steering.js +42 -0
  127. package/scripts/statusline/flow-agents-statusline.js +3 -1
  128. package/src/cli/assignment-provider.ts +137 -55
  129. package/src/cli/workflow-sidecar.ts +430 -33
  130. package/src/lib/flow-resolver.ts +35 -14
  131. package/src/tools/validate-source-tree.ts +2 -1
@@ -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[] = [];
@@ -760,7 +840,7 @@ export async function buildTrustBundle(slug: string, timestamp: string, checks:
760
840
  * @param criteria Acceptance criteria objects (same as buildTrustBundle)
761
841
  * @param critiques Critique objects (same as buildTrustBundle)
762
842
  */
763
- 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[] }> {
764
844
  try {
765
845
  // Fold the deterministic capture log (PostToolUse evidence-capture) into the
766
846
  // bundle so capture is authoritative over claimed status. Best-effort read.
@@ -772,16 +852,22 @@ export async function writeTrustBundle(dir: string, slug: string, timestamp: str
772
852
  // ADR 0016 Abstraction A (P-d): pass the runtime artifact root ONLY when current.json
773
853
  // points to this session (scoped active-flow guard). If current.json.artifact_dir
774
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.
775
860
  const _flowAgentsDir = path.dirname(dir);
861
+ const _effectiveActorKey = actorKey ?? loadActorIdentityHelper().resolveActor(process.env).actor;
776
862
  let _scopedFlowAgentsDir: string | undefined = undefined;
777
863
  try {
778
- const _currentRaw = JSON.parse(fs.readFileSync(path.join(_flowAgentsDir, "current.json"), "utf8")) as Record<string, unknown>;
779
- 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;
780
866
  if (_artDir && path.resolve(_flowAgentsDir, _artDir) === path.resolve(dir)) {
781
867
  _scopedFlowAgentsDir = _flowAgentsDir;
782
868
  }
783
869
  } catch { /* current.json absent or unreadable — no scoping */ }
784
- const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir);
870
+ const bundle = await buildTrustBundle(slug, timestamp, checks, criteria, critiques, commandLog, _scopedFlowAgentsDir, _effectiveActorKey);
785
871
  if (!bundle) return { written: false, errors: [] }; // Surface unavailable — fail-open, skip write
786
872
  const result = await validateTrustBundle(bundle);
787
873
  if (result.available && !result.valid) {
@@ -1095,12 +1181,43 @@ function resolveFirstStep(flowId: string, repoRoot: string): string | null {
1095
1181
  }
1096
1182
  }
1097
1183
 
1098
- 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 {
1099
1216
  // #289: mirror the active session's already-recorded branch (state.json.branch) into
1100
1217
  // current.json so consumers of current.json (which has no schema of its own — not one of the
1101
1218
  // 9 schemas under schemas/) see the routing branch without re-reading state.json separately.
1102
1219
  const branch = loadJson(path.join(dir, "state.json")).branch;
1103
- writeJson(path.join(root, "current.json"), {
1220
+ const payload: AnyObj = {
1104
1221
  schema_version: "1.0",
1105
1222
  active_slug: path.basename(dir),
1106
1223
  artifact_dir: path.relative(root, dir) || ".",
@@ -1119,15 +1236,35 @@ function writeCurrent(root: string, dir: string, timestamp: string, owner: strin
1119
1236
  // gate-review can distinguish an intentional direct entry (e.g. a planning-only
1120
1237
  // session that skips pull-work) from a stale/mis-stamped active_step_id.
1121
1238
  ...(adHocReason ? { ad_hoc_entry: true, ad_hoc_reason: adHocReason } : {}),
1122
- });
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
+ }
1123
1252
  }
1124
1253
  function loadCurrent(root: string): AnyObj | null {
1125
1254
  const file = path.join(root, "current.json");
1126
1255
  if (!fs.existsSync(file)) return null;
1127
1256
  return JSON.parse(read(file));
1128
1257
  }
1129
- function currentDir(root: string): string | null {
1130
- 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;
1131
1268
  if (!c) return null;
1132
1269
  const dir = path.resolve(root, c.artifact_dir ?? c.active_slug ?? "");
1133
1270
  if (!fs.existsSync(dir)) return null;
@@ -1138,14 +1275,34 @@ function currentDir(root: string): string | null {
1138
1275
  }
1139
1276
  return dir;
1140
1277
  }
1141
- 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
+
1142
1292
  const cur = loadCurrent(root);
1143
- if (!cur || path.resolve(root, cur.artifact_dir ?? "") !== path.resolve(dir)) return;
1144
- const active = Array.isArray(cur.active_agents) ? cur.active_agents.filter((a: AnyObj) => a.agent_id !== agentId) : [];
1145
- if (status === "active" || status === "blocked") active.push({ agent_id: agentId, status, updated_at: timestamp });
1146
- cur.active_agents = active;
1147
- cur.updated_at = timestamp;
1148
- 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
+ }
1149
1306
  }
1150
1307
 
1151
1308
  function initSidecars(dir: string, slug: string, sourceRequest: string, summary: string, nextAction: string, timestamp: string, markdown?: string): void {
@@ -1195,10 +1352,201 @@ function initSidecars(dir: string, slug: string, sourceRequest: string, summary:
1195
1352
  });
1196
1353
  }
1197
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
+ */
1198
1540
  function ensureSession(p: ReturnType<typeof parseArgs>): number {
1199
1541
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : flowAgentsArtifactRoot();
1200
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)"));
1201
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);
1202
1550
  fs.mkdirSync(dir, { recursive: true });
1203
1551
  const timestamp = opt(p, "timestamp", now());
1204
1552
  let md = fs.existsSync(path.join(dir, `${slug}--deliver.md`)) ? read(path.join(dir, `${slug}--deliver.md`)) : "";
@@ -1241,14 +1589,19 @@ function ensureSession(p: ReturnType<typeof parseArgs>): number {
1241
1589
  adHocReason = opt(p, "ad-hoc-reason") || `direct entry at step "${explicitStep}" via --step-id (flow first step is "${firstStep}")`;
1242
1590
  }
1243
1591
  }
1244
- 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);
1245
1593
  console.log(dir);
1246
1594
  return 0;
1247
1595
  }
1248
1596
 
1249
1597
  function current(p: ReturnType<typeof parseArgs>): number {
1250
1598
  const root = opt(p, "artifact-root") ? path.resolve(opt(p, "artifact-root")) : defaultArtifactRootForRead();
1251
- 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);
1252
1605
  if (!dir) die("no current workflow session is recorded");
1253
1606
  const format = opt(p, "format", "path");
1254
1607
  console.log(format === "slug" ? path.basename(dir) : dir);
@@ -1259,7 +1612,8 @@ function recordAgentEvent(p: ReturnType<typeof parseArgs>): number {
1259
1612
  const hasExplicitRoot = !!opt(p, "artifact-root");
1260
1613
  const root = explicitArtifactRoot(p);
1261
1614
  const explicit = opt(p, "artifact-dir");
1262
- const dir = explicit ? path.resolve(explicit) : currentDir(root);
1615
+ const actorKey = resolveReadActorKey(p);
1616
+ const dir = explicit ? path.resolve(explicit) : currentDir(root, actorKey);
1263
1617
  if (!dir || !fs.existsSync(dir)) die("artifact directory does not exist");
1264
1618
  if (explicit && fs.lstatSync(dir).isSymbolicLink()) die(`artifact directory must not be a symlink: ${dir}`);
1265
1619
  if (hasExplicitRoot || !explicit) requireArtifactDirUnderRoot(dir, root);
@@ -1267,7 +1621,7 @@ function recordAgentEvent(p: ReturnType<typeof parseArgs>): number {
1267
1621
  const agent = validateAgentId(opt(p, "agent-id"));
1268
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") } : {}) };
1269
1623
  appendJsonl(path.join(dir, "agents", agent, "events.jsonl"), event);
1270
- updateCurrentAgent(root, dir, agent, event.status, timestamp);
1624
+ updateCurrentAgent(root, dir, agent, event.status, timestamp, actorKey);
1271
1625
  return 0;
1272
1626
  }
1273
1627
 
@@ -1649,9 +2003,14 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
1649
2003
  const summary = opt(p, "summary") || die("--summary is required");
1650
2004
  const expectationId = opt(p, "expectation");
1651
2005
 
1652
- // 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.
1653
2011
  const flowAgentsDir = path.dirname(dir);
1654
- const activeStep = resolveActiveFlowStep(flowAgentsDir);
2012
+ const gateClaimActorKey = resolveReadActorKey(p);
2013
+ const activeStep = resolveActiveFlowStep(flowAgentsDir, gateClaimActorKey);
1655
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)");
1656
2015
 
1657
2016
  const expects = activeStep.gateExpects;
@@ -1699,7 +2058,7 @@ async function recordGateClaim(p: ReturnType<typeof parseArgs>): Promise<number>
1699
2058
  if (gateWaiver) checkNormalized._waiver = gateWaiver;
1700
2059
  // Log the targeted gate expectation for transparency (goes to stderr only)
1701
2060
  process.stderr.write(`[record-gate-claim] targeting ${activeStep.stepId}/${activeStep.gateId}/${targetExpectation.id} → claimType=${claimType} subjectType=${subjectType}${gateWaiver ? " (WAIVED accepted_gap)" : ""}\n`);
1702
- assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], []));
2061
+ assertBundleWritten(await writeTrustBundle(dir, slug, ts, [checkNormalized], [], [], gateClaimActorKey));
1703
2062
  return 0;
1704
2063
  }
1705
2064
 
@@ -1835,7 +2194,11 @@ async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
1835
2194
  const phaseMap = resolvePhaseMap(flow, repoRoot);
1836
2195
  const stepId = phaseMap?.[phase] ?? undefined;
1837
2196
  if (stepId) {
1838
- 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));
1839
2202
  }
1840
2203
  }
1841
2204
  livenessLifecycle(dir, slug, LIVENESS_TERMINAL.has(status) ? "release" : "heartbeat", timestamp);
@@ -2943,6 +3306,26 @@ function livenessLabel(status: string): string {
2943
3306
  return status;
2944
3307
  }
2945
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
+
2946
3329
  // ─── ADR 0012 lifecycle-driven liveness (default-on; opt-out via FLOW_AGENTS_LIVENESS) ──
2947
3330
  // init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
2948
3331
  // so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
@@ -2958,6 +3341,16 @@ function loadActorIdentityHelper(): {
2958
3341
  resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
2959
3342
  sanitizeSegment: (value: unknown) => string;
2960
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;
2961
3354
  } {
2962
3355
  const _req = createRequire(import.meta.url);
2963
3356
  const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
@@ -2965,6 +3358,10 @@ function loadActorIdentityHelper(): {
2965
3358
  resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
2966
3359
  sanitizeSegment: (value: unknown) => string;
2967
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;
2968
3365
  };
2969
3366
  }
2970
3367
  function resolveLivenessActor(): string {