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.
- package/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
- package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
- package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
- package/agent-spine-plugin/.mcp.json +8 -8
- package/agent-spine-plugin/CONTRIBUTING.md +52 -0
- package/agent-spine-plugin/LICENSE +186 -186
- package/agent-spine-plugin/README.md +394 -0
- package/agent-spine-plugin/SECURITY.md +47 -0
- package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
- package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
- package/agent-spine-plugin/bin/agentspine.js +7 -7
- package/agent-spine-plugin/blun.plugin.json +33 -33
- package/agent-spine-plugin/hooks/codex.json +47 -47
- package/agent-spine-plugin/hooks/hooks.json +106 -106
- package/agent-spine-plugin/hooks/version.json +5 -5
- package/agent-spine-plugin/package.json +69 -69
- package/agent-spine-plugin/scripts/check-hosts.js +199 -199
- package/agent-spine-plugin/skill/SKILL.md +76 -76
- package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
- package/agent-spine-plugin/src/cli.js +1488 -1442
- package/agent-spine-plugin/src/hook.js +886 -812
- package/agent-spine-plugin/src/index.js +93 -93
- package/agent-spine-plugin/src/lib/acceptance.js +333 -333
- package/agent-spine-plugin/src/lib/attention.js +755 -755
- package/agent-spine-plugin/src/lib/audit.js +351 -342
- package/agent-spine-plugin/src/lib/authentication.js +515 -515
- package/agent-spine-plugin/src/lib/briefing.js +317 -317
- package/agent-spine-plugin/src/lib/catalog.js +167 -167
- package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
- package/agent-spine-plugin/src/lib/context.js +154 -154
- package/agent-spine-plugin/src/lib/continuity.js +338 -338
- package/agent-spine-plugin/src/lib/coordination.js +577 -577
- package/agent-spine-plugin/src/lib/documents.js +217 -217
- package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
- package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
- package/agent-spine-plugin/src/lib/gateway-runtime.js +1119 -760
- package/agent-spine-plugin/src/lib/graph.js +337 -337
- package/agent-spine-plugin/src/lib/hook-audit.js +29 -0
- package/agent-spine-plugin/src/lib/https-transport.js +392 -392
- package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
- package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
- package/agent-spine-plugin/src/lib/learning.js +6923 -6466
- package/agent-spine-plugin/src/lib/object-transport.js +206 -206
- package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
- package/agent-spine-plugin/src/lib/paths.js +109 -109
- package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
- package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
- package/agent-spine-plugin/src/lib/preflight.js +702 -702
- package/agent-spine-plugin/src/lib/runtime.js +13 -13
- package/agent-spine-plugin/src/lib/selfstarter.js +892 -819
- package/agent-spine-plugin/src/lib/sharing.js +969 -969
- package/agent-spine-plugin/src/lib/source-roots.js +529 -482
- package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
- package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
- package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
- package/agent-spine-plugin/src/mcp.js +597 -572
- package/agent-spine-plugin/src/version.js +1 -1
- package/agent-spine-plugin/src/worker.js +202 -195
- package/bin/active-steer-priority-policy.cjs +24 -0
- package/bin/launcher-runtime.js +8 -1
- package/bin/mnemo-tool-agent-policy.cjs +22 -0
- package/bin/thinking-activity-status-policy.cjs +132 -0
- package/bin/thinking-only-guard.cjs +75 -0
- package/bin/tool-call-loop-policy.cjs +53 -0
- package/bin/turn-thinking-policy.cjs +25 -1
- package/blun.mjs +554 -64
- package/package.json +4 -1
- package/standard-skills/translate-native/LICENSE +21 -21
- package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
- package/standard-skills/translate-native/references/native-orthography.md +79 -79
- package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
- package/standard-skills/translate-native/references/structured-content.md +72 -72
- package/standard-skills/translate-native/references/translationese-review.md +77 -77
- package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
- package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
- package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
- package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
- package/standard-skills/translate-native/scripts/language_quality.py +377 -377
- package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
- package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
- package/standard-tools/language-guard/blun_language_guard.py +697 -697
- package/standard-tools/language-guard/check_diacritics.py +353 -353
- package/standard-tools/language-guard/guard_service_client.py +264 -264
- package/standard-tools/language-guard/language_quality.py +377 -377
- package/standard-tools/language-guard/translation_guard.py +916 -916
|
@@ -1,577 +1,577 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { open, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { buildCatalog } from "./catalog.js";
|
|
5
|
-
import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
|
|
6
|
-
import { loadGraph } from "./graph.js";
|
|
7
|
-
import { projectStateDir } from "./paths.js";
|
|
8
|
-
|
|
9
|
-
const ACTIONS = new Set(["assign", "reassign", "manage", "complete", "cancel"]);
|
|
10
|
-
const TASK_KINDS = new Set(["task", "open-thread", "handoff"]);
|
|
11
|
-
const STATUSES = new Set(["open", "in-progress", "blocked", "completed", "cancelled"]);
|
|
12
|
-
const PRIVACY = new Set(["private", "shared", "group"]);
|
|
13
|
-
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
14
|
-
const MAX_STATE_BYTES = 5 * 1024 * 1024;
|
|
15
|
-
const CONFIRMATION = "local-owner-confirmed";
|
|
16
|
-
const SECRET_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|gh[opusu])_[A-Za-z0-9_-]{20,}\b|\bBearer\s+[A-Za-z0-9._~+/-]{20,}|\b(?:api[-_ ]?key|token|password|secret)\s*[:=]\s*\S{8,}/i;
|
|
17
|
-
|
|
18
|
-
function emptyPolicy(root) {
|
|
19
|
-
return {
|
|
20
|
-
schema: "agentspine.delegation-policy/v1",
|
|
21
|
-
root,
|
|
22
|
-
revision: 0,
|
|
23
|
-
grants: [],
|
|
24
|
-
history: []
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function emptyCoordination(root) {
|
|
29
|
-
return {
|
|
30
|
-
schema: "agentspine.coordination/v1",
|
|
31
|
-
root,
|
|
32
|
-
tasks: [],
|
|
33
|
-
history: []
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function validateStateShape(value, root, schema, arrays) {
|
|
38
|
-
if (!value || typeof value !== "object" || Array.isArray(value) || value.schema !== schema || value.root !== root
|
|
39
|
-
|| arrays.some((key) => !Array.isArray(value[key]) || !value[key].every((item) => item && typeof item === "object"))) {
|
|
40
|
-
throw new Error(`${schema} state structure is invalid; run the audit before coordination`);
|
|
41
|
-
}
|
|
42
|
-
return value;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function normalizePolicy(value, root) {
|
|
46
|
-
const policy = validateStateShape(value, root, "agentspine.delegation-policy/v1", ["grants", "history"]);
|
|
47
|
-
if (!Number.isInteger(policy.revision) || policy.revision < 0) throw new Error("delegation policy revision is invalid");
|
|
48
|
-
return policy;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function normalizeCoordination(value, root) {
|
|
52
|
-
return validateStateShape(value, root, "agentspine.coordination/v1", ["tasks", "history"]);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function timestamp(value, field = "date", nullable = false) {
|
|
56
|
-
if ((value === null || value === undefined || value === "") && nullable) return null;
|
|
57
|
-
const parsed = value instanceof Date ? value : new Date(value);
|
|
58
|
-
if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
|
|
59
|
-
return parsed.toISOString();
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function numeric(value, field, minimum, maximum) {
|
|
63
|
-
const parsed = Number(value);
|
|
64
|
-
if (!Number.isFinite(parsed) || parsed < minimum || parsed > maximum) throw new Error(`${field} must be between ${minimum} and ${maximum}`);
|
|
65
|
-
return parsed;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function text(value, field, maximum, nullable = false) {
|
|
69
|
-
if ((value === null || value === undefined || value === "") && nullable) return null;
|
|
70
|
-
if (!value || typeof value !== "string") throw new Error(`${field} is required`);
|
|
71
|
-
const result = value.trim().slice(0, maximum);
|
|
72
|
-
if (!result) {
|
|
73
|
-
if (nullable) return null;
|
|
74
|
-
throw new Error(`${field} is required`);
|
|
75
|
-
}
|
|
76
|
-
if (SECRET_RE.test(result)) throw new Error(`${field} appears to contain a secret and cannot enter coordination state`);
|
|
77
|
-
return result;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function readJson(path, root, normalize, empty) {
|
|
81
|
-
try {
|
|
82
|
-
const metadata = await stat(path);
|
|
83
|
-
if (metadata.size > MAX_STATE_BYTES) throw new Error("coordination state exceeds the 5 MiB read limit");
|
|
84
|
-
return normalize(JSON.parse(await readFile(path, "utf8")), root);
|
|
85
|
-
} catch (error) {
|
|
86
|
-
if (error.code !== "ENOENT") throw error;
|
|
87
|
-
return empty(root);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async function saveJson(value, path) {
|
|
92
|
-
const content = `${JSON.stringify(value, null, 2)}\n`;
|
|
93
|
-
if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("coordination state exceeds 5 MiB; archive old tasks first");
|
|
94
|
-
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
95
|
-
await writeFile(temporary, content, { mode: 0o600 });
|
|
96
|
-
await replaceFileWithRetry(temporary, path);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function acquireLock(path) {
|
|
100
|
-
const lockPath = `${path}.lock`;
|
|
101
|
-
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
102
|
-
try {
|
|
103
|
-
return { handle: await open(lockPath, "wx", 0o600), lockPath };
|
|
104
|
-
} catch (error) {
|
|
105
|
-
if (!isFileLockContention(error)) throw error;
|
|
106
|
-
try {
|
|
107
|
-
const metadata = await stat(lockPath);
|
|
108
|
-
if (Date.now() - metadata.mtimeMs > 15000) await unlink(lockPath);
|
|
109
|
-
} catch (lockError) {
|
|
110
|
-
if (lockError.code !== "ENOENT") throw lockError;
|
|
111
|
-
}
|
|
112
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
throw new Error("coordination state is busy; retry shortly");
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
async function withStateLock(path, read, task, save = true) {
|
|
119
|
-
const { handle, lockPath } = await acquireLock(path);
|
|
120
|
-
try {
|
|
121
|
-
const state = await read();
|
|
122
|
-
const result = await task(state);
|
|
123
|
-
if (save) await saveJson(state, path);
|
|
124
|
-
return result;
|
|
125
|
-
} finally {
|
|
126
|
-
await handle.close();
|
|
127
|
-
await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async function pathsFor(root, providedCatalog = null) {
|
|
132
|
-
const catalog = providedCatalog || await buildCatalog(root);
|
|
133
|
-
const directory = await projectStateDir(catalog.root);
|
|
134
|
-
return {
|
|
135
|
-
catalog,
|
|
136
|
-
policyPath: join(directory, "delegation-policy.json"),
|
|
137
|
-
coordinationPath: join(directory, "coordination.json")
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export async function loadDelegationPolicy(root = process.cwd(), providedCatalog = null) {
|
|
142
|
-
const paths = await pathsFor(root, providedCatalog);
|
|
143
|
-
const policy = await readJson(paths.policyPath, paths.catalog.root, normalizePolicy, emptyPolicy);
|
|
144
|
-
return { policy, ...paths };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export async function loadCoordination(root = process.cwd(), providedCatalog = null) {
|
|
148
|
-
const paths = await pathsFor(root, providedCatalog);
|
|
149
|
-
const coordination = await readJson(paths.coordinationPath, paths.catalog.root, normalizeCoordination, emptyCoordination);
|
|
150
|
-
return { coordination, ...paths };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export async function inspectCoordination(root = process.cwd(), providedCatalog = null) {
|
|
154
|
-
const paths = await pathsFor(root, providedCatalog);
|
|
155
|
-
let policy = emptyPolicy(paths.catalog.root);
|
|
156
|
-
let coordination = emptyCoordination(paths.catalog.root);
|
|
157
|
-
const errors = [];
|
|
158
|
-
try { policy = await readJson(paths.policyPath, paths.catalog.root, normalizePolicy, emptyPolicy); } catch (error) { errors.push(`policy:${error.message}`); }
|
|
159
|
-
try { coordination = await readJson(paths.coordinationPath, paths.catalog.root, normalizeCoordination, emptyCoordination); } catch (error) { errors.push(`coordination:${error.message}`); }
|
|
160
|
-
return { policy, coordination, errors, ...paths };
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function knownActor(graph, id) {
|
|
164
|
-
return graph.entities.find((entity) => entity.id === id && ["person", "agent"].includes(entity.kind));
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function validateEntity(graph, id, field, nullable = false) {
|
|
168
|
-
if ((id === null || id === undefined) && nullable) return null;
|
|
169
|
-
if (!ID_RE.test(id || "") || !knownActor(graph, id)) throw new Error(`${field} must reference a known person or agent`);
|
|
170
|
-
return id;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function policyDecision(policy, actorId, action, targetId) {
|
|
174
|
-
const grant = policy.grants.find((item) => item.active === true && item.actorId === actorId
|
|
175
|
-
&& item.actions.includes(action) && (item.targetIds.includes("*") || (targetId && item.targetIds.includes(targetId))));
|
|
176
|
-
return grant ? {
|
|
177
|
-
allowed: true,
|
|
178
|
-
action,
|
|
179
|
-
actorId,
|
|
180
|
-
targetId,
|
|
181
|
-
grantId: grant.id,
|
|
182
|
-
policyRevision: policy.revision,
|
|
183
|
-
reason: "matched explicit local delegation policy",
|
|
184
|
-
authority: "explicit-local-delegation-policy"
|
|
185
|
-
} : {
|
|
186
|
-
allowed: false,
|
|
187
|
-
action,
|
|
188
|
-
actorId,
|
|
189
|
-
targetId,
|
|
190
|
-
grantId: null,
|
|
191
|
-
policyRevision: policy.revision,
|
|
192
|
-
reason: "default deny: no matching explicit local delegation grant",
|
|
193
|
-
authority: "explicit-local-delegation-policy"
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export async function checkDelegation({ root = process.cwd(), actorId, action, targetId = null }) {
|
|
198
|
-
if (!ACTIONS.has(action)) throw new Error(`unsupported delegation action: ${action}`);
|
|
199
|
-
const { policy, catalog } = await loadDelegationPolicy(root);
|
|
200
|
-
const { graph } = await loadGraph(catalog.root, catalog);
|
|
201
|
-
const findings = delegationPolicyFindings(policy, graph);
|
|
202
|
-
if (findings.length) throw new Error(`delegation policy failed closed: ${findings.join(", ")}`);
|
|
203
|
-
validateEntity(graph, actorId, "actorId");
|
|
204
|
-
if (targetId !== null) validateEntity(graph, targetId, "targetId");
|
|
205
|
-
return {
|
|
206
|
-
...policyDecision(policy, actorId, action, targetId),
|
|
207
|
-
note: "This decision covers AgentSpine task coordination only. It grants no host, tool, file, network, production, or spending rights."
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function requireOwnerConfirmation(confirmation) {
|
|
212
|
-
if (confirmation !== CONFIRMATION) throw new Error("delegation policy changes require explicit local owner confirmation");
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function preservePolicy(policy, value, now) {
|
|
216
|
-
policy.history.push({
|
|
217
|
-
kind: "delegation-grant",
|
|
218
|
-
recordId: value.id,
|
|
219
|
-
supersededAt: now,
|
|
220
|
-
value: { ...value, authority: "explicit-local-delegation-policy" },
|
|
221
|
-
authority: "explicit-local-delegation-policy"
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export async function grantDelegation({
|
|
226
|
-
root = process.cwd(), id = `grant:${randomUUID()}`, actorId, actions, targetIds = ["*"],
|
|
227
|
-
reason, confirmation, now = new Date()
|
|
228
|
-
}) {
|
|
229
|
-
requireOwnerConfirmation(confirmation);
|
|
230
|
-
if (!ID_RE.test(id)) throw new Error("grant id must be stable and whitespace-free");
|
|
231
|
-
if (!Array.isArray(actions) || !actions.length || actions.some((action) => !ACTIONS.has(action))) throw new Error("actions contains an unsupported delegation action");
|
|
232
|
-
if (!Array.isArray(targetIds) || !targetIds.length) throw new Error("targetIds is required");
|
|
233
|
-
const policyReason = text(reason, "reason", 500);
|
|
234
|
-
const at = timestamp(now, "now");
|
|
235
|
-
const { catalog, policyPath } = await pathsFor(root);
|
|
236
|
-
const { graph } = await loadGraph(catalog.root, catalog);
|
|
237
|
-
validateEntity(graph, actorId, "actorId");
|
|
238
|
-
for (const targetId of targetIds) if (targetId !== "*") validateEntity(graph, targetId, "targetId");
|
|
239
|
-
return withStateLock(policyPath, () => readJson(policyPath, catalog.root, normalizePolicy, emptyPolicy), (policy) => {
|
|
240
|
-
const findings = delegationPolicyFindings(policy, graph);
|
|
241
|
-
if (findings.length) throw new Error(`delegation policy failed closed: ${findings.join(", ")}`);
|
|
242
|
-
if (policy.grants.some((grant) => grant.id === id)) throw new Error("delegation grant IDs are immutable; revoke the old grant and create a new one");
|
|
243
|
-
policy.revision += 1;
|
|
244
|
-
const grant = {
|
|
245
|
-
id,
|
|
246
|
-
actorId,
|
|
247
|
-
actions: [...new Set(actions)].sort(),
|
|
248
|
-
targetIds: [...new Set(targetIds)].sort(),
|
|
249
|
-
reason: policyReason,
|
|
250
|
-
active: true,
|
|
251
|
-
createdAt: at,
|
|
252
|
-
updatedAt: at,
|
|
253
|
-
revision: policy.revision,
|
|
254
|
-
source: "explicit-local-owner-policy",
|
|
255
|
-
authority: "explicit-local-delegation-policy"
|
|
256
|
-
};
|
|
257
|
-
policy.grants.push(grant);
|
|
258
|
-
policy.grants.sort((a, b) => a.id.localeCompare(b.id));
|
|
259
|
-
return { grant, policyRevision: policy.revision, policyPath };
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
export async function revokeDelegation({ root = process.cwd(), id, reason, confirmation, now = new Date() }) {
|
|
264
|
-
requireOwnerConfirmation(confirmation);
|
|
265
|
-
if (!ID_RE.test(id || "")) throw new Error("grant id is required");
|
|
266
|
-
const revokeReason = text(reason, "reason", 500);
|
|
267
|
-
const at = timestamp(now, "now");
|
|
268
|
-
const { catalog, policyPath } = await pathsFor(root);
|
|
269
|
-
const { graph } = await loadGraph(catalog.root, catalog);
|
|
270
|
-
return withStateLock(policyPath, () => readJson(policyPath, catalog.root, normalizePolicy, emptyPolicy), (policy) => {
|
|
271
|
-
const findings = delegationPolicyFindings(policy, graph);
|
|
272
|
-
if (findings.length) throw new Error(`delegation policy failed closed: ${findings.join(", ")}`);
|
|
273
|
-
const previous = policy.grants.find((grant) => grant.id === id);
|
|
274
|
-
if (!previous) throw new Error(`unknown delegation grant: ${id}`);
|
|
275
|
-
if (!previous.active) throw new Error(`delegation grant is already revoked: ${id}`);
|
|
276
|
-
preservePolicy(policy, previous, at);
|
|
277
|
-
policy.revision += 1;
|
|
278
|
-
const grant = {
|
|
279
|
-
...previous,
|
|
280
|
-
active: false,
|
|
281
|
-
revokedAt: at,
|
|
282
|
-
revokeReason,
|
|
283
|
-
updatedAt: at,
|
|
284
|
-
revision: policy.revision,
|
|
285
|
-
authority: "explicit-local-delegation-policy"
|
|
286
|
-
};
|
|
287
|
-
policy.grants = policy.grants.map((item) => item.id === id ? grant : item);
|
|
288
|
-
return { grant, policyRevision: policy.revision, policyPath };
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function isGroupMember(graph, groupId, entityId) {
|
|
293
|
-
if (!entityId || entityId === groupId) return true;
|
|
294
|
-
return graph.entityEdges.some((edge) => edge.relation === "member-of" && edge.privacy !== "private" && (
|
|
295
|
-
(edge.from === entityId && edge.to === groupId) || (edge.to === entityId && edge.from === groupId)
|
|
296
|
-
));
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function validatePrivacy(graph, privacy, groupId, actorId, assigneeId) {
|
|
300
|
-
if (!PRIVACY.has(privacy)) throw new Error(`unsupported privacy scope: ${privacy}`);
|
|
301
|
-
if (privacy === "group") {
|
|
302
|
-
const group = graph.entities.find((entity) => entity.id === groupId && entity.kind === "group");
|
|
303
|
-
if (!group) throw new Error("group privacy requires a known groupId");
|
|
304
|
-
for (const entityId of [actorId, assigneeId].filter(Boolean)) {
|
|
305
|
-
if (!isGroupMember(graph, groupId, entityId)) throw new Error(`${entityId} is not a visible member of ${groupId}`);
|
|
306
|
-
}
|
|
307
|
-
} else if (groupId !== null && groupId !== undefined) {
|
|
308
|
-
throw new Error("groupId is only valid with group privacy");
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function preserveTask(coordination, value, now) {
|
|
313
|
-
coordination.history.push({
|
|
314
|
-
kind: "coordination-task",
|
|
315
|
-
recordId: value.id,
|
|
316
|
-
actorId: value.createdBy,
|
|
317
|
-
assigneeId: value.assigneeId,
|
|
318
|
-
supersededAt: now,
|
|
319
|
-
privacy: value.privacy,
|
|
320
|
-
value: { ...value, authority: "context-only" },
|
|
321
|
-
authority: "context-only"
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
async function coordinationMutation(root, operation) {
|
|
326
|
-
const { catalog, policyPath, coordinationPath } = await pathsFor(root);
|
|
327
|
-
const { graph } = await loadGraph(catalog.root, catalog);
|
|
328
|
-
return withStateLock(
|
|
329
|
-
policyPath,
|
|
330
|
-
() => readJson(policyPath, catalog.root, normalizePolicy, emptyPolicy),
|
|
331
|
-
(policy) => withStateLock(
|
|
332
|
-
coordinationPath,
|
|
333
|
-
() => readJson(coordinationPath, catalog.root, normalizeCoordination, emptyCoordination),
|
|
334
|
-
(coordination) => {
|
|
335
|
-
const findings = [
|
|
336
|
-
...delegationPolicyFindings(policy, graph),
|
|
337
|
-
...coordinationFindings(coordination, policy, graph)
|
|
338
|
-
];
|
|
339
|
-
if (findings.length) throw new Error(`coordination state failed closed: ${findings.join(", ")}`);
|
|
340
|
-
return operation({ policy, coordination, graph, catalog, policyPath, coordinationPath });
|
|
341
|
-
}
|
|
342
|
-
),
|
|
343
|
-
false
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function decisionSnapshot(decision, at) {
|
|
348
|
-
return { ...decision, decidedAt: at };
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
export async function createTask({
|
|
352
|
-
root = process.cwd(), id = `task:${randomUUID()}`, actorId, assigneeId = null,
|
|
353
|
-
kind = "task", title, summary = null, projectId = null, privacy = "private", groupId = null,
|
|
354
|
-
priority = 50, dueAt = null, now = new Date()
|
|
355
|
-
}) {
|
|
356
|
-
if (!ID_RE.test(id)) throw new Error("task id must be stable and whitespace-free");
|
|
357
|
-
if (!TASK_KINDS.has(kind)) throw new Error(`unsupported task kind: ${kind}`);
|
|
358
|
-
const taskTitle = text(title, "title", 200);
|
|
359
|
-
const taskSummary = text(summary, "summary", 1000, true);
|
|
360
|
-
const at = timestamp(now, "now");
|
|
361
|
-
const due = timestamp(dueAt, "dueAt", true);
|
|
362
|
-
return coordinationMutation(root, async ({ policy, coordination, graph, coordinationPath }) => {
|
|
363
|
-
if (coordination.tasks.some((task) => task.id === id)) throw new Error("task IDs are immutable");
|
|
364
|
-
validateEntity(graph, actorId, "actorId");
|
|
365
|
-
if (assigneeId !== null) validateEntity(graph, assigneeId, "assigneeId");
|
|
366
|
-
if (projectId !== null && !graph.entities.some((entity) => entity.id === projectId && entity.kind === "project")) throw new Error("projectId must reference a known project");
|
|
367
|
-
validatePrivacy(graph, privacy, groupId, actorId, assigneeId);
|
|
368
|
-
let assignment;
|
|
369
|
-
if (!assigneeId || assigneeId === actorId) {
|
|
370
|
-
assignment = { allowed: true, action: "assign", actorId, targetId: assigneeId, grantId: null, policyRevision: policy.revision, reason: "self-assignment or unassigned thread", authority: "self-coordination" };
|
|
371
|
-
} else {
|
|
372
|
-
assignment = policyDecision(policy, actorId, "assign", assigneeId);
|
|
373
|
-
if (!assignment.allowed) throw new Error(assignment.reason);
|
|
374
|
-
}
|
|
375
|
-
const task = {
|
|
376
|
-
id,
|
|
377
|
-
kind,
|
|
378
|
-
title: taskTitle,
|
|
379
|
-
summary: taskSummary,
|
|
380
|
-
createdBy: actorId,
|
|
381
|
-
assigneeId,
|
|
382
|
-
projectId,
|
|
383
|
-
privacy,
|
|
384
|
-
groupId,
|
|
385
|
-
priority: numeric(priority, "priority", 0, 100),
|
|
386
|
-
dueAt: due,
|
|
387
|
-
status: "open",
|
|
388
|
-
note: null,
|
|
389
|
-
assignment: decisionSnapshot(assignment, at),
|
|
390
|
-
createdAt: at,
|
|
391
|
-
updatedAt: at,
|
|
392
|
-
authority: "context-only"
|
|
393
|
-
};
|
|
394
|
-
coordination.tasks.push(task);
|
|
395
|
-
coordination.tasks.sort((a, b) => a.id.localeCompare(b.id));
|
|
396
|
-
return { task, coordinationPath };
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
function canManageTask(policy, task, actorId, action, targetId) {
|
|
401
|
-
if (actorId === task.assigneeId || (task.assigneeId === null && actorId === task.createdBy)) {
|
|
402
|
-
return { allowed: true, action, actorId, targetId, grantId: null, policyRevision: policy.revision, reason: "assignee self-management", authority: "self-coordination" };
|
|
403
|
-
}
|
|
404
|
-
return policyDecision(policy, actorId, action, targetId);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
export async function updateTask({ root = process.cwd(), id, actorId, patch = {}, now = new Date() }) {
|
|
408
|
-
if (!ID_RE.test(id || "")) throw new Error("task id is required");
|
|
409
|
-
if (!patch || typeof patch !== "object" || Array.isArray(patch) || !Object.keys(patch).length) throw new Error("patch must change at least one task field");
|
|
410
|
-
const allowedFields = new Set(["status", "assigneeId", "title", "summary", "priority", "dueAt", "note"]);
|
|
411
|
-
const unknown = Object.keys(patch).filter((key) => !allowedFields.has(key));
|
|
412
|
-
if (unknown.length) throw new Error(`unsupported task patch: ${unknown.join(", ")}`);
|
|
413
|
-
const at = timestamp(now, "now");
|
|
414
|
-
return coordinationMutation(root, async ({ policy, coordination, graph, coordinationPath }) => {
|
|
415
|
-
const previous = coordination.tasks.find((task) => task.id === id);
|
|
416
|
-
if (!previous) throw new Error(`unknown coordination task: ${id}`);
|
|
417
|
-
validateEntity(graph, actorId, "actorId");
|
|
418
|
-
let assignment = previous.assignment;
|
|
419
|
-
const nextAssignee = "assigneeId" in patch ? patch.assigneeId : previous.assigneeId;
|
|
420
|
-
if (nextAssignee !== null) validateEntity(graph, nextAssignee, "assigneeId");
|
|
421
|
-
if (nextAssignee !== previous.assigneeId) {
|
|
422
|
-
const decision = policyDecision(policy, actorId, "reassign", nextAssignee);
|
|
423
|
-
if (!decision.allowed) throw new Error(decision.reason);
|
|
424
|
-
validatePrivacy(graph, previous.privacy, previous.groupId, actorId, nextAssignee);
|
|
425
|
-
assignment = decisionSnapshot(decision, at);
|
|
426
|
-
}
|
|
427
|
-
if (patch.status && !STATUSES.has(patch.status)) throw new Error(`unsupported task status: ${patch.status}`);
|
|
428
|
-
if (patch.status && patch.status !== previous.status) {
|
|
429
|
-
const action = patch.status === "cancelled" ? "cancel" : patch.status === "completed" ? "complete" : "manage";
|
|
430
|
-
const decision = canManageTask(policy, previous, actorId, action, previous.assigneeId);
|
|
431
|
-
if (!decision.allowed) throw new Error(decision.reason);
|
|
432
|
-
}
|
|
433
|
-
const changesContent = ["title", "summary", "priority", "dueAt", "note"].some((key) => key in patch);
|
|
434
|
-
if (changesContent && actorId !== previous.createdBy && actorId !== previous.assigneeId) {
|
|
435
|
-
const decision = policyDecision(policy, actorId, "manage", previous.assigneeId);
|
|
436
|
-
if (!decision.allowed) throw new Error(decision.reason);
|
|
437
|
-
}
|
|
438
|
-
preserveTask(coordination, previous, at);
|
|
439
|
-
const task = {
|
|
440
|
-
...previous,
|
|
441
|
-
assigneeId: nextAssignee,
|
|
442
|
-
assignment,
|
|
443
|
-
status: patch.status || previous.status,
|
|
444
|
-
title: "title" in patch ? text(patch.title, "title", 200) : previous.title,
|
|
445
|
-
summary: "summary" in patch ? text(patch.summary, "summary", 1000, true) : previous.summary,
|
|
446
|
-
priority: "priority" in patch ? numeric(patch.priority, "priority", 0, 100) : previous.priority,
|
|
447
|
-
dueAt: "dueAt" in patch ? timestamp(patch.dueAt, "dueAt", true) : previous.dueAt,
|
|
448
|
-
note: "note" in patch ? text(patch.note, "note", 1000, true) : previous.note,
|
|
449
|
-
updatedAt: at,
|
|
450
|
-
authority: "context-only"
|
|
451
|
-
};
|
|
452
|
-
coordination.tasks = coordination.tasks.map((entry) => entry.id === id ? task : entry);
|
|
453
|
-
return { task, coordinationPath };
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
function audienceIds(graph, groupId, includePrivate) {
|
|
458
|
-
const ids = new Set();
|
|
459
|
-
if (!groupId) return ids;
|
|
460
|
-
ids.add(groupId);
|
|
461
|
-
for (const edge of graph.entityEdges) {
|
|
462
|
-
if (edge.relation !== "member-of" || (!includePrivate && edge.privacy === "private")) continue;
|
|
463
|
-
if (edge.to === groupId) ids.add(edge.from);
|
|
464
|
-
if (edge.from === groupId) ids.add(edge.to);
|
|
465
|
-
}
|
|
466
|
-
return ids;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
function taskVisible(task, entities, audience, includePrivate, groupId) {
|
|
470
|
-
if (task.privacy === "private" && !includePrivate) return false;
|
|
471
|
-
if (task.privacy === "group" && (!groupId || task.groupId !== groupId)) return false;
|
|
472
|
-
for (const id of [task.createdBy, task.assigneeId].filter(Boolean)) {
|
|
473
|
-
const entity = entities.get(id);
|
|
474
|
-
if (entity?.privacy === "private" && !includePrivate) return false;
|
|
475
|
-
if (entity?.privacy === "group" && !audience.has(id)) return false;
|
|
476
|
-
}
|
|
477
|
-
return true;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
export async function taskContext({
|
|
481
|
-
root = process.cwd(), includePrivate = false, groupId = null, actorId = null,
|
|
482
|
-
assigneeId = null, projectId = null, includeClosed = false, maxItems = 20,
|
|
483
|
-
catalog: providedCatalog = null
|
|
484
|
-
} = {}) {
|
|
485
|
-
const { coordination, catalog } = await loadCoordination(root, providedCatalog);
|
|
486
|
-
const { policy } = await loadDelegationPolicy(catalog.root, catalog);
|
|
487
|
-
const { graph } = await loadGraph(catalog.root, catalog);
|
|
488
|
-
const findings = [
|
|
489
|
-
...delegationPolicyFindings(policy, graph),
|
|
490
|
-
...coordinationFindings(coordination, policy, graph)
|
|
491
|
-
];
|
|
492
|
-
if (findings.length) throw new Error(`coordination state failed closed: ${findings.join(", ")}`);
|
|
493
|
-
const entities = new Map(graph.entities.map((entity) => [entity.id, entity]));
|
|
494
|
-
if (groupId !== null && !graph.entities.some((entity) => entity.id === groupId && entity.kind === "group")) throw new Error(`unknown group entity: ${groupId}`);
|
|
495
|
-
const audience = audienceIds(graph, groupId, includePrivate);
|
|
496
|
-
const limit = numeric(maxItems, "maxItems", 0, 100);
|
|
497
|
-
if (!Number.isInteger(limit)) throw new Error("maxItems must be an integer");
|
|
498
|
-
const items = coordination.tasks
|
|
499
|
-
.filter((task) => includeClosed || !["completed", "cancelled"].includes(task.status))
|
|
500
|
-
.filter((task) => !actorId || task.createdBy === actorId)
|
|
501
|
-
.filter((task) => !assigneeId || task.assigneeId === assigneeId)
|
|
502
|
-
.filter((task) => !projectId || task.projectId === projectId)
|
|
503
|
-
.filter((task) => taskVisible(task, entities, audience, includePrivate, groupId))
|
|
504
|
-
.sort((a, b) => b.priority - a.priority || (a.dueAt || "9999").localeCompare(b.dueAt || "9999") || a.id.localeCompare(b.id))
|
|
505
|
-
.slice(0, limit)
|
|
506
|
-
.map(({ assignment: _assignment, ...task }) => ({ ...task, authority: "context-only" }));
|
|
507
|
-
return {
|
|
508
|
-
schema: "agentspine.task-context/v1",
|
|
509
|
-
root: catalog.root,
|
|
510
|
-
groupId,
|
|
511
|
-
items,
|
|
512
|
-
authority: "context-only",
|
|
513
|
-
note: "Tasks describe coordination only. They grant no host, tool, file, network, production, or spending rights."
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
export async function deleteTask({ root = process.cwd(), id, confirmation }) {
|
|
518
|
-
requireOwnerConfirmation(confirmation);
|
|
519
|
-
if (!ID_RE.test(id || "")) throw new Error("task id is required");
|
|
520
|
-
return coordinationMutation(root, ({ coordination, coordinationPath }) => {
|
|
521
|
-
const existed = coordination.tasks.some((task) => task.id === id);
|
|
522
|
-
coordination.tasks = coordination.tasks.filter((task) => task.id !== id);
|
|
523
|
-
coordination.history = coordination.history.filter((entry) => entry.recordId !== id && entry.value?.id !== id);
|
|
524
|
-
return { deleted: existed, id, coordinationPath };
|
|
525
|
-
});
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
export function delegationPolicyFindings(policy, graph) {
|
|
529
|
-
const findings = [];
|
|
530
|
-
if (policy.schema !== "agentspine.delegation-policy/v1" || !Number.isInteger(policy.revision) || policy.revision < 0) findings.push("invalid-policy");
|
|
531
|
-
const grants = [...policy.grants, ...policy.history.map((entry) => entry.value).filter(Boolean)];
|
|
532
|
-
if (new Set(policy.grants.map((grant) => grant.id)).size !== policy.grants.length) findings.push("duplicate-active-grant-id");
|
|
533
|
-
for (const grant of grants) {
|
|
534
|
-
if (!ID_RE.test(grant.id || "") || grant.authority !== "explicit-local-delegation-policy" || grant.source !== "explicit-local-owner-policy"
|
|
535
|
-
|| !knownActor(graph, grant.actorId) || !Array.isArray(grant.actions) || grant.actions.some((action) => !ACTIONS.has(action))
|
|
536
|
-
|| !Array.isArray(grant.targetIds) || grant.targetIds.some((id) => id !== "*" && !knownActor(graph, id))
|
|
537
|
-
|| typeof grant.active !== "boolean" || typeof grant.reason !== "string" || !grant.reason
|
|
538
|
-
|| SECRET_RE.test(grant.reason || "") || !Number.isInteger(grant.revision) || grant.revision < 1 || grant.revision > policy.revision) {
|
|
539
|
-
findings.push(`invalid-grant:${grant.id || "unknown"}`);
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
if (policy.history.some((entry) => entry.authority !== "explicit-local-delegation-policy")) findings.push("invalid-policy-history");
|
|
543
|
-
return findings;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
export function coordinationFindings(coordination, policy, graph) {
|
|
547
|
-
const findings = [];
|
|
548
|
-
const allGrants = new Map([...policy.grants, ...policy.history.map((entry) => entry.value).filter(Boolean)].map((grant) => [grant.id, grant]));
|
|
549
|
-
const records = [
|
|
550
|
-
...coordination.tasks.map((task) => ({ task, current: true })),
|
|
551
|
-
...coordination.history.map((entry) => entry.value).filter(Boolean).map((task) => ({ task, current: false }))
|
|
552
|
-
];
|
|
553
|
-
for (const { task, current } of records) {
|
|
554
|
-
if (!ID_RE.test(task.id || "") || task.authority !== "context-only" || !TASK_KINDS.has(task.kind) || !STATUSES.has(task.status) || !PRIVACY.has(task.privacy)
|
|
555
|
-
|| typeof task.title !== "string" || !task.title || task.title.length > 200
|
|
556
|
-
|| (task.summary !== null && typeof task.summary !== "string") || (task.note !== null && typeof task.note !== "string")
|
|
557
|
-
|| !Number.isFinite(task.priority) || task.priority < 0 || task.priority > 100
|
|
558
|
-
|| !knownActor(graph, task.createdBy) || (task.assigneeId && !knownActor(graph, task.assigneeId))) {
|
|
559
|
-
findings.push(`invalid-task:${task.id || "unknown"}`);
|
|
560
|
-
}
|
|
561
|
-
const grant = allGrants.get(task.assignment?.grantId);
|
|
562
|
-
if (task.assignment?.authority === "explicit-local-delegation-policy" && (
|
|
563
|
-
!grant || grant.actorId !== task.assignment.actorId || !grant.actions.includes(task.assignment.action)
|
|
564
|
-
|| (!grant.targetIds.includes("*") && !grant.targetIds.includes(task.assignment.targetId))
|
|
565
|
-
)) findings.push(`missing-policy-snapshot:${task.id}`);
|
|
566
|
-
if (task.assignment?.allowed !== true || !new Set(["explicit-local-delegation-policy", "self-coordination"]).has(task.assignment?.authority)
|
|
567
|
-
|| task.assignment?.targetId !== task.assigneeId || !Number.isInteger(task.assignment?.policyRevision)
|
|
568
|
-
|| task.assignment.policyRevision < 0 || task.assignment.policyRevision > policy.revision) findings.push(`invalid-assignment:${task.id}`);
|
|
569
|
-
if (task.assignment?.authority === "self-coordination" && task.assigneeId && task.assigneeId !== task.createdBy) findings.push(`invalid-self-assignment:${task.id}`);
|
|
570
|
-
if (SECRET_RE.test(`${task.title || ""} ${task.summary || ""} ${task.note || ""}`)) findings.push(`unsafe-task:${task.id}`);
|
|
571
|
-
const knownGroup = graph.entities.some((entity) => entity.id === task.groupId && entity.kind === "group");
|
|
572
|
-
if (task.privacy === "group" && (!knownGroup || (current && (!isGroupMember(graph, task.groupId, task.createdBy) || !isGroupMember(graph, task.groupId, task.assigneeId))))) findings.push(`invalid-task-group:${task.id}`);
|
|
573
|
-
if (task.privacy !== "group" && task.groupId) findings.push(`unexpected-task-group:${task.id}`);
|
|
574
|
-
}
|
|
575
|
-
if (coordination.history.some((entry) => entry.authority !== "context-only")) findings.push("invalid-task-history");
|
|
576
|
-
return findings;
|
|
577
|
-
}
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { open, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { buildCatalog } from "./catalog.js";
|
|
5
|
+
import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
|
|
6
|
+
import { loadGraph } from "./graph.js";
|
|
7
|
+
import { projectStateDir } from "./paths.js";
|
|
8
|
+
|
|
9
|
+
const ACTIONS = new Set(["assign", "reassign", "manage", "complete", "cancel"]);
|
|
10
|
+
const TASK_KINDS = new Set(["task", "open-thread", "handoff"]);
|
|
11
|
+
const STATUSES = new Set(["open", "in-progress", "blocked", "completed", "cancelled"]);
|
|
12
|
+
const PRIVACY = new Set(["private", "shared", "group"]);
|
|
13
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
14
|
+
const MAX_STATE_BYTES = 5 * 1024 * 1024;
|
|
15
|
+
const CONFIRMATION = "local-owner-confirmed";
|
|
16
|
+
const SECRET_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|gh[opusu])_[A-Za-z0-9_-]{20,}\b|\bBearer\s+[A-Za-z0-9._~+/-]{20,}|\b(?:api[-_ ]?key|token|password|secret)\s*[:=]\s*\S{8,}/i;
|
|
17
|
+
|
|
18
|
+
function emptyPolicy(root) {
|
|
19
|
+
return {
|
|
20
|
+
schema: "agentspine.delegation-policy/v1",
|
|
21
|
+
root,
|
|
22
|
+
revision: 0,
|
|
23
|
+
grants: [],
|
|
24
|
+
history: []
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function emptyCoordination(root) {
|
|
29
|
+
return {
|
|
30
|
+
schema: "agentspine.coordination/v1",
|
|
31
|
+
root,
|
|
32
|
+
tasks: [],
|
|
33
|
+
history: []
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function validateStateShape(value, root, schema, arrays) {
|
|
38
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || value.schema !== schema || value.root !== root
|
|
39
|
+
|| arrays.some((key) => !Array.isArray(value[key]) || !value[key].every((item) => item && typeof item === "object"))) {
|
|
40
|
+
throw new Error(`${schema} state structure is invalid; run the audit before coordination`);
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizePolicy(value, root) {
|
|
46
|
+
const policy = validateStateShape(value, root, "agentspine.delegation-policy/v1", ["grants", "history"]);
|
|
47
|
+
if (!Number.isInteger(policy.revision) || policy.revision < 0) throw new Error("delegation policy revision is invalid");
|
|
48
|
+
return policy;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeCoordination(value, root) {
|
|
52
|
+
return validateStateShape(value, root, "agentspine.coordination/v1", ["tasks", "history"]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function timestamp(value, field = "date", nullable = false) {
|
|
56
|
+
if ((value === null || value === undefined || value === "") && nullable) return null;
|
|
57
|
+
const parsed = value instanceof Date ? value : new Date(value);
|
|
58
|
+
if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
|
|
59
|
+
return parsed.toISOString();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function numeric(value, field, minimum, maximum) {
|
|
63
|
+
const parsed = Number(value);
|
|
64
|
+
if (!Number.isFinite(parsed) || parsed < minimum || parsed > maximum) throw new Error(`${field} must be between ${minimum} and ${maximum}`);
|
|
65
|
+
return parsed;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function text(value, field, maximum, nullable = false) {
|
|
69
|
+
if ((value === null || value === undefined || value === "") && nullable) return null;
|
|
70
|
+
if (!value || typeof value !== "string") throw new Error(`${field} is required`);
|
|
71
|
+
const result = value.trim().slice(0, maximum);
|
|
72
|
+
if (!result) {
|
|
73
|
+
if (nullable) return null;
|
|
74
|
+
throw new Error(`${field} is required`);
|
|
75
|
+
}
|
|
76
|
+
if (SECRET_RE.test(result)) throw new Error(`${field} appears to contain a secret and cannot enter coordination state`);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function readJson(path, root, normalize, empty) {
|
|
81
|
+
try {
|
|
82
|
+
const metadata = await stat(path);
|
|
83
|
+
if (metadata.size > MAX_STATE_BYTES) throw new Error("coordination state exceeds the 5 MiB read limit");
|
|
84
|
+
return normalize(JSON.parse(await readFile(path, "utf8")), root);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (error.code !== "ENOENT") throw error;
|
|
87
|
+
return empty(root);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function saveJson(value, path) {
|
|
92
|
+
const content = `${JSON.stringify(value, null, 2)}\n`;
|
|
93
|
+
if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("coordination state exceeds 5 MiB; archive old tasks first");
|
|
94
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
95
|
+
await writeFile(temporary, content, { mode: 0o600 });
|
|
96
|
+
await replaceFileWithRetry(temporary, path);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function acquireLock(path) {
|
|
100
|
+
const lockPath = `${path}.lock`;
|
|
101
|
+
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
102
|
+
try {
|
|
103
|
+
return { handle: await open(lockPath, "wx", 0o600), lockPath };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (!isFileLockContention(error)) throw error;
|
|
106
|
+
try {
|
|
107
|
+
const metadata = await stat(lockPath);
|
|
108
|
+
if (Date.now() - metadata.mtimeMs > 15000) await unlink(lockPath);
|
|
109
|
+
} catch (lockError) {
|
|
110
|
+
if (lockError.code !== "ENOENT") throw lockError;
|
|
111
|
+
}
|
|
112
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
throw new Error("coordination state is busy; retry shortly");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function withStateLock(path, read, task, save = true) {
|
|
119
|
+
const { handle, lockPath } = await acquireLock(path);
|
|
120
|
+
try {
|
|
121
|
+
const state = await read();
|
|
122
|
+
const result = await task(state);
|
|
123
|
+
if (save) await saveJson(state, path);
|
|
124
|
+
return result;
|
|
125
|
+
} finally {
|
|
126
|
+
await handle.close();
|
|
127
|
+
await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function pathsFor(root, providedCatalog = null) {
|
|
132
|
+
const catalog = providedCatalog || await buildCatalog(root);
|
|
133
|
+
const directory = await projectStateDir(catalog.root);
|
|
134
|
+
return {
|
|
135
|
+
catalog,
|
|
136
|
+
policyPath: join(directory, "delegation-policy.json"),
|
|
137
|
+
coordinationPath: join(directory, "coordination.json")
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function loadDelegationPolicy(root = process.cwd(), providedCatalog = null) {
|
|
142
|
+
const paths = await pathsFor(root, providedCatalog);
|
|
143
|
+
const policy = await readJson(paths.policyPath, paths.catalog.root, normalizePolicy, emptyPolicy);
|
|
144
|
+
return { policy, ...paths };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function loadCoordination(root = process.cwd(), providedCatalog = null) {
|
|
148
|
+
const paths = await pathsFor(root, providedCatalog);
|
|
149
|
+
const coordination = await readJson(paths.coordinationPath, paths.catalog.root, normalizeCoordination, emptyCoordination);
|
|
150
|
+
return { coordination, ...paths };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function inspectCoordination(root = process.cwd(), providedCatalog = null) {
|
|
154
|
+
const paths = await pathsFor(root, providedCatalog);
|
|
155
|
+
let policy = emptyPolicy(paths.catalog.root);
|
|
156
|
+
let coordination = emptyCoordination(paths.catalog.root);
|
|
157
|
+
const errors = [];
|
|
158
|
+
try { policy = await readJson(paths.policyPath, paths.catalog.root, normalizePolicy, emptyPolicy); } catch (error) { errors.push(`policy:${error.message}`); }
|
|
159
|
+
try { coordination = await readJson(paths.coordinationPath, paths.catalog.root, normalizeCoordination, emptyCoordination); } catch (error) { errors.push(`coordination:${error.message}`); }
|
|
160
|
+
return { policy, coordination, errors, ...paths };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function knownActor(graph, id) {
|
|
164
|
+
return graph.entities.find((entity) => entity.id === id && ["person", "agent"].includes(entity.kind));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function validateEntity(graph, id, field, nullable = false) {
|
|
168
|
+
if ((id === null || id === undefined) && nullable) return null;
|
|
169
|
+
if (!ID_RE.test(id || "") || !knownActor(graph, id)) throw new Error(`${field} must reference a known person or agent`);
|
|
170
|
+
return id;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function policyDecision(policy, actorId, action, targetId) {
|
|
174
|
+
const grant = policy.grants.find((item) => item.active === true && item.actorId === actorId
|
|
175
|
+
&& item.actions.includes(action) && (item.targetIds.includes("*") || (targetId && item.targetIds.includes(targetId))));
|
|
176
|
+
return grant ? {
|
|
177
|
+
allowed: true,
|
|
178
|
+
action,
|
|
179
|
+
actorId,
|
|
180
|
+
targetId,
|
|
181
|
+
grantId: grant.id,
|
|
182
|
+
policyRevision: policy.revision,
|
|
183
|
+
reason: "matched explicit local delegation policy",
|
|
184
|
+
authority: "explicit-local-delegation-policy"
|
|
185
|
+
} : {
|
|
186
|
+
allowed: false,
|
|
187
|
+
action,
|
|
188
|
+
actorId,
|
|
189
|
+
targetId,
|
|
190
|
+
grantId: null,
|
|
191
|
+
policyRevision: policy.revision,
|
|
192
|
+
reason: "default deny: no matching explicit local delegation grant",
|
|
193
|
+
authority: "explicit-local-delegation-policy"
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function checkDelegation({ root = process.cwd(), actorId, action, targetId = null }) {
|
|
198
|
+
if (!ACTIONS.has(action)) throw new Error(`unsupported delegation action: ${action}`);
|
|
199
|
+
const { policy, catalog } = await loadDelegationPolicy(root);
|
|
200
|
+
const { graph } = await loadGraph(catalog.root, catalog);
|
|
201
|
+
const findings = delegationPolicyFindings(policy, graph);
|
|
202
|
+
if (findings.length) throw new Error(`delegation policy failed closed: ${findings.join(", ")}`);
|
|
203
|
+
validateEntity(graph, actorId, "actorId");
|
|
204
|
+
if (targetId !== null) validateEntity(graph, targetId, "targetId");
|
|
205
|
+
return {
|
|
206
|
+
...policyDecision(policy, actorId, action, targetId),
|
|
207
|
+
note: "This decision covers AgentSpine task coordination only. It grants no host, tool, file, network, production, or spending rights."
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function requireOwnerConfirmation(confirmation) {
|
|
212
|
+
if (confirmation !== CONFIRMATION) throw new Error("delegation policy changes require explicit local owner confirmation");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function preservePolicy(policy, value, now) {
|
|
216
|
+
policy.history.push({
|
|
217
|
+
kind: "delegation-grant",
|
|
218
|
+
recordId: value.id,
|
|
219
|
+
supersededAt: now,
|
|
220
|
+
value: { ...value, authority: "explicit-local-delegation-policy" },
|
|
221
|
+
authority: "explicit-local-delegation-policy"
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function grantDelegation({
|
|
226
|
+
root = process.cwd(), id = `grant:${randomUUID()}`, actorId, actions, targetIds = ["*"],
|
|
227
|
+
reason, confirmation, now = new Date()
|
|
228
|
+
}) {
|
|
229
|
+
requireOwnerConfirmation(confirmation);
|
|
230
|
+
if (!ID_RE.test(id)) throw new Error("grant id must be stable and whitespace-free");
|
|
231
|
+
if (!Array.isArray(actions) || !actions.length || actions.some((action) => !ACTIONS.has(action))) throw new Error("actions contains an unsupported delegation action");
|
|
232
|
+
if (!Array.isArray(targetIds) || !targetIds.length) throw new Error("targetIds is required");
|
|
233
|
+
const policyReason = text(reason, "reason", 500);
|
|
234
|
+
const at = timestamp(now, "now");
|
|
235
|
+
const { catalog, policyPath } = await pathsFor(root);
|
|
236
|
+
const { graph } = await loadGraph(catalog.root, catalog);
|
|
237
|
+
validateEntity(graph, actorId, "actorId");
|
|
238
|
+
for (const targetId of targetIds) if (targetId !== "*") validateEntity(graph, targetId, "targetId");
|
|
239
|
+
return withStateLock(policyPath, () => readJson(policyPath, catalog.root, normalizePolicy, emptyPolicy), (policy) => {
|
|
240
|
+
const findings = delegationPolicyFindings(policy, graph);
|
|
241
|
+
if (findings.length) throw new Error(`delegation policy failed closed: ${findings.join(", ")}`);
|
|
242
|
+
if (policy.grants.some((grant) => grant.id === id)) throw new Error("delegation grant IDs are immutable; revoke the old grant and create a new one");
|
|
243
|
+
policy.revision += 1;
|
|
244
|
+
const grant = {
|
|
245
|
+
id,
|
|
246
|
+
actorId,
|
|
247
|
+
actions: [...new Set(actions)].sort(),
|
|
248
|
+
targetIds: [...new Set(targetIds)].sort(),
|
|
249
|
+
reason: policyReason,
|
|
250
|
+
active: true,
|
|
251
|
+
createdAt: at,
|
|
252
|
+
updatedAt: at,
|
|
253
|
+
revision: policy.revision,
|
|
254
|
+
source: "explicit-local-owner-policy",
|
|
255
|
+
authority: "explicit-local-delegation-policy"
|
|
256
|
+
};
|
|
257
|
+
policy.grants.push(grant);
|
|
258
|
+
policy.grants.sort((a, b) => a.id.localeCompare(b.id));
|
|
259
|
+
return { grant, policyRevision: policy.revision, policyPath };
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function revokeDelegation({ root = process.cwd(), id, reason, confirmation, now = new Date() }) {
|
|
264
|
+
requireOwnerConfirmation(confirmation);
|
|
265
|
+
if (!ID_RE.test(id || "")) throw new Error("grant id is required");
|
|
266
|
+
const revokeReason = text(reason, "reason", 500);
|
|
267
|
+
const at = timestamp(now, "now");
|
|
268
|
+
const { catalog, policyPath } = await pathsFor(root);
|
|
269
|
+
const { graph } = await loadGraph(catalog.root, catalog);
|
|
270
|
+
return withStateLock(policyPath, () => readJson(policyPath, catalog.root, normalizePolicy, emptyPolicy), (policy) => {
|
|
271
|
+
const findings = delegationPolicyFindings(policy, graph);
|
|
272
|
+
if (findings.length) throw new Error(`delegation policy failed closed: ${findings.join(", ")}`);
|
|
273
|
+
const previous = policy.grants.find((grant) => grant.id === id);
|
|
274
|
+
if (!previous) throw new Error(`unknown delegation grant: ${id}`);
|
|
275
|
+
if (!previous.active) throw new Error(`delegation grant is already revoked: ${id}`);
|
|
276
|
+
preservePolicy(policy, previous, at);
|
|
277
|
+
policy.revision += 1;
|
|
278
|
+
const grant = {
|
|
279
|
+
...previous,
|
|
280
|
+
active: false,
|
|
281
|
+
revokedAt: at,
|
|
282
|
+
revokeReason,
|
|
283
|
+
updatedAt: at,
|
|
284
|
+
revision: policy.revision,
|
|
285
|
+
authority: "explicit-local-delegation-policy"
|
|
286
|
+
};
|
|
287
|
+
policy.grants = policy.grants.map((item) => item.id === id ? grant : item);
|
|
288
|
+
return { grant, policyRevision: policy.revision, policyPath };
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isGroupMember(graph, groupId, entityId) {
|
|
293
|
+
if (!entityId || entityId === groupId) return true;
|
|
294
|
+
return graph.entityEdges.some((edge) => edge.relation === "member-of" && edge.privacy !== "private" && (
|
|
295
|
+
(edge.from === entityId && edge.to === groupId) || (edge.to === entityId && edge.from === groupId)
|
|
296
|
+
));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function validatePrivacy(graph, privacy, groupId, actorId, assigneeId) {
|
|
300
|
+
if (!PRIVACY.has(privacy)) throw new Error(`unsupported privacy scope: ${privacy}`);
|
|
301
|
+
if (privacy === "group") {
|
|
302
|
+
const group = graph.entities.find((entity) => entity.id === groupId && entity.kind === "group");
|
|
303
|
+
if (!group) throw new Error("group privacy requires a known groupId");
|
|
304
|
+
for (const entityId of [actorId, assigneeId].filter(Boolean)) {
|
|
305
|
+
if (!isGroupMember(graph, groupId, entityId)) throw new Error(`${entityId} is not a visible member of ${groupId}`);
|
|
306
|
+
}
|
|
307
|
+
} else if (groupId !== null && groupId !== undefined) {
|
|
308
|
+
throw new Error("groupId is only valid with group privacy");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function preserveTask(coordination, value, now) {
|
|
313
|
+
coordination.history.push({
|
|
314
|
+
kind: "coordination-task",
|
|
315
|
+
recordId: value.id,
|
|
316
|
+
actorId: value.createdBy,
|
|
317
|
+
assigneeId: value.assigneeId,
|
|
318
|
+
supersededAt: now,
|
|
319
|
+
privacy: value.privacy,
|
|
320
|
+
value: { ...value, authority: "context-only" },
|
|
321
|
+
authority: "context-only"
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function coordinationMutation(root, operation) {
|
|
326
|
+
const { catalog, policyPath, coordinationPath } = await pathsFor(root);
|
|
327
|
+
const { graph } = await loadGraph(catalog.root, catalog);
|
|
328
|
+
return withStateLock(
|
|
329
|
+
policyPath,
|
|
330
|
+
() => readJson(policyPath, catalog.root, normalizePolicy, emptyPolicy),
|
|
331
|
+
(policy) => withStateLock(
|
|
332
|
+
coordinationPath,
|
|
333
|
+
() => readJson(coordinationPath, catalog.root, normalizeCoordination, emptyCoordination),
|
|
334
|
+
(coordination) => {
|
|
335
|
+
const findings = [
|
|
336
|
+
...delegationPolicyFindings(policy, graph),
|
|
337
|
+
...coordinationFindings(coordination, policy, graph)
|
|
338
|
+
];
|
|
339
|
+
if (findings.length) throw new Error(`coordination state failed closed: ${findings.join(", ")}`);
|
|
340
|
+
return operation({ policy, coordination, graph, catalog, policyPath, coordinationPath });
|
|
341
|
+
}
|
|
342
|
+
),
|
|
343
|
+
false
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function decisionSnapshot(decision, at) {
|
|
348
|
+
return { ...decision, decidedAt: at };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function createTask({
|
|
352
|
+
root = process.cwd(), id = `task:${randomUUID()}`, actorId, assigneeId = null,
|
|
353
|
+
kind = "task", title, summary = null, projectId = null, privacy = "private", groupId = null,
|
|
354
|
+
priority = 50, dueAt = null, now = new Date()
|
|
355
|
+
}) {
|
|
356
|
+
if (!ID_RE.test(id)) throw new Error("task id must be stable and whitespace-free");
|
|
357
|
+
if (!TASK_KINDS.has(kind)) throw new Error(`unsupported task kind: ${kind}`);
|
|
358
|
+
const taskTitle = text(title, "title", 200);
|
|
359
|
+
const taskSummary = text(summary, "summary", 1000, true);
|
|
360
|
+
const at = timestamp(now, "now");
|
|
361
|
+
const due = timestamp(dueAt, "dueAt", true);
|
|
362
|
+
return coordinationMutation(root, async ({ policy, coordination, graph, coordinationPath }) => {
|
|
363
|
+
if (coordination.tasks.some((task) => task.id === id)) throw new Error("task IDs are immutable");
|
|
364
|
+
validateEntity(graph, actorId, "actorId");
|
|
365
|
+
if (assigneeId !== null) validateEntity(graph, assigneeId, "assigneeId");
|
|
366
|
+
if (projectId !== null && !graph.entities.some((entity) => entity.id === projectId && entity.kind === "project")) throw new Error("projectId must reference a known project");
|
|
367
|
+
validatePrivacy(graph, privacy, groupId, actorId, assigneeId);
|
|
368
|
+
let assignment;
|
|
369
|
+
if (!assigneeId || assigneeId === actorId) {
|
|
370
|
+
assignment = { allowed: true, action: "assign", actorId, targetId: assigneeId, grantId: null, policyRevision: policy.revision, reason: "self-assignment or unassigned thread", authority: "self-coordination" };
|
|
371
|
+
} else {
|
|
372
|
+
assignment = policyDecision(policy, actorId, "assign", assigneeId);
|
|
373
|
+
if (!assignment.allowed) throw new Error(assignment.reason);
|
|
374
|
+
}
|
|
375
|
+
const task = {
|
|
376
|
+
id,
|
|
377
|
+
kind,
|
|
378
|
+
title: taskTitle,
|
|
379
|
+
summary: taskSummary,
|
|
380
|
+
createdBy: actorId,
|
|
381
|
+
assigneeId,
|
|
382
|
+
projectId,
|
|
383
|
+
privacy,
|
|
384
|
+
groupId,
|
|
385
|
+
priority: numeric(priority, "priority", 0, 100),
|
|
386
|
+
dueAt: due,
|
|
387
|
+
status: "open",
|
|
388
|
+
note: null,
|
|
389
|
+
assignment: decisionSnapshot(assignment, at),
|
|
390
|
+
createdAt: at,
|
|
391
|
+
updatedAt: at,
|
|
392
|
+
authority: "context-only"
|
|
393
|
+
};
|
|
394
|
+
coordination.tasks.push(task);
|
|
395
|
+
coordination.tasks.sort((a, b) => a.id.localeCompare(b.id));
|
|
396
|
+
return { task, coordinationPath };
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function canManageTask(policy, task, actorId, action, targetId) {
|
|
401
|
+
if (actorId === task.assigneeId || (task.assigneeId === null && actorId === task.createdBy)) {
|
|
402
|
+
return { allowed: true, action, actorId, targetId, grantId: null, policyRevision: policy.revision, reason: "assignee self-management", authority: "self-coordination" };
|
|
403
|
+
}
|
|
404
|
+
return policyDecision(policy, actorId, action, targetId);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export async function updateTask({ root = process.cwd(), id, actorId, patch = {}, now = new Date() }) {
|
|
408
|
+
if (!ID_RE.test(id || "")) throw new Error("task id is required");
|
|
409
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch) || !Object.keys(patch).length) throw new Error("patch must change at least one task field");
|
|
410
|
+
const allowedFields = new Set(["status", "assigneeId", "title", "summary", "priority", "dueAt", "note"]);
|
|
411
|
+
const unknown = Object.keys(patch).filter((key) => !allowedFields.has(key));
|
|
412
|
+
if (unknown.length) throw new Error(`unsupported task patch: ${unknown.join(", ")}`);
|
|
413
|
+
const at = timestamp(now, "now");
|
|
414
|
+
return coordinationMutation(root, async ({ policy, coordination, graph, coordinationPath }) => {
|
|
415
|
+
const previous = coordination.tasks.find((task) => task.id === id);
|
|
416
|
+
if (!previous) throw new Error(`unknown coordination task: ${id}`);
|
|
417
|
+
validateEntity(graph, actorId, "actorId");
|
|
418
|
+
let assignment = previous.assignment;
|
|
419
|
+
const nextAssignee = "assigneeId" in patch ? patch.assigneeId : previous.assigneeId;
|
|
420
|
+
if (nextAssignee !== null) validateEntity(graph, nextAssignee, "assigneeId");
|
|
421
|
+
if (nextAssignee !== previous.assigneeId) {
|
|
422
|
+
const decision = policyDecision(policy, actorId, "reassign", nextAssignee);
|
|
423
|
+
if (!decision.allowed) throw new Error(decision.reason);
|
|
424
|
+
validatePrivacy(graph, previous.privacy, previous.groupId, actorId, nextAssignee);
|
|
425
|
+
assignment = decisionSnapshot(decision, at);
|
|
426
|
+
}
|
|
427
|
+
if (patch.status && !STATUSES.has(patch.status)) throw new Error(`unsupported task status: ${patch.status}`);
|
|
428
|
+
if (patch.status && patch.status !== previous.status) {
|
|
429
|
+
const action = patch.status === "cancelled" ? "cancel" : patch.status === "completed" ? "complete" : "manage";
|
|
430
|
+
const decision = canManageTask(policy, previous, actorId, action, previous.assigneeId);
|
|
431
|
+
if (!decision.allowed) throw new Error(decision.reason);
|
|
432
|
+
}
|
|
433
|
+
const changesContent = ["title", "summary", "priority", "dueAt", "note"].some((key) => key in patch);
|
|
434
|
+
if (changesContent && actorId !== previous.createdBy && actorId !== previous.assigneeId) {
|
|
435
|
+
const decision = policyDecision(policy, actorId, "manage", previous.assigneeId);
|
|
436
|
+
if (!decision.allowed) throw new Error(decision.reason);
|
|
437
|
+
}
|
|
438
|
+
preserveTask(coordination, previous, at);
|
|
439
|
+
const task = {
|
|
440
|
+
...previous,
|
|
441
|
+
assigneeId: nextAssignee,
|
|
442
|
+
assignment,
|
|
443
|
+
status: patch.status || previous.status,
|
|
444
|
+
title: "title" in patch ? text(patch.title, "title", 200) : previous.title,
|
|
445
|
+
summary: "summary" in patch ? text(patch.summary, "summary", 1000, true) : previous.summary,
|
|
446
|
+
priority: "priority" in patch ? numeric(patch.priority, "priority", 0, 100) : previous.priority,
|
|
447
|
+
dueAt: "dueAt" in patch ? timestamp(patch.dueAt, "dueAt", true) : previous.dueAt,
|
|
448
|
+
note: "note" in patch ? text(patch.note, "note", 1000, true) : previous.note,
|
|
449
|
+
updatedAt: at,
|
|
450
|
+
authority: "context-only"
|
|
451
|
+
};
|
|
452
|
+
coordination.tasks = coordination.tasks.map((entry) => entry.id === id ? task : entry);
|
|
453
|
+
return { task, coordinationPath };
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function audienceIds(graph, groupId, includePrivate) {
|
|
458
|
+
const ids = new Set();
|
|
459
|
+
if (!groupId) return ids;
|
|
460
|
+
ids.add(groupId);
|
|
461
|
+
for (const edge of graph.entityEdges) {
|
|
462
|
+
if (edge.relation !== "member-of" || (!includePrivate && edge.privacy === "private")) continue;
|
|
463
|
+
if (edge.to === groupId) ids.add(edge.from);
|
|
464
|
+
if (edge.from === groupId) ids.add(edge.to);
|
|
465
|
+
}
|
|
466
|
+
return ids;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function taskVisible(task, entities, audience, includePrivate, groupId) {
|
|
470
|
+
if (task.privacy === "private" && !includePrivate) return false;
|
|
471
|
+
if (task.privacy === "group" && (!groupId || task.groupId !== groupId)) return false;
|
|
472
|
+
for (const id of [task.createdBy, task.assigneeId].filter(Boolean)) {
|
|
473
|
+
const entity = entities.get(id);
|
|
474
|
+
if (entity?.privacy === "private" && !includePrivate) return false;
|
|
475
|
+
if (entity?.privacy === "group" && !audience.has(id)) return false;
|
|
476
|
+
}
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export async function taskContext({
|
|
481
|
+
root = process.cwd(), includePrivate = false, groupId = null, actorId = null,
|
|
482
|
+
assigneeId = null, projectId = null, includeClosed = false, maxItems = 20,
|
|
483
|
+
catalog: providedCatalog = null
|
|
484
|
+
} = {}) {
|
|
485
|
+
const { coordination, catalog } = await loadCoordination(root, providedCatalog);
|
|
486
|
+
const { policy } = await loadDelegationPolicy(catalog.root, catalog);
|
|
487
|
+
const { graph } = await loadGraph(catalog.root, catalog);
|
|
488
|
+
const findings = [
|
|
489
|
+
...delegationPolicyFindings(policy, graph),
|
|
490
|
+
...coordinationFindings(coordination, policy, graph)
|
|
491
|
+
];
|
|
492
|
+
if (findings.length) throw new Error(`coordination state failed closed: ${findings.join(", ")}`);
|
|
493
|
+
const entities = new Map(graph.entities.map((entity) => [entity.id, entity]));
|
|
494
|
+
if (groupId !== null && !graph.entities.some((entity) => entity.id === groupId && entity.kind === "group")) throw new Error(`unknown group entity: ${groupId}`);
|
|
495
|
+
const audience = audienceIds(graph, groupId, includePrivate);
|
|
496
|
+
const limit = numeric(maxItems, "maxItems", 0, 100);
|
|
497
|
+
if (!Number.isInteger(limit)) throw new Error("maxItems must be an integer");
|
|
498
|
+
const items = coordination.tasks
|
|
499
|
+
.filter((task) => includeClosed || !["completed", "cancelled"].includes(task.status))
|
|
500
|
+
.filter((task) => !actorId || task.createdBy === actorId)
|
|
501
|
+
.filter((task) => !assigneeId || task.assigneeId === assigneeId)
|
|
502
|
+
.filter((task) => !projectId || task.projectId === projectId)
|
|
503
|
+
.filter((task) => taskVisible(task, entities, audience, includePrivate, groupId))
|
|
504
|
+
.sort((a, b) => b.priority - a.priority || (a.dueAt || "9999").localeCompare(b.dueAt || "9999") || a.id.localeCompare(b.id))
|
|
505
|
+
.slice(0, limit)
|
|
506
|
+
.map(({ assignment: _assignment, ...task }) => ({ ...task, authority: "context-only" }));
|
|
507
|
+
return {
|
|
508
|
+
schema: "agentspine.task-context/v1",
|
|
509
|
+
root: catalog.root,
|
|
510
|
+
groupId,
|
|
511
|
+
items,
|
|
512
|
+
authority: "context-only",
|
|
513
|
+
note: "Tasks describe coordination only. They grant no host, tool, file, network, production, or spending rights."
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export async function deleteTask({ root = process.cwd(), id, confirmation }) {
|
|
518
|
+
requireOwnerConfirmation(confirmation);
|
|
519
|
+
if (!ID_RE.test(id || "")) throw new Error("task id is required");
|
|
520
|
+
return coordinationMutation(root, ({ coordination, coordinationPath }) => {
|
|
521
|
+
const existed = coordination.tasks.some((task) => task.id === id);
|
|
522
|
+
coordination.tasks = coordination.tasks.filter((task) => task.id !== id);
|
|
523
|
+
coordination.history = coordination.history.filter((entry) => entry.recordId !== id && entry.value?.id !== id);
|
|
524
|
+
return { deleted: existed, id, coordinationPath };
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export function delegationPolicyFindings(policy, graph) {
|
|
529
|
+
const findings = [];
|
|
530
|
+
if (policy.schema !== "agentspine.delegation-policy/v1" || !Number.isInteger(policy.revision) || policy.revision < 0) findings.push("invalid-policy");
|
|
531
|
+
const grants = [...policy.grants, ...policy.history.map((entry) => entry.value).filter(Boolean)];
|
|
532
|
+
if (new Set(policy.grants.map((grant) => grant.id)).size !== policy.grants.length) findings.push("duplicate-active-grant-id");
|
|
533
|
+
for (const grant of grants) {
|
|
534
|
+
if (!ID_RE.test(grant.id || "") || grant.authority !== "explicit-local-delegation-policy" || grant.source !== "explicit-local-owner-policy"
|
|
535
|
+
|| !knownActor(graph, grant.actorId) || !Array.isArray(grant.actions) || grant.actions.some((action) => !ACTIONS.has(action))
|
|
536
|
+
|| !Array.isArray(grant.targetIds) || grant.targetIds.some((id) => id !== "*" && !knownActor(graph, id))
|
|
537
|
+
|| typeof grant.active !== "boolean" || typeof grant.reason !== "string" || !grant.reason
|
|
538
|
+
|| SECRET_RE.test(grant.reason || "") || !Number.isInteger(grant.revision) || grant.revision < 1 || grant.revision > policy.revision) {
|
|
539
|
+
findings.push(`invalid-grant:${grant.id || "unknown"}`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (policy.history.some((entry) => entry.authority !== "explicit-local-delegation-policy")) findings.push("invalid-policy-history");
|
|
543
|
+
return findings;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function coordinationFindings(coordination, policy, graph) {
|
|
547
|
+
const findings = [];
|
|
548
|
+
const allGrants = new Map([...policy.grants, ...policy.history.map((entry) => entry.value).filter(Boolean)].map((grant) => [grant.id, grant]));
|
|
549
|
+
const records = [
|
|
550
|
+
...coordination.tasks.map((task) => ({ task, current: true })),
|
|
551
|
+
...coordination.history.map((entry) => entry.value).filter(Boolean).map((task) => ({ task, current: false }))
|
|
552
|
+
];
|
|
553
|
+
for (const { task, current } of records) {
|
|
554
|
+
if (!ID_RE.test(task.id || "") || task.authority !== "context-only" || !TASK_KINDS.has(task.kind) || !STATUSES.has(task.status) || !PRIVACY.has(task.privacy)
|
|
555
|
+
|| typeof task.title !== "string" || !task.title || task.title.length > 200
|
|
556
|
+
|| (task.summary !== null && typeof task.summary !== "string") || (task.note !== null && typeof task.note !== "string")
|
|
557
|
+
|| !Number.isFinite(task.priority) || task.priority < 0 || task.priority > 100
|
|
558
|
+
|| !knownActor(graph, task.createdBy) || (task.assigneeId && !knownActor(graph, task.assigneeId))) {
|
|
559
|
+
findings.push(`invalid-task:${task.id || "unknown"}`);
|
|
560
|
+
}
|
|
561
|
+
const grant = allGrants.get(task.assignment?.grantId);
|
|
562
|
+
if (task.assignment?.authority === "explicit-local-delegation-policy" && (
|
|
563
|
+
!grant || grant.actorId !== task.assignment.actorId || !grant.actions.includes(task.assignment.action)
|
|
564
|
+
|| (!grant.targetIds.includes("*") && !grant.targetIds.includes(task.assignment.targetId))
|
|
565
|
+
)) findings.push(`missing-policy-snapshot:${task.id}`);
|
|
566
|
+
if (task.assignment?.allowed !== true || !new Set(["explicit-local-delegation-policy", "self-coordination"]).has(task.assignment?.authority)
|
|
567
|
+
|| task.assignment?.targetId !== task.assigneeId || !Number.isInteger(task.assignment?.policyRevision)
|
|
568
|
+
|| task.assignment.policyRevision < 0 || task.assignment.policyRevision > policy.revision) findings.push(`invalid-assignment:${task.id}`);
|
|
569
|
+
if (task.assignment?.authority === "self-coordination" && task.assigneeId && task.assigneeId !== task.createdBy) findings.push(`invalid-self-assignment:${task.id}`);
|
|
570
|
+
if (SECRET_RE.test(`${task.title || ""} ${task.summary || ""} ${task.note || ""}`)) findings.push(`unsafe-task:${task.id}`);
|
|
571
|
+
const knownGroup = graph.entities.some((entity) => entity.id === task.groupId && entity.kind === "group");
|
|
572
|
+
if (task.privacy === "group" && (!knownGroup || (current && (!isGroupMember(graph, task.groupId, task.createdBy) || !isGroupMember(graph, task.groupId, task.assigneeId))))) findings.push(`invalid-task-group:${task.id}`);
|
|
573
|
+
if (task.privacy !== "group" && task.groupId) findings.push(`unexpected-task-group:${task.id}`);
|
|
574
|
+
}
|
|
575
|
+
if (coordination.history.some((entry) => entry.authority !== "context-only")) findings.push("invalid-task-history");
|
|
576
|
+
return findings;
|
|
577
|
+
}
|