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,581 +1,581 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { constants as fsConstants } from "node:fs";
3
- import { lstat, open, opendir, readFile, realpath, stat, unlink, writeFile } from "node:fs/promises";
4
- import { isAbsolute, join } from "node:path";
5
- import { buildCatalog } from "./catalog.js";
6
- import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
7
- import { linkEntities, loadGraph, unlinkEntities, upsertEntity } from "./graph.js";
8
- import { isInside, projectStateDir } from "./paths.js";
9
-
10
- export const PERSONA_POLICY_SCHEMA = "agentspine.persona-policy/v1";
11
- export const PERSONA_RUNTIME_SCHEMA = "agentspine.persona-runtime/v1";
12
- export const PERSONA_EVENT_SCHEMA = "agentspine.persona-event/v1";
13
- export const PERSONA_ROSTER_SCHEMA = "agentspine.persona-roster/v1";
14
-
15
- const CONFIRMATION = "local-owner-confirmed";
16
- const MAX_BYTES = 5 * 1024 * 1024;
17
- const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/+~-]{0,255}$/;
18
- const KINDS = new Set(["person", "agent", "bot"]);
19
- const EVENTS = new Set(["join", "rename", "group-change", "leave", "deactivate", "rejoin"]);
20
- const ACTIVE = new Set(["active", "left", "deactivated"]);
21
- const AUTHENTICATORS = new Set(["host-manifest", "oauth-subject", "local-roster", "signed-roster"]);
22
- const NATIVE_HOSTS = new Set(["claude", "codex"]);
23
- const NATIVE_SCOPES = new Set(["user", "project"]);
24
- const FORBIDDEN = /\b(?:permission|rights?|roles?|owner|trusted|reports[- ]to|responsible[- ]for|delegat|token|secret|credential|api[-_ ]?key|network|production|payment|send capability|tool capability)\b/i;
25
-
26
- function emptyPolicy(root) {
27
- return { schema: PERSONA_POLICY_SCHEMA, root, revision: 0, bindings: [], history: [] };
28
- }
29
-
30
- function emptyRuntime(root) {
31
- return { schema: PERSONA_RUNTIME_SCHEMA, root, revision: 0, personas: [], events: [], receipts: [] };
32
- }
33
-
34
- function exactId(value, field, nullable = false) {
35
- if ((value === null || value === undefined || value === "") && nullable) return null;
36
- if (typeof value !== "string" || !ID_RE.test(value) || value.includes("*")) {
37
- throw new Error(field + " must be an exact stable ID without wildcards");
38
- }
39
- return value;
40
- }
41
-
42
- function exactText(value, field, maximum = 200) {
43
- if (typeof value !== "string" || !value.trim()) throw new Error(field + " is required");
44
- const text = value.trim().replace(/\s+/g, " ").slice(0, maximum);
45
- if (FORBIDDEN.test(text)) throw new Error(field + " contains authority or secret-shaped content");
46
- return text;
47
- }
48
-
49
- function sourceText(value) {
50
- if (typeof value !== "string" || !value.trim() || value.includes("\0")) throw new Error("sourceBinding is invalid");
51
- return value.trim().replaceAll("\\", "/").slice(0, 500);
52
- }
53
-
54
- function at(value = new Date()) {
55
- const date = value instanceof Date ? value : new Date(value);
56
- if (!Number.isFinite(date.getTime())) throw new Error("timestamp is invalid");
57
- return date.toISOString();
58
- }
59
-
60
- function digest(value) {
61
- return createHash("sha256").update(value).digest("hex");
62
- }
63
-
64
- function identityMaterial(binding) {
65
- return [binding.authenticator, binding.issuer, binding.tenantId, binding.host, binding.profileId,
66
- binding.subjectId, binding.kind].join("\0");
67
- }
68
-
69
- function stablePersonaId(binding) {
70
- return binding.kind + ":runtime:" + digest(identityMaterial(binding)).slice(0, 32);
71
- }
72
-
73
- function validBinding(binding) {
74
- return binding && ID_RE.test(binding.id || "") && KINDS.has(binding.kind)
75
- && AUTHENTICATORS.has(binding.authenticator) && ID_RE.test(binding.issuer || "")
76
- && ID_RE.test(binding.tenantId || "") && ID_RE.test(binding.host || "")
77
- && ID_RE.test(binding.profileId || "") && ID_RE.test(binding.subjectId || "")
78
- && ID_RE.test(binding.personaId || "") && binding.personaId === stablePersonaId(binding)
79
- && typeof binding.displayName === "string" && binding.displayName.length > 0 && !FORBIDDEN.test(binding.displayName)
80
- && (binding.sourceBinding === null || typeof binding.sourceBinding === "string")
81
- && (binding.groupId === null || ID_RE.test(binding.groupId || ""))
82
- && typeof binding.active === "boolean" && typeof binding.deactivated === "boolean"
83
- && !(binding.active && binding.deactivated) && binding.authority === "authenticated-identity-policy"
84
- && Number.isFinite(new Date(binding.createdAt).getTime())
85
- && Number.isFinite(new Date(binding.updatedAt).getTime());
86
- }
87
-
88
- function validPersona(persona) {
89
- return persona && ID_RE.test(persona.personaId || "") && ID_RE.test(persona.bindingId || "")
90
- && KINDS.has(persona.kind) && ACTIVE.has(persona.status)
91
- && typeof persona.displayName === "string" && persona.displayName.length > 0
92
- && (persona.groupId === null || ID_RE.test(persona.groupId || ""))
93
- && Number.isInteger(persona.sequence) && persona.sequence >= 1
94
- && persona.authority === "identity-state-only" && Number.isFinite(new Date(persona.updatedAt).getTime());
95
- }
96
-
97
- function validEvent(event) {
98
- if (!(event && event.schema === PERSONA_EVENT_SCHEMA && ID_RE.test(event.eventId || "")
99
- && ID_RE.test(event.personaId || "") && ID_RE.test(event.bindingId || "")
100
- && EVENTS.has(event.type) && Number.isInteger(event.sequence) && event.sequence >= 1
101
- && Number.isFinite(new Date(event.observedAt).getTime())
102
- && /^[a-f0-9]{64}$/.test(event.payloadDigest || "")
103
- && event.authority === "identity-state-only")) return false;
104
- const payload = { personaId: event.personaId, bindingId: event.bindingId, type: event.type, sequence: event.sequence,
105
- displayName: event.displayName, groupId: event.groupId, observedAt: event.observedAt };
106
- const payloadDigest = digest(JSON.stringify(payload));
107
- return event.payloadDigest === payloadDigest && event.eventId === "persona-event:" + payloadDigest.slice(0, 32);
108
- }
109
-
110
- function validReceipt(receipt) {
111
- if (!(receipt && ID_RE.test(receipt.id || "") && ID_RE.test(receipt.eventId || "")
112
- && /^[a-f0-9]{64}$/.test(receipt.payloadDigest || "") && /^[a-f0-9]{64}$/.test(receipt.digest || "")
113
- && Number.isFinite(new Date(receipt.at).getTime()) && receipt.authority === "identity-state-only")) return false;
114
- const material = { eventId: receipt.eventId, payloadDigest: receipt.payloadDigest, at: receipt.at,
115
- authority: "identity-state-only" };
116
- return receipt.digest === digest(JSON.stringify(material)) && receipt.id === "persona-receipt:" + receipt.digest.slice(0, 24);
117
- }
118
-
119
- function normalizePolicy(value, root) {
120
- if (!value || value.schema !== PERSONA_POLICY_SCHEMA || value.root !== root
121
- || !Number.isInteger(value.revision) || !Array.isArray(value.bindings) || !Array.isArray(value.history)
122
- || value.bindings.some((item) => !validBinding(item))
123
- || value.history.some((item) => !item || !validBinding(item.value) || item.authority !== "authenticated-identity-policy")) {
124
- throw new Error("persona policy is invalid; automatic persona synchronization is disabled");
125
- }
126
- return value;
127
- }
128
-
129
- function normalizeRuntime(value, root) {
130
- if (!value || value.schema !== PERSONA_RUNTIME_SCHEMA || value.root !== root
131
- || !Number.isInteger(value.revision) || !Array.isArray(value.personas)
132
- || !Array.isArray(value.events) || !Array.isArray(value.receipts)
133
- || value.personas.some((item) => !validPersona(item)) || value.events.some((item) => !validEvent(item))
134
- || value.receipts.some((item) => !validReceipt(item))) {
135
- throw new Error("persona runtime is invalid; persona context is disabled");
136
- }
137
- return value;
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 { catalog, directory, personaPolicyPath: join(directory, "persona-policy.json"), personaRuntimePath: join(directory, "persona-runtime.json") };
144
- }
145
-
146
- async function readJson(path, root, normalize, empty) {
147
- try {
148
- const metadata = await stat(path);
149
- if (metadata.size > MAX_BYTES) throw new Error("persona state exceeds 5 MiB");
150
- return normalize(JSON.parse(await readFile(path, "utf8")), root);
151
- } catch (error) {
152
- if (error.code !== "ENOENT") throw error;
153
- return empty(root);
154
- }
155
- }
156
-
157
- async function writeJson(path, value) {
158
- const content = JSON.stringify(value, null, 2) + "\n";
159
- if (Buffer.byteLength(content) > MAX_BYTES) throw new Error("persona state exceeds 5 MiB");
160
- const temporary = path + "." + process.pid + "." + randomUUID() + ".tmp";
161
- try {
162
- await writeFile(temporary, content, { mode: 0o600 });
163
- await replaceFileWithRetry(temporary, path);
164
- } finally {
165
- await unlink(temporary).catch((error) => { if (error.code !== "ENOENT") throw error; });
166
- }
167
- }
168
-
169
- async function withLock(paths, task) {
170
- const lockPath = join(paths.directory, "persona-runtime.lock");
171
- let handle;
172
- for (let attempt = 0; attempt < 120; attempt += 1) {
173
- try { handle = await open(lockPath, "wx", 0o600); break; } catch (error) {
174
- if (!isFileLockContention(error)) throw error;
175
- try {
176
- const metadata = await stat(lockPath);
177
- if (Date.now() - metadata.mtimeMs > 90000) await unlink(lockPath);
178
- } catch (lockError) { if (lockError.code !== "ENOENT") throw lockError; }
179
- await new Promise((resolve) => setTimeout(resolve, 25));
180
- }
181
- }
182
- if (!handle) throw new Error("persona state is busy; retry later");
183
- try { return await task(); } finally {
184
- await handle.close();
185
- await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
186
- }
187
- }
188
-
189
- function normalizeBinding(input, timestamp) {
190
- const binding = {
191
- id: exactId(input.id || "persona-binding:" + randomUUID(), "bindingId"),
192
- authenticator: AUTHENTICATORS.has(input.authenticator) ? input.authenticator : null,
193
- issuer: exactId(input.issuer, "issuer"), tenantId: exactId(input.tenantId, "tenantId"),
194
- host: exactId(input.host, "host"), profileId: exactId(input.profileId, "profileId"),
195
- subjectId: exactId(input.subjectId, "subjectId"), kind: input.kind,
196
- displayName: exactText(input.displayName, "displayName"),
197
- sourceBinding: input.sourceBinding === null || input.sourceBinding === undefined ? null : sourceText(input.sourceBinding),
198
- groupId: exactId(input.groupId, "groupId", true), deactivated: input.deactivated === true,
199
- active: input.deactivated === true ? false : input.active !== false,
200
- createdAt: timestamp, updatedAt: timestamp, authority: "authenticated-identity-policy"
201
- };
202
- if (!binding.authenticator || !KINDS.has(binding.kind)) throw new Error("persona binding requires an approved authenticator and person, agent, or bot kind");
203
- binding.personaId = stablePersonaId(binding);
204
- return binding;
205
- }
206
-
207
- function eventPayload(binding, type, sequence, observedAt) {
208
- const payload = { personaId: binding.personaId, bindingId: binding.id, type, sequence, displayName: binding.displayName,
209
- groupId: binding.groupId, observedAt };
210
- return { schema: PERSONA_EVENT_SCHEMA, eventId: "persona-event:" + digest(JSON.stringify(payload)).slice(0, 32),
211
- ...payload, payloadDigest: digest(JSON.stringify(payload)), authority: "identity-state-only" };
212
- }
213
-
214
- function eventReceipt(event) {
215
- const material = { eventId: event.eventId, payloadDigest: event.payloadDigest, at: event.observedAt,
216
- authority: "identity-state-only" };
217
- const receiptDigest = digest(JSON.stringify(material));
218
- return { id: "persona-receipt:" + receiptDigest.slice(0, 24), ...material, digest: receiptDigest };
219
- }
220
-
221
- function nextEventTypes(previous, binding) {
222
- if (!previous) return ["join"];
223
- if (!binding.active && previous.status === "active") return [binding.deactivated ? "deactivate" : "leave"];
224
- if (!binding.active) return [];
225
- const types = previous.status !== "active" ? ["rejoin"] : [];
226
- if (previous.displayName !== binding.displayName) types.push("rename");
227
- if (previous.groupId !== binding.groupId) types.push("group-change");
228
- return types;
229
- }
230
-
231
- function runtimeEntityAttributes(persona, binding, existing) {
232
- return {
233
- ...(existing?.attributes || {}),
234
- runtimeKind: persona.kind,
235
- identityBindingId: persona.bindingId,
236
- identityStatus: persona.status,
237
- sourceBinding: binding?.sourceBinding || null
238
- };
239
- }
240
-
241
- function sameRuntimeEntity(entity, persona, binding) {
242
- if (!entity) return false;
243
- const expectedKind = persona.kind === "bot" ? "agent" : persona.kind;
244
- const expectedPrivacy = persona.groupId ? "group" : "shared";
245
- const attributes = runtimeEntityAttributes(persona, binding, entity);
246
- return entity.kind === expectedKind && entity.displayName === persona.displayName
247
- && entity.privacy === expectedPrivacy
248
- && JSON.stringify(entity.attributes) === JSON.stringify(attributes);
249
- }
250
-
251
- async function reconcilePersonaGraph(paths, policy, runtime) {
252
- let { graph } = await loadGraph(paths.catalog.root, paths.catalog);
253
- const changes = { groupsCreated: 0, entitiesUpdated: 0, membershipsAdded: 0, membershipsRemoved: 0 };
254
- const activeGroupIds = [...new Set(runtime.personas
255
- .filter((item) => item.status === "active" && item.groupId !== null)
256
- .map((item) => item.groupId))].sort();
257
-
258
- for (const groupId of activeGroupIds) {
259
- const existing = graph.entities.find((item) => item.id === groupId);
260
- if (existing && existing.kind !== "group") {
261
- throw new Error(`authenticated persona group conflicts with a non-group entity: ${groupId}`);
262
- }
263
- if (existing?.privacy === "private") {
264
- throw new Error(`authenticated persona group cannot use private relationship visibility: ${groupId}`);
265
- }
266
- if (!existing) {
267
- await upsertEntity({ root: paths.catalog.root, id: groupId, kind: "group", privacy: "group",
268
- attributes: { identitySource: "authenticated-persona-roster" }, confidence: 1, catalog: paths.catalog });
269
- changes.groupsCreated += 1;
270
- graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
271
- }
272
- }
273
-
274
- for (const persona of runtime.personas) {
275
- const binding = policy.bindings.find((item) => item.id === persona.bindingId);
276
- let existing = graph.entities.find((item) => item.id === persona.personaId);
277
- if (existing && existing.kind !== (persona.kind === "bot" ? "agent" : persona.kind)) {
278
- throw new Error(`authenticated persona conflicts with an existing entity kind: ${persona.personaId}`);
279
- }
280
- if (!sameRuntimeEntity(existing, persona, binding)) {
281
- await upsertEntity({ root: paths.catalog.root, id: persona.personaId,
282
- kind: persona.kind === "bot" ? "agent" : persona.kind, displayName: persona.displayName,
283
- aliases: existing?.aliases || [], attributes: runtimeEntityAttributes(persona, binding, existing),
284
- sourceDocument: existing?.sourceDocument
285
- && paths.catalog.documents.some((item) => item.relativePath === existing.sourceDocument)
286
- ? existing.sourceDocument : null,
287
- privacy: persona.groupId ? "group" : "shared", confidence: 1, catalog: paths.catalog });
288
- changes.entitiesUpdated += 1;
289
- graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
290
- existing = graph.entities.find((item) => item.id === persona.personaId);
291
- }
292
-
293
- const memberships = graph.entityEdges.filter((edge) => edge.from === persona.personaId && edge.relation === "member-of");
294
- for (const edge of memberships.filter((item) => persona.status !== "active" || item.to !== persona.groupId)) {
295
- await unlinkEntities({ root: paths.catalog.root, from: edge.from, to: edge.to, relation: edge.relation,
296
- catalog: paths.catalog });
297
- changes.membershipsRemoved += 1;
298
- graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
299
- }
300
- if (persona.status === "active" && persona.groupId !== null
301
- && !graph.entityEdges.some((edge) => edge.from === persona.personaId && edge.to === persona.groupId
302
- && edge.relation === "member-of" && edge.privacy === "group")) {
303
- await linkEntities({ root: paths.catalog.root, from: persona.personaId, to: persona.groupId,
304
- relation: "member-of", reason: "Authenticated roster membership; context only.", confidence: 1, privacy: "group",
305
- catalog: paths.catalog });
306
- changes.membershipsAdded += 1;
307
- graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
308
- }
309
- }
310
- return changes;
311
- }
312
-
313
- export async function applyPersonaRoster({
314
- root = process.cwd(), bindings, rosterScopes = [], confirmation, now = new Date(), catalog: providedCatalog = null
315
- }) {
316
- if (confirmation !== CONFIRMATION) throw new Error("persona roster changes require explicit local owner confirmation");
317
- if (!Array.isArray(bindings) || bindings.length > 256 || (!bindings.length && !rosterScopes.length)) {
318
- throw new Error("bindings must contain up to 256 persona bindings or one explicit roster scope");
319
- }
320
- const paths = await pathsFor(root, providedCatalog);
321
- return withLock(paths, async () => {
322
- const [policy, runtime] = await Promise.all([
323
- readJson(paths.personaPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
324
- readJson(paths.personaRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime)
325
- ]);
326
- const observedAt = at(now);
327
- const normalized = bindings.map((item) => normalizeBinding(item, observedAt));
328
- let changed = false;
329
- const scopedRosterKeys = new Set([
330
- ...normalized.map((item) => [item.authenticator, item.issuer, item.tenantId, item.host, item.profileId].join("\0")),
331
- ...rosterScopes.map((item) => [item.authenticator, item.issuer, item.tenantId, item.host, item.profileId].join("\0"))
332
- ]);
333
- const suppliedIds = new Set(normalized.map((item) => item.id));
334
- for (const missing of policy.bindings.filter((item) => item.active && scopedRosterKeys.has([
335
- item.authenticator, item.issuer, item.tenantId, item.host, item.profileId
336
- ].join("\0")) && !suppliedIds.has(item.id))) {
337
- const retired = { ...missing, active: false, deactivated: false, updatedAt: observedAt };
338
- normalized.push(retired);
339
- }
340
- const identities = new Set();
341
- for (const binding of normalized) {
342
- const key = identityMaterial(binding);
343
- if (identities.has(key)) throw new Error("persona roster contains a duplicate authenticated identity");
344
- identities.add(key);
345
- const previousBinding = policy.bindings.find((item) => item.id === binding.id);
346
- if (previousBinding && previousBinding.personaId !== binding.personaId) throw new Error("a binding ID cannot be reassigned to another authenticated identity");
347
- const unchangedBinding = previousBinding && ["authenticator", "issuer", "tenantId", "host", "profileId", "subjectId", "kind",
348
- "displayName", "sourceBinding", "groupId", "active", "deactivated", "personaId"].every((key) => previousBinding[key] === binding[key]);
349
- if (unchangedBinding) continue;
350
- changed = true;
351
- if (previousBinding) {
352
- policy.history.push({ recordId: previousBinding.id, supersededAt: observedAt, value: structuredClone(previousBinding), authority: "authenticated-identity-policy" });
353
- binding.createdAt = previousBinding.createdAt;
354
- }
355
- policy.bindings = policy.bindings.filter((item) => item.id !== binding.id);
356
- policy.bindings.push(binding);
357
- const previous = runtime.personas.find((item) => item.personaId === binding.personaId);
358
- const types = nextEventTypes(previous, binding);
359
- let sequence = previous?.sequence || 0;
360
- for (const type of types) {
361
- sequence += 1;
362
- const event = eventPayload(binding, type, sequence, observedAt);
363
- if (!runtime.receipts.some((item) => item.eventId === event.eventId)) {
364
- runtime.events.push(event);
365
- runtime.receipts.push(eventReceipt(event));
366
- }
367
- }
368
- if (types.length) {
369
- runtime.personas = runtime.personas.filter((item) => item.personaId !== binding.personaId);
370
- runtime.personas.push({ personaId: binding.personaId, bindingId: binding.id, kind: binding.kind,
371
- displayName: binding.displayName, groupId: binding.groupId,
372
- status: binding.active ? "active" : binding.deactivated ? "deactivated" : "left",
373
- sequence, updatedAt: observedAt, authority: "identity-state-only" });
374
- }
375
- }
376
- if (changed) {
377
- policy.bindings.sort((a, b) => a.id.localeCompare(b.id));
378
- runtime.personas.sort((a, b) => a.personaId.localeCompare(b.personaId));
379
- runtime.events.sort((a, b) => a.observedAt.localeCompare(b.observedAt) || a.eventId.localeCompare(b.eventId));
380
- policy.revision += 1;
381
- runtime.revision += 1;
382
- await Promise.all([writeJson(paths.personaPolicyPath, policy), writeJson(paths.personaRuntimePath, runtime)]);
383
- }
384
- const graphChanges = await reconcilePersonaGraph(paths, policy, runtime);
385
- const graphReconciled = Object.values(graphChanges).some((value) => value > 0);
386
- return { policy, runtime, personaPolicyPath: paths.personaPolicyPath,
387
- personaRuntimePath: paths.personaRuntimePath, duplicate: !changed, graphReconciled, graphChanges };
388
- });
389
- }
390
-
391
- function normalizeNativeDiscovery(value) {
392
- if (!value || typeof value !== "object" || !NATIVE_HOSTS.has(value.host) || !NATIVE_SCOPES.has(value.scope)) {
393
- throw new Error("native persona discovery requires a supported host and user or project scope");
394
- }
395
- return {
396
- id: exactId(value.id, "nativeDiscovery.id"), host: value.host, scope: value.scope,
397
- authenticator: "host-manifest", issuer: exactId(value.issuer, "nativeDiscovery.issuer"),
398
- tenantId: exactId(value.tenantId, "nativeDiscovery.tenantId"),
399
- profileId: exactId(value.profileId, "nativeDiscovery.profileId"),
400
- kind: value.kind === "bot" ? "bot" : "agent", groupId: exactId(value.groupId, "nativeDiscovery.groupId", true)
401
- };
402
- }
403
-
404
- function nativeAgentDirectory(scope, catalog, env) {
405
- if (scope.scope === "project") return join(catalog.root, scope.host === "claude" ? ".claude" : ".codex", "agents");
406
- const home = scope.host === "claude" ? env.CLAUDE_CONFIG_DIR
407
- : env.CODEX_HOME || env.BLUN_HOME;
408
- if (home) {
409
- if (!isAbsolute(home)) throw new Error(scope.host + " home override must be absolute");
410
- return join(home, "agents");
411
- }
412
- const userHome = env.HOME || env.USERPROFILE;
413
- if (!userHome || !isAbsolute(userHome)) throw new Error("native persona discovery requires an absolute host home");
414
- return join(userHome, scope.host === "claude" ? ".claude" : ".codex", "agents");
415
- }
416
-
417
- function manifestName(host, content) {
418
- if (host === "claude") {
419
- const frontmatter = content.startsWith("---") ? content.slice(3, content.indexOf("\n---", 3) + 1) : "";
420
- const match = frontmatter.match(/^\s*name\s*:\s*['\"]?([^'\"\r\n]+)['\"]?\s*$/mi);
421
- return match ? exactText(match[1], "manifest name") : null;
422
- }
423
- const match = content.match(/^\s*name\s*=\s*['\"]([^'\"\r\n]+)['\"]\s*$/mi);
424
- return match ? exactText(match[1], "manifest name") : null;
425
- }
426
-
427
- async function nativeManifestBindings(scope, catalog, env) {
428
- const directory = nativeAgentDirectory(scope, catalog, env);
429
- let metadata;
430
- try { metadata = await lstat(directory); } catch (error) { if (error.code === "ENOENT") return []; throw error; }
431
- if (metadata.isSymbolicLink() || !metadata.isDirectory()) throw new Error("native persona directory must be a regular non-symlink directory");
432
- const canonicalDirectory = await realpath(directory);
433
- const stream = await opendir(canonicalDirectory); const entries = [];
434
- try { for await (const entry of stream) entries.push(entry); } finally { await stream.close().catch(() => {}); }
435
- const extension = scope.host === "claude" ? ".md" : ".toml";
436
- const candidates = entries.filter((entry) => entry.isFile() && !entry.isSymbolicLink() && entry.name.endsWith(extension))
437
- .sort((left, right) => left.name.localeCompare(right.name));
438
- if (candidates.length > 128) throw new Error("native persona directory exceeds 128 direct manifests");
439
- const bindings = [];
440
- for (const entry of candidates) {
441
- const path = join(canonicalDirectory, entry.name);
442
- const pathBefore = await lstat(path);
443
- if (pathBefore.isSymbolicLink() || !pathBefore.isFile()) throw new Error("native persona manifest must be a regular non-symlink file");
444
- const handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
445
- try {
446
- const before = await handle.stat();
447
- if (!before.isFile() || before.size > 256 * 1024) throw new Error("native persona manifest exceeds 256 KiB");
448
- const content = await handle.readFile("utf8");
449
- const after = await handle.stat();
450
- const pathAfter = await lstat(path);
451
- if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
452
- throw new Error("native persona manifest changed during read");
453
- }
454
- if (pathAfter.isSymbolicLink() || !pathAfter.isFile() || pathAfter.dev !== before.dev || pathAfter.ino !== before.ino) {
455
- throw new Error("native persona manifest was exchanged during read");
456
- }
457
- const displayName = manifestName(scope.host, content);
458
- if (!displayName) continue;
459
- const sourceBinding = `${scope.host}:${scope.scope}-agents/${entry.name}`;
460
- const subjectId = "manifest:" + digest([scope.host, scope.profileId, sourceBinding].join("\0")).slice(0, 32);
461
- bindings.push({ id: "persona-binding:native:" + digest(scope.id + "\0" + sourceBinding).slice(0, 24),
462
- authenticator: "host-manifest", issuer: scope.issuer, tenantId: scope.tenantId, host: scope.host,
463
- profileId: scope.profileId, subjectId, kind: scope.kind, displayName, sourceBinding, groupId: scope.groupId });
464
- } finally { await handle.close(); }
465
- }
466
- return bindings;
467
- }
468
-
469
- export async function loadPersonaRuntime(root = process.cwd(), providedCatalog = null) {
470
- const paths = await pathsFor(root, providedCatalog);
471
- const [policy, runtime] = await Promise.all([
472
- readJson(paths.personaPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
473
- readJson(paths.personaRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime)
474
- ]);
475
- return { policy, runtime, ...paths };
476
- }
477
-
478
- export async function syncPersonaRosterFromEnvironment({
479
- root = process.cwd(), env = process.env, now = new Date(), catalog: providedCatalog = null
480
- } = {}) {
481
- const configured = env.AGENTSPINE_PERSONA_ROSTER_FILE;
482
- if (!configured) return { configured: false, changed: false };
483
- if (!isAbsolute(configured)) throw new Error("AGENTSPINE_PERSONA_ROSTER_FILE must be an absolute path");
484
- const catalog = providedCatalog || await buildCatalog(root);
485
- const supplied = await lstat(configured);
486
- if (supplied.isSymbolicLink() || !supplied.isFile()) throw new Error("persona roster must be a regular non-symlink file");
487
- const canonical = await realpath(configured);
488
- if (isInside(catalog.root, canonical)) throw new Error("persona roster must remain outside the agent project");
489
- const handle = await open(canonical, "r");
490
- let value;
491
- try {
492
- const metadata = await handle.stat();
493
- if (!metadata.isFile() || metadata.size > 1024 * 1024) throw new Error("persona roster exceeds its 1 MiB limit");
494
- value = JSON.parse(await handle.readFile("utf8"));
495
- } finally { await handle.close(); }
496
- if (!value || value.schema !== PERSONA_ROSTER_SCHEMA || !Number.isInteger(value.revision)
497
- || value.revision < 1 || !Array.isArray(value.bindings || []) || !Array.isArray(value.nativeDiscovery || [])) {
498
- throw new Error("persona roster envelope is invalid");
499
- }
500
- const nativeScopes = (value.nativeDiscovery || []).map(normalizeNativeDiscovery);
501
- const nativeBindings = (await Promise.all(nativeScopes.map((scope) => nativeManifestBindings(scope, catalog, env)))).flat();
502
- const bindings = [...(value.bindings || []), ...nativeBindings];
503
- const rosterScopes = nativeScopes.map((scope) => ({ authenticator: "host-manifest", issuer: scope.issuer,
504
- tenantId: scope.tenantId, host: scope.host, profileId: scope.profileId }));
505
- const result = await applyPersonaRoster({ root: catalog.root, bindings, rosterScopes,
506
- confirmation: CONFIRMATION, now: value.observedAt || now, catalog });
507
- return { configured: true, changed: !result.duplicate || result.graphReconciled,
508
- rosterChanged: !result.duplicate, graphReconciled: result.graphReconciled, graphChanges: result.graphChanges,
509
- revision: value.revision,
510
- personas: result.runtime.personas.length, nativeManifests: nativeBindings.length,
511
- rosterDigest: digest(JSON.stringify(value)) };
512
- }
513
-
514
- export async function inspectPersonaRuntime(root = process.cwd(), providedCatalog = null) {
515
- const paths = await pathsFor(root, providedCatalog);
516
- const errors = [];
517
- let policy = emptyPolicy(paths.catalog.root);
518
- let runtime = emptyRuntime(paths.catalog.root);
519
- try { policy = await readJson(paths.personaPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy); } catch (error) { errors.push("policy:" + error.message); }
520
- try { runtime = await readJson(paths.personaRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime); } catch (error) { errors.push("runtime:" + error.message); }
521
- return { policy, runtime, errors, ...paths };
522
- }
523
-
524
- export function personaRuntimeFindings(policy, runtime, graph = null) {
525
- const findings = [];
526
- const bindingIds = new Set();
527
- const personaIds = new Set();
528
- for (const binding of policy.bindings) {
529
- if (!validBinding(binding)) findings.push("invalid-persona-binding:" + (binding?.id || "unknown"));
530
- if (bindingIds.has(binding.id)) findings.push("duplicate-persona-binding:" + binding.id);
531
- bindingIds.add(binding.id);
532
- }
533
- for (const persona of runtime.personas) {
534
- if (!validPersona(persona)) findings.push("invalid-persona:" + (persona?.personaId || "unknown"));
535
- if (personaIds.has(persona.personaId)) findings.push("duplicate-persona:" + persona.personaId);
536
- if (!bindingIds.has(persona.bindingId)) findings.push("orphan-persona:" + persona.personaId);
537
- if (graph) {
538
- const entity = graph.entities.find((item) => item.id === persona.personaId);
539
- const expectedKind = persona.kind === "bot" ? "agent" : persona.kind;
540
- if (!entity || entity.kind !== expectedKind) findings.push("persona-graph-mismatch:" + persona.personaId);
541
- if (entity?.attributes?.identityStatus !== persona.status) findings.push("persona-graph-status-mismatch:" + persona.personaId);
542
- const memberships = graph.entityEdges.filter((edge) => edge.from === persona.personaId && edge.relation === "member-of");
543
- const group = persona.groupId === null ? null : graph.entities.find((item) => item.id === persona.groupId);
544
- if (persona.status === "active" && persona.groupId !== null
545
- && (!group || group.kind !== "group" || group.privacy === "private")) {
546
- findings.push("persona-group-entity-mismatch:" + persona.personaId);
547
- }
548
- if (persona.status === "active" && persona.groupId !== null
549
- && !memberships.some((edge) => edge.to === persona.groupId && edge.privacy === "group")) {
550
- findings.push("persona-group-membership-mismatch:" + persona.personaId);
551
- }
552
- if (memberships.some((edge) => edge.to !== persona.groupId || persona.status !== "active")) {
553
- findings.push("persona-stale-group-membership:" + persona.personaId);
554
- }
555
- }
556
- personaIds.add(persona.personaId);
557
- }
558
- const eventIds = new Set();
559
- for (const event of runtime.events) {
560
- if (!validEvent(event) || !personaIds.has(event.personaId)) findings.push("invalid-persona-event:" + (event?.eventId || "unknown"));
561
- if (eventIds.has(event.eventId)) findings.push("duplicate-persona-event:" + event.eventId);
562
- eventIds.add(event.eventId);
563
- }
564
- const receiptIds = new Set();
565
- for (const receipt of runtime.receipts) {
566
- if (!validReceipt(receipt) || !eventIds.has(receipt.eventId)) findings.push("invalid-persona-receipt:" + (receipt?.id || "unknown"));
567
- if (receiptIds.has(receipt.id)) findings.push("duplicate-persona-receipt:" + receipt.id);
568
- receiptIds.add(receipt.id);
569
- }
570
- return findings;
571
- }
572
-
573
- export async function personaContext({ root = process.cwd(), personaId = null, groupId = undefined, includeInactive = false } = {}) {
574
- const { policy, runtime } = await loadPersonaRuntime(root);
575
- const findings = personaRuntimeFindings(policy, runtime);
576
- if (findings.length) throw new Error("persona runtime failed closed: " + findings.join(", "));
577
- const items = runtime.personas.filter((item) => (includeInactive || item.status === "active")
578
- && (personaId === null || item.personaId === exactId(personaId, "personaId"))
579
- && (groupId === undefined || item.groupId === exactId(groupId, "groupId", true)));
580
- return { schema: "agentspine.persona-context/v1", items: structuredClone(items), authority: "identity-state-only" };
581
- }
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { lstat, open, opendir, readFile, realpath, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { isAbsolute, join } from "node:path";
5
+ import { buildCatalog } from "./catalog.js";
6
+ import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
7
+ import { linkEntities, loadGraph, unlinkEntities, upsertEntity } from "./graph.js";
8
+ import { isInside, projectStateDir } from "./paths.js";
9
+
10
+ export const PERSONA_POLICY_SCHEMA = "agentspine.persona-policy/v1";
11
+ export const PERSONA_RUNTIME_SCHEMA = "agentspine.persona-runtime/v1";
12
+ export const PERSONA_EVENT_SCHEMA = "agentspine.persona-event/v1";
13
+ export const PERSONA_ROSTER_SCHEMA = "agentspine.persona-roster/v1";
14
+
15
+ const CONFIRMATION = "local-owner-confirmed";
16
+ const MAX_BYTES = 5 * 1024 * 1024;
17
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/+~-]{0,255}$/;
18
+ const KINDS = new Set(["person", "agent", "bot"]);
19
+ const EVENTS = new Set(["join", "rename", "group-change", "leave", "deactivate", "rejoin"]);
20
+ const ACTIVE = new Set(["active", "left", "deactivated"]);
21
+ const AUTHENTICATORS = new Set(["host-manifest", "oauth-subject", "local-roster", "signed-roster"]);
22
+ const NATIVE_HOSTS = new Set(["claude", "codex"]);
23
+ const NATIVE_SCOPES = new Set(["user", "project"]);
24
+ const FORBIDDEN = /\b(?:permission|rights?|roles?|owner|trusted|reports[- ]to|responsible[- ]for|delegat|token|secret|credential|api[-_ ]?key|network|production|payment|send capability|tool capability)\b/i;
25
+
26
+ function emptyPolicy(root) {
27
+ return { schema: PERSONA_POLICY_SCHEMA, root, revision: 0, bindings: [], history: [] };
28
+ }
29
+
30
+ function emptyRuntime(root) {
31
+ return { schema: PERSONA_RUNTIME_SCHEMA, root, revision: 0, personas: [], events: [], receipts: [] };
32
+ }
33
+
34
+ function exactId(value, field, nullable = false) {
35
+ if ((value === null || value === undefined || value === "") && nullable) return null;
36
+ if (typeof value !== "string" || !ID_RE.test(value) || value.includes("*")) {
37
+ throw new Error(field + " must be an exact stable ID without wildcards");
38
+ }
39
+ return value;
40
+ }
41
+
42
+ function exactText(value, field, maximum = 200) {
43
+ if (typeof value !== "string" || !value.trim()) throw new Error(field + " is required");
44
+ const text = value.trim().replace(/\s+/g, " ").slice(0, maximum);
45
+ if (FORBIDDEN.test(text)) throw new Error(field + " contains authority or secret-shaped content");
46
+ return text;
47
+ }
48
+
49
+ function sourceText(value) {
50
+ if (typeof value !== "string" || !value.trim() || value.includes("\0")) throw new Error("sourceBinding is invalid");
51
+ return value.trim().replaceAll("\\", "/").slice(0, 500);
52
+ }
53
+
54
+ function at(value = new Date()) {
55
+ const date = value instanceof Date ? value : new Date(value);
56
+ if (!Number.isFinite(date.getTime())) throw new Error("timestamp is invalid");
57
+ return date.toISOString();
58
+ }
59
+
60
+ function digest(value) {
61
+ return createHash("sha256").update(value).digest("hex");
62
+ }
63
+
64
+ function identityMaterial(binding) {
65
+ return [binding.authenticator, binding.issuer, binding.tenantId, binding.host, binding.profileId,
66
+ binding.subjectId, binding.kind].join("\0");
67
+ }
68
+
69
+ function stablePersonaId(binding) {
70
+ return binding.kind + ":runtime:" + digest(identityMaterial(binding)).slice(0, 32);
71
+ }
72
+
73
+ function validBinding(binding) {
74
+ return binding && ID_RE.test(binding.id || "") && KINDS.has(binding.kind)
75
+ && AUTHENTICATORS.has(binding.authenticator) && ID_RE.test(binding.issuer || "")
76
+ && ID_RE.test(binding.tenantId || "") && ID_RE.test(binding.host || "")
77
+ && ID_RE.test(binding.profileId || "") && ID_RE.test(binding.subjectId || "")
78
+ && ID_RE.test(binding.personaId || "") && binding.personaId === stablePersonaId(binding)
79
+ && typeof binding.displayName === "string" && binding.displayName.length > 0 && !FORBIDDEN.test(binding.displayName)
80
+ && (binding.sourceBinding === null || typeof binding.sourceBinding === "string")
81
+ && (binding.groupId === null || ID_RE.test(binding.groupId || ""))
82
+ && typeof binding.active === "boolean" && typeof binding.deactivated === "boolean"
83
+ && !(binding.active && binding.deactivated) && binding.authority === "authenticated-identity-policy"
84
+ && Number.isFinite(new Date(binding.createdAt).getTime())
85
+ && Number.isFinite(new Date(binding.updatedAt).getTime());
86
+ }
87
+
88
+ function validPersona(persona) {
89
+ return persona && ID_RE.test(persona.personaId || "") && ID_RE.test(persona.bindingId || "")
90
+ && KINDS.has(persona.kind) && ACTIVE.has(persona.status)
91
+ && typeof persona.displayName === "string" && persona.displayName.length > 0
92
+ && (persona.groupId === null || ID_RE.test(persona.groupId || ""))
93
+ && Number.isInteger(persona.sequence) && persona.sequence >= 1
94
+ && persona.authority === "identity-state-only" && Number.isFinite(new Date(persona.updatedAt).getTime());
95
+ }
96
+
97
+ function validEvent(event) {
98
+ if (!(event && event.schema === PERSONA_EVENT_SCHEMA && ID_RE.test(event.eventId || "")
99
+ && ID_RE.test(event.personaId || "") && ID_RE.test(event.bindingId || "")
100
+ && EVENTS.has(event.type) && Number.isInteger(event.sequence) && event.sequence >= 1
101
+ && Number.isFinite(new Date(event.observedAt).getTime())
102
+ && /^[a-f0-9]{64}$/.test(event.payloadDigest || "")
103
+ && event.authority === "identity-state-only")) return false;
104
+ const payload = { personaId: event.personaId, bindingId: event.bindingId, type: event.type, sequence: event.sequence,
105
+ displayName: event.displayName, groupId: event.groupId, observedAt: event.observedAt };
106
+ const payloadDigest = digest(JSON.stringify(payload));
107
+ return event.payloadDigest === payloadDigest && event.eventId === "persona-event:" + payloadDigest.slice(0, 32);
108
+ }
109
+
110
+ function validReceipt(receipt) {
111
+ if (!(receipt && ID_RE.test(receipt.id || "") && ID_RE.test(receipt.eventId || "")
112
+ && /^[a-f0-9]{64}$/.test(receipt.payloadDigest || "") && /^[a-f0-9]{64}$/.test(receipt.digest || "")
113
+ && Number.isFinite(new Date(receipt.at).getTime()) && receipt.authority === "identity-state-only")) return false;
114
+ const material = { eventId: receipt.eventId, payloadDigest: receipt.payloadDigest, at: receipt.at,
115
+ authority: "identity-state-only" };
116
+ return receipt.digest === digest(JSON.stringify(material)) && receipt.id === "persona-receipt:" + receipt.digest.slice(0, 24);
117
+ }
118
+
119
+ function normalizePolicy(value, root) {
120
+ if (!value || value.schema !== PERSONA_POLICY_SCHEMA || value.root !== root
121
+ || !Number.isInteger(value.revision) || !Array.isArray(value.bindings) || !Array.isArray(value.history)
122
+ || value.bindings.some((item) => !validBinding(item))
123
+ || value.history.some((item) => !item || !validBinding(item.value) || item.authority !== "authenticated-identity-policy")) {
124
+ throw new Error("persona policy is invalid; automatic persona synchronization is disabled");
125
+ }
126
+ return value;
127
+ }
128
+
129
+ function normalizeRuntime(value, root) {
130
+ if (!value || value.schema !== PERSONA_RUNTIME_SCHEMA || value.root !== root
131
+ || !Number.isInteger(value.revision) || !Array.isArray(value.personas)
132
+ || !Array.isArray(value.events) || !Array.isArray(value.receipts)
133
+ || value.personas.some((item) => !validPersona(item)) || value.events.some((item) => !validEvent(item))
134
+ || value.receipts.some((item) => !validReceipt(item))) {
135
+ throw new Error("persona runtime is invalid; persona context is disabled");
136
+ }
137
+ return value;
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 { catalog, directory, personaPolicyPath: join(directory, "persona-policy.json"), personaRuntimePath: join(directory, "persona-runtime.json") };
144
+ }
145
+
146
+ async function readJson(path, root, normalize, empty) {
147
+ try {
148
+ const metadata = await stat(path);
149
+ if (metadata.size > MAX_BYTES) throw new Error("persona state exceeds 5 MiB");
150
+ return normalize(JSON.parse(await readFile(path, "utf8")), root);
151
+ } catch (error) {
152
+ if (error.code !== "ENOENT") throw error;
153
+ return empty(root);
154
+ }
155
+ }
156
+
157
+ async function writeJson(path, value) {
158
+ const content = JSON.stringify(value, null, 2) + "\n";
159
+ if (Buffer.byteLength(content) > MAX_BYTES) throw new Error("persona state exceeds 5 MiB");
160
+ const temporary = path + "." + process.pid + "." + randomUUID() + ".tmp";
161
+ try {
162
+ await writeFile(temporary, content, { mode: 0o600 });
163
+ await replaceFileWithRetry(temporary, path);
164
+ } finally {
165
+ await unlink(temporary).catch((error) => { if (error.code !== "ENOENT") throw error; });
166
+ }
167
+ }
168
+
169
+ async function withLock(paths, task) {
170
+ const lockPath = join(paths.directory, "persona-runtime.lock");
171
+ let handle;
172
+ for (let attempt = 0; attempt < 120; attempt += 1) {
173
+ try { handle = await open(lockPath, "wx", 0o600); break; } catch (error) {
174
+ if (!isFileLockContention(error)) throw error;
175
+ try {
176
+ const metadata = await stat(lockPath);
177
+ if (Date.now() - metadata.mtimeMs > 90000) await unlink(lockPath);
178
+ } catch (lockError) { if (lockError.code !== "ENOENT") throw lockError; }
179
+ await new Promise((resolve) => setTimeout(resolve, 25));
180
+ }
181
+ }
182
+ if (!handle) throw new Error("persona state is busy; retry later");
183
+ try { return await task(); } finally {
184
+ await handle.close();
185
+ await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
186
+ }
187
+ }
188
+
189
+ function normalizeBinding(input, timestamp) {
190
+ const binding = {
191
+ id: exactId(input.id || "persona-binding:" + randomUUID(), "bindingId"),
192
+ authenticator: AUTHENTICATORS.has(input.authenticator) ? input.authenticator : null,
193
+ issuer: exactId(input.issuer, "issuer"), tenantId: exactId(input.tenantId, "tenantId"),
194
+ host: exactId(input.host, "host"), profileId: exactId(input.profileId, "profileId"),
195
+ subjectId: exactId(input.subjectId, "subjectId"), kind: input.kind,
196
+ displayName: exactText(input.displayName, "displayName"),
197
+ sourceBinding: input.sourceBinding === null || input.sourceBinding === undefined ? null : sourceText(input.sourceBinding),
198
+ groupId: exactId(input.groupId, "groupId", true), deactivated: input.deactivated === true,
199
+ active: input.deactivated === true ? false : input.active !== false,
200
+ createdAt: timestamp, updatedAt: timestamp, authority: "authenticated-identity-policy"
201
+ };
202
+ if (!binding.authenticator || !KINDS.has(binding.kind)) throw new Error("persona binding requires an approved authenticator and person, agent, or bot kind");
203
+ binding.personaId = stablePersonaId(binding);
204
+ return binding;
205
+ }
206
+
207
+ function eventPayload(binding, type, sequence, observedAt) {
208
+ const payload = { personaId: binding.personaId, bindingId: binding.id, type, sequence, displayName: binding.displayName,
209
+ groupId: binding.groupId, observedAt };
210
+ return { schema: PERSONA_EVENT_SCHEMA, eventId: "persona-event:" + digest(JSON.stringify(payload)).slice(0, 32),
211
+ ...payload, payloadDigest: digest(JSON.stringify(payload)), authority: "identity-state-only" };
212
+ }
213
+
214
+ function eventReceipt(event) {
215
+ const material = { eventId: event.eventId, payloadDigest: event.payloadDigest, at: event.observedAt,
216
+ authority: "identity-state-only" };
217
+ const receiptDigest = digest(JSON.stringify(material));
218
+ return { id: "persona-receipt:" + receiptDigest.slice(0, 24), ...material, digest: receiptDigest };
219
+ }
220
+
221
+ function nextEventTypes(previous, binding) {
222
+ if (!previous) return ["join"];
223
+ if (!binding.active && previous.status === "active") return [binding.deactivated ? "deactivate" : "leave"];
224
+ if (!binding.active) return [];
225
+ const types = previous.status !== "active" ? ["rejoin"] : [];
226
+ if (previous.displayName !== binding.displayName) types.push("rename");
227
+ if (previous.groupId !== binding.groupId) types.push("group-change");
228
+ return types;
229
+ }
230
+
231
+ function runtimeEntityAttributes(persona, binding, existing) {
232
+ return {
233
+ ...(existing?.attributes || {}),
234
+ runtimeKind: persona.kind,
235
+ identityBindingId: persona.bindingId,
236
+ identityStatus: persona.status,
237
+ sourceBinding: binding?.sourceBinding || null
238
+ };
239
+ }
240
+
241
+ function sameRuntimeEntity(entity, persona, binding) {
242
+ if (!entity) return false;
243
+ const expectedKind = persona.kind === "bot" ? "agent" : persona.kind;
244
+ const expectedPrivacy = persona.groupId ? "group" : "shared";
245
+ const attributes = runtimeEntityAttributes(persona, binding, entity);
246
+ return entity.kind === expectedKind && entity.displayName === persona.displayName
247
+ && entity.privacy === expectedPrivacy
248
+ && JSON.stringify(entity.attributes) === JSON.stringify(attributes);
249
+ }
250
+
251
+ async function reconcilePersonaGraph(paths, policy, runtime) {
252
+ let { graph } = await loadGraph(paths.catalog.root, paths.catalog);
253
+ const changes = { groupsCreated: 0, entitiesUpdated: 0, membershipsAdded: 0, membershipsRemoved: 0 };
254
+ const activeGroupIds = [...new Set(runtime.personas
255
+ .filter((item) => item.status === "active" && item.groupId !== null)
256
+ .map((item) => item.groupId))].sort();
257
+
258
+ for (const groupId of activeGroupIds) {
259
+ const existing = graph.entities.find((item) => item.id === groupId);
260
+ if (existing && existing.kind !== "group") {
261
+ throw new Error(`authenticated persona group conflicts with a non-group entity: ${groupId}`);
262
+ }
263
+ if (existing?.privacy === "private") {
264
+ throw new Error(`authenticated persona group cannot use private relationship visibility: ${groupId}`);
265
+ }
266
+ if (!existing) {
267
+ await upsertEntity({ root: paths.catalog.root, id: groupId, kind: "group", privacy: "group",
268
+ attributes: { identitySource: "authenticated-persona-roster" }, confidence: 1, catalog: paths.catalog });
269
+ changes.groupsCreated += 1;
270
+ graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
271
+ }
272
+ }
273
+
274
+ for (const persona of runtime.personas) {
275
+ const binding = policy.bindings.find((item) => item.id === persona.bindingId);
276
+ let existing = graph.entities.find((item) => item.id === persona.personaId);
277
+ if (existing && existing.kind !== (persona.kind === "bot" ? "agent" : persona.kind)) {
278
+ throw new Error(`authenticated persona conflicts with an existing entity kind: ${persona.personaId}`);
279
+ }
280
+ if (!sameRuntimeEntity(existing, persona, binding)) {
281
+ await upsertEntity({ root: paths.catalog.root, id: persona.personaId,
282
+ kind: persona.kind === "bot" ? "agent" : persona.kind, displayName: persona.displayName,
283
+ aliases: existing?.aliases || [], attributes: runtimeEntityAttributes(persona, binding, existing),
284
+ sourceDocument: existing?.sourceDocument
285
+ && paths.catalog.documents.some((item) => item.relativePath === existing.sourceDocument)
286
+ ? existing.sourceDocument : null,
287
+ privacy: persona.groupId ? "group" : "shared", confidence: 1, catalog: paths.catalog });
288
+ changes.entitiesUpdated += 1;
289
+ graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
290
+ existing = graph.entities.find((item) => item.id === persona.personaId);
291
+ }
292
+
293
+ const memberships = graph.entityEdges.filter((edge) => edge.from === persona.personaId && edge.relation === "member-of");
294
+ for (const edge of memberships.filter((item) => persona.status !== "active" || item.to !== persona.groupId)) {
295
+ await unlinkEntities({ root: paths.catalog.root, from: edge.from, to: edge.to, relation: edge.relation,
296
+ catalog: paths.catalog });
297
+ changes.membershipsRemoved += 1;
298
+ graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
299
+ }
300
+ if (persona.status === "active" && persona.groupId !== null
301
+ && !graph.entityEdges.some((edge) => edge.from === persona.personaId && edge.to === persona.groupId
302
+ && edge.relation === "member-of" && edge.privacy === "group")) {
303
+ await linkEntities({ root: paths.catalog.root, from: persona.personaId, to: persona.groupId,
304
+ relation: "member-of", reason: "Authenticated roster membership; context only.", confidence: 1, privacy: "group",
305
+ catalog: paths.catalog });
306
+ changes.membershipsAdded += 1;
307
+ graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
308
+ }
309
+ }
310
+ return changes;
311
+ }
312
+
313
+ export async function applyPersonaRoster({
314
+ root = process.cwd(), bindings, rosterScopes = [], confirmation, now = new Date(), catalog: providedCatalog = null
315
+ }) {
316
+ if (confirmation !== CONFIRMATION) throw new Error("persona roster changes require explicit local owner confirmation");
317
+ if (!Array.isArray(bindings) || bindings.length > 256 || (!bindings.length && !rosterScopes.length)) {
318
+ throw new Error("bindings must contain up to 256 persona bindings or one explicit roster scope");
319
+ }
320
+ const paths = await pathsFor(root, providedCatalog);
321
+ return withLock(paths, async () => {
322
+ const [policy, runtime] = await Promise.all([
323
+ readJson(paths.personaPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
324
+ readJson(paths.personaRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime)
325
+ ]);
326
+ const observedAt = at(now);
327
+ const normalized = bindings.map((item) => normalizeBinding(item, observedAt));
328
+ let changed = false;
329
+ const scopedRosterKeys = new Set([
330
+ ...normalized.map((item) => [item.authenticator, item.issuer, item.tenantId, item.host, item.profileId].join("\0")),
331
+ ...rosterScopes.map((item) => [item.authenticator, item.issuer, item.tenantId, item.host, item.profileId].join("\0"))
332
+ ]);
333
+ const suppliedIds = new Set(normalized.map((item) => item.id));
334
+ for (const missing of policy.bindings.filter((item) => item.active && scopedRosterKeys.has([
335
+ item.authenticator, item.issuer, item.tenantId, item.host, item.profileId
336
+ ].join("\0")) && !suppliedIds.has(item.id))) {
337
+ const retired = { ...missing, active: false, deactivated: false, updatedAt: observedAt };
338
+ normalized.push(retired);
339
+ }
340
+ const identities = new Set();
341
+ for (const binding of normalized) {
342
+ const key = identityMaterial(binding);
343
+ if (identities.has(key)) throw new Error("persona roster contains a duplicate authenticated identity");
344
+ identities.add(key);
345
+ const previousBinding = policy.bindings.find((item) => item.id === binding.id);
346
+ if (previousBinding && previousBinding.personaId !== binding.personaId) throw new Error("a binding ID cannot be reassigned to another authenticated identity");
347
+ const unchangedBinding = previousBinding && ["authenticator", "issuer", "tenantId", "host", "profileId", "subjectId", "kind",
348
+ "displayName", "sourceBinding", "groupId", "active", "deactivated", "personaId"].every((key) => previousBinding[key] === binding[key]);
349
+ if (unchangedBinding) continue;
350
+ changed = true;
351
+ if (previousBinding) {
352
+ policy.history.push({ recordId: previousBinding.id, supersededAt: observedAt, value: structuredClone(previousBinding), authority: "authenticated-identity-policy" });
353
+ binding.createdAt = previousBinding.createdAt;
354
+ }
355
+ policy.bindings = policy.bindings.filter((item) => item.id !== binding.id);
356
+ policy.bindings.push(binding);
357
+ const previous = runtime.personas.find((item) => item.personaId === binding.personaId);
358
+ const types = nextEventTypes(previous, binding);
359
+ let sequence = previous?.sequence || 0;
360
+ for (const type of types) {
361
+ sequence += 1;
362
+ const event = eventPayload(binding, type, sequence, observedAt);
363
+ if (!runtime.receipts.some((item) => item.eventId === event.eventId)) {
364
+ runtime.events.push(event);
365
+ runtime.receipts.push(eventReceipt(event));
366
+ }
367
+ }
368
+ if (types.length) {
369
+ runtime.personas = runtime.personas.filter((item) => item.personaId !== binding.personaId);
370
+ runtime.personas.push({ personaId: binding.personaId, bindingId: binding.id, kind: binding.kind,
371
+ displayName: binding.displayName, groupId: binding.groupId,
372
+ status: binding.active ? "active" : binding.deactivated ? "deactivated" : "left",
373
+ sequence, updatedAt: observedAt, authority: "identity-state-only" });
374
+ }
375
+ }
376
+ if (changed) {
377
+ policy.bindings.sort((a, b) => a.id.localeCompare(b.id));
378
+ runtime.personas.sort((a, b) => a.personaId.localeCompare(b.personaId));
379
+ runtime.events.sort((a, b) => a.observedAt.localeCompare(b.observedAt) || a.eventId.localeCompare(b.eventId));
380
+ policy.revision += 1;
381
+ runtime.revision += 1;
382
+ await Promise.all([writeJson(paths.personaPolicyPath, policy), writeJson(paths.personaRuntimePath, runtime)]);
383
+ }
384
+ const graphChanges = await reconcilePersonaGraph(paths, policy, runtime);
385
+ const graphReconciled = Object.values(graphChanges).some((value) => value > 0);
386
+ return { policy, runtime, personaPolicyPath: paths.personaPolicyPath,
387
+ personaRuntimePath: paths.personaRuntimePath, duplicate: !changed, graphReconciled, graphChanges };
388
+ });
389
+ }
390
+
391
+ function normalizeNativeDiscovery(value) {
392
+ if (!value || typeof value !== "object" || !NATIVE_HOSTS.has(value.host) || !NATIVE_SCOPES.has(value.scope)) {
393
+ throw new Error("native persona discovery requires a supported host and user or project scope");
394
+ }
395
+ return {
396
+ id: exactId(value.id, "nativeDiscovery.id"), host: value.host, scope: value.scope,
397
+ authenticator: "host-manifest", issuer: exactId(value.issuer, "nativeDiscovery.issuer"),
398
+ tenantId: exactId(value.tenantId, "nativeDiscovery.tenantId"),
399
+ profileId: exactId(value.profileId, "nativeDiscovery.profileId"),
400
+ kind: value.kind === "bot" ? "bot" : "agent", groupId: exactId(value.groupId, "nativeDiscovery.groupId", true)
401
+ };
402
+ }
403
+
404
+ function nativeAgentDirectory(scope, catalog, env) {
405
+ if (scope.scope === "project") return join(catalog.root, scope.host === "claude" ? ".claude" : ".codex", "agents");
406
+ const home = scope.host === "claude" ? env.CLAUDE_CONFIG_DIR
407
+ : env.CODEX_HOME || env.BLUN_HOME;
408
+ if (home) {
409
+ if (!isAbsolute(home)) throw new Error(scope.host + " home override must be absolute");
410
+ return join(home, "agents");
411
+ }
412
+ const userHome = env.HOME || env.USERPROFILE;
413
+ if (!userHome || !isAbsolute(userHome)) throw new Error("native persona discovery requires an absolute host home");
414
+ return join(userHome, scope.host === "claude" ? ".claude" : ".codex", "agents");
415
+ }
416
+
417
+ function manifestName(host, content) {
418
+ if (host === "claude") {
419
+ const frontmatter = content.startsWith("---") ? content.slice(3, content.indexOf("\n---", 3) + 1) : "";
420
+ const match = frontmatter.match(/^\s*name\s*:\s*['\"]?([^'\"\r\n]+)['\"]?\s*$/mi);
421
+ return match ? exactText(match[1], "manifest name") : null;
422
+ }
423
+ const match = content.match(/^\s*name\s*=\s*['\"]([^'\"\r\n]+)['\"]\s*$/mi);
424
+ return match ? exactText(match[1], "manifest name") : null;
425
+ }
426
+
427
+ async function nativeManifestBindings(scope, catalog, env) {
428
+ const directory = nativeAgentDirectory(scope, catalog, env);
429
+ let metadata;
430
+ try { metadata = await lstat(directory); } catch (error) { if (error.code === "ENOENT") return []; throw error; }
431
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) throw new Error("native persona directory must be a regular non-symlink directory");
432
+ const canonicalDirectory = await realpath(directory);
433
+ const stream = await opendir(canonicalDirectory); const entries = [];
434
+ try { for await (const entry of stream) entries.push(entry); } finally { await stream.close().catch(() => {}); }
435
+ const extension = scope.host === "claude" ? ".md" : ".toml";
436
+ const candidates = entries.filter((entry) => entry.isFile() && !entry.isSymbolicLink() && entry.name.endsWith(extension))
437
+ .sort((left, right) => left.name.localeCompare(right.name));
438
+ if (candidates.length > 128) throw new Error("native persona directory exceeds 128 direct manifests");
439
+ const bindings = [];
440
+ for (const entry of candidates) {
441
+ const path = join(canonicalDirectory, entry.name);
442
+ const pathBefore = await lstat(path);
443
+ if (pathBefore.isSymbolicLink() || !pathBefore.isFile()) throw new Error("native persona manifest must be a regular non-symlink file");
444
+ const handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
445
+ try {
446
+ const before = await handle.stat();
447
+ if (!before.isFile() || before.size > 256 * 1024) throw new Error("native persona manifest exceeds 256 KiB");
448
+ const content = await handle.readFile("utf8");
449
+ const after = await handle.stat();
450
+ const pathAfter = await lstat(path);
451
+ if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
452
+ throw new Error("native persona manifest changed during read");
453
+ }
454
+ if (pathAfter.isSymbolicLink() || !pathAfter.isFile() || pathAfter.dev !== before.dev || pathAfter.ino !== before.ino) {
455
+ throw new Error("native persona manifest was exchanged during read");
456
+ }
457
+ const displayName = manifestName(scope.host, content);
458
+ if (!displayName) continue;
459
+ const sourceBinding = `${scope.host}:${scope.scope}-agents/${entry.name}`;
460
+ const subjectId = "manifest:" + digest([scope.host, scope.profileId, sourceBinding].join("\0")).slice(0, 32);
461
+ bindings.push({ id: "persona-binding:native:" + digest(scope.id + "\0" + sourceBinding).slice(0, 24),
462
+ authenticator: "host-manifest", issuer: scope.issuer, tenantId: scope.tenantId, host: scope.host,
463
+ profileId: scope.profileId, subjectId, kind: scope.kind, displayName, sourceBinding, groupId: scope.groupId });
464
+ } finally { await handle.close(); }
465
+ }
466
+ return bindings;
467
+ }
468
+
469
+ export async function loadPersonaRuntime(root = process.cwd(), providedCatalog = null) {
470
+ const paths = await pathsFor(root, providedCatalog);
471
+ const [policy, runtime] = await Promise.all([
472
+ readJson(paths.personaPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
473
+ readJson(paths.personaRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime)
474
+ ]);
475
+ return { policy, runtime, ...paths };
476
+ }
477
+
478
+ export async function syncPersonaRosterFromEnvironment({
479
+ root = process.cwd(), env = process.env, now = new Date(), catalog: providedCatalog = null
480
+ } = {}) {
481
+ const configured = env.AGENTSPINE_PERSONA_ROSTER_FILE;
482
+ if (!configured) return { configured: false, changed: false };
483
+ if (!isAbsolute(configured)) throw new Error("AGENTSPINE_PERSONA_ROSTER_FILE must be an absolute path");
484
+ const catalog = providedCatalog || await buildCatalog(root);
485
+ const supplied = await lstat(configured);
486
+ if (supplied.isSymbolicLink() || !supplied.isFile()) throw new Error("persona roster must be a regular non-symlink file");
487
+ const canonical = await realpath(configured);
488
+ if (isInside(catalog.root, canonical)) throw new Error("persona roster must remain outside the agent project");
489
+ const handle = await open(canonical, "r");
490
+ let value;
491
+ try {
492
+ const metadata = await handle.stat();
493
+ if (!metadata.isFile() || metadata.size > 1024 * 1024) throw new Error("persona roster exceeds its 1 MiB limit");
494
+ value = JSON.parse(await handle.readFile("utf8"));
495
+ } finally { await handle.close(); }
496
+ if (!value || value.schema !== PERSONA_ROSTER_SCHEMA || !Number.isInteger(value.revision)
497
+ || value.revision < 1 || !Array.isArray(value.bindings || []) || !Array.isArray(value.nativeDiscovery || [])) {
498
+ throw new Error("persona roster envelope is invalid");
499
+ }
500
+ const nativeScopes = (value.nativeDiscovery || []).map(normalizeNativeDiscovery);
501
+ const nativeBindings = (await Promise.all(nativeScopes.map((scope) => nativeManifestBindings(scope, catalog, env)))).flat();
502
+ const bindings = [...(value.bindings || []), ...nativeBindings];
503
+ const rosterScopes = nativeScopes.map((scope) => ({ authenticator: "host-manifest", issuer: scope.issuer,
504
+ tenantId: scope.tenantId, host: scope.host, profileId: scope.profileId }));
505
+ const result = await applyPersonaRoster({ root: catalog.root, bindings, rosterScopes,
506
+ confirmation: CONFIRMATION, now: value.observedAt || now, catalog });
507
+ return { configured: true, changed: !result.duplicate || result.graphReconciled,
508
+ rosterChanged: !result.duplicate, graphReconciled: result.graphReconciled, graphChanges: result.graphChanges,
509
+ revision: value.revision,
510
+ personas: result.runtime.personas.length, nativeManifests: nativeBindings.length,
511
+ rosterDigest: digest(JSON.stringify(value)) };
512
+ }
513
+
514
+ export async function inspectPersonaRuntime(root = process.cwd(), providedCatalog = null) {
515
+ const paths = await pathsFor(root, providedCatalog);
516
+ const errors = [];
517
+ let policy = emptyPolicy(paths.catalog.root);
518
+ let runtime = emptyRuntime(paths.catalog.root);
519
+ try { policy = await readJson(paths.personaPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy); } catch (error) { errors.push("policy:" + error.message); }
520
+ try { runtime = await readJson(paths.personaRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime); } catch (error) { errors.push("runtime:" + error.message); }
521
+ return { policy, runtime, errors, ...paths };
522
+ }
523
+
524
+ export function personaRuntimeFindings(policy, runtime, graph = null) {
525
+ const findings = [];
526
+ const bindingIds = new Set();
527
+ const personaIds = new Set();
528
+ for (const binding of policy.bindings) {
529
+ if (!validBinding(binding)) findings.push("invalid-persona-binding:" + (binding?.id || "unknown"));
530
+ if (bindingIds.has(binding.id)) findings.push("duplicate-persona-binding:" + binding.id);
531
+ bindingIds.add(binding.id);
532
+ }
533
+ for (const persona of runtime.personas) {
534
+ if (!validPersona(persona)) findings.push("invalid-persona:" + (persona?.personaId || "unknown"));
535
+ if (personaIds.has(persona.personaId)) findings.push("duplicate-persona:" + persona.personaId);
536
+ if (!bindingIds.has(persona.bindingId)) findings.push("orphan-persona:" + persona.personaId);
537
+ if (graph) {
538
+ const entity = graph.entities.find((item) => item.id === persona.personaId);
539
+ const expectedKind = persona.kind === "bot" ? "agent" : persona.kind;
540
+ if (!entity || entity.kind !== expectedKind) findings.push("persona-graph-mismatch:" + persona.personaId);
541
+ if (entity?.attributes?.identityStatus !== persona.status) findings.push("persona-graph-status-mismatch:" + persona.personaId);
542
+ const memberships = graph.entityEdges.filter((edge) => edge.from === persona.personaId && edge.relation === "member-of");
543
+ const group = persona.groupId === null ? null : graph.entities.find((item) => item.id === persona.groupId);
544
+ if (persona.status === "active" && persona.groupId !== null
545
+ && (!group || group.kind !== "group" || group.privacy === "private")) {
546
+ findings.push("persona-group-entity-mismatch:" + persona.personaId);
547
+ }
548
+ if (persona.status === "active" && persona.groupId !== null
549
+ && !memberships.some((edge) => edge.to === persona.groupId && edge.privacy === "group")) {
550
+ findings.push("persona-group-membership-mismatch:" + persona.personaId);
551
+ }
552
+ if (memberships.some((edge) => edge.to !== persona.groupId || persona.status !== "active")) {
553
+ findings.push("persona-stale-group-membership:" + persona.personaId);
554
+ }
555
+ }
556
+ personaIds.add(persona.personaId);
557
+ }
558
+ const eventIds = new Set();
559
+ for (const event of runtime.events) {
560
+ if (!validEvent(event) || !personaIds.has(event.personaId)) findings.push("invalid-persona-event:" + (event?.eventId || "unknown"));
561
+ if (eventIds.has(event.eventId)) findings.push("duplicate-persona-event:" + event.eventId);
562
+ eventIds.add(event.eventId);
563
+ }
564
+ const receiptIds = new Set();
565
+ for (const receipt of runtime.receipts) {
566
+ if (!validReceipt(receipt) || !eventIds.has(receipt.eventId)) findings.push("invalid-persona-receipt:" + (receipt?.id || "unknown"));
567
+ if (receiptIds.has(receipt.id)) findings.push("duplicate-persona-receipt:" + receipt.id);
568
+ receiptIds.add(receipt.id);
569
+ }
570
+ return findings;
571
+ }
572
+
573
+ export async function personaContext({ root = process.cwd(), personaId = null, groupId = undefined, includeInactive = false } = {}) {
574
+ const { policy, runtime } = await loadPersonaRuntime(root);
575
+ const findings = personaRuntimeFindings(policy, runtime);
576
+ if (findings.length) throw new Error("persona runtime failed closed: " + findings.join(", "));
577
+ const items = runtime.personas.filter((item) => (includeInactive || item.status === "active")
578
+ && (personaId === null || item.personaId === exactId(personaId, "personaId"))
579
+ && (groupId === undefined || item.groupId === exactId(groupId, "groupId", true)));
580
+ return { schema: "agentspine.persona-context/v1", items: structuredClone(items), authority: "identity-state-only" };
581
+ }