@kontourai/flow-agents 3.0.0 → 3.2.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 (158) hide show
  1. package/.github/workflows/ci.yml +12 -0
  2. package/CHANGELOG.md +32 -0
  3. package/CONTEXT.md +67 -1
  4. package/README.md +4 -0
  5. package/build/src/cli/assignment-provider.d.ts +188 -0
  6. package/build/src/cli/assignment-provider.js +193 -51
  7. package/build/src/cli/workflow-sidecar.d.ts +16 -6
  8. package/build/src/cli/workflow-sidecar.js +492 -45
  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 +10 -1
  13. package/context/contracts/execution-contract.md +78 -0
  14. package/context/contracts/probe-docs-write-contract.md +187 -0
  15. package/context/scripts/hooks/config-protection.js +24 -4
  16. package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
  17. package/context/scripts/hooks/stop-goal-fit.js +262 -5
  18. package/context/scripts/hooks/workflow-steering.js +42 -0
  19. package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
  20. package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
  21. package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
  22. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  23. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  24. package/docs/adr/0006-typescript-first-source-policy.md +2 -0
  25. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  26. package/docs/adr/0007-skill-audit.md +2 -0
  27. package/docs/adr/0008-kit-operation-boundary.md +2 -0
  28. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
  29. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
  30. package/docs/adr/0011-mcp-posture.md +2 -0
  31. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
  32. package/docs/adr/0013-context-lifecycle.md +2 -0
  33. package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
  34. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
  35. package/docs/adr/0016-three-hard-boundary-model.md +2 -0
  36. package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
  37. package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
  38. package/docs/adr/0019-kit-dependency-ownership.md +2 -0
  39. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
  40. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
  41. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +113 -0
  42. package/docs/adr/README.md +49 -0
  43. package/docs/adr/index.md +34 -0
  44. package/docs/decisions/agent-coordination.md +20 -0
  45. package/docs/decisions/anti-gaming-trust-security.md +20 -0
  46. package/docs/decisions/context-lifecycle.md +18 -0
  47. package/docs/decisions/core-domain-kit-boundary.md +18 -0
  48. package/docs/decisions/flow-flow-agents-boundary.md +18 -0
  49. package/docs/decisions/flow-kit.md +20 -0
  50. package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
  51. package/docs/decisions/graph-knowledge-provider.md +63 -0
  52. package/docs/decisions/hook-core-kit-boundary.md +18 -0
  53. package/docs/decisions/index.md +19 -0
  54. package/docs/decisions/kit-dependency-ownership.md +18 -0
  55. package/docs/decisions/kit-operation-boundary.md +18 -0
  56. package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
  57. package/docs/decisions/kontour-resource-contract.md +18 -0
  58. package/docs/decisions/mcp-posture.md +18 -0
  59. package/docs/decisions/three-hard-boundary-model.md +18 -0
  60. package/docs/decisions/trust-reconcile.md +20 -0
  61. package/docs/decisions/typescript-source-policy.md +48 -0
  62. package/docs/decisions/workflow-enforcement.md +18 -0
  63. package/docs/decisions/workflow-trust-state.md +20 -0
  64. package/docs/fixture-ownership.md +1 -1
  65. package/docs/workflow-usage-guide.md +1 -1
  66. package/evals/ci/run-baseline.sh +6 -0
  67. package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
  68. package/evals/integration/test_checkpoint_signing.sh +4 -3
  69. package/evals/integration/test_current_json_per_actor.sh +516 -0
  70. package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
  71. package/evals/integration/test_gate_lockdown.sh +46 -0
  72. package/evals/integration/test_model_routing_escalation.sh +145 -0
  73. package/evals/integration/test_publish_delivery.sh +14 -6
  74. package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
  75. package/evals/integration/test_stop_hook_release.sh +552 -0
  76. package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
  77. package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
  78. package/evals/run.sh +10 -0
  79. package/evals/static/test_knowledge_providers.sh +13 -4
  80. package/evals/static/test_model_routing_hints.sh +107 -0
  81. package/evals/static/test_workflow_skills.sh +15 -2
  82. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  83. package/kits/builder/skills/deliver/SKILL.md +69 -11
  84. package/kits/builder/skills/design-probe/SKILL.md +47 -0
  85. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  86. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  87. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  88. package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
  89. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  90. package/kits/builder/skills/pull-work/SKILL.md +10 -0
  91. package/kits/builder/skills/review-work/SKILL.md +11 -0
  92. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  93. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  94. package/kits/knowledge/adapters/default-store/index.js +147 -18
  95. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  96. package/kits/knowledge/adapters/obsidian-store/index.js +97 -14
  97. package/kits/knowledge/adapters/shared/codec.js +265 -0
  98. package/kits/knowledge/docs/README.md +121 -2
  99. package/kits/knowledge/docs/store-contract.md +517 -7
  100. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  101. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  102. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  103. package/kits/knowledge/evals/contract-suite/suite.test.js +212 -0
  104. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  105. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  106. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  107. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  108. package/kits/knowledge/flows/promote.flow.json +84 -0
  109. package/kits/knowledge/kit.json +15 -0
  110. package/kits/knowledge/promote/distill.js +96 -0
  111. package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
  112. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
  113. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
  114. package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
  115. package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
  116. package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
  117. package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
  118. package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
  119. package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
  120. package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
  121. package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
  122. package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
  123. package/kits/knowledge/promote/fixtures/session/state.json +19 -0
  124. package/kits/knowledge/promote/health.js +137 -0
  125. package/kits/knowledge/promote/index.js +176 -0
  126. package/kits/knowledge/promote/ingest.js +103 -0
  127. package/kits/knowledge/promote/lib.js +132 -0
  128. package/kits/knowledge/promote/link.js +84 -0
  129. package/kits/knowledge/promote/promote.test.js +174 -0
  130. package/kits/knowledge/providers/conformance/suite.test.js +18 -0
  131. package/kits/knowledge/providers/index.js +1 -0
  132. package/kits/knowledge/providers/neo4j/connection.js +121 -0
  133. package/kits/knowledge/providers/neo4j/cypher.js +190 -0
  134. package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
  135. package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
  136. package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
  137. package/kits/knowledge/providers/neo4j/index.js +280 -0
  138. package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
  139. package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
  140. package/kits/knowledge/providers/neo4j/sync.js +235 -0
  141. package/package.json +4 -2
  142. package/schemas/workflow-handoff.schema.json +6 -0
  143. package/scripts/README.md +1 -0
  144. package/scripts/ci/mint-attestation.js +33 -6
  145. package/scripts/ci/trust-reconcile.js +144 -26
  146. package/scripts/freeze-adrs.mjs +364 -0
  147. package/scripts/hooks/config-protection.js +24 -4
  148. package/scripts/hooks/evidence-capture.js +4 -1
  149. package/scripts/hooks/lib/config-protection-remedies.js +9 -0
  150. package/scripts/hooks/lib/current-pointer.js +123 -0
  151. package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
  152. package/scripts/hooks/stop-goal-fit.js +262 -5
  153. package/scripts/hooks/workflow-steering.js +42 -0
  154. package/scripts/statusline/flow-agents-statusline.js +3 -1
  155. package/src/cli/assignment-provider.ts +239 -59
  156. package/src/cli/workflow-sidecar.ts +529 -43
  157. package/src/lib/flow-resolver.ts +35 -14
  158. package/src/tools/validate-source-tree.ts +2 -1
