jeo-code 0.12.22 → 0.12.24

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.
@@ -22,8 +22,8 @@ export function withSandboxExecution<T>(scope: SandboxExecutionScope, run: () =>
22
22
  import * as fs from "node:fs/promises";
23
23
  import * as path from "node:path";
24
24
  import { resolvePosixTool } from "../util/posix-tools";
25
- import { readGlobalConfig } from "./state";
26
- import type { HookConfig } from "./state";
25
+ import { readGlobalConfig, userHooksPath } from "./state";
26
+ import type { HookConfig, HookEntry, HookEvent } from "./state";
27
27
 
28
28
  interface HookRunResult {
29
29
  stdout: string;
@@ -57,6 +57,23 @@ type LocalHooks =
57
57
  const localHooksCache = new Map<string, { mtimeMs: number; size: number; result: LocalHooks }>();
58
58
  const LOCAL_HOOKS_CACHE_CAP = 32;
59
59
 
60
+ const HOOK_EVENTS: ReadonlySet<string> = new Set<HookEvent>(["pre-tool", "post-turn", "post-implementation", "pre-prompt", "turn-end"]);
61
+
62
+ function isRecord(value: unknown): value is Record<string, unknown> {
63
+ return typeof value === "object" && value !== null && !Array.isArray(value);
64
+ }
65
+
66
+ /** Hook files are written by installers as well as by hand: drop entries that cannot run
67
+ * (unknown event, missing command, bad timeout) instead of letting one poison the batch. */
68
+ function isHookEntry(value: unknown): value is HookEntry {
69
+ if (!isRecord(value)) return false;
70
+ if (typeof value.event !== "string" || !HOOK_EVENTS.has(value.event)) return false;
71
+ if (typeof value.run !== "string" || !value.run.trim()) return false;
72
+ if (value.timeoutMs !== undefined && !(typeof value.timeoutMs === "number" && value.timeoutMs > 0)) return false;
73
+ if (value.match !== undefined && !isRecord(value.match)) return false;
74
+ return true;
75
+ }
76
+
60
77
  async function readLocalHooks(localPath: string): Promise<LocalHooks> {
61
78
  let st: { mtimeMs: number; size: number };
62
79
  try {
@@ -72,7 +89,7 @@ async function readLocalHooks(localPath: string): Promise<LocalHooks> {
72
89
  const parsed = JSON.parse(await fs.readFile(localPath, "utf-8"));
73
90
  if (parsed && typeof parsed === "object") {
74
91
  if (parsed.enabled === false) result = { kind: "disabled" };
75
- else if (Array.isArray(parsed.hooks)) result = { kind: "hooks", hooks: parsed.hooks };
92
+ else if (Array.isArray(parsed.hooks)) result = { kind: "hooks", hooks: parsed.hooks.filter(isHookEntry) };
76
93
  }
77
94
  } catch {
78
95
  // Missing/invalid local file → fall back to the global config hooks.
@@ -85,35 +102,126 @@ async function readLocalHooks(localPath: string): Promise<LocalHooks> {
85
102
  return result;
86
103
  }
87
104
 
88
- export async function loadHooks(cwd: string, signal?: AbortSignal): Promise<NonNullable<HookConfig["hooks"]>> {
89
- const config = await readGlobalConfig();
90
- if (!config.hooks?.enabled) {
91
- return [];
105
+ /**
106
+ * A sandboxed session reads the project hook file through the supervisor so the host
107
+ * never trusts workspace content it did not mediate. Same outcome shape as
108
+ * `readLocalHooks`.
109
+ */
110
+ async function readSandboxLocalHooks(sandbox: SandboxExecutionScope, cwd: string, signal?: AbortSignal): Promise<LocalHooks> {
111
+ const result = await sandbox.supervisor.exec(["bun", "-e", "const file=Bun.file('.jeo/hooks.json');let value={};try{if(await file.exists())value=await file.json()}catch{}process.stdout.write(JSON.stringify(value));"], { cwd, signal: signal ?? sandbox.signal });
112
+ if (!result.success || result.exitCode !== 0) throw new Error(`Sandbox project hook configuration read failed (exit code ${result.exitCode ?? "unavailable"}): ${result.error || result.output}`);
113
+ let local: unknown;
114
+ try { local = JSON.parse(result.stdout ?? result.output); }
115
+ catch { throw new Error("Malformed sandbox project hook configuration"); }
116
+ if (isRecord(local)) {
117
+ if (local.enabled === false) return { kind: "disabled" };
118
+ if (Array.isArray(local.hooks)) return { kind: "hooks", hooks: local.hooks.filter(isHookEntry) };
92
119
  }
120
+ return { kind: "fallback" };
121
+ }
122
+
123
+ /**
124
+ * Effective hook list, in run order: user registry (`userHooksPath()`) first, then the
125
+ * configured list.
126
+ *
127
+ * Precedence, strongest first:
128
+ * 1. `config.hooks.enabled === false` — global kill switch; nothing runs.
129
+ * 2. Project `.jeo/hooks.json` with `enabled: false` — project opt-out; nothing runs.
130
+ * 3. The user registry runs unless it opts itself out (`enabled: false`). It is
131
+ * user-owned and installer-managed, so it does NOT need `config.hooks.enabled: true`.
132
+ * 4. `config.hooks.hooks` / the project `.jeo/hooks.json` list run only when
133
+ * `config.hooks.enabled === true` (so an untrusted repo cannot enable its own hooks);
134
+ * the project list replaces the config list, as before, but never the user registry.
135
+ *
136
+ * Sandboxed sessions skip the user registry: its commands live on the host and cannot
137
+ * run inside the sandbox.
138
+ */
139
+ export async function loadHooks(cwd: string, signal?: AbortSignal): Promise<HookEntry[]> {
140
+ const config = await readGlobalConfig();
141
+ if (config.hooks?.enabled === false) return [];
93
142
 
94
143
  const sandbox = currentSandboxExecution();
95
- if (sandbox) {
96
- const result = await sandbox.supervisor.exec(["bun", "-e", "const file=Bun.file('.jeo/hooks.json');let value={};try{if(await file.exists())value=await file.json()}catch{}process.stdout.write(JSON.stringify(value));"], { cwd, signal: signal ?? sandbox.signal });
97
- if (!result.success || result.exitCode !== 0) throw new Error(`Sandbox project hook configuration read failed (exit code ${result.exitCode ?? "unavailable"}): ${result.error || result.output}`);
98
- let local: unknown;
99
- try { local = JSON.parse(result.stdout ?? result.output); }
100
- catch { throw new Error("Malformed sandbox project hook configuration"); }
101
- if (local && typeof local === "object") {
102
- if ("enabled" in local && local.enabled === false) return [];
103
- if ("hooks" in local && Array.isArray(local.hooks)) return local.hooks;
104
- }
105
- return config.hooks.hooks || [];
106
- }
107
- const local = await readLocalHooks(path.join(cwd, ".jeo", "hooks.json"));
144
+ const configHooksOn = config.hooks?.enabled === true;
145
+ // Sandboxed sessions read the project file only when hooks are explicitly enabled, as before.
146
+ if (sandbox && !configHooksOn) return [];
147
+ // The registry lives on the host; a sandboxed session never runs it.
148
+ const user = sandbox ? undefined : await readLocalHooks(userHooksPath());
149
+ const userHooks = user?.kind === "hooks" ? user.hooks : [];
150
+ // Common case (no registry, hooks not enabled): nothing can run, so skip the project file.
151
+ if (!configHooksOn && userHooks.length === 0) return [];
152
+
153
+ const local = sandbox
154
+ ? await readSandboxLocalHooks(sandbox, cwd, signal)
155
+ : await readLocalHooks(path.join(cwd, ".jeo", "hooks.json"));
108
156
  if (local.kind === "disabled") return [];
109
- if (local.kind === "hooks") return local.hooks;
110
157
 
111
- return config.hooks?.hooks || [];
158
+ const configured = !configHooksOn ? [] : local.kind === "hooks" ? local.hooks : config.hooks?.hooks ?? [];
159
+ return userHooks.length ? [...userHooks, ...configured] : configured;
160
+ }
161
+
162
+ /** One hook file as `describeHooks` sees it, read fresh (never from the loader cache). */
163
+ export interface HookFileReport {
164
+ path: string;
165
+ /** `missing`; `ok`; `disabled` (`enabled: false`); `unreadable` (not valid JSON or not an object). */
166
+ state: "missing" | "ok" | "disabled" | "unreadable";
167
+ /** Entries that can run. */
168
+ entries: number;
169
+ /** Entries dropped as unrunnable (unknown event, missing `run`, bad `timeoutMs`). */
170
+ invalid: number;
171
+ }
172
+
173
+ export interface HookReport {
174
+ /** `config.hooks.enabled`: `on` (true), `off` (false: global kill switch), `unset`. */
175
+ gate: "on" | "off" | "unset";
176
+ registry: HookFileReport;
177
+ project: HookFileReport;
178
+ /** What `loadHooks(cwd)` returns right now, counted by event. */
179
+ active: Partial<Record<HookEvent, number>>;
180
+ total: number;
181
+ }
182
+
183
+ async function inspectHookFile(file: string): Promise<HookFileReport> {
184
+ let text: string;
185
+ try {
186
+ text = await fs.readFile(file, "utf-8");
187
+ } catch (err: unknown) {
188
+ const missing = isRecord(err) && err.code === "ENOENT";
189
+ return { path: file, state: missing ? "missing" : "unreadable", entries: 0, invalid: 0 };
190
+ }
191
+ let parsed: unknown;
192
+ try { parsed = JSON.parse(text); } catch { return { path: file, state: "unreadable", entries: 0, invalid: 0 }; }
193
+ if (!isRecord(parsed)) return { path: file, state: "unreadable", entries: 0, invalid: 0 };
194
+ const list = Array.isArray(parsed.hooks) ? parsed.hooks : [];
195
+ const entries = list.filter(isHookEntry).length;
196
+ return { path: file, state: parsed.enabled === false ? "disabled" : "ok", entries, invalid: list.length - entries };
197
+ }
198
+
199
+ /**
200
+ * Read-only account of the hook setup for `jeo doctor`: the global switch, the user
201
+ * registry and the project file as written (with how many entries were dropped as
202
+ * unrunnable), and what actually runs for `cwd`. Installers write these files, and a bad
203
+ * entry is otherwise dropped silently, so this is the one place to see why a hook is idle.
204
+ */
205
+ export async function describeHooks(cwd: string): Promise<HookReport> {
206
+ const config = await readGlobalConfig();
207
+ const gate = config.hooks?.enabled === true ? "on" : config.hooks?.enabled === false ? "off" : "unset";
208
+ const [registry, project] = await Promise.all([inspectHookFile(userHooksPath()), inspectHookFile(path.join(cwd, ".jeo", "hooks.json"))]);
209
+ const active: Partial<Record<HookEvent, number>> = {};
210
+ let total = 0;
211
+ try {
212
+ for (const hook of await loadHooks(cwd)) {
213
+ active[hook.event] = (active[hook.event] ?? 0) + 1;
214
+ total++;
215
+ }
216
+ } catch {
217
+ // An unreadable project file must not take the whole report down.
218
+ }
219
+ return { gate, registry, project, active, total };
112
220
  }
113
221
 
114
222
  async function runHookCommand(
115
223
  runCmd: string,
116
- payload: any,
224
+ payload: unknown,
117
225
  cwd: string,
118
226
  timeoutMs = 30000,
119
227
  signal?: AbortSignal
@@ -211,13 +319,79 @@ export function hookMatchesTool(matchTool: string | undefined, tool: string): bo
211
319
  return matchTool.split("|").some(t => t.trim() === tool);
212
320
  }
213
321
 
322
+ let hookSessionId = "";
323
+
324
+ /** Set by the launch loop so every hook payload carries the live session id
325
+ * (`session_id`, Claude hook protocol). Empty string when no session is active. */
326
+ export function setHookSessionId(sessionId: string | undefined): void {
327
+ hookSessionId = sessionId ?? "";
328
+ }
329
+
330
+ /**
331
+ * Fields every hook payload carries. `event` is jeo's own name (kept for existing hooks);
332
+ * `hook_event_name`/`session_id`/`cwd` follow the Claude hook protocol so scripts written
333
+ * for Claude Code / Codex (`UserPromptSubmit`, `Stop`, `PreToolUse`) run unmodified.
334
+ *
335
+ * A sandboxed hook runs in the guest, so it gets the legacy payload only: never the host
336
+ * `cwd` or session id.
337
+ */
338
+ function basePayload(event: HookEvent, claudeEvent: string, cwd: string) {
339
+ if (currentSandboxExecution()) return { event };
340
+ return { event, hook_event_name: claudeEvent, session_id: hookSessionId, cwd };
341
+ }
342
+
343
+ /** Claude-protocol aliases (`tool_name`/`tool_input`) for a tool call; none in a sandbox. */
344
+ function toolAliases(tool: string, args: Record<string, unknown>) {
345
+ return currentSandboxExecution() ? {} : { tool_name: tool, tool_input: args };
346
+ }
347
+
348
+ const MAX_HOOK_CONTEXT_CHARS = 8_000;
349
+
350
+ function parseHookJson(stdout: string): Record<string, unknown> | undefined {
351
+ const text = stdout.trim();
352
+ if (!text.startsWith("{")) return undefined;
353
+ try {
354
+ const parsed: unknown = JSON.parse(text);
355
+ return isRecord(parsed) ? parsed : undefined;
356
+ } catch {
357
+ return undefined;
358
+ }
359
+ }
360
+
361
+ function clampContext(text: string): string {
362
+ return text.length > MAX_HOOK_CONTEXT_CHARS ? `${text.slice(0, MAX_HOOK_CONTEXT_CHARS)}\n... (hook context truncated)` : text;
363
+ }
364
+
365
+ /** `additionalContext` from a Claude-protocol hook output (`hookSpecificOutput.additionalContext`,
366
+ * top-level `additionalContext`, or `context`). Empty when the output carries none. */
367
+ function jsonHookContext(json: Record<string, unknown>): string {
368
+ const specific = isRecord(json.hookSpecificOutput) ? json.hookSpecificOutput : {};
369
+ for (const value of [specific.additionalContext, json.additionalContext, json.context]) {
370
+ if (typeof value === "string" && value.trim()) return clampContext(value.trim());
371
+ }
372
+ return "";
373
+ }
374
+
375
+ /** A deny decision a hook returned with exit 0 (Claude `permissionDecision: deny|ask`,
376
+ * legacy `decision: block`, or `continue: false`). jeo has no approval prompt for hooks,
377
+ * so `ask` is treated as deny — the safe reading. */
378
+ function jsonHookDenial(json: Record<string, unknown>): { reason: string } | undefined {
379
+ const specific = isRecord(json.hookSpecificOutput) ? json.hookSpecificOutput : {};
380
+ const decision = specific.permissionDecision ?? json.decision;
381
+ const denied = decision === "deny" || decision === "ask" || decision === "block" || json.continue === false;
382
+ if (!denied) return undefined;
383
+ const reason = [specific.permissionDecisionReason, json.reason, json.stopReason].find(v => typeof v === "string" && v.trim());
384
+ return { reason: typeof reason === "string" ? reason.trim() : "no reason given" };
385
+ }
386
+
214
387
  export async function runPreToolHooks(
215
388
  cwd: string,
216
389
  tool: string,
217
- args: Record<string, any>,
390
+ args: Record<string, unknown>,
218
391
  signal?: AbortSignal,
219
392
  onNotice?: (msg: string) => void
220
- ): Promise<{ vetoed: boolean; error?: string; output?: string }> {
393
+ ): Promise<{ vetoed: boolean; error?: string; output?: string; context?: string }> {
394
+ const contexts: string[] = [];
221
395
  try {
222
396
  const hooks = await loadHooks(cwd, signal);
223
397
  const preToolHooks = hooks.filter(
@@ -226,9 +400,10 @@ export async function runPreToolHooks(
226
400
 
227
401
  for (const hook of preToolHooks) {
228
402
  const payload = {
229
- event: "pre-tool",
403
+ ...basePayload("pre-tool", "PreToolUse", cwd),
230
404
  tool,
231
405
  args,
406
+ ...toolAliases(tool, args),
232
407
  };
233
408
 
234
409
  const timeoutMs = hook.timeoutMs || 30000;
@@ -248,14 +423,27 @@ export async function runPreToolHooks(
248
423
  onNotice?.(msg);
249
424
  return { vetoed: true, error: msg, output: result.output };
250
425
  }
426
+ // Exit 0 can still carry a structured decision (Claude-protocol hooks never exit 2
427
+ // to deny; they print `permissionDecision`). Plain stdout stays ignored so a
428
+ // hook's own logging never leaks into the model's tool result.
429
+ const json = parseHookJson(result.stdout);
430
+ if (!json) continue;
431
+ const denial = jsonHookDenial(json);
432
+ if (denial) {
433
+ const msg = `Pre-tool hook "${hook.run}" denied execution: ${denial.reason}`;
434
+ onNotice?.(msg);
435
+ return { vetoed: true, error: msg };
436
+ }
437
+ const context = jsonHookContext(json);
438
+ if (context) contexts.push(context);
251
439
  }
252
- } catch (err: any) {
253
- const msg = `Error executing pre-tool hooks: ${err.message}`;
440
+ } catch (err: unknown) {
441
+ const msg = `Error executing pre-tool hooks: ${err instanceof Error ? err.message : String(err)}`;
254
442
  onNotice?.(msg);
255
443
  return { vetoed: true, error: msg, output: "" };
256
444
  }
257
445
 
258
- return { vetoed: false };
446
+ return contexts.length ? { vetoed: false, context: contexts.join("\n") } : { vetoed: false };
259
447
  }
260
448
 
261
449
  /** One executed tool call fed to the post-turn hooks of a batch. */
@@ -301,10 +489,11 @@ export async function runPostTurnHooksForBatch(
301
489
  // Distinct hooks are independent commands → run them concurrently. Each hook
302
490
  // itself runs once for the whole batch (the redundancy this fix removes).
303
491
  const settled = await Promise.all(jobs.map(async ({ hook, matched }) => {
492
+ const base = basePayload("post-turn", "PostToolUse", cwd);
304
493
  const payload = matched.length === 1
305
- ? { event: "post-turn", tool: matched[0].tool, args: matched[0].args, success: matched[0].success, output: outputPreviewOf(matched[0].output) }
494
+ ? { ...base, tool: matched[0].tool, args: matched[0].args, ...toolAliases(matched[0].tool, matched[0].args), success: matched[0].success, output: outputPreviewOf(matched[0].output) }
306
495
  : {
307
- event: "post-turn",
496
+ ...base,
308
497
  tool: matched.map(c => c.tool).join(","),
309
498
  calls: matched.map(c => ({ tool: c.tool, args: c.args, success: c.success })),
310
499
  success: matched.every(c => c.success),
@@ -368,7 +557,7 @@ export async function runPostImplementationHooks(
368
557
 
369
558
  for (const hook of postImplHooks) {
370
559
  const payload = {
371
- event: "post-implementation",
560
+ ...basePayload("post-implementation", "post-implementation", cwd),
372
561
  request,
373
562
  };
374
563
 
@@ -389,3 +578,124 @@ export async function runPostImplementationHooks(
389
578
  return { success: false, output: msg };
390
579
  }
391
580
  }
581
+
582
+ const PRE_PROMPT_TIMEOUT_MS = 10_000;
583
+ const TURN_END_TIMEOUT_MS = 30_000;
584
+ const MAX_PROMPT_PAYLOAD_CHARS = 20_000;
585
+ const MAX_RESPONSE_PAYLOAD_CHARS = 200_000;
586
+
587
+ export interface PrePromptHookResult {
588
+ /** Context the hooks asked to place in front of the model (empty when none). */
589
+ context: string;
590
+ /** Hooks that ran to completion (exit 0) — lets callers tell "nothing to add" from "no hook". */
591
+ ran: number;
592
+ }
593
+
594
+ /**
595
+ * Run `pre-prompt` hooks once for a real user prompt, before the model sees it
596
+ * (Claude `UserPromptSubmit`). Payload: `{event, hook_event_name:"UserPromptSubmit",
597
+ * session_id, cwd, prompt}`. A hook contributes context by printing either plain text or a
598
+ * Claude-protocol JSON object (`hookSpecificOutput.additionalContext` / `additionalContext`
599
+ * / `context`). This is a routing/enrichment point, never a gate: a hook that fails, times
600
+ * out or is aborted yields a notice and no context, so a broken hook can't block a prompt.
601
+ */
602
+ export async function runPrePromptHooks(
603
+ cwd: string,
604
+ prompt: string,
605
+ signal?: AbortSignal,
606
+ onNotice?: (msg: string) => void,
607
+ ): Promise<PrePromptHookResult> {
608
+ const contexts: string[] = [];
609
+ let ran = 0;
610
+ try {
611
+ const hooks = (await loadHooks(cwd, signal)).filter(h => h.event === "pre-prompt");
612
+ if (hooks.length === 0 || !prompt.trim()) return { context: "", ran: 0 };
613
+ const payload = { ...basePayload("pre-prompt", "UserPromptSubmit", cwd), prompt: prompt.slice(0, MAX_PROMPT_PAYLOAD_CHARS) };
614
+ const settled = await Promise.all(hooks.map(async hook => {
615
+ try {
616
+ return { hook, result: await runHookCommand(hook.run, payload, cwd, hook.timeoutMs || PRE_PROMPT_TIMEOUT_MS, signal) };
617
+ } catch (err: unknown) {
618
+ onNotice?.(`Pre-prompt hook "${hook.run}" failed to start (advisory): ${err instanceof Error ? err.message : String(err)}`);
619
+ return undefined;
620
+ }
621
+ }));
622
+ // Hook order, not completion order, decides how contexts are stacked.
623
+ for (const entry of settled) {
624
+ if (!entry) continue;
625
+ const { hook, result } = entry;
626
+ if (result.timedOut) onNotice?.(`Pre-prompt hook "${hook.run}" timed out (advisory).`);
627
+ else if (result.aborted) continue;
628
+ else if (result.exitCode !== 0) onNotice?.(`Pre-prompt hook "${hook.run}" exited with non-zero code ${result.exitCode} (advisory).`);
629
+ else {
630
+ ran++;
631
+ const json = parseHookJson(result.stdout);
632
+ const context = json ? jsonHookContext(json) : clampContext(result.stdout.trim());
633
+ if (context) contexts.push(context);
634
+ }
635
+ }
636
+ } catch (err: unknown) {
637
+ onNotice?.(`Error executing pre-prompt hooks (advisory): ${err instanceof Error ? err.message : String(err)}`);
638
+ }
639
+ return { context: contexts.join("\n\n"), ran };
640
+ }
641
+
642
+ export interface TurnEndInfo {
643
+ prompt: string;
644
+ /** The final reply text of the turn. */
645
+ response: string;
646
+ /** True when the model signalled completion (false for a step-limit/unverified stop). */
647
+ done: boolean;
648
+ steps: number;
649
+ model?: string;
650
+ }
651
+
652
+ /**
653
+ * Run `turn-end` hooks once per finished turn (Claude `Stop`). Payload carries the turn's
654
+ * final reply under `response` and `last_assistant_message` (and `assistant_output`) so
655
+ * scripts that ingest assistant output can read it directly, alongside `prompt`, `done`,
656
+ * `steps`, `model`, `session_id`, `cwd`. Advisory only: failures yield a notice and never
657
+ * change the turn's result. Awaited so a one-shot `jeo -p` does not exit before they finish.
658
+ */
659
+ export async function runTurnEndHooks(
660
+ cwd: string,
661
+ info: TurnEndInfo,
662
+ signal?: AbortSignal,
663
+ onNotice?: (msg: string) => void,
664
+ ): Promise<{ ran: number }> {
665
+ let ran = 0;
666
+ try {
667
+ const hooks = (await loadHooks(cwd, signal)).filter(h => h.event === "turn-end");
668
+ if (hooks.length === 0) return { ran: 0 };
669
+ const response = info.response.length > MAX_RESPONSE_PAYLOAD_CHARS ? info.response.slice(0, MAX_RESPONSE_PAYLOAD_CHARS) : info.response;
670
+ const payload = {
671
+ ...basePayload("turn-end", "Stop", cwd),
672
+ prompt: info.prompt.slice(0, MAX_PROMPT_PAYLOAD_CHARS),
673
+ response,
674
+ last_assistant_message: response,
675
+ assistant_output: response,
676
+ done: info.done,
677
+ steps: info.steps,
678
+ model: info.model ?? "",
679
+ stop_hook_active: false,
680
+ };
681
+ const settled = await Promise.all(hooks.map(async hook => {
682
+ try {
683
+ return { hook, result: await runHookCommand(hook.run, payload, cwd, hook.timeoutMs || TURN_END_TIMEOUT_MS, signal) };
684
+ } catch (err: unknown) {
685
+ onNotice?.(`Turn-end hook "${hook.run}" failed to start (advisory): ${err instanceof Error ? err.message : String(err)}`);
686
+ return undefined;
687
+ }
688
+ }));
689
+ for (const entry of settled) {
690
+ if (!entry) continue;
691
+ const { hook, result } = entry;
692
+ if (result.timedOut) onNotice?.(`Turn-end hook "${hook.run}" timed out (advisory).`);
693
+ else if (result.aborted) continue;
694
+ else if (result.exitCode !== 0) onNotice?.(`Turn-end hook "${hook.run}" exited with non-zero code ${result.exitCode} (advisory).`);
695
+ else ran++;
696
+ }
697
+ } catch (err: unknown) {
698
+ onNotice?.(`Error executing turn-end hooks (advisory): ${err instanceof Error ? err.message : String(err)}`);
699
+ }
700
+ return { ran };
701
+ }
@@ -233,6 +233,12 @@ export interface DoneGateInput {
233
233
  verificationStale?: boolean;
234
234
  /** The run-command of the most recent still-failing post-turn hook, or null. */
235
235
  pendingHookFailure: string | null;
236
+ /**
237
+ * Why the host could not measure a shell call's effect on the workspace this turn (snapshot cap or
238
+ * error), when that happened. Such a call is counted as a possible mutation, so the gate says why
239
+ * instead of claiming files were modified.
240
+ */
241
+ unmeasured?: string;
236
242
  }
237
243
 
238
244
  /** Verdict from {@link classifyDoneGate}: whether to bounce `done`, and the message. */
@@ -257,6 +263,10 @@ export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
257
263
  const stale = input.sawVerification && input.verificationStale === true;
258
264
  const block = input.sawMutation && (!input.sawVerification || stale || hookFailing);
259
265
  if (!block) return { state: "done_ok", block: false, message: "" };
266
+ const unmeasuredNote = input.unmeasured
267
+ ? ` Note: the host could not measure a shell call's effect on the workspace (${input.unmeasured}), so it is counted as a possible file change; ` +
268
+ "if every command you ran was read-only, say so in your reply."
269
+ : "";
260
270
  if (hookFailing) {
261
271
  return {
262
272
  state: "done_hook_failing",
@@ -272,7 +282,8 @@ export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
272
282
  block: true,
273
283
  message:
274
284
  "You verified earlier, but then modified files again — your last passing test/build no longer reflects the current tree. " +
275
- "Re-run the narrowest verification command against the latest changes (e.g. a standalone bash call such as 'bun test', 'bun run typecheck', 'tsc --noEmit', 'npm test', 'pytest', 'cargo test', or 'go test ./...'), then call done.",
285
+ "Re-run the narrowest verification command against the latest changes (e.g. a standalone bash call such as 'bun test', 'bun run typecheck', 'tsc --noEmit', 'npm test', 'pytest', 'cargo test', or 'go test ./...'), then call done." +
286
+ unmeasuredNote,
276
287
  };
277
288
  }
278
289
  return {
@@ -280,6 +291,7 @@ export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
280
291
  block: true,
281
292
  message:
282
293
  "You modified files this turn but have no passing verification (no test/build/typecheck command succeeded, or the latest recognized verification failed). " +
283
- "Run the narrowest command that proves your change works (e.g. a standalone bash call such as 'bun test', 'bun run typecheck', 'tsc --noEmit', 'npm test', 'pytest', 'cargo test', or 'go test ./...'), then call done.",
294
+ "Run the narrowest command that proves your change works (e.g. a standalone bash call such as 'bun test', 'bun run typecheck', 'tsc --noEmit', 'npm test', 'pytest', 'cargo test', or 'go test ./...'), then call done." +
295
+ unmeasuredNote,
284
296
  };
285
297
  }