blun-king-cli 9.1.564 → 9.1.566

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LIESMICH.txt +2 -2
  2. package/README.md +1 -1
  3. package/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
  4. package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
  5. package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
  6. package/agent-spine-plugin/.mcp.json +8 -8
  7. package/agent-spine-plugin/CONTRIBUTING.md +52 -0
  8. package/agent-spine-plugin/LICENSE +186 -186
  9. package/agent-spine-plugin/README.md +406 -0
  10. package/agent-spine-plugin/SECURITY.md +47 -0
  11. package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
  12. package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
  13. package/agent-spine-plugin/bin/agentspine.js +7 -7
  14. package/agent-spine-plugin/blun.plugin.json +33 -33
  15. package/agent-spine-plugin/hooks/codex.json +47 -47
  16. package/agent-spine-plugin/hooks/hooks.json +106 -106
  17. package/agent-spine-plugin/hooks/version.json +5 -5
  18. package/agent-spine-plugin/package.json +69 -69
  19. package/agent-spine-plugin/scripts/check-hosts.js +199 -199
  20. package/agent-spine-plugin/skill/SKILL.md +76 -76
  21. package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
  22. package/agent-spine-plugin/src/cli.js +1488 -1442
  23. package/agent-spine-plugin/src/hook.js +882 -861
  24. package/agent-spine-plugin/src/index.js +94 -93
  25. package/agent-spine-plugin/src/lib/acceptance.js +333 -333
  26. package/agent-spine-plugin/src/lib/attention.js +755 -755
  27. package/agent-spine-plugin/src/lib/audit.js +351 -342
  28. package/agent-spine-plugin/src/lib/authentication.js +515 -515
  29. package/agent-spine-plugin/src/lib/briefing.js +317 -317
  30. package/agent-spine-plugin/src/lib/catalog.js +167 -167
  31. package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
  32. package/agent-spine-plugin/src/lib/context.js +154 -154
  33. package/agent-spine-plugin/src/lib/continuity.js +338 -338
  34. package/agent-spine-plugin/src/lib/coordination.js +577 -577
  35. package/agent-spine-plugin/src/lib/documents.js +217 -217
  36. package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
  37. package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
  38. package/agent-spine-plugin/src/lib/gateway-runtime.js +1623 -760
  39. package/agent-spine-plugin/src/lib/graph.js +337 -337
  40. package/agent-spine-plugin/src/lib/hook-audit.js +29 -29
  41. package/agent-spine-plugin/src/lib/https-transport.js +392 -392
  42. package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
  43. package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
  44. package/agent-spine-plugin/src/lib/learning.js +6923 -6466
  45. package/agent-spine-plugin/src/lib/object-transport.js +206 -206
  46. package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
  47. package/agent-spine-plugin/src/lib/paths.js +109 -109
  48. package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
  49. package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
  50. package/agent-spine-plugin/src/lib/preflight.js +702 -702
  51. package/agent-spine-plugin/src/lib/runtime.js +13 -13
  52. package/agent-spine-plugin/src/lib/selfstarter.js +911 -866
  53. package/agent-spine-plugin/src/lib/sharing.js +969 -969
  54. package/agent-spine-plugin/src/lib/source-roots.js +530 -498
  55. package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
  56. package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
  57. package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
  58. package/agent-spine-plugin/src/mcp.js +597 -572
  59. package/agent-spine-plugin/src/version.js +1 -1
  60. package/agent-spine-plugin/src/worker.js +210 -195
  61. package/bin/active-steer-priority-policy.cjs +24 -0
  62. package/bin/launcher-runtime.js +8 -1
  63. package/bin/mnemo-tool-agent-policy.cjs +22 -0
  64. package/bin/native-runtime-cache.cjs +76 -0
  65. package/bin/thinking-activity-status-policy.cjs +132 -0
  66. package/bin/thinking-only-guard.cjs +75 -0
  67. package/bin/tool-call-loop-policy.cjs +53 -0
  68. package/bin/turn-thinking-policy.cjs +25 -1
  69. package/blun.mjs +556 -63
  70. package/package.json +4 -1
  71. package/standard-skills/translate-native/LICENSE +21 -21
  72. package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
  73. package/standard-skills/translate-native/references/native-orthography.md +79 -79
  74. package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
  75. package/standard-skills/translate-native/references/structured-content.md +72 -72
  76. package/standard-skills/translate-native/references/translationese-review.md +77 -77
  77. package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
  78. package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
  79. package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
  80. package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
  81. package/standard-skills/translate-native/scripts/language_quality.py +377 -377
  82. package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
  83. package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
  84. package/standard-tools/language-guard/blun_language_guard.py +697 -697
  85. package/standard-tools/language-guard/check_diacritics.py +353 -353
  86. package/standard-tools/language-guard/guard_service_client.py +264 -264
  87. package/standard-tools/language-guard/language_quality.py +377 -377
  88. package/standard-tools/language-guard/translation_guard.py +916 -916
