@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,805 @@
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 } from "../lib/args.js";
7
+ import { readJson, writeJson, isoNow } from "../lib/fs.js";
8
+ const DEFAULT_LABEL_NAME = "agent:claimed";
9
+ const CLAIM_COMMENT_MARKER_DEFAULT = "<!-- flow-agents:assignment-claim -->";
10
+ /**
11
+ * Delegate to the shared pure-CJS resolver (scripts/hooks/lib/actor-identity.js), mirroring the
12
+ * exact createRequire pattern `workflow-sidecar.ts`'s loadActorIdentityHelper() already uses for
13
+ * this module. Deliberately NO inline duplicate fallback — same rationale as that function: if
14
+ * the module fails to load, that failure must surface loudly, never silently degrade to a forked
15
+ * actor concept or a second sanitizer.
16
+ */
17
+ function loadActorIdentityHelper() {
18
+ const _req = createRequire(import.meta.url);
19
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/actor-identity.js");
20
+ return _req(helperPath);
21
+ }
22
+ /**
23
+ * Delegate to the shared pure-CJS liveness reader (scripts/hooks/lib/liveness-read.js), same
24
+ * createRequire idiom as loadActorIdentityHelper() above. Used only for the join computation —
25
+ * this module never writes liveness events (that stays the ADR 0012 lifecycle's job).
26
+ */
27
+ function loadLivenessReadHelper() {
28
+ const _req = createRequire(import.meta.url);
29
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-read.js");
30
+ return _req(helperPath);
31
+ }
32
+ function loadJsonInput(file) {
33
+ return file === "-" ? JSON.parse(fs.readFileSync(0, "utf8")) : readJson(file);
34
+ }
35
+ function requireFlag(args, name) {
36
+ const value = flagString(args.flags, name);
37
+ if (!value)
38
+ throw new Error(`--${name} is required`);
39
+ return value;
40
+ }
41
+ /**
42
+ * Build an ActorStruct from an already-loaded JSON value (used for --actor-json,
43
+ * --from-actor-json, --to-actor-json). Fails loud on a malformed/incomplete struct — a
44
+ * durable claim record must never carry a partial actor identity.
45
+ */
46
+ function actorStructFromJson(data, sourceLabel) {
47
+ if (typeof data !== "object" || data === null)
48
+ throw new Error(`${sourceLabel} must contain an object`);
49
+ const struct = data;
50
+ if (!struct.runtime || !struct.session_id || !struct.host)
51
+ throw new Error(`${sourceLabel} must include runtime, session_id, and host`);
52
+ return {
53
+ runtime: String(struct.runtime),
54
+ session_id: String(struct.session_id),
55
+ host: String(struct.host),
56
+ human: struct.human != null && String(struct.human).trim() !== "" ? String(struct.human) : null,
57
+ };
58
+ }
59
+ function loadActorStructFromFile(file) {
60
+ return actorStructFromJson(loadJsonInput(file), `actor JSON (${file})`);
61
+ }
62
+ /**
63
+ * Resolve the acting actor for a local-file mutation: --actor-json is the deterministic,
64
+ * fixture-friendly path (used by evals and any caller that already knows its own struct);
65
+ * when omitted, auto-derive from the live environment via the shared resolver, mirroring
66
+ * (never forking) the exact struct fields serializeActor() already defines.
67
+ *
68
+ * F1 fix (fix-plan iteration 1, HIGH): also returns `actorKey` — set to the canonical
69
+ * `resolveActor(env).actor` string ONLY on the auto-derive path (pull-work's real path, and any
70
+ * other caller with no --actor-json), so a claim made via `assignment-provider claim` and one
71
+ * made via ensure-session share the same canonical key. `--actor-json` explicit fixtures leave
72
+ * `actorKey` unset — `performLocalClaim`/`performLocalSupersede` then fall back to
73
+ * `serializeActor(actor)` for the record's `actor_key`, preserving existing fixture behavior.
74
+ */
75
+ function loadActorStruct(args) {
76
+ const actorJsonPath = flagString(args.flags, "actor-json");
77
+ if (actorJsonPath)
78
+ return { actor: loadActorStructFromFile(actorJsonPath) };
79
+ const helper = loadActorIdentityHelper();
80
+ const resolved = helper.resolveActor(process.env);
81
+ if (helper.isUnresolvedActor(resolved.actor))
82
+ throw new Error("could not resolve an actor identity (no --actor-json and no resolvable environment actor); pass --actor-json explicitly");
83
+ return { actor: { runtime: helper.detectRuntime(process.env), session_id: resolved.actor, host: os.hostname(), human: null }, actorKey: resolved.actor };
84
+ }
85
+ export function assignmentFilePath(artifactRoot, subjectId) {
86
+ const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
87
+ return path.join(artifactRoot, "assignment", `${sanitized}.json`);
88
+ }
89
+ export function readLocalRecord(artifactRoot, subjectId) {
90
+ const file = assignmentFilePath(artifactRoot, subjectId);
91
+ if (!fs.existsSync(file))
92
+ return null;
93
+ let data;
94
+ try {
95
+ data = readJson(file);
96
+ }
97
+ catch (error) {
98
+ // Fail loud: a corrupt claim record must never be silently treated as "no claim" — that
99
+ // would be a fail-open path that could let a second claim silently overwrite a real one.
100
+ throw new Error(`assignment record is corrupt, refusing to proceed: ${file}: ${error.message}`);
101
+ }
102
+ if (typeof data !== "object" || data === null)
103
+ throw new Error(`assignment record is not an object: ${file}`);
104
+ const record = data;
105
+ if (record.schema_version !== "1.0")
106
+ throw new Error(`${file}: unsupported schema_version ${String(record.schema_version)}`);
107
+ return record;
108
+ }
109
+ export function writeLocalRecord(artifactRoot, subjectId, record) {
110
+ // writeJson throws on any mkdir/writeFileSync failure; that error is intentionally allowed to
111
+ // propagate to main()'s top-level try/catch and exit non-zero. Durable writes must fail loud,
112
+ // never fail open (artifact-contract.md).
113
+ writeJson(assignmentFilePath(artifactRoot, subjectId), record);
114
+ }
115
+ /**
116
+ * Synchronous busy-sleep via Atomics.wait on a throwaway SharedArrayBuffer — Node.js (unlike
117
+ * browser engines) permits Atomics.wait on the main thread, so this gives withSubjectLock() a
118
+ * true blocking sleep without going async. Kept to a small, bounded delay (see withSubjectLock's
119
+ * spin loop) — never used outside the lock-acquire spin below.
120
+ */
121
+ function sleepSync(ms) {
122
+ const sab = new SharedArrayBuffer(4);
123
+ const ia = new Int32Array(sab);
124
+ Atomics.wait(ia, 0, 0, ms);
125
+ }
126
+ function subjectLockDir(artifactRoot, subjectId) {
127
+ const assignmentDir = path.join(artifactRoot, "assignment");
128
+ fs.mkdirSync(assignmentDir, { recursive: true });
129
+ const sanitized = loadActorIdentityHelper().sanitizeSegment(subjectId);
130
+ return path.join(assignmentDir, `.${sanitized}.lockdir`);
131
+ }
132
+ /**
133
+ * F1 fix (fix-plan iteration 1, CRITICAL): claimLocalFile/releaseLocalFile/supersedeLocalFile were
134
+ * a plain read -> compare-actor -> write with no lock, so two concurrently-launched OS processes
135
+ * could both read "no conflicting claim" before either wrote, and the second write would silently
136
+ * clobber the first with zero error and zero audit-trail entry for the loser (reproduced 29/40
137
+ * races against the built CLI). This mirrors the EXACT mechanism `withLock` already uses in
138
+ * workflow-sidecar.ts:908 for the same class of shared-state mutation — atomic `fs.mkdirSync`
139
+ * lockdir create as the mutual-exclusion primitive, EEXIST-spin with a staleness-reclaim check
140
+ * (a lock directory older than the stale threshold is presumed abandoned by a crashed process and
141
+ * is reclaimed rather than waited on forever) and a bounded deadline, `finally` rmSync release —
142
+ * as a small LOCAL helper (not a cross-import of that private function, which would pull the
143
+ * entire workflow-sidecar module in for one primitive). Deliberately synchronous (sleepSync's
144
+ * Atomics.wait spin, not setTimeout/await) so claim/release/supersede can stay sync `number`
145
+ * -returning functions and the CLI dispatcher (src/cli.ts, `number | Promise<number>`) does not
146
+ * need any ripple to async. On lock-acquire failure (any error other than a live contested lock,
147
+ * or a timeout waiting one out) this THROWS — never a silent no-op — "fail loud, never fail-open"
148
+ * (artifact-contract.md). Wrap the ENTIRE read-modify-write body (the existing-claim check AND
149
+ * the write) of all three local-file mutators in this, since all three mutate the same record
150
+ * file for a given subject.
151
+ */
152
+ export function withSubjectLock(artifactRoot, subjectId, body) {
153
+ const lockDir = subjectLockDir(artifactRoot, subjectId);
154
+ const staleMs = Number(process.env.FLOW_AGENTS_ASSIGNMENT_STALE_LOCK_MS ?? 5 * 60 * 1000);
155
+ const deadline = Date.now() + 30000;
156
+ while (true) {
157
+ try {
158
+ fs.mkdirSync(lockDir);
159
+ break;
160
+ }
161
+ catch (error) {
162
+ const lockError = error;
163
+ if (lockError.code !== "EEXIST") {
164
+ throw new Error(`failed to acquire assignment lock for subject ${subjectId}: ${lockDir}: ${lockError.message || lockError.code || String(lockError)}`);
165
+ }
166
+ try {
167
+ const stat = fs.statSync(lockDir);
168
+ if (staleMs > 0 && Date.now() - stat.mtimeMs > staleMs) {
169
+ fs.rmSync(lockDir, { recursive: true, force: true });
170
+ continue;
171
+ }
172
+ }
173
+ catch (statError) {
174
+ if (statError.code === "ENOENT")
175
+ continue; // lock released between mkdir/EEXIST and stat; retry immediately
176
+ throw statError;
177
+ }
178
+ if (Date.now() > deadline) {
179
+ throw new Error(`timed out waiting for assignment lock for subject ${subjectId}: ${lockDir}`);
180
+ }
181
+ sleepSync(20);
182
+ }
183
+ }
184
+ try {
185
+ return body();
186
+ }
187
+ finally {
188
+ fs.rmSync(lockDir, { recursive: true, force: true });
189
+ }
190
+ }
191
+ /**
192
+ * The assignment ⋈ liveness join (contract doc's "assignment ⋈ liveness join" section, ADR 0021
193
+ * §1). Pure function: `{ assignment, freshHoldersList, selfActor, nowMs }` -> one of five
194
+ * effective states (held/reclaimable/human-held/free — "held" covers both the plain and
195
+ * assignment-lagging rows, matching the contract table's own repeated "held" label).
196
+ *
197
+ * The human-assignee gate (AC11, Design Decision 3) reads `record.actor.human` being *present*,
198
+ * never a username heuristic — an idle human assignment is always `human-held`, regardless of
199
+ * idle duration, and is never auto-reclaimable by this function.
200
+ *
201
+ * `nowMs` (F3 fix, fix-plan iteration 1) is the SAME resolved "now" the caller already threads
202
+ * into `freshHolders()` (the `--now` override, when passed, else `Date.now()`) — passing it
203
+ * through here too means `--now` deterministically governs idle_days as well as liveness
204
+ * freshness, rather than idle_days silently reading the real wall clock regardless of `--now`.
205
+ */
206
+ export function computeEffectiveState(assignment, freshHoldersList, selfActor, nowMs) {
207
+ const record = assignment.record && assignment.record.status === "claimed" ? assignment.record : null;
208
+ const isAssigned = Boolean(assignment.assignee) || Boolean(record);
209
+ if (!isAssigned) {
210
+ if (freshHoldersList.length > 0) {
211
+ const holder = freshHoldersList[0];
212
+ return { effective_state: "held", reason: "liveness_claim_present_assignment_lagging", holder: { actor: holder.actor, last_at: holder.lastAt } };
213
+ }
214
+ return { effective_state: "free", reason: "no_assignment_no_liveness" };
215
+ }
216
+ if (record && record.actor && record.actor.human != null && String(record.actor.human).trim() !== "") {
217
+ // F3 fix (fix-plan iteration 1): idle_days is computed from the SAME resolved `now` the
218
+ // caller already threads into freshHolders() (the `--now` override, when passed), not a
219
+ // second, independent Date.now() read — so `--now` governs both liveness freshness AND
220
+ // idle-based classification deterministically, rather than only the former.
221
+ const idleMs = nowMs - Date.parse(record.claimed_at);
222
+ const idleDays = Number.isFinite(idleMs) ? Math.floor(idleMs / 86_400_000) : null;
223
+ return { effective_state: "human-held", reason: "assignee_is_human", holder: { actor: assignment.assignee ?? undefined, idle_days: idleDays } };
224
+ }
225
+ if (!record) {
226
+ // Assignee present (e.g. a raw GitHub assignee) with no parseable machine claim record: we
227
+ // cannot positively identify this as a stale agent session, so the conservative ask-first
228
+ // default treats it the same as an explicit human assignment rather than risk reclaiming a
229
+ // human's work (Design Decision 3 / ADR 0021 §6's "never auto-reclaim" non-goal).
230
+ return { effective_state: "human-held", reason: "assignee_without_claim_record", holder: { assignee: assignment.assignee } };
231
+ }
232
+ // F1 fix (fix-plan iteration 1, HIGH): prefer the canonical actor_key over re-serializing
233
+ // record.actor. record.actor_key IS resolveActor(env).actor for records written by the fixed
234
+ // performLocalClaim/performLocalSupersede paths (below) — the same flat/bare string liveness
235
+ // whoami, `liveness claim --actor`, per-actor current.json, and pull-work's --self-actor all
236
+ // use — so for an explicit-override actor the self-check and the liveness join now agree with
237
+ // every other tool. BACK-COMPAT: records with no actor_key (every pre-fix record, every #290
238
+ // eval fixture) fall back to serializeActor(record.actor) exactly as before this fix.
239
+ const holderActorKey = record.actor_key || loadActorIdentityHelper().serializeActor(record.actor);
240
+ if (selfActor && holderActorKey === selfActor)
241
+ return { effective_state: "held", reason: "self_is_holder", holder: { actor: holderActorKey } };
242
+ const fresh = freshHoldersList.find((holder) => holder.actor === holderActorKey);
243
+ if (fresh)
244
+ return { effective_state: "held", reason: "fresh_liveness_heartbeat", holder: { actor: holderActorKey, last_at: fresh.lastAt } };
245
+ return { effective_state: "reclaimable", reason: "assignment_present_liveness_stale_or_absent", holder: { actor: holderActorKey, last_at: record.claimed_at } };
246
+ }
247
+ function namesOf(list, key) {
248
+ if (!Array.isArray(list))
249
+ return [];
250
+ return list.map((item) => typeof item === "string" ? item : (item && typeof item === "object" ? String(item[key] ?? "") : "")).filter(Boolean);
251
+ }
252
+ /**
253
+ * F2 fix (fix-plan iteration 1, HIGH): every string field on a GitHub claim record is sourced
254
+ * from a parsed, attacker-postable issue comment (any GitHub user who can comment can forge a
255
+ * claim-marker comment with a hostile fenced JSON block — commenting requires no elevated
256
+ * access, unlike the assignee/label mutations this contract otherwise gates). Mirrors
257
+ * workflow-sidecar.ts's `stripControlCharsForDisplay` (the established #287/#320 mitigation for
258
+ * exactly this class of untrusted multi-writer/attacker-postable display input): strips C0
259
+ * (0x00-0x1F), DEL (0x7F), and C1 (0x80-0x9F, which includes ANSI-CSI-adjacent bytes), then caps
260
+ * length (this repo's 64/240 convention: 64 for id-like fields, 240 for free text). Display-only
261
+ * — sanitizing the string CONTENT never changes presence/emptiness for any well-formed value, so
262
+ * it does not perturb computeEffectiveState()'s human-assignee presence gate or any equality
263
+ * check downstream.
264
+ */
265
+ function sanitizeDisplayField(value, maxLength) {
266
+ const stripped = String(value ?? "").replace(/[\u0000-\u001F\u007F-\u009F]/g, "");
267
+ return stripped.length > maxLength ? stripped.slice(0, maxLength) : stripped;
268
+ }
269
+ function sanitizeActorForDisplay(actor) {
270
+ return {
271
+ runtime: sanitizeDisplayField(actor.runtime, 64),
272
+ session_id: sanitizeDisplayField(actor.session_id, 64),
273
+ host: sanitizeDisplayField(actor.host, 64),
274
+ human: actor.human != null ? sanitizeDisplayField(actor.human, 240) : (actor.human ?? null),
275
+ };
276
+ }
277
+ function sanitizeAuditEntryForDisplay(entry) {
278
+ return {
279
+ ...entry,
280
+ from_actor: entry.from_actor ? sanitizeActorForDisplay(entry.from_actor) : (entry.from_actor ?? null),
281
+ to_actor: entry.to_actor ? sanitizeActorForDisplay(entry.to_actor) : (entry.to_actor ?? null),
282
+ reason: entry.reason != null ? sanitizeDisplayField(entry.reason, 240) : entry.reason,
283
+ };
284
+ }
285
+ /**
286
+ * The single choke point (per the code review's explicit recommendation) every parsed GitHub
287
+ * claim record passes through before any string in it leaves this module in any form — both
288
+ * `status`/`list`'s JSON output (this fix) and any future consumer of `extractClaimRecord`'s
289
+ * return value inherit clean values from here, mirroring the #320 `computeConflict()` precedent
290
+ * of sanitizing once at construction rather than at each print site.
291
+ */
292
+ function sanitizeClaimRecordForDisplay(record) {
293
+ return {
294
+ ...record,
295
+ subject_id: sanitizeDisplayField(record.subject_id, 64),
296
+ branch: sanitizeDisplayField(record.branch, 240),
297
+ artifact_dir: sanitizeDisplayField(record.artifact_dir, 240),
298
+ actor: sanitizeActorForDisplay(record.actor),
299
+ audit_trail: Array.isArray(record.audit_trail) ? record.audit_trail.map(sanitizeAuditEntryForDisplay) : record.audit_trail,
300
+ };
301
+ }
302
+ /**
303
+ * Locate the machine-readable claim comment among human comments (via the fixed marker) and
304
+ * extract/validate its fenced JSON block. Fails loud on an unparseable or misversioned record —
305
+ * never silently treats a corrupt comment as "no claim" (same rationale as readLocalRecord()).
306
+ * The returned record's display-surfaced string fields are sanitized (F2 fix, above) before
307
+ * return — this is the single choke point, so schema/role/status checks above still validate
308
+ * the RAW parsed shape (never weakened), and only the string fields are transformed afterward.
309
+ */
310
+ function extractClaimRecord(issue, marker) {
311
+ const comments = Array.isArray(issue.comments) ? issue.comments : [];
312
+ for (const comment of comments) {
313
+ const body = String(comment.body ?? "");
314
+ const markerIndex = body.indexOf(marker);
315
+ if (markerIndex === -1)
316
+ continue;
317
+ const fenceMatch = body.slice(markerIndex).match(/```json\s*([\s\S]*?)```/);
318
+ if (!fenceMatch)
319
+ throw new Error(`claim comment (id ${comment.id ?? "?"}) has the claim marker but no fenced JSON block`);
320
+ let parsed;
321
+ try {
322
+ parsed = JSON.parse(fenceMatch[1]);
323
+ }
324
+ catch (error) {
325
+ throw new Error(`claim comment (id ${comment.id ?? "?"}) fenced JSON is unparseable: ${error.message}`);
326
+ }
327
+ if (typeof parsed !== "object" || parsed === null)
328
+ throw new Error(`claim comment (id ${comment.id ?? "?"}) fenced JSON is not an object`);
329
+ const record = parsed;
330
+ if (record.schema_version !== "1.0")
331
+ throw new Error(`claim comment (id ${comment.id ?? "?"}) has unsupported schema_version ${String(record.schema_version)}`);
332
+ if (record.role !== "AssignmentClaimRecord")
333
+ throw new Error(`claim comment (id ${comment.id ?? "?"}) has unexpected role ${String(record.role)}`);
334
+ return sanitizeClaimRecordForDisplay(record);
335
+ }
336
+ return null;
337
+ }
338
+ function githubAssignmentStatus(issue, labelName, marker) {
339
+ const assignees = namesOf(issue.assignees, "login");
340
+ const labels = namesOf(issue.labels, "name");
341
+ const record = extractClaimRecord(issue, marker);
342
+ return {
343
+ subject_id: record?.subject_id ?? "",
344
+ provider: "github",
345
+ assignee: assignees[0] ?? null,
346
+ record,
347
+ has_claim_label: labels.map((label) => label.toLowerCase()).includes(labelName.toLowerCase()),
348
+ };
349
+ }
350
+ function loadLivenessInputs(args) {
351
+ const eventsJsonPath = flagString(args.flags, "liveness-events-json");
352
+ const streamPath = flagString(args.flags, "liveness-stream");
353
+ const selfActor = flagString(args.flags, "self-actor");
354
+ if (eventsJsonPath) {
355
+ const data = loadJsonInput(eventsJsonPath);
356
+ if (!Array.isArray(data))
357
+ throw new Error(`--liveness-events-json must contain a JSON array: ${eventsJsonPath}`);
358
+ return { events: data, selfActor };
359
+ }
360
+ if (streamPath)
361
+ return { events: loadLivenessReadHelper().readLivenessEvents(streamPath), selfActor };
362
+ return { events: null, selfActor };
363
+ }
364
+ // ─── local-file: claim | release | supersede (the durable-write path; real I/O by design — no
365
+ // external mutation to defer to a skill for this provider kind, per Design Decision 1) ─────────
366
+ /**
367
+ * Wave 1 (#291) extraction: the durable-write body previously inlined inside claimLocalFile's
368
+ * withSubjectLock() closure, now a parameter-driven pure function so ensure-session's ownership
369
+ * guard (workflow-sidecar.ts, Wave 2) can reuse the EXACT same claim logic — same-actor idempotent
370
+ * refresh, different-actor throw, atomic write under withSubjectLock — rather than reimplementing
371
+ * a second, parallel claim path. claimLocalFile (CLI wrapper, below) is now a thin
372
+ * parse-args/print-envelope shell around this.
373
+ */
374
+ export function performLocalClaim(artifactRoot, subjectId, actor, opts) {
375
+ const helper = loadActorIdentityHelper();
376
+ const reason = opts.reason ?? "claim";
377
+ // F1 fix (fix-plan iteration 1, CRITICAL): the existing-claim check AND the write must happen
378
+ // atomically with respect to any other `assignment-provider` invocation on the same subject —
379
+ // see withSubjectLock()'s doc comment for the full rationale.
380
+ return withSubjectLock(artifactRoot, subjectId, () => {
381
+ const existing = readLocalRecord(artifactRoot, subjectId);
382
+ if (existing && existing.status === "claimed") {
383
+ const existingActorKey = helper.serializeActor(existing.actor);
384
+ const newActorKey = helper.serializeActor(actor);
385
+ // AC7: a second claim from a different actor must never silently overwrite the first.
386
+ // Same actor re-claiming (refresh before TTL expiry) is allowed and idempotent.
387
+ if (existingActorKey !== newActorKey) {
388
+ throw new Error(`subject already claimed by a different actor: ${existingActorKey} (claimed_at ${existing.claimed_at}); refusing to overwrite — use supersede to reassign`);
389
+ }
390
+ }
391
+ const record = {
392
+ schema_version: "1.0",
393
+ role: "AssignmentClaimRecord",
394
+ subject_id: subjectId,
395
+ actor,
396
+ ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
397
+ claimed_at: isoNow(),
398
+ ttl_seconds: opts.ttlSeconds,
399
+ branch: opts.branch,
400
+ artifact_dir: opts.artifactDir,
401
+ status: "claimed",
402
+ audit_trail: [...(existing?.audit_trail ?? []), { at: isoNow(), transition: "claim", from_actor: null, to_actor: actor, reason }],
403
+ };
404
+ writeLocalRecord(artifactRoot, subjectId, record);
405
+ return record;
406
+ });
407
+ }
408
+ function claimLocalFile(argv) {
409
+ const args = parseArgs(argv);
410
+ const provider = flagString(args.flags, "provider", "local-file");
411
+ if (provider !== "local-file")
412
+ throw new Error(`claim: --provider must be local-file (use render-claim for github); got ${provider}`);
413
+ const artifactRoot = requireFlag(args, "artifact-root");
414
+ const subjectId = requireFlag(args, "subject-id");
415
+ const { actor, actorKey } = loadActorStruct(args);
416
+ const ttlSecondsRaw = flagString(args.flags, "ttl-seconds", "1800") ?? "1800";
417
+ const ttlSeconds = Number(ttlSecondsRaw);
418
+ if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0)
419
+ throw new Error(`--ttl-seconds must be a positive number; got ${ttlSecondsRaw}`);
420
+ const branch = requireFlag(args, "branch");
421
+ const artifactDir = requireFlag(args, "artifact-dir");
422
+ const reason = flagString(args.flags, "reason") ?? "claim";
423
+ const record = performLocalClaim(artifactRoot, subjectId, actor, { ttlSeconds, branch, artifactDir, reason, actorKey });
424
+ console.log(JSON.stringify({ role: "AssignmentClaimResult", subject_id: subjectId, record }, null, 2));
425
+ return 0;
426
+ }
427
+ function releaseLocalFile(argv) {
428
+ const args = parseArgs(argv);
429
+ const provider = flagString(args.flags, "provider", "local-file");
430
+ if (provider !== "local-file")
431
+ throw new Error(`release: --provider must be local-file (use render-release for github); got ${provider}`);
432
+ const artifactRoot = requireFlag(args, "artifact-root");
433
+ const subjectId = requireFlag(args, "subject-id");
434
+ const releasedBy = flagString(args.flags, "actor-json") ? loadActorStructFromFile(requireFlag(args, "actor-json")) : null;
435
+ const reason = flagString(args.flags, "reason") ?? "released";
436
+ // F1 fix (fix-plan iteration 1, CRITICAL): release mutates the same record file claim/supersede
437
+ // do, under the same per-subject lock (see withSubjectLock()'s doc comment).
438
+ return withSubjectLock(artifactRoot, subjectId, () => {
439
+ const existing = readLocalRecord(artifactRoot, subjectId);
440
+ if (!existing || existing.status !== "claimed")
441
+ throw new Error(`no active claim to release for subject: ${subjectId}`);
442
+ const record = {
443
+ ...existing,
444
+ status: "released",
445
+ audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "release", from_actor: existing.actor, to_actor: releasedBy, reason }],
446
+ };
447
+ writeLocalRecord(artifactRoot, subjectId, record);
448
+ console.log(JSON.stringify({ role: "AssignmentReleaseResult", subject_id: subjectId, record }, null, 2));
449
+ return 0;
450
+ });
451
+ }
452
+ /**
453
+ * Wave 1 (#291) extraction: the durable-write body previously inlined inside supersedeLocalFile's
454
+ * withSubjectLock() closure, now a parameter-driven pure function so ensure-session's
455
+ * `--supersede-stale` takeover path (workflow-sidecar.ts, Wave 2) can reuse the EXACT same
456
+ * supersede logic — from-actor holder verification, ttl/branch/artifact_dir carry-forward,
457
+ * audit-trail append, atomic write under withSubjectLock — rather than reimplementing a second,
458
+ * parallel supersede path. supersedeLocalFile (CLI wrapper, below) is now a thin
459
+ * parse-args/print-envelope shell around this.
460
+ */
461
+ export function performLocalSupersede(artifactRoot, subjectId, fromActor, toActor, opts = {}) {
462
+ const helper = loadActorIdentityHelper();
463
+ const reason = opts.reason ?? "supersede";
464
+ // F1 fix (fix-plan iteration 1, CRITICAL): supersede mutates the same record file claim/release
465
+ // do, under the same per-subject lock (see withSubjectLock()'s doc comment).
466
+ return withSubjectLock(artifactRoot, subjectId, () => {
467
+ const existing = readLocalRecord(artifactRoot, subjectId);
468
+ if (!existing || existing.status !== "claimed")
469
+ throw new Error(`no active claim to supersede for subject: ${subjectId}`);
470
+ if (helper.serializeActor(existing.actor) !== helper.serializeActor(fromActor)) {
471
+ 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`);
472
+ }
473
+ const ttlSecondsRaw = opts.ttlSeconds != null ? String(opts.ttlSeconds) : String(existing.ttl_seconds);
474
+ const ttlSeconds = Number(ttlSecondsRaw);
475
+ if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0)
476
+ throw new Error(`--ttl-seconds must be a positive number; got ${ttlSecondsRaw}`);
477
+ const record = {
478
+ schema_version: "1.0",
479
+ role: "AssignmentClaimRecord",
480
+ subject_id: subjectId,
481
+ actor: toActor,
482
+ ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
483
+ claimed_at: isoNow(),
484
+ ttl_seconds: ttlSeconds,
485
+ branch: opts.branch ?? existing.branch,
486
+ artifact_dir: opts.artifactDir ?? existing.artifact_dir,
487
+ status: "claimed",
488
+ audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "supersede", from_actor: fromActor, to_actor: toActor, reason }],
489
+ };
490
+ writeLocalRecord(artifactRoot, subjectId, record);
491
+ return record;
492
+ });
493
+ }
494
+ function supersedeLocalFile(argv) {
495
+ const args = parseArgs(argv);
496
+ const provider = flagString(args.flags, "provider", "local-file");
497
+ if (provider !== "local-file")
498
+ throw new Error(`supersede: --provider must be local-file (use render-supersede for github); got ${provider}`);
499
+ const artifactRoot = requireFlag(args, "artifact-root");
500
+ const subjectId = requireFlag(args, "subject-id");
501
+ const fromActor = loadActorStructFromFile(requireFlag(args, "from-actor-json"));
502
+ const toActor = loadActorStructFromFile(requireFlag(args, "to-actor-json"));
503
+ const reason = flagString(args.flags, "reason") ?? "supersede";
504
+ const ttlSecondsOverride = flagString(args.flags, "ttl-seconds");
505
+ const branchOverride = flagString(args.flags, "branch");
506
+ const artifactDirOverride = flagString(args.flags, "artifact-dir");
507
+ const record = performLocalSupersede(artifactRoot, subjectId, fromActor, toActor, {
508
+ ttlSeconds: ttlSecondsOverride != null ? Number(ttlSecondsOverride) : undefined,
509
+ branch: branchOverride ?? undefined,
510
+ artifactDir: artifactDirOverride ?? undefined,
511
+ reason,
512
+ });
513
+ console.log(JSON.stringify({ role: "AssignmentSupersedeResult", subject_id: subjectId, record }, null, 2));
514
+ return 0;
515
+ }
516
+ // ─── GitHub: render-claim | render-release | render-supersede (render, don't execute — Design
517
+ // Decision 1). Pure functions: no I/O beyond reading --input-json/--actor-json. Never invoke
518
+ // `gh` (or any process) here — the calling skill runs the emitted argv verbatim. ────────────────
519
+ function requireRepo(input) {
520
+ const repo = input.repo;
521
+ if (!repo || !repo.owner || !repo.name)
522
+ throw new Error("input-json.repo.owner and input-json.repo.name are required");
523
+ return { owner: repo.owner, name: repo.name };
524
+ }
525
+ function requireIssueNumber(input) {
526
+ const issueNumber = input.issue_number;
527
+ if (!Number.isFinite(issueNumber))
528
+ throw new Error("input-json.issue_number is required");
529
+ return Number(issueNumber);
530
+ }
531
+ function renderClaimCommentBody(record, marker) {
532
+ const humanNote = record.actor.human ? `Assigned to human ${record.actor.human}.` : `Claimed by an automated agent session (${record.actor.runtime}).`;
533
+ return [
534
+ marker,
535
+ `**Assignment claim** — ${humanNote}`,
536
+ "",
537
+ `- actor: \`${loadActorIdentityHelper().serializeActor(record.actor)}\``,
538
+ `- claimed_at: ${record.claimed_at}`,
539
+ `- ttl_seconds: ${record.ttl_seconds}`,
540
+ `- branch: \`${record.branch}\``,
541
+ "",
542
+ "```json",
543
+ JSON.stringify(record, null, 2),
544
+ "```",
545
+ ].join("\n");
546
+ }
547
+ function renderHandoffCommentBody(subjectId, input) {
548
+ const marker = input.claim_comment_marker ?? CLAIM_COMMENT_MARKER_DEFAULT;
549
+ const record = input.previous_record
550
+ ? {
551
+ ...input.previous_record,
552
+ status: "released",
553
+ audit_trail: [...(input.previous_record.audit_trail ?? []), { at: isoNow(), transition: "release", from_actor: input.previous_record.actor, to_actor: null, reason: input.reason ?? "released" }],
554
+ }
555
+ : null;
556
+ const lines = [marker, `**Assignment released** — subject \`${subjectId}\` is free.`];
557
+ if (record)
558
+ lines.push("", "```json", JSON.stringify(record, null, 2), "```");
559
+ return lines.join("\n");
560
+ }
561
+ function renderClaim(argv) {
562
+ const args = parseArgs(argv);
563
+ const provider = flagString(args.flags, "provider", "github");
564
+ if (provider !== "github")
565
+ throw new Error(`render-claim: --provider must be github; got ${provider}`);
566
+ const subjectId = requireFlag(args, "subject-id");
567
+ const input = loadJsonInput(requireFlag(args, "input-json"));
568
+ const actor = loadActorStructFromFile(requireFlag(args, "actor-json"));
569
+ const repo = requireRepo(input);
570
+ const issueNumber = requireIssueNumber(input);
571
+ const labelName = input.label_name ?? DEFAULT_LABEL_NAME;
572
+ const marker = input.claim_comment_marker ?? CLAIM_COMMENT_MARKER_DEFAULT;
573
+ const ttlSeconds = input.ttl_seconds ?? 1800;
574
+ const branch = input.branch;
575
+ const artifactDir = input.artifact_dir;
576
+ if (!branch)
577
+ throw new Error("input-json.branch is required for render-claim");
578
+ if (!artifactDir)
579
+ throw new Error("input-json.artifact_dir is required for render-claim");
580
+ const record = {
581
+ schema_version: "1.0",
582
+ role: "AssignmentClaimRecord",
583
+ subject_id: subjectId,
584
+ actor,
585
+ claimed_at: isoNow(),
586
+ ttl_seconds: ttlSeconds,
587
+ branch,
588
+ artifact_dir: artifactDir,
589
+ status: "claimed",
590
+ };
591
+ const repoSlug = `${repo.owner}/${repo.name}`;
592
+ const commentBody = renderClaimCommentBody(record, marker);
593
+ const ghCommands = [];
594
+ if (input.assignee_login)
595
+ ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--add-assignee", input.assignee_login]);
596
+ ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--add-label", labelName]);
597
+ ghCommands.push(input.existing_comment_id
598
+ ? ["gh", "api", "--method", "PATCH", `repos/${repoSlug}/issues/comments/${input.existing_comment_id}`, "-f", `body=${commentBody}`]
599
+ : ["gh", "issue", "comment", String(issueNumber), "--repo", repoSlug, "--body", commentBody]);
600
+ console.log(JSON.stringify({ role: "AssignmentRenderResult", transition: "claim", subject_id: subjectId, gh_commands: ghCommands, claim_comment_body: commentBody, record }, null, 2));
601
+ return 0;
602
+ }
603
+ function renderRelease(argv) {
604
+ const args = parseArgs(argv);
605
+ const provider = flagString(args.flags, "provider", "github");
606
+ if (provider !== "github")
607
+ throw new Error(`render-release: --provider must be github; got ${provider}`);
608
+ const subjectId = requireFlag(args, "subject-id");
609
+ const input = loadJsonInput(requireFlag(args, "input-json"));
610
+ const repo = requireRepo(input);
611
+ const issueNumber = requireIssueNumber(input);
612
+ const labelName = input.label_name ?? DEFAULT_LABEL_NAME;
613
+ const repoSlug = `${repo.owner}/${repo.name}`;
614
+ const ghCommands = [];
615
+ const assigneeLogin = input.existing_assignee_login ?? input.assignee_login;
616
+ if (assigneeLogin)
617
+ ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--remove-assignee", assigneeLogin]);
618
+ ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--remove-label", labelName]);
619
+ const handoffBody = renderHandoffCommentBody(subjectId, input);
620
+ ghCommands.push(input.existing_comment_id
621
+ ? ["gh", "api", "--method", "PATCH", `repos/${repoSlug}/issues/comments/${input.existing_comment_id}`, "-f", `body=${handoffBody}`]
622
+ : ["gh", "issue", "comment", String(issueNumber), "--repo", repoSlug, "--body", handoffBody]);
623
+ console.log(JSON.stringify({ role: "AssignmentRenderResult", transition: "release", subject_id: subjectId, gh_commands: ghCommands, claim_comment_body: handoffBody }, null, 2));
624
+ return 0;
625
+ }
626
+ function renderSupersede(argv) {
627
+ const args = parseArgs(argv);
628
+ const provider = flagString(args.flags, "provider", "github");
629
+ if (provider !== "github")
630
+ throw new Error(`render-supersede: --provider must be github; got ${provider}`);
631
+ const subjectId = requireFlag(args, "subject-id");
632
+ const input = loadJsonInput(requireFlag(args, "input-json"));
633
+ const toActor = loadActorStructFromFile(requireFlag(args, "actor-json"));
634
+ const repo = requireRepo(input);
635
+ const issueNumber = requireIssueNumber(input);
636
+ const labelName = input.label_name ?? DEFAULT_LABEL_NAME;
637
+ const marker = input.claim_comment_marker ?? CLAIM_COMMENT_MARKER_DEFAULT;
638
+ const ttlSeconds = input.ttl_seconds ?? 1800;
639
+ const branch = input.branch;
640
+ const artifactDir = input.artifact_dir;
641
+ if (!branch)
642
+ throw new Error("input-json.branch is required for render-supersede");
643
+ if (!artifactDir)
644
+ throw new Error("input-json.artifact_dir is required for render-supersede");
645
+ // Wave 4 AC: render-supersede must edit the existing claim comment in place, never duplicate it.
646
+ if (!input.existing_comment_id)
647
+ throw new Error("input-json.existing_comment_id is required for render-supersede (edits the claim comment in place; never duplicates it)");
648
+ const previousActor = input.previous_record?.actor ?? null;
649
+ const record = {
650
+ schema_version: "1.0",
651
+ role: "AssignmentClaimRecord",
652
+ subject_id: subjectId,
653
+ actor: toActor,
654
+ claimed_at: isoNow(),
655
+ ttl_seconds: ttlSeconds,
656
+ branch,
657
+ artifact_dir: artifactDir,
658
+ status: "claimed",
659
+ audit_trail: [...(input.previous_record?.audit_trail ?? []), { at: isoNow(), transition: "supersede", from_actor: previousActor, to_actor: toActor, reason: input.reason ?? "supersede" }],
660
+ };
661
+ const repoSlug = `${repo.owner}/${repo.name}`;
662
+ const commentBody = renderClaimCommentBody(record, marker);
663
+ const ghCommands = [];
664
+ const previousAssignee = input.existing_assignee_login;
665
+ if (previousAssignee && previousAssignee !== input.assignee_login)
666
+ ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--remove-assignee", previousAssignee]);
667
+ if (input.assignee_login)
668
+ ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--add-assignee", input.assignee_login]);
669
+ ghCommands.push(["gh", "issue", "edit", String(issueNumber), "--repo", repoSlug, "--add-label", labelName]);
670
+ ghCommands.push(["gh", "api", "--method", "PATCH", `repos/${repoSlug}/issues/comments/${input.existing_comment_id}`, "-f", `body=${commentBody}`]);
671
+ console.log(JSON.stringify({ role: "AssignmentRenderResult", transition: "supersede", subject_id: subjectId, gh_commands: ghCommands, claim_comment_body: commentBody, record }, null, 2));
672
+ return 0;
673
+ }
674
+ // ─── status | list (both provider kinds) ────────────────────────────────────────────────────
675
+ /**
676
+ * Wave 1 (#291) extraction: the local-file branch of statusCommand's assignment-layer read,
677
+ * mirrored exactly so ensure-session's ownership guard (workflow-sidecar.ts, Wave 2) derives an
678
+ * AssignmentStatus identically to the `assignment-provider status` CLI command — a single
679
+ * implementation, not a second parallel local-file read.
680
+ */
681
+ export function readLocalAssignmentStatus(artifactRoot, subjectId) {
682
+ const record = readLocalRecord(artifactRoot, subjectId);
683
+ const active = record && record.status === "claimed" ? record : null;
684
+ return { subject_id: subjectId, provider: "local-file", assignee: active ? loadActorIdentityHelper().serializeActor(active.actor) : null, record: active };
685
+ }
686
+ function statusCommand(argv) {
687
+ const args = parseArgs(argv);
688
+ const provider = requireFlag(args, "provider");
689
+ const requestedSubjectId = flagString(args.flags, "subject-id");
690
+ let assignment;
691
+ if (provider === "local-file") {
692
+ const artifactRoot = requireFlag(args, "artifact-root");
693
+ if (!requestedSubjectId)
694
+ throw new Error("--subject-id is required for status --provider local-file");
695
+ assignment = readLocalAssignmentStatus(artifactRoot, requestedSubjectId);
696
+ }
697
+ else if (provider === "github") {
698
+ const issueJsonPath = requireFlag(args, "issue-json");
699
+ const issue = loadJsonInput(issueJsonPath);
700
+ const labelName = flagString(args.flags, "label-name", DEFAULT_LABEL_NAME) ?? DEFAULT_LABEL_NAME;
701
+ const marker = flagString(args.flags, "claim-comment-marker", CLAIM_COMMENT_MARKER_DEFAULT) ?? CLAIM_COMMENT_MARKER_DEFAULT;
702
+ assignment = githubAssignmentStatus(issue, labelName, marker);
703
+ if (requestedSubjectId && assignment.record && assignment.record.subject_id !== requestedSubjectId) {
704
+ throw new Error(`claim record subject_id ${assignment.record.subject_id} does not match requested --subject-id ${requestedSubjectId}`);
705
+ }
706
+ if (requestedSubjectId)
707
+ assignment.subject_id = requestedSubjectId;
708
+ }
709
+ else {
710
+ throw new Error(`status: unsupported --provider ${provider}`);
711
+ }
712
+ const { events, selfActor } = loadLivenessInputs(args);
713
+ const nowMs = flagString(args.flags, "now") ? Date.parse(flagString(args.flags, "now")) : Date.now();
714
+ const freshList = events !== null ? loadLivenessReadHelper().freshHolders(events, assignment.subject_id, selfActor ?? "", nowMs) : [];
715
+ const effective = events !== null
716
+ ? computeEffectiveState(assignment, freshList, selfActor, nowMs)
717
+ : { effective_state: null, reason: "liveness input not provided (pass --liveness-events-json or --liveness-stream); effective state not computed" };
718
+ console.log(JSON.stringify({ role: "AssignmentStatus", provider, assignment, effective }, null, 2));
719
+ return 0;
720
+ }
721
+ function listCommand(argv) {
722
+ const args = parseArgs(argv);
723
+ const provider = requireFlag(args, "provider");
724
+ const actorJsonFilter = flagString(args.flags, "actor-json");
725
+ const actorFilter = actorJsonFilter ? loadActorIdentityHelper().serializeActor(loadActorStructFromFile(actorJsonFilter)) : flagString(args.flags, "actor");
726
+ const subjectIds = [];
727
+ if (provider === "local-file") {
728
+ const artifactRoot = requireFlag(args, "artifact-root");
729
+ const dir = path.join(artifactRoot, "assignment");
730
+ const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((name) => name.endsWith(".json")).sort() : [];
731
+ for (const name of files) {
732
+ const record = readJson(path.join(dir, name));
733
+ if (record.status !== "claimed")
734
+ continue;
735
+ if (actorFilter && loadActorIdentityHelper().serializeActor(record.actor) !== actorFilter)
736
+ continue;
737
+ subjectIds.push(record.subject_id);
738
+ }
739
+ }
740
+ else if (provider === "github") {
741
+ const issuesJsonPath = requireFlag(args, "issues-json");
742
+ const doc = loadJsonInput(issuesJsonPath);
743
+ const issues = Array.isArray(doc) ? doc : (doc.items ?? []);
744
+ const labelName = flagString(args.flags, "label-name", DEFAULT_LABEL_NAME) ?? DEFAULT_LABEL_NAME;
745
+ const marker = flagString(args.flags, "claim-comment-marker", CLAIM_COMMENT_MARKER_DEFAULT) ?? CLAIM_COMMENT_MARKER_DEFAULT;
746
+ for (const issue of issues) {
747
+ const assignment = githubAssignmentStatus(issue, labelName, marker);
748
+ if (!assignment.record || assignment.record.status !== "claimed")
749
+ continue;
750
+ if (actorFilter && loadActorIdentityHelper().serializeActor(assignment.record.actor) !== actorFilter)
751
+ continue;
752
+ subjectIds.push(assignment.record.subject_id);
753
+ }
754
+ }
755
+ else {
756
+ throw new Error(`list: unsupported --provider ${provider}`);
757
+ }
758
+ console.log(JSON.stringify({ role: "AssignmentList", provider, actor: actorFilter ?? null, subject_ids: subjectIds }, null, 2));
759
+ return 0;
760
+ }
761
+ export function main(argv = process.argv.slice(2)) {
762
+ try {
763
+ const [command, ...rest] = argv;
764
+ if (command === "claim")
765
+ return claimLocalFile(rest);
766
+ if (command === "release")
767
+ return releaseLocalFile(rest);
768
+ if (command === "supersede")
769
+ return supersedeLocalFile(rest);
770
+ if (command === "render-claim")
771
+ return renderClaim(rest);
772
+ if (command === "render-release")
773
+ return renderRelease(rest);
774
+ if (command === "render-supersede")
775
+ return renderSupersede(rest);
776
+ if (command === "status")
777
+ return statusCommand(rest);
778
+ if (command === "list")
779
+ return listCommand(rest);
780
+ console.error("usage: assignment-provider <claim|release|supersede|render-claim|render-release|render-supersede|status|list> [flags]");
781
+ return 2;
782
+ }
783
+ catch (error) {
784
+ console.error(`assignment-provider: ${error.message}`);
785
+ return 1;
786
+ }
787
+ }
788
+ // Use process.exitCode (not process.exit) to allow stdout to be flushed before exit.
789
+ // Resolve real paths to handle symlinks (e.g. /tmp -> /private/tmp on macOS) so the
790
+ // entry-point guard fires correctly when the module is loaded directly as a script.
791
+ const _selfRealPath = (() => { try {
792
+ return fs.realpathSync(fileURLToPath(import.meta.url));
793
+ }
794
+ catch {
795
+ return fileURLToPath(import.meta.url);
796
+ } })();
797
+ const _argv1RealPath = (() => { try {
798
+ return fs.realpathSync(process.argv[1]);
799
+ }
800
+ catch {
801
+ return process.argv[1];
802
+ } })();
803
+ if (_selfRealPath === _argv1RealPath) {
804
+ process.exitCode = main();
805
+ }