blun-king-cli 9.1.563 → 9.1.565

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