@kontourai/flow-agents 2.4.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/.github/CODEOWNERS +8 -0
  2. package/.github/workflows/ci.yml +20 -0
  3. package/.github/workflows/trust-reconcile.yml +62 -4
  4. package/CHANGELOG.md +38 -0
  5. package/CONTEXT.md +88 -1
  6. package/README.md +4 -0
  7. package/build/src/cli/assignment-provider.d.ts +144 -0
  8. package/build/src/cli/assignment-provider.js +805 -0
  9. package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
  10. package/build/src/cli/effective-assignment-provider-settings.js +125 -0
  11. package/build/src/cli/validate-workflow-artifacts.js +5 -1
  12. package/build/src/cli/workflow-sidecar.d.ts +2 -2
  13. package/build/src/cli/workflow-sidecar.js +549 -145
  14. package/build/src/cli.js +6 -0
  15. package/build/src/lib/flow-resolver.d.ts +12 -6
  16. package/build/src/lib/flow-resolver.js +30 -14
  17. package/build/src/tools/validate-source-tree.js +2 -0
  18. package/context/contracts/artifact-contract.md +2 -0
  19. package/context/contracts/assignment-provider-contract.md +248 -0
  20. package/context/contracts/builder-kit-workflow-state-contract.md +2 -0
  21. package/context/contracts/decision-registry-contract.md +2 -0
  22. package/context/contracts/delivery-contract.md +2 -0
  23. package/context/contracts/execution-contract.md +25 -0
  24. package/context/contracts/governance-adapter-contract.md +2 -0
  25. package/context/contracts/knowledge-store-contract.md +197 -0
  26. package/context/contracts/planning-contract.md +2 -0
  27. package/context/contracts/probe-docs-write-contract.md +187 -0
  28. package/context/contracts/review-contract.md +2 -0
  29. package/context/contracts/sandbox-policy.md +2 -0
  30. package/context/contracts/standing-directives.md +13 -0
  31. package/context/contracts/verification-contract.md +2 -0
  32. package/context/contracts/work-item-contract.md +2 -0
  33. package/context/scripts/hooks/config-protection.js +14 -1
  34. package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
  35. package/context/scripts/hooks/stop-goal-fit.js +4 -2
  36. package/context/scripts/hooks/workflow-steering.js +42 -0
  37. package/context/settings/assignment-provider-settings.json +33 -0
  38. package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
  39. package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
  40. package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
  41. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  42. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  43. package/docs/adr/0006-typescript-first-source-policy.md +2 -0
  44. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  45. package/docs/adr/0007-skill-audit.md +2 -0
  46. package/docs/adr/0008-kit-operation-boundary.md +2 -0
  47. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
  48. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
  49. package/docs/adr/0011-mcp-posture.md +2 -0
  50. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
  51. package/docs/adr/0013-context-lifecycle.md +2 -0
  52. package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
  53. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
  54. package/docs/adr/0016-three-hard-boundary-model.md +2 -0
  55. package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
  56. package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
  57. package/docs/adr/0019-kit-dependency-ownership.md +2 -0
  58. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
  59. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
  60. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +182 -0
  61. package/docs/adr/README.md +49 -0
  62. package/docs/adr/index.md +34 -0
  63. package/docs/context-map.md +1 -0
  64. package/docs/decisions/agent-coordination.md +20 -0
  65. package/docs/decisions/anti-gaming-trust-security.md +20 -0
  66. package/docs/decisions/context-lifecycle.md +18 -0
  67. package/docs/decisions/core-domain-kit-boundary.md +18 -0
  68. package/docs/decisions/flow-flow-agents-boundary.md +18 -0
  69. package/docs/decisions/flow-kit.md +20 -0
  70. package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
  71. package/docs/decisions/graph-knowledge-provider.md +63 -0
  72. package/docs/decisions/hook-core-kit-boundary.md +18 -0
  73. package/docs/decisions/index.md +22 -0
  74. package/docs/decisions/kit-dependency-ownership.md +18 -0
  75. package/docs/decisions/kit-operation-boundary.md +18 -0
  76. package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
  77. package/docs/decisions/knowledge-store-provider.md +51 -0
  78. package/docs/decisions/kontour-resource-contract.md +18 -0
  79. package/docs/decisions/mcp-posture.md +18 -0
  80. package/docs/decisions/model-routing.md +63 -0
  81. package/docs/decisions/standing-directives.md +66 -0
  82. package/docs/decisions/three-hard-boundary-model.md +18 -0
  83. package/docs/decisions/trust-reconcile.md +20 -0
  84. package/docs/decisions/typescript-source-policy.md +48 -0
  85. package/docs/decisions/workflow-enforcement.md +18 -0
  86. package/docs/decisions/workflow-trust-state.md +20 -0
  87. package/docs/fixture-ownership.md +1 -0
  88. package/docs/workflow-shared-contracts.md +2 -1
  89. package/docs/workflow-usage-guide.md +7 -1
  90. package/evals/ci/run-baseline.sh +10 -0
  91. package/evals/fixtures/assignment-provider/actor-a.json +6 -0
  92. package/evals/fixtures/assignment-provider/actor-b.json +6 -0
  93. package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
  94. package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
  95. package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
  96. package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
  97. package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
  98. package/evals/integration/test_assignment_provider_github.sh +318 -0
  99. package/evals/integration/test_assignment_provider_local_file.sh +222 -0
  100. package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
  101. package/evals/integration/test_current_json_per_actor.sh +516 -0
  102. package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
  103. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  104. package/evals/integration/test_gate_lockdown.sh +10 -0
  105. package/evals/integration/test_publish_delivery.sh +21 -4
  106. package/evals/integration/test_pull_work_assignment_join.sh +132 -0
  107. package/evals/integration/test_pull_work_liveness_preflight.sh +20 -7
  108. package/evals/integration/test_reconcile_soundness.sh +33 -9
  109. package/evals/integration/test_trust_reconcile.sh +9 -8
  110. package/evals/integration/test_trust_reconcile_negatives.sh +608 -0
  111. package/evals/integration/test_workflow_sidecar_writer.sh +91 -3
  112. package/evals/run.sh +14 -0
  113. package/evals/static/test_flowdef_codeowners_coverage.sh +6 -0
  114. package/evals/static/test_knowledge_providers.sh +32 -0
  115. package/evals/static/test_workflow_skills.sh +15 -2
  116. package/kits/builder/skills/deliver/SKILL.md +36 -0
  117. package/kits/builder/skills/design-probe/SKILL.md +37 -0
  118. package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
  119. package/kits/builder/skills/pull-work/SKILL.md +78 -10
  120. package/kits/knowledge/adapters/default-store/index.js +92 -4
  121. package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
  122. package/kits/knowledge/adapters/shared/codec.js +141 -0
  123. package/kits/knowledge/docs/README.md +121 -2
  124. package/kits/knowledge/docs/store-contract.md +112 -4
  125. package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
  126. package/kits/knowledge/flows/promote.flow.json +84 -0
  127. package/kits/knowledge/kit.json +50 -0
  128. package/kits/knowledge/promote/distill.js +96 -0
  129. package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
  130. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
  131. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
  132. package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
  133. package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
  134. package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
  135. package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
  136. package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
  137. package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
  138. package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
  139. package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
  140. package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
  141. package/kits/knowledge/promote/fixtures/session/state.json +19 -0
  142. package/kits/knowledge/promote/health.js +137 -0
  143. package/kits/knowledge/promote/index.js +176 -0
  144. package/kits/knowledge/promote/ingest.js +103 -0
  145. package/kits/knowledge/promote/lib.js +132 -0
  146. package/kits/knowledge/promote/link.js +84 -0
  147. package/kits/knowledge/promote/promote.test.js +174 -0
  148. package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
  149. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
  150. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
  151. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
  152. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
  153. package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
  154. package/kits/knowledge/providers/conformance/suite.test.js +143 -0
  155. package/kits/knowledge/providers/git-repo/index.js +236 -0
  156. package/kits/knowledge/providers/health/health-pass.test.js +99 -0
  157. package/kits/knowledge/providers/health/index.js +153 -0
  158. package/kits/knowledge/providers/index.js +25 -0
  159. package/kits/knowledge/providers/lib/model.js +91 -0
  160. package/kits/knowledge/providers/lib/schema-validate.js +119 -0
  161. package/kits/knowledge/providers/markdown-vault/index.js +169 -0
  162. package/kits/knowledge/providers/neo4j/connection.js +121 -0
  163. package/kits/knowledge/providers/neo4j/cypher.js +190 -0
  164. package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
  165. package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
  166. package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
  167. package/kits/knowledge/providers/neo4j/index.js +280 -0
  168. package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
  169. package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
  170. package/kits/knowledge/providers/neo4j/sync.js +235 -0
  171. package/kits/knowledge/providers/work-item/index.js +204 -0
  172. package/package.json +7 -3
  173. package/schemas/assignment-provider-settings.schema.json +125 -0
  174. package/schemas/knowledge/edge.schema.json +54 -0
  175. package/schemas/knowledge/health-report.schema.json +45 -0
  176. package/schemas/knowledge/node.schema.json +49 -0
  177. package/schemas/knowledge/proposal.schema.json +53 -0
  178. package/scripts/README.md +1 -0
  179. package/scripts/ci/trust-reconcile.js +521 -24
  180. package/scripts/freeze-adrs.mjs +364 -0
  181. package/scripts/hooks/config-protection.js +14 -1
  182. package/scripts/hooks/evidence-capture.js +4 -1
  183. package/scripts/hooks/lib/config-protection-remedies.js +9 -0
  184. package/scripts/hooks/lib/current-pointer.js +123 -0
  185. package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
  186. package/scripts/hooks/stop-goal-fit.js +4 -2
  187. package/scripts/hooks/workflow-steering.js +42 -0
  188. package/scripts/statusline/flow-agents-statusline.js +3 -1
  189. package/src/cli/assignment-provider.ts +927 -0
  190. package/src/cli/effective-assignment-provider-settings.ts +112 -0
  191. package/src/cli/validate-workflow-artifacts.ts +5 -1
  192. package/src/cli/workflow-sidecar.ts +577 -139
  193. package/src/cli.ts +6 -0
  194. package/src/lib/flow-resolver.ts +35 -14
  195. package/src/tools/validate-source-tree.ts +2 -0