@@ -24,7 +24,7 @@ import { readJson, writeJson, isoNow } from "../lib/fs.js";
24
24
 
25
25
  type AnyObj = Record<string, unknown>;
26
26
 
27
- type ActorStruct = {
27
+ export type ActorStruct = {
28
28
  runtime: string;
29
29
  session_id: string;
30
30
  host: string;
@@ -45,12 +45,21 @@ type AssignmentAuditEntry = {
45
45
  * The versioned claim-record shape from the contract doc's "Versioned claim-record format"
46
46
  * section (Design Decision 2). `schema_version` is bumped only on an incompatible change, per
47
47
  * artifact-contract.md's existing sidecar rule.
48
+ *
49
+ * `actor_key` (F1 fix, fix-plan iteration 1, HIGH — additive field, schema_version unchanged):
50
+ * the canonical `resolveActor(env).actor` string for the claiming actor — the SAME flat/bare
51
+ * token every other tool (`liveness whoami`, `liveness claim --actor`, per-actor current.json,
52
+ * pull-work's `--self-actor`) already uses. Optional so every pre-fix record and every #290 eval
53
+ * fixture with no `actor_key` still parses; `computeEffectiveState` falls back to
54
+ * `serializeActor(record.actor)` (today's behavior) whenever it's absent. Present, it is the ONLY
55
+ * correct self-recognition/liveness-join key — see computeEffectiveState's holderActorKey.
48
56
  */
49
- type AssignmentClaimRecord = {
57
+ export type AssignmentClaimRecord = {
50
58
  schema_version: "1.0";
51
59
  role: "AssignmentClaimRecord";
52
60
  subject_id: string;
53
61
  actor: ActorStruct;
62
+ actor_key?: string;
54
63
  claimed_at: string;
55
64
  ttl_seconds: number;
56
65
  branch: string;
@@ -59,12 +68,12 @@ type AssignmentClaimRecord = {
59
68
  audit_trail?: AssignmentAuditEntry[];
60
69
  };
61
70
 
62
- type FreshHolder = { actor: string; lastAt: string; ttlSeconds: number; fresh: boolean };
71
+ export type FreshHolder = { actor: string; lastAt: string; ttlSeconds: number; fresh: boolean };
63
72
 
64
- type EffectiveState = "held" | "reclaimable" | "human-held" | "free";
73
+ export type EffectiveState = "held" | "reclaimable" | "human-held" | "free";
65
74
 
66
75
  /** Provider-neutral assignment-layer read, before any liveness join (contract doc's status()). */
67
- type AssignmentStatus = {
76
+ export type AssignmentStatus = {
68
77
  subject_id: string;
69
78
  provider: "local-file" | "github";
70
79
  assignee: string | null;
@@ -176,22 +185,29 @@ function loadActorStructFromFile(file: string): ActorStruct {
176
185
  * fixture-friendly path (used by evals and any caller that already knows its own struct);
177
186
  * when omitted, auto-derive from the live environment via the shared resolver, mirroring
178
187
  * (never forking) the exact struct fields serializeActor() already defines.
188
+ *
189
+ * F1 fix (fix-plan iteration 1, HIGH): also returns `actorKey` — set to the canonical
190
+ * `resolveActor(env).actor` string ONLY on the auto-derive path (pull-work's real path, and any
191
+ * other caller with no --actor-json), so a claim made via `assignment-provider claim` and one
192
+ * made via ensure-session share the same canonical key. `--actor-json` explicit fixtures leave
193
+ * `actorKey` unset — `performLocalClaim`/`performLocalSupersede` then fall back to
194
+ * `serializeActor(actor)` for the record's `actor_key`, preserving existing fixture behavior.
179
195
  */
180
- function loadActorStruct(args: ParsedArgs): ActorStruct {
196
+ function loadActorStruct(args: ParsedArgs): { actor: ActorStruct; actorKey?: string } {
181
197
  const actorJsonPath = flagString(args.flags, "actor-json");
182
- if (actorJsonPath) return loadActorStructFromFile(actorJsonPath);
198
+ if (actorJsonPath) return { actor: loadActorStructFromFile(actorJsonPath) };
183
199
  const helper = loadActorIdentityHelper();
184
200
  const resolved = helper.resolveActor(process.env);
185
201
  if (helper.isUnresolvedActor(resolved.actor)) throw new Error("could not resolve an actor identity (no --actor-json and no resolvable environment actor); pass --actor-json explicitly");
186
- return { runtime: helper.detectRuntime(process.env), session_id: resolved.actor, host: os.hostname(), human: null };
202
+ return { actor: { runtime: helper.detectRuntime(process.env), session_id: resolved.actor, host: os.hostname(), human: null }, actorKey: resolved.actor };
187
203
  }
188
204
 
189
- function assignmentFilePath(artifactRoot: string, subjectId: string): string {
205
+ export function assignmentFilePath(artifactRoot: string, subjectId: string): string {
190
206
  const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
191
207
  return path.join(artifactRoot, "assignment", `${sanitized}.json`);
192
208
  }
193
209
 
194
- function readLocalRecord(artifactRoot: string, subjectId: string): AssignmentClaimRecord | null {
210
+ export function readLocalRecord(artifactRoot: string, subjectId: string): AssignmentClaimRecord | null {
195
211
  const file = assignmentFilePath(artifactRoot, subjectId);
196
212
  if (!fs.existsSync(file)) return null;
197
213
  let data: unknown;
@@ -208,7 +224,7 @@ function readLocalRecord(artifactRoot: string, subjectId: string): AssignmentCla
208
224
  return record;
209
225
  }
210
226
 
211
- function writeLocalRecord(artifactRoot: string, subjectId: string, record: AssignmentClaimRecord): void {
227
+ export function writeLocalRecord(artifactRoot: string, subjectId: string, record: AssignmentClaimRecord): void {
212
228
  // writeJson throws on any mkdir/writeFileSync failure; that error is intentionally allowed to
213
229
  // propagate to main()'s top-level try/catch and exit non-zero. Durable writes must fail loud,
214
230
  // never fail open (artifact-contract.md).
@@ -254,7 +270,7 @@ function subjectLockDir(artifactRoot: string, subjectId: string): string {
254
270
  * the write) of all three local-file mutators in this, since all three mutate the same record
255
271
  * file for a given subject.
256
272
  */
257
- function withSubjectLock<T>(artifactRoot: string, subjectId: string, body: () => T): T {
273
+ export function withSubjectLock<T>(artifactRoot: string, subjectId: string, body: () => T): T {
258
274
  const lockDir = subjectLockDir(artifactRoot, subjectId);
259
275
  const staleMs = Number(process.env.FLOW_AGENTS_ASSIGNMENT_STALE_LOCK_MS ?? 5 * 60 * 1000);
260
276
  const deadline = Date.now() + 30000;
@@ -305,7 +321,7 @@ function withSubjectLock<T>(artifactRoot: string, subjectId: string, body: () =>
305
321
  * through here too means `--now` deterministically governs idle_days as well as liveness
306
322
  * freshness, rather than idle_days silently reading the real wall clock regardless of `--now`.
307
323
  */
308
- function computeEffectiveState(assignment: AssignmentStatus, freshHoldersList: FreshHolder[], selfActor: string | undefined, nowMs: number): {
324
+ export function computeEffectiveState(assignment: AssignmentStatus, freshHoldersList: FreshHolder[], selfActor: string | undefined, nowMs: number): {
309
325
  effective_state: EffectiveState;
310
326
  reason: string;
311
327
  holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string };
@@ -339,7 +355,14 @@ function computeEffectiveState(assignment: AssignmentStatus, freshHoldersList: F
339
355
  return { effective_state: "human-held", reason: "assignee_without_claim_record", holder: { assignee: assignment.assignee } };
340
356
  }
341
357
 
342
- const holderActorKey = loadActorIdentityHelper().serializeActor(record.actor);
358
+ // F1 fix (fix-plan iteration 1, HIGH): prefer the canonical actor_key over re-serializing
359
+ // record.actor. record.actor_key IS resolveActor(env).actor for records written by the fixed
360
+ // performLocalClaim/performLocalSupersede paths (below) — the same flat/bare string liveness
361
+ // whoami, `liveness claim --actor`, per-actor current.json, and pull-work's --self-actor all
362
+ // use — so for an explicit-override actor the self-check and the liveness join now agree with
363
+ // every other tool. BACK-COMPAT: records with no actor_key (every pre-fix record, every #290
364
+ // eval fixture) fall back to serializeActor(record.actor) exactly as before this fix.
365
+ const holderActorKey = record.actor_key || loadActorIdentityHelper().serializeActor(record.actor);
343
366
  if (selfActor && holderActorKey === selfActor) return { effective_state: "held", reason: "self_is_holder", holder: { actor: holderActorKey } };
344
367
 
345
368
  const fresh = freshHoldersList.find((holder) => holder.actor === holderActorKey);
@@ -466,26 +489,27 @@ function loadLivenessInputs(args: ParsedArgs): { events: AnyObj[] | null; selfAc
466
489
  // ─── local-file: claim | release | supersede (the durable-write path; real I/O by design — no
467
490
  // external mutation to defer to a skill for this provider kind, per Design Decision 1) ─────────
468
491
 
469
- function claimLocalFile(argv: string[]): number {
470
- const args = parseArgs(argv);
471
- const provider = flagString(args.flags, "provider", "local-file");
472
- if (provider !== "local-file") throw new Error(`claim: --provider must be local-file (use render-claim for github); got ${provider}`);
473
- const artifactRoot = requireFlag(args, "artifact-root");
474
- const subjectId = requireFlag(args, "subject-id");
475
- const actor = loadActorStruct(args);
492
+ /**
493
+ * Wave 1 (#291) extraction: the durable-write body previously inlined inside claimLocalFile's
494
+ * withSubjectLock() closure, now a parameter-driven pure function so ensure-session's ownership
495
+ * guard (workflow-sidecar.ts, Wave 2) can reuse the EXACT same claim logic same-actor idempotent
496
+ * refresh, different-actor throw, atomic write under withSubjectLock — rather than reimplementing
497
+ * a second, parallel claim path. claimLocalFile (CLI wrapper, below) is now a thin
498
+ * parse-args/print-envelope shell around this.
499
+ */
500
+ export function performLocalClaim(
501
+ artifactRoot: string,
502
+ subjectId: string,
503
+ actor: ActorStruct,
504
+ opts: { ttlSeconds: number; branch: string; artifactDir: string; reason?: string; actorKey?: string },
505
+ ): AssignmentClaimRecord {
476
506
  const helper = loadActorIdentityHelper();
477
-
478
- const ttlSecondsRaw = flagString(args.flags, "ttl-seconds", "1800") ?? "1800";
479
- const ttlSeconds = Number(ttlSecondsRaw);
480
- if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) throw new Error(`--ttl-seconds must be a positive number; got ${ttlSecondsRaw}`);
481
- const branch = requireFlag(args, "branch");
482
- const artifactDir = requireFlag(args, "artifact-dir");
483
- const reason = flagString(args.flags, "reason") ?? "claim";
507
+ const reason = opts.reason ?? "claim";
484
508
 
485
509
  // F1 fix (fix-plan iteration 1, CRITICAL): the existing-claim check AND the write must happen
486
510
  // atomically with respect to any other `assignment-provider` invocation on the same subject —
487
511
  // see withSubjectLock()'s doc comment for the full rationale.
488
- return withSubjectLock(artifactRoot, subjectId, (): number => {
512
+ return withSubjectLock(artifactRoot, subjectId, (): AssignmentClaimRecord => {
489
513
  const existing = readLocalRecord(artifactRoot, subjectId);
490
514
  if (existing && existing.status === "claimed") {
491
515
  const existingActorKey = helper.serializeActor(existing.actor);
@@ -502,61 +526,184 @@ function claimLocalFile(argv: string[]): number {
502
526
  role: "AssignmentClaimRecord",
503
527
  subject_id: subjectId,
504
528
  actor,
529
+ ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
505
530
  claimed_at: isoNow(),
506
- ttl_seconds: ttlSeconds,
507
- branch,
508
- artifact_dir: artifactDir,
531
+ ttl_seconds: opts.ttlSeconds,
532
+ branch: opts.branch,
533
+ artifact_dir: opts.artifactDir,
509
534
  status: "claimed",
510
535
  audit_trail: [...(existing?.audit_trail ?? []), { at: isoNow(), transition: "claim", from_actor: null, to_actor: actor, reason }],
511
536
  };
512
537
  writeLocalRecord(artifactRoot, subjectId, record);
513
- console.log(JSON.stringify({ role: "AssignmentClaimResult", subject_id: subjectId, record }, null, 2));
514
- return 0;
538
+ return record;
515
539
  });
516
540
  }
517
541
 
518
- function releaseLocalFile(argv: string[]): number {
542
+ function claimLocalFile(argv: string[]): number {
519
543
  const args = parseArgs(argv);
520
544
  const provider = flagString(args.flags, "provider", "local-file");
521
- if (provider !== "local-file") throw new Error(`release: --provider must be local-file (use render-release for github); got ${provider}`);
545
+ if (provider !== "local-file") throw new Error(`claim: --provider must be local-file (use render-claim for github); got ${provider}`);
522
546
  const artifactRoot = requireFlag(args, "artifact-root");
523
547
  const subjectId = requireFlag(args, "subject-id");
524
- const releasedBy = flagString(args.flags, "actor-json") ? loadActorStructFromFile(requireFlag(args, "actor-json")) : null;
525
- const reason = flagString(args.flags, "reason") ?? "released";
548
+ const { actor, actorKey } = loadActorStruct(args);
549
+
550
+ const ttlSecondsRaw = flagString(args.flags, "ttl-seconds", "1800") ?? "1800";
551
+ const ttlSeconds = Number(ttlSecondsRaw);
552
+ if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) throw new Error(`--ttl-seconds must be a positive number; got ${ttlSecondsRaw}`);
553
+ const branch = requireFlag(args, "branch");
554
+ const artifactDir = requireFlag(args, "artifact-dir");
555
+ const reason = flagString(args.flags, "reason") ?? "claim";
556
+
557
+ const record = performLocalClaim(artifactRoot, subjectId, actor, { ttlSeconds, branch, artifactDir, reason, actorKey });
558
+ console.log(JSON.stringify({ role: "AssignmentClaimResult", subject_id: subjectId, record }, null, 2));
559
+ return 0;
560
+ }
561
+
562
+ /**
563
+ * Wave 1 (#292) extraction: the durable-write body previously inlined inside releaseLocalFile's
564
+ * withSubjectLock() closure, now a parameter-driven pure function so the Stop hook's non-terminal
565
+ * release lifecycle (scripts/hooks/stop-goal-fit.js, #292 Wave 2) can reuse the EXACT same release
566
+ * logic — actor-ownership verification, audit-trail append, atomic write under withSubjectLock —
567
+ * rather than reimplementing a second, parallel release path. releaseLocalFile (CLI wrapper,
568
+ * below) is now a thin parse-args/print-envelope shell around this, mirroring the
569
+ * performLocalSupersede/supersedeLocalFile extraction shape exactly.
570
+ *
571
+ * Two behaviors are deliberately DIFFERENT from a naive inline release, both required for the
572
+ * Stop hook's idempotent, actor-scoped lifecycle release (never for the interactive CLI, which
573
+ * keeps `tolerateNoActiveClaim` unset/false and therefore 100% of its prior throw-on-error shape):
574
+ *
575
+ * - `opts.tolerateNoActiveClaim === true` and there is no existing record, or the existing
576
+ * record's status is not `"claimed"`: return `null` (a tolerated no-op) instead of throwing
577
+ * "no active claim to release". This is the one deliberate idempotency change vs today's
578
+ * releaseLocalFile — a second release call (e.g. a double Stop event) must be a safe no-op.
579
+ * - `releasedBy` is provided and does not match the existing record's holder: never force-release
580
+ * a claim held by a different actor — return `null` (if tolerateNoActiveClaim) or throw
581
+ * (otherwise), same as the no-active-claim case. The comparison mirrors computeEffectiveState()'s
582
+ * `record.actor_key || helper.serializeActor(record.actor)` canonical-key preference EXACTLY
583
+ * (actor_key-first, falling back to serializeActor only when actor_key is absent) — the read
584
+ * path (status/effective-state) and this write path (release) must use the identical
585
+ * canonical-key comparison, or a claim written under an explicit-override actor (`actor_key`
586
+ * bare, e.g. `"canonical-x"`, but `serializeActor(record.actor)` a DIFFERENT triple, e.g.
587
+ * `"explicit-override:canonical-x:host"`) can be self-recognized by computeEffectiveState() yet
588
+ * fail to release here because the releaser's canonical key was compared against the wrong
589
+ * (re-derived, triple) form instead of the stored actor_key. Comparing two serializeActor()
590
+ * calls unconditionally — as a prior version of this function did — is NOT correct for override
591
+ * actors and reintroduces the exact #291 seam on the release path.
592
+ *
593
+ * Contract: when `releasedBy` is provided AND the existing record is `actor_key`-stamped,
594
+ * `opts.actorKey` is REQUIRED (the canonical `resolveActor(env).actor` string) — otherwise
595
+ * ownership cannot be verified. A caller that passes `releasedBy` without `opts.actorKey` against
596
+ * an `actor_key`-stamped record would have its ownership compared as
597
+ * `existing.actor_key` (bare canonical) vs `serializeActor(releasedBy)` (re-derived triple), which
598
+ * can NEVER match even for the legitimate holder — a silent-failure trap, not a real ownership
599
+ * check. This is refused loudly (see the guard at the top of the `releasedBy` branch below) rather
600
+ * than allowed to silently no-op or wrongly refuse.
601
+ */
602
+ export function performLocalRelease(
603
+ artifactRoot: string,
604
+ subjectId: string,
605
+ releasedBy: ActorStruct | null,
606
+ opts: { reason?: string; actorKey?: string; tolerateNoActiveClaim?: boolean } = {},
607
+ ): AssignmentClaimRecord | null {
608
+ const helper = loadActorIdentityHelper();
609
+ const reason = opts.reason ?? "released";
610
+ const tolerateNoActiveClaim = opts.tolerateNoActiveClaim ?? false;
526
611
 
527
612
  // F1 fix (fix-plan iteration 1, CRITICAL): release mutates the same record file claim/supersede
528
613
  // do, under the same per-subject lock (see withSubjectLock()'s doc comment).
529
- return withSubjectLock(artifactRoot, subjectId, (): number => {
614
+ return withSubjectLock(artifactRoot, subjectId, (): AssignmentClaimRecord | null => {
530
615
  const existing = readLocalRecord(artifactRoot, subjectId);
531
- if (!existing || existing.status !== "claimed") throw new Error(`no active claim to release for subject: ${subjectId}`);
616
+ if (!existing || existing.status !== "claimed") {
617
+ if (tolerateNoActiveClaim) return null;
618
+ throw new Error(`no active claim to release for subject: ${subjectId}`);
619
+ }
620
+
621
+ if (releasedBy) {
622
+ // Contract guard (hardening fix, #292 review): a caller that supplies `releasedBy` but NOT
623
+ // `opts.actorKey` against a record that already carries `actor_key` cannot reliably prove
624
+ // ownership — see this function's doc comment. This is the ONLY combination that fires: it
625
+ // does NOT fire when `existing.actor_key` is absent (the CLI/fixture path, where both sides
626
+ // fall back to serializeActor() and legitimately compare equal). Fail loudly rather than
627
+ // silently no-op (tolerant callers) or wrongly refuse (throwing callers) — never silent.
628
+ if (!opts.actorKey && existing.actor_key) {
629
+ if (tolerateNoActiveClaim) {
630
+ console.error(
631
+ `[performLocalRelease] cannot verify ownership of an actor_key-stamped record without opts.actorKey; skipping release for ${subjectId}`,
632
+ );
633
+ return null;
634
+ }
635
+ throw new Error(
636
+ "performLocalRelease: pass opts.actorKey (the canonical resolveActor().actor string) when releasedBy is set and the record carries actor_key — serializeActor(releasedBy) is not a valid ownership key for actor_key-stamped records",
637
+ );
638
+ }
639
+
640
+ // AC6: never force-release a claim held by a different actor. Mirrors
641
+ // computeEffectiveState()'s canonical self-recognition comparison EXACTLY —
642
+ // `holderActorKey` prefers the stored `actor_key` (the canonical resolveActor(env).actor
643
+ // string, present on records written by the fixed performLocalClaim/performLocalSupersede
644
+ // paths) and only falls back to `serializeActor(existing.actor)` when `actor_key` is
645
+ // absent (every pre-fix record, every #290 eval fixture). The releaser's side must use the
646
+ // SAME canonical form: `opts.actorKey` (the caller's resolveActor(env).actor string, e.g.
647
+ // scripts/hooks/stop-goal-fit.js's Stop hook) when provided, else re-derived via
648
+ // serializeActor(releasedBy) — never serializeActor() unconditionally on both sides, which
649
+ // would compare the bare actor_key form against a re-derived triple form for an
650
+ // explicit-override actor and spuriously reject a legitimate same-actor release (the #291
651
+ // seam, relocated to this write path).
652
+ const holderActorKey = existing.actor_key || helper.serializeActor(existing.actor);
653
+ const releasedByActorKey = opts.actorKey || helper.serializeActor(releasedBy);
654
+ if (holderActorKey !== releasedByActorKey) {
655
+ if (tolerateNoActiveClaim) return null;
656
+ throw new Error(`--actor-json does not match the current holder (${holderActorKey}); refusing to release a claim held by someone else`);
657
+ }
658
+ }
659
+
532
660
  const record: AssignmentClaimRecord = {
533
661
  ...existing,
662
+ ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
534
663
  status: "released",
535
664
  audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "release", from_actor: existing.actor, to_actor: releasedBy, reason }],
536
665
  };
537
666
  writeLocalRecord(artifactRoot, subjectId, record);
538
- console.log(JSON.stringify({ role: "AssignmentReleaseResult", subject_id: subjectId, record }, null, 2));
539
- return 0;
667
+ return record;
540
668
  });
541
669
  }
542
670
 
543
- function supersedeLocalFile(argv: string[]): number {
671
+ function releaseLocalFile(argv: string[]): number {
544
672
  const args = parseArgs(argv);
545
673
  const provider = flagString(args.flags, "provider", "local-file");
546
- if (provider !== "local-file") throw new Error(`supersede: --provider must be local-file (use render-supersede for github); got ${provider}`);
674
+ if (provider !== "local-file") throw new Error(`release: --provider must be local-file (use render-release for github); got ${provider}`);
547
675
  const artifactRoot = requireFlag(args, "artifact-root");
548
676
  const subjectId = requireFlag(args, "subject-id");
677
+ const releasedBy = flagString(args.flags, "actor-json") ? loadActorStructFromFile(requireFlag(args, "actor-json")) : null;
678
+ const reason = flagString(args.flags, "reason") ?? "released";
679
+
680
+ const record = performLocalRelease(artifactRoot, subjectId, releasedBy, { reason, tolerateNoActiveClaim: false });
681
+ console.log(JSON.stringify({ role: "AssignmentReleaseResult", subject_id: subjectId, record }, null, 2));
682
+ return 0;
683
+ }
684
+
685
+ /**
686
+ * Wave 1 (#291) extraction: the durable-write body previously inlined inside supersedeLocalFile's
687
+ * withSubjectLock() closure, now a parameter-driven pure function so ensure-session's
688
+ * `--supersede-stale` takeover path (workflow-sidecar.ts, Wave 2) can reuse the EXACT same
689
+ * supersede logic — from-actor holder verification, ttl/branch/artifact_dir carry-forward,
690
+ * audit-trail append, atomic write under withSubjectLock — rather than reimplementing a second,
691
+ * parallel supersede path. supersedeLocalFile (CLI wrapper, below) is now a thin
692
+ * parse-args/print-envelope shell around this.
693
+ */
694
+ export function performLocalSupersede(
695
+ artifactRoot: string,
696
+ subjectId: string,
697
+ fromActor: ActorStruct,
698
+ toActor: ActorStruct,
699
+ opts: { ttlSeconds?: number; branch?: string; artifactDir?: string; reason?: string; actorKey?: string } = {},
700
+ ): AssignmentClaimRecord {
549
701
  const helper = loadActorIdentityHelper();
550
- const fromActor = loadActorStructFromFile(requireFlag(args, "from-actor-json"));
551
- const toActor = loadActorStructFromFile(requireFlag(args, "to-actor-json"));
552
- const reason = flagString(args.flags, "reason") ?? "supersede";
553
- const ttlSecondsOverride = flagString(args.flags, "ttl-seconds");
554
- const branchOverride = flagString(args.flags, "branch");
555
- const artifactDirOverride = flagString(args.flags, "artifact-dir");
702
+ const reason = opts.reason ?? "supersede";
556
703
 
557
704
  // F1 fix (fix-plan iteration 1, CRITICAL): supersede mutates the same record file claim/release
558
705
  // do, under the same per-subject lock (see withSubjectLock()'s doc comment).
559
- return withSubjectLock(artifactRoot, subjectId, (): number => {
706
+ return withSubjectLock(artifactRoot, subjectId, (): AssignmentClaimRecord => {
560
707
  const existing = readLocalRecord(artifactRoot, subjectId);
561
708
  if (!existing || existing.status !== "claimed") throw new Error(`no active claim to supersede for subject: ${subjectId}`);
562
709
 
@@ -564,7 +711,7 @@ function supersedeLocalFile(argv: string[]): number {
564
711
  throw new Error(`--from-actor-json does not match the current holder (${helper.serializeActor(existing.actor)}); refusing to supersede a claim held by someone else`);
565
712
  }
566
713
 
567
- const ttlSecondsRaw = ttlSecondsOverride ?? String(existing.ttl_seconds);
714
+ const ttlSecondsRaw = opts.ttlSeconds != null ? String(opts.ttlSeconds) : String(existing.ttl_seconds);
568
715
  const ttlSeconds = Number(ttlSecondsRaw);
569
716
  if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) throw new Error(`--ttl-seconds must be a positive number; got ${ttlSecondsRaw}`);
570
717
 
@@ -573,17 +720,40 @@ function supersedeLocalFile(argv: string[]): number {
573
720
  role: "AssignmentClaimRecord",
574
721
  subject_id: subjectId,
575
722
  actor: toActor,
723
+ ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
576
724
  claimed_at: isoNow(),
577
725
  ttl_seconds: ttlSeconds,
578
- branch: branchOverride ?? existing.branch,
579
- artifact_dir: artifactDirOverride ?? existing.artifact_dir,
726
+ branch: opts.branch ?? existing.branch,
727
+ artifact_dir: opts.artifactDir ?? existing.artifact_dir,
580
728
  status: "claimed",
581
729
  audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "supersede", from_actor: fromActor, to_actor: toActor, reason }],
582
730
  };
583
731
  writeLocalRecord(artifactRoot, subjectId, record);
584
- console.log(JSON.stringify({ role: "AssignmentSupersedeResult", subject_id: subjectId, record }, null, 2));
585
- return 0;
732
+ return record;
733
+ });
734
+ }
735
+
736
+ function supersedeLocalFile(argv: string[]): number {
737
+ const args = parseArgs(argv);
738
+ const provider = flagString(args.flags, "provider", "local-file");
739
+ if (provider !== "local-file") throw new Error(`supersede: --provider must be local-file (use render-supersede for github); got ${provider}`);
740
+ const artifactRoot = requireFlag(args, "artifact-root");
741
+ const subjectId = requireFlag(args, "subject-id");
742
+ const fromActor = loadActorStructFromFile(requireFlag(args, "from-actor-json"));
743
+ const toActor = loadActorStructFromFile(requireFlag(args, "to-actor-json"));
744
+ const reason = flagString(args.flags, "reason") ?? "supersede";
745
+ const ttlSecondsOverride = flagString(args.flags, "ttl-seconds");
746
+ const branchOverride = flagString(args.flags, "branch");
747
+ const artifactDirOverride = flagString(args.flags, "artifact-dir");
748
+
749
+ const record = performLocalSupersede(artifactRoot, subjectId, fromActor, toActor, {
750
+ ttlSeconds: ttlSecondsOverride != null ? Number(ttlSecondsOverride) : undefined,
751
+ branch: branchOverride ?? undefined,
752
+ artifactDir: artifactDirOverride ?? undefined,
753
+ reason,
586
754
  });
755
+ console.log(JSON.stringify({ role: "AssignmentSupersedeResult", subject_id: subjectId, record }, null, 2));
756
+ return 0;
587
757
  }
588
758
 
589
759
  // ─── GitHub: render-claim | render-release | render-supersede (render, don't execute — Design
@@ -745,6 +915,18 @@ function renderSupersede(argv: string[]): number {
745
915
 
746
916
  // ─── status | list (both provider kinds) ────────────────────────────────────────────────────
747
917
 
918
+ /**
919
+ * Wave 1 (#291) extraction: the local-file branch of statusCommand's assignment-layer read,
920
+ * mirrored exactly so ensure-session's ownership guard (workflow-sidecar.ts, Wave 2) derives an
921
+ * AssignmentStatus identically to the `assignment-provider status` CLI command — a single
922
+ * implementation, not a second parallel local-file read.
923
+ */
924
+ export function readLocalAssignmentStatus(artifactRoot: string, subjectId: string): AssignmentStatus {
925
+ const record = readLocalRecord(artifactRoot, subjectId);
926
+ const active = record && record.status === "claimed" ? record : null;
927
+ return { subject_id: subjectId, provider: "local-file", assignee: active ? loadActorIdentityHelper().serializeActor(active.actor) : null, record: active };
928
+ }
929
+
748
930
  function statusCommand(argv: string[]): number {
749
931
  const args = parseArgs(argv);
750
932
  const provider = requireFlag(args, "provider");
@@ -754,9 +936,7 @@ function statusCommand(argv: string[]): number {
754
936
  if (provider === "local-file") {
755
937
  const artifactRoot = requireFlag(args, "artifact-root");
756
938
  if (!requestedSubjectId) throw new Error("--subject-id is required for status --provider local-file");
757
- const record = readLocalRecord(artifactRoot, requestedSubjectId);
758
- const active = record && record.status === "claimed" ? record : null;
759
- assignment = { subject_id: requestedSubjectId, provider: "local-file", assignee: active ? loadActorIdentityHelper().serializeActor(active.actor) : null, record: active };
939
+ assignment = readLocalAssignmentStatus(artifactRoot, requestedSubjectId);
760
940
  } else if (provider === "github") {
761
941
  const issueJsonPath = requireFlag(args, "issue-json");
762
942
  const issue = loadJsonInput(issueJsonPath) as GithubIssueDoc;