@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
@@ -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,19 +526,39 @@ 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
 
542
+ function claimLocalFile(argv: string[]): number {
543
+ const args = parseArgs(argv);
544
+ const provider = flagString(args.flags, "provider", "local-file");
545
+ if (provider !== "local-file") throw new Error(`claim: --provider must be local-file (use render-claim for github); got ${provider}`);
546
+ const artifactRoot = requireFlag(args, "artifact-root");
547
+ const subjectId = requireFlag(args, "subject-id");
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
+
518
562
  function releaseLocalFile(argv: string[]): number {
519
563
  const args = parseArgs(argv);
520
564
  const provider = flagString(args.flags, "provider", "local-file");
@@ -540,23 +584,28 @@ function releaseLocalFile(argv: string[]): number {
540
584
  });
541
585
  }
542
586
 
543
- function supersedeLocalFile(argv: string[]): number {
544
- const args = parseArgs(argv);
545
- 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}`);
547
- const artifactRoot = requireFlag(args, "artifact-root");
548
- const subjectId = requireFlag(args, "subject-id");
587
+ /**
588
+ * Wave 1 (#291) extraction: the durable-write body previously inlined inside supersedeLocalFile's
589
+ * withSubjectLock() closure, now a parameter-driven pure function so ensure-session's
590
+ * `--supersede-stale` takeover path (workflow-sidecar.ts, Wave 2) can reuse the EXACT same
591
+ * supersede logic — from-actor holder verification, ttl/branch/artifact_dir carry-forward,
592
+ * audit-trail append, atomic write under withSubjectLock — rather than reimplementing a second,
593
+ * parallel supersede path. supersedeLocalFile (CLI wrapper, below) is now a thin
594
+ * parse-args/print-envelope shell around this.
595
+ */
596
+ export function performLocalSupersede(
597
+ artifactRoot: string,
598
+ subjectId: string,
599
+ fromActor: ActorStruct,
600
+ toActor: ActorStruct,
601
+ opts: { ttlSeconds?: number; branch?: string; artifactDir?: string; reason?: string; actorKey?: string } = {},
602
+ ): AssignmentClaimRecord {
549
603
  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");
604
+ const reason = opts.reason ?? "supersede";
556
605
 
557
606
  // F1 fix (fix-plan iteration 1, CRITICAL): supersede mutates the same record file claim/release
558
607
  // do, under the same per-subject lock (see withSubjectLock()'s doc comment).
559
- return withSubjectLock(artifactRoot, subjectId, (): number => {
608
+ return withSubjectLock(artifactRoot, subjectId, (): AssignmentClaimRecord => {
560
609
  const existing = readLocalRecord(artifactRoot, subjectId);
561
610
  if (!existing || existing.status !== "claimed") throw new Error(`no active claim to supersede for subject: ${subjectId}`);
562
611
 
@@ -564,7 +613,7 @@ function supersedeLocalFile(argv: string[]): number {
564
613
  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
614
  }
566
615
 
567
- const ttlSecondsRaw = ttlSecondsOverride ?? String(existing.ttl_seconds);
616
+ const ttlSecondsRaw = opts.ttlSeconds != null ? String(opts.ttlSeconds) : String(existing.ttl_seconds);
568
617
  const ttlSeconds = Number(ttlSecondsRaw);
569
618
  if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) throw new Error(`--ttl-seconds must be a positive number; got ${ttlSecondsRaw}`);
570
619
 
@@ -573,19 +622,42 @@ function supersedeLocalFile(argv: string[]): number {
573
622
  role: "AssignmentClaimRecord",
574
623
  subject_id: subjectId,
575
624
  actor: toActor,
625
+ ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
576
626
  claimed_at: isoNow(),
577
627
  ttl_seconds: ttlSeconds,
578
- branch: branchOverride ?? existing.branch,
579
- artifact_dir: artifactDirOverride ?? existing.artifact_dir,
628
+ branch: opts.branch ?? existing.branch,
629
+ artifact_dir: opts.artifactDir ?? existing.artifact_dir,
580
630
  status: "claimed",
581
631
  audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "supersede", from_actor: fromActor, to_actor: toActor, reason }],
582
632
  };
583
633
  writeLocalRecord(artifactRoot, subjectId, record);
584
- console.log(JSON.stringify({ role: "AssignmentSupersedeResult", subject_id: subjectId, record }, null, 2));
585
- return 0;
634
+ return record;
586
635
  });
587
636
  }
588
637
 
638
+ function supersedeLocalFile(argv: string[]): number {
639
+ const args = parseArgs(argv);
640
+ const provider = flagString(args.flags, "provider", "local-file");
641
+ if (provider !== "local-file") throw new Error(`supersede: --provider must be local-file (use render-supersede for github); got ${provider}`);
642
+ const artifactRoot = requireFlag(args, "artifact-root");
643
+ const subjectId = requireFlag(args, "subject-id");
644
+ const fromActor = loadActorStructFromFile(requireFlag(args, "from-actor-json"));
645
+ const toActor = loadActorStructFromFile(requireFlag(args, "to-actor-json"));
646
+ const reason = flagString(args.flags, "reason") ?? "supersede";
647
+ const ttlSecondsOverride = flagString(args.flags, "ttl-seconds");
648
+ const branchOverride = flagString(args.flags, "branch");
649
+ const artifactDirOverride = flagString(args.flags, "artifact-dir");
650
+
651
+ const record = performLocalSupersede(artifactRoot, subjectId, fromActor, toActor, {
652
+ ttlSeconds: ttlSecondsOverride != null ? Number(ttlSecondsOverride) : undefined,
653
+ branch: branchOverride ?? undefined,
654
+ artifactDir: artifactDirOverride ?? undefined,
655
+ reason,
656
+ });
657
+ console.log(JSON.stringify({ role: "AssignmentSupersedeResult", subject_id: subjectId, record }, null, 2));
658
+ return 0;
659
+ }
660
+
589
661
  // ─── GitHub: render-claim | render-release | render-supersede (render, don't execute — Design
590
662
  // Decision 1). Pure functions: no I/O beyond reading --input-json/--actor-json. Never invoke
591
663
  // `gh` (or any process) here — the calling skill runs the emitted argv verbatim. ────────────────
@@ -745,6 +817,18 @@ function renderSupersede(argv: string[]): number {
745
817
 
746
818
  // ─── status | list (both provider kinds) ────────────────────────────────────────────────────
747
819
 
820
+ /**
821
+ * Wave 1 (#291) extraction: the local-file branch of statusCommand's assignment-layer read,
822
+ * mirrored exactly so ensure-session's ownership guard (workflow-sidecar.ts, Wave 2) derives an
823
+ * AssignmentStatus identically to the `assignment-provider status` CLI command — a single
824
+ * implementation, not a second parallel local-file read.
825
+ */
826
+ export function readLocalAssignmentStatus(artifactRoot: string, subjectId: string): AssignmentStatus {
827
+ const record = readLocalRecord(artifactRoot, subjectId);
828
+ const active = record && record.status === "claimed" ? record : null;
829
+ return { subject_id: subjectId, provider: "local-file", assignee: active ? loadActorIdentityHelper().serializeActor(active.actor) : null, record: active };
830
+ }
831
+
748
832
  function statusCommand(argv: string[]): number {
749
833
  const args = parseArgs(argv);
750
834
  const provider = requireFlag(args, "provider");
@@ -754,9 +838,7 @@ function statusCommand(argv: string[]): number {
754
838
  if (provider === "local-file") {
755
839
  const artifactRoot = requireFlag(args, "artifact-root");
756
840
  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 };
841
+ assignment = readLocalAssignmentStatus(artifactRoot, requestedSubjectId);
760
842
  } else if (provider === "github") {
761
843
  const issueJsonPath = requireFlag(args, "issue-json");
762
844
  const issue = loadJsonInput(issueJsonPath) as GithubIssueDoc;