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