aiwcli 0.12.6 → 0.12.7

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 (124) hide show
  1. package/bin/dev.cmd +3 -3
  2. package/bin/dev.js +16 -16
  3. package/bin/run.cmd +3 -3
  4. package/bin/run.js +21 -21
  5. package/dist/commands/branch.js +7 -2
  6. package/dist/lib/bmad-installer.js +37 -37
  7. package/dist/lib/terminal.d.ts +2 -0
  8. package/dist/lib/terminal.js +57 -7
  9. package/dist/templates/CLAUDE.md +205 -205
  10. package/dist/templates/_shared/.claude/commands/handoff-resume.md +12 -12
  11. package/dist/templates/_shared/.claude/commands/handoff.md +12 -12
  12. package/dist/templates/_shared/.claude/settings.json +65 -65
  13. package/dist/templates/_shared/.codex/workflows/handoff.md +226 -226
  14. package/dist/templates/_shared/.windsurf/workflows/handoff.md +226 -226
  15. package/dist/templates/_shared/handoff-system/CLAUDE.md +421 -421
  16. package/dist/templates/_shared/handoff-system/lib/document-generator.ts +215 -215
  17. package/dist/templates/_shared/handoff-system/lib/handoff-reader.ts +158 -158
  18. package/dist/templates/_shared/handoff-system/scripts/resume_handoff.ts +373 -373
  19. package/dist/templates/_shared/handoff-system/scripts/save_handoff.ts +469 -469
  20. package/dist/templates/_shared/handoff-system/workflows/handoff-resume.md +66 -66
  21. package/dist/templates/_shared/handoff-system/workflows/handoff.md +254 -254
  22. package/dist/templates/_shared/hooks-ts/_utils/git-state.ts +2 -2
  23. package/dist/templates/_shared/hooks-ts/archive_plan.ts +159 -159
  24. package/dist/templates/_shared/hooks-ts/context_monitor.ts +147 -147
  25. package/dist/templates/_shared/hooks-ts/file-suggestion.ts +128 -128
  26. package/dist/templates/_shared/hooks-ts/pre_compact.ts +49 -49
  27. package/dist/templates/_shared/hooks-ts/session_end.ts +196 -196
  28. package/dist/templates/_shared/hooks-ts/session_start.ts +163 -163
  29. package/dist/templates/_shared/hooks-ts/task_create_capture.ts +48 -48
  30. package/dist/templates/_shared/hooks-ts/task_update_capture.ts +74 -74
  31. package/dist/templates/_shared/hooks-ts/user_prompt_submit.ts +93 -93
  32. package/dist/templates/_shared/lib-ts/CLAUDE.md +367 -367
  33. package/dist/templates/_shared/lib-ts/base/atomic-write.ts +138 -138
  34. package/dist/templates/_shared/lib-ts/base/constants.ts +303 -303
  35. package/dist/templates/_shared/lib-ts/base/git-state.ts +58 -58
  36. package/dist/templates/_shared/lib-ts/base/hook-utils.ts +582 -582
  37. package/dist/templates/_shared/lib-ts/base/inference.ts +301 -301
  38. package/dist/templates/_shared/lib-ts/base/logger.ts +247 -247
  39. package/dist/templates/_shared/lib-ts/base/state-io.ts +202 -202
  40. package/dist/templates/_shared/lib-ts/base/stop-words.ts +184 -184
  41. package/dist/templates/_shared/lib-ts/base/utils.ts +184 -184
  42. package/dist/templates/_shared/lib-ts/context/context-formatter.ts +566 -566
  43. package/dist/templates/_shared/lib-ts/context/context-selector.ts +524 -524
  44. package/dist/templates/_shared/lib-ts/context/context-store.ts +712 -712
  45. package/dist/templates/_shared/lib-ts/context/plan-manager.ts +312 -312
  46. package/dist/templates/_shared/lib-ts/context/task-tracker.ts +185 -185
  47. package/dist/templates/_shared/lib-ts/package.json +20 -20
  48. package/dist/templates/_shared/lib-ts/templates/formatters.ts +102 -102
  49. package/dist/templates/_shared/lib-ts/templates/plan-context.ts +58 -58
  50. package/dist/templates/_shared/lib-ts/tsconfig.json +13 -13
  51. package/dist/templates/_shared/lib-ts/types.ts +186 -186
  52. package/dist/templates/_shared/scripts/resolve_context.ts +33 -33
  53. package/dist/templates/_shared/scripts/status_line.ts +690 -690
  54. package/dist/templates/cc-native/.claude/commands/cc-native/rlm/ask.md +136 -136
  55. package/dist/templates/cc-native/.claude/commands/cc-native/rlm/index.md +21 -21
  56. package/dist/templates/cc-native/.claude/commands/cc-native/rlm/overview.md +56 -56
  57. package/dist/templates/cc-native/.claude/commands/cc-native/specdev.md +10 -10
  58. package/dist/templates/cc-native/.windsurf/workflows/cc-native/fix.md +8 -8
  59. package/dist/templates/cc-native/.windsurf/workflows/cc-native/implement.md +8 -8
  60. package/dist/templates/cc-native/.windsurf/workflows/cc-native/research.md +8 -8
  61. package/dist/templates/cc-native/CC-NATIVE-README.md +189 -189
  62. package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +304 -304
  63. package/dist/templates/cc-native/_cc-native/agents/CLAUDE.md +143 -143
  64. package/dist/templates/cc-native/_cc-native/agents/PLAN-ORCHESTRATOR.md +213 -213
  65. package/dist/templates/cc-native/_cc-native/agents/plan-questions/PLAN-QUESTIONER.md +70 -70
  66. package/dist/templates/cc-native/_cc-native/cc-native.config.json +96 -96
  67. package/dist/templates/cc-native/_cc-native/hooks/CLAUDE.md +247 -247
  68. package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.ts +76 -76
  69. package/dist/templates/cc-native/_cc-native/hooks/enhance_plan_post_subagent.ts +54 -54
  70. package/dist/templates/cc-native/_cc-native/hooks/enhance_plan_post_write.ts +51 -51
  71. package/dist/templates/cc-native/_cc-native/hooks/mark_questions_asked.ts +53 -53
  72. package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.ts +61 -61
  73. package/dist/templates/cc-native/_cc-native/lib-ts/agent-selection.ts +163 -163
  74. package/dist/templates/cc-native/_cc-native/lib-ts/aggregate-agents.ts +156 -156
  75. package/dist/templates/cc-native/_cc-native/lib-ts/artifacts/format.ts +597 -597
  76. package/dist/templates/cc-native/_cc-native/lib-ts/artifacts/index.ts +26 -26
  77. package/dist/templates/cc-native/_cc-native/lib-ts/artifacts/tracker.ts +107 -107
  78. package/dist/templates/cc-native/_cc-native/lib-ts/artifacts/write.ts +119 -119
  79. package/dist/templates/cc-native/_cc-native/lib-ts/artifacts.ts +21 -21
  80. package/dist/templates/cc-native/_cc-native/lib-ts/cc-native-state.ts +319 -319
  81. package/dist/templates/cc-native/_cc-native/lib-ts/cli-output-parser.ts +144 -144
  82. package/dist/templates/cc-native/_cc-native/lib-ts/config.ts +57 -57
  83. package/dist/templates/cc-native/_cc-native/lib-ts/constants.ts +83 -83
  84. package/dist/templates/cc-native/_cc-native/lib-ts/corroboration.ts +119 -119
  85. package/dist/templates/cc-native/_cc-native/lib-ts/debug.ts +79 -79
  86. package/dist/templates/cc-native/_cc-native/lib-ts/graduation.ts +132 -132
  87. package/dist/templates/cc-native/_cc-native/lib-ts/index.ts +116 -116
  88. package/dist/templates/cc-native/_cc-native/lib-ts/json-parser.ts +168 -168
  89. package/dist/templates/cc-native/_cc-native/lib-ts/orchestrator.ts +70 -70
  90. package/dist/templates/cc-native/_cc-native/lib-ts/output-builder.ts +130 -130
  91. package/dist/templates/cc-native/_cc-native/lib-ts/plan-discovery.ts +80 -80
  92. package/dist/templates/cc-native/_cc-native/lib-ts/plan-enhancement.ts +41 -41
  93. package/dist/templates/cc-native/_cc-native/lib-ts/plan-questions.ts +101 -101
  94. package/dist/templates/cc-native/_cc-native/lib-ts/review-pipeline.ts +511 -511
  95. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/agent.ts +71 -71
  96. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/base/base-agent.ts +217 -217
  97. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/index.ts +12 -12
  98. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/providers/claude-agent.ts +66 -66
  99. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/providers/codex-agent.ts +184 -184
  100. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/providers/gemini-agent.ts +39 -39
  101. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/providers/orchestrator-claude-agent.ts +196 -196
  102. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/schemas.ts +201 -201
  103. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/types.ts +21 -21
  104. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/CLAUDE.md +480 -480
  105. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/embedding-indexer.ts +287 -287
  106. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/hyde.ts +148 -148
  107. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/index.ts +54 -54
  108. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/logger.ts +58 -58
  109. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/ollama-client.ts +208 -208
  110. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/retrieval-pipeline.ts +460 -460
  111. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/transcript-indexer.ts +446 -446
  112. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/transcript-loader.ts +280 -280
  113. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/transcript-searcher.ts +274 -274
  114. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/types.ts +201 -201
  115. package/dist/templates/cc-native/_cc-native/lib-ts/rlm/vector-store.ts +278 -278
  116. package/dist/templates/cc-native/_cc-native/lib-ts/settings.ts +184 -184
  117. package/dist/templates/cc-native/_cc-native/lib-ts/state.ts +275 -275
  118. package/dist/templates/cc-native/_cc-native/lib-ts/tsconfig.json +18 -18
  119. package/dist/templates/cc-native/_cc-native/lib-ts/types.ts +329 -329
  120. package/dist/templates/cc-native/_cc-native/lib-ts/verdict.ts +72 -72
  121. package/dist/templates/cc-native/_cc-native/workflows/specdev.md +9 -9
  122. package/oclif.manifest.json +1 -1
  123. package/package.json +108 -108
  124. package/dist/templates/cc-native/_cc-native/lib-ts/nul +0 -3
@@ -1,586 +1,586 @@
1
- /**
2
- * Common utilities for hook scripts.
3
- * Standardized boilerplate for JSON parsing, validation, error handling.
4
- * See SPEC.md §5
5
- */
6
-
1
+ /**
2
+ * Common utilities for hook scripts.
3
+ * Standardized boilerplate for JSON parsing, validation, error handling.
4
+ * See SPEC.md §5
5
+ */
6
+
7
7
  import * as fs from "node:fs";
8
-
9
- import { getProjectRoot } from "./constants.js";
10
- import { logDebug, logWarn, hookLog, setSessionId, getContextPath as _getContextPath } from "./logger.js";
11
- import { getContextBySessionId } from "../context/context-store.js";
12
- import type { HookInput, HookOutput, PermissionRequestOutput } from "../types.js";
13
-
14
- // Re-export logger functions for convenience (matches Python hook_utils re-exports)
15
- export { setSessionId };
16
-
17
- // Context window baseline: tokens not visible in hook data §5.9
18
- export const CONTEXT_BASELINE_TOKENS = 22_600;
19
- export const DEFAULT_CONTEXT_WINDOW_SIZE = 200_000;
20
-
21
- // Event metadata stash — populated by loadHookInput(), read by runHook()
22
- let _lastHookEvent: string | null = null;
23
- let _lastToolName: string | null = null;
24
- let _cachedHookName: string | null = null;
25
-
26
- // Pre-fetched input stash
27
- let _prefetchedInput: Record<string, any> | null = null;
28
-
29
- /**
30
- * Load and parse JSON from stdin (or return prefetched input if set).
31
- * Returns null if stdin is empty or invalid JSON.
32
- * See SPEC.md §5.1
33
- */
34
- export function loadHookInput(): HookInput | null {
35
- if (_prefetchedInput !== null) {
36
- const result = _prefetchedInput;
37
- _prefetchedInput = null; // consume once
38
- if (result && typeof result === "object") {
39
- _lastHookEvent = result.hook_event_name ?? null;
40
- _lastToolName = result.tool_name ?? null;
41
- }
42
- return result as HookInput;
43
- }
44
-
45
- try {
46
- // Read entire stdin using fd 0 (cross-platform, works on Windows)
47
- const inputData = fs.readFileSync(0, "utf-8").trim();
48
- if (!inputData) return null;
49
-
50
- const result = JSON.parse(inputData);
51
- if (result && typeof result === "object") {
52
- _lastHookEvent = result.hook_event_name ?? null;
53
- _lastToolName = result.tool_name ?? null;
54
- }
55
- return result as HookInput;
56
- } catch {
57
- return null;
58
- }
59
- }
60
-
61
- /**
62
- * Validate hook event type and optional tool name.
63
- * See SPEC.md §5.2
64
- */
65
- export function validateHookEvent(
66
- payload: HookInput,
67
- expectedEvent: string,
68
- expectedTool?: string,
69
- ): boolean {
70
- if (payload.hook_event_name !== expectedEvent) return false;
71
- if (expectedTool && payload.tool_name !== expectedTool) return false;
72
- return true;
73
- }
74
-
75
- /**
76
- * Extract and validate tool_input from payload.
77
- * See SPEC.md §5.3
78
- */
79
- export function getToolInput(
80
- payload: HookInput,
81
- ): Record<string, any> | null {
82
- const toolInput = payload.tool_input;
83
- return toolInput && typeof toolInput === "object" ? toolInput : null;
84
- }
85
-
86
- /**
87
- * Check if persistence should be skipped based on metadata flags.
88
- * See SPEC.md §5.4
89
- */
90
- export function checkSkipPersistence(
91
- payload: HookInput,
92
- hookName = "hook",
93
- ): boolean {
94
- const toolInput = getToolInput(payload);
95
- if (!toolInput) return false;
96
-
97
- const {metadata} = toolInput;
98
- if (metadata && typeof metadata === "object" && metadata.skip_persistence) {
99
- logDebug(hookName, "Skipping persistence (skip_persistence flag set)");
100
- return true;
101
- }
102
- return false;
103
- }
104
-
105
- /**
106
- * Emit hookSpecificOutput with additionalContext to stdout.
107
- * hookEventName is required by Claude Code's Zod validator (discriminated union).
108
- * Auto-detected from stdin payload (set by loadHookInput/runHook).
109
- *
110
- * SubagentStop and Stop events use top-level systemMessage field instead of hookSpecificOutput.
111
- * See SPEC.md §5.5
112
- */
113
- export function emitContext(additionalContext: string): void {
114
- const eventName = _lastHookEvent ?? undefined;
115
- const tool = _lastToolName;
116
-
117
- // SubagentStop and Stop use top-level systemMessage field
118
- if (eventName === "SubagentStop" || eventName === "Stop") {
119
- const out = { systemMessage: additionalContext };
120
- process.stdout.write(JSON.stringify(out) + "\n");
121
- _logEmit("systemMessage", additionalContext.length, { event: eventName ?? "unknown", systemMessage: additionalContext });
122
- return;
123
- }
124
-
125
- // All other events use hookSpecificOutput
126
- const out: HookOutput = {
127
- hookSpecificOutput: {
128
- ...(eventName ? { hookEventName: eventName } : {}),
129
- additionalContext,
130
- },
131
- };
132
- const json = JSON.stringify(out);
133
- const eventDesc = tool ? `${eventName}:${tool}` : eventName ?? "unknown";
134
- _logEmit("context", additionalContext.length, { event: eventDesc, additionalContext });
135
- process.stdout.write(json + "\n");
136
- }
137
-
138
- /**
139
- * Emit hookSpecificOutput that denies the tool call with context and reason.
140
- * hookEventName is required by Claude Code's Zod validator (discriminated union).
141
- * Auto-detected from stdin payload (set by loadHookInput/runHook).
142
- * See SPEC.md §5.6
143
- */
144
- export function emitContextAndBlock(
145
- additionalContext: string,
146
- reason: string,
147
- ): void {
148
- const eventName = _lastHookEvent ?? undefined;
149
- if (eventName && eventName !== "PreToolUse") {
150
- logWarn(_cachedHookName ?? "unknown",
151
- `emitContextAndBlock() called from ${eventName} — permissionDecision only works for PreToolUse. ` +
152
- `Use emitBlock() or the event-specific function instead.`);
153
- }
154
- const tool = _lastToolName;
155
- const out: HookOutput = {
156
- hookSpecificOutput: {
157
- ...(eventName ? { hookEventName: eventName } : {}),
158
- additionalContext,
159
- permissionDecision: "deny",
160
- permissionDecisionReason: reason,
161
- },
162
- };
163
- const json = JSON.stringify(out);
164
- const eventDesc = tool ? `${eventName}:${tool}` : eventName ?? "unknown";
165
- _logEmit("block", additionalContext.length, { event: eventDesc, additionalContext, blockReason: reason });
166
- process.stdout.write(json + "\n");
167
- }
168
-
169
- /** Log hook output (context, systemMessage, or block) to hook-log.jsonl for visibility. */
170
- function _logEmit(type: "context" | "systemMessage" | "block", chars: number, payload: Record<string, any>): void {
171
- const hook = _cachedHookName ?? "unknown";
172
- const event = payload.event ?? "unknown";
173
- const mechanism = payload.mechanism ? ` via ${payload.mechanism}` : "";
174
- const msg = type === "block"
175
- ? `HOOK_OUTPUT [${type}] ${event} ${chars} chars${mechanism}, reason="${(payload.blockReason ?? "").slice(0, 80)}"`
176
- : `HOOK_OUTPUT [${type}] ${event} ${chars} chars`;
177
- hookLog("info", hook, msg, { data: payload });
178
- }
179
-
180
- /**
181
- * Block a user prompt submission with a reason.
182
- * Only works for UserPromptSubmit hooks.
183
- * Output: top-level { decision: "block", reason } + optional hookSpecificOutput.additionalContext
184
- */
185
- export function emitBlockPrompt(reason: string, context?: string): void {
186
- const eventName = _lastHookEvent ?? undefined;
187
- if (eventName && eventName !== "UserPromptSubmit") {
188
- logWarn(_cachedHookName ?? "unknown",
189
- `emitBlockPrompt() called from ${eventName} — only works for UserPromptSubmit`);
190
- }
191
- const out: HookOutput = {
192
- decision: "block",
193
- reason,
194
- ...(context ? {
195
- hookSpecificOutput: {
196
- ...(eventName ? { hookEventName: eventName } : {}),
197
- additionalContext: context,
198
- }
199
- } : {}),
200
- };
201
- _logEmit("block", context?.length ?? 0, { event: eventName ?? "unknown", additionalContext: context, blockReason: reason });
202
- process.stdout.write(JSON.stringify(out) + "\n");
203
- }
204
-
205
- /**
206
- * Block via exit code 2 + stderr feedback.
207
- * Works for PostToolUse, PostToolUseFailure.
208
- * The reason becomes the stderr message (fed to Claude as system-reminder).
209
- * If context is provided, it's prepended to the stderr message for richer feedback.
210
- * NOTE: Exit 2 causes Claude Code to ignore all JSON stdout — only stderr matters.
211
- */
212
- export function emitBlockViaExit(reason: string, context?: string): void {
213
- const stderrMessage = context ? `${context}\n\n${reason}` : reason;
214
- _logEmit("block", stderrMessage.length, {
215
- event: _lastHookEvent ?? "unknown",
216
- blockReason: reason,
217
- mechanism: "exit2",
218
- });
219
- process.stderr.write(stderrMessage + "\n");
220
- throw new Error("SystemExit:2");
221
- }
222
-
223
- /**
224
- * Block via top-level { decision: "block", reason }.
225
- * Works for Stop and SubagentStop events.
226
- * These events do NOT support additionalContext — only reason is available.
227
- */
228
- export function emitBlockTopLevel(reason: string): void {
229
- const eventName = _lastHookEvent ?? undefined;
230
- if (eventName && eventName !== "Stop" && eventName !== "SubagentStop") {
231
- logWarn(_cachedHookName ?? "unknown",
232
- `emitBlockTopLevel() called from ${eventName} — only works for Stop/SubagentStop`);
233
- }
234
- const out = { decision: "block", reason };
235
- _logEmit("block", reason.length, {
236
- event: eventName ?? "unknown",
237
- blockReason: reason,
238
- mechanism: "topLevelDecision",
239
- });
240
- process.stdout.write(JSON.stringify(out) + "\n");
241
- }
242
-
243
- /**
244
- * Respond to a PermissionRequest with allow/deny.
245
- * Only works for PermissionRequest hooks.
246
- */
247
- export function emitPermissionDecision(
248
- behavior: "allow" | "deny",
249
- opts?: { message?: string; updatedInput?: Record<string, unknown>; updatedPermissions?: Record<string, unknown> },
250
- ): void {
251
- const out: PermissionRequestOutput = {
252
- decision: {
253
- behavior,
254
- ...(opts?.message ? { message: opts.message } : {}),
255
- ...(opts?.updatedInput ? { updatedInput: opts.updatedInput } : {}),
256
- ...(opts?.updatedPermissions ? { updatedPermissions: opts.updatedPermissions } : {}),
257
- },
258
- };
259
- _logEmit("block", 0, {
260
- event: _lastHookEvent ?? "unknown",
261
- blockReason: `permission:${behavior}`,
262
- mechanism: "permissionRequest",
263
- });
264
- process.stdout.write(JSON.stringify(out) + "\n");
265
- }
266
-
267
- /**
268
- * Unified block dispatcher — auto-detects the correct blocking mechanism
269
- * based on the current hook event type.
270
- *
271
- * PreToolUse → permissionDecision: "deny" (via emitContextAndBlock)
272
- * UserPromptSubmit → top-level decision: "block" (via emitBlockPrompt)
273
- * PostToolUse/PostToolUseFailure → exit(2) + stderr (via emitBlockViaExit)
274
- * Stop/SubagentStop → top-level { decision: "block", reason } (via emitBlockTopLevel)
275
- * PermissionRequest → decision: { behavior: "deny" } (via emitPermissionDecision)
276
- * SessionStart/Notification/SubagentStart/SessionEnd/etc. → warn and no-op
277
- *
278
- * This is the RECOMMENDED universal blocking API. Hook authors should use
279
- * emitBlock() and let the library handle event-specific dispatch.
280
- */
281
- export function emitBlock(reason: string, context?: string): void {
282
- const event = _lastHookEvent;
283
- switch (event) {
284
- case "PermissionRequest":
285
- emitPermissionDecision("deny", { message: reason });
286
- break;
287
- case "PostToolUse":
288
- case "PostToolUseFailure":
289
- emitBlockViaExit(reason, context);
290
- break;
291
- case "PreToolUse":
292
- emitContextAndBlock(context ?? reason, reason);
293
- break;
294
- case "Stop":
295
- case "SubagentStop":
296
- emitBlockTopLevel(reason);
297
- break;
298
- case "UserPromptSubmit":
299
- emitBlockPrompt(reason, context);
300
- break;
301
- default: {
302
- logWarn(_cachedHookName ?? "unknown",
303
- `emitBlock() called from ${event ?? "unknown"} — no blocking mechanism exists for this event type, ignoring`);
8
+
9
+ import { getProjectRoot } from "./constants.js";
10
+ import { logDebug, logWarn, hookLog, setSessionId, getContextPath as _getContextPath } from "./logger.js";
11
+ import { getContextBySessionId } from "../context/context-store.js";
12
+ import type { HookInput, HookOutput, PermissionRequestOutput } from "../types.js";
13
+
14
+ // Re-export logger functions for convenience (matches Python hook_utils re-exports)
15
+ export { setSessionId };
16
+
17
+ // Context window baseline: tokens not visible in hook data §5.9
18
+ export const CONTEXT_BASELINE_TOKENS = 22_600;
19
+ export const DEFAULT_CONTEXT_WINDOW_SIZE = 200_000;
20
+
21
+ // Event metadata stash — populated by loadHookInput(), read by runHook()
22
+ let _lastHookEvent: string | null = null;
23
+ let _lastToolName: string | null = null;
24
+ let _cachedHookName: string | null = null;
25
+
26
+ // Pre-fetched input stash
27
+ let _prefetchedInput: Record<string, any> | null = null;
28
+
29
+ /**
30
+ * Load and parse JSON from stdin (or return prefetched input if set).
31
+ * Returns null if stdin is empty or invalid JSON.
32
+ * See SPEC.md §5.1
33
+ */
34
+ export function loadHookInput(): HookInput | null {
35
+ if (_prefetchedInput !== null) {
36
+ const result = _prefetchedInput;
37
+ _prefetchedInput = null; // consume once
38
+ if (result && typeof result === "object") {
39
+ _lastHookEvent = result.hook_event_name ?? null;
40
+ _lastToolName = result.tool_name ?? null;
41
+ }
42
+ return result as HookInput;
43
+ }
44
+
45
+ try {
46
+ // Read entire stdin using fd 0 (cross-platform, works on Windows)
47
+ const inputData = fs.readFileSync(0, "utf-8").trim();
48
+ if (!inputData) return null;
49
+
50
+ const result = JSON.parse(inputData);
51
+ if (result && typeof result === "object") {
52
+ _lastHookEvent = result.hook_event_name ?? null;
53
+ _lastToolName = result.tool_name ?? null;
54
+ }
55
+ return result as HookInput;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Validate hook event type and optional tool name.
63
+ * See SPEC.md §5.2
64
+ */
65
+ export function validateHookEvent(
66
+ payload: HookInput,
67
+ expectedEvent: string,
68
+ expectedTool?: string,
69
+ ): boolean {
70
+ if (payload.hook_event_name !== expectedEvent) return false;
71
+ if (expectedTool && payload.tool_name !== expectedTool) return false;
72
+ return true;
73
+ }
74
+
75
+ /**
76
+ * Extract and validate tool_input from payload.
77
+ * See SPEC.md §5.3
78
+ */
79
+ export function getToolInput(
80
+ payload: HookInput,
81
+ ): Record<string, any> | null {
82
+ const toolInput = payload.tool_input;
83
+ return toolInput && typeof toolInput === "object" ? toolInput : null;
84
+ }
85
+
86
+ /**
87
+ * Check if persistence should be skipped based on metadata flags.
88
+ * See SPEC.md §5.4
89
+ */
90
+ export function checkSkipPersistence(
91
+ payload: HookInput,
92
+ hookName = "hook",
93
+ ): boolean {
94
+ const toolInput = getToolInput(payload);
95
+ if (!toolInput) return false;
96
+
97
+ const {metadata} = toolInput;
98
+ if (metadata && typeof metadata === "object" && metadata.skip_persistence) {
99
+ logDebug(hookName, "Skipping persistence (skip_persistence flag set)");
100
+ return true;
101
+ }
102
+ return false;
103
+ }
104
+
105
+ /**
106
+ * Emit hookSpecificOutput with additionalContext to stdout.
107
+ * hookEventName is required by Claude Code's Zod validator (discriminated union).
108
+ * Auto-detected from stdin payload (set by loadHookInput/runHook).
109
+ *
110
+ * SubagentStop and Stop events use top-level systemMessage field instead of hookSpecificOutput.
111
+ * See SPEC.md §5.5
112
+ */
113
+ export function emitContext(additionalContext: string): void {
114
+ const eventName = _lastHookEvent ?? undefined;
115
+ const tool = _lastToolName;
116
+
117
+ // SubagentStop and Stop use top-level systemMessage field
118
+ if (eventName === "SubagentStop" || eventName === "Stop") {
119
+ const out = { systemMessage: additionalContext };
120
+ process.stdout.write(JSON.stringify(out) + "\n");
121
+ _logEmit("systemMessage", additionalContext.length, { event: eventName ?? "unknown", systemMessage: additionalContext });
122
+ return;
123
+ }
124
+
125
+ // All other events use hookSpecificOutput
126
+ const out: HookOutput = {
127
+ hookSpecificOutput: {
128
+ ...(eventName ? { hookEventName: eventName } : {}),
129
+ additionalContext,
130
+ },
131
+ };
132
+ const json = JSON.stringify(out);
133
+ const eventDesc = tool ? `${eventName}:${tool}` : eventName ?? "unknown";
134
+ _logEmit("context", additionalContext.length, { event: eventDesc, additionalContext });
135
+ process.stdout.write(json + "\n");
136
+ }
137
+
138
+ /**
139
+ * Emit hookSpecificOutput that denies the tool call with context and reason.
140
+ * hookEventName is required by Claude Code's Zod validator (discriminated union).
141
+ * Auto-detected from stdin payload (set by loadHookInput/runHook).
142
+ * See SPEC.md §5.6
143
+ */
144
+ export function emitContextAndBlock(
145
+ additionalContext: string,
146
+ reason: string,
147
+ ): void {
148
+ const eventName = _lastHookEvent ?? undefined;
149
+ if (eventName && eventName !== "PreToolUse") {
150
+ logWarn(_cachedHookName ?? "unknown",
151
+ `emitContextAndBlock() called from ${eventName} — permissionDecision only works for PreToolUse. ` +
152
+ `Use emitBlock() or the event-specific function instead.`);
153
+ }
154
+ const tool = _lastToolName;
155
+ const out: HookOutput = {
156
+ hookSpecificOutput: {
157
+ ...(eventName ? { hookEventName: eventName } : {}),
158
+ additionalContext,
159
+ permissionDecision: "deny",
160
+ permissionDecisionReason: reason,
161
+ },
162
+ };
163
+ const json = JSON.stringify(out);
164
+ const eventDesc = tool ? `${eventName}:${tool}` : eventName ?? "unknown";
165
+ _logEmit("block", additionalContext.length, { event: eventDesc, additionalContext, blockReason: reason });
166
+ process.stdout.write(json + "\n");
167
+ }
168
+
169
+ /** Log hook output (context, systemMessage, or block) to hook-log.jsonl for visibility. */
170
+ function _logEmit(type: "context" | "systemMessage" | "block", chars: number, payload: Record<string, any>): void {
171
+ const hook = _cachedHookName ?? "unknown";
172
+ const event = payload.event ?? "unknown";
173
+ const mechanism = payload.mechanism ? ` via ${payload.mechanism}` : "";
174
+ const msg = type === "block"
175
+ ? `HOOK_OUTPUT [${type}] ${event} ${chars} chars${mechanism}, reason="${(payload.blockReason ?? "").slice(0, 80)}"`
176
+ : `HOOK_OUTPUT [${type}] ${event} ${chars} chars`;
177
+ hookLog("info", hook, msg, { data: payload });
178
+ }
179
+
180
+ /**
181
+ * Block a user prompt submission with a reason.
182
+ * Only works for UserPromptSubmit hooks.
183
+ * Output: top-level { decision: "block", reason } + optional hookSpecificOutput.additionalContext
184
+ */
185
+ export function emitBlockPrompt(reason: string, context?: string): void {
186
+ const eventName = _lastHookEvent ?? undefined;
187
+ if (eventName && eventName !== "UserPromptSubmit") {
188
+ logWarn(_cachedHookName ?? "unknown",
189
+ `emitBlockPrompt() called from ${eventName} — only works for UserPromptSubmit`);
190
+ }
191
+ const out: HookOutput = {
192
+ decision: "block",
193
+ reason,
194
+ ...(context ? {
195
+ hookSpecificOutput: {
196
+ ...(eventName ? { hookEventName: eventName } : {}),
197
+ additionalContext: context,
198
+ }
199
+ } : {}),
200
+ };
201
+ _logEmit("block", context?.length ?? 0, { event: eventName ?? "unknown", additionalContext: context, blockReason: reason });
202
+ process.stdout.write(JSON.stringify(out) + "\n");
203
+ }
204
+
205
+ /**
206
+ * Block via exit code 2 + stderr feedback.
207
+ * Works for PostToolUse, PostToolUseFailure.
208
+ * The reason becomes the stderr message (fed to Claude as system-reminder).
209
+ * If context is provided, it's prepended to the stderr message for richer feedback.
210
+ * NOTE: Exit 2 causes Claude Code to ignore all JSON stdout — only stderr matters.
211
+ */
212
+ export function emitBlockViaExit(reason: string, context?: string): void {
213
+ const stderrMessage = context ? `${context}\n\n${reason}` : reason;
214
+ _logEmit("block", stderrMessage.length, {
215
+ event: _lastHookEvent ?? "unknown",
216
+ blockReason: reason,
217
+ mechanism: "exit2",
218
+ });
219
+ process.stderr.write(stderrMessage + "\n");
220
+ throw new Error("SystemExit:2");
221
+ }
222
+
223
+ /**
224
+ * Block via top-level { decision: "block", reason }.
225
+ * Works for Stop and SubagentStop events.
226
+ * These events do NOT support additionalContext — only reason is available.
227
+ */
228
+ export function emitBlockTopLevel(reason: string): void {
229
+ const eventName = _lastHookEvent ?? undefined;
230
+ if (eventName && eventName !== "Stop" && eventName !== "SubagentStop") {
231
+ logWarn(_cachedHookName ?? "unknown",
232
+ `emitBlockTopLevel() called from ${eventName} — only works for Stop/SubagentStop`);
233
+ }
234
+ const out = { decision: "block", reason };
235
+ _logEmit("block", reason.length, {
236
+ event: eventName ?? "unknown",
237
+ blockReason: reason,
238
+ mechanism: "topLevelDecision",
239
+ });
240
+ process.stdout.write(JSON.stringify(out) + "\n");
241
+ }
242
+
243
+ /**
244
+ * Respond to a PermissionRequest with allow/deny.
245
+ * Only works for PermissionRequest hooks.
246
+ */
247
+ export function emitPermissionDecision(
248
+ behavior: "allow" | "deny",
249
+ opts?: { message?: string; updatedInput?: Record<string, unknown>; updatedPermissions?: Record<string, unknown> },
250
+ ): void {
251
+ const out: PermissionRequestOutput = {
252
+ decision: {
253
+ behavior,
254
+ ...(opts?.message ? { message: opts.message } : {}),
255
+ ...(opts?.updatedInput ? { updatedInput: opts.updatedInput } : {}),
256
+ ...(opts?.updatedPermissions ? { updatedPermissions: opts.updatedPermissions } : {}),
257
+ },
258
+ };
259
+ _logEmit("block", 0, {
260
+ event: _lastHookEvent ?? "unknown",
261
+ blockReason: `permission:${behavior}`,
262
+ mechanism: "permissionRequest",
263
+ });
264
+ process.stdout.write(JSON.stringify(out) + "\n");
265
+ }
266
+
267
+ /**
268
+ * Unified block dispatcher — auto-detects the correct blocking mechanism
269
+ * based on the current hook event type.
270
+ *
271
+ * PreToolUse → permissionDecision: "deny" (via emitContextAndBlock)
272
+ * UserPromptSubmit → top-level decision: "block" (via emitBlockPrompt)
273
+ * PostToolUse/PostToolUseFailure → exit(2) + stderr (via emitBlockViaExit)
274
+ * Stop/SubagentStop → top-level { decision: "block", reason } (via emitBlockTopLevel)
275
+ * PermissionRequest → decision: { behavior: "deny" } (via emitPermissionDecision)
276
+ * SessionStart/Notification/SubagentStart/SessionEnd/etc. → warn and no-op
277
+ *
278
+ * This is the RECOMMENDED universal blocking API. Hook authors should use
279
+ * emitBlock() and let the library handle event-specific dispatch.
280
+ */
281
+ export function emitBlock(reason: string, context?: string): void {
282
+ const event = _lastHookEvent;
283
+ switch (event) {
284
+ case "PermissionRequest":
285
+ emitPermissionDecision("deny", { message: reason });
286
+ break;
287
+ case "PostToolUse":
288
+ case "PostToolUseFailure":
289
+ emitBlockViaExit(reason, context);
290
+ break;
291
+ case "PreToolUse":
292
+ emitContextAndBlock(context ?? reason, reason);
293
+ break;
294
+ case "Stop":
295
+ case "SubagentStop":
296
+ emitBlockTopLevel(reason);
304
297
  break;
305
- }
306
- }
307
- }
308
-
309
- /**
310
- * Auto-detect template origin from the hook script path.
311
- */
312
- function detectTemplate(scriptPath = ""): string {
313
- const p = (scriptPath || (process.argv[1] ?? "")).replaceAll('\\', "/");
314
- if (p.includes("/_shared/hooks/") || p.startsWith("_shared/hooks/")) {
315
- return "shared";
316
- }
317
- const match = p.match(/_([a-z][a-z0-9-]*)\/hooks\//);
318
- if (match?.[1]) return match[1]; // e.g., "cc-native"
319
- return "unknown";
320
- }
321
-
322
- /**
323
- * Parse context window from hook input.
324
- * Returns [tokensUsed, maxTokens] or [null, null].
325
- * See SPEC.md §5.9
326
- */
327
- export function parseContextWindow(
328
- hookInput: HookInput,
329
- ): [number | null, number | null] {
330
- const contextWindow = hookInput.context_window;
331
- if (!contextWindow) return [null, null];
332
-
333
- const currentUsage = contextWindow.current_usage;
334
- if (!currentUsage) return [null, null];
335
-
336
- const cacheRead = currentUsage.cache_read_input_tokens ?? 0;
337
- const inputTokens = currentUsage.input_tokens ?? 0;
338
- const cacheCreation = currentUsage.cache_creation_input_tokens ?? 0;
339
- const outputTokens = currentUsage.output_tokens ?? 0;
340
-
341
- const contentTokens = cacheRead + inputTokens + cacheCreation + outputTokens;
342
- const tokensUsed = contentTokens + CONTEXT_BASELINE_TOKENS;
343
- const maxTokens = contextWindow.context_window_size ?? DEFAULT_CONTEXT_WINDOW_SIZE;
344
-
345
- return [tokensUsed, maxTokens];
346
- }
347
-
348
- /**
349
- * Get context percentage remaining with fallback.
350
- * Returns [percentRemaining, tokensUsed, maxTokens] or [null, null, null].
351
- * See SPEC.md §5.9
352
- */
353
- export function getContextPercentRemaining(
354
- hookInput: HookInput,
355
- ): [number | null, number | null, number | null] {
356
- const [tokensUsed, maxTokens] = parseContextWindow(hookInput);
357
-
358
- if (tokensUsed !== null && maxTokens !== null && maxTokens > 0) {
359
- const remaining = maxTokens - tokensUsed;
360
- const percentRemaining = Math.max(
361
- 0,
362
- Math.min(100, Math.round((remaining / maxTokens) * 100)),
363
- );
364
- return [percentRemaining, tokensUsed, maxTokens];
365
- }
366
-
367
- // Source 2: context.json fallback (written by status_line.py)
368
- try {
369
- const sessionId = hookInput.session_id;
370
- if (sessionId) {
371
- const projectRoot = getProjectRoot(hookInput.cwd);
372
- const context = getContextBySessionId(sessionId, projectRoot);
373
- if (context?.last_session?.context_remaining_pct !== undefined) {
374
- return [context.last_session.context_remaining_pct, null, null];
375
- }
376
- }
377
- } catch {
378
- // Fallback failed — degrade gracefully
379
- }
380
-
381
- return [null, null, null];
382
- }
383
-
384
- /**
385
- * Read stdin early and extract session_id + event metadata.
386
- * Stashes parsed input for loadHookInput() to consume later.
387
- */
388
- function _earlyReadInput(prefetchedInput?: Record<string, any>): void {
389
- if (prefetchedInput !== undefined) {
390
- _prefetchedInput = prefetchedInput;
391
- }
392
-
393
- // If we already have prefetched input, extract metadata from it
394
- if (_prefetchedInput && typeof _prefetchedInput === "object") {
395
- _lastHookEvent = _prefetchedInput.hook_event_name ?? null;
396
- _lastToolName = _prefetchedInput.tool_name ?? null;
397
- if (_prefetchedInput.session_id) {
398
- setSessionId(_prefetchedInput.session_id);
399
- }
400
- return;
401
- }
402
-
403
- // Read stdin now so HOOK_START can include sid
404
- try {
405
- const inputData = fs.readFileSync(0, "utf-8").trim();
406
- if (inputData) {
407
- const parsed = JSON.parse(inputData);
408
- if (parsed && typeof parsed === "object") {
409
- _prefetchedInput = parsed;
410
- _lastHookEvent = parsed.hook_event_name ?? null;
411
- _lastToolName = parsed.tool_name ?? null;
412
- if (parsed.session_id) {
413
- setSessionId(parsed.session_id);
414
- }
415
- }
416
- }
417
- } catch {
418
- // Non-fatal loadHookInput will return null
419
- }
420
- }
421
-
422
- /**
423
- * Standard hook entry point with lifecycle logging.
424
- * See SPEC.md §5.7
425
- */
426
- export function runHook(
427
- mainFunc: () => number | void,
428
- hookName = "unknown",
429
- prefetchedInput?: Record<string, any>,
430
- ): never {
431
- _earlyReadInput(prefetchedInput);
432
- _cachedHookName = hookName;
433
-
434
- const startTime = performance.now();
435
- const template = detectTemplate();
436
- const event = _lastHookEvent ?? "unknown";
437
- const tool = _lastToolName;
438
-
439
- const startData: Record<string, any> = {
440
- lifecycle: "start",
441
- template,
442
- event,
443
- };
444
- if (tool) startData.tool = tool;
445
- hookLog("info", hookName, "HOOK_START", { data: startData });
446
-
447
- let exitCode = 0;
448
- let status = "success";
449
- let errorInfo: [Error, string] | null = null;
450
-
451
- try {
452
- const result = mainFunc();
453
- exitCode = typeof result === "number" ? result : 0;
454
- status = exitCode !== 0 ? "blocked" : "success";
455
- } catch (error: any) {
456
- if (error instanceof Error && error.message.startsWith("SystemExit:")) {
457
- const code = parseInt(error.message.slice(11), 10);
458
- exitCode = isNaN(code) ? (error.message.slice(11) ? 1 : 0) : code;
459
- status = exitCode !== 0 ? "blocked" : "success";
460
- } else {
461
- exitCode = 0; // Non-blocking
462
- status = "error";
463
- const stack = error instanceof Error ? error.stack ?? "" : "";
464
- errorInfo = [error instanceof Error ? error : new Error(String(error)), stack];
465
- }
466
- }
467
-
468
- _emitHookEnd(hookName, startTime, exitCode, status, errorInfo, startData, event, tool, template);
469
- process.exit(exitCode);
470
- }
471
-
472
- /**
473
- * Async variant of runHook for hooks that need await (e.g., AI inference).
474
- * Provides identical structured JSONL lifecycle logging as runHook.
475
- * See SPEC.md §5.7
476
- */
477
- export function runHookAsync(
478
- mainFunc: () => Promise<number | void>,
479
- hookName = "unknown",
480
- prefetchedInput?: Record<string, any>,
481
- ): void {
482
- _earlyReadInput(prefetchedInput);
483
- _cachedHookName = hookName;
484
-
485
- const startTime = performance.now();
486
- const template = detectTemplate();
487
- const event = _lastHookEvent ?? "unknown";
488
- const tool = _lastToolName;
489
-
490
- const startData: Record<string, any> = {
491
- lifecycle: "start",
492
- template,
493
- event,
494
- };
495
- if (tool) startData.tool = tool;
496
- hookLog("info", hookName, "HOOK_START", { data: startData });
497
-
498
- mainFunc()
499
- .then((result) => {
500
- const exitCode = typeof result === "number" ? result : 0;
501
- _emitHookEnd(hookName, startTime, exitCode, exitCode !== 0 ? "blocked" : "success", null, startData, event, tool, template);
502
- _drainAndExit(exitCode);
503
- })
504
- .catch((error: any) => {
505
- let exitCode = 0;
506
- let status = "error";
507
- let errorInfo: [Error, string] | null = null;
508
-
509
- if (error instanceof Error && error.message.startsWith("SystemExit:")) {
510
- const code = parseInt(error.message.slice(11), 10);
511
- exitCode = isNaN(code) ? (error.message.slice(11) ? 1 : 0) : code;
512
- status = exitCode !== 0 ? "blocked" : "success";
513
- } else {
514
- exitCode = 0; // Non-blocking (fail open)
515
- const stack = error instanceof Error ? error.stack ?? "" : "";
516
- errorInfo = [error instanceof Error ? error : new Error(String(error)), stack];
517
- }
518
-
519
- _emitHookEnd(hookName, startTime, exitCode, status, errorInfo, startData, event, tool, template);
520
- _drainAndExit(exitCode);
521
- });
522
- }
523
-
524
- /** Shared HOOK_END logic for runHook and runHookAsync */
525
- function _emitHookEnd(
526
- hookName: string,
527
- startTime: number,
528
- exitCode: number,
529
- status: string,
530
- errorInfo: [Error, string] | null,
531
- startData: Record<string, any>,
532
- event: string,
533
- tool: string | null,
534
- template: string,
535
- ): void {
536
- // Retroactive HOOK_START to per-context log (context_path resolved after main runs)
537
- const resolvedAfter = _getContextPath();
538
- if (resolvedAfter && fs.existsSync(resolvedAfter)) {
539
- hookLog("info", hookName, "HOOK_START", { data: startData });
540
- }
541
-
542
- const durationMs = Math.round((performance.now() - startTime) * 10) / 10;
543
- const endEvent = _lastHookEvent ?? event;
544
- const endTool = _lastToolName ?? tool;
545
- const endData: Record<string, any> = {
546
- lifecycle: "end",
547
- status,
548
- duration_ms: durationMs,
549
- exit_code: exitCode,
550
- template,
551
- event: endEvent,
552
- };
553
- if (endTool) endData.tool = endTool;
554
-
555
- if (errorInfo) {
556
- const [err, tb] = errorInfo;
557
- endData.error_type = err.constructor.name;
558
- hookLog("error", hookName, `[${endEvent}] ${err.constructor.name}: ${String(err).replaceAll(/[\n\r]/g, " ").slice(0, 200)}`, { traceback_str: tb });
559
- hookLog("error", hookName, `HOOK_END: ${err}`, { data: endData, traceback_str: tb });
560
- } else if (status === "blocked") {
561
- hookLog("warn", hookName, "HOOK_END", { data: endData });
562
- } else {
563
- hookLog("info", hookName, "HOOK_END", { data: endData });
564
- }
565
- }
566
-
567
- /**
568
- * Drain stdout before exiting to ensure pipe consumers receive all data.
569
- * On Windows, stdout to a pipe is fully buffered — process.exit() can
570
- * discard unflushed data. This waits for the write buffer to drain.
571
- */
572
- function _drainAndExit(code: number): void {
573
- // If stdout is already finished or not writable, exit immediately
574
- if (!process.stdout.writable || process.stdout.writableFinished) {
575
- process.exit(code);
576
- }
577
-
578
- // Attempt to end stdout and wait for drain
579
- const timeout = setTimeout(() => process.exit(code), 1000); // safety fallback
580
- process.stdout.end(() => {
581
- clearTimeout(timeout);
582
- process.exit(code);
583
- });
584
- }
298
+ case "UserPromptSubmit":
299
+ emitBlockPrompt(reason, context);
300
+ break;
301
+ default: {
302
+ logWarn(_cachedHookName ?? "unknown",
303
+ `emitBlock() called from ${event ?? "unknown"} no blocking mechanism exists for this event type, ignoring`);
304
+ break;
305
+ }
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Auto-detect template origin from the hook script path.
311
+ */
312
+ function detectTemplate(scriptPath = ""): string {
313
+ const p = (scriptPath || (process.argv[1] ?? "")).replaceAll('\\', "/");
314
+ if (p.includes("/_shared/hooks/") || p.startsWith("_shared/hooks/")) {
315
+ return "shared";
316
+ }
317
+ const match = p.match(/_([a-z][a-z0-9-]*)\/hooks\//);
318
+ if (match?.[1]) return match[1]; // e.g., "cc-native"
319
+ return "unknown";
320
+ }
321
+
322
+ /**
323
+ * Parse context window from hook input.
324
+ * Returns [tokensUsed, maxTokens] or [null, null].
325
+ * See SPEC.md §5.9
326
+ */
327
+ export function parseContextWindow(
328
+ hookInput: HookInput,
329
+ ): [number | null, number | null] {
330
+ const contextWindow = hookInput.context_window;
331
+ if (!contextWindow) return [null, null];
332
+
333
+ const currentUsage = contextWindow.current_usage;
334
+ if (!currentUsage) return [null, null];
335
+
336
+ const cacheRead = currentUsage.cache_read_input_tokens ?? 0;
337
+ const inputTokens = currentUsage.input_tokens ?? 0;
338
+ const cacheCreation = currentUsage.cache_creation_input_tokens ?? 0;
339
+ const outputTokens = currentUsage.output_tokens ?? 0;
340
+
341
+ const contentTokens = cacheRead + inputTokens + cacheCreation + outputTokens;
342
+ const tokensUsed = contentTokens + CONTEXT_BASELINE_TOKENS;
343
+ const maxTokens = contextWindow.context_window_size ?? DEFAULT_CONTEXT_WINDOW_SIZE;
344
+
345
+ return [tokensUsed, maxTokens];
346
+ }
347
+
348
+ /**
349
+ * Get context percentage remaining with fallback.
350
+ * Returns [percentRemaining, tokensUsed, maxTokens] or [null, null, null].
351
+ * See SPEC.md §5.9
352
+ */
353
+ export function getContextPercentRemaining(
354
+ hookInput: HookInput,
355
+ ): [number | null, number | null, number | null] {
356
+ const [tokensUsed, maxTokens] = parseContextWindow(hookInput);
357
+
358
+ if (tokensUsed !== null && maxTokens !== null && maxTokens > 0) {
359
+ const remaining = maxTokens - tokensUsed;
360
+ const percentRemaining = Math.max(
361
+ 0,
362
+ Math.min(100, Math.round((remaining / maxTokens) * 100)),
363
+ );
364
+ return [percentRemaining, tokensUsed, maxTokens];
365
+ }
366
+
367
+ // Source 2: context.json fallback (written by status_line.py)
368
+ try {
369
+ const sessionId = hookInput.session_id;
370
+ if (sessionId) {
371
+ const projectRoot = getProjectRoot(hookInput.cwd);
372
+ const context = getContextBySessionId(sessionId, projectRoot);
373
+ if (context?.last_session?.context_remaining_pct !== undefined) {
374
+ return [context.last_session.context_remaining_pct, null, null];
375
+ }
376
+ }
377
+ } catch {
378
+ // Fallback failed degrade gracefully
379
+ }
380
+
381
+ return [null, null, null];
382
+ }
383
+
384
+ /**
385
+ * Read stdin early and extract session_id + event metadata.
386
+ * Stashes parsed input for loadHookInput() to consume later.
387
+ */
388
+ function _earlyReadInput(prefetchedInput?: Record<string, any>): void {
389
+ if (prefetchedInput !== undefined) {
390
+ _prefetchedInput = prefetchedInput;
391
+ }
392
+
393
+ // If we already have prefetched input, extract metadata from it
394
+ if (_prefetchedInput && typeof _prefetchedInput === "object") {
395
+ _lastHookEvent = _prefetchedInput.hook_event_name ?? null;
396
+ _lastToolName = _prefetchedInput.tool_name ?? null;
397
+ if (_prefetchedInput.session_id) {
398
+ setSessionId(_prefetchedInput.session_id);
399
+ }
400
+ return;
401
+ }
402
+
403
+ // Read stdin now so HOOK_START can include sid
404
+ try {
405
+ const inputData = fs.readFileSync(0, "utf-8").trim();
406
+ if (inputData) {
407
+ const parsed = JSON.parse(inputData);
408
+ if (parsed && typeof parsed === "object") {
409
+ _prefetchedInput = parsed;
410
+ _lastHookEvent = parsed.hook_event_name ?? null;
411
+ _lastToolName = parsed.tool_name ?? null;
412
+ if (parsed.session_id) {
413
+ setSessionId(parsed.session_id);
414
+ }
415
+ }
416
+ }
417
+ } catch {
418
+ // Non-fatal — loadHookInput will return null
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Standard hook entry point with lifecycle logging.
424
+ * See SPEC.md §5.7
425
+ */
426
+ export function runHook(
427
+ mainFunc: () => number | void,
428
+ hookName = "unknown",
429
+ prefetchedInput?: Record<string, any>,
430
+ ): never {
431
+ _earlyReadInput(prefetchedInput);
432
+ _cachedHookName = hookName;
433
+
434
+ const startTime = performance.now();
435
+ const template = detectTemplate();
436
+ const event = _lastHookEvent ?? "unknown";
437
+ const tool = _lastToolName;
438
+
439
+ const startData: Record<string, any> = {
440
+ lifecycle: "start",
441
+ template,
442
+ event,
443
+ };
444
+ if (tool) startData.tool = tool;
445
+ hookLog("info", hookName, "HOOK_START", { data: startData });
446
+
447
+ let exitCode = 0;
448
+ let status = "success";
449
+ let errorInfo: [Error, string] | null = null;
450
+
451
+ try {
452
+ const result = mainFunc();
453
+ exitCode = typeof result === "number" ? result : 0;
454
+ status = exitCode !== 0 ? "blocked" : "success";
455
+ } catch (error: any) {
456
+ if (error instanceof Error && error.message.startsWith("SystemExit:")) {
457
+ const code = parseInt(error.message.slice(11), 10);
458
+ exitCode = isNaN(code) ? (error.message.slice(11) ? 1 : 0) : code;
459
+ status = exitCode !== 0 ? "blocked" : "success";
460
+ } else {
461
+ exitCode = 0; // Non-blocking
462
+ status = "error";
463
+ const stack = error instanceof Error ? error.stack ?? "" : "";
464
+ errorInfo = [error instanceof Error ? error : new Error(String(error)), stack];
465
+ }
466
+ }
467
+
468
+ _emitHookEnd(hookName, startTime, exitCode, status, errorInfo, startData, event, tool, template);
469
+ process.exit(exitCode);
470
+ }
471
+
472
+ /**
473
+ * Async variant of runHook for hooks that need await (e.g., AI inference).
474
+ * Provides identical structured JSONL lifecycle logging as runHook.
475
+ * See SPEC.md §5.7
476
+ */
477
+ export function runHookAsync(
478
+ mainFunc: () => Promise<number | void>,
479
+ hookName = "unknown",
480
+ prefetchedInput?: Record<string, any>,
481
+ ): void {
482
+ _earlyReadInput(prefetchedInput);
483
+ _cachedHookName = hookName;
484
+
485
+ const startTime = performance.now();
486
+ const template = detectTemplate();
487
+ const event = _lastHookEvent ?? "unknown";
488
+ const tool = _lastToolName;
489
+
490
+ const startData: Record<string, any> = {
491
+ lifecycle: "start",
492
+ template,
493
+ event,
494
+ };
495
+ if (tool) startData.tool = tool;
496
+ hookLog("info", hookName, "HOOK_START", { data: startData });
497
+
498
+ mainFunc()
499
+ .then((result) => {
500
+ const exitCode = typeof result === "number" ? result : 0;
501
+ _emitHookEnd(hookName, startTime, exitCode, exitCode !== 0 ? "blocked" : "success", null, startData, event, tool, template);
502
+ _drainAndExit(exitCode);
503
+ })
504
+ .catch((error: any) => {
505
+ let exitCode = 0;
506
+ let status = "error";
507
+ let errorInfo: [Error, string] | null = null;
508
+
509
+ if (error instanceof Error && error.message.startsWith("SystemExit:")) {
510
+ const code = parseInt(error.message.slice(11), 10);
511
+ exitCode = isNaN(code) ? (error.message.slice(11) ? 1 : 0) : code;
512
+ status = exitCode !== 0 ? "blocked" : "success";
513
+ } else {
514
+ exitCode = 0; // Non-blocking (fail open)
515
+ const stack = error instanceof Error ? error.stack ?? "" : "";
516
+ errorInfo = [error instanceof Error ? error : new Error(String(error)), stack];
517
+ }
518
+
519
+ _emitHookEnd(hookName, startTime, exitCode, status, errorInfo, startData, event, tool, template);
520
+ _drainAndExit(exitCode);
521
+ });
522
+ }
523
+
524
+ /** Shared HOOK_END logic for runHook and runHookAsync */
525
+ function _emitHookEnd(
526
+ hookName: string,
527
+ startTime: number,
528
+ exitCode: number,
529
+ status: string,
530
+ errorInfo: [Error, string] | null,
531
+ startData: Record<string, any>,
532
+ event: string,
533
+ tool: string | null,
534
+ template: string,
535
+ ): void {
536
+ // Retroactive HOOK_START to per-context log (context_path resolved after main runs)
537
+ const resolvedAfter = _getContextPath();
538
+ if (resolvedAfter && fs.existsSync(resolvedAfter)) {
539
+ hookLog("info", hookName, "HOOK_START", { data: startData });
540
+ }
541
+
542
+ const durationMs = Math.round((performance.now() - startTime) * 10) / 10;
543
+ const endEvent = _lastHookEvent ?? event;
544
+ const endTool = _lastToolName ?? tool;
545
+ const endData: Record<string, any> = {
546
+ lifecycle: "end",
547
+ status,
548
+ duration_ms: durationMs,
549
+ exit_code: exitCode,
550
+ template,
551
+ event: endEvent,
552
+ };
553
+ if (endTool) endData.tool = endTool;
554
+
555
+ if (errorInfo) {
556
+ const [err, tb] = errorInfo;
557
+ endData.error_type = err.constructor.name;
558
+ hookLog("error", hookName, `[${endEvent}] ${err.constructor.name}: ${String(err).replaceAll(/[\n\r]/g, " ").slice(0, 200)}`, { traceback_str: tb });
559
+ hookLog("error", hookName, `HOOK_END: ${err}`, { data: endData, traceback_str: tb });
560
+ } else if (status === "blocked") {
561
+ hookLog("warn", hookName, "HOOK_END", { data: endData });
562
+ } else {
563
+ hookLog("info", hookName, "HOOK_END", { data: endData });
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Drain stdout before exiting to ensure pipe consumers receive all data.
569
+ * On Windows, stdout to a pipe is fully buffered — process.exit() can
570
+ * discard unflushed data. This waits for the write buffer to drain.
571
+ */
572
+ function _drainAndExit(code: number): void {
573
+ // If stdout is already finished or not writable, exit immediately
574
+ if (!process.stdout.writable || process.stdout.writableFinished) {
575
+ process.exit(code);
576
+ }
577
+
578
+ // Attempt to end stdout and wait for drain
579
+ const timeout = setTimeout(() => process.exit(code), 1000); // safety fallback
580
+ process.stdout.end(() => {
581
+ clearTimeout(timeout);
582
+ process.exit(code);
583
+ });
584
+ }
585
585
 
586
586
  export {logInfo, logError, logBlocking, logHookError, logDiagnostic, setContextPath, hookLog, logDebug, logWarn} from "./logger.js";