@@ -0,0 +1,927 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { createRequire } from "node:module";
5
+ import { fileURLToPath } from "node:url";
6
+ import { parseArgs, flagString, type ParsedArgs } from "../lib/args.js";
7
+ import { readJson, writeJson, isoNow } from "../lib/fs.js";
8
+
9
+ // ─── AssignmentProvider CLI (#290) ──────────────────────────────────────────
10
+ // context/contracts/assignment-provider-contract.md is the governing vocabulary doc for this
11
+ // module. Read it first if the shapes below are unclear — it documents the five operations, the
12
+ // assignment ⋈ liveness join table, the lazy-correction transition table, and the versioned
13
+ // claim-record format this file implements.
14
+ //
15
+ // Three distinct "claim" concepts exist in this repo (see the contract doc's terminology
16
+ // callout): this file implements the *assignment* claim only — never the ADR 0012 *liveness*
17
+ // claim (workflow-sidecar.ts `liveness claim` / `freshHolders`) or the Hachure *trust* claim
18
+ // (workflow-sidecar.ts `claim <id> <dir>` / `claimLookup`). Always qualify "claim" in prose here.
19
+ //
20
+ // GitHub mutation path is render-don't-execute (Design Decision 1): every `render-*` subcommand
21
+ // is a pure function — no I/O beyond reading its `--input-json`/`--actor-json` inputs — that
22
+ // emits the exact `gh` argv the calling skill must run verbatim via its Bash tool. This file must
23
+ // never itself shell out to `gh` (no execFileSync/spawn/exec to `gh` anywhere below).
24
+
25
+ type AnyObj = Record<string, unknown>;
26
+
27
+ export type ActorStruct = {
28
+ runtime: string;
29
+ session_id: string;
30
+ host: string;
31
+ human?: string | null;
32
+ };
33
+
34
+ type AssignmentClaimRecordStatus = "claimed" | "released" | "superseded";
35
+
36
+ type AssignmentAuditEntry = {
37
+ at: string;
38
+ transition: "claim" | "release" | "supersede";
39
+ from_actor?: ActorStruct | null;
40
+ to_actor?: ActorStruct | null;
41
+ reason?: string;
42
+ };
43
+
44
+ /**
45
+ * The versioned claim-record shape from the contract doc's "Versioned claim-record format"
46
+ * section (Design Decision 2). `schema_version` is bumped only on an incompatible change, per
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.
56
+ */
57
+ export type AssignmentClaimRecord = {
58
+ schema_version: "1.0";
59
+ role: "AssignmentClaimRecord";
60
+ subject_id: string;
61
+ actor: ActorStruct;
62
+ actor_key?: string;
63
+ claimed_at: string;
64
+ ttl_seconds: number;
65
+ branch: string;
66
+ artifact_dir: string;
67
+ status: AssignmentClaimRecordStatus;
68
+ audit_trail?: AssignmentAuditEntry[];
69
+ };
70
+
71
+ export type FreshHolder = { actor: string; lastAt: string; ttlSeconds: number; fresh: boolean };
72
+
73
+ export type EffectiveState = "held" | "reclaimable" | "human-held" | "free";
74
+
75
+ /** Provider-neutral assignment-layer read, before any liveness join (contract doc's status()). */
76
+ export type AssignmentStatus = {
77
+ subject_id: string;
78
+ provider: "local-file" | "github";
79
+ assignee: string | null;
80
+ record: AssignmentClaimRecord | null;
81
+ has_claim_label?: boolean;
82
+ };
83
+
84
+ type GithubIssueDoc = {
85
+ number?: number;
86
+ assignees?: Array<{ login?: string } | string>;
87
+ labels?: Array<{ name?: string } | string>;
88
+ comments?: Array<{ id?: number; body?: string }>;
89
+ state?: string;
90
+ };
91
+
92
+ type RenderClaimInput = {
93
+ repo?: { owner?: string; name?: string };
94
+ issue_number?: number;
95
+ assignee_login?: string;
96
+ existing_assignee_login?: string;
97
+ label_name?: string;
98
+ claim_comment_marker?: string;
99
+ ttl_seconds?: number;
100
+ branch?: string;
101
+ artifact_dir?: string;
102
+ existing_comment_id?: number;
103
+ previous_record?: AssignmentClaimRecord;
104
+ reason?: string;
105
+ };
106
+
107
+ const DEFAULT_LABEL_NAME = "agent:claimed";
108
+ const CLAIM_COMMENT_MARKER_DEFAULT = "<!-- flow-agents:assignment-claim -->";
109
+
110
+ /**
111
+ * Delegate to the shared pure-CJS resolver (scripts/hooks/lib/actor-identity.js), mirroring the
112
+ * exact createRequire pattern `workflow-sidecar.ts`'s loadActorIdentityHelper() already uses for
113
+ * this module. Deliberately NO inline duplicate fallback — same rationale as that function: if
114
+ * the module fails to load, that failure must surface loudly, never silently degrade to a forked
115
+ * actor concept or a second sanitizer.
116
+ */
117
+ function loadActorIdentityHelper(): {
118
+ resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
119
+ serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
120
+ isUnresolvedActor: (actor: string) => boolean;
121
+ sanitizeSegment: (value: unknown) => string;
122
+ detectRuntime: (env: NodeJS.ProcessEnv) => string;
123
+ } {
124
+ const _req = createRequire(import.meta.url);
125
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
126
+ return _req(helperPath) as {
127
+ resolveActor: (env: NodeJS.ProcessEnv) => { actor: string; source: string };
128
+ serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
129
+ isUnresolvedActor: (actor: string) => boolean;
130
+ sanitizeSegment: (value: unknown) => string;
131
+ detectRuntime: (env: NodeJS.ProcessEnv) => string;
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Delegate to the shared pure-CJS liveness reader (scripts/hooks/lib/liveness-read.js), same
137
+ * createRequire idiom as loadActorIdentityHelper() above. Used only for the join computation —
138
+ * this module never writes liveness events (that stays the ADR 0012 lifecycle's job).
139
+ */
140
+ function loadLivenessReadHelper(): {
141
+ readLivenessEvents: (streamPath: string) => AnyObj[];
142
+ freshHolders: (events: AnyObj[], slug: string, selfActor: string, nowMs: number) => FreshHolder[];
143
+ } {
144
+ const _req = createRequire(import.meta.url);
145
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
146
+ return _req(helperPath) as {
147
+ readLivenessEvents: (streamPath: string) => AnyObj[];
148
+ freshHolders: (events: AnyObj[], slug: string, selfActor: string, nowMs: number) => FreshHolder[];
149
+ };
150
+ }
151
+
152
+ function loadJsonInput(file: string): unknown {
153
+ return file === "-" ? JSON.parse(fs.readFileSync(0, "utf8")) : readJson(file);
154
+ }
155
+
156
+ function requireFlag(args: ParsedArgs, name: string): string {
157
+ const value = flagString(args.flags, name);
158
+ if (!value) throw new Error(`--${name} is required`);
159
+ return value;
160
+ }
161
+
162
+ /**
163
+ * Build an ActorStruct from an already-loaded JSON value (used for --actor-json,
164
+ * --from-actor-json, --to-actor-json). Fails loud on a malformed/incomplete struct — a
165
+ * durable claim record must never carry a partial actor identity.
166
+ */
167
+ function actorStructFromJson(data: unknown, sourceLabel: string): ActorStruct {
168
+ if (typeof data !== "object" || data === null) throw new Error(`${sourceLabel} must contain an object`);
169
+ const struct = data as Partial<ActorStruct>;
170
+ if (!struct.runtime || !struct.session_id || !struct.host) throw new Error(`${sourceLabel} must include runtime, session_id, and host`);
171
+ return {
172
+ runtime: String(struct.runtime),
173
+ session_id: String(struct.session_id),
174
+ host: String(struct.host),
175
+ human: struct.human != null && String(struct.human).trim() !== "" ? String(struct.human) : null,
176
+ };
177
+ }
178
+
179
+ function loadActorStructFromFile(file: string): ActorStruct {
180
+ return actorStructFromJson(loadJsonInput(file), `actor JSON (${file})`);
181
+ }
182
+
183
+ /**
184
+ * Resolve the acting actor for a local-file mutation: --actor-json is the deterministic,
185
+ * fixture-friendly path (used by evals and any caller that already knows its own struct);
186
+ * when omitted, auto-derive from the live environment via the shared resolver, mirroring
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.
195
+ */
196
+ function loadActorStruct(args: ParsedArgs): { actor: ActorStruct; actorKey?: string } {
197
+ const actorJsonPath = flagString(args.flags, "actor-json");
198
+ if (actorJsonPath) return { actor: loadActorStructFromFile(actorJsonPath) };
199
+ const helper = loadActorIdentityHelper();
200
+ const resolved = helper.resolveActor(process.env);
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");
202
+ return { actor: { runtime: helper.detectRuntime(process.env), session_id: resolved.actor, host: os.hostname(), human: null }, actorKey: resolved.actor };
203
+ }
204
+
205
+ export function assignmentFilePath(artifactRoot: string, subjectId: string): string {
206
+ const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
207
+ return path.join(artifactRoot, "assignment", `${sanitized}.json`);
208
+ }
209
+
210
+ export function readLocalRecord(artifactRoot: string, subjectId: string): AssignmentClaimRecord | null {
211
+ const file = assignmentFilePath(artifactRoot, subjectId);
212
+ if (!fs.existsSync(file)) return null;
213
+ let data: unknown;
214
+ try {
215
+ data = readJson(file);
216
+ } catch (error) {
217
+ // Fail loud: a corrupt claim record must never be silently treated as "no claim" — that
218
+ // would be a fail-open path that could let a second claim silently overwrite a real one.
219
+ throw new Error(`assignment record is corrupt, refusing to proceed: ${file}: ${(error as Error).message}`);
220
+ }
221
+ if (typeof data !== "object" || data === null) throw new Error(`assignment record is not an object: ${file}`);
222
+ const record = data as AssignmentClaimRecord;
223
+ if (record.schema_version !== "1.0") throw new Error(`${file}: unsupported schema_version ${String((record as AnyObj).schema_version)}`);
224
+ return record;
225
+ }
226
+
227
+ export function writeLocalRecord(artifactRoot: string, subjectId: string, record: AssignmentClaimRecord): void {
228
+ // writeJson throws on any mkdir/writeFileSync failure; that error is intentionally allowed to
229
+ // propagate to main()'s top-level try/catch and exit non-zero. Durable writes must fail loud,
230
+ // never fail open (artifact-contract.md).
231
+ writeJson(assignmentFilePath(artifactRoot, subjectId), record);
232
+ }
233
+
234
+ /**
235
+ * Synchronous busy-sleep via Atomics.wait on a throwaway SharedArrayBuffer — Node.js (unlike
236
+ * browser engines) permits Atomics.wait on the main thread, so this gives withSubjectLock() a
237
+ * true blocking sleep without going async. Kept to a small, bounded delay (see withSubjectLock's
238
+ * spin loop) — never used outside the lock-acquire spin below.
239
+ */
240
+ function sleepSync(ms: number): void {
241
+ const sab = new SharedArrayBuffer(4);
242
+ const ia = new Int32Array(sab);
243
+ Atomics.wait(ia, 0, 0, ms);
244
+ }
245
+
246
+ function subjectLockDir(artifactRoot: string, subjectId: string): string {
247
+ const assignmentDir = path.join(artifactRoot, "assignment");
248
+ fs.mkdirSync(assignmentDir, { recursive: true });
249
+ const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
250
+ return path.join(assignmentDir, `.${sanitized}.lockdir`);
251
+ }
252
+
253
+ /**
254
+ * F1 fix (fix-plan iteration 1, CRITICAL): claimLocalFile/releaseLocalFile/supersedeLocalFile were
255
+ * a plain read -> compare-actor -> write with no lock, so two concurrently-launched OS processes
256
+ * could both read "no conflicting claim" before either wrote, and the second write would silently
257
+ * clobber the first with zero error and zero audit-trail entry for the loser (reproduced 29/40
258
+ * races against the built CLI). This mirrors the EXACT mechanism `withLock` already uses in
259
+ * workflow-sidecar.ts:908 for the same class of shared-state mutation — atomic `fs.mkdirSync`
260
+ * lockdir create as the mutual-exclusion primitive, EEXIST-spin with a staleness-reclaim check
261
+ * (a lock directory older than the stale threshold is presumed abandoned by a crashed process and
262
+ * is reclaimed rather than waited on forever) and a bounded deadline, `finally` rmSync release —
263
+ * as a small LOCAL helper (not a cross-import of that private function, which would pull the
264
+ * entire workflow-sidecar module in for one primitive). Deliberately synchronous (sleepSync's
265
+ * Atomics.wait spin, not setTimeout/await) so claim/release/supersede can stay sync `number`
266
+ * -returning functions and the CLI dispatcher (src/cli.ts, `number | Promise<number>`) does not
267
+ * need any ripple to async. On lock-acquire failure (any error other than a live contested lock,
268
+ * or a timeout waiting one out) this THROWS — never a silent no-op — "fail loud, never fail-open"
269
+ * (artifact-contract.md). Wrap the ENTIRE read-modify-write body (the existing-claim check AND
270
+ * the write) of all three local-file mutators in this, since all three mutate the same record
271
+ * file for a given subject.
272
+ */
273
+ export function withSubjectLock<T>(artifactRoot: string, subjectId: string, body: () => T): T {
274
+ const lockDir = subjectLockDir(artifactRoot, subjectId);
275
+ const staleMs = Number(process.env.FLOW_AGENTS_ASSIGNMENT_STALE_LOCK_MS ?? 5 * 60 * 1000);
276
+ const deadline = Date.now() + 30000;
277
+ while (true) {
278
+ try {
279
+ fs.mkdirSync(lockDir);
280
+ break;
281
+ } catch (error) {
282
+ const lockError = error as NodeJS.ErrnoException;
283
+ if (lockError.code !== "EEXIST") {
284
+ throw new Error(`failed to acquire assignment lock for subject ${subjectId}: ${lockDir}: ${lockError.message || lockError.code || String(lockError)}`);
285
+ }
286
+ try {
287
+ const stat = fs.statSync(lockDir);
288
+ if (staleMs > 0 && Date.now() - stat.mtimeMs > staleMs) {
289
+ fs.rmSync(lockDir, { recursive: true, force: true });
290
+ continue;
291
+ }
292
+ } catch (statError) {
293
+ if ((statError as NodeJS.ErrnoException).code === "ENOENT") continue; // lock released between mkdir/EEXIST and stat; retry immediately
294
+ throw statError;
295
+ }
296
+ if (Date.now() > deadline) {
297
+ throw new Error(`timed out waiting for assignment lock for subject ${subjectId}: ${lockDir}`);
298
+ }
299
+ sleepSync(20);
300
+ }
301
+ }
302
+ try {
303
+ return body();
304
+ } finally {
305
+ fs.rmSync(lockDir, { recursive: true, force: true });
306
+ }
307
+ }
308
+
309
+ /**
310
+ * The assignment ⋈ liveness join (contract doc's "assignment ⋈ liveness join" section, ADR 0021
311
+ * §1). Pure function: `{ assignment, freshHoldersList, selfActor, nowMs }` -> one of five
312
+ * effective states (held/reclaimable/human-held/free — "held" covers both the plain and
313
+ * assignment-lagging rows, matching the contract table's own repeated "held" label).
314
+ *
315
+ * The human-assignee gate (AC11, Design Decision 3) reads `record.actor.human` being *present*,
316
+ * never a username heuristic — an idle human assignment is always `human-held`, regardless of
317
+ * idle duration, and is never auto-reclaimable by this function.
318
+ *
319
+ * `nowMs` (F3 fix, fix-plan iteration 1) is the SAME resolved "now" the caller already threads
320
+ * into `freshHolders()` (the `--now` override, when passed, else `Date.now()`) — passing it
321
+ * through here too means `--now` deterministically governs idle_days as well as liveness
322
+ * freshness, rather than idle_days silently reading the real wall clock regardless of `--now`.
323
+ */
324
+ export function computeEffectiveState(assignment: AssignmentStatus, freshHoldersList: FreshHolder[], selfActor: string | undefined, nowMs: number): {
325
+ effective_state: EffectiveState;
326
+ reason: string;
327
+ holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string };
328
+ } {
329
+ const record = assignment.record && assignment.record.status === "claimed" ? assignment.record : null;
330
+ const isAssigned = Boolean(assignment.assignee) || Boolean(record);
331
+
332
+ if (!isAssigned) {
333
+ if (freshHoldersList.length > 0) {
334
+ const holder = freshHoldersList[0];
335
+ return { effective_state: "held", reason: "liveness_claim_present_assignment_lagging", holder: { actor: holder.actor, last_at: holder.lastAt } };
336
+ }
337
+ return { effective_state: "free", reason: "no_assignment_no_liveness" };
338
+ }
339
+
340
+ if (record && record.actor && record.actor.human != null && String(record.actor.human).trim() !== "") {
341
+ // F3 fix (fix-plan iteration 1): idle_days is computed from the SAME resolved `now` the
342
+ // caller already threads into freshHolders() (the `--now` override, when passed), not a
343
+ // second, independent Date.now() read — so `--now` governs both liveness freshness AND
344
+ // idle-based classification deterministically, rather than only the former.
345
+ const idleMs = nowMs - Date.parse(record.claimed_at);
346
+ const idleDays = Number.isFinite(idleMs) ? Math.floor(idleMs / 86_400_000) : null;
347
+ return { effective_state: "human-held", reason: "assignee_is_human", holder: { actor: assignment.assignee ?? undefined, idle_days: idleDays } };
348
+ }
349
+
350
+ if (!record) {
351
+ // Assignee present (e.g. a raw GitHub assignee) with no parseable machine claim record: we
352
+ // cannot positively identify this as a stale agent session, so the conservative ask-first
353
+ // default treats it the same as an explicit human assignment rather than risk reclaiming a
354
+ // human's work (Design Decision 3 / ADR 0021 §6's "never auto-reclaim" non-goal).
355
+ return { effective_state: "human-held", reason: "assignee_without_claim_record", holder: { assignee: assignment.assignee } };
356
+ }
357
+
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);
366
+ if (selfActor && holderActorKey === selfActor) return { effective_state: "held", reason: "self_is_holder", holder: { actor: holderActorKey } };
367
+
368
+ const fresh = freshHoldersList.find((holder) => holder.actor === holderActorKey);
369
+ if (fresh) return { effective_state: "held", reason: "fresh_liveness_heartbeat", holder: { actor: holderActorKey, last_at: fresh.lastAt } };
370
+ return { effective_state: "reclaimable", reason: "assignment_present_liveness_stale_or_absent", holder: { actor: holderActorKey, last_at: record.claimed_at } };
371
+ }
372
+
373
+ function namesOf(list: unknown, key: string): string[] {
374
+ if (!Array.isArray(list)) return [];
375
+ return list.map((item) => typeof item === "string" ? item : (item && typeof item === "object" ? String((item as AnyObj)[key] ?? "") : "")).filter(Boolean);
376
+ }
377
+
378
+ /**
379
+ * F2 fix (fix-plan iteration 1, HIGH): every string field on a GitHub claim record is sourced
380
+ * from a parsed, attacker-postable issue comment (any GitHub user who can comment can forge a
381
+ * claim-marker comment with a hostile fenced JSON block — commenting requires no elevated
382
+ * access, unlike the assignee/label mutations this contract otherwise gates). Mirrors
383
+ * workflow-sidecar.ts's `stripControlCharsForDisplay` (the established #287/#320 mitigation for
384
+ * exactly this class of untrusted multi-writer/attacker-postable display input): strips C0
385
+ * (0x00-0x1F), DEL (0x7F), and C1 (0x80-0x9F, which includes ANSI-CSI-adjacent bytes), then caps
386
+ * length (this repo's 64/240 convention: 64 for id-like fields, 240 for free text). Display-only
387
+ * — sanitizing the string CONTENT never changes presence/emptiness for any well-formed value, so
388
+ * it does not perturb computeEffectiveState()'s human-assignee presence gate or any equality
389
+ * check downstream.
390
+ */
391
+ function sanitizeDisplayField(value: unknown, maxLength: number): string {
392
+ const stripped = String(value ?? "").replace(/[\u0000-\u001F\u007F-\u009F]/g, "");
393
+ return stripped.length > maxLength ? stripped.slice(0, maxLength) : stripped;
394
+ }
395
+
396
+ function sanitizeActorForDisplay(actor: ActorStruct): ActorStruct {
397
+ return {
398
+ runtime: sanitizeDisplayField(actor.runtime, 64),
399
+ session_id: sanitizeDisplayField(actor.session_id, 64),
400
+ host: sanitizeDisplayField(actor.host, 64),
401
+ human: actor.human != null ? sanitizeDisplayField(actor.human, 240) : (actor.human ?? null),
402
+ };
403
+ }
404
+
405
+ function sanitizeAuditEntryForDisplay(entry: AssignmentAuditEntry): AssignmentAuditEntry {
406
+ return {
407
+ ...entry,
408
+ from_actor: entry.from_actor ? sanitizeActorForDisplay(entry.from_actor) : (entry.from_actor ?? null),
409
+ to_actor: entry.to_actor ? sanitizeActorForDisplay(entry.to_actor) : (entry.to_actor ?? null),
410
+ reason: entry.reason != null ? sanitizeDisplayField(entry.reason, 240) : entry.reason,
411
+ };
412
+ }
413
+
414
+ /**
415
+ * The single choke point (per the code review's explicit recommendation) every parsed GitHub
416
+ * claim record passes through before any string in it leaves this module in any form — both
417
+ * `status`/`list`'s JSON output (this fix) and any future consumer of `extractClaimRecord`'s
418
+ * return value inherit clean values from here, mirroring the #320 `computeConflict()` precedent
419
+ * of sanitizing once at construction rather than at each print site.
420
+ */
421
+ function sanitizeClaimRecordForDisplay(record: AssignmentClaimRecord): AssignmentClaimRecord {
422
+ return {
423
+ ...record,
424
+ subject_id: sanitizeDisplayField(record.subject_id, 64),
425
+ branch: sanitizeDisplayField(record.branch, 240),
426
+ artifact_dir: sanitizeDisplayField(record.artifact_dir, 240),
427
+ actor: sanitizeActorForDisplay(record.actor),
428
+ audit_trail: Array.isArray(record.audit_trail) ? record.audit_trail.map(sanitizeAuditEntryForDisplay) : record.audit_trail,
429
+ };
430
+ }
431
+
432
+ /**
433
+ * Locate the machine-readable claim comment among human comments (via the fixed marker) and
434
+ * extract/validate its fenced JSON block. Fails loud on an unparseable or misversioned record —
435
+ * never silently treats a corrupt comment as "no claim" (same rationale as readLocalRecord()).
436
+ * The returned record's display-surfaced string fields are sanitized (F2 fix, above) before
437
+ * return — this is the single choke point, so schema/role/status checks above still validate
438
+ * the RAW parsed shape (never weakened), and only the string fields are transformed afterward.
439
+ */
440
+ function extractClaimRecord(issue: GithubIssueDoc, marker: string): AssignmentClaimRecord | null {
441
+ const comments = Array.isArray(issue.comments) ? issue.comments : [];
442
+ for (const comment of comments) {
443
+ const body = String(comment.body ?? "");
444
+ const markerIndex = body.indexOf(marker);
445
+ if (markerIndex === -1) continue;
446
+ const fenceMatch = body.slice(markerIndex).match(/```json\s*([\s\S]*?)```/);
447
+ if (!fenceMatch) throw new Error(`claim comment (id ${comment.id ?? "?"}) has the claim marker but no fenced JSON block`);
448
+ let parsed: unknown;
449
+ try {
450
+ parsed = JSON.parse(fenceMatch[1]);
451
+ } catch (error) {
452
+ throw new Error(`claim comment (id ${comment.id ?? "?"}) fenced JSON is unparseable: ${(error as Error).message}`);
453
+ }
454
+ if (typeof parsed !== "object" || parsed === null) throw new Error(`claim comment (id ${comment.id ?? "?"}) fenced JSON is not an object`);
455
+ const record = parsed as AssignmentClaimRecord;
456
+ if (record.schema_version !== "1.0") throw new Error(`claim comment (id ${comment.id ?? "?"}) has unsupported schema_version ${String((record as AnyObj).schema_version)}`);
457
+ if (record.role !== "AssignmentClaimRecord") throw new Error(`claim comment (id ${comment.id ?? "?"}) has unexpected role ${String((record as AnyObj).role)}`);
458
+ return sanitizeClaimRecordForDisplay(record);
459
+ }
460
+ return null;
461
+ }
462
+
463
+ function githubAssignmentStatus(issue: GithubIssueDoc, labelName: string, marker: string): AssignmentStatus {
464
+ const assignees = namesOf(issue.assignees, "login");
465
+ const labels = namesOf(issue.labels, "name");
466
+ const record = extractClaimRecord(issue, marker);
467
+ return {
468
+ subject_id: record?.subject_id ?? "",
469
+ provider: "github",
470
+ assignee: assignees[0] ?? null,
471
+ record,
472
+ has_claim_label: labels.map((label) => label.toLowerCase()).includes(labelName.toLowerCase()),
473
+ };
474
+ }
475
+
476
+ function loadLivenessInputs(args: ParsedArgs): { events: AnyObj[] | null; selfActor: string | undefined } {
477
+ const eventsJsonPath = flagString(args.flags, "liveness-events-json");
478
+ const streamPath = flagString(args.flags, "liveness-stream");
479
+ const selfActor = flagString(args.flags, "self-actor");
480
+ if (eventsJsonPath) {
481
+ const data = loadJsonInput(eventsJsonPath);
482
+ if (!Array.isArray(data)) throw new Error(`--liveness-events-json must contain a JSON array: ${eventsJsonPath}`);
483
+ return { events: data as AnyObj[], selfActor };
484
+ }
485
+ if (streamPath) return { events: loadLivenessReadHelper().readLivenessEvents(streamPath), selfActor };
486
+ return { events: null, selfActor };
487
+ }
488
+
489
+ // ─── local-file: claim | release | supersede (the durable-write path; real I/O by design — no
490
+ // external mutation to defer to a skill for this provider kind, per Design Decision 1) ─────────
491
+
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 {
506
+ const helper = loadActorIdentityHelper();
507
+ const reason = opts.reason ?? "claim";
508
+
509
+ // F1 fix (fix-plan iteration 1, CRITICAL): the existing-claim check AND the write must happen
510
+ // atomically with respect to any other `assignment-provider` invocation on the same subject —
511
+ // see withSubjectLock()'s doc comment for the full rationale.
512
+ return withSubjectLock(artifactRoot, subjectId, (): AssignmentClaimRecord => {
513
+ const existing = readLocalRecord(artifactRoot, subjectId);
514
+ if (existing && existing.status === "claimed") {
515
+ const existingActorKey = helper.serializeActor(existing.actor);
516
+ const newActorKey = helper.serializeActor(actor);
517
+ // AC7: a second claim from a different actor must never silently overwrite the first.
518
+ // Same actor re-claiming (refresh before TTL expiry) is allowed and idempotent.
519
+ if (existingActorKey !== newActorKey) {
520
+ throw new Error(`subject already claimed by a different actor: ${existingActorKey} (claimed_at ${existing.claimed_at}); refusing to overwrite — use supersede to reassign`);
521
+ }
522
+ }
523
+
524
+ const record: AssignmentClaimRecord = {
525
+ schema_version: "1.0",
526
+ role: "AssignmentClaimRecord",
527
+ subject_id: subjectId,
528
+ actor,
529
+ ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
530
+ claimed_at: isoNow(),
531
+ ttl_seconds: opts.ttlSeconds,
532
+ branch: opts.branch,
533
+ artifact_dir: opts.artifactDir,
534
+ status: "claimed",
535
+ audit_trail: [...(existing?.audit_trail ?? []), { at: isoNow(), transition: "claim", from_actor: null, to_actor: actor, reason }],
536
+ };
537
+ writeLocalRecord(artifactRoot, subjectId, record);
538
+ return record;
539
+ });
540
+ }
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
+
562
+ function releaseLocalFile(argv: string[]): number {
563
+ const args = parseArgs(argv);
564
+ const provider = flagString(args.flags, "provider", "local-file");
565
+ if (provider !== "local-file") throw new Error(`release: --provider must be local-file (use render-release for github); got ${provider}`);
566
+ const artifactRoot = requireFlag(args, "artifact-root");
567
+ const subjectId = requireFlag(args, "subject-id");
568
+ const releasedBy = flagString(args.flags, "actor-json") ? loadActorStructFromFile(requireFlag(args, "actor-json")) : null;
569
+ const reason = flagString(args.flags, "reason") ?? "released";
570
+
571
+ // F1 fix (fix-plan iteration 1, CRITICAL): release mutates the same record file claim/supersede
572
+ // do, under the same per-subject lock (see withSubjectLock()'s doc comment).
573
+ return withSubjectLock(artifactRoot, subjectId, (): number => {
574
+ const existing = readLocalRecord(artifactRoot, subjectId);
575
+ if (!existing || existing.status !== "claimed") throw new Error(`no active claim to release for subject: ${subjectId}`);
576
+ const record: AssignmentClaimRecord = {
577
+ ...existing,
578
+ status: "released",
579
+ audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "release", from_actor: existing.actor, to_actor: releasedBy, reason }],
580
+ };
581
+ writeLocalRecord(artifactRoot, subjectId, record);
582
+ console.log(JSON.stringify({ role: "AssignmentReleaseResult", subject_id: subjectId, record }, null, 2));
583
+ return 0;
584
+ });
585
+ }
586
+
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 {
603
+ const helper = loadActorIdentityHelper();
604
+ const reason = opts.reason ?? "supersede";
605
+
606
+ // F1 fix (fix-plan iteration 1, CRITICAL): supersede mutates the same record file claim/release
607
+ // do, under the same per-subject lock (see withSubjectLock()'s doc comment).
608
+ return withSubjectLock(artifactRoot, subjectId, (): AssignmentClaimRecord => {
609
+ const existing = readLocalRecord(artifactRoot, subjectId);
610
+ if (!existing || existing.status !== "claimed") throw new Error(`no active claim to supersede for subject: ${subjectId}`);
611
+
612
+ if (helper.serializeActor(existing.actor) !== helper.serializeActor(fromActor)) {
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`);
614
+ }
615
+
616
+ const ttlSecondsRaw = opts.ttlSeconds != null ? String(opts.ttlSeconds) : String(existing.ttl_seconds);
617
+ const ttlSeconds = Number(ttlSecondsRaw);
618
+ if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) throw new Error(`--ttl-seconds must be a positive number; got ${ttlSecondsRaw}`);
619
+
620
+ const record: AssignmentClaimRecord = {
621
+ schema_version: "1.0",
622
+ role: "AssignmentClaimRecord",
623
+ subject_id: subjectId,
624
+ actor: toActor,
625
+ ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
626
+ claimed_at: isoNow(),
627
+ ttl_seconds: ttlSeconds,
628
+ branch: opts.branch ?? existing.branch,
629
+ artifact_dir: opts.artifactDir ?? existing.artifact_dir,
630
+ status: "claimed",
631
+ audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "supersede", from_actor: fromActor, to_actor: toActor, reason }],
632
+ };
633
+ writeLocalRecord(artifactRoot, subjectId, record);
634
+ return record;
635
+ });
636
+ }
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
+
661
+ // ─── GitHub: render-claim | render-release | render-supersede (render, don't execute — Design
662
+ // Decision 1). Pure functions: no I/O beyond reading --input-json/--actor-json. Never invoke
663
+ // `gh` (or any process) here — the calling skill runs the emitted argv verbatim. ────────────────
664
+
665
+ function requireRepo(input: RenderClaimInput): { owner: string; name: string } {
666
+ const repo = input.repo;
667
+ if (!repo || !repo.owner || !repo.name) throw new Error("input-json.repo.owner and input-json.repo.name are required");
668
+ return { owner: repo.owner, name: repo.name };
669
+ }
670
+
671
+ function requireIssueNumber(input: RenderClaimInput): number {
672
+ const issueNumber = input.issue_number;
673
+ if (!Number.isFinite(issueNumber)) throw new Error("input-json.issue_number is required");
674
+ return Number(issueNumber);
675
+ }
676
+
677
+ function renderClaimCommentBody(record: AssignmentClaimRecord, marker: string): string {
678
+ const humanNote = record.actor.human ? `Assigned to human ${record.actor.human}.` : `Claimed by an automated agent session (${record.actor.runtime}).`;
679
+ return [
680
+ marker,
681
+ `**Assignment claim** — ${humanNote}`,
682
+ "",
683
+ `- actor: \`${loadActorIdentityHelper().serializeActor(record.actor)}\``,
684
+ `- claimed_at: ${record.claimed_at}`,
685
+ `- ttl_seconds: ${record.ttl_seconds}`,
686
+ `- branch: \`${record.branch}\``,
687
+ "",
688
+ "```json",
689
+ JSON.stringify(record, null, 2),
690
+ "```",
691
+ ].join("\n");
692
+ }
693
+
694
+ function renderHandoffCommentBody(subjectId: string, input: RenderClaimInput): string {
695
+ const marker = input.claim_comment_marker ?? CLAIM_COMMENT_MARKER_DEFAULT;
696
+ const record = input.previous_record
697
+ ? {
698
+ ...input.previous_record,
699
+ status: "released" as const,
700
+ audit_trail: [...(input.previous_record.audit_trail ?? []), { at: isoNow(), transition: "release" as const, from_actor: input.previous_record.actor, to_actor: null, reason: input.reason ?? "released" }],
701
+ }
702
+ : null;
703
+ const lines = [marker, `**Assignment released** — subject \`${subjectId}\` is free.`];
704
+ if (record) lines.push("", "```json", JSON.stringify(record, null, 2), "```");
705
+ return lines.join("\n");
706
+ }
707
+
708
+ function renderClaim(argv: string[]): number {
709
+ const args = parseArgs(argv);
710
+ const provider = flagString(args.flags, "provider", "github");
711
+ if (provider !== "github") throw new Error(`render-claim: --provider must be github; got ${provider}`);
712
+ const subjectId = requireFlag(args, "subject-id");
713
+ const input = loadJsonInput(requireFlag(args, "input-json")) as RenderClaimInput;
714
+ const actor = loadActorStructFromFile(requireFlag(args, "actor-json"));
715
+ const repo = requireRepo(input);
716
+ const issueNumber = requireIssueNumber(input);
717
+ const labelName = input.label_name ?? DEFAULT_LABEL_NAME;
718
+ const marker = input.claim_comment_marker ?? CLAIM_COMMENT_MARKER_DEFAULT;
719
+ const ttlSeconds = input.ttl_seconds ?? 1800;
720
+ const branch = input.branch;
721
+ const artifactDir = input.artifact_dir;
722
+ if (!branch) throw new Error("input-json.branch is required for render-claim");
723
+ if (!artifactDir) throw new Error("input-json.artifact_dir is required for render-claim");
724
+
725
+ const record: AssignmentClaimRecord = {
726
+ schema_version: "1.0",
727
+ role: "AssignmentClaimRecord",
728
+ subject_id: subjectId,
729
+ actor,
730
+ claimed_at: isoNow(),
731
+ ttl_seconds: ttlSeconds,
732
+ branch,
733
+ artifact_dir: artifactDir,
734
+ status: "claimed",
735
+ };
736
+ const repoSlug = `${repo.owner}/${repo.name}`;
737
+ const commentBody = renderClaimCommentBody(record, marker);
738
+ const ghCommands: string[][] = [];
739
+ if (input.assignee_login) ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--add-assignee", input.assignee_login]);
740
+ ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--add-label", labelName]);
741
+ ghCommands.push(
742
+ input.existing_comment_id
743
+ ? ["gh", "api", "--method", "PATCH", `repos/${repoSlug}/issues/comments/${input.existing_comment_id}`, "-f", `body=${commentBody}`]
744
+ : ["gh", "issue", "comment", String(issueNumber), "--repo", repoSlug, "--body", commentBody],
745
+ );
746
+ console.log(JSON.stringify({ role: "AssignmentRenderResult", transition: "claim", subject_id: subjectId, gh_commands: ghCommands, claim_comment_body: commentBody, record }, null, 2));
747
+ return 0;
748
+ }
749
+
750
+ function renderRelease(argv: string[]): number {
751
+ const args = parseArgs(argv);
752
+ const provider = flagString(args.flags, "provider", "github");
753
+ if (provider !== "github") throw new Error(`render-release: --provider must be github; got ${provider}`);
754
+ const subjectId = requireFlag(args, "subject-id");
755
+ const input = loadJsonInput(requireFlag(args, "input-json")) as RenderClaimInput;
756
+ const repo = requireRepo(input);
757
+ const issueNumber = requireIssueNumber(input);
758
+ const labelName = input.label_name ?? DEFAULT_LABEL_NAME;
759
+ const repoSlug = `${repo.owner}/${repo.name}`;
760
+ const ghCommands: string[][] = [];
761
+ const assigneeLogin = input.existing_assignee_login ?? input.assignee_login;
762
+ if (assigneeLogin) ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--remove-assignee", assigneeLogin]);
763
+ ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--remove-label", labelName]);
764
+ const handoffBody = renderHandoffCommentBody(subjectId, input);
765
+ ghCommands.push(
766
+ input.existing_comment_id
767
+ ? ["gh", "api", "--method", "PATCH", `repos/${repoSlug}/issues/comments/${input.existing_comment_id}`, "-f", `body=${handoffBody}`]
768
+ : ["gh", "issue", "comment", String(issueNumber), "--repo", repoSlug, "--body", handoffBody],
769
+ );
770
+ console.log(JSON.stringify({ role: "AssignmentRenderResult", transition: "release", subject_id: subjectId, gh_commands: ghCommands, claim_comment_body: handoffBody }, null, 2));
771
+ return 0;
772
+ }
773
+
774
+ function renderSupersede(argv: string[]): number {
775
+ const args = parseArgs(argv);
776
+ const provider = flagString(args.flags, "provider", "github");
777
+ if (provider !== "github") throw new Error(`render-supersede: --provider must be github; got ${provider}`);
778
+ const subjectId = requireFlag(args, "subject-id");
779
+ const input = loadJsonInput(requireFlag(args, "input-json")) as RenderClaimInput;
780
+ const toActor = loadActorStructFromFile(requireFlag(args, "actor-json"));
781
+ const repo = requireRepo(input);
782
+ const issueNumber = requireIssueNumber(input);
783
+ const labelName = input.label_name ?? DEFAULT_LABEL_NAME;
784
+ const marker = input.claim_comment_marker ?? CLAIM_COMMENT_MARKER_DEFAULT;
785
+ const ttlSeconds = input.ttl_seconds ?? 1800;
786
+ const branch = input.branch;
787
+ const artifactDir = input.artifact_dir;
788
+ if (!branch) throw new Error("input-json.branch is required for render-supersede");
789
+ if (!artifactDir) throw new Error("input-json.artifact_dir is required for render-supersede");
790
+ // Wave 4 AC: render-supersede must edit the existing claim comment in place, never duplicate it.
791
+ if (!input.existing_comment_id) throw new Error("input-json.existing_comment_id is required for render-supersede (edits the claim comment in place; never duplicates it)");
792
+
793
+ const previousActor = input.previous_record?.actor ?? null;
794
+ const record: AssignmentClaimRecord = {
795
+ schema_version: "1.0",
796
+ role: "AssignmentClaimRecord",
797
+ subject_id: subjectId,
798
+ actor: toActor,
799
+ claimed_at: isoNow(),
800
+ ttl_seconds: ttlSeconds,
801
+ branch,
802
+ artifact_dir: artifactDir,
803
+ status: "claimed",
804
+ audit_trail: [...(input.previous_record?.audit_trail ?? []), { at: isoNow(), transition: "supersede", from_actor: previousActor, to_actor: toActor, reason: input.reason ?? "supersede" }],
805
+ };
806
+ const repoSlug = `${repo.owner}/${repo.name}`;
807
+ const commentBody = renderClaimCommentBody(record, marker);
808
+ const ghCommands: string[][] = [];
809
+ const previousAssignee = input.existing_assignee_login;
810
+ if (previousAssignee && previousAssignee !== input.assignee_login) ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--remove-assignee", previousAssignee]);
811
+ if (input.assignee_login) ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--add-assignee", input.assignee_login]);
812
+ ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--add-label", labelName]);
813
+ ghCommands.push(["gh", "api", "--method", "PATCH", `repos/${repoSlug}/issues/comments/${input.existing_comment_id}`, "-f", `body=${commentBody}`]);
814
+ console.log(JSON.stringify({ role: "AssignmentRenderResult", transition: "supersede", subject_id: subjectId, gh_commands: ghCommands, claim_comment_body: commentBody, record }, null, 2));
815
+ return 0;
816
+ }
817
+
818
+ // ─── status | list (both provider kinds) ────────────────────────────────────────────────────
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
+
832
+ function statusCommand(argv: string[]): number {
833
+ const args = parseArgs(argv);
834
+ const provider = requireFlag(args, "provider");
835
+ const requestedSubjectId = flagString(args.flags, "subject-id");
836
+ let assignment: AssignmentStatus;
837
+
838
+ if (provider === "local-file") {
839
+ const artifactRoot = requireFlag(args, "artifact-root");
840
+ if (!requestedSubjectId) throw new Error("--subject-id is required for status --provider local-file");
841
+ assignment = readLocalAssignmentStatus(artifactRoot, requestedSubjectId);
842
+ } else if (provider === "github") {
843
+ const issueJsonPath = requireFlag(args, "issue-json");
844
+ const issue = loadJsonInput(issueJsonPath) as GithubIssueDoc;
845
+ const labelName = flagString(args.flags, "label-name", DEFAULT_LABEL_NAME) ?? DEFAULT_LABEL_NAME;
846
+ const marker = flagString(args.flags, "claim-comment-marker", CLAIM_COMMENT_MARKER_DEFAULT) ?? CLAIM_COMMENT_MARKER_DEFAULT;
847
+ assignment = githubAssignmentStatus(issue, labelName, marker);
848
+ if (requestedSubjectId && assignment.record && assignment.record.subject_id !== requestedSubjectId) {
849
+ throw new Error(`claim record subject_id ${assignment.record.subject_id} does not match requested --subject-id ${requestedSubjectId}`);
850
+ }
851
+ if (requestedSubjectId) assignment.subject_id = requestedSubjectId;
852
+ } else {
853
+ throw new Error(`status: unsupported --provider ${provider}`);
854
+ }
855
+
856
+ const { events, selfActor } = loadLivenessInputs(args);
857
+ const nowMs = flagString(args.flags, "now") ? Date.parse(flagString(args.flags, "now") as string) : Date.now();
858
+ const freshList = events !== null ? loadLivenessReadHelper().freshHolders(events, assignment.subject_id, selfActor ?? "", nowMs) : [];
859
+ const effective = events !== null
860
+ ? computeEffectiveState(assignment, freshList, selfActor, nowMs)
861
+ : { effective_state: null, reason: "liveness input not provided (pass --liveness-events-json or --liveness-stream); effective state not computed" };
862
+ console.log(JSON.stringify({ role: "AssignmentStatus", provider, assignment, effective }, null, 2));
863
+ return 0;
864
+ }
865
+
866
+ function listCommand(argv: string[]): number {
867
+ const args = parseArgs(argv);
868
+ const provider = requireFlag(args, "provider");
869
+ const actorJsonFilter = flagString(args.flags, "actor-json");
870
+ const actorFilter = actorJsonFilter ? loadActorIdentityHelper().serializeActor(loadActorStructFromFile(actorJsonFilter)) : flagString(args.flags, "actor");
871
+ const subjectIds: string[] = [];
872
+
873
+ if (provider === "local-file") {
874
+ const artifactRoot = requireFlag(args, "artifact-root");
875
+ const dir = path.join(artifactRoot, "assignment");
876
+ const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((name) => name.endsWith(".json")).sort() : [];
877
+ for (const name of files) {
878
+ const record = readJson(path.join(dir, name)) as AssignmentClaimRecord;
879
+ if (record.status !== "claimed") continue;
880
+ if (actorFilter && loadActorIdentityHelper().serializeActor(record.actor) !== actorFilter) continue;
881
+ subjectIds.push(record.subject_id);
882
+ }
883
+ } else if (provider === "github") {
884
+ const issuesJsonPath = requireFlag(args, "issues-json");
885
+ const doc = loadJsonInput(issuesJsonPath);
886
+ const issues = Array.isArray(doc) ? doc as GithubIssueDoc[] : ((doc as AnyObj).items as GithubIssueDoc[] ?? []);
887
+ const labelName = flagString(args.flags, "label-name", DEFAULT_LABEL_NAME) ?? DEFAULT_LABEL_NAME;
888
+ const marker = flagString(args.flags, "claim-comment-marker", CLAIM_COMMENT_MARKER_DEFAULT) ?? CLAIM_COMMENT_MARKER_DEFAULT;
889
+ for (const issue of issues) {
890
+ const assignment = githubAssignmentStatus(issue, labelName, marker);
891
+ if (!assignment.record || assignment.record.status !== "claimed") continue;
892
+ if (actorFilter && loadActorIdentityHelper().serializeActor(assignment.record.actor) !== actorFilter) continue;
893
+ subjectIds.push(assignment.record.subject_id);
894
+ }
895
+ } else {
896
+ throw new Error(`list: unsupported --provider ${provider}`);
897
+ }
898
+
899
+ console.log(JSON.stringify({ role: "AssignmentList", provider, actor: actorFilter ?? null, subject_ids: subjectIds }, null, 2));
900
+ return 0;
901
+ }
902
+
903
+ export function main(argv = process.argv.slice(2)): number {
904
+ try {
905
+ const [command, ...rest] = argv;
906
+ if (command === "claim") return claimLocalFile(rest);
907
+ if (command === "release") return releaseLocalFile(rest);
908
+ if (command === "supersede") return supersedeLocalFile(rest);
909
+ if (command === "render-claim") return renderClaim(rest);
910
+ if (command === "render-release") return renderRelease(rest);
911
+ if (command === "render-supersede") return renderSupersede(rest);
912
+ if (command === "status") return statusCommand(rest);
913
+ if (command === "list") return listCommand(rest);
914
+ console.error("usage: assignment-provider <claim|release|supersede|render-claim|render-release|render-supersede|status|list> [flags]");
915
+ return 2;
916
+ } catch (error) {
917
+ console.error(`assignment-provider: ${(error as Error).message}`);
918
+ return 1;
919
+ }
920
+ }
921
+
922
+ // Use process.exitCode (not process.exit) to allow stdout to be flushed before exit.
923
+ // Resolve real paths to handle symlinks (e.g. /tmp -> /private/tmp on macOS) so the
924
+ // entry-point guard fires correctly when the module is loaded directly as a script.
925
+ const _selfRealPath = (() => { try { return fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return fileURLToPath(import.meta.url); } })();
926
+ const _argv1RealPath = (() => { try { return fs.realpathSync(process.argv[1]); } catch { return process.argv[1]; } })();
927
+ if (_selfRealPath === _argv1RealPath) { process.exitCode = main(); }