blun-king-cli 9.1.564 → 9.1.566

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 (88) hide show
  1. package/LIESMICH.txt +2 -2
  2. package/README.md +1 -1
  3. package/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
  4. package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
  5. package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
  6. package/agent-spine-plugin/.mcp.json +8 -8
  7. package/agent-spine-plugin/CONTRIBUTING.md +52 -0
  8. package/agent-spine-plugin/LICENSE +186 -186
  9. package/agent-spine-plugin/README.md +406 -0
  10. package/agent-spine-plugin/SECURITY.md +47 -0
  11. package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
  12. package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
  13. package/agent-spine-plugin/bin/agentspine.js +7 -7
  14. package/agent-spine-plugin/blun.plugin.json +33 -33
  15. package/agent-spine-plugin/hooks/codex.json +47 -47
  16. package/agent-spine-plugin/hooks/hooks.json +106 -106
  17. package/agent-spine-plugin/hooks/version.json +5 -5
  18. package/agent-spine-plugin/package.json +69 -69
  19. package/agent-spine-plugin/scripts/check-hosts.js +199 -199
  20. package/agent-spine-plugin/skill/SKILL.md +76 -76
  21. package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
  22. package/agent-spine-plugin/src/cli.js +1488 -1442
  23. package/agent-spine-plugin/src/hook.js +882 -861
  24. package/agent-spine-plugin/src/index.js +94 -93
  25. package/agent-spine-plugin/src/lib/acceptance.js +333 -333
  26. package/agent-spine-plugin/src/lib/attention.js +755 -755
  27. package/agent-spine-plugin/src/lib/audit.js +351 -342
  28. package/agent-spine-plugin/src/lib/authentication.js +515 -515
  29. package/agent-spine-plugin/src/lib/briefing.js +317 -317
  30. package/agent-spine-plugin/src/lib/catalog.js +167 -167
  31. package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
  32. package/agent-spine-plugin/src/lib/context.js +154 -154
  33. package/agent-spine-plugin/src/lib/continuity.js +338 -338
  34. package/agent-spine-plugin/src/lib/coordination.js +577 -577
  35. package/agent-spine-plugin/src/lib/documents.js +217 -217
  36. package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
  37. package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
  38. package/agent-spine-plugin/src/lib/gateway-runtime.js +1623 -760
  39. package/agent-spine-plugin/src/lib/graph.js +337 -337
  40. package/agent-spine-plugin/src/lib/hook-audit.js +29 -29
  41. package/agent-spine-plugin/src/lib/https-transport.js +392 -392
  42. package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
  43. package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
  44. package/agent-spine-plugin/src/lib/learning.js +6923 -6466
  45. package/agent-spine-plugin/src/lib/object-transport.js +206 -206
  46. package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
  47. package/agent-spine-plugin/src/lib/paths.js +109 -109
  48. package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
  49. package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
  50. package/agent-spine-plugin/src/lib/preflight.js +702 -702
  51. package/agent-spine-plugin/src/lib/runtime.js +13 -13
  52. package/agent-spine-plugin/src/lib/selfstarter.js +911 -866
  53. package/agent-spine-plugin/src/lib/sharing.js +969 -969
  54. package/agent-spine-plugin/src/lib/source-roots.js +530 -498
  55. package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
  56. package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
  57. package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
  58. package/agent-spine-plugin/src/mcp.js +597 -572
  59. package/agent-spine-plugin/src/version.js +1 -1
  60. package/agent-spine-plugin/src/worker.js +210 -195
  61. package/bin/active-steer-priority-policy.cjs +24 -0
  62. package/bin/launcher-runtime.js +8 -1
  63. package/bin/mnemo-tool-agent-policy.cjs +22 -0
  64. package/bin/native-runtime-cache.cjs +76 -0
  65. package/bin/thinking-activity-status-policy.cjs +132 -0
  66. package/bin/thinking-only-guard.cjs +75 -0
  67. package/bin/tool-call-loop-policy.cjs +53 -0
  68. package/bin/turn-thinking-policy.cjs +25 -1
  69. package/blun.mjs +556 -63
  70. package/package.json +4 -1
  71. package/standard-skills/translate-native/LICENSE +21 -21
  72. package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
  73. package/standard-skills/translate-native/references/native-orthography.md +79 -79
  74. package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
  75. package/standard-skills/translate-native/references/structured-content.md +72 -72
  76. package/standard-skills/translate-native/references/translationese-review.md +77 -77
  77. package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
  78. package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
  79. package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
  80. package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
  81. package/standard-skills/translate-native/scripts/language_quality.py +377 -377
  82. package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
  83. package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
  84. package/standard-tools/language-guard/blun_language_guard.py +697 -697
  85. package/standard-tools/language-guard/check_diacritics.py +353 -353
  86. package/standard-tools/language-guard/guard_service_client.py +264 -264
  87. package/standard-tools/language-guard/language_quality.py +377 -377
  88. package/standard-tools/language-guard/translation_guard.py +916 -916
@@ -1,866 +1,911 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { lstat, open, opendir, readFile, stat, unlink, writeFile } from "node:fs/promises";
3
- import { join, relative, resolve, sep } from "node:path";
4
- import { buildCatalog } from "./catalog.js";
5
- import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
6
- import { loadCoordination } from "./coordination.js";
7
- import { loadGraph } from "./graph.js";
8
- import { projectStateDir } from "./paths.js";
9
- import { isInaccessibleScanError } from "./source-roots.js";
10
-
11
- const POLICY_SCHEMA = "agentspine.execution-policy/v1";
12
- const JOB_SCHEMA = "agentspine.selfstarter/v1";
13
- const ACTIONS = new Set(["start", "resume", "effect"]);
14
- const JOB_STATUSES = new Set(["waiting", "running", "blocked", "completed", "cancelled", "exhausted"]);
15
- const HOSTS = new Set(["claude", "codex"]);
16
- const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
17
- const CAPABILITY_RE = /^tool:[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
18
- const DIGEST_RE = /^[a-f0-9]{64}$/;
19
- const MAX_STATE_BYTES = 5 * 1024 * 1024;
20
- const MAX_WORKSPACE_FILES = 10000;
21
- const MAX_WORKSPACE_BYTES = 64 * 1024 * 1024;
22
- const CONFIRMATION = "local-owner-confirmed";
23
- const EXCLUDED_NAMES = new Set([".git", "node_modules", ".DS_Store"]);
24
- const SECRET_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|gh[opusu])_[A-Za-z0-9_-]{20,}|\bBearer\s+[A-Za-z0-9._~+/-]{20,}|\b(?:api[-_ ]?key|token|password|secret)\s*[:=]\s*\S{8,}/i;
25
-
26
- function emptyPolicy(root) {
27
- return { schema: POLICY_SCHEMA, root, revision: 0, grants: [], history: [] };
28
- }
29
-
30
- function emptyJobs(root) {
31
- return { schema: JOB_SCHEMA, root, jobs: [], history: [], receipts: [] };
32
- }
33
-
34
- function timestamp(value, field = "date", nullable = false) {
35
- if ((value === null || value === undefined || value === "") && nullable) return null;
36
- const parsed = value instanceof Date ? value : new Date(value);
37
- if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
38
- return parsed.toISOString();
39
- }
40
-
41
- function stableId(value, field, nullable = false) {
42
- if ((value === null || value === undefined || value === "") && nullable) return null;
43
- if (typeof value !== "string" || !ID_RE.test(value)) throw new Error(`${field} must be a stable whitespace-free ID`);
44
- return value;
45
- }
46
-
47
- function safeText(value, field, maximum, nullable = false) {
48
- if ((value === null || value === undefined || value === "") && nullable) return null;
49
- if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
50
- const result = value.trim().slice(0, maximum);
51
- if (SECRET_RE.test(result)) throw new Error(`${field} appears to contain a secret and cannot enter self-starter state`);
52
- return result;
53
- }
54
-
55
- function integer(value, field, minimum, maximum) {
56
- const parsed = Number(value);
57
- if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) throw new Error(`${field} must be an integer between ${minimum} and ${maximum}`);
58
- return parsed;
59
- }
60
-
61
- function capabilities(value) {
62
- if (!Array.isArray(value) || !value.length || value.some((item) => typeof item !== "string" || !CAPABILITY_RE.test(item))) {
63
- throw new Error("capabilities must contain exact tool:<name> values and cannot use wildcards");
64
- }
65
- const unique = [...new Set(value)].sort();
66
- if (unique.some((item) => item.includes("*"))) throw new Error("capability wildcards are forbidden");
67
- return unique;
68
- }
69
-
70
- function normalizePolicy(value, root) {
71
- if (!value || typeof value !== "object" || Array.isArray(value) || value.schema !== POLICY_SCHEMA || value.root !== root
72
- || !Number.isInteger(value.revision) || value.revision < 0 || !Array.isArray(value.grants) || !Array.isArray(value.history)) {
73
- throw new Error("execution policy structure is invalid; self-starter is disabled until repaired");
74
- }
75
- return value;
76
- }
77
-
78
- function normalizeJobs(value, root) {
79
- if (!value || typeof value !== "object" || Array.isArray(value) || value.schema !== JOB_SCHEMA || value.root !== root
80
- || !Array.isArray(value.jobs) || !Array.isArray(value.history) || !Array.isArray(value.receipts)) {
81
- throw new Error("self-starter checkpoint structure is invalid; automatic execution is disabled until repaired");
82
- }
83
- return value;
84
- }
85
-
86
- async function readJson(path, root, normalize, empty) {
87
- try {
88
- const metadata = await stat(path);
89
- if (metadata.size > MAX_STATE_BYTES) throw new Error("self-starter state exceeds the 5 MiB read limit");
90
- return normalize(JSON.parse(await readFile(path, "utf8")), root);
91
- } catch (error) {
92
- if (error.code !== "ENOENT") throw error;
93
- return empty(root);
94
- }
95
- }
96
-
97
- async function saveJson(value, path) {
98
- const content = `${JSON.stringify(value, null, 2)}\n`;
99
- if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("self-starter state exceeds 5 MiB; purge completed jobs first");
100
- const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
101
- try {
102
- await writeFile(temporary, content, { mode: 0o600 });
103
- await replaceFileWithRetry(temporary, path);
104
- } finally {
105
- await unlink(temporary).catch((error) => { if (error.code !== "ENOENT") throw error; });
106
- }
107
- }
108
-
109
- async function acquireLock(path) {
110
- const lockPath = `${path}.lock`;
111
- for (let attempt = 0; attempt < 120; attempt += 1) {
112
- try {
113
- return { handle: await open(lockPath, "wx", 0o600), lockPath };
114
- } catch (error) {
115
- if (!isFileLockContention(error)) throw error;
116
- try {
117
- const metadata = await stat(lockPath);
118
- if (Date.now() - metadata.mtimeMs > 90000) await unlink(lockPath);
119
- } catch (lockError) {
120
- if (lockError.code !== "ENOENT") throw lockError;
121
- }
122
- await new Promise((resolveDelay) => setTimeout(resolveDelay, 25));
123
- }
124
- }
125
- throw new Error("self-starter state is busy; retry later");
126
- }
127
-
128
- async function withLock(path, read, operation, save = true) {
129
- const { handle, lockPath } = await acquireLock(path);
130
- try {
131
- const state = await read();
132
- const result = await operation(state);
133
- if (save) await saveJson(state, path);
134
- return result;
135
- } finally {
136
- await handle.close();
137
- await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
138
- }
139
- }
140
-
141
- async function pathsFor(root, providedCatalog = null) {
142
- const catalog = providedCatalog || await buildCatalog(root);
143
- const directory = await projectStateDir(catalog.root);
144
- return {
145
- catalog,
146
- executionPolicyPath: join(directory, "execution-policy.json"),
147
- selfstarterPath: join(directory, "selfstarter.json")
148
- };
149
- }
150
-
151
- async function collectWorkspaceFiles(root) {
152
- const files = [];
153
- const skippedInaccessibleEntries = [];
154
- let totalBytes = 0;
155
- async function walk(directory) {
156
- let stream;
157
- try {
158
- stream = await opendir(directory);
159
- } catch (error) {
160
- if (!isInaccessibleScanError(error)) throw error;
161
- skippedInaccessibleEntries.push({
162
- relativePath: relative(root, directory).split(sep).join("/") || ".",
163
- code: error.code,
164
- kind: "directory"
165
- });
166
- return;
167
- }
168
- const entries = [];
169
- for await (const entry of stream) entries.push(entry);
170
- entries.sort((a, b) => a.name.localeCompare(b.name));
171
- for (const entry of entries) {
172
- if (EXCLUDED_NAMES.has(entry.name)) continue;
173
- const path = join(directory, entry.name);
174
- let metadata;
175
- try {
176
- metadata = await lstat(path);
177
- } catch (error) {
178
- if (!isInaccessibleScanError(error)) throw error;
179
- skippedInaccessibleEntries.push({
180
- relativePath: relative(root, path).split(sep).join("/"),
181
- code: error.code,
182
- kind: "entry"
183
- });
184
- continue;
185
- }
186
- if (metadata.isSymbolicLink()) throw new Error(`workspace fingerprint rejects symbolic link: ${relative(root, path)}`);
187
- if (metadata.isDirectory()) await walk(path);
188
- else if (metadata.isFile()) {
189
- files.push({ path, relativePath: relative(root, path).split(sep).join("/"), size: metadata.size });
190
- totalBytes += metadata.size;
191
- if (files.length > MAX_WORKSPACE_FILES || totalBytes > MAX_WORKSPACE_BYTES) {
192
- throw new Error("workspace exceeds the self-starter fingerprint limit");
193
- }
194
- }
195
- }
196
- }
197
- await walk(root);
198
- return { files, skippedInaccessibleEntries };
199
- }
200
-
201
- export async function workspaceFingerprint(inputRoot = process.cwd()) {
202
- const root = resolve(inputRoot);
203
- const { files, skippedInaccessibleEntries } = await collectWorkspaceFiles(root);
204
- const hash = createHash("sha256");
205
- for (const skipped of skippedInaccessibleEntries) {
206
- hash.update("inaccessible\0").update(skipped.kind).update("\0")
207
- .update(skipped.relativePath).update("\0").update(skipped.code).update("\0");
208
- }
209
- for (const file of files) {
210
- hash.update(file.relativePath).update("\0").update(String(file.size)).update("\0");
211
- hash.update(await readFile(file.path)).update("\0");
212
- }
213
- return {
214
- digest: hash.digest("hex"), files: files.length,
215
- bytes: files.reduce((sum, file) => sum + file.size, 0),
216
- skippedInaccessibleEntries: skippedInaccessibleEntries.length,
217
- skippedInaccessibleDetails: skippedInaccessibleEntries
218
- };
219
- }
220
-
221
- function incompleteWorkspaceScan(skipped) {
222
- const first = skipped[0];
223
- const error = new Error(`workspace scan skipped ${skipped.length} inaccessible path${skipped.length === 1 ? "" : "s"}: ${first.relativePath}`);
224
- error.code = "AGENTSPINE_SCAN_INCOMPLETE";
225
- error.path = first.relativePath;
226
- error.syscall = first.kind;
227
- error.agentSpineScan = true;
228
- error.skipped = skipped;
229
- return error;
230
- }
231
-
232
- function knownActor(graph, id) {
233
- return graph.entities.find((entity) => entity.id === id && ["person", "agent"].includes(entity.kind));
234
- }
235
-
236
- function currentTask(coordination, id) {
237
- return coordination.tasks.find((task) => task.id === id);
238
- }
239
-
240
- async function referenceState(catalog) {
241
- const [{ graph }, { coordination }] = await Promise.all([
242
- loadGraph(catalog.root, catalog), loadCoordination(catalog.root, catalog)
243
- ]);
244
- return { graph, coordination };
245
- }
246
-
247
- function validateGrantReferences(grant, graph, coordination) {
248
- const task = currentTask(coordination, grant.taskId);
249
- return Boolean(knownActor(graph, grant.actorId) && knownActor(graph, grant.targetId) && task
250
- && task.assigneeId === grant.actorId && task.projectId === grant.projectId && task.groupId === grant.groupId
251
- && ["open", "in-progress"].includes(task.status));
252
- }
253
-
254
- function executionPolicyFindingsInternal(policy, graph, coordination, { checkCurrentReferences = true } = {}) {
255
- const findings = [];
256
- if (policy.schema !== POLICY_SCHEMA || !Number.isInteger(policy.revision) || policy.revision < 0) findings.push("invalid-execution-policy");
257
- if (new Set(policy.grants.map((grant) => grant.id)).size !== policy.grants.length) findings.push("duplicate-execution-grant-id");
258
- const records = [
259
- ...policy.grants.map((grant) => ({ grant, current: true })),
260
- ...policy.history.map((entry) => entry.value).filter(Boolean).map((grant) => ({ grant, current: false }))
261
- ];
262
- for (const { grant, current } of records) {
263
- const valid = ID_RE.test(grant.id || "") && ID_RE.test(grant.jobId || "") && ID_RE.test(grant.taskId || "")
264
- && ID_RE.test(grant.actorId || "") && ID_RE.test(grant.targetId || "") && ID_RE.test(grant.projectId || "")
265
- && (grant.groupId === null || ID_RE.test(grant.groupId || "")) && HOSTS.has(grant.host)
266
- && JSON.stringify(grant.actions) === JSON.stringify(["effect", "resume", "start"])
267
- && Array.isArray(grant.capabilities) && grant.capabilities.length > 0
268
- && JSON.stringify(grant.capabilities) === JSON.stringify([...new Set(grant.capabilities)].sort())
269
- && grant.capabilities.every((item) => CAPABILITY_RE.test(item) && !item.includes("*"))
270
- && typeof grant.active === "boolean" && typeof grant.reason === "string" && !SECRET_RE.test(grant.reason)
271
- && Number.isInteger(grant.revision) && grant.revision > 0 && grant.revision <= policy.revision
272
- && grant.source === "explicit-local-owner-policy" && grant.authority === "explicit-local-execution-policy"
273
- && typeof grant.createdAt === "string" && typeof grant.updatedAt === "string"
274
- && (grant.expiresAt === null || Number.isFinite(new Date(grant.expiresAt).getTime()));
275
- if (!valid) findings.push(`invalid-execution-grant:${grant.id || "unknown"}`);
276
- if (checkCurrentReferences && current && grant.active && !validateGrantReferences(grant, graph, coordination)) {
277
- findings.push(`stale-execution-grant:${grant.id || "unknown"}`);
278
- }
279
- }
280
- if (policy.history.some((entry) => entry.kind !== "execution-grant" || !ID_RE.test(entry.recordId || "")
281
- || !Number.isFinite(new Date(entry.supersededAt).getTime())
282
- || entry.authority !== "explicit-local-execution-policy" || !entry.value)) findings.push("invalid-execution-policy-history");
283
- return findings;
284
- }
285
-
286
- function receiptDigest(value) {
287
- return createHash("sha256").update(JSON.stringify(value)).digest("hex");
288
- }
289
-
290
- function validLease(value) {
291
- return value === null || (value && ID_RE.test(value.sessionId || "") && HOSTS.has(value.host)
292
- && Number.isFinite(new Date(value.acquiredAt).getTime()) && Number.isFinite(new Date(value.expiresAt).getTime())
293
- && new Date(value.expiresAt) > new Date(value.acquiredAt) && DIGEST_RE.test(value.workspaceDigest || ""));
294
- }
295
-
296
- function validPending(value) {
297
- return value === null || (value && ID_RE.test(value.toolUseId || "") && CAPABILITY_RE.test(value.capability || "")
298
- && DIGEST_RE.test(value.beforeDigest || "") && Number.isFinite(new Date(value.authorizedAt).getTime())
299
- && ID_RE.test(value.sessionId || ""));
300
- }
301
-
302
- function validBlocker(value) {
303
- return value === null || (value && typeof value.code === "string" && value.code.length > 0 && value.code.length <= 128
304
- && Number.isFinite(new Date(value.at).getTime()) && value.authority === "execution-state-only");
305
- }
306
-
307
- function validJobRecord(job) {
308
- return job && ID_RE.test(job.id || "") && ID_RE.test(job.taskId || "") && ID_RE.test(job.actorId || "")
309
- && ID_RE.test(job.targetId || "") && ID_RE.test(job.projectId || "")
310
- && (job.groupId === null || ID_RE.test(job.groupId || "")) && HOSTS.has(job.host) && JOB_STATUSES.has(job.status)
311
- && Array.isArray(job.capabilities) && job.capabilities.length > 0
312
- && JSON.stringify(job.capabilities) === JSON.stringify([...new Set(job.capabilities)].sort())
313
- && job.capabilities.every((item) => CAPABILITY_RE.test(item) && !item.includes("*"))
314
- && ID_RE.test(job.grantId || "") && DIGEST_RE.test(job.checkpoint?.workspaceDigest || "")
315
- && Number.isInteger(job.checkpoint?.sequence) && job.checkpoint.sequence >= 0
316
- && Number.isInteger(job.checkpoint?.workspaceFiles) && job.checkpoint.workspaceFiles >= 0
317
- && Number.isInteger(job.checkpoint?.workspaceBytes) && job.checkpoint.workspaceBytes >= 0
318
- && (job.checkpoint?.lastToolUseId === null || ID_RE.test(job.checkpoint?.lastToolUseId || ""))
319
- && (job.checkpoint?.resultDigest === null || DIGEST_RE.test(job.checkpoint?.resultDigest || ""))
320
- && Number.isInteger(job.checkpoint?.retryCount) && job.checkpoint.retryCount >= 0
321
- && (job.checkpoint?.nextRetryAt === null || Number.isFinite(new Date(job.checkpoint?.nextRetryAt).getTime()))
322
- && Number.isFinite(new Date(job.checkpoint?.updatedAt).getTime())
323
- && Number.isInteger(job.maxRetries) && job.maxRetries >= 0 && job.maxRetries <= 10
324
- && Number.isInteger(job.leaseSeconds) && job.leaseSeconds >= 15 && job.leaseSeconds <= 900
325
- && Number.isInteger(job.baseRetrySeconds) && job.baseRetrySeconds >= 1 && job.baseRetrySeconds <= 3600
326
- && validLease(job.lease) && validPending(job.pendingEffect) && validBlocker(job.lastBlocker)
327
- && job.authority === "execution-state-only" && Number.isFinite(new Date(job.createdAt).getTime())
328
- && Number.isFinite(new Date(job.updatedAt).getTime());
329
- }
330
-
331
- function validReceipt(receipt) {
332
- if (!receipt || !ID_RE.test(receipt.id || "") || !ID_RE.test(receipt.jobId || "") || typeof receipt.event !== "string"
333
- || !Number.isFinite(new Date(receipt.at).getTime()) || !receipt.details || typeof receipt.details !== "object"
334
- || Array.isArray(receipt.details) || !DIGEST_RE.test(receipt.digest || "") || receipt.authority !== "execution-state-only") return false;
335
- const { digest, ...material } = receipt;
336
- return receiptDigest(material) === digest;
337
- }
338
-
339
- function selfstarterFindingsInternal(state, policy, graph, coordination) {
340
- const findings = [];
341
- const grants = new Map(policy.grants.map((grant) => [grant.id, grant]));
342
- if (state.schema !== JOB_SCHEMA) findings.push("invalid-selfstarter-state");
343
- if (new Set(state.jobs.map((job) => job.id)).size !== state.jobs.length) findings.push("duplicate-job-id");
344
- for (const job of state.jobs) {
345
- const grant = grants.get(job.grantId);
346
- const task = currentTask(coordination, job.taskId);
347
- if (!validJobRecord(job)) findings.push(`invalid-job:${job.id || "unknown"}`);
348
- if (!grant || grant.jobId !== job.id || grant.taskId !== job.taskId || grant.actorId !== job.actorId
349
- || grant.targetId !== job.targetId || grant.projectId !== job.projectId || grant.groupId !== job.groupId
350
- || grant.host !== job.host || JSON.stringify(grant.capabilities) !== JSON.stringify(job.capabilities)) findings.push(`job-grant-mismatch:${job.id || "unknown"}`);
351
- if (!task || task.assigneeId !== job.actorId || task.projectId !== job.projectId || task.groupId !== job.groupId) findings.push(`job-task-mismatch:${job.id || "unknown"}`);
352
- }
353
- if (state.history.some((entry) => entry.kind !== "selfstarter-job" || !ID_RE.test(entry.recordId || "")
354
- || typeof entry.event !== "string" || !Number.isFinite(new Date(entry.supersededAt).getTime())
355
- || entry.authority !== "execution-state-only" || !validJobRecord(entry.value))) findings.push("invalid-job-history");
356
- if (state.receipts.some((receipt) => !validReceipt(receipt))) findings.push("invalid-job-receipt");
357
- return findings;
358
- }
359
-
360
- export function executionPolicyFindings(policy, graph, coordination) {
361
- return executionPolicyFindingsInternal(policy, graph, coordination);
362
- }
363
-
364
- export function selfstarterFindings(state, policy, graph, coordination) {
365
- return selfstarterFindingsInternal(state, policy, graph, coordination);
366
- }
367
-
368
- export async function loadExecutionPolicy(root = process.cwd(), providedCatalog = null) {
369
- const paths = await pathsFor(root, providedCatalog);
370
- return { policy: await readJson(paths.executionPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy), ...paths };
371
- }
372
-
373
- export async function loadSelfstarter(root = process.cwd(), providedCatalog = null) {
374
- const paths = await pathsFor(root, providedCatalog);
375
- return { state: await readJson(paths.selfstarterPath, paths.catalog.root, normalizeJobs, emptyJobs), ...paths };
376
- }
377
-
378
- export async function resolveSessionJob({
379
- root = process.cwd(), actorId = null, projectId = null, groupId = null, taskId = null,
380
- host, sessionId, action = "effect", now = new Date()
381
- }) {
382
- if (!ACTIONS.has(action)) throw new Error("session job resolution requires start, resume, or effect");
383
- if (!HOSTS.has(host)) throw new Error("self-starter host must be claude or codex");
384
- const session = stableId(sessionId, "sessionId");
385
- const at = timestamp(now, "now");
386
- const paths = await pathsFor(root);
387
- const [policy, state] = await Promise.all([
388
- readJson(paths.executionPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
389
- readJson(paths.selfstarterPath, paths.catalog.root, normalizeJobs, emptyJobs)
390
- ]);
391
- const { graph, coordination } = await referenceState(paths.catalog);
392
- const findings = [
393
- ...executionPolicyFindingsInternal(policy, graph, coordination, { checkCurrentReferences: false }),
394
- ...selfstarterFindingsInternal(state, policy, graph, coordination)
395
- ];
396
- if (findings.length) throw new Error(`self-starter failed closed: ${findings.join(", ")}`);
397
- const candidates = state.jobs.filter((job) => job.lease?.sessionId === session);
398
- if (!candidates.length) return null;
399
- if (candidates.length !== 1) throw new Error("host session has multiple self-starter leases");
400
- const job = candidates[0];
401
- if (leaseExpired(job, at)) throw new Error(`job ${job.id} lease expired`);
402
- for (const [value, expected, label] of [
403
- [actorId, job.actorId, "actor"], [projectId, job.projectId, "project"],
404
- [taskId, job.taskId, "task"], [groupId, job.groupId, "group"], [host, job.host, "host"]
405
- ]) {
406
- if (value !== null && value !== undefined && value !== expected) throw new Error(`self-starter ${label} scope mismatch for ${job.id}`);
407
- }
408
- exactGrant(policy, job, action, at);
409
- if (!currentTaskValid(job, coordination)) throw new Error("self-starter task or assignment changed");
410
- return {
411
- jobId: job.id, actorId: job.actorId, projectId: job.projectId, groupId: job.groupId,
412
- taskId: job.taskId, host: job.host, sessionId: session
413
- };
414
- }
415
-
416
- export async function inspectSelfstarter(root = process.cwd(), providedCatalog = null) {
417
- const paths = await pathsFor(root, providedCatalog);
418
- let policy = emptyPolicy(paths.catalog.root);
419
- let state = emptyJobs(paths.catalog.root);
420
- const errors = [];
421
- try { policy = await readJson(paths.executionPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy); } catch (error) { errors.push(`policy:${error.message}`); }
422
- try { state = await readJson(paths.selfstarterPath, paths.catalog.root, normalizeJobs, emptyJobs); } catch (error) { errors.push(`state:${error.message}`); }
423
- return { policy, state, errors, ...paths };
424
- }
425
-
426
- function requireOwnerConfirmation(confirmation) {
427
- if (confirmation !== CONFIRMATION) throw new Error("self-starter policy changes require explicit local owner confirmation");
428
- }
429
-
430
- function preservePolicy(policy, grant, at) {
431
- policy.history.push({ kind: "execution-grant", recordId: grant.id, supersededAt: at, value: { ...grant }, authority: "explicit-local-execution-policy" });
432
- }
433
-
434
- function preserveJob(state, job, event, at) {
435
- state.history.push({ kind: "selfstarter-job", event, recordId: job.id, supersededAt: at, value: structuredClone(job), authority: "execution-state-only" });
436
- }
437
-
438
- function appendReceipt(state, job, id, event, at, details = {}) {
439
- const existing = state.receipts.find((receipt) => receipt.id === id);
440
- const material = { id, jobId: job.id, event, at, details, authority: "execution-state-only" };
441
- const digest = receiptDigest(material);
442
- if (existing) {
443
- if (existing.digest !== digest) throw new Error("self-starter receipt collision; execution stopped");
444
- return { receipt: existing, duplicate: true };
445
- }
446
- const receipt = { ...material, digest };
447
- state.receipts.push(receipt);
448
- return { receipt, duplicate: false };
449
- }
450
-
451
- export async function grantExecution({
452
- root = process.cwd(), id = `execution-grant:${randomUUID()}`, actorId, actions = ["start", "resume", "effect"],
453
- jobId, taskId, targetId, projectId, groupId = null, host, capabilities: requestedCapabilities,
454
- reason, expiresAt = null, confirmation, now = new Date()
455
- }) {
456
- requireOwnerConfirmation(confirmation);
457
- const grant = {
458
- id: stableId(id, "grantId"), actorId: stableId(actorId, "actorId"),
459
- actions: [...new Set(actions)].sort(), jobId: stableId(jobId, "jobId"), taskId: stableId(taskId, "taskId"),
460
- targetId: stableId(targetId, "targetId"), projectId: stableId(projectId, "projectId"),
461
- groupId: stableId(groupId, "groupId", true), host, capabilities: capabilities(requestedCapabilities),
462
- reason: safeText(reason, "reason", 500), active: true, expiresAt: timestamp(expiresAt, "expiresAt", true)
463
- };
464
- if (grant.actions.length !== 3 || grant.actions.some((action) => !ACTIONS.has(action))) throw new Error("a self-starter grant must explicitly include start, resume, and effect");
465
- if (!HOSTS.has(host)) throw new Error("host must be claude or codex");
466
- const at = timestamp(now, "now");
467
- if (grant.expiresAt && new Date(grant.expiresAt) <= new Date(at)) throw new Error("execution grant expiry must be in the future");
468
- const { catalog, executionPolicyPath } = await pathsFor(root);
469
- const { graph, coordination } = await referenceState(catalog);
470
- if (!validateGrantReferences(grant, graph, coordination)) throw new Error("execution grant must match a current assigned task and its exact scope");
471
- return withLock(executionPolicyPath, () => readJson(executionPolicyPath, catalog.root, normalizePolicy, emptyPolicy), (policy) => {
472
- const findings = executionPolicyFindingsInternal(policy, graph, coordination, { checkCurrentReferences: false });
473
- if (findings.length) throw new Error(`execution policy failed closed: ${findings.join(", ")}`);
474
- if (policy.grants.some((item) => item.id === grant.id || item.jobId === grant.jobId)) throw new Error("execution grant and job IDs are immutable and unique");
475
- policy.revision += 1;
476
- Object.assign(grant, {
477
- revision: policy.revision, createdAt: at, updatedAt: at,
478
- source: "explicit-local-owner-policy", authority: "explicit-local-execution-policy"
479
- });
480
- policy.grants.push(grant);
481
- policy.grants.sort((a, b) => a.id.localeCompare(b.id));
482
- return { grant, policyRevision: policy.revision, executionPolicyPath };
483
- });
484
- }
485
-
486
- export async function revokeExecution({ root = process.cwd(), id, reason, confirmation, now = new Date() }) {
487
- requireOwnerConfirmation(confirmation);
488
- stableId(id, "grantId");
489
- const at = timestamp(now, "now");
490
- const revokeReason = safeText(reason, "reason", 500);
491
- const { catalog, executionPolicyPath } = await pathsFor(root);
492
- const { graph, coordination } = await referenceState(catalog);
493
- return withLock(executionPolicyPath, () => readJson(executionPolicyPath, catalog.root, normalizePolicy, emptyPolicy), (policy) => {
494
- const findings = executionPolicyFindingsInternal(policy, graph, coordination);
495
- if (findings.length) throw new Error(`execution policy failed closed: ${findings.join(", ")}`);
496
- const previous = policy.grants.find((grant) => grant.id === id);
497
- if (!previous || !previous.active) throw new Error("execution grant is unknown or already revoked");
498
- preservePolicy(policy, previous, at);
499
- policy.revision += 1;
500
- const grant = { ...previous, active: false, revokedAt: at, revokeReason, revision: policy.revision, updatedAt: at };
501
- policy.grants = policy.grants.map((item) => item.id === id ? grant : item);
502
- return { grant, policyRevision: policy.revision, executionPolicyPath };
503
- });
504
- }
505
-
506
- function exactGrant(policy, job, action, at) {
507
- const grant = policy.grants.find((item) => item.id === job.grantId);
508
- if (!grant || !grant.active || !grant.actions.includes(action)) throw new Error(`default deny: no current exact ${action} grant for ${job.id}`);
509
- if (grant.expiresAt && new Date(grant.expiresAt) <= new Date(at)) throw new Error(`execution grant expired for ${job.id}`);
510
- const matches = grant.actorId === job.actorId && grant.jobId === job.id && grant.taskId === job.taskId
511
- && grant.targetId === job.targetId && grant.projectId === job.projectId && grant.groupId === job.groupId
512
- && grant.host === job.host && JSON.stringify(grant.capabilities) === JSON.stringify(job.capabilities);
513
- if (!matches) throw new Error(`execution grant scope mismatch for ${job.id}`);
514
- return grant;
515
- }
516
-
517
- export async function registerJob({
518
- root = process.cwd(), id, grantId, maxRetries = 3, leaseSeconds = 120,
519
- baseRetrySeconds = 5, confirmation, now = new Date()
520
- }) {
521
- requireOwnerConfirmation(confirmation);
522
- const jobId = stableId(id, "jobId");
523
- const at = timestamp(now, "now");
524
- const paths = await pathsFor(root);
525
- const fingerprint = await workspaceFingerprint(paths.catalog.root);
526
- const { graph, coordination } = await referenceState(paths.catalog);
527
- return withLock(paths.executionPolicyPath, () => readJson(paths.executionPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy), (policy) => withLock(
528
- paths.selfstarterPath,
529
- () => readJson(paths.selfstarterPath, paths.catalog.root, normalizeJobs, emptyJobs),
530
- (state) => {
531
- const policyIssues = executionPolicyFindingsInternal(policy, graph, coordination);
532
- const stateIssues = selfstarterFindingsInternal(state, policy, graph, coordination);
533
- if (policyIssues.length || stateIssues.length) throw new Error(`self-starter registration failed closed: ${[...policyIssues, ...stateIssues].join(", ")}`);
534
- const grant = policy.grants.find((item) => item.id === grantId && item.jobId === jobId);
535
- if (!grant) throw new Error("registration requires the exact current execution grant");
536
- if (state.jobs.some((job) => job.id === jobId)) throw new Error("job IDs are immutable");
537
- const template = {
538
- id: jobId, taskId: grant.taskId, actorId: grant.actorId, targetId: grant.targetId,
539
- projectId: grant.projectId, groupId: grant.groupId, host: grant.host,
540
- capabilities: [...grant.capabilities], grantId: grant.id
541
- };
542
- exactGrant(policy, template, "start", at);
543
- if (!validateGrantReferences(grant, graph, coordination)) throw new Error("execution grant no longer matches the current task");
544
- const job = {
545
- ...template, status: "waiting",
546
- checkpoint: {
547
- sequence: 0, workspaceDigest: fingerprint.digest, workspaceFiles: fingerprint.files,
548
- workspaceBytes: fingerprint.bytes, lastToolUseId: null, resultDigest: null,
549
- retryCount: 0, nextRetryAt: null, updatedAt: at
550
- },
551
- lease: null, pendingEffect: null,
552
- maxRetries: integer(maxRetries, "maxRetries", 0, 10),
553
- leaseSeconds: integer(leaseSeconds, "leaseSeconds", 15, 900),
554
- baseRetrySeconds: integer(baseRetrySeconds, "baseRetrySeconds", 1, 3600),
555
- lastBlocker: null, createdAt: at, updatedAt: at, authority: "execution-state-only"
556
- };
557
- state.jobs.push(job);
558
- state.jobs.sort((a, b) => a.id.localeCompare(b.id));
559
- appendReceipt(state, job, `receipt:registered:${receiptDigest({ id: job.id, at }).slice(0, 20)}`, "registered", at, { checkpoint: job.checkpoint.workspaceDigest });
560
- return { job, selfstarterPath: paths.selfstarterPath, executionPolicyPath: paths.executionPolicyPath };
561
- }
562
- ), false);
563
- }
564
-
565
- function ensureScope(job, scope) {
566
- const matches = job.actorId === scope.actorId && job.projectId === scope.projectId
567
- && job.groupId === (scope.groupId ?? null) && job.host === scope.host
568
- && (!scope.taskId || job.taskId === scope.taskId) && (!scope.jobId || job.id === scope.jobId);
569
- if (!matches) throw new Error(`self-starter scope mismatch for ${job.id}`);
570
- }
571
-
572
- function currentTaskValid(job, coordination) {
573
- const task = currentTask(coordination, job.taskId);
574
- return task && task.assigneeId === job.actorId && task.projectId === job.projectId && task.groupId === job.groupId
575
- && ["open", "in-progress"].includes(task.status);
576
- }
577
-
578
- function leaseExpired(job, at) {
579
- return job.lease && new Date(job.lease.expiresAt) <= new Date(at);
580
- }
581
-
582
- function blocker(job, code, at) {
583
- job.status = code === "retry-budget-exhausted" ? "exhausted" : "blocked";
584
- job.lastBlocker = { code, at, authority: "execution-state-only" };
585
- job.lease = null;
586
- job.updatedAt = at;
587
- }
588
-
589
- async function lockedStates(root, operation) {
590
- const paths = await pathsFor(root);
591
- const { graph, coordination } = await referenceState(paths.catalog);
592
- return withLock(paths.executionPolicyPath, () => readJson(paths.executionPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy), (policy) => withLock(
593
- paths.selfstarterPath,
594
- () => readJson(paths.selfstarterPath, paths.catalog.root, normalizeJobs, emptyJobs),
595
- (state) => {
596
- const findings = [
597
- ...executionPolicyFindingsInternal(policy, graph, coordination, { checkCurrentReferences: false }),
598
- ...selfstarterFindingsInternal(state, policy, graph, coordination)
599
- ];
600
- if (findings.length) throw new Error(`self-starter failed closed: ${findings.join(", ")}`);
601
- return operation({ paths, policy, state, graph, coordination });
602
- }
603
- ), false);
604
- }
605
-
606
- export async function startOrResumeJob({ root = process.cwd(), actorId, projectId, groupId = null, taskId = null, jobId = null, host, sessionId, now = new Date() }) {
607
- const scope = {
608
- actorId: stableId(actorId, "actorId"), projectId: stableId(projectId, "projectId"),
609
- groupId: stableId(groupId, "groupId", true), taskId: stableId(taskId, "taskId", true),
610
- jobId: stableId(jobId, "jobId", true), host, sessionId: stableId(sessionId, "sessionId")
611
- };
612
- if (!HOSTS.has(host)) throw new Error("self-starter host must be claude or codex");
613
- const at = timestamp(now, "now");
614
- const fingerprint = await workspaceFingerprint(root);
615
- return lockedStates(root, ({ policy, state, coordination, paths }) => {
616
- const candidates = state.jobs.filter((job) => !["completed", "cancelled", "exhausted"].includes(job.status)
617
- && job.actorId === scope.actorId && job.projectId === scope.projectId && job.groupId === scope.groupId
618
- && job.host === host && (!scope.taskId || job.taskId === scope.taskId) && (!scope.jobId || job.id === scope.jobId));
619
- if (!candidates.length) return { job: null, reason: "no-exact-waiting-job", selfstarterPath: paths.selfstarterPath };
620
- if (candidates.length !== 1) throw new Error("multiple eligible self-starter jobs require an exact job or task scope");
621
- const job = candidates[0];
622
- ensureScope(job, scope);
623
- if (!currentTaskValid(job, coordination)) {
624
- preserveJob(state, job, "task-invalid", at);
625
- blocker(job, "task-or-assignment-changed", at);
626
- appendReceipt(state, job, `receipt:blocked:${receiptDigest({ id: job.id, at, code: job.lastBlocker.code }).slice(0, 20)}`, "blocked", at, { code: job.lastBlocker.code });
627
- return { job: null, blocked: true, reason: job.lastBlocker.code, selfstarterPath: paths.selfstarterPath };
628
- }
629
- if (job.lease && !leaseExpired(job, at) && job.lease.sessionId !== scope.sessionId) {
630
- throw new Error(`job ${job.id} has an active lease`);
631
- }
632
- const hasPriorRun = Boolean(job.lease) || state.receipts.some((receipt) => receipt.jobId === job.id && ["start", "resume"].includes(receipt.event));
633
- const action = job.checkpoint.sequence === 0 && !hasPriorRun ? "start" : "resume";
634
- exactGrant(policy, job, action, at);
635
- if (job.checkpoint.nextRetryAt && new Date(job.checkpoint.nextRetryAt) > new Date(at)) {
636
- return { job: null, blocked: true, reason: "retry-backoff-active", nextRetryAt: job.checkpoint.nextRetryAt, selfstarterPath: paths.selfstarterPath };
637
- }
638
- if (job.status === "blocked" && job.lastBlocker?.code === "host-reported-blocker") {
639
- return { job: null, blocked: true, reason: "host-reported-blocker", selfstarterPath: paths.selfstarterPath };
640
- }
641
- if (job.pendingEffect && leaseExpired(job, at)) {
642
- if (fingerprint.digest !== job.pendingEffect.beforeDigest) {
643
- preserveJob(state, job, "crash-workspace-unknown", at);
644
- blocker(job, "workspace-changed-after-uncheckpointed-effect", at);
645
- appendReceipt(state, job, `receipt:blocked:${receiptDigest({ id: job.id, at, code: job.lastBlocker.code }).slice(0, 20)}`, "blocked", at, { code: job.lastBlocker.code });
646
- return { job: null, blocked: true, reason: job.lastBlocker.code, selfstarterPath: paths.selfstarterPath };
647
- }
648
- preserveJob(state, job, "crash-recovered", at);
649
- job.pendingEffect = null;
650
- job.checkpoint.retryCount += 1;
651
- if (job.checkpoint.retryCount > job.maxRetries) {
652
- blocker(job, "retry-budget-exhausted", at);
653
- return { job: null, blocked: true, reason: job.lastBlocker.code, selfstarterPath: paths.selfstarterPath };
654
- }
655
- }
656
- if (fingerprint.digest !== job.checkpoint.workspaceDigest) {
657
- preserveJob(state, job, "workspace-drift", at);
658
- blocker(job, "workspace-changed-outside-checkpoint", at);
659
- appendReceipt(state, job, `receipt:blocked:${receiptDigest({ id: job.id, at, code: job.lastBlocker.code }).slice(0, 20)}`, "blocked", at, { code: job.lastBlocker.code });
660
- return { job: null, blocked: true, reason: job.lastBlocker.code, selfstarterPath: paths.selfstarterPath };
661
- }
662
- const receiptId = `receipt:${action}:${receiptDigest({ job: job.id, session: scope.sessionId }).slice(0, 20)}`;
663
- const existing = state.receipts.find((receipt) => receipt.id === receiptId);
664
- if (existing && job.lease?.sessionId === scope.sessionId) return { job, action, duplicate: true, receipt: existing, selfstarterPath: paths.selfstarterPath };
665
- if (existing) return {
666
- job: null, action, duplicate: true, blocked: true, reason: "session-lifecycle-already-closed",
667
- receipt: existing, selfstarterPath: paths.selfstarterPath
668
- };
669
- preserveJob(state, job, action, at);
670
- job.status = "running";
671
- job.lastBlocker = null;
672
- job.lease = {
673
- sessionId: scope.sessionId, host, acquiredAt: at,
674
- expiresAt: new Date(new Date(at).getTime() + job.leaseSeconds * 1000).toISOString(),
675
- workspaceDigest: fingerprint.digest
676
- };
677
- job.updatedAt = at;
678
- const { receipt } = appendReceipt(state, job, receiptId, action, at, { sessionId: scope.sessionId, workspaceDigest: fingerprint.digest });
679
- return { job, action, duplicate: false, receipt, selfstarterPath: paths.selfstarterPath, authority: "explicit-local-execution-policy" };
680
- });
681
- }
682
-
683
- export async function authorizeJobEffect({
684
- root = process.cwd(), jobId, actorId, projectId, groupId = null, taskId,
685
- host, sessionId, toolName, toolUseId, now = new Date()
686
- }) {
687
- const scope = {
688
- jobId: stableId(jobId, "jobId"), actorId: stableId(actorId, "actorId"),
689
- projectId: stableId(projectId, "projectId"), groupId: stableId(groupId, "groupId", true),
690
- taskId: stableId(taskId, "taskId"), host, sessionId: stableId(sessionId, "sessionId")
691
- };
692
- const capability = `tool:${stableId(toolName, "toolName")}`;
693
- const deliveryId = stableId(toolUseId, "toolUseId");
694
- const at = timestamp(now, "now");
695
- const fingerprint = await workspaceFingerprint(root);
696
- if (fingerprint.skippedInaccessibleDetails.length) {
697
- throw incompleteWorkspaceScan(fingerprint.skippedInaccessibleDetails);
698
- }
699
- return lockedStates(root, ({ policy, state, coordination, paths }) => {
700
- const job = state.jobs.find((item) => item.id === scope.jobId);
701
- if (!job) throw new Error("unknown self-starter job");
702
- ensureScope(job, scope);
703
- exactGrant(policy, job, "effect", at);
704
- if (!currentTaskValid(job, coordination)) throw new Error("self-starter task or assignment changed");
705
- if (job.status !== "running" || !job.lease || leaseExpired(job, at) || job.lease.sessionId !== scope.sessionId) throw new Error("self-starter effect requires the current live lease");
706
- if (!job.capabilities.includes(capability)) throw new Error(`default deny: capability ${capability} is not granted for ${job.id}`);
707
- if (fingerprint.digest !== job.checkpoint.workspaceDigest || fingerprint.digest !== job.lease.workspaceDigest) throw new Error("workspace changed outside the current checkpoint");
708
- if (job.pendingEffect) {
709
- if (job.pendingEffect.toolUseId === deliveryId && job.pendingEffect.capability === capability) return { allowed: true, duplicate: true, job, pendingEffect: job.pendingEffect };
710
- throw new Error("another self-starter effect is already pending");
711
- }
712
- preserveJob(state, job, "effect-authorized", at);
713
- job.pendingEffect = { toolUseId: deliveryId, capability, beforeDigest: fingerprint.digest, authorizedAt: at, sessionId: scope.sessionId };
714
- job.updatedAt = at;
715
- const receiptId = `receipt:authorize:${receiptDigest({ job: job.id, toolUseId: deliveryId, capability }).slice(0, 20)}`;
716
- const { receipt } = appendReceipt(state, job, receiptId, "effect-authorized", at, { toolUseId: deliveryId, capability, beforeDigest: fingerprint.digest });
717
- return { allowed: true, duplicate: false, job, pendingEffect: job.pendingEffect, receipt, selfstarterPath: paths.selfstarterPath };
718
- });
719
- }
720
-
721
- function resultHash(value) {
722
- let encoded;
723
- try { encoded = JSON.stringify(value ?? null); } catch { encoded = "unserializable-result"; }
724
- return createHash("sha256").update(encoded.slice(0, 65536)).digest("hex");
725
- }
726
-
727
- export async function checkpointJobEffect({
728
- root = process.cwd(), jobId, actorId, projectId, groupId = null, taskId,
729
- host, sessionId, toolName, toolUseId, success = true, result = null, now = new Date()
730
- }) {
731
- const scope = {
732
- jobId: stableId(jobId, "jobId"), actorId: stableId(actorId, "actorId"),
733
- projectId: stableId(projectId, "projectId"), groupId: stableId(groupId, "groupId", true),
734
- taskId: stableId(taskId, "taskId"), host, sessionId: stableId(sessionId, "sessionId")
735
- };
736
- const capability = `tool:${stableId(toolName, "toolName")}`;
737
- const deliveryId = stableId(toolUseId, "toolUseId");
738
- const at = timestamp(now, "now");
739
- const fingerprint = await workspaceFingerprint(root);
740
- return lockedStates(root, ({ policy, state, coordination, paths }) => {
741
- const job = state.jobs.find((item) => item.id === scope.jobId);
742
- if (!job) throw new Error("unknown self-starter job");
743
- ensureScope(job, scope);
744
- exactGrant(policy, job, "effect", at);
745
- if (!currentTaskValid(job, coordination)) throw new Error("self-starter task or assignment changed");
746
- const receiptId = `receipt:checkpoint:${receiptDigest({ job: job.id, toolUseId: deliveryId }).slice(0, 20)}`;
747
- const existing = state.receipts.find((receipt) => receipt.id === receiptId);
748
- if (existing && job.checkpoint.lastToolUseId === deliveryId) return { job, duplicate: true, receipt: existing, selfstarterPath: paths.selfstarterPath };
749
- if (!job.lease || job.lease.sessionId !== scope.sessionId || !job.pendingEffect
750
- || job.pendingEffect.toolUseId !== deliveryId || job.pendingEffect.capability !== capability) throw new Error("effect checkpoint does not match the pending authorization");
751
- preserveJob(state, job, success ? "effect-succeeded" : "effect-failed", at);
752
- job.checkpoint.sequence += 1;
753
- job.checkpoint.workspaceDigest = fingerprint.digest;
754
- job.checkpoint.workspaceFiles = fingerprint.files;
755
- job.checkpoint.workspaceBytes = fingerprint.bytes;
756
- job.checkpoint.lastToolUseId = deliveryId;
757
- job.checkpoint.resultDigest = resultHash(result);
758
- job.checkpoint.updatedAt = at;
759
- if (success) {
760
- job.checkpoint.retryCount = 0;
761
- job.checkpoint.nextRetryAt = null;
762
- job.lastBlocker = null;
763
- job.lease.workspaceDigest = fingerprint.digest;
764
- job.lease.expiresAt = new Date(new Date(at).getTime() + job.leaseSeconds * 1000).toISOString();
765
- } else {
766
- job.checkpoint.retryCount += 1;
767
- if (job.checkpoint.retryCount > job.maxRetries) blocker(job, "retry-budget-exhausted", at);
768
- else {
769
- const seconds = Math.min(3600, job.baseRetrySeconds * (2 ** (job.checkpoint.retryCount - 1)));
770
- job.checkpoint.nextRetryAt = new Date(new Date(at).getTime() + seconds * 1000).toISOString();
771
- blocker(job, "effect-failed", at);
772
- }
773
- }
774
- job.pendingEffect = null;
775
- job.updatedAt = at;
776
- const { receipt } = appendReceipt(state, job, receiptId, success ? "effect-succeeded" : "effect-failed", at, {
777
- toolUseId: deliveryId, capability, checkpointSequence: job.checkpoint.sequence,
778
- workspaceDigest: fingerprint.digest, resultDigest: job.checkpoint.resultDigest, retryCount: job.checkpoint.retryCount
779
- });
780
- return { job, duplicate: false, receipt, selfstarterPath: paths.selfstarterPath };
781
- });
782
- }
783
-
784
- export async function closeJobLease({ root = process.cwd(), jobId, actorId, projectId, groupId = null, taskId, host, sessionId, status = "waiting", now = new Date() }) {
785
- if (!["waiting", "blocked", "completed"].includes(status)) throw new Error("closing status must be waiting, blocked, or completed");
786
- const scope = {
787
- jobId: stableId(jobId, "jobId"), actorId: stableId(actorId, "actorId"),
788
- projectId: stableId(projectId, "projectId"), groupId: stableId(groupId, "groupId", true),
789
- taskId: stableId(taskId, "taskId"), host, sessionId: stableId(sessionId, "sessionId")
790
- };
791
- const at = timestamp(now, "now");
792
- return lockedStates(root, ({ policy, state, coordination, paths }) => {
793
- const job = state.jobs.find((item) => item.id === scope.jobId);
794
- if (!job) throw new Error("unknown self-starter job");
795
- ensureScope(job, scope);
796
- exactGrant(policy, job, "resume", at);
797
- if (!currentTaskValid(job, coordination) && status !== "completed") throw new Error("self-starter task or assignment changed");
798
- const receiptId = `receipt:close:${receiptDigest({ job: job.id, session: scope.sessionId, status }).slice(0, 20)}`;
799
- const existing = state.receipts.find((receipt) => receipt.id === receiptId);
800
- if (existing && !job.lease) return { job, duplicate: true, receipt: existing, selfstarterPath: paths.selfstarterPath };
801
- if (!job.lease || job.lease.sessionId !== scope.sessionId) throw new Error("only the current lease holder may close a job");
802
- if (job.pendingEffect) throw new Error("cannot close a job with an uncheckpointed effect");
803
- preserveJob(state, job, "lease-closed", at);
804
- job.status = status;
805
- job.lease = null;
806
- if (status === "blocked") job.lastBlocker = { code: "host-reported-blocker", at, authority: "execution-state-only" };
807
- job.updatedAt = at;
808
- const { receipt } = appendReceipt(state, job, receiptId, "lease-closed", at, { sessionId: scope.sessionId, status, checkpointSequence: job.checkpoint.sequence });
809
- return { job, duplicate: false, receipt, selfstarterPath: paths.selfstarterPath };
810
- });
811
- }
812
-
813
- export async function cancelJob({ root = process.cwd(), id, reason, confirmation, now = new Date() }) {
814
- requireOwnerConfirmation(confirmation);
815
- stableId(id, "jobId");
816
- const at = timestamp(now, "now");
817
- const cancelReason = safeText(reason, "reason", 500);
818
- return lockedStates(root, ({ state, paths }) => {
819
- const job = state.jobs.find((item) => item.id === id);
820
- if (!job) throw new Error("unknown self-starter job");
821
- if (["completed", "cancelled"].includes(job.status)) throw new Error("self-starter job is already terminal");
822
- preserveJob(state, job, "cancelled", at);
823
- job.status = "cancelled";
824
- job.lease = null;
825
- job.pendingEffect = null;
826
- job.lastBlocker = { code: "owner-cancelled", at, authority: "execution-state-only" };
827
- job.cancelReason = cancelReason;
828
- job.updatedAt = at;
829
- appendReceipt(state, job, `receipt:cancel:${receiptDigest({ id, at }).slice(0, 20)}`, "cancelled", at, { reasonDigest: resultHash(cancelReason) });
830
- return { job, selfstarterPath: paths.selfstarterPath };
831
- });
832
- }
833
-
834
- export async function deleteJob({ root = process.cwd(), id, confirmation }) {
835
- requireOwnerConfirmation(confirmation);
836
- stableId(id, "jobId");
837
- return lockedStates(root, ({ state, paths }) => {
838
- const job = state.jobs.find((item) => item.id === id);
839
- if (job?.lease || job?.pendingEffect) throw new Error("cannot delete a leased or uncheckpointed job");
840
- const existed = Boolean(job);
841
- state.jobs = state.jobs.filter((item) => item.id !== id);
842
- state.history = state.history.filter((entry) => entry.recordId !== id && entry.value?.id !== id);
843
- state.receipts = state.receipts.filter((receipt) => receipt.jobId !== id);
844
- return { deleted: existed, id, selfstarterPath: paths.selfstarterPath };
845
- });
846
- }
847
-
848
- export async function selfstarterContext({ root = process.cwd(), actorId = null, projectId = null, taskId = null, includeTerminal = false } = {}) {
849
- const paths = await pathsFor(root);
850
- const [policy, state] = await Promise.all([
851
- readJson(paths.executionPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
852
- readJson(paths.selfstarterPath, paths.catalog.root, normalizeJobs, emptyJobs)
853
- ]);
854
- const { graph, coordination } = await referenceState(paths.catalog);
855
- const findings = [...executionPolicyFindingsInternal(policy, graph, coordination), ...selfstarterFindingsInternal(state, policy, graph, coordination)];
856
- if (findings.length) throw new Error(`self-starter failed closed: ${findings.join(", ")}`);
857
- const items = state.jobs.filter((job) => (!actorId || job.actorId === actorId) && (!projectId || job.projectId === projectId)
858
- && (!taskId || job.taskId === taskId) && (includeTerminal || !["completed", "cancelled"].includes(job.status)))
859
- .map((job) => ({
860
- id: job.id, taskId: job.taskId, actorId: job.actorId, targetId: job.targetId,
861
- projectId: job.projectId, groupId: job.groupId, host: job.host, status: job.status,
862
- checkpoint: { ...job.checkpoint }, leased: Boolean(job.lease), pendingEffect: Boolean(job.pendingEffect),
863
- lastBlocker: job.lastBlocker, updatedAt: job.updatedAt, authority: "execution-state-only"
864
- }));
865
- return { schema: "agentspine.selfstarter-context/v1", root: paths.catalog.root, items, authority: "execution-state-only" };
866
- }
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { lstat, open, opendir, readFile, realpath, stat, unlink, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join, relative, resolve, sep } from "node:path";
5
+ import { buildCatalog } from "./catalog.js";
6
+ import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
7
+ import { loadCoordination } from "./coordination.js";
8
+ import { loadGraph } from "./graph.js";
9
+ import { comparablePath, projectStateDir } from "./paths.js";
10
+
11
+ const POLICY_SCHEMA = "agentspine.execution-policy/v1";
12
+ const JOB_SCHEMA = "agentspine.selfstarter/v1";
13
+ const ACTIONS = new Set(["start", "resume", "effect"]);
14
+ const JOB_STATUSES = new Set(["waiting", "running", "blocked", "completed", "cancelled", "exhausted"]);
15
+ const HOSTS = new Set(["claude", "codex"]);
16
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
17
+ const CAPABILITY_RE = /^tool:[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
18
+ const DIGEST_RE = /^[a-f0-9]{64}$/;
19
+ const MAX_STATE_BYTES = 5 * 1024 * 1024;
20
+ const MAX_WORKSPACE_FILES = 10000;
21
+ const MAX_WORKSPACE_BYTES = 64 * 1024 * 1024;
22
+ const CONFIRMATION = "local-owner-confirmed";
23
+ const EXCLUDED_NAMES = new Set([".git", "node_modules", ".DS_Store"]);
24
+ const SECRET_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|gh[opusu])_[A-Za-z0-9_-]{20,}|\bBearer\s+[A-Za-z0-9._~+/-]{20,}|\b(?:api[-_ ]?key|token|password|secret)\s*[:=]\s*\S{8,}/i;
25
+
26
+ function workspaceScanError(error) { error.agentSpineScan = true; return error; }
27
+
28
+ function configuredHostProfileRoots(env = process.env) {
29
+ return [env.CODEX_HOME, env.BLUN_HOME, env.CLAUDE_CONFIG_DIR, join(homedir(), ".codex"), join(homedir(), ".claude")]
30
+ .filter((value) => typeof value === "string" && value)
31
+ .map((value) => comparablePath(value));
32
+ }
33
+
34
+ function isConfiguredHostProfileRoot(root, env = process.env) {
35
+ const target = comparablePath(root);
36
+ return configuredHostProfileRoots(env).some((profile) => process.platform === "win32"
37
+ ? profile.toLowerCase() === target.toLowerCase() : profile === target);
38
+ }
39
+
40
+ function emptyPolicy(root) {
41
+ return { schema: POLICY_SCHEMA, root, revision: 0, grants: [], history: [] };
42
+ }
43
+
44
+ function emptyJobs(root) {
45
+ return { schema: JOB_SCHEMA, root, jobs: [], history: [], receipts: [] };
46
+ }
47
+
48
+ function timestamp(value, field = "date", nullable = false) {
49
+ if ((value === null || value === undefined || value === "") && nullable) return null;
50
+ const parsed = value instanceof Date ? value : new Date(value);
51
+ if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
52
+ return parsed.toISOString();
53
+ }
54
+
55
+ function stableId(value, field, nullable = false) {
56
+ if ((value === null || value === undefined || value === "") && nullable) return null;
57
+ if (typeof value !== "string" || !ID_RE.test(value)) throw new Error(`${field} must be a stable whitespace-free ID`);
58
+ return value;
59
+ }
60
+
61
+ function safeText(value, field, maximum, nullable = false) {
62
+ if ((value === null || value === undefined || value === "") && nullable) return null;
63
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
64
+ const result = value.trim().slice(0, maximum);
65
+ if (SECRET_RE.test(result)) throw new Error(`${field} appears to contain a secret and cannot enter self-starter state`);
66
+ return result;
67
+ }
68
+
69
+ function integer(value, field, minimum, maximum) {
70
+ const parsed = Number(value);
71
+ if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) throw new Error(`${field} must be an integer between ${minimum} and ${maximum}`);
72
+ return parsed;
73
+ }
74
+
75
+ function capabilities(value) {
76
+ if (!Array.isArray(value) || !value.length || value.some((item) => typeof item !== "string" || !CAPABILITY_RE.test(item))) {
77
+ throw new Error("capabilities must contain exact tool:<name> values and cannot use wildcards");
78
+ }
79
+ const unique = [...new Set(value)].sort();
80
+ if (unique.some((item) => item.includes("*"))) throw new Error("capability wildcards are forbidden");
81
+ return unique;
82
+ }
83
+
84
+ function normalizePolicy(value, root) {
85
+ if (!value || typeof value !== "object" || Array.isArray(value) || value.schema !== POLICY_SCHEMA || value.root !== root
86
+ || !Number.isInteger(value.revision) || value.revision < 0 || !Array.isArray(value.grants) || !Array.isArray(value.history)) {
87
+ throw new Error("execution policy structure is invalid; self-starter is disabled until repaired");
88
+ }
89
+ return value;
90
+ }
91
+
92
+ function normalizeJobs(value, root) {
93
+ if (!value || typeof value !== "object" || Array.isArray(value) || value.schema !== JOB_SCHEMA || value.root !== root
94
+ || !Array.isArray(value.jobs) || !Array.isArray(value.history) || !Array.isArray(value.receipts)) {
95
+ throw new Error("self-starter checkpoint structure is invalid; automatic execution is disabled until repaired");
96
+ }
97
+ return value;
98
+ }
99
+
100
+ async function readJson(path, root, normalize, empty) {
101
+ try {
102
+ const metadata = await stat(path);
103
+ if (metadata.size > MAX_STATE_BYTES) throw new Error("self-starter state exceeds the 5 MiB read limit");
104
+ return normalize(JSON.parse(await readFile(path, "utf8")), root);
105
+ } catch (error) {
106
+ if (error.code !== "ENOENT") throw error;
107
+ return empty(root);
108
+ }
109
+ }
110
+
111
+ async function saveJson(value, path) {
112
+ const content = `${JSON.stringify(value, null, 2)}\n`;
113
+ if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("self-starter state exceeds 5 MiB; purge completed jobs first");
114
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
115
+ try {
116
+ await writeFile(temporary, content, { mode: 0o600 });
117
+ await replaceFileWithRetry(temporary, path);
118
+ } finally {
119
+ await unlink(temporary).catch((error) => { if (error.code !== "ENOENT") throw error; });
120
+ }
121
+ }
122
+
123
+ async function acquireLock(path) {
124
+ const lockPath = `${path}.lock`;
125
+ for (let attempt = 0; attempt < 120; attempt += 1) {
126
+ try {
127
+ return { handle: await open(lockPath, "wx", 0o600), lockPath };
128
+ } catch (error) {
129
+ if (!isFileLockContention(error)) throw error;
130
+ try {
131
+ const metadata = await stat(lockPath);
132
+ if (Date.now() - metadata.mtimeMs > 90000) await unlink(lockPath);
133
+ } catch (lockError) {
134
+ if (lockError.code !== "ENOENT") throw lockError;
135
+ }
136
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 25));
137
+ }
138
+ }
139
+ throw new Error("self-starter state is busy; retry later");
140
+ }
141
+
142
+ async function withLock(path, read, operation, save = true) {
143
+ const { handle, lockPath } = await acquireLock(path);
144
+ try {
145
+ const state = await read();
146
+ const result = await operation(state);
147
+ if (save) await saveJson(state, path);
148
+ return result;
149
+ } finally {
150
+ await handle.close();
151
+ await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
152
+ }
153
+ }
154
+
155
+ async function pathsFor(root, providedCatalog = null) {
156
+ if (isConfiguredHostProfileRoot(root)) {
157
+ throw new Error("self-starter cannot use a host profile as its workspace root");
158
+ }
159
+ let catalog = providedCatalog;
160
+ if (!catalog) {
161
+ try {
162
+ catalog = await buildCatalog(root);
163
+ } catch (error) {
164
+ if (typeof error?.code === "string" && (error.path || error.syscall)) throw workspaceScanError(error);
165
+ throw error;
166
+ }
167
+ }
168
+ const directory = await projectStateDir(catalog.root);
169
+ return {
170
+ catalog,
171
+ executionPolicyPath: join(directory, "execution-policy.json"),
172
+ selfstarterPath: join(directory, "selfstarter.json")
173
+ };
174
+ }
175
+
176
+ function skippableTraversalError(error) {
177
+ return ["EPERM", "EACCES", "ENOENT"].includes(error?.code);
178
+ }
179
+
180
+ function skippedPath(skipped, path, error, operation) {
181
+ skipped.push({ path, code: error.code, operation });
182
+ }
183
+
184
+ export async function collectWorkspaceFiles(root) {
185
+ const files = [];
186
+ const skipped = [];
187
+ let totalBytes = 0;
188
+ async function walk(directory) {
189
+ let stream;
190
+ try {
191
+ stream = await opendir(directory);
192
+ } catch (error) {
193
+ if (!skippableTraversalError(error)) throw workspaceScanError(error);
194
+ skippedPath(skipped, error.path || directory, error, "opendir");
195
+ return;
196
+ }
197
+ const entries = [];
198
+ try {
199
+ for await (const entry of stream) entries.push(entry);
200
+ } catch (error) {
201
+ if (!skippableTraversalError(error)) throw workspaceScanError(error);
202
+ skippedPath(skipped, directory, error, "readdir");
203
+ return;
204
+ }
205
+ entries.sort((a, b) => a.name.localeCompare(b.name));
206
+ for (const entry of entries) {
207
+ if (EXCLUDED_NAMES.has(entry.name)) continue;
208
+ const path = join(directory, entry.name);
209
+ let metadata;
210
+ try {
211
+ metadata = await lstat(path);
212
+ } catch (error) {
213
+ if (!skippableTraversalError(error)) throw workspaceScanError(error);
214
+ skippedPath(skipped, error.path || path, error, "lstat");
215
+ continue;
216
+ }
217
+ if (metadata.isSymbolicLink()) {
218
+ try {
219
+ await realpath(path);
220
+ } catch (error) {
221
+ if (!skippableTraversalError(error)) throw workspaceScanError(error);
222
+ skippedPath(skipped, error.path || path, error, "realpath");
223
+ continue;
224
+ }
225
+ throw new Error(`workspace fingerprint rejects symbolic link: ${relative(root, path)}`);
226
+ }
227
+ if (metadata.isDirectory()) await walk(path);
228
+ else if (metadata.isFile()) {
229
+ files.push({ path, relativePath: relative(root, path).split(sep).join("/"), size: metadata.size });
230
+ totalBytes += metadata.size;
231
+ if (files.length > MAX_WORKSPACE_FILES || totalBytes > MAX_WORKSPACE_BYTES) {
232
+ throw new Error("workspace exceeds the self-starter fingerprint limit");
233
+ }
234
+ }
235
+ }
236
+ }
237
+ await walk(root);
238
+ return { files, skipped: skipped.sort((a, b) => a.path.localeCompare(b.path) || a.operation.localeCompare(b.operation)) };
239
+ }
240
+
241
+ export async function workspaceFingerprint(inputRoot = process.cwd()) {
242
+ const root = resolve(inputRoot);
243
+ if (isConfiguredHostProfileRoot(root)) {
244
+ throw new Error("self-starter cannot fingerprint a host profile root");
245
+ }
246
+ const collected = await collectWorkspaceFiles(root);
247
+ const files = [];
248
+ const skipped = [...collected.skipped];
249
+ const hash = createHash("sha256");
250
+ for (const file of collected.files) {
251
+ let content;
252
+ try {
253
+ content = await readFile(file.path);
254
+ } catch (error) {
255
+ if (!skippableTraversalError(error)) throw workspaceScanError(error);
256
+ skippedPath(skipped, error.path || file.path, error, "readFile");
257
+ continue;
258
+ }
259
+ files.push(file);
260
+ hash.update(file.relativePath).update("\0").update(String(file.size)).update("\0");
261
+ hash.update(content).update("\0");
262
+ }
263
+ return {
264
+ digest: hash.digest("hex"), files: files.length, bytes: files.reduce((sum, file) => sum + file.size, 0),
265
+ skipped: skipped.sort((a, b) => a.path.localeCompare(b.path) || a.operation.localeCompare(b.operation))
266
+ };
267
+ }
268
+
269
+ function incompleteWorkspaceScan(skipped) {
270
+ const first = skipped[0];
271
+ const error = new Error(`workspace scan skipped ${skipped.length} inaccessible path${skipped.length === 1 ? "" : "s"}: ${first.path}`);
272
+ error.code = "AGENTSPINE_SCAN_INCOMPLETE";
273
+ error.path = first.path;
274
+ error.syscall = first.operation;
275
+ error.skipped = skipped;
276
+ return error;
277
+ }
278
+
279
+ function knownActor(graph, id) {
280
+ return graph.entities.find((entity) => entity.id === id && ["person", "agent"].includes(entity.kind));
281
+ }
282
+
283
+ function currentTask(coordination, id) {
284
+ return coordination.tasks.find((task) => task.id === id);
285
+ }
286
+
287
+ async function referenceState(catalog) {
288
+ const [{ graph }, { coordination }] = await Promise.all([
289
+ loadGraph(catalog.root, catalog), loadCoordination(catalog.root, catalog)
290
+ ]);
291
+ return { graph, coordination };
292
+ }
293
+
294
+ function validateGrantReferences(grant, graph, coordination) {
295
+ const task = currentTask(coordination, grant.taskId);
296
+ return Boolean(knownActor(graph, grant.actorId) && knownActor(graph, grant.targetId) && task
297
+ && task.assigneeId === grant.actorId && task.projectId === grant.projectId && task.groupId === grant.groupId
298
+ && ["open", "in-progress"].includes(task.status));
299
+ }
300
+
301
+ function executionPolicyFindingsInternal(policy, graph, coordination, { checkCurrentReferences = true } = {}) {
302
+ const findings = [];
303
+ if (policy.schema !== POLICY_SCHEMA || !Number.isInteger(policy.revision) || policy.revision < 0) findings.push("invalid-execution-policy");
304
+ if (new Set(policy.grants.map((grant) => grant.id)).size !== policy.grants.length) findings.push("duplicate-execution-grant-id");
305
+ const records = [
306
+ ...policy.grants.map((grant) => ({ grant, current: true })),
307
+ ...policy.history.map((entry) => entry.value).filter(Boolean).map((grant) => ({ grant, current: false }))
308
+ ];
309
+ for (const { grant, current } of records) {
310
+ const valid = ID_RE.test(grant.id || "") && ID_RE.test(grant.jobId || "") && ID_RE.test(grant.taskId || "")
311
+ && ID_RE.test(grant.actorId || "") && ID_RE.test(grant.targetId || "") && ID_RE.test(grant.projectId || "")
312
+ && (grant.groupId === null || ID_RE.test(grant.groupId || "")) && HOSTS.has(grant.host)
313
+ && JSON.stringify(grant.actions) === JSON.stringify(["effect", "resume", "start"])
314
+ && Array.isArray(grant.capabilities) && grant.capabilities.length > 0
315
+ && JSON.stringify(grant.capabilities) === JSON.stringify([...new Set(grant.capabilities)].sort())
316
+ && grant.capabilities.every((item) => CAPABILITY_RE.test(item) && !item.includes("*"))
317
+ && typeof grant.active === "boolean" && typeof grant.reason === "string" && !SECRET_RE.test(grant.reason)
318
+ && Number.isInteger(grant.revision) && grant.revision > 0 && grant.revision <= policy.revision
319
+ && grant.source === "explicit-local-owner-policy" && grant.authority === "explicit-local-execution-policy"
320
+ && typeof grant.createdAt === "string" && typeof grant.updatedAt === "string"
321
+ && (grant.expiresAt === null || Number.isFinite(new Date(grant.expiresAt).getTime()));
322
+ if (!valid) findings.push(`invalid-execution-grant:${grant.id || "unknown"}`);
323
+ if (checkCurrentReferences && current && grant.active && !validateGrantReferences(grant, graph, coordination)) {
324
+ findings.push(`stale-execution-grant:${grant.id || "unknown"}`);
325
+ }
326
+ }
327
+ if (policy.history.some((entry) => entry.kind !== "execution-grant" || !ID_RE.test(entry.recordId || "")
328
+ || !Number.isFinite(new Date(entry.supersededAt).getTime())
329
+ || entry.authority !== "explicit-local-execution-policy" || !entry.value)) findings.push("invalid-execution-policy-history");
330
+ return findings;
331
+ }
332
+
333
+ function receiptDigest(value) {
334
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
335
+ }
336
+
337
+ function validLease(value) {
338
+ return value === null || (value && ID_RE.test(value.sessionId || "") && HOSTS.has(value.host)
339
+ && Number.isFinite(new Date(value.acquiredAt).getTime()) && Number.isFinite(new Date(value.expiresAt).getTime())
340
+ && new Date(value.expiresAt) > new Date(value.acquiredAt) && DIGEST_RE.test(value.workspaceDigest || ""));
341
+ }
342
+
343
+ function validPending(value) {
344
+ return value === null || (value && ID_RE.test(value.toolUseId || "") && CAPABILITY_RE.test(value.capability || "")
345
+ && DIGEST_RE.test(value.beforeDigest || "") && Number.isFinite(new Date(value.authorizedAt).getTime())
346
+ && ID_RE.test(value.sessionId || ""));
347
+ }
348
+
349
+ function validBlocker(value) {
350
+ return value === null || (value && typeof value.code === "string" && value.code.length > 0 && value.code.length <= 128
351
+ && Number.isFinite(new Date(value.at).getTime()) && value.authority === "execution-state-only");
352
+ }
353
+
354
+ function validJobRecord(job) {
355
+ return job && ID_RE.test(job.id || "") && ID_RE.test(job.taskId || "") && ID_RE.test(job.actorId || "")
356
+ && ID_RE.test(job.targetId || "") && ID_RE.test(job.projectId || "")
357
+ && (job.groupId === null || ID_RE.test(job.groupId || "")) && HOSTS.has(job.host) && JOB_STATUSES.has(job.status)
358
+ && Array.isArray(job.capabilities) && job.capabilities.length > 0
359
+ && JSON.stringify(job.capabilities) === JSON.stringify([...new Set(job.capabilities)].sort())
360
+ && job.capabilities.every((item) => CAPABILITY_RE.test(item) && !item.includes("*"))
361
+ && ID_RE.test(job.grantId || "") && DIGEST_RE.test(job.checkpoint?.workspaceDigest || "")
362
+ && Number.isInteger(job.checkpoint?.sequence) && job.checkpoint.sequence >= 0
363
+ && Number.isInteger(job.checkpoint?.workspaceFiles) && job.checkpoint.workspaceFiles >= 0
364
+ && Number.isInteger(job.checkpoint?.workspaceBytes) && job.checkpoint.workspaceBytes >= 0
365
+ && (job.checkpoint?.lastToolUseId === null || ID_RE.test(job.checkpoint?.lastToolUseId || ""))
366
+ && (job.checkpoint?.resultDigest === null || DIGEST_RE.test(job.checkpoint?.resultDigest || ""))
367
+ && Number.isInteger(job.checkpoint?.retryCount) && job.checkpoint.retryCount >= 0
368
+ && (job.checkpoint?.nextRetryAt === null || Number.isFinite(new Date(job.checkpoint?.nextRetryAt).getTime()))
369
+ && Number.isFinite(new Date(job.checkpoint?.updatedAt).getTime())
370
+ && Number.isInteger(job.maxRetries) && job.maxRetries >= 0 && job.maxRetries <= 10
371
+ && Number.isInteger(job.leaseSeconds) && job.leaseSeconds >= 15 && job.leaseSeconds <= 900
372
+ && Number.isInteger(job.baseRetrySeconds) && job.baseRetrySeconds >= 1 && job.baseRetrySeconds <= 3600
373
+ && validLease(job.lease) && validPending(job.pendingEffect) && validBlocker(job.lastBlocker)
374
+ && job.authority === "execution-state-only" && Number.isFinite(new Date(job.createdAt).getTime())
375
+ && Number.isFinite(new Date(job.updatedAt).getTime());
376
+ }
377
+
378
+ function validReceipt(receipt) {
379
+ if (!receipt || !ID_RE.test(receipt.id || "") || !ID_RE.test(receipt.jobId || "") || typeof receipt.event !== "string"
380
+ || !Number.isFinite(new Date(receipt.at).getTime()) || !receipt.details || typeof receipt.details !== "object"
381
+ || Array.isArray(receipt.details) || !DIGEST_RE.test(receipt.digest || "") || receipt.authority !== "execution-state-only") return false;
382
+ const { digest, ...material } = receipt;
383
+ return receiptDigest(material) === digest;
384
+ }
385
+
386
+ function selfstarterFindingsInternal(state, policy, graph, coordination) {
387
+ const findings = [];
388
+ const grants = new Map(policy.grants.map((grant) => [grant.id, grant]));
389
+ if (state.schema !== JOB_SCHEMA) findings.push("invalid-selfstarter-state");
390
+ if (new Set(state.jobs.map((job) => job.id)).size !== state.jobs.length) findings.push("duplicate-job-id");
391
+ for (const job of state.jobs) {
392
+ const grant = grants.get(job.grantId);
393
+ const task = currentTask(coordination, job.taskId);
394
+ if (!validJobRecord(job)) findings.push(`invalid-job:${job.id || "unknown"}`);
395
+ if (!grant || grant.jobId !== job.id || grant.taskId !== job.taskId || grant.actorId !== job.actorId
396
+ || grant.targetId !== job.targetId || grant.projectId !== job.projectId || grant.groupId !== job.groupId
397
+ || grant.host !== job.host || JSON.stringify(grant.capabilities) !== JSON.stringify(job.capabilities)) findings.push(`job-grant-mismatch:${job.id || "unknown"}`);
398
+ if (!task || task.assigneeId !== job.actorId || task.projectId !== job.projectId || task.groupId !== job.groupId) findings.push(`job-task-mismatch:${job.id || "unknown"}`);
399
+ }
400
+ if (state.history.some((entry) => entry.kind !== "selfstarter-job" || !ID_RE.test(entry.recordId || "")
401
+ || typeof entry.event !== "string" || !Number.isFinite(new Date(entry.supersededAt).getTime())
402
+ || entry.authority !== "execution-state-only" || !validJobRecord(entry.value))) findings.push("invalid-job-history");
403
+ if (state.receipts.some((receipt) => !validReceipt(receipt))) findings.push("invalid-job-receipt");
404
+ return findings;
405
+ }
406
+
407
+ export function executionPolicyFindings(policy, graph, coordination) {
408
+ return executionPolicyFindingsInternal(policy, graph, coordination);
409
+ }
410
+
411
+ export function selfstarterFindings(state, policy, graph, coordination) {
412
+ return selfstarterFindingsInternal(state, policy, graph, coordination);
413
+ }
414
+
415
+ export async function loadExecutionPolicy(root = process.cwd(), providedCatalog = null) {
416
+ const paths = await pathsFor(root, providedCatalog);
417
+ return { policy: await readJson(paths.executionPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy), ...paths };
418
+ }
419
+
420
+ export async function loadSelfstarter(root = process.cwd(), providedCatalog = null) {
421
+ const paths = await pathsFor(root, providedCatalog);
422
+ return { state: await readJson(paths.selfstarterPath, paths.catalog.root, normalizeJobs, emptyJobs), ...paths };
423
+ }
424
+
425
+ export async function resolveSessionJob({
426
+ root = process.cwd(), actorId = null, projectId = null, groupId = null, taskId = null,
427
+ host, sessionId, action = "effect", now = new Date()
428
+ }) {
429
+ if (!ACTIONS.has(action)) throw new Error("session job resolution requires start, resume, or effect");
430
+ if (!HOSTS.has(host)) throw new Error("self-starter host must be claude or codex");
431
+ const session = stableId(sessionId, "sessionId");
432
+ const at = timestamp(now, "now");
433
+ const paths = await pathsFor(root);
434
+ const [policy, state] = await Promise.all([
435
+ readJson(paths.executionPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
436
+ readJson(paths.selfstarterPath, paths.catalog.root, normalizeJobs, emptyJobs)
437
+ ]);
438
+ const { graph, coordination } = await referenceState(paths.catalog);
439
+ const findings = [
440
+ ...executionPolicyFindingsInternal(policy, graph, coordination, { checkCurrentReferences: false }),
441
+ ...selfstarterFindingsInternal(state, policy, graph, coordination)
442
+ ];
443
+ if (findings.length) throw new Error(`self-starter failed closed: ${findings.join(", ")}`);
444
+ const candidates = state.jobs.filter((job) => job.lease?.sessionId === session);
445
+ if (!candidates.length) return null;
446
+ if (candidates.length !== 1) throw new Error("host session has multiple self-starter leases");
447
+ const job = candidates[0];
448
+ if (leaseExpired(job, at)) throw new Error(`job ${job.id} lease expired`);
449
+ for (const [value, expected, label] of [
450
+ [actorId, job.actorId, "actor"], [projectId, job.projectId, "project"],
451
+ [taskId, job.taskId, "task"], [groupId, job.groupId, "group"], [host, job.host, "host"]
452
+ ]) {
453
+ if (value !== null && value !== undefined && value !== expected) throw new Error(`self-starter ${label} scope mismatch for ${job.id}`);
454
+ }
455
+ exactGrant(policy, job, action, at);
456
+ if (!currentTaskValid(job, coordination)) throw new Error("self-starter task or assignment changed");
457
+ return {
458
+ jobId: job.id, actorId: job.actorId, projectId: job.projectId, groupId: job.groupId,
459
+ taskId: job.taskId, host: job.host, sessionId: session
460
+ };
461
+ }
462
+
463
+ export async function inspectSelfstarter(root = process.cwd(), providedCatalog = null) {
464
+ const paths = await pathsFor(root, providedCatalog);
465
+ let policy = emptyPolicy(paths.catalog.root);
466
+ let state = emptyJobs(paths.catalog.root);
467
+ const errors = [];
468
+ try { policy = await readJson(paths.executionPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy); } catch (error) { errors.push(`policy:${error.message}`); }
469
+ try { state = await readJson(paths.selfstarterPath, paths.catalog.root, normalizeJobs, emptyJobs); } catch (error) { errors.push(`state:${error.message}`); }
470
+ return { policy, state, errors, ...paths };
471
+ }
472
+
473
+ function requireOwnerConfirmation(confirmation) {
474
+ if (confirmation !== CONFIRMATION) throw new Error("self-starter policy changes require explicit local owner confirmation");
475
+ }
476
+
477
+ function preservePolicy(policy, grant, at) {
478
+ policy.history.push({ kind: "execution-grant", recordId: grant.id, supersededAt: at, value: { ...grant }, authority: "explicit-local-execution-policy" });
479
+ }
480
+
481
+ function preserveJob(state, job, event, at) {
482
+ state.history.push({ kind: "selfstarter-job", event, recordId: job.id, supersededAt: at, value: structuredClone(job), authority: "execution-state-only" });
483
+ }
484
+
485
+ function appendReceipt(state, job, id, event, at, details = {}) {
486
+ const existing = state.receipts.find((receipt) => receipt.id === id);
487
+ const material = { id, jobId: job.id, event, at, details, authority: "execution-state-only" };
488
+ const digest = receiptDigest(material);
489
+ if (existing) {
490
+ if (existing.digest !== digest) throw new Error("self-starter receipt collision; execution stopped");
491
+ return { receipt: existing, duplicate: true };
492
+ }
493
+ const receipt = { ...material, digest };
494
+ state.receipts.push(receipt);
495
+ return { receipt, duplicate: false };
496
+ }
497
+
498
+ export async function grantExecution({
499
+ root = process.cwd(), id = `execution-grant:${randomUUID()}`, actorId, actions = ["start", "resume", "effect"],
500
+ jobId, taskId, targetId, projectId, groupId = null, host, capabilities: requestedCapabilities,
501
+ reason, expiresAt = null, confirmation, now = new Date()
502
+ }) {
503
+ requireOwnerConfirmation(confirmation);
504
+ const grant = {
505
+ id: stableId(id, "grantId"), actorId: stableId(actorId, "actorId"),
506
+ actions: [...new Set(actions)].sort(), jobId: stableId(jobId, "jobId"), taskId: stableId(taskId, "taskId"),
507
+ targetId: stableId(targetId, "targetId"), projectId: stableId(projectId, "projectId"),
508
+ groupId: stableId(groupId, "groupId", true), host, capabilities: capabilities(requestedCapabilities),
509
+ reason: safeText(reason, "reason", 500), active: true, expiresAt: timestamp(expiresAt, "expiresAt", true)
510
+ };
511
+ if (grant.actions.length !== 3 || grant.actions.some((action) => !ACTIONS.has(action))) throw new Error("a self-starter grant must explicitly include start, resume, and effect");
512
+ if (!HOSTS.has(host)) throw new Error("host must be claude or codex");
513
+ const at = timestamp(now, "now");
514
+ if (grant.expiresAt && new Date(grant.expiresAt) <= new Date(at)) throw new Error("execution grant expiry must be in the future");
515
+ const { catalog, executionPolicyPath } = await pathsFor(root);
516
+ const { graph, coordination } = await referenceState(catalog);
517
+ if (!validateGrantReferences(grant, graph, coordination)) throw new Error("execution grant must match a current assigned task and its exact scope");
518
+ return withLock(executionPolicyPath, () => readJson(executionPolicyPath, catalog.root, normalizePolicy, emptyPolicy), (policy) => {
519
+ const findings = executionPolicyFindingsInternal(policy, graph, coordination, { checkCurrentReferences: false });
520
+ if (findings.length) throw new Error(`execution policy failed closed: ${findings.join(", ")}`);
521
+ if (policy.grants.some((item) => item.id === grant.id || item.jobId === grant.jobId)) throw new Error("execution grant and job IDs are immutable and unique");
522
+ policy.revision += 1;
523
+ Object.assign(grant, {
524
+ revision: policy.revision, createdAt: at, updatedAt: at,
525
+ source: "explicit-local-owner-policy", authority: "explicit-local-execution-policy"
526
+ });
527
+ policy.grants.push(grant);
528
+ policy.grants.sort((a, b) => a.id.localeCompare(b.id));
529
+ return { grant, policyRevision: policy.revision, executionPolicyPath };
530
+ });
531
+ }
532
+
533
+ export async function revokeExecution({ root = process.cwd(), id, reason, confirmation, now = new Date() }) {
534
+ requireOwnerConfirmation(confirmation);
535
+ stableId(id, "grantId");
536
+ const at = timestamp(now, "now");
537
+ const revokeReason = safeText(reason, "reason", 500);
538
+ const { catalog, executionPolicyPath } = await pathsFor(root);
539
+ const { graph, coordination } = await referenceState(catalog);
540
+ return withLock(executionPolicyPath, () => readJson(executionPolicyPath, catalog.root, normalizePolicy, emptyPolicy), (policy) => {
541
+ const findings = executionPolicyFindingsInternal(policy, graph, coordination);
542
+ if (findings.length) throw new Error(`execution policy failed closed: ${findings.join(", ")}`);
543
+ const previous = policy.grants.find((grant) => grant.id === id);
544
+ if (!previous || !previous.active) throw new Error("execution grant is unknown or already revoked");
545
+ preservePolicy(policy, previous, at);
546
+ policy.revision += 1;
547
+ const grant = { ...previous, active: false, revokedAt: at, revokeReason, revision: policy.revision, updatedAt: at };
548
+ policy.grants = policy.grants.map((item) => item.id === id ? grant : item);
549
+ return { grant, policyRevision: policy.revision, executionPolicyPath };
550
+ });
551
+ }
552
+
553
+ function exactGrant(policy, job, action, at) {
554
+ const grant = policy.grants.find((item) => item.id === job.grantId);
555
+ if (!grant || !grant.active || !grant.actions.includes(action)) throw new Error(`default deny: no current exact ${action} grant for ${job.id}`);
556
+ if (grant.expiresAt && new Date(grant.expiresAt) <= new Date(at)) throw new Error(`execution grant expired for ${job.id}`);
557
+ const matches = grant.actorId === job.actorId && grant.jobId === job.id && grant.taskId === job.taskId
558
+ && grant.targetId === job.targetId && grant.projectId === job.projectId && grant.groupId === job.groupId
559
+ && grant.host === job.host && JSON.stringify(grant.capabilities) === JSON.stringify(job.capabilities);
560
+ if (!matches) throw new Error(`execution grant scope mismatch for ${job.id}`);
561
+ return grant;
562
+ }
563
+
564
+ export async function registerJob({
565
+ root = process.cwd(), id, grantId, maxRetries = 3, leaseSeconds = 120,
566
+ baseRetrySeconds = 5, confirmation, now = new Date()
567
+ }) {
568
+ requireOwnerConfirmation(confirmation);
569
+ const jobId = stableId(id, "jobId");
570
+ const at = timestamp(now, "now");
571
+ const paths = await pathsFor(root);
572
+ const fingerprint = await workspaceFingerprint(paths.catalog.root);
573
+ const { graph, coordination } = await referenceState(paths.catalog);
574
+ return withLock(paths.executionPolicyPath, () => readJson(paths.executionPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy), (policy) => withLock(
575
+ paths.selfstarterPath,
576
+ () => readJson(paths.selfstarterPath, paths.catalog.root, normalizeJobs, emptyJobs),
577
+ (state) => {
578
+ const policyIssues = executionPolicyFindingsInternal(policy, graph, coordination);
579
+ const stateIssues = selfstarterFindingsInternal(state, policy, graph, coordination);
580
+ if (policyIssues.length || stateIssues.length) throw new Error(`self-starter registration failed closed: ${[...policyIssues, ...stateIssues].join(", ")}`);
581
+ const grant = policy.grants.find((item) => item.id === grantId && item.jobId === jobId);
582
+ if (!grant) throw new Error("registration requires the exact current execution grant");
583
+ if (state.jobs.some((job) => job.id === jobId)) throw new Error("job IDs are immutable");
584
+ const template = {
585
+ id: jobId, taskId: grant.taskId, actorId: grant.actorId, targetId: grant.targetId,
586
+ projectId: grant.projectId, groupId: grant.groupId, host: grant.host,
587
+ capabilities: [...grant.capabilities], grantId: grant.id
588
+ };
589
+ exactGrant(policy, template, "start", at);
590
+ if (!validateGrantReferences(grant, graph, coordination)) throw new Error("execution grant no longer matches the current task");
591
+ const job = {
592
+ ...template, status: "waiting",
593
+ checkpoint: {
594
+ sequence: 0, workspaceDigest: fingerprint.digest, workspaceFiles: fingerprint.files,
595
+ workspaceBytes: fingerprint.bytes, lastToolUseId: null, resultDigest: null,
596
+ retryCount: 0, nextRetryAt: null, updatedAt: at
597
+ },
598
+ lease: null, pendingEffect: null,
599
+ maxRetries: integer(maxRetries, "maxRetries", 0, 10),
600
+ leaseSeconds: integer(leaseSeconds, "leaseSeconds", 15, 900),
601
+ baseRetrySeconds: integer(baseRetrySeconds, "baseRetrySeconds", 1, 3600),
602
+ lastBlocker: null, createdAt: at, updatedAt: at, authority: "execution-state-only"
603
+ };
604
+ state.jobs.push(job);
605
+ state.jobs.sort((a, b) => a.id.localeCompare(b.id));
606
+ appendReceipt(state, job, `receipt:registered:${receiptDigest({ id: job.id, at }).slice(0, 20)}`, "registered", at, { checkpoint: job.checkpoint.workspaceDigest });
607
+ return { job, selfstarterPath: paths.selfstarterPath, executionPolicyPath: paths.executionPolicyPath };
608
+ }
609
+ ), false);
610
+ }
611
+
612
+ function ensureScope(job, scope) {
613
+ const matches = job.actorId === scope.actorId && job.projectId === scope.projectId
614
+ && job.groupId === (scope.groupId ?? null) && job.host === scope.host
615
+ && (!scope.taskId || job.taskId === scope.taskId) && (!scope.jobId || job.id === scope.jobId);
616
+ if (!matches) throw new Error(`self-starter scope mismatch for ${job.id}`);
617
+ }
618
+
619
+ function currentTaskValid(job, coordination) {
620
+ const task = currentTask(coordination, job.taskId);
621
+ return task && task.assigneeId === job.actorId && task.projectId === job.projectId && task.groupId === job.groupId
622
+ && ["open", "in-progress"].includes(task.status);
623
+ }
624
+
625
+ function leaseExpired(job, at) {
626
+ return job.lease && new Date(job.lease.expiresAt) <= new Date(at);
627
+ }
628
+
629
+ function blocker(job, code, at) {
630
+ job.status = code === "retry-budget-exhausted" ? "exhausted" : "blocked";
631
+ job.lastBlocker = { code, at, authority: "execution-state-only" };
632
+ job.lease = null;
633
+ job.updatedAt = at;
634
+ }
635
+
636
+ async function lockedStates(root, operation) {
637
+ const paths = await pathsFor(root);
638
+ const { graph, coordination } = await referenceState(paths.catalog);
639
+ return withLock(paths.executionPolicyPath, () => readJson(paths.executionPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy), (policy) => withLock(
640
+ paths.selfstarterPath,
641
+ () => readJson(paths.selfstarterPath, paths.catalog.root, normalizeJobs, emptyJobs),
642
+ (state) => {
643
+ const findings = [
644
+ ...executionPolicyFindingsInternal(policy, graph, coordination, { checkCurrentReferences: false }),
645
+ ...selfstarterFindingsInternal(state, policy, graph, coordination)
646
+ ];
647
+ if (findings.length) throw new Error(`self-starter failed closed: ${findings.join(", ")}`);
648
+ return operation({ paths, policy, state, graph, coordination });
649
+ }
650
+ ), false);
651
+ }
652
+
653
+ export async function startOrResumeJob({ root = process.cwd(), actorId, projectId, groupId = null, taskId = null, jobId = null, host, sessionId, now = new Date() }) {
654
+ const scope = {
655
+ actorId: stableId(actorId, "actorId"), projectId: stableId(projectId, "projectId"),
656
+ groupId: stableId(groupId, "groupId", true), taskId: stableId(taskId, "taskId", true),
657
+ jobId: stableId(jobId, "jobId", true), host, sessionId: stableId(sessionId, "sessionId")
658
+ };
659
+ if (!HOSTS.has(host)) throw new Error("self-starter host must be claude or codex");
660
+ const at = timestamp(now, "now");
661
+ const fingerprint = await workspaceFingerprint(root);
662
+ return lockedStates(root, ({ policy, state, coordination, paths }) => {
663
+ const candidates = state.jobs.filter((job) => !["completed", "cancelled", "exhausted"].includes(job.status)
664
+ && job.actorId === scope.actorId && job.projectId === scope.projectId && job.groupId === scope.groupId
665
+ && job.host === host && (!scope.taskId || job.taskId === scope.taskId) && (!scope.jobId || job.id === scope.jobId));
666
+ if (!candidates.length) return { job: null, reason: "no-exact-waiting-job", selfstarterPath: paths.selfstarterPath };
667
+ if (candidates.length !== 1) throw new Error("multiple eligible self-starter jobs require an exact job or task scope");
668
+ const job = candidates[0];
669
+ ensureScope(job, scope);
670
+ if (!currentTaskValid(job, coordination)) {
671
+ preserveJob(state, job, "task-invalid", at);
672
+ blocker(job, "task-or-assignment-changed", at);
673
+ appendReceipt(state, job, `receipt:blocked:${receiptDigest({ id: job.id, at, code: job.lastBlocker.code }).slice(0, 20)}`, "blocked", at, { code: job.lastBlocker.code });
674
+ return { job: null, blocked: true, reason: job.lastBlocker.code, selfstarterPath: paths.selfstarterPath };
675
+ }
676
+ if (job.lease && !leaseExpired(job, at) && job.lease.sessionId !== scope.sessionId) {
677
+ throw new Error(`job ${job.id} has an active lease`);
678
+ }
679
+ const hasPriorRun = Boolean(job.lease) || state.receipts.some((receipt) => receipt.jobId === job.id && ["start", "resume"].includes(receipt.event));
680
+ const action = job.checkpoint.sequence === 0 && !hasPriorRun ? "start" : "resume";
681
+ exactGrant(policy, job, action, at);
682
+ if (job.checkpoint.nextRetryAt && new Date(job.checkpoint.nextRetryAt) > new Date(at)) {
683
+ return { job: null, blocked: true, reason: "retry-backoff-active", nextRetryAt: job.checkpoint.nextRetryAt, selfstarterPath: paths.selfstarterPath };
684
+ }
685
+ if (job.status === "blocked" && job.lastBlocker?.code === "host-reported-blocker") {
686
+ return { job: null, blocked: true, reason: "host-reported-blocker", selfstarterPath: paths.selfstarterPath };
687
+ }
688
+ if (job.pendingEffect && leaseExpired(job, at)) {
689
+ if (fingerprint.digest !== job.pendingEffect.beforeDigest) {
690
+ preserveJob(state, job, "crash-workspace-unknown", at);
691
+ blocker(job, "workspace-changed-after-uncheckpointed-effect", at);
692
+ appendReceipt(state, job, `receipt:blocked:${receiptDigest({ id: job.id, at, code: job.lastBlocker.code }).slice(0, 20)}`, "blocked", at, { code: job.lastBlocker.code });
693
+ return { job: null, blocked: true, reason: job.lastBlocker.code, selfstarterPath: paths.selfstarterPath };
694
+ }
695
+ preserveJob(state, job, "crash-recovered", at);
696
+ job.pendingEffect = null;
697
+ job.checkpoint.retryCount += 1;
698
+ if (job.checkpoint.retryCount > job.maxRetries) {
699
+ blocker(job, "retry-budget-exhausted", at);
700
+ return { job: null, blocked: true, reason: job.lastBlocker.code, selfstarterPath: paths.selfstarterPath };
701
+ }
702
+ }
703
+ if (fingerprint.digest !== job.checkpoint.workspaceDigest) {
704
+ preserveJob(state, job, "workspace-drift", at);
705
+ blocker(job, "workspace-changed-outside-checkpoint", at);
706
+ appendReceipt(state, job, `receipt:blocked:${receiptDigest({ id: job.id, at, code: job.lastBlocker.code }).slice(0, 20)}`, "blocked", at, { code: job.lastBlocker.code });
707
+ return { job: null, blocked: true, reason: job.lastBlocker.code, selfstarterPath: paths.selfstarterPath };
708
+ }
709
+ const receiptId = `receipt:${action}:${receiptDigest({ job: job.id, session: scope.sessionId }).slice(0, 20)}`;
710
+ const existing = state.receipts.find((receipt) => receipt.id === receiptId);
711
+ if (existing && job.lease?.sessionId === scope.sessionId) return { job, action, duplicate: true, receipt: existing, selfstarterPath: paths.selfstarterPath };
712
+ if (existing) return {
713
+ job: null, action, duplicate: true, blocked: true, reason: "session-lifecycle-already-closed",
714
+ receipt: existing, selfstarterPath: paths.selfstarterPath
715
+ };
716
+ preserveJob(state, job, action, at);
717
+ job.status = "running";
718
+ job.lastBlocker = null;
719
+ job.lease = {
720
+ sessionId: scope.sessionId, host, acquiredAt: at,
721
+ expiresAt: new Date(new Date(at).getTime() + job.leaseSeconds * 1000).toISOString(),
722
+ workspaceDigest: fingerprint.digest
723
+ };
724
+ job.updatedAt = at;
725
+ const { receipt } = appendReceipt(state, job, receiptId, action, at, { sessionId: scope.sessionId, workspaceDigest: fingerprint.digest });
726
+ return { job, action, duplicate: false, receipt, selfstarterPath: paths.selfstarterPath, authority: "explicit-local-execution-policy" };
727
+ });
728
+ }
729
+
730
+ export async function authorizeJobEffect({
731
+ root = process.cwd(), jobId, actorId, projectId, groupId = null, taskId,
732
+ host, sessionId, toolName, toolUseId, now = new Date()
733
+ }) {
734
+ const scope = {
735
+ jobId: stableId(jobId, "jobId"), actorId: stableId(actorId, "actorId"),
736
+ projectId: stableId(projectId, "projectId"), groupId: stableId(groupId, "groupId", true),
737
+ taskId: stableId(taskId, "taskId"), host, sessionId: stableId(sessionId, "sessionId")
738
+ };
739
+ const capability = `tool:${stableId(toolName, "toolName")}`;
740
+ const deliveryId = stableId(toolUseId, "toolUseId");
741
+ const at = timestamp(now, "now");
742
+ const fingerprint = await workspaceFingerprint(root);
743
+ if (fingerprint.skipped.length) throw incompleteWorkspaceScan(fingerprint.skipped);
744
+ return lockedStates(root, ({ policy, state, coordination, paths }) => {
745
+ const job = state.jobs.find((item) => item.id === scope.jobId);
746
+ if (!job) throw new Error("unknown self-starter job");
747
+ ensureScope(job, scope);
748
+ exactGrant(policy, job, "effect", at);
749
+ if (!currentTaskValid(job, coordination)) throw new Error("self-starter task or assignment changed");
750
+ if (job.status !== "running" || !job.lease || leaseExpired(job, at) || job.lease.sessionId !== scope.sessionId) throw new Error("self-starter effect requires the current live lease");
751
+ if (!job.capabilities.includes(capability)) throw new Error(`default deny: capability ${capability} is not granted for ${job.id}`);
752
+ if (fingerprint.digest !== job.checkpoint.workspaceDigest || fingerprint.digest !== job.lease.workspaceDigest) throw new Error("workspace changed outside the current checkpoint");
753
+ if (job.pendingEffect) {
754
+ if (job.pendingEffect.toolUseId === deliveryId && job.pendingEffect.capability === capability) return { allowed: true, duplicate: true, job, pendingEffect: job.pendingEffect };
755
+ throw new Error("another self-starter effect is already pending");
756
+ }
757
+ preserveJob(state, job, "effect-authorized", at);
758
+ job.pendingEffect = { toolUseId: deliveryId, capability, beforeDigest: fingerprint.digest, authorizedAt: at, sessionId: scope.sessionId };
759
+ job.updatedAt = at;
760
+ const receiptId = `receipt:authorize:${receiptDigest({ job: job.id, toolUseId: deliveryId, capability }).slice(0, 20)}`;
761
+ const { receipt } = appendReceipt(state, job, receiptId, "effect-authorized", at, { toolUseId: deliveryId, capability, beforeDigest: fingerprint.digest });
762
+ return { allowed: true, duplicate: false, job, pendingEffect: job.pendingEffect, receipt, selfstarterPath: paths.selfstarterPath };
763
+ });
764
+ }
765
+
766
+ function resultHash(value) {
767
+ let encoded;
768
+ try { encoded = JSON.stringify(value ?? null); } catch { encoded = "unserializable-result"; }
769
+ return createHash("sha256").update(encoded.slice(0, 65536)).digest("hex");
770
+ }
771
+
772
+ export async function checkpointJobEffect({
773
+ root = process.cwd(), jobId, actorId, projectId, groupId = null, taskId,
774
+ host, sessionId, toolName, toolUseId, success = true, result = null, now = new Date()
775
+ }) {
776
+ const scope = {
777
+ jobId: stableId(jobId, "jobId"), actorId: stableId(actorId, "actorId"),
778
+ projectId: stableId(projectId, "projectId"), groupId: stableId(groupId, "groupId", true),
779
+ taskId: stableId(taskId, "taskId"), host, sessionId: stableId(sessionId, "sessionId")
780
+ };
781
+ const capability = `tool:${stableId(toolName, "toolName")}`;
782
+ const deliveryId = stableId(toolUseId, "toolUseId");
783
+ const at = timestamp(now, "now");
784
+ const fingerprint = await workspaceFingerprint(root);
785
+ return lockedStates(root, ({ policy, state, coordination, paths }) => {
786
+ const job = state.jobs.find((item) => item.id === scope.jobId);
787
+ if (!job) throw new Error("unknown self-starter job");
788
+ ensureScope(job, scope);
789
+ exactGrant(policy, job, "effect", at);
790
+ if (!currentTaskValid(job, coordination)) throw new Error("self-starter task or assignment changed");
791
+ const receiptId = `receipt:checkpoint:${receiptDigest({ job: job.id, toolUseId: deliveryId }).slice(0, 20)}`;
792
+ const existing = state.receipts.find((receipt) => receipt.id === receiptId);
793
+ if (existing && job.checkpoint.lastToolUseId === deliveryId) return { job, duplicate: true, receipt: existing, selfstarterPath: paths.selfstarterPath };
794
+ if (!job.lease || job.lease.sessionId !== scope.sessionId || !job.pendingEffect
795
+ || job.pendingEffect.toolUseId !== deliveryId || job.pendingEffect.capability !== capability) throw new Error("effect checkpoint does not match the pending authorization");
796
+ preserveJob(state, job, success ? "effect-succeeded" : "effect-failed", at);
797
+ job.checkpoint.sequence += 1;
798
+ job.checkpoint.workspaceDigest = fingerprint.digest;
799
+ job.checkpoint.workspaceFiles = fingerprint.files;
800
+ job.checkpoint.workspaceBytes = fingerprint.bytes;
801
+ job.checkpoint.lastToolUseId = deliveryId;
802
+ job.checkpoint.resultDigest = resultHash(result);
803
+ job.checkpoint.updatedAt = at;
804
+ if (success) {
805
+ job.checkpoint.retryCount = 0;
806
+ job.checkpoint.nextRetryAt = null;
807
+ job.lastBlocker = null;
808
+ job.lease.workspaceDigest = fingerprint.digest;
809
+ job.lease.expiresAt = new Date(new Date(at).getTime() + job.leaseSeconds * 1000).toISOString();
810
+ } else {
811
+ job.checkpoint.retryCount += 1;
812
+ if (job.checkpoint.retryCount > job.maxRetries) blocker(job, "retry-budget-exhausted", at);
813
+ else {
814
+ const seconds = Math.min(3600, job.baseRetrySeconds * (2 ** (job.checkpoint.retryCount - 1)));
815
+ job.checkpoint.nextRetryAt = new Date(new Date(at).getTime() + seconds * 1000).toISOString();
816
+ blocker(job, "effect-failed", at);
817
+ }
818
+ }
819
+ job.pendingEffect = null;
820
+ job.updatedAt = at;
821
+ const { receipt } = appendReceipt(state, job, receiptId, success ? "effect-succeeded" : "effect-failed", at, {
822
+ toolUseId: deliveryId, capability, checkpointSequence: job.checkpoint.sequence,
823
+ workspaceDigest: fingerprint.digest, resultDigest: job.checkpoint.resultDigest, retryCount: job.checkpoint.retryCount
824
+ });
825
+ return { job, duplicate: false, receipt, selfstarterPath: paths.selfstarterPath };
826
+ });
827
+ }
828
+
829
+ export async function closeJobLease({ root = process.cwd(), jobId, actorId, projectId, groupId = null, taskId, host, sessionId, status = "waiting", now = new Date() }) {
830
+ if (!["waiting", "blocked", "completed"].includes(status)) throw new Error("closing status must be waiting, blocked, or completed");
831
+ const scope = {
832
+ jobId: stableId(jobId, "jobId"), actorId: stableId(actorId, "actorId"),
833
+ projectId: stableId(projectId, "projectId"), groupId: stableId(groupId, "groupId", true),
834
+ taskId: stableId(taskId, "taskId"), host, sessionId: stableId(sessionId, "sessionId")
835
+ };
836
+ const at = timestamp(now, "now");
837
+ return lockedStates(root, ({ policy, state, coordination, paths }) => {
838
+ const job = state.jobs.find((item) => item.id === scope.jobId);
839
+ if (!job) throw new Error("unknown self-starter job");
840
+ ensureScope(job, scope);
841
+ exactGrant(policy, job, "resume", at);
842
+ if (!currentTaskValid(job, coordination) && status !== "completed") throw new Error("self-starter task or assignment changed");
843
+ const receiptId = `receipt:close:${receiptDigest({ job: job.id, session: scope.sessionId, status }).slice(0, 20)}`;
844
+ const existing = state.receipts.find((receipt) => receipt.id === receiptId);
845
+ if (existing && !job.lease) return { job, duplicate: true, receipt: existing, selfstarterPath: paths.selfstarterPath };
846
+ if (!job.lease || job.lease.sessionId !== scope.sessionId) throw new Error("only the current lease holder may close a job");
847
+ if (job.pendingEffect) throw new Error("cannot close a job with an uncheckpointed effect");
848
+ preserveJob(state, job, "lease-closed", at);
849
+ job.status = status;
850
+ job.lease = null;
851
+ if (status === "blocked") job.lastBlocker = { code: "host-reported-blocker", at, authority: "execution-state-only" };
852
+ job.updatedAt = at;
853
+ const { receipt } = appendReceipt(state, job, receiptId, "lease-closed", at, { sessionId: scope.sessionId, status, checkpointSequence: job.checkpoint.sequence });
854
+ return { job, duplicate: false, receipt, selfstarterPath: paths.selfstarterPath };
855
+ });
856
+ }
857
+
858
+ export async function cancelJob({ root = process.cwd(), id, reason, confirmation, now = new Date() }) {
859
+ requireOwnerConfirmation(confirmation);
860
+ stableId(id, "jobId");
861
+ const at = timestamp(now, "now");
862
+ const cancelReason = safeText(reason, "reason", 500);
863
+ return lockedStates(root, ({ state, paths }) => {
864
+ const job = state.jobs.find((item) => item.id === id);
865
+ if (!job) throw new Error("unknown self-starter job");
866
+ if (["completed", "cancelled"].includes(job.status)) throw new Error("self-starter job is already terminal");
867
+ preserveJob(state, job, "cancelled", at);
868
+ job.status = "cancelled";
869
+ job.lease = null;
870
+ job.pendingEffect = null;
871
+ job.lastBlocker = { code: "owner-cancelled", at, authority: "execution-state-only" };
872
+ job.cancelReason = cancelReason;
873
+ job.updatedAt = at;
874
+ appendReceipt(state, job, `receipt:cancel:${receiptDigest({ id, at }).slice(0, 20)}`, "cancelled", at, { reasonDigest: resultHash(cancelReason) });
875
+ return { job, selfstarterPath: paths.selfstarterPath };
876
+ });
877
+ }
878
+
879
+ export async function deleteJob({ root = process.cwd(), id, confirmation }) {
880
+ requireOwnerConfirmation(confirmation);
881
+ stableId(id, "jobId");
882
+ return lockedStates(root, ({ state, paths }) => {
883
+ const job = state.jobs.find((item) => item.id === id);
884
+ if (job?.lease || job?.pendingEffect) throw new Error("cannot delete a leased or uncheckpointed job");
885
+ const existed = Boolean(job);
886
+ state.jobs = state.jobs.filter((item) => item.id !== id);
887
+ state.history = state.history.filter((entry) => entry.recordId !== id && entry.value?.id !== id);
888
+ state.receipts = state.receipts.filter((receipt) => receipt.jobId !== id);
889
+ return { deleted: existed, id, selfstarterPath: paths.selfstarterPath };
890
+ });
891
+ }
892
+
893
+ export async function selfstarterContext({ root = process.cwd(), actorId = null, projectId = null, taskId = null, includeTerminal = false } = {}) {
894
+ const paths = await pathsFor(root);
895
+ const [policy, state] = await Promise.all([
896
+ readJson(paths.executionPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
897
+ readJson(paths.selfstarterPath, paths.catalog.root, normalizeJobs, emptyJobs)
898
+ ]);
899
+ const { graph, coordination } = await referenceState(paths.catalog);
900
+ const findings = [...executionPolicyFindingsInternal(policy, graph, coordination), ...selfstarterFindingsInternal(state, policy, graph, coordination)];
901
+ if (findings.length) throw new Error(`self-starter failed closed: ${findings.join(", ")}`);
902
+ const items = state.jobs.filter((job) => (!actorId || job.actorId === actorId) && (!projectId || job.projectId === projectId)
903
+ && (!taskId || job.taskId === taskId) && (includeTerminal || !["completed", "cancelled"].includes(job.status)))
904
+ .map((job) => ({
905
+ id: job.id, taskId: job.taskId, actorId: job.actorId, targetId: job.targetId,
906
+ projectId: job.projectId, groupId: job.groupId, host: job.host, status: job.status,
907
+ checkpoint: { ...job.checkpoint }, leased: Boolean(job.lease), pendingEffect: Boolean(job.pendingEffect),
908
+ lastBlocker: job.lastBlocker, updatedAt: job.updatedAt, authority: "execution-state-only"
909
+ }));
910
+ return { schema: "agentspine.selfstarter-context/v1", root: paths.catalog.root, items, authority: "execution-state-only" };
911
+ }