@@ -1,861 +1,882 @@
1
- #!/usr/bin/env node
2
- import { createHash } from "node:crypto";
3
- import { isAbsolute, relative, resolve } from "node:path";
4
- import { recordAttentionEvent } from "./lib/attention.js";
5
- import { catalogForStateRoot, saveCatalog } from "./lib/catalog.js";
6
- import { loadGraph } from "./lib/graph.js";
7
- import { canonicalPath } from "./lib/paths.js";
8
- import { isInaccessibleScanError, resolveHostSourceCatalog } from "./lib/source-roots.js";
9
- import { sessionBriefing } from "./lib/briefing.js";
10
- import { captureContinuityPrompt, loadContinuity } from "./lib/continuity.js";
11
- import { recordLearningApplications, recordLearningDeliveries } from "./lib/learning.js";
12
- import {
13
- authorizeJobEffect, checkpointJobEffect, closeJobLease, resolveSessionJob, startOrResumeJob
14
- } from "./lib/selfstarter.js";
15
- import { claimChannelEvent } from "./lib/channel-runtime.js";
16
- import { syncPersonaRosterFromEnvironment } from "./lib/persona-runtime.js";
17
- import { captureMustRememberPrompt, recordPreflightFailure, runPreflight, verifyPreflightReceipt } from "./lib/preflight.js";
18
- import { isMainModule } from "./lib/runtime.js";
19
- import { recordHookScanAudit } from "./lib/hook-audit.js";
20
-
21
- const MAX_STDIN_BYTES = 64 * 1024;
22
- const STANDARD_HOST_CONTEXT_BYTES = 9500;
23
- const MAX_CLAUDE_OVERFLOW_CONTEXT_BYTES = 32 * 1024;
24
- const CONTEXT_EVENTS = new Set(["SessionStart", "UserPromptSubmit", "PreCompact", "PostCompact"]);
25
- const KNOWN_EVENTS = new Set([
26
- ...CONTEXT_EVENTS, "InstructionsLoaded", "PreToolUse", "PostToolUse", "Stop", "SubagentStop"
27
- ]);
28
- const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
29
- const ATTENTION_WRITE_EVENTS = new Set(["UserPromptSubmit", "PostToolUse", "Stop", "SubagentStop"]);
30
- const SELFSTART_EVENTS = new Set(["SessionStart", "PostCompact"]);
31
- const SILENT_OVERSIZE_POST_TOOL_USE = Symbol("silent-oversize-post-tool-use");
32
- const SILENT_OVERSIZE_POST_TOOL_USE_ARG = "--silent-oversize-post-tool-use";
33
-
34
- async function readStdin({ silentOversizePostToolUse = false } = {}) {
35
- const chunks = [];
36
- let bytes = 0;
37
- let oversized = false;
38
- for await (const chunk of process.stdin) {
39
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
40
- const remaining = Math.max(0, MAX_STDIN_BYTES - bytes);
41
- if (remaining) chunks.push(buffer.subarray(0, remaining));
42
- bytes += buffer.length;
43
- if (bytes > MAX_STDIN_BYTES) oversized = true;
44
- }
45
- if (oversized) {
46
- if (silentOversizePostToolUse) return SILENT_OVERSIZE_POST_TOOL_USE;
47
- throw new Error("hook input exceeds the 64 KiB limit");
48
- }
49
- const value = Buffer.concat(chunks, bytes).toString("utf8");
50
- const parsed = value.trim() ? JSON.parse(value) : {};
51
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("hook input must be one JSON object");
52
- return parsed;
53
- }
54
-
55
- function boundedId(value, field) {
56
- if (value === null || value === undefined || value === "") return null;
57
- if (typeof value !== "string" || !ID_RE.test(value)) throw new Error(`${field} is invalid`);
58
- return value;
59
- }
60
-
61
- function promptFromInput(input) {
62
- for (const key of ["prompt", "user_prompt", "message", "input"]) {
63
- const value = input[key];
64
- if (typeof value === "string") return value;
65
- if (Array.isArray(value)) {
66
- const text = value
67
- .filter((part) => part && typeof part === "object" && part.type === "text" && typeof part.text === "string")
68
- .map((part) => part.text)
69
- .join("\n")
70
- .trim();
71
- if (text) return text;
72
- }
73
- }
74
- return null;
75
- }
76
-
77
- function hostContextLimit(preflight) {
78
- return preflight?.receipt?.instructionBudget?.mode === "claude-required-overflow"
79
- ? MAX_CLAUDE_OVERFLOW_CONTEXT_BYTES
80
- : STANDARD_HOST_CONTEXT_BYTES;
81
- }
82
-
83
- function hostFromInput(input) {
84
- const explicit = input.host || input.provider || process.env.AGENTSPINE_HOST;
85
- if (["claude", "codex", "generic"].includes(explicit)) return explicit;
86
- if ((typeof input.model === "string" && input.model.trim()) || process.env.PLUGIN_ROOT || process.env.CODEX_HOME
87
- || process.env.BLUN_PLUGIN_ROOT || process.env.BLUN_HOME) return "codex";
88
- return "claude";
89
- }
90
-
91
- function gatewayEnvironmentContext(env = process.env) {
92
- if (env.AGENTSPINE_GATEWAY_CONTEXT !== "agentspine.gateway-start/v1") return null;
93
- return {
94
- entityId: boundedId(env.AGENTSPINE_ENTITY_ID, "AGENTSPINE_ENTITY_ID"),
95
- groupId: boundedId(env.AGENTSPINE_GROUP_ID, "AGENTSPINE_GROUP_ID"),
96
- projectId: boundedId(env.AGENTSPINE_PROJECT_ID, "AGENTSPINE_PROJECT_ID"),
97
- taskId: boundedId(env.AGENTSPINE_TASK_ID, "AGENTSPINE_TASK_ID"),
98
- eventId: boundedId(env.AGENTSPINE_CHANNEL_EVENT_ID, "AGENTSPINE_CHANNEL_EVENT_ID"),
99
- provider: boundedId(env.AGENTSPINE_CHANNEL_PROVIDER, "AGENTSPINE_CHANNEL_PROVIDER")
100
- };
101
- }
102
-
103
- async function runtimeScope(input, root, userStateRoot = null, catalog) {
104
- const { continuity: projectContinuity } = await loadContinuity(root, catalog);
105
- const userCatalog = userStateRoot && userStateRoot !== root
106
- ? catalogForStateRoot(catalog, userStateRoot) : catalog;
107
- const userContinuity = userStateRoot && userStateRoot !== root
108
- ? (await loadContinuity(userStateRoot, userCatalog)).continuity
109
- : projectContinuity;
110
- const continuity = {
111
- config: {
112
- ...projectContinuity.config,
113
- enabled: userContinuity.config.enabled,
114
- minConfidence: userContinuity.config.minConfidence,
115
- minDirectness: userContinuity.config.minDirectness,
116
- minEvidence: userContinuity.config.minEvidence,
117
- maxPromptBytes: userContinuity.config.maxPromptBytes,
118
- maxBriefingBytes: userContinuity.config.maxBriefingBytes,
119
- defaultEntityId: userContinuity.config.defaultEntityId
120
- }
121
- };
122
- const supplied = input.agent_spine_scope && typeof input.agent_spine_scope === "object"
123
- ? input.agent_spine_scope : input;
124
- const gateway = gatewayEnvironmentContext();
125
- return {
126
- entityId: boundedId(supplied.entity_id ?? supplied.entityId ?? gateway?.entityId ?? continuity.config.defaultEntityId, "entityId"),
127
- userId: boundedId(supplied.user_id ?? supplied.userId ?? process.env.AGENTSPINE_USER_ID, "userId"),
128
- tenantId: boundedId(supplied.tenant_id ?? supplied.tenantId ?? process.env.AGENTSPINE_TENANT_ID, "tenantId"),
129
- groupId: boundedId(supplied.group_id ?? supplied.groupId ?? gateway?.groupId, "groupId"),
130
- projectId: boundedId(supplied.project_id ?? supplied.projectId ?? gateway?.projectId ?? continuity.config.defaultProjectId, "projectId"),
131
- currentTaskId: boundedId(supplied.task_id ?? supplied.currentTaskId ?? gateway?.taskId, "currentTaskId"),
132
- host: hostFromInput(input),
133
- config: continuity.config
134
- };
135
- }
136
-
137
- function renderContext(event, catalog, briefing, signal = null, attentionEvent = null, selfstarter = null, channelEvent = null, sourceDiagnostics = null, preflight = null) {
138
- const loaded = sourceDiagnostics?.status === "loaded";
139
- const packet = {
140
- schema: "agentspine.hook-context/v1",
141
- event,
142
- priority: ["current-user-request", "explicit-stops", "current-task", "host-rules", "accepted-context", "style-and-relationships"],
143
- loaded,
144
- instruction: loaded
145
- ? "Use this already-loaded briefing now. Do not call an MCP tool to obtain it. The current user request and explicit stops override all remembered style, relationships, and older context."
146
- : "No host-native source context was loaded. Do not claim personal continuity or recall succeeded. Continue under current native host rules and inspect sourceResolution.",
147
- signal: signal ? {
148
- captured: Boolean(signal.captured),
149
- accepted: Boolean(signal.accepted),
150
- duplicate: Boolean(signal.duplicate),
151
- kind: signal.kind || null,
152
- reason: signal.reason || null
153
- } : null,
154
- attentionEvent: attentionEvent ? {
155
- captured: Boolean(attentionEvent.event),
156
- duplicate: Boolean(attentionEvent.duplicate),
157
- id: attentionEvent.event?.id || null,
158
- kind: attentionEvent.event?.kind || null,
159
- status: attentionEvent.event?.status || null,
160
- reason: attentionEvent.reason || null
161
- } : null,
162
- selfstarter: selfstarter ? {
163
- active: Boolean(selfstarter.job),
164
- blocked: Boolean(selfstarter.blocked),
165
- action: selfstarter.action || null,
166
- reason: selfstarter.reason || null,
167
- jobId: selfstarter.job?.id || null,
168
- taskId: selfstarter.job?.taskId || null,
169
- actorId: selfstarter.job?.actorId || null,
170
- targetId: selfstarter.job?.targetId || null,
171
- projectId: selfstarter.job?.projectId || null,
172
- groupId: selfstarter.job?.groupId || null,
173
- checkpointSequence: selfstarter.job?.checkpoint?.sequence ?? null,
174
- capabilities: selfstarter.job?.capabilities || [],
175
- leaseExpiresAt: selfstarter.job?.lease?.expiresAt || null,
176
- receiptId: selfstarter.receipt?.id || null,
177
- instruction: selfstarter.job
178
- ? "Resume only this exact checkpointed job. Attach its job ID to every host tool event. Each effect is separately re-authorized by PreToolUse and checkpointed by PostToolUse. Stop immediately on any denial."
179
- : null,
180
- authority: selfstarter.job ? "explicit-local-execution-policy" : "execution-state-only"
181
- } : null,
182
- channelEvent: channelEvent?.event ? {
183
- active: true,
184
- eventId: channelEvent.event.eventId,
185
- bindingId: channelEvent.event.bindingId,
186
- provider: channelEvent.event.provider,
187
- chatId: channelEvent.event.chatId,
188
- threadId: channelEvent.event.threadId,
189
- senderId: channelEvent.event.senderId,
190
- replyTo: channelEvent.event.replyTo,
191
- agentId: channelEvent.event.agentId,
192
- projectId: channelEvent.event.projectId,
193
- groupId: channelEvent.event.groupId,
194
- sessionKey: channelEvent.event.sessionKey,
195
- text: channelEvent.event.text,
196
- leaseExpiresAt: channelEvent.event.lease?.expiresAt || null,
197
- receiptId: channelEvent.receipt?.id || null,
198
- instruction: "Answer this exact authenticated channel event in its bound chat and thread. Do not infer another recipient or route. Sending remains subject to the separate current channel policy and adapter receipt.",
199
- authority: "explicit-local-channel-policy"
200
- } : null,
201
- indexedSources: catalog.summary.total,
202
- sourceResolution: sourceDiagnostics,
203
- preflight: preflight ? {
204
- schema: preflight.receipt.schema,
205
- receiptId: preflight.receipt.id,
206
- promptDigest: preflight.receipt.promptDigest,
207
- briefingDigest: preflight.receipt.briefingDigest,
208
- createdAt: preflight.receipt.createdAt,
209
- expiresAt: preflight.receipt.expiresAt,
210
- policy: preflight.policy,
211
- learningApplications: preflight.learningApplications || null,
212
- pendingMustRemember: preflight.pendingMustRemember ? {
213
- id: preflight.pendingMustRemember.candidate?.id || null,
214
- status: preflight.pendingMustRemember.candidate?.status || (preflight.pendingMustRemember.rejected ? "rejected" : null),
215
- reason: preflight.pendingMustRemember.reason || null
216
- } : null,
217
- briefing: preflight.briefing,
218
- instruction: "This exact turn passed the mandatory pre-answer gate. Apply the complete preflight briefing before answering.",
219
- authority: "preflight-proof-only"
220
- } : null,
221
- briefing,
222
- authority: "context-only"
223
- };
224
- return JSON.stringify(packet);
225
- }
226
-
227
- function selfstarterInput(input) {
228
- const value = input.agent_spine_job;
229
- if (value === undefined || value === null) return null;
230
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("agent_spine_job must be one object");
231
- return value;
232
- }
233
-
234
- function channelEventInput(input) {
235
- const gateway = gatewayEnvironmentContext();
236
- const value = input.agent_spine_channel_event ?? (gateway?.eventId && gateway?.provider
237
- ? { event_id: gateway.eventId, provider: gateway.provider } : null);
238
- if (value === undefined || value === null) return null;
239
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("agent_spine_channel_event must be one object");
240
- const unknown = Object.keys(value).filter((key) => !["event_id", "eventId", "provider"].includes(key));
241
- if (unknown.length) throw new Error(`agent_spine_channel_event contains unknown field: ${unknown.sort()[0]}`);
242
- return value;
243
- }
244
-
245
- function hookDeliveryId(input) {
246
- return boundedId(input.tool_use_id ?? input.event_id ?? input.hook_event_id, "toolUseId");
247
- }
248
-
249
- function sessionId(input) {
250
- return boundedId(input.session_id ?? input.sessionId, "sessionId");
251
- }
252
-
253
- async function startSelfstarter(input, event, root, scope, sourceDiagnostics) {
254
- if (!SELFSTART_EVENTS.has(event)) return null;
255
- const requested = selfstarterInput(input);
256
- if (["skipped-unmarked-home", "skipped-home-root"].includes(sourceDiagnostics?.projectTreeScan)) {
257
- if (requested) throw new Error("self-starter cannot use a user home as its workspace root");
258
- return null;
259
- }
260
- const session = sessionId(input);
261
- if (!scope.entityId || !scope.projectId || !session) {
262
- if (requested) throw new Error("self-starter start requires an exact actor, project, and host session");
263
- return null;
264
- }
265
- return startOrResumeJob({
266
- root, actorId: scope.entityId, projectId: scope.projectId, groupId: scope.groupId,
267
- taskId: scope.currentTaskId, jobId: boundedId(requested?.job_id ?? requested?.jobId, "jobId"),
268
- host: scope.host, sessionId: session, now: input.timestamp || new Date()
269
- });
270
- }
271
-
272
- async function startChannelEvent(input, event, root, scope, catalog) {
273
- const requested = channelEventInput(input);
274
- if (!requested) return null;
275
- if (event !== "SessionStart") throw new Error("channel event claims are accepted only at SessionStart");
276
- const session = sessionId(input);
277
- if (!scope.entityId || !scope.projectId || !session) {
278
- throw new Error("channel event start requires an exact agent, project, and host session");
279
- }
280
- const workerId = `channel-worker:${createHash("sha256").update(`${scope.host}\0${session}`).digest("hex").slice(0, 24)}`;
281
- const claim = await claimChannelEvent({
282
- root, eventId: boundedId(requested.event_id ?? requested.eventId, "channelEventId"),
283
- agentId: scope.entityId, projectId: scope.projectId, groupId: scope.groupId,
284
- provider: boundedId(requested.provider, "channelProvider"), workerId,
285
- now: input.timestamp || new Date(), catalog
286
- });
287
- if (!claim.event) throw new Error("the exact channel event is unavailable in this agent lane");
288
- return claim;
289
- }
290
-
291
- async function selfstarterScope(input, scope, root, action) {
292
- const requested = selfstarterInput(input);
293
- const hostSession = sessionId(input);
294
- const supplied = {
295
- actorId: scope.entityId, projectId: scope.projectId, groupId: scope.groupId,
296
- taskId: scope.currentTaskId, host: scope.host, sessionId: hostSession
297
- };
298
- if (requested) return { ...supplied, jobId: boundedId(requested.job_id ?? requested.jobId, "jobId") };
299
- if (!hostSession) return null;
300
- return resolveSessionJob({ root, ...supplied, action, now: input.timestamp || new Date() });
301
- }
302
-
303
- function toolSucceeded(input) {
304
- if (input.success === false || input.is_error === true || input.tool_error) return false;
305
- if (input.tool_result && typeof input.tool_result === "object" && input.tool_result.isError === true) return false;
306
- return true;
307
- }
308
-
309
- function toolResult(input) {
310
- return input.tool_result ?? input.tool_response ?? input.result ?? (input.tool_error ? { failed: true } : null);
311
- }
312
-
313
- function eventReceipt(input, event, scope, discriminator = "lifecycle") {
314
- const supplied = input.event_id ?? input.hook_event_id ?? input.tool_use_id ?? input.session_id;
315
- if (typeof supplied === "string" && ID_RE.test(supplied)) {
316
- return `receipt:${createHash("sha256").update(`${event}\0${supplied}\0${discriminator}`).digest("hex").slice(0, 24)}`;
317
- }
318
- const material = [event, scope.host, scope.entityId, scope.projectId, scope.currentTaskId, discriminator].join("\0");
319
- return `receipt:${createHash("sha256").update(material).digest("hex").slice(0, 24)}`;
320
- }
321
-
322
- function heartbeatReceipt(input, scope) {
323
- const at = new Date(input.timestamp || Date.now());
324
- if (!Number.isFinite(at.getTime())) throw new Error("heartbeat timestamp is invalid");
325
- const minute = at.toISOString().slice(0, 16);
326
- const material = [scope.host, scope.entityId, scope.groupId, scope.projectId, scope.currentTaskId, minute].join("\0");
327
- return `receipt:heartbeat:${createHash("sha256").update(material).digest("hex").slice(0, 20)}`;
328
- }
329
-
330
- function minimalAttentionSignal(prompt) {
331
- if (typeof prompt !== "string" || Buffer.byteLength(prompt) > 16384) return null;
332
- const rules = [
333
- { kind: "promise", re: /^(?:i promise(?: to)?|i will|ich verspreche(?:,)?|ich werde|prometo(?: que)?|voy a|jag lovar att|jag kommer att)\s+(.+)$/i, prefix: "Promise: " },
334
- { kind: "blocker", re: /^(?:blocker|blocked(?: by)?|i am blocked(?: by)?|ich bin blockiert(?: durch)?|blockiert durch|bloquead[oa](?: por)?|estoy bloquead[oa](?: por)?|blockerad(?: av)?|jag är blockerad(?: av)?)\s*[:,-]?\s*(.+)$/i, prefix: "Blocker: " }
335
- ];
336
- for (const rule of rules) {
337
- const match = prompt.trim().match(rule.re);
338
- if (!match) continue;
339
- const value = match[1].trim().replace(/\s+/g, " ").replace(/[.!?]+$/, "").slice(0, 220);
340
- if (value) return { kind: rule.kind, summary: `${rule.prefix}${value}` };
341
- }
342
- return null;
343
- }
344
-
345
- async function captureAttentionLifecycle(input, event, root, scope, catalog) {
346
- if (!ATTENTION_WRITE_EVENTS.has(event)) return null;
347
- const explicit = input.agent_spine_attention && typeof input.agent_spine_attention === "object"
348
- && !Array.isArray(input.agent_spine_attention) ? input.agent_spine_attention : null;
349
- let proposed = explicit;
350
- let automaticHeartbeat = false;
351
- if (!proposed && event === "UserPromptSubmit" && scope.config.enabled) {
352
- proposed = minimalAttentionSignal(promptFromInput(input));
353
- if (proposed && scope.groupId) return { event: null, duplicate: false, reason: "rejected:group conversation events are never learned automatically" };
354
- }
355
- if (!proposed && event === "PostToolUse" && scope.currentTaskId) {
356
- proposed = { kind: "heartbeat", summary: "Work heartbeat recorded.", status: "active" };
357
- automaticHeartbeat = true;
358
- }
359
- if (!proposed && ["Stop", "SubagentStop"].includes(event) && scope.currentTaskId) {
360
- proposed = { kind: "heartbeat", summary: "Work heartbeat recorded.", status: "stopped" };
361
- }
362
- if (!proposed) return null;
363
- if (event === "PostToolUse" && ![input.event_id, input.hook_event_id, input.tool_use_id].some((value) => typeof value === "string" && ID_RE.test(value))) {
364
- throw new Error("PostToolUse attention requires a stable host delivery ID");
365
- }
366
- if (!scope.entityId || !scope.projectId || !scope.currentTaskId) {
367
- if (explicit) throw new Error("lifecycle attention event is missing exact actor, project, or task scope");
368
- return { event: null, duplicate: false, reason: "missing-exact-scope" };
369
- }
370
- const privacy = proposed.privacy || "private";
371
- if (event === "UserPromptSubmit" && privacy === "group") {
372
- return { event: null, duplicate: false, reason: "rejected:group conversation events are never learned automatically" };
373
- }
374
- const discriminator = proposed.id || `${proposed.kind}:${proposed.status || ""}:${proposed.summary}`;
375
- return recordAttentionEvent({
376
- root,
377
- id: boundedId(proposed.id, "attentionEventId"),
378
- kind: proposed.kind,
379
- summary: proposed.summary,
380
- status: proposed.status || null,
381
- entityId: scope.entityId,
382
- groupId: privacy === "group" ? scope.groupId : null,
383
- projectId: scope.projectId,
384
- taskId: scope.currentTaskId,
385
- privacy,
386
- dueAt: proposed.due_at ?? proposed.dueAt ?? null,
387
- receiptId: automaticHeartbeat ? heartbeatReceipt(input, scope) : eventReceipt(input, event, scope, discriminator),
388
- host: scope.host,
389
- hookEvent: event,
390
- observedAt: input.timestamp || new Date(), catalog
391
- });
392
- }
393
-
394
- export function blunRuntimeContext(context) {
395
- const detailed = JSON.parse(context);
396
- const sourceResolution = detailed.sourceResolution ? {
397
- status: detailed.sourceResolution.status || null,
398
- reason: detailed.sourceResolution.reason || null
399
- } : null;
400
- const runtime = {
401
- schema: "agentspine.blun-runtime-context/v1",
402
- event: detailed.event,
403
- loaded: Boolean(detailed.loaded),
404
- failedClosed: detailed.failedClosed ? true : undefined,
405
- indexedSources: detailed.indexedSources || 0,
406
- sourceResolution,
407
- instruction: detailed.loaded
408
- ? "Detailed AgentSpine context is available on demand through session_briefing. Load it only when the current request needs continuity."
409
- : detailed.instruction,
410
- authority: "context-only"
411
- };
412
- if (detailed.signal && (detailed.signal.captured || detailed.signal.accepted || detailed.signal.reason)) {
413
- runtime.signal = detailed.signal;
414
- }
415
- if (detailed.attentionEvent
416
- && (detailed.attentionEvent.captured || detailed.attentionEvent.duplicate || detailed.attentionEvent.reason)) {
417
- runtime.attentionEvent = detailed.attentionEvent;
418
- }
419
- if (detailed.selfstarter && (detailed.selfstarter.active || detailed.selfstarter.blocked)) {
420
- runtime.selfstarter = detailed.selfstarter;
421
- }
422
- if (detailed.channelEvent?.active) runtime.channelEvent = detailed.channelEvent;
423
- return JSON.stringify(runtime);
424
- }
425
-
426
- export function blunRuntimeMessage(context) {
427
- const runtime = JSON.parse(blunRuntimeContext(context));
428
- const base = runtime.loaded
429
- ? `AgentSpine ready: ${runtime.indexedSources} sources indexed. Load detailed continuity only on demand through session_briefing.`
430
- : `AgentSpine unavailable${runtime.sourceResolution?.reason ? `: ${runtime.sourceResolution.reason}` : ""}. ${runtime.instruction}`;
431
- const active = {};
432
- if (runtime.signal && (runtime.signal.captured || runtime.signal.accepted
433
- || String(runtime.signal.reason || "").startsWith("rejected:"))) {
434
- active.signal = runtime.signal;
435
- }
436
- if (runtime.attentionEvent && (runtime.attentionEvent.captured || runtime.attentionEvent.duplicate
437
- || String(runtime.attentionEvent.reason || "").startsWith("rejected:"))) {
438
- active.attentionEvent = runtime.attentionEvent;
439
- }
440
- if (runtime.selfstarter) active.selfstarter = runtime.selfstarter;
441
- if (runtime.channelEvent) active.channelEvent = runtime.channelEvent;
442
- return Object.keys(active).length === 0
443
- ? base
444
- : `${base}\nActive AgentSpine runtime data: ${JSON.stringify(active)}`;
445
- }
446
-
447
- function hookOutput(event, context) {
448
- if (process.env.BLUN_PLUGIN_ROOT) {
449
- return { hookSpecificOutput: { hookEventName: event, message: blunRuntimeMessage(context) } };
450
- }
451
- return { hookSpecificOutput: { hookEventName: event, additionalContext: context } };
452
- }
453
-
454
- function candidatePaths(value, output = []) {
455
- if (!value) return output;
456
- if (typeof value === "string") {
457
- for (const match of value.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)) output.push(match[1].trim());
458
- return output;
459
- }
460
- if (Array.isArray(value)) {
461
- for (const item of value) candidatePaths(item, output);
462
- return output;
463
- }
464
- for (const [key, item] of Object.entries(value)) {
465
- if (["path", "file_path", "target_file", "filename"].includes(key) && typeof item === "string") output.push(item);
466
- else candidatePaths(item, output);
467
- }
468
- return output;
469
- }
470
-
471
- function isMutationTool(name = "") {
472
- return /(^|__)(apply_patch|edit|write|delete|move|rename|bash|exec_command|shell)(_|$)/i.test(name);
473
- }
474
-
475
- function filesystemScanError(error) {
476
- return Boolean(error && (isInaccessibleScanError(error)
477
- || error.code === "AGENTSPINE_SCAN_INCOMPLETE" || error.agentSpineScan === true));
478
- }
479
-
480
- async function auditSkippedScans(input, phase, skipped = []) {
481
- for (const item of skipped) {
482
- await recordHookScanAudit({
483
- event: "PreToolUse", toolName: input.tool_name || null, phase,
484
- error: { code: item.code, message: `${item.code}: ${item.kind || item.operation || "scan"} skipped ${item.relativePath || item.path}` },
485
- path: item.path || item.relativePath, operation: item.operation || item.kind,
486
- now: input.timestamp || new Date()
487
- });
488
- }
489
- }
490
-
491
- async function allowScanFailure(input, phase, error) {
492
- await recordHookScanAudit({
493
- event: "PreToolUse", toolName: input.tool_name || null, phase, error,
494
- path: error?.path || input.cwd || process.cwd(), now: input.timestamp || new Date()
495
- });
496
- return { blocked: false, degraded: true, scanFailedOpen: true, phase, error: error.message };
497
- }
498
-
499
- function stringValues(value, output = []) {
500
- if (typeof value === "string") output.push(value);
501
- else if (Array.isArray(value)) value.forEach((item) => stringValues(item, output));
502
- else if (value && typeof value === "object") Object.values(value).forEach((item) => stringValues(item, output));
503
- return output;
504
- }
505
-
506
- function shellTargetsProtected(input, documents, cwd, root) {
507
- if (!/(bash|exec_command|shell)/i.test(input.tool_name || "")) return null;
508
- const command = stringValues(input.tool_input || input.tool_args).join("\n").replaceAll("\\", "/");
509
- if (!/(?:^|[;&|\s])(?:rm|mv|cp|truncate|tee|sed\s+-i|perl\s+-i)\b|(?:^|[^>])>{1,2}(?!>)/i.test(command)) return null;
510
- for (const document of documents) {
511
- const forms = new Set([document.path.replaceAll("\\", "/"), document.relativePath]);
512
- for (const base of [cwd, root]) {
513
- const candidate = relative(base, document.path);
514
- if (candidate && !candidate.startsWith("..") && !isAbsolute(candidate)) forms.add(candidate.replaceAll("\\", "/"));
515
- }
516
- if ([...forms].some((form) => form && command.includes(form))) return document;
517
- }
518
- return null;
519
- }
520
-
521
- function deny(reason) {
522
- process.stdout.write(`${JSON.stringify({
523
- decision: "block",
524
- reason,
525
- hookSpecificOutput: {
526
- hookEventName: "PreToolUse",
527
- permissionDecision: "deny",
528
- permissionDecisionReason: reason
529
- }
530
- })}\n`);
531
- }
532
-
533
- function blockPrompt(reason) {
534
- process.stdout.write(`${JSON.stringify({
535
- decision: "block",
536
- reason,
537
- hookSpecificOutput: { hookEventName: "UserPromptSubmit", decision: "block", reason }
538
- })}\n`);
539
- }
540
-
541
- export async function runHook(payload = null, options = {}) {
542
- const input = payload || await readStdin(options);
543
- if (input === SILENT_OVERSIZE_POST_TOOL_USE) return;
544
- if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("hook input must be one JSON object");
545
- const event = input.hook_event_name || input.event_name || "";
546
- if (!KNOWN_EVENTS.has(event)) throw new Error(`unsupported hook event: ${event || "missing"}`);
547
- if (event === "InstructionsLoaded") {
548
- const file = input.file_path;
549
- if (typeof file !== "string" || !file || !["User", "Project", "Local", "Managed"].includes(input.memory_type)
550
- || !["session_start", "nested_traversal", "path_glob_match", "include", "compact"].includes(input.load_reason)) {
551
- throw new Error("InstructionsLoaded payload is invalid");
552
- }
553
- if (payload) return { blocked: false, observed: true };
554
- process.stdout.write("{}\n");
555
- return;
556
- }
557
- const cwd = await canonicalPath(input.cwd || process.cwd());
558
- const host = hostFromInput(input);
559
- const instructionHost = host === "generic" ? input.instruction_host : host;
560
- if (host === "generic" && !["claude", "codex"].includes(instructionHost)) {
561
- const reason = "AgentSpine generic hosts must bind instruction_host to claude or codex";
562
- if (event === "UserPromptSubmit") {
563
- if (payload) return { blocked: true, failedClosed: true, reason };
564
- blockPrompt(reason);
565
- return;
566
- }
567
- throw new Error(reason);
568
- }
569
- let resolvedSources;
570
- try {
571
- resolvedSources = await resolveHostSourceCatalog({ host: instructionHost, cwd, input });
572
- } catch (error) {
573
- const reason = `AgentSpine source resolution failed closed: ${error.message}`;
574
- if (event === "PreToolUse" && isMutationTool(input.tool_name) && filesystemScanError(error)) {
575
- const allowed = await allowScanFailure(input, "source-resolution", error);
576
- const context = JSON.stringify({
577
- schema: "agentspine.hook-context/v1", event, loaded: false, failedClosed: false,
578
- indexedSources: 0,
579
- sourceResolution: { status: "degraded", reason: error.message, auditFinding: "inaccessible-source-scan" },
580
- instruction: "The filesystem scan was incomplete. The requested tool remains governed by native host permissions.",
581
- authority: "context-only"
582
- });
583
- if (payload) return { ...allowed, context };
584
- process.stdout.write("{}\n");
585
- return;
586
- }
587
- if (event === "PreToolUse" && isMutationTool(input.tool_name)) {
588
- if (payload) return { blocked: true, failedClosed: true, reason };
589
- deny(reason);
590
- return;
591
- }
592
- if (event === "UserPromptSubmit") {
593
- if (payload) return { blocked: true, failedClosed: true, reason };
594
- blockPrompt(reason);
595
- return;
596
- }
597
- const context = JSON.stringify({
598
- schema: "agentspine.hook-context/v1", event, loaded: false, failedClosed: true,
599
- indexedSources: 0, sourceResolution: { status: "failed-closed", reason: error.message },
600
- instruction: "Do not claim AgentSpine recall succeeded. Continue under current native host rules; no remembered context or automatic effect was applied.",
601
- authority: "context-only"
602
- });
603
- if (payload) return { blocked: false, failedClosed: true, context, error: error.message };
604
- if (CONTEXT_EVENTS.has(event)) process.stdout.write(`${JSON.stringify(hookOutput(event, context))}\n`);
605
- else process.stdout.write("{}\n");
606
- return;
607
- }
608
- const root = resolvedSources.projectRoot;
609
- const catalog = resolvedSources.catalog;
610
- const catalogPath = await saveCatalog(catalog);
611
- let scope = null;
612
- let selfstarter = null;
613
- let channelEvent = null;
614
- let learningDelivery = null;
615
-
616
- if (event === "PreToolUse" && isMutationTool(input.tool_name)
617
- && resolvedSources.diagnostics.skippedInaccessibleDirectories?.length) {
618
- await auditSkippedScans(input, "source-resolution", resolvedSources.diagnostics.skippedInaccessibleDirectories);
619
- }
620
-
621
- if (event === "PreToolUse" && isMutationTool(input.tool_name)) {
622
- const { graph } = await loadGraph(root, catalog);
623
- const inferredProtected = new Set(graph.annotations
624
- .filter((annotation) => ["constitution", "soul", "memory-index", "memory-fact"].includes(annotation.layer))
625
- .map((annotation) => annotation.path));
626
- const protectedRelative = new Set(catalog.documents
627
- .filter((doc) => doc.protected || inferredProtected.has(doc.relativePath))
628
- .map((doc) => doc.relativePath));
629
- let expanded = true;
630
- while (expanded) {
631
- expanded = false;
632
- for (const edge of graph.edges) {
633
- if (protectedRelative.has(edge.from) && !protectedRelative.has(edge.to)) {
634
- protectedRelative.add(edge.to);
635
- expanded = true;
636
- }
637
- }
638
- }
639
- const protectedDocuments = catalog.documents.filter((doc) => protectedRelative.has(doc.relativePath));
640
- const protectedPaths = new Set(protectedDocuments.map((doc) => resolve(doc.path)));
641
- const targets = await Promise.all(candidatePaths(input.tool_input || input.tool_args).map(async (path) => {
642
- const target = resolve(cwd, path);
643
- try { return await canonicalPath(target); } catch (error) {
644
- if (error.code !== "ENOENT") throw error;
645
- return target;
646
- }
647
- }));
648
- const hit = targets.find((path) => protectedPaths.has(path));
649
- const shellHit = shellTargetsProtected(input, protectedDocuments, cwd, root);
650
- if (hit || shellHit) {
651
- const relativePath = shellHit?.relativePath || catalog.documents.find((doc) => resolve(doc.path) === hit)?.relativePath || hit;
652
- const reason = `AgentSpine protected source: ${relativePath}. Existing identity, rule, soul, and memory documents are read-only to agents.`;
653
- if (payload) return { blocked: true, reason };
654
- deny(reason);
655
- return;
656
- }
657
- }
658
-
659
- if (event === "PreToolUse") {
660
- try {
661
- scope ||= await runtimeScope(input, root, resolvedSources.userStateRoot, catalog);
662
- const exact = await selfstarterScope(input, scope, root, "effect");
663
- if (exact) {
664
- selfstarter = await authorizeJobEffect({
665
- root, ...exact, toolName: input.tool_name, toolUseId: hookDeliveryId(input),
666
- now: input.timestamp || new Date()
667
- });
668
- }
669
- } catch (error) {
670
- if (isMutationTool(input.tool_name) && filesystemScanError(error)) {
671
- const allowed = await allowScanFailure(input, "self-starter", error);
672
- if (payload) return allowed;
673
- process.stdout.write("{}\n");
674
- return;
675
- }
676
- const reason = `AgentSpine self-starter denied this effect: ${error.message}`;
677
- if (payload) return { blocked: true, reason, selfstarter: { allowed: false, reason: error.message } };
678
- deny(reason);
679
- return;
680
- }
681
- }
682
-
683
- let attentionEvent = null;
684
- if (ATTENTION_WRITE_EVENTS.has(event) && !CONTEXT_EVENTS.has(event)) {
685
- scope = await runtimeScope(input, root, resolvedSources.userStateRoot, catalog);
686
- attentionEvent = await captureAttentionLifecycle(input, event, root, scope, catalog);
687
- }
688
-
689
- if (event === "PostToolUse") {
690
- scope ||= await runtimeScope(input, root, resolvedSources.userStateRoot, catalog);
691
- const exact = await selfstarterScope(input, scope, root, "effect");
692
- if (exact) {
693
- selfstarter = await checkpointJobEffect({
694
- root, ...exact, toolName: input.tool_name, toolUseId: hookDeliveryId(input),
695
- success: toolSucceeded(input), result: toolResult(input), now: input.timestamp || new Date()
696
- });
697
- }
698
- }
699
-
700
- if (["Stop", "SubagentStop"].includes(event)) {
701
- scope ||= await runtimeScope(input, root, resolvedSources.userStateRoot, catalog);
702
- const exact = await selfstarterScope(input, scope, root, "resume");
703
- if (exact && !scope.currentTaskId) scope.currentTaskId = exact.taskId;
704
- try {
705
- learningDelivery = await recordLearningDeliveries({
706
- root, sessionId: sessionId(input), hookEvent: event,
707
- scope: {
708
- personaId: scope.entityId, userId: scope.userId, tenantId: scope.tenantId,
709
- projectId: scope.projectId, groupId: scope.groupId, taskId: scope.currentTaskId
710
- },
711
- completedAt: input.timestamp || new Date()
712
- });
713
- } catch (error) {
714
- learningDelivery = { status: "degraded", receipts: [], reason: error.message, authority: "context-only" };
715
- }
716
- const requested = selfstarterInput(input);
717
- if (exact) {
718
- selfstarter = await closeJobLease({
719
- root, ...exact, status: requested?.status || "waiting", now: input.timestamp || new Date()
720
- });
721
- }
722
- }
723
-
724
- if (CONTEXT_EVENTS.has(event)) {
725
- let signal = null;
726
- let preflight = null;
727
- try {
728
- await syncPersonaRosterFromEnvironment({ root, env: process.env, now: input.timestamp || new Date(), catalog });
729
- scope ||= await runtimeScope(input, root, resolvedSources.userStateRoot, catalog);
730
- channelEvent = await startChannelEvent(input, event, root, scope, catalog);
731
- selfstarter = await startSelfstarter(input, event, root, scope, resolvedSources.diagnostics);
732
- if (selfstarter?.job && !scope.currentTaskId) scope.currentTaskId = selfstarter.job.taskId;
733
- if (event === "UserPromptSubmit") {
734
- const prompt = promptFromInput(input);
735
- if (prompt === null) throw new Error("mandatory preflight requires the exact current prompt");
736
- preflight = await runPreflight({
737
- input, scope, resolvedSources, prompt, now: input.timestamp || new Date(), env: process.env
738
- });
739
- if (!await verifyPreflightReceipt({
740
- receipt: preflight.receipt, input, scope, resolvedSources, prompt,
741
- now: input.timestamp || new Date(), env: process.env
742
- })) throw new Error("newly created preflight receipt failed exact turn verification");
743
- preflight.pendingMustRemember = await captureMustRememberPrompt({ prompt, receipt: preflight.receipt, env: process.env });
744
- try {
745
- attentionEvent = await captureAttentionLifecycle(input, event, root, scope, catalog);
746
- } catch (error) {
747
- attentionEvent = { event: null, duplicate: false, reason: `rejected:${error.message}` };
748
- }
749
- if (prompt !== null) {
750
- try {
751
- signal = await captureContinuityPrompt({
752
- root, prompt, entityId: scope.entityId, groupId: scope.groupId,
753
- projectId: scope.projectId, userStateRoot: resolvedSources.userStateRoot,
754
- eventId: boundedId(input.event_id ?? input.hook_event_id, "eventId"), catalog,
755
- userCatalog: resolvedSources.userStateRoot && resolvedSources.userStateRoot !== root
756
- ? catalogForStateRoot(catalog, resolvedSources.userStateRoot) : catalog
757
- });
758
- } catch (error) {
759
- signal = { captured: false, accepted: false, reason: `rejected:${error.message}` };
760
- }
761
- }
762
- }
763
- const briefing = await sessionBriefing({
764
- root, cwd, host: scope.host, entityId: scope.entityId, groupId: scope.groupId,
765
- userId: scope.userId, tenantId: scope.tenantId, projectId: scope.projectId, currentTaskId: scope.currentTaskId,
766
- includePrivate: Boolean(scope.entityId && !scope.groupId),
767
- focusActive: true, includeSourceContent: event === "UserPromptSubmit" ? false : !scope.groupId,
768
- maxBytes: event === "UserPromptSubmit" ? 4096 : scope.config.maxBriefingBytes,
769
- now: input.timestamp || new Date(),
770
- catalog, userStateRoot: resolvedSources.userStateRoot, sourceDiagnostics: resolvedSources.diagnostics,
771
- prompt: event === "UserPromptSubmit" ? promptFromInput(input) : null
772
- });
773
- let context = renderContext(event, catalog, briefing, signal, attentionEvent, selfstarter, channelEvent, resolvedSources.diagnostics, preflight);
774
- if (event === "UserPromptSubmit" && Buffer.byteLength(context) > hostContextLimit(preflight)) {
775
- throw new Error("mandatory preflight context exceeds the host hook injection limit");
776
- }
777
- if (event === "UserPromptSubmit" && !await verifyPreflightReceipt({
778
- receipt: preflight.receipt, input, scope, resolvedSources, prompt: promptFromInput(input),
779
- now: input.timestamp || new Date(), env: process.env, consume: true
780
- })) throw new Error("preflight receipt could not be consumed atomically for this exact turn");
781
- if (event === "UserPromptSubmit") {
782
- const activeCanaries = briefing.learning.filter((item) => ["active", "revalidating"].includes(item.outcomeStatus));
783
- if (!activeCanaries.length) {
784
- preflight.learningApplications = {
785
- status: "not-applicable", receipts: [], authority: "context-only"
786
- };
787
- } else try {
788
- const application = await recordLearningApplications({
789
- root, items: briefing.learning,
790
- scope: {
791
- personaId: scope.entityId, userId: scope.userId, tenantId: scope.tenantId,
792
- projectId: scope.projectId, groupId: scope.groupId, taskId: scope.currentTaskId
793
- },
794
- preflightReceipt: preflight.receipt,
795
- sessionBriefingDigest: createHash("sha256").update(JSON.stringify(briefing)).digest("hex"),
796
- projectedAt: input.timestamp || new Date()
797
- });
798
- preflight.learningApplications = {
799
- status: application.receipts.length ? "recorded" : "not-applicable",
800
- receipts: application.receipts.map((item) => ({
801
- id: item.id, learningId: item.learningId, projectedAt: item.projectedAt, expiresAt: item.expiresAt
802
- })),
803
- authority: "context-only"
804
- };
805
- } catch (error) {
806
- briefing.learning = briefing.learning.filter((item) => !["active", "revalidating"].includes(item.outcomeStatus));
807
- preflight.learningApplications = {
808
- status: "degraded", receipts: [], reason: error.message, authority: "context-only"
809
- };
810
- }
811
- const enriched = renderContext(event, catalog, briefing, signal, attentionEvent, selfstarter, channelEvent,
812
- resolvedSources.diagnostics, preflight);
813
- if (Buffer.byteLength(enriched) <= hostContextLimit(preflight)) context = enriched;
814
- else if (preflight.learningApplications.status === "degraded") {
815
- preflight.learningApplications = null;
816
- context = renderContext(event, catalog, briefing, signal, attentionEvent, selfstarter, channelEvent,
817
- resolvedSources.diagnostics, preflight);
818
- }
819
- }
820
- if (payload) return { blocked: false, context, briefing, preflight, signal, attentionEvent, channelEvent, catalogPath };
821
- process.stdout.write(`${JSON.stringify(hookOutput(event, context))}\n`);
822
- return;
823
- } catch (error) {
824
- if (event === "UserPromptSubmit") {
825
- await recordPreflightFailure({ receiptId: preflight?.receipt?.id || null, input, host,
826
- error, now: input.timestamp || new Date(), env: process.env }).catch(() => {});
827
- const reason = `AgentSpine pre-answer preflight blocked this turn: ${error.message}`;
828
- if (payload) return { blocked: true, failedClosed: true, reason, error: error.message, catalogPath };
829
- blockPrompt(reason);
830
- return;
831
- }
832
- const context = JSON.stringify({
833
- schema: "agentspine.hook-context/v1", event, loaded: false, failedClosed: true,
834
- indexedSources: catalog.summary.total,
835
- sourceResolution: resolvedSources.diagnostics,
836
- error: error.message,
837
- instruction: "Do not claim AgentSpine recall succeeded. Continue with the current request under native host rules and run agentspine audit before using remembered context.",
838
- authority: "context-only"
839
- });
840
- if (payload) return { blocked: false, failedClosed: true, context, error: error.message, catalogPath };
841
- process.stdout.write(`${JSON.stringify(hookOutput(event, context))}\n`);
842
- return;
843
- }
844
- }
845
-
846
- if (payload) return { blocked: false, attentionEvent, selfstarter, learningDelivery };
847
- process.stdout.write("{}\n");
848
- }
849
-
850
- if (isMainModule(import.meta.url)) {
851
- const args = process.argv.slice(2);
852
- const silentOversizePostToolUse = args.length === 1 && args[0] === SILENT_OVERSIZE_POST_TOOL_USE_ARG;
853
- const argumentError = args.length && !silentOversizePostToolUse
854
- ? new Error(`unsupported hook argument: ${args[0]}`) : null;
855
- (argumentError ? Promise.reject(argumentError) : runHook(null, { silentOversizePostToolUse })).catch((error) => {
856
- process.stderr.write(`AgentSpine hook: ${String(error.message).slice(0, 2048)}\n`);
857
- // Claude command hooks treat exit 2 as a blocking failure. Exit 1 is
858
- // fail-open, which is unsafe for malformed pre-answer payloads.
859
- process.exitCode = 2;
860
- });
861
- }
1
+ #!/usr/bin/env node
2
+ import { createHash } from "node:crypto";
3
+ import { isAbsolute, relative, resolve } from "node:path";
4
+ import { recordAttentionEvent } from "./lib/attention.js";
5
+ import { catalogForStateRoot, saveCatalog } from "./lib/catalog.js";
6
+ import { loadGraph } from "./lib/graph.js";
7
+ import { canonicalPath } from "./lib/paths.js";
8
+ import { resolveHostSourceCatalog } from "./lib/source-roots.js";
9
+ import { sessionBriefing } from "./lib/briefing.js";
10
+ import { captureContinuityPrompt, loadContinuity } from "./lib/continuity.js";
11
+ import { recordLearningApplications, recordLearningDeliveries } from "./lib/learning.js";
12
+ import {
13
+ authorizeJobEffect, checkpointJobEffect, closeJobLease, resolveSessionJob, startOrResumeJob
14
+ } from "./lib/selfstarter.js";
15
+ import { claimChannelEvent } from "./lib/channel-runtime.js";
16
+ import { syncPersonaRosterFromEnvironment } from "./lib/persona-runtime.js";
17
+ import { captureMustRememberPrompt, recordPreflightFailure, runPreflight, verifyPreflightReceipt } from "./lib/preflight.js";
18
+ import { isMainModule } from "./lib/runtime.js";
19
+ import { recordHookScanAudit } from "./lib/hook-audit.js";
20
+
21
+ const MAX_STDIN_BYTES = 64 * 1024;
22
+ const STANDARD_HOST_CONTEXT_BYTES = 9500;
23
+ const MAX_CLAUDE_OVERFLOW_CONTEXT_BYTES = 32 * 1024;
24
+ const CONTEXT_EVENTS = new Set(["SessionStart", "UserPromptSubmit", "PreCompact", "PostCompact"]);
25
+ const KNOWN_EVENTS = new Set([
26
+ ...CONTEXT_EVENTS, "InstructionsLoaded", "PreToolUse", "PostToolUse", "Stop", "SubagentStop"
27
+ ]);
28
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
29
+ const ATTENTION_WRITE_EVENTS = new Set(["UserPromptSubmit", "PostToolUse", "Stop", "SubagentStop"]);
30
+ const SELFSTART_EVENTS = new Set(["SessionStart", "PostCompact"]);
31
+ const SILENT_OVERSIZE_POST_TOOL_USE = Symbol("silent-oversize-post-tool-use");
32
+ const SILENT_OVERSIZE_POST_TOOL_USE_ARG = "--silent-oversize-post-tool-use";
33
+
34
+ async function readStdin({ silentOversizePostToolUse = false } = {}) {
35
+ const chunks = [];
36
+ let bytes = 0;
37
+ let oversized = false;
38
+ for await (const chunk of process.stdin) {
39
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
40
+ const remaining = Math.max(0, MAX_STDIN_BYTES - bytes);
41
+ if (remaining) chunks.push(buffer.subarray(0, remaining));
42
+ bytes += buffer.length;
43
+ if (bytes > MAX_STDIN_BYTES) oversized = true;
44
+ }
45
+ if (oversized) {
46
+ if (silentOversizePostToolUse) return SILENT_OVERSIZE_POST_TOOL_USE;
47
+ throw new Error("hook input exceeds the 64 KiB limit");
48
+ }
49
+ const value = Buffer.concat(chunks, bytes).toString("utf8");
50
+ const parsed = value.trim() ? JSON.parse(value) : {};
51
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("hook input must be one JSON object");
52
+ return parsed;
53
+ }
54
+
55
+ function boundedId(value, field) {
56
+ if (value === null || value === undefined || value === "") return null;
57
+ if (typeof value !== "string" || !ID_RE.test(value)) throw new Error(`${field} is invalid`);
58
+ return value;
59
+ }
60
+
61
+ function promptFromInput(input) {
62
+ for (const key of ["prompt", "user_prompt", "message", "input"]) {
63
+ const value = input[key];
64
+ if (typeof value === "string") return value;
65
+ if (Array.isArray(value)) {
66
+ const text = value
67
+ .filter((part) => part && typeof part === "object" && part.type === "text" && typeof part.text === "string")
68
+ .map((part) => part.text)
69
+ .join("\n")
70
+ .trim();
71
+ if (text) return text;
72
+ }
73
+ }
74
+ return null;
75
+ }
76
+
77
+ function hostContextLimit(preflight) {
78
+ return preflight?.receipt?.instructionBudget?.mode === "claude-required-overflow"
79
+ ? MAX_CLAUDE_OVERFLOW_CONTEXT_BYTES
80
+ : STANDARD_HOST_CONTEXT_BYTES;
81
+ }
82
+
83
+ function hostFromInput(input) {
84
+ const explicit = input.host || input.provider || process.env.AGENTSPINE_HOST;
85
+ if (["claude", "codex", "generic"].includes(explicit)) return explicit;
86
+ if ((typeof input.model === "string" && input.model.trim()) || process.env.PLUGIN_ROOT || process.env.CODEX_HOME
87
+ || process.env.BLUN_PLUGIN_ROOT || process.env.BLUN_HOME) return "codex";
88
+ return "claude";
89
+ }
90
+
91
+ function gatewayEnvironmentContext(env = process.env) {
92
+ if (env.AGENTSPINE_GATEWAY_CONTEXT !== "agentspine.gateway-start/v1") return null;
93
+ return {
94
+ entityId: boundedId(env.AGENTSPINE_ENTITY_ID, "AGENTSPINE_ENTITY_ID"),
95
+ groupId: boundedId(env.AGENTSPINE_GROUP_ID, "AGENTSPINE_GROUP_ID"),
96
+ projectId: boundedId(env.AGENTSPINE_PROJECT_ID, "AGENTSPINE_PROJECT_ID"),
97
+ taskId: boundedId(env.AGENTSPINE_TASK_ID, "AGENTSPINE_TASK_ID"),
98
+ eventId: boundedId(env.AGENTSPINE_CHANNEL_EVENT_ID, "AGENTSPINE_CHANNEL_EVENT_ID"),
99
+ provider: boundedId(env.AGENTSPINE_CHANNEL_PROVIDER, "AGENTSPINE_CHANNEL_PROVIDER")
100
+ };
101
+ }
102
+
103
+ async function runtimeScope(input, root, userStateRoot = null, catalog) {
104
+ const { continuity: projectContinuity } = await loadContinuity(root, catalog);
105
+ const userCatalog = userStateRoot && userStateRoot !== root
106
+ ? catalogForStateRoot(catalog, userStateRoot) : catalog;
107
+ const userContinuity = userStateRoot && userStateRoot !== root
108
+ ? (await loadContinuity(userStateRoot, userCatalog)).continuity
109
+ : projectContinuity;
110
+ const continuity = {
111
+ config: {
112
+ ...projectContinuity.config,
113
+ enabled: userContinuity.config.enabled,
114
+ minConfidence: userContinuity.config.minConfidence,
115
+ minDirectness: userContinuity.config.minDirectness,
116
+ minEvidence: userContinuity.config.minEvidence,
117
+ maxPromptBytes: userContinuity.config.maxPromptBytes,
118
+ maxBriefingBytes: userContinuity.config.maxBriefingBytes,
119
+ defaultEntityId: userContinuity.config.defaultEntityId
120
+ }
121
+ };
122
+ const supplied = input.agent_spine_scope && typeof input.agent_spine_scope === "object"
123
+ ? input.agent_spine_scope : input;
124
+ const gateway = gatewayEnvironmentContext();
125
+ return {
126
+ entityId: boundedId(supplied.entity_id ?? supplied.entityId ?? gateway?.entityId ?? continuity.config.defaultEntityId, "entityId"),
127
+ userId: boundedId(supplied.user_id ?? supplied.userId ?? process.env.AGENTSPINE_USER_ID, "userId"),
128
+ tenantId: boundedId(supplied.tenant_id ?? supplied.tenantId ?? process.env.AGENTSPINE_TENANT_ID, "tenantId"),
129
+ groupId: boundedId(supplied.group_id ?? supplied.groupId ?? gateway?.groupId, "groupId"),
130
+ projectId: boundedId(supplied.project_id ?? supplied.projectId ?? gateway?.projectId ?? continuity.config.defaultProjectId, "projectId"),
131
+ currentTaskId: boundedId(supplied.task_id ?? supplied.currentTaskId ?? gateway?.taskId, "currentTaskId"),
132
+ host: hostFromInput(input),
133
+ config: continuity.config
134
+ };
135
+ }
136
+
137
+ function renderContext(event, catalog, briefing, signal = null, attentionEvent = null, selfstarter = null, channelEvent = null, sourceDiagnostics = null, preflight = null) {
138
+ const loaded = sourceDiagnostics?.status === "loaded";
139
+ const packet = {
140
+ schema: "agentspine.hook-context/v1",
141
+ event,
142
+ priority: ["current-user-request", "explicit-stops", "current-task", "host-rules", "accepted-context", "style-and-relationships"],
143
+ loaded,
144
+ instruction: loaded
145
+ ? "Use this already-loaded briefing now. Do not call an MCP tool to obtain it. The current user request and explicit stops override all remembered style, relationships, and older context."
146
+ : "No host-native source context was loaded. Do not claim personal continuity or recall succeeded. Continue under current native host rules and inspect sourceResolution.",
147
+ signal: signal ? {
148
+ captured: Boolean(signal.captured),
149
+ accepted: Boolean(signal.accepted),
150
+ duplicate: Boolean(signal.duplicate),
151
+ kind: signal.kind || null,
152
+ reason: signal.reason || null
153
+ } : null,
154
+ attentionEvent: attentionEvent ? {
155
+ captured: Boolean(attentionEvent.event),
156
+ duplicate: Boolean(attentionEvent.duplicate),
157
+ id: attentionEvent.event?.id || null,
158
+ kind: attentionEvent.event?.kind || null,
159
+ status: attentionEvent.event?.status || null,
160
+ reason: attentionEvent.reason || null
161
+ } : null,
162
+ selfstarter: selfstarter ? {
163
+ active: Boolean(selfstarter.job),
164
+ blocked: Boolean(selfstarter.blocked),
165
+ action: selfstarter.action || null,
166
+ reason: selfstarter.reason || null,
167
+ jobId: selfstarter.job?.id || null,
168
+ taskId: selfstarter.job?.taskId || null,
169
+ actorId: selfstarter.job?.actorId || null,
170
+ targetId: selfstarter.job?.targetId || null,
171
+ projectId: selfstarter.job?.projectId || null,
172
+ groupId: selfstarter.job?.groupId || null,
173
+ checkpointSequence: selfstarter.job?.checkpoint?.sequence ?? null,
174
+ capabilities: selfstarter.job?.capabilities || [],
175
+ leaseExpiresAt: selfstarter.job?.lease?.expiresAt || null,
176
+ receiptId: selfstarter.receipt?.id || null,
177
+ instruction: selfstarter.job
178
+ ? "Resume only this exact checkpointed job. Attach its job ID to every host tool event. Each effect is separately re-authorized by PreToolUse and checkpointed by PostToolUse. Stop immediately on any denial."
179
+ : null,
180
+ authority: selfstarter.job ? "explicit-local-execution-policy" : "execution-state-only"
181
+ } : null,
182
+ channelEvent: channelEvent?.event ? {
183
+ active: true,
184
+ eventId: channelEvent.event.eventId,
185
+ bindingId: channelEvent.event.bindingId,
186
+ provider: channelEvent.event.provider,
187
+ chatId: channelEvent.event.chatId,
188
+ threadId: channelEvent.event.threadId,
189
+ senderId: channelEvent.event.senderId,
190
+ replyTo: channelEvent.event.replyTo,
191
+ agentId: channelEvent.event.agentId,
192
+ projectId: channelEvent.event.projectId,
193
+ groupId: channelEvent.event.groupId,
194
+ sessionKey: channelEvent.event.sessionKey,
195
+ text: channelEvent.event.text,
196
+ leaseExpiresAt: channelEvent.event.lease?.expiresAt || null,
197
+ receiptId: channelEvent.receipt?.id || null,
198
+ instruction: "Answer this exact authenticated channel event in its bound chat and thread. Do not infer another recipient or route. Sending remains subject to the separate current channel policy and adapter receipt.",
199
+ authority: "explicit-local-channel-policy"
200
+ } : null,
201
+ indexedSources: catalog.summary.total,
202
+ sourceResolution: sourceDiagnostics,
203
+ preflight: preflight ? {
204
+ schema: preflight.receipt.schema,
205
+ receiptId: preflight.receipt.id,
206
+ promptDigest: preflight.receipt.promptDigest,
207
+ briefingDigest: preflight.receipt.briefingDigest,
208
+ createdAt: preflight.receipt.createdAt,
209
+ expiresAt: preflight.receipt.expiresAt,
210
+ policy: preflight.policy,
211
+ learningApplications: preflight.learningApplications || null,
212
+ pendingMustRemember: preflight.pendingMustRemember ? {
213
+ id: preflight.pendingMustRemember.candidate?.id || null,
214
+ status: preflight.pendingMustRemember.candidate?.status || (preflight.pendingMustRemember.rejected ? "rejected" : null),
215
+ reason: preflight.pendingMustRemember.reason || null
216
+ } : null,
217
+ briefing: preflight.briefing,
218
+ instruction: "This exact turn passed the mandatory pre-answer gate. Apply the complete preflight briefing before answering.",
219
+ authority: "preflight-proof-only"
220
+ } : null,
221
+ briefing,
222
+ authority: "context-only"
223
+ };
224
+ return JSON.stringify(packet);
225
+ }
226
+
227
+ function selfstarterInput(input) {
228
+ const value = input.agent_spine_job;
229
+ if (value === undefined || value === null) return null;
230
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("agent_spine_job must be one object");
231
+ return value;
232
+ }
233
+
234
+ function channelEventInput(input) {
235
+ const gateway = gatewayEnvironmentContext();
236
+ const value = input.agent_spine_channel_event ?? (gateway?.eventId && gateway?.provider
237
+ ? { event_id: gateway.eventId, provider: gateway.provider } : null);
238
+ if (value === undefined || value === null) return null;
239
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("agent_spine_channel_event must be one object");
240
+ const unknown = Object.keys(value).filter((key) => !["event_id", "eventId", "provider"].includes(key));
241
+ if (unknown.length) throw new Error(`agent_spine_channel_event contains unknown field: ${unknown.sort()[0]}`);
242
+ return value;
243
+ }
244
+
245
+ function hookDeliveryId(input) {
246
+ return boundedId(input.tool_use_id ?? input.event_id ?? input.hook_event_id, "toolUseId");
247
+ }
248
+
249
+ function sessionId(input) {
250
+ return boundedId(input.session_id ?? input.sessionId, "sessionId");
251
+ }
252
+
253
+ async function startSelfstarter(input, event, root, scope, sourceDiagnostics) {
254
+ if (!SELFSTART_EVENTS.has(event)) return null;
255
+ const requested = selfstarterInput(input);
256
+ if (selfstarterRootSkipped(sourceDiagnostics)) {
257
+ if (requested) throw new Error("self-starter cannot use a user home or host profile as its workspace root");
258
+ return null;
259
+ }
260
+ const session = sessionId(input);
261
+ if (!scope.entityId || !scope.projectId || !session) {
262
+ if (requested) throw new Error("self-starter start requires an exact actor, project, and host session");
263
+ return null;
264
+ }
265
+ return startOrResumeJob({
266
+ root, actorId: scope.entityId, projectId: scope.projectId, groupId: scope.groupId,
267
+ taskId: scope.currentTaskId, jobId: boundedId(requested?.job_id ?? requested?.jobId, "jobId"),
268
+ host: scope.host, sessionId: session, now: input.timestamp || new Date()
269
+ });
270
+ }
271
+
272
+ function selfstarterRootSkipped(sourceDiagnostics) {
273
+ return ["skipped-unmarked-home", "skipped-home-root", "skipped-profile-root"]
274
+ .includes(sourceDiagnostics?.projectTreeScan);
275
+ }
276
+
277
+ async function startChannelEvent(input, event, root, scope, catalog) {
278
+ const requested = channelEventInput(input);
279
+ if (!requested) return null;
280
+ if (event !== "SessionStart") throw new Error("channel event claims are accepted only at SessionStart");
281
+ const session = sessionId(input);
282
+ if (!scope.entityId || !scope.projectId || !session) {
283
+ throw new Error("channel event start requires an exact agent, project, and host session");
284
+ }
285
+ const workerId = `channel-worker:${createHash("sha256").update(`${scope.host}\0${session}`).digest("hex").slice(0, 24)}`;
286
+ const claim = await claimChannelEvent({
287
+ root, eventId: boundedId(requested.event_id ?? requested.eventId, "channelEventId"),
288
+ agentId: scope.entityId, projectId: scope.projectId, groupId: scope.groupId,
289
+ provider: boundedId(requested.provider, "channelProvider"), workerId,
290
+ now: input.timestamp || new Date(), catalog
291
+ });
292
+ if (!claim.event) throw new Error("the exact channel event is unavailable in this agent lane");
293
+ return claim;
294
+ }
295
+
296
+ async function selfstarterScope(input, scope, root, action) {
297
+ const requested = selfstarterInput(input);
298
+ const hostSession = sessionId(input);
299
+ const supplied = {
300
+ actorId: scope.entityId, projectId: scope.projectId, groupId: scope.groupId,
301
+ taskId: scope.currentTaskId, host: scope.host, sessionId: hostSession
302
+ };
303
+ if (requested) return { ...supplied, jobId: boundedId(requested.job_id ?? requested.jobId, "jobId") };
304
+ if (!hostSession) return null;
305
+ return resolveSessionJob({ root, ...supplied, action, now: input.timestamp || new Date() });
306
+ }
307
+
308
+ function toolSucceeded(input) {
309
+ if (input.success === false || input.is_error === true || input.tool_error) return false;
310
+ if (input.tool_result && typeof input.tool_result === "object" && input.tool_result.isError === true) return false;
311
+ return true;
312
+ }
313
+
314
+ function toolResult(input) {
315
+ return input.tool_result ?? input.tool_response ?? input.result ?? (input.tool_error ? { failed: true } : null);
316
+ }
317
+
318
+ function eventReceipt(input, event, scope, discriminator = "lifecycle") {
319
+ const supplied = input.event_id ?? input.hook_event_id ?? input.tool_use_id ?? input.session_id;
320
+ if (typeof supplied === "string" && ID_RE.test(supplied)) {
321
+ return `receipt:${createHash("sha256").update(`${event}\0${supplied}\0${discriminator}`).digest("hex").slice(0, 24)}`;
322
+ }
323
+ const material = [event, scope.host, scope.entityId, scope.projectId, scope.currentTaskId, discriminator].join("\0");
324
+ return `receipt:${createHash("sha256").update(material).digest("hex").slice(0, 24)}`;
325
+ }
326
+
327
+ function heartbeatReceipt(input, scope) {
328
+ const at = new Date(input.timestamp || Date.now());
329
+ if (!Number.isFinite(at.getTime())) throw new Error("heartbeat timestamp is invalid");
330
+ const minute = at.toISOString().slice(0, 16);
331
+ const material = [scope.host, scope.entityId, scope.groupId, scope.projectId, scope.currentTaskId, minute].join("\0");
332
+ return `receipt:heartbeat:${createHash("sha256").update(material).digest("hex").slice(0, 20)}`;
333
+ }
334
+
335
+ function minimalAttentionSignal(prompt) {
336
+ if (typeof prompt !== "string" || Buffer.byteLength(prompt) > 16384) return null;
337
+ const rules = [
338
+ { kind: "promise", re: /^(?:i promise(?: to)?|i will|ich verspreche(?:,)?|ich werde|prometo(?: que)?|voy a|jag lovar att|jag kommer att)\s+(.+)$/i, prefix: "Promise: " },
339
+ { kind: "blocker", re: /^(?:blocker|blocked(?: by)?|i am blocked(?: by)?|ich bin blockiert(?: durch)?|blockiert durch|bloquead[oa](?: por)?|estoy bloquead[oa](?: por)?|blockerad(?: av)?|jag är blockerad(?: av)?)\s*[:,-]?\s*(.+)$/i, prefix: "Blocker: " }
340
+ ];
341
+ for (const rule of rules) {
342
+ const match = prompt.trim().match(rule.re);
343
+ if (!match) continue;
344
+ const value = match[1].trim().replace(/\s+/g, " ").replace(/[.!?]+$/, "").slice(0, 220);
345
+ if (value) return { kind: rule.kind, summary: `${rule.prefix}${value}` };
346
+ }
347
+ return null;
348
+ }
349
+
350
+ async function captureAttentionLifecycle(input, event, root, scope, catalog) {
351
+ if (!ATTENTION_WRITE_EVENTS.has(event)) return null;
352
+ const explicit = input.agent_spine_attention && typeof input.agent_spine_attention === "object"
353
+ && !Array.isArray(input.agent_spine_attention) ? input.agent_spine_attention : null;
354
+ let proposed = explicit;
355
+ let automaticHeartbeat = false;
356
+ if (!proposed && event === "UserPromptSubmit" && scope.config.enabled) {
357
+ proposed = minimalAttentionSignal(promptFromInput(input));
358
+ if (proposed && scope.groupId) return { event: null, duplicate: false, reason: "rejected:group conversation events are never learned automatically" };
359
+ }
360
+ if (!proposed && event === "PostToolUse" && scope.currentTaskId) {
361
+ proposed = { kind: "heartbeat", summary: "Work heartbeat recorded.", status: "active" };
362
+ automaticHeartbeat = true;
363
+ }
364
+ if (!proposed && ["Stop", "SubagentStop"].includes(event) && scope.currentTaskId) {
365
+ proposed = { kind: "heartbeat", summary: "Work heartbeat recorded.", status: "stopped" };
366
+ }
367
+ if (!proposed) return null;
368
+ if (event === "PostToolUse" && ![input.event_id, input.hook_event_id, input.tool_use_id].some((value) => typeof value === "string" && ID_RE.test(value))) {
369
+ throw new Error("PostToolUse attention requires a stable host delivery ID");
370
+ }
371
+ if (!scope.entityId || !scope.projectId || !scope.currentTaskId) {
372
+ if (explicit) throw new Error("lifecycle attention event is missing exact actor, project, or task scope");
373
+ return { event: null, duplicate: false, reason: "missing-exact-scope" };
374
+ }
375
+ const privacy = proposed.privacy || "private";
376
+ if (event === "UserPromptSubmit" && privacy === "group") {
377
+ return { event: null, duplicate: false, reason: "rejected:group conversation events are never learned automatically" };
378
+ }
379
+ const discriminator = proposed.id || `${proposed.kind}:${proposed.status || ""}:${proposed.summary}`;
380
+ return recordAttentionEvent({
381
+ root,
382
+ id: boundedId(proposed.id, "attentionEventId"),
383
+ kind: proposed.kind,
384
+ summary: proposed.summary,
385
+ status: proposed.status || null,
386
+ entityId: scope.entityId,
387
+ groupId: privacy === "group" ? scope.groupId : null,
388
+ projectId: scope.projectId,
389
+ taskId: scope.currentTaskId,
390
+ privacy,
391
+ dueAt: proposed.due_at ?? proposed.dueAt ?? null,
392
+ receiptId: automaticHeartbeat ? heartbeatReceipt(input, scope) : eventReceipt(input, event, scope, discriminator),
393
+ host: scope.host,
394
+ hookEvent: event,
395
+ observedAt: input.timestamp || new Date(), catalog
396
+ });
397
+ }
398
+
399
+ export function blunRuntimeContext(context) {
400
+ const detailed = JSON.parse(context);
401
+ const sourceResolution = detailed.sourceResolution ? {
402
+ status: detailed.sourceResolution.status || null,
403
+ reason: detailed.sourceResolution.reason || null
404
+ } : null;
405
+ const runtime = {
406
+ schema: "agentspine.blun-runtime-context/v1",
407
+ event: detailed.event,
408
+ loaded: Boolean(detailed.loaded),
409
+ failedClosed: detailed.failedClosed ? true : undefined,
410
+ indexedSources: detailed.indexedSources || 0,
411
+ sourceResolution,
412
+ instruction: detailed.loaded
413
+ ? "Detailed AgentSpine context is available on demand through session_briefing. Load it only when the current request needs continuity."
414
+ : detailed.instruction,
415
+ authority: "context-only"
416
+ };
417
+ if (detailed.signal && (detailed.signal.captured || detailed.signal.accepted || detailed.signal.reason)) {
418
+ runtime.signal = detailed.signal;
419
+ }
420
+ if (detailed.attentionEvent
421
+ && (detailed.attentionEvent.captured || detailed.attentionEvent.duplicate || detailed.attentionEvent.reason)) {
422
+ runtime.attentionEvent = detailed.attentionEvent;
423
+ }
424
+ if (detailed.selfstarter && (detailed.selfstarter.active || detailed.selfstarter.blocked)) {
425
+ runtime.selfstarter = detailed.selfstarter;
426
+ }
427
+ if (detailed.channelEvent?.active) runtime.channelEvent = detailed.channelEvent;
428
+ return JSON.stringify(runtime);
429
+ }
430
+
431
+ export function blunRuntimeMessage(context) {
432
+ const runtime = JSON.parse(blunRuntimeContext(context));
433
+ const base = runtime.loaded
434
+ ? `AgentSpine ready: ${runtime.indexedSources} sources indexed. Load detailed continuity only on demand through session_briefing.`
435
+ : `AgentSpine unavailable${runtime.sourceResolution?.reason ? `: ${runtime.sourceResolution.reason}` : ""}. ${runtime.instruction}`;
436
+ const active = {};
437
+ if (runtime.signal && (runtime.signal.captured || runtime.signal.accepted
438
+ || String(runtime.signal.reason || "").startsWith("rejected:"))) {
439
+ active.signal = runtime.signal;
440
+ }
441
+ if (runtime.attentionEvent && (runtime.attentionEvent.captured || runtime.attentionEvent.duplicate
442
+ || String(runtime.attentionEvent.reason || "").startsWith("rejected:"))) {
443
+ active.attentionEvent = runtime.attentionEvent;
444
+ }
445
+ if (runtime.selfstarter) active.selfstarter = runtime.selfstarter;
446
+ if (runtime.channelEvent) active.channelEvent = runtime.channelEvent;
447
+ return Object.keys(active).length === 0
448
+ ? base
449
+ : `${base}\nActive AgentSpine runtime data: ${JSON.stringify(active)}`;
450
+ }
451
+
452
+ function hookOutput(event, context) {
453
+ if (process.env.BLUN_PLUGIN_ROOT) {
454
+ return { hookSpecificOutput: { hookEventName: event, message: blunRuntimeMessage(context) } };
455
+ }
456
+ return { hookSpecificOutput: { hookEventName: event, additionalContext: context } };
457
+ }
458
+
459
+ function candidatePaths(value, output = []) {
460
+ if (!value) return output;
461
+ if (typeof value === "string") {
462
+ for (const match of value.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)) output.push(match[1].trim());
463
+ return output;
464
+ }
465
+ if (Array.isArray(value)) {
466
+ for (const item of value) candidatePaths(item, output);
467
+ return output;
468
+ }
469
+ for (const [key, item] of Object.entries(value)) {
470
+ if (["path", "file_path", "target_file", "filename"].includes(key) && typeof item === "string") output.push(item);
471
+ else candidatePaths(item, output);
472
+ }
473
+ return output;
474
+ }
475
+
476
+ function isMutationTool(name = "") {
477
+ return /(^|__)(apply_patch|edit|write|delete|move|rename|bash|exec_command|shell)(_|$)/i.test(name);
478
+ }
479
+
480
+ function isScanFailOpenTool(name = "") {
481
+ return /(^|__)(apply_patch|edit|write|bash|exec_command)(_|$)/i.test(name);
482
+ }
483
+
484
+ function filesystemScanError(error) {
485
+ return Boolean(error && (error.code === "AGENTSPINE_SCAN_INCOMPLETE" || error.agentSpineScan === true));
486
+ }
487
+
488
+ export function hookScanFailureFailsOpen(eventOrError, _toolName, suppliedError) {
489
+ const error = suppliedError ?? eventOrError;
490
+ return filesystemScanError(error) || (["EPERM", "EACCES"].includes(error?.code)
491
+ && ["opendir", "readdir", "scandir"].includes(error?.syscall));
492
+ }
493
+
494
+ async function auditSkippedScans(input, phase, skipped = []) {
495
+ const item = skipped[0];
496
+ if (!item) return;
497
+ await recordHookScanAudit({
498
+ event: "PreToolUse", toolName: input.tool_name || null, phase,
499
+ error: { code: item.code, message: `${item.code}: ${item.operation} skipped ${item.path}` },
500
+ path: item.path, operation: item.operation, now: input.timestamp || new Date()
501
+ });
502
+ }
503
+
504
+ async function allowScanFailure(input, phase, error) {
505
+ const event = input.hook_event_name || input.event_name || "unknown";
506
+ await recordHookScanAudit({
507
+ event, toolName: input.tool_name || null, phase, error,
508
+ path: error?.path || input.cwd || process.cwd(), now: input.timestamp || new Date()
509
+ });
510
+ return { blocked: false, scanFailedOpen: true, event, phase, error: error.message };
511
+ }
512
+
513
+ async function finishScanFailure(input, payload, phase, error) {
514
+ const allowed = await allowScanFailure(input, phase, error);
515
+ if (payload) return allowed;
516
+ process.stdout.write("{}\n");
517
+ return undefined;
518
+ }
519
+
520
+ function stringValues(value, output = []) {
521
+ if (typeof value === "string") output.push(value);
522
+ else if (Array.isArray(value)) value.forEach((item) => stringValues(item, output));
523
+ else if (value && typeof value === "object") Object.values(value).forEach((item) => stringValues(item, output));
524
+ return output;
525
+ }
526
+
527
+ function shellTargetsProtected(input, documents, cwd, root) {
528
+ if (!/(bash|exec_command|shell)/i.test(input.tool_name || "")) return null;
529
+ const command = stringValues(input.tool_input || input.tool_args).join("\n").replaceAll("\\", "/");
530
+ if (!/(?:^|[;&|\s])(?:rm|mv|cp|truncate|tee|sed\s+-i|perl\s+-i)\b|(?:^|[^>])>{1,2}(?!>)/i.test(command)) return null;
531
+ for (const document of documents) {
532
+ const forms = new Set([document.path.replaceAll("\\", "/"), document.relativePath]);
533
+ for (const base of [cwd, root]) {
534
+ const candidate = relative(base, document.path);
535
+ if (candidate && !candidate.startsWith("..") && !isAbsolute(candidate)) forms.add(candidate.replaceAll("\\", "/"));
536
+ }
537
+ if ([...forms].some((form) => form && command.includes(form))) return document;
538
+ }
539
+ return null;
540
+ }
541
+
542
+ function deny(reason) {
543
+ process.stdout.write(`${JSON.stringify({
544
+ decision: "block",
545
+ reason,
546
+ hookSpecificOutput: {
547
+ hookEventName: "PreToolUse",
548
+ permissionDecision: "deny",
549
+ permissionDecisionReason: reason
550
+ }
551
+ })}\n`);
552
+ }
553
+
554
+ function blockPrompt(reason) {
555
+ process.stdout.write(`${JSON.stringify({
556
+ decision: "block",
557
+ reason,
558
+ hookSpecificOutput: { hookEventName: "UserPromptSubmit", decision: "block", reason }
559
+ })}\n`);
560
+ }
561
+
562
+ async function runHookCore(input, payload) {
563
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("hook input must be one JSON object");
564
+ const event = input.hook_event_name || input.event_name || "";
565
+ if (!KNOWN_EVENTS.has(event)) throw new Error(`unsupported hook event: ${event || "missing"}`);
566
+ if (event === "InstructionsLoaded") {
567
+ const file = input.file_path;
568
+ if (typeof file !== "string" || !file || !["User", "Project", "Local", "Managed"].includes(input.memory_type)
569
+ || !["session_start", "nested_traversal", "path_glob_match", "include", "compact"].includes(input.load_reason)) {
570
+ throw new Error("InstructionsLoaded payload is invalid");
571
+ }
572
+ if (payload) return { blocked: false, observed: true };
573
+ process.stdout.write("{}\n");
574
+ return;
575
+ }
576
+ const cwd = await canonicalPath(input.cwd || process.cwd());
577
+ const host = hostFromInput(input);
578
+ const instructionHost = host === "generic" ? input.instruction_host : host;
579
+ if (host === "generic" && !["claude", "codex"].includes(instructionHost)) {
580
+ const reason = "AgentSpine generic hosts must bind instruction_host to claude or codex";
581
+ if (event === "UserPromptSubmit") {
582
+ if (payload) return { blocked: true, failedClosed: true, reason };
583
+ blockPrompt(reason);
584
+ return;
585
+ }
586
+ throw new Error(reason);
587
+ }
588
+ let resolvedSources;
589
+ try {
590
+ resolvedSources = await resolveHostSourceCatalog({ host: instructionHost, cwd, input });
591
+ } catch (error) {
592
+ if (hookScanFailureFailsOpen(error)) {
593
+ return finishScanFailure(input, payload, "source-resolution", error);
594
+ }
595
+ const reason = `AgentSpine source resolution failed closed: ${error.message}`;
596
+ if (event === "PreToolUse" && isMutationTool(input.tool_name)) {
597
+ if (payload) return { blocked: true, failedClosed: true, reason };
598
+ deny(reason);
599
+ return;
600
+ }
601
+ if (event === "UserPromptSubmit") {
602
+ if (payload) return { blocked: true, failedClosed: true, reason };
603
+ blockPrompt(reason);
604
+ return;
605
+ }
606
+ const context = JSON.stringify({
607
+ schema: "agentspine.hook-context/v1", event, loaded: false, failedClosed: true,
608
+ indexedSources: 0, sourceResolution: { status: "failed-closed", reason: error.message },
609
+ instruction: "Do not claim AgentSpine recall succeeded. Continue under current native host rules; no remembered context or automatic effect was applied.",
610
+ authority: "context-only"
611
+ });
612
+ if (payload) return { blocked: false, failedClosed: true, context, error: error.message };
613
+ if (CONTEXT_EVENTS.has(event)) process.stdout.write(`${JSON.stringify(hookOutput(event, context))}\n`);
614
+ else process.stdout.write("{}\n");
615
+ return;
616
+ }
617
+ const root = resolvedSources.projectRoot;
618
+ const catalog = resolvedSources.catalog;
619
+ const catalogPath = await saveCatalog(catalog);
620
+ let scope = null;
621
+ let selfstarter = null;
622
+ let channelEvent = null;
623
+ let learningDelivery = null;
624
+
625
+ if (event === "PreToolUse" && isScanFailOpenTool(input.tool_name) && resolvedSources.diagnostics.skipped?.length) {
626
+ await auditSkippedScans(input, "source-resolution", resolvedSources.diagnostics.skipped);
627
+ }
628
+
629
+ if (event === "PreToolUse" && isMutationTool(input.tool_name)) {
630
+ const { graph } = await loadGraph(root, catalog);
631
+ const inferredProtected = new Set(graph.annotations
632
+ .filter((annotation) => ["constitution", "soul", "memory-index", "memory-fact"].includes(annotation.layer))
633
+ .map((annotation) => annotation.path));
634
+ const protectedRelative = new Set(catalog.documents
635
+ .filter((doc) => doc.protected || inferredProtected.has(doc.relativePath))
636
+ .map((doc) => doc.relativePath));
637
+ let expanded = true;
638
+ while (expanded) {
639
+ expanded = false;
640
+ for (const edge of graph.edges) {
641
+ if (protectedRelative.has(edge.from) && !protectedRelative.has(edge.to)) {
642
+ protectedRelative.add(edge.to);
643
+ expanded = true;
644
+ }
645
+ }
646
+ }
647
+ const protectedDocuments = catalog.documents.filter((doc) => protectedRelative.has(doc.relativePath));
648
+ const protectedPaths = new Set(protectedDocuments.map((doc) => resolve(doc.path)));
649
+ const targets = await Promise.all(candidatePaths(input.tool_input || input.tool_args).map(async (path) => {
650
+ const target = resolve(cwd, path);
651
+ try { return await canonicalPath(target); } catch (error) {
652
+ if (error.code !== "ENOENT") throw error;
653
+ return target;
654
+ }
655
+ }));
656
+ const hit = targets.find((path) => protectedPaths.has(path));
657
+ const shellHit = shellTargetsProtected(input, protectedDocuments, cwd, root);
658
+ if (hit || shellHit) {
659
+ const relativePath = shellHit?.relativePath || catalog.documents.find((doc) => resolve(doc.path) === hit)?.relativePath || hit;
660
+ const reason = `AgentSpine protected source: ${relativePath}. Existing identity, rule, soul, and memory documents are read-only to agents.`;
661
+ if (payload) return { blocked: true, reason };
662
+ deny(reason);
663
+ return;
664
+ }
665
+ }
666
+
667
+ if (event === "PreToolUse" && !selfstarterRootSkipped(resolvedSources.diagnostics)) {
668
+ try {
669
+ scope ||= await runtimeScope(input, root, resolvedSources.userStateRoot, catalog);
670
+ const exact = await selfstarterScope(input, scope, root, "effect");
671
+ if (exact) {
672
+ selfstarter = await authorizeJobEffect({
673
+ root, ...exact, toolName: input.tool_name, toolUseId: hookDeliveryId(input),
674
+ now: input.timestamp || new Date()
675
+ });
676
+ }
677
+ } catch (error) {
678
+ if (isScanFailOpenTool(input.tool_name) && hookScanFailureFailsOpen(error)) {
679
+ return finishScanFailure(input, payload, "self-starter", error);
680
+ }
681
+ const reason = `AgentSpine self-starter denied this effect: ${error.message}`;
682
+ if (payload) return { blocked: true, reason, selfstarter: { allowed: false, reason: error.message } };
683
+ deny(reason);
684
+ return;
685
+ }
686
+ }
687
+
688
+ let attentionEvent = null;
689
+ if (ATTENTION_WRITE_EVENTS.has(event) && !CONTEXT_EVENTS.has(event)) {
690
+ scope = await runtimeScope(input, root, resolvedSources.userStateRoot, catalog);
691
+ attentionEvent = await captureAttentionLifecycle(input, event, root, scope, catalog);
692
+ }
693
+
694
+ if (event === "PostToolUse" && !selfstarterRootSkipped(resolvedSources.diagnostics)) {
695
+ scope ||= await runtimeScope(input, root, resolvedSources.userStateRoot, catalog);
696
+ const exact = await selfstarterScope(input, scope, root, "effect");
697
+ if (exact) {
698
+ selfstarter = await checkpointJobEffect({
699
+ root, ...exact, toolName: input.tool_name, toolUseId: hookDeliveryId(input),
700
+ success: toolSucceeded(input), result: toolResult(input), now: input.timestamp || new Date()
701
+ });
702
+ }
703
+ }
704
+
705
+ if (["Stop", "SubagentStop"].includes(event)) {
706
+ scope ||= await runtimeScope(input, root, resolvedSources.userStateRoot, catalog);
707
+ const exact = selfstarterRootSkipped(resolvedSources.diagnostics)
708
+ ? null : await selfstarterScope(input, scope, root, "resume");
709
+ if (exact && !scope.currentTaskId) scope.currentTaskId = exact.taskId;
710
+ try {
711
+ learningDelivery = await recordLearningDeliveries({
712
+ root, sessionId: sessionId(input), hookEvent: event,
713
+ scope: {
714
+ personaId: scope.entityId, userId: scope.userId, tenantId: scope.tenantId,
715
+ projectId: scope.projectId, groupId: scope.groupId, taskId: scope.currentTaskId
716
+ },
717
+ completedAt: input.timestamp || new Date()
718
+ });
719
+ } catch (error) {
720
+ learningDelivery = { status: "degraded", receipts: [], reason: error.message, authority: "context-only" };
721
+ }
722
+ const requested = selfstarterInput(input);
723
+ if (exact) {
724
+ selfstarter = await closeJobLease({
725
+ root, ...exact, status: requested?.status || "waiting", now: input.timestamp || new Date()
726
+ });
727
+ }
728
+ }
729
+
730
+ if (CONTEXT_EVENTS.has(event)) {
731
+ let signal = null;
732
+ let preflight = null;
733
+ try {
734
+ await syncPersonaRosterFromEnvironment({ root, env: process.env, now: input.timestamp || new Date(), catalog });
735
+ scope ||= await runtimeScope(input, root, resolvedSources.userStateRoot, catalog);
736
+ channelEvent = await startChannelEvent(input, event, root, scope, catalog);
737
+ selfstarter = await startSelfstarter(input, event, root, scope, resolvedSources.diagnostics);
738
+ if (selfstarter?.job && !scope.currentTaskId) scope.currentTaskId = selfstarter.job.taskId;
739
+ if (event === "UserPromptSubmit") {
740
+ const prompt = promptFromInput(input);
741
+ if (prompt === null) throw new Error("mandatory preflight requires the exact current prompt");
742
+ preflight = await runPreflight({
743
+ input, scope, resolvedSources, prompt, now: input.timestamp || new Date(), env: process.env
744
+ });
745
+ if (!await verifyPreflightReceipt({
746
+ receipt: preflight.receipt, input, scope, resolvedSources, prompt,
747
+ now: input.timestamp || new Date(), env: process.env
748
+ })) throw new Error("newly created preflight receipt failed exact turn verification");
749
+ preflight.pendingMustRemember = await captureMustRememberPrompt({ prompt, receipt: preflight.receipt, env: process.env });
750
+ try {
751
+ attentionEvent = await captureAttentionLifecycle(input, event, root, scope, catalog);
752
+ } catch (error) {
753
+ attentionEvent = { event: null, duplicate: false, reason: `rejected:${error.message}` };
754
+ }
755
+ if (prompt !== null) {
756
+ try {
757
+ signal = await captureContinuityPrompt({
758
+ root, prompt, entityId: scope.entityId, groupId: scope.groupId,
759
+ projectId: scope.projectId, userStateRoot: resolvedSources.userStateRoot,
760
+ eventId: boundedId(input.event_id ?? input.hook_event_id, "eventId"), catalog,
761
+ userCatalog: resolvedSources.userStateRoot && resolvedSources.userStateRoot !== root
762
+ ? catalogForStateRoot(catalog, resolvedSources.userStateRoot) : catalog
763
+ });
764
+ } catch (error) {
765
+ signal = { captured: false, accepted: false, reason: `rejected:${error.message}` };
766
+ }
767
+ }
768
+ }
769
+ const briefing = await sessionBriefing({
770
+ root, cwd, host: scope.host, entityId: scope.entityId, groupId: scope.groupId,
771
+ userId: scope.userId, tenantId: scope.tenantId, projectId: scope.projectId, currentTaskId: scope.currentTaskId,
772
+ includePrivate: Boolean(scope.entityId && !scope.groupId),
773
+ focusActive: true, includeSourceContent: event === "UserPromptSubmit" ? false : !scope.groupId,
774
+ maxBytes: event === "UserPromptSubmit" ? 4096 : scope.config.maxBriefingBytes,
775
+ now: input.timestamp || new Date(),
776
+ catalog, userStateRoot: resolvedSources.userStateRoot, sourceDiagnostics: resolvedSources.diagnostics,
777
+ prompt: event === "UserPromptSubmit" ? promptFromInput(input) : null
778
+ });
779
+ let context = renderContext(event, catalog, briefing, signal, attentionEvent, selfstarter, channelEvent, resolvedSources.diagnostics, preflight);
780
+ if (event === "UserPromptSubmit" && Buffer.byteLength(context) > hostContextLimit(preflight)) {
781
+ throw new Error("mandatory preflight context exceeds the host hook injection limit");
782
+ }
783
+ if (event === "UserPromptSubmit" && !await verifyPreflightReceipt({
784
+ receipt: preflight.receipt, input, scope, resolvedSources, prompt: promptFromInput(input),
785
+ now: input.timestamp || new Date(), env: process.env, consume: true
786
+ })) throw new Error("preflight receipt could not be consumed atomically for this exact turn");
787
+ if (event === "UserPromptSubmit") {
788
+ const activeCanaries = briefing.learning.filter((item) => ["active", "revalidating"].includes(item.outcomeStatus));
789
+ if (!activeCanaries.length) {
790
+ preflight.learningApplications = {
791
+ status: "not-applicable", receipts: [], authority: "context-only"
792
+ };
793
+ } else try {
794
+ const application = await recordLearningApplications({
795
+ root, items: briefing.learning,
796
+ scope: {
797
+ personaId: scope.entityId, userId: scope.userId, tenantId: scope.tenantId,
798
+ projectId: scope.projectId, groupId: scope.groupId, taskId: scope.currentTaskId
799
+ },
800
+ preflightReceipt: preflight.receipt,
801
+ sessionBriefingDigest: createHash("sha256").update(JSON.stringify(briefing)).digest("hex"),
802
+ projectedAt: input.timestamp || new Date()
803
+ });
804
+ preflight.learningApplications = {
805
+ status: application.receipts.length ? "recorded" : "not-applicable",
806
+ receipts: application.receipts.map((item) => ({
807
+ id: item.id, learningId: item.learningId, projectedAt: item.projectedAt, expiresAt: item.expiresAt
808
+ })),
809
+ authority: "context-only"
810
+ };
811
+ } catch (error) {
812
+ briefing.learning = briefing.learning.filter((item) => !["active", "revalidating"].includes(item.outcomeStatus));
813
+ preflight.learningApplications = {
814
+ status: "degraded", receipts: [], reason: error.message, authority: "context-only"
815
+ };
816
+ }
817
+ const enriched = renderContext(event, catalog, briefing, signal, attentionEvent, selfstarter, channelEvent,
818
+ resolvedSources.diagnostics, preflight);
819
+ if (Buffer.byteLength(enriched) <= hostContextLimit(preflight)) context = enriched;
820
+ else if (preflight.learningApplications.status === "degraded") {
821
+ preflight.learningApplications = null;
822
+ context = renderContext(event, catalog, briefing, signal, attentionEvent, selfstarter, channelEvent,
823
+ resolvedSources.diagnostics, preflight);
824
+ }
825
+ }
826
+ if (payload) return { blocked: false, context, briefing, preflight, signal, attentionEvent, channelEvent, catalogPath };
827
+ process.stdout.write(`${JSON.stringify(hookOutput(event, context))}\n`);
828
+ return;
829
+ } catch (error) {
830
+ if (hookScanFailureFailsOpen(error)) {
831
+ return finishScanFailure(input, payload, "context-lifecycle", error);
832
+ }
833
+ if (event === "UserPromptSubmit") {
834
+ await recordPreflightFailure({ receiptId: preflight?.receipt?.id || null, input, host,
835
+ error, now: input.timestamp || new Date(), env: process.env }).catch(() => {});
836
+ const reason = `AgentSpine pre-answer preflight blocked this turn: ${error.message}`;
837
+ if (payload) return { blocked: true, failedClosed: true, reason, error: error.message, catalogPath };
838
+ blockPrompt(reason);
839
+ return;
840
+ }
841
+ const context = JSON.stringify({
842
+ schema: "agentspine.hook-context/v1", event, loaded: false, failedClosed: true,
843
+ indexedSources: catalog.summary.total,
844
+ sourceResolution: resolvedSources.diagnostics,
845
+ error: error.message,
846
+ instruction: "Do not claim AgentSpine recall succeeded. Continue with the current request under native host rules and run agentspine audit before using remembered context.",
847
+ authority: "context-only"
848
+ });
849
+ if (payload) return { blocked: false, failedClosed: true, context, error: error.message, catalogPath };
850
+ process.stdout.write(`${JSON.stringify(hookOutput(event, context))}\n`);
851
+ return;
852
+ }
853
+ }
854
+
855
+ if (payload) return { blocked: false, attentionEvent, selfstarter, learningDelivery };
856
+ process.stdout.write("{}\n");
857
+ }
858
+
859
+ export async function runHook(payload = null, options = {}) {
860
+ let input = payload;
861
+ try {
862
+ input ||= await readStdin(options);
863
+ if (input === SILENT_OVERSIZE_POST_TOOL_USE) return;
864
+ return await runHookCore(input, payload);
865
+ } catch (error) {
866
+ if (!hookScanFailureFailsOpen(error) || input === SILENT_OVERSIZE_POST_TOOL_USE) throw error;
867
+ return finishScanFailure(input, payload, "lifecycle", error);
868
+ }
869
+ }
870
+
871
+ if (isMainModule(import.meta.url)) {
872
+ const args = process.argv.slice(2);
873
+ const silentOversizePostToolUse = args.length === 1 && args[0] === SILENT_OVERSIZE_POST_TOOL_USE_ARG;
874
+ const argumentError = args.length && !silentOversizePostToolUse
875
+ ? new Error(`unsupported hook argument: ${args[0]}`) : null;
876
+ (argumentError ? Promise.reject(argumentError) : runHook(null, { silentOversizePostToolUse })).catch((error) => {
877
+ process.stderr.write(`AgentSpine hook: ${String(error.message).slice(0, 2048)}\n`);
878
+ // Claude command hooks treat exit 2 as a blocking failure. Exit 1 is
879
+ // fail-open, which is unsafe for malformed pre-answer payloads.
880
+ process.exitCode = 2;
881
+ });
882
+ }