blun-king-cli 9.1.563 → 9.1.565

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