@namzu/sdk 63.0.0 → 64.0.0

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 (123) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/agents/QueryAgent.d.ts.map +1 -1
  3. package/dist/agents/QueryAgent.js +2 -1
  4. package/dist/agents/QueryAgent.js.map +1 -1
  5. package/dist/compaction/hooks.d.ts +57 -0
  6. package/dist/compaction/hooks.d.ts.map +1 -0
  7. package/dist/compaction/hooks.js +44 -0
  8. package/dist/compaction/hooks.js.map +1 -0
  9. package/dist/compaction/manual.d.ts +9 -0
  10. package/dist/compaction/manual.d.ts.map +1 -1
  11. package/dist/compaction/manual.js +74 -5
  12. package/dist/compaction/manual.js.map +1 -1
  13. package/dist/compaction/model-summary.d.ts +6 -0
  14. package/dist/compaction/model-summary.d.ts.map +1 -1
  15. package/dist/compaction/model-summary.js +13 -3
  16. package/dist/compaction/model-summary.js.map +1 -1
  17. package/dist/compaction/verifier.d.ts +8 -2
  18. package/dist/compaction/verifier.d.ts.map +1 -1
  19. package/dist/compaction/verifier.js +23 -7
  20. package/dist/compaction/verifier.js.map +1 -1
  21. package/dist/plugin/hook-context.d.ts +16 -0
  22. package/dist/plugin/hook-context.d.ts.map +1 -1
  23. package/dist/plugin/hook-context.js +40 -0
  24. package/dist/plugin/hook-context.js.map +1 -1
  25. package/dist/plugin/index.d.ts +4 -3
  26. package/dist/plugin/index.d.ts.map +1 -1
  27. package/dist/plugin/index.js +3 -2
  28. package/dist/plugin/index.js.map +1 -1
  29. package/dist/plugin/shell-hook-command.d.ts +25 -0
  30. package/dist/plugin/shell-hook-command.d.ts.map +1 -0
  31. package/dist/plugin/shell-hook-command.js +218 -0
  32. package/dist/plugin/shell-hook-command.js.map +1 -0
  33. package/dist/plugin/shell-hook-tool-names.d.ts +20 -0
  34. package/dist/plugin/shell-hook-tool-names.d.ts.map +1 -0
  35. package/dist/plugin/shell-hook-tool-names.js +43 -0
  36. package/dist/plugin/shell-hook-tool-names.js.map +1 -0
  37. package/dist/plugin/shell-hook.d.ts +62 -20
  38. package/dist/plugin/shell-hook.d.ts.map +1 -1
  39. package/dist/plugin/shell-hook.js +372 -85
  40. package/dist/plugin/shell-hook.js.map +1 -1
  41. package/dist/public-runtime.d.ts +3 -1
  42. package/dist/public-runtime.d.ts.map +1 -1
  43. package/dist/public-runtime.js +2 -1
  44. package/dist/public-runtime.js.map +1 -1
  45. package/dist/public-types.d.ts +2 -1
  46. package/dist/public-types.d.ts.map +1 -1
  47. package/dist/runtime/query/compact-session.d.ts +7 -1
  48. package/dist/runtime/query/compact-session.d.ts.map +1 -1
  49. package/dist/runtime/query/compact-session.js +8 -3
  50. package/dist/runtime/query/compact-session.js.map +1 -1
  51. package/dist/runtime/query/executor/tool-call-admission.d.ts +1 -1
  52. package/dist/runtime/query/executor/tool-call-admission.d.ts.map +1 -1
  53. package/dist/runtime/query/executor/tool-call-admission.js +4 -3
  54. package/dist/runtime/query/executor/tool-call-admission.js.map +1 -1
  55. package/dist/runtime/query/executor.d.ts +8 -0
  56. package/dist/runtime/query/executor.d.ts.map +1 -1
  57. package/dist/runtime/query/executor.js +17 -7
  58. package/dist/runtime/query/executor.js.map +1 -1
  59. package/dist/runtime/query/finalize-turn.d.ts.map +1 -1
  60. package/dist/runtime/query/finalize-turn.js +9 -2
  61. package/dist/runtime/query/finalize-turn.js.map +1 -1
  62. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  63. package/dist/runtime/query/iteration/index.js +4 -1
  64. package/dist/runtime/query/iteration/index.js.map +1 -1
  65. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  66. package/dist/runtime/query/iteration/phases/compaction.js +24 -12
  67. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  68. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  69. package/dist/runtime/query/iteration/phases/tool-review.js +2 -0
  70. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  71. package/dist/runtime/query/plugin-hooks.d.ts +13 -0
  72. package/dist/runtime/query/plugin-hooks.d.ts.map +1 -1
  73. package/dist/runtime/query/plugin-hooks.js +29 -2
  74. package/dist/runtime/query/plugin-hooks.js.map +1 -1
  75. package/dist/runtime/query/turn/outcome-ledger.d.ts +4 -0
  76. package/dist/runtime/query/turn/outcome-ledger.d.ts.map +1 -1
  77. package/dist/runtime/query/turn/outcome-ledger.js +2 -1
  78. package/dist/runtime/query/turn/outcome-ledger.js.map +1 -1
  79. package/dist/tools/builtins/bash.d.ts.map +1 -1
  80. package/dist/tools/builtins/bash.js +10 -2
  81. package/dist/tools/builtins/bash.js.map +1 -1
  82. package/dist/tools/coordinator/index.d.ts.map +1 -1
  83. package/dist/tools/coordinator/index.js +1 -0
  84. package/dist/tools/coordinator/index.js.map +1 -1
  85. package/dist/types/agent/query.d.ts +8 -0
  86. package/dist/types/agent/query.d.ts.map +1 -1
  87. package/dist/types/plugin/index.d.ts +55 -11
  88. package/dist/types/plugin/index.d.ts.map +1 -1
  89. package/dist/types/plugin/index.js.map +1 -1
  90. package/dist/types/session/events.d.ts +2 -0
  91. package/dist/types/session/events.d.ts.map +1 -1
  92. package/dist/types/session/events.js.map +1 -1
  93. package/dist/types/tool/index.d.ts +13 -1
  94. package/dist/types/tool/index.d.ts.map +1 -1
  95. package/dist/types/tool/index.js.map +1 -1
  96. package/package.json +1 -1
  97. package/src/agents/QueryAgent.ts +2 -1
  98. package/src/compaction/hooks.ts +104 -0
  99. package/src/compaction/manual.ts +117 -4
  100. package/src/compaction/model-summary.ts +19 -3
  101. package/src/compaction/verifier.ts +33 -3
  102. package/src/plugin/hook-context.ts +46 -0
  103. package/src/plugin/index.ts +14 -0
  104. package/src/plugin/shell-hook-command.ts +215 -0
  105. package/src/plugin/shell-hook-tool-names.ts +46 -0
  106. package/src/plugin/shell-hook.ts +405 -79
  107. package/src/public-runtime.ts +12 -0
  108. package/src/public-types.ts +2 -0
  109. package/src/runtime/query/compact-session.ts +16 -4
  110. package/src/runtime/query/executor/tool-call-admission.ts +10 -2
  111. package/src/runtime/query/executor.ts +33 -8
  112. package/src/runtime/query/finalize-turn.ts +9 -2
  113. package/src/runtime/query/iteration/index.ts +4 -1
  114. package/src/runtime/query/iteration/phases/compaction.ts +31 -12
  115. package/src/runtime/query/iteration/phases/tool-review.ts +2 -0
  116. package/src/runtime/query/plugin-hooks.ts +32 -2
  117. package/src/runtime/query/turn/outcome-ledger.ts +6 -1
  118. package/src/tools/builtins/bash.ts +16 -3
  119. package/src/tools/coordinator/index.ts +1 -0
  120. package/src/types/agent/query.ts +9 -0
  121. package/src/types/plugin/index.ts +66 -22
  122. package/src/types/session/events.ts +2 -0
  123. package/src/types/tool/index.ts +14 -1
@@ -24,8 +24,8 @@
24
24
  * same facts as `NAMZU_HOOK_EVENT`, `NAMZU_SESSION_ID`, `NAMZU_TURN_ID`,
25
25
  * `NAMZU_TOOL_NAME` and, when the input names a file, `NAMZU_TOOL_PATH`;
26
26
  * `NAMZU_PROJECT_DIR` is the folder the session was started for.
27
- * `session_start` also sends `source` (`startup` or `resume`) and `session_end`
28
- * `reason` (`exit` or `replaced`).
27
+ * `session_start` also sends `source` (`startup`, `resume` or `clear`) and
28
+ * `session_end` `reason` (`exit`, `replaced` or `clear`).
29
29
  *
30
30
  * `session_start` and `session_end` run outside any turn, so they carry no
31
31
  * `turn_id` on stdin and no `NAMZU_TURN_ID` in the environment — not even
@@ -36,7 +36,8 @@
36
36
  * stdout. The exit codes:
37
37
  *
38
38
  * - `0` — carry on. On `user_prompt_submit`, `subagent_start` and
39
- * `session_start`, plain (non-JSON) stdout becomes context for the model.
39
+ * `session_start`, plain (non-JSON) stdout becomes context for the model;
40
+ * on `pre_compact` it is instructions for the summariser.
40
41
  * - `2` — **block**, read per event: before a tool (`pre_tool_use`) the call is
41
42
  * skipped and the model is told why, with the hook's stderr as the reason;
42
43
  * on `user_prompt_submit` the turn ends; on `turn_end` and `subagent_stop`
@@ -112,7 +113,9 @@ import { RENAMED_PLUGIN_HOOK_EVENTS } from '../types/plugin/index.js';
112
113
  import { asPluginId } from '../utils/id.js';
113
114
  import { NOOP_LOGGER } from '../utils/log/create-logger.js';
114
115
  import { boundHookReplacement } from './hook-context.js';
116
+ import { readHookCommand } from './shell-hook-command.js';
115
117
  import { postHookJson } from './shell-hook-http.js';
118
+ import { shellHookToolName } from './shell-hook-tool-names.js';
116
119
  export const SHELL_HOOK_EVENTS = [
117
120
  'user_prompt_submit',
118
121
  'session_start',
@@ -169,12 +172,28 @@ const WRITTEN_TOOL_NAMES = Object.freeze({
169
172
  taskupdate: 'TaskUpdate',
170
173
  tasklist: 'TaskList',
171
174
  });
175
+ /**
176
+ * The tools a written name stands for. The skill alias table sends `Task` and
177
+ * `Agent` to `create_task`, but the command line's sub-agent tool is named
178
+ * `Agent`, and a hook written for either spelling means it: both name it.
179
+ */
180
+ function toolsNamedBy(written) {
181
+ const alias = SKILL_TOOL_NAME_ALIASES[written];
182
+ const names = alias !== undefined ? [alias] : [];
183
+ if (written === 'task' || written === 'agent')
184
+ names.push('agent');
185
+ return names;
186
+ }
187
+ /** A name with the separators dropped: `ViewImage` and `view_image` are one tool. */
188
+ function squashed(name) {
189
+ return name.toLowerCase().replace(/[_-]/g, '');
190
+ }
172
191
  /** The names a skill writes for the tool called `toolName` here. */
173
192
  function writtenNamesFor(toolName) {
174
193
  const lower = toolName.toLowerCase();
175
194
  const names = [];
176
- for (const [written, tool] of Object.entries(SKILL_TOOL_NAME_ALIASES)) {
177
- if (tool !== lower)
195
+ for (const written of new Set([...Object.keys(SKILL_TOOL_NAME_ALIASES), 'agent', 'task'])) {
196
+ if (!toolsNamedBy(written).includes(lower))
178
197
  continue;
179
198
  names.push(written);
180
199
  const spelled = WRITTEN_TOOL_NAMES[written];
@@ -183,27 +202,74 @@ function writtenNamesFor(toolName) {
183
202
  }
184
203
  return names;
185
204
  }
205
+ /** A glob over names: `*` matches any run of characters, the rest is literal. */
206
+ function globMatches(pattern, subject) {
207
+ const source = pattern
208
+ .split('*')
209
+ .map((piece) => piece.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
210
+ .join('.*');
211
+ return new RegExp(`^${source}$`, 'i').test(subject);
212
+ }
213
+ /**
214
+ * Whether a list part (`Edit`, `Web*`, `mcp__*__search`) names `subject`, as a
215
+ * tool name or not. A `*` at the end is a prefix; a `*` anywhere else is a
216
+ * glob. For a tool, a bare tool-server name (`mcp__github`) names every tool
217
+ * of that server.
218
+ */
219
+ function listPartNames(part, subject, tool) {
220
+ const prefix = part.endsWith('*');
221
+ const core = prefix ? part.slice(0, -1) : part;
222
+ const written = core.toLowerCase();
223
+ const lower = subject.toLowerCase();
224
+ if (core.includes('*')) {
225
+ if (globMatches(part, subject))
226
+ return true;
227
+ return tool && writtenNamesFor(subject).some((name) => globMatches(part, name));
228
+ }
229
+ const spellings = [written];
230
+ if (tool)
231
+ spellings.push(...toolsNamedBy(written));
232
+ return spellings.some((name) => {
233
+ if (prefix ? lower.startsWith(name) : lower === name)
234
+ return true;
235
+ if (!tool)
236
+ return false;
237
+ // A tool's name also compares without its `_` and `-`.
238
+ if (prefix ? squashed(lower).startsWith(squashed(name)) : squashed(lower) === squashed(name)) {
239
+ return true;
240
+ }
241
+ return !prefix && name.startsWith('mcp__') && !name.slice(5).includes('__')
242
+ ? lower.startsWith(`${name}__`)
243
+ : false;
244
+ });
245
+ }
246
+ const MATCHER_LIST_PART = /^[A-Za-z0-9_.:*-]+$/;
186
247
  /**
187
248
  * `matcher` against a name (a tool's, or a notification's type).
188
249
  *
189
250
  * Empty or `*` matches everything. A list of names separated by `|` or `,`
190
251
  * matches exactly, each part optionally ending in `*` as a prefix (the
191
252
  * spelling this adapter has always taken). Anything else is a regular
192
- * expression, tested unanchored against the name; one that does not compile
193
- * matches nothing. A matcher on an event that has no name (`turn_start`)
194
- * matches nothing unless it is `*`.
253
+ * expression, tested unanchored against the name. This function called with no
254
+ * name matches only `*`; a hook on an event that has no name (`turn_end`)
255
+ * ignores its matcher and fires (see `createShellHook`).
256
+ *
257
+ * A text that reads both ways (it is a list, and it holds a `.*`
258
+ * that a regular expression reads differently: `Bash.*`,
259
+ * `mcp__.*__search`) fires when either reading matches. A pattern that does
260
+ * not compile cannot be read, and a guard that silently matches nothing is
261
+ * the failure to avoid, so it matches every name; `/hooks` and
262
+ * `namzu doctor` say that it cannot be read (`shellHookMatcherProblem`).
195
263
  *
196
264
  * Names in a list compare without regard to case. With `tool: true` (the
197
265
  * event's name is a tool: the tool events and `permission_request`) they also
198
- * take the names skills write, the way an entry's `if` does: `Bash` matches
199
- * the tool `bash`, `MultiEdit` matches `edit`, and a prefix such as `Web*`
200
- * matches `web_fetch` by its written name too. Where the name is not a tool
201
- * (a notification's type, an agent's name, a session's `source` or `reason`)
202
- * no tool alias applies, since those words are not tool names; case is still
203
- * ignored there, because a guard that silently never runs over a capital
204
- * letter is worse than one that also runs for a name differing only in case.
205
- *
206
- * A list part ending in `.*` is also tried as a regular expression.
266
+ * take the names skills write, the way an entry's `if` does, and a tool name
267
+ * compares without its `_` and `-`: `Bash` matches the tool `bash`,
268
+ * `MultiEdit` matches `edit`, `ViewImage` matches `view_image`, `Task` and
269
+ * `Agent` match the sub-agent tool `Agent`, and a prefix such as `Web*`
270
+ * matches `web_fetch`. Where the name is not a tool (a notification's type,
271
+ * an agent's name, a session's `source` or `reason`) no tool alias applies,
272
+ * since those words are not tool names; case is still ignored there.
207
273
  *
208
274
  * A regular expression is tested, case-sensitively as written, against the
209
275
  * real name and, with `tool: true`, against each name skills write for that
@@ -217,23 +283,13 @@ export function shellHookMatches(matcher, toolName, options = {}) {
217
283
  return false;
218
284
  const tool = options.tool === true;
219
285
  const parts = matcher.split(/[|,]/).map((part) => part.trim());
220
- if (parts.every((part) => /^[A-Za-z0-9_.:-]+\*?$/.test(part))) {
221
- const subject = toolName.toLowerCase();
222
- const listed = parts.some((candidate) => {
223
- const prefix = candidate.endsWith('*');
224
- const written = (prefix ? candidate.slice(0, -1) : candidate).toLowerCase();
225
- const spellings = [written];
226
- if (tool) {
227
- const alias = SKILL_TOOL_NAME_ALIASES[written];
228
- if (alias !== undefined)
229
- spellings.push(alias);
230
- }
231
- return spellings.some((name) => (prefix ? subject.startsWith(name) : subject === name));
232
- });
233
- // `Bash.*` reads as the prefix `bash.` above, which no tool has. A part
234
- // ending in `.*` is also a regular expression, so it is tried as one.
235
- if (listed || !matcher.includes('.*'))
236
- return listed;
286
+ if (parts.every((part) => MATCHER_LIST_PART.test(part))) {
287
+ if (parts.some((part) => listPartNames(part, toolName, tool)))
288
+ return true;
289
+ // A list of names is exact (a `*` in it is a prefix or a glob). Only `.*` reads differently
290
+ // as a regular expression, so only then is the second reading tried.
291
+ if (!matcher.includes('.*'))
292
+ return false;
237
293
  }
238
294
  try {
239
295
  const expression = new RegExp(matcher);
@@ -242,41 +298,95 @@ export function shellHookMatches(matcher, toolName, options = {}) {
242
298
  return tool && writtenNamesFor(toolName).some((name) => expression.test(name));
243
299
  }
244
300
  catch {
245
- return false;
301
+ return true;
246
302
  }
247
303
  }
304
+ /**
305
+ * Why a `matcher` cannot be read, or `undefined` when it can. A matcher that
306
+ * is not a list of names and does not compile as a regular expression fires
307
+ * for every name (see {@link shellHookMatches}).
308
+ */
309
+ export function shellHookMatcherProblem(matcher) {
310
+ if (matcher === undefined || matcher.trim() === '' || matcher.trim() === '*')
311
+ return undefined;
312
+ const parts = matcher.split(/[|,]/).map((part) => part.trim());
313
+ if (parts.every((part) => MATCHER_LIST_PART.test(part)))
314
+ return undefined;
315
+ // `Bash(git push *)` is a tool and a pattern, which is what an `if` reads. As a
316
+ // matcher it is a regular expression that no tool name satisfies, so the hook
317
+ // would never fire; say so, and name the `if`.
318
+ if (/^[A-Za-z0-9_.:*-]+\([\s\S]*\)$/.test(matcher.trim())) {
319
+ return `the matcher "${matcher}" is written as Tool(pattern), which is an if, not a matcher: as a matcher it matches no tool name and the hook never fires; put the tool in matcher and the pattern in if`;
320
+ }
321
+ try {
322
+ new RegExp(matcher);
323
+ return undefined;
324
+ }
325
+ catch (error) {
326
+ return `the matcher "${matcher}" is not a list of names and is not a regular expression (${error.message}), so it cannot be read and the hook fires for every call`;
327
+ }
328
+ }
329
+ const IF_SHAPE = /^([A-Za-z0-9_.:*|, -]+)(?:\(([\s\S]*)\))?$/;
330
+ /**
331
+ * Why an `if` cannot be read, or `undefined` when it can. A filter that
332
+ * cannot be read runs the hook.
333
+ */
334
+ export function shellHookIfProblem(filter) {
335
+ if (filter === undefined || filter.trim() === '')
336
+ return undefined;
337
+ return IF_SHAPE.test(filter.trim())
338
+ ? undefined
339
+ : `the if "${filter}" is not Tool or Tool(pattern), so it cannot be read and the hook runs for every call`;
340
+ }
248
341
  /**
249
342
  * A hook's `if` against one tool call: `Tool` or `Tool(pattern)`. The tool
250
- * part is case-insensitive and takes the names skills write (`Bash`,
251
- * `Read`). A pattern is matched against a shell tool's command line and only
252
- * there; for a tool with no command line the filter cannot be read, which
253
- * runs the hook.
343
+ * part takes every spelling a `matcher` takes as a list: names skills write
344
+ * (`Bash`, `Read`, `Agent`), separated by `|` or `,`, a prefix ending in `*`,
345
+ * case ignored; a regular expression is not read here (use `matcher`). A
346
+ * pattern is matched against a shell tool's command line (one command at a
347
+ * time) or, for a file tool, a path the host reads; for a tool with neither
348
+ * the filter cannot be read, which runs the hook.
254
349
  */
255
350
  export function shellHookIfMatches(filter, toolName, toolInput, pathFilter) {
256
351
  if (filter === undefined || filter.trim() === '')
257
352
  return true;
258
353
  if (toolName === undefined)
259
354
  return true;
260
- const shape = /^([A-Za-z0-9_.-]+)(?:\(([\s\S]*)\))?$/.exec(filter.trim());
355
+ const shape = IF_SHAPE.exec(filter.trim());
261
356
  if (!shape)
262
357
  return true;
263
- const written = (shape[1] ?? '').toLowerCase();
264
- const tool = SKILL_TOOL_NAME_ALIASES[written] ?? written;
265
358
  const specifier = shape[2]?.trim();
266
359
  const hasPattern = specifier !== undefined && specifier !== '' && specifier.replaceAll('*', '') !== '';
267
- // A path pattern is the permission rules' own: the host that has them says
268
- // whether this call's path is named. It decides for the tools it knows.
269
- if (hasPattern && pathFilter !== undefined) {
270
- const answer = pathFilter(tool, specifier, toolName, toolInput);
271
- if (answer !== undefined)
272
- return answer;
273
- }
274
- if (tool !== toolName.toLowerCase())
275
- return false;
276
- if (!hasPattern)
360
+ const parts = (shape[1] ?? '')
361
+ .split(/[|,]/)
362
+ .map((part) => part.trim())
363
+ .filter((part) => part !== '');
364
+ if (parts.length === 0)
277
365
  return true;
278
- if (tool !== 'bash')
366
+ if (parts.some((part) => part === '*'))
279
367
  return true;
368
+ const callTool = toolName.toLowerCase();
369
+ return parts.some((part) => {
370
+ const written = part.replace(/\*$/, '').toLowerCase();
371
+ const tool = SKILL_TOOL_NAME_ALIASES[written] ?? written;
372
+ // A path pattern is the permission rules' own: the host that has them says
373
+ // whether this call's path is named. It decides for the tools it knows.
374
+ if (hasPattern && pathFilter !== undefined && !part.endsWith('*')) {
375
+ const answer = pathFilter(tool, specifier, toolName, toolInput);
376
+ if (answer !== undefined)
377
+ return answer;
378
+ }
379
+ if (!listPartNames(part, toolName, true))
380
+ return false;
381
+ if (!hasPattern)
382
+ return true;
383
+ if (callTool !== 'bash')
384
+ return true;
385
+ return commandMatches(specifier, toolInput);
386
+ });
387
+ }
388
+ /** A shell tool's command line against a permission-rule pattern, per command. */
389
+ function commandMatches(specifier, toolInput) {
280
390
  const line = toolInput !== null && typeof toolInput === 'object'
281
391
  ? toolInput.command
282
392
  : undefined;
@@ -291,7 +401,17 @@ export function shellHookIfMatches(filter, toolName, toolInput, pathFilter) {
291
401
  // whole line is visible and none of its commands matches.
292
402
  if (decomposed.opaque)
293
403
  return true;
294
- const candidates = [...decomposed.segments, ...decodedCommands(line, 'sh')].map((each) => each.trim());
404
+ // Wrappers, a program's full path and git's global options do not change
405
+ // which command runs; a command whose name is only known at run time
406
+ // cannot be compared, so the hook runs.
407
+ const reading = readHookCommand(line);
408
+ if (reading.unknowable)
409
+ return true;
410
+ const candidates = [
411
+ ...decomposed.segments,
412
+ ...decodedCommands(line, 'sh'),
413
+ ...reading.candidates,
414
+ ].map((each) => each.trim());
295
415
  return candidates.some((each) => matcher.test(each));
296
416
  }
297
417
  catch {
@@ -302,7 +422,7 @@ export function shellHookIfMatches(filter, toolName, toolInput, pathFilter) {
302
422
  export function shellHookPayload(input) {
303
423
  return JSON.stringify({
304
424
  event: input.event,
305
- hook_event_name: PASCAL_EVENT_NAMES[input.event] ?? input.event,
425
+ hook_event_name: SHELL_HOOK_PASCAL_EVENT_NAMES[input.event] ?? input.event,
306
426
  cwd: input.cwd,
307
427
  session_id: input.sessionId,
308
428
  ...(input.transcriptPath !== undefined ? { transcript_path: input.transcriptPath } : {}),
@@ -315,9 +435,14 @@ export function shellHookPayload(input) {
315
435
  ? { agent_id: input.sessionId, agent_type: input.agentType }
316
436
  : {}),
317
437
  ...(input.prompt !== undefined ? { prompt: input.prompt } : {}),
318
- ...(input.compaction !== undefined ? { compaction: input.compaction } : {}),
319
- ...(input.toolName !== undefined ? { tool_name: input.toolName } : {}),
320
- ...(input.toolInput !== undefined ? { tool_input: input.toolInput } : {}),
438
+ ...(input.compaction !== undefined ? compactionFields(input.event, input.compaction) : {}),
439
+ ...(input.toolName !== undefined
440
+ ? { tool_name: shellHookToolName(input.toolName, input.toolNames) }
441
+ : {}),
442
+ ...(input.toolCallId !== undefined ? { tool_use_id: input.toolCallId } : {}),
443
+ ...(input.toolInput !== undefined
444
+ ? { tool_input: withFilePathKey(input.toolName, input.toolInput) }
445
+ : {}),
321
446
  ...(input.toolResult !== undefined
322
447
  ? { tool_result: input.toolResult, tool_response: input.toolResult }
323
448
  : {}),
@@ -327,8 +452,8 @@ export function shellHookPayload(input) {
327
452
  ...(input.toolCalls !== undefined
328
453
  ? {
329
454
  tool_calls: input.toolCalls.map((call) => ({
330
- tool_name: call.toolName,
331
- tool_input: call.toolInput,
455
+ tool_name: shellHookToolName(call.toolName, input.toolNames),
456
+ tool_input: withFilePathKey(call.toolName, call.toolInput),
332
457
  tool_use_id: call.toolCallId,
333
458
  tool_response: call.output,
334
459
  is_error: call.isError,
@@ -348,8 +473,26 @@ export function shellHookPayload(input) {
348
473
  }
349
474
  : {}),
350
475
  ...(input.stopHookActive === true ? { stop_hook_active: true } : {}),
476
+ ...(input.lastAssistantMessage !== undefined
477
+ ? { last_assistant_message: input.lastAssistantMessage }
478
+ : {}),
351
479
  });
352
480
  }
481
+ /**
482
+ * A compaction hook's fields. `compaction` keeps what it always had and gains
483
+ * `trigger`; the person's instructions and the summary are top-level
484
+ * (`custom_instructions`, null when there are none, and `compact_summary`),
485
+ * where a script reads them without opening the object.
486
+ */
487
+ function compactionFields(event, compaction) {
488
+ const { instructions, summary, ...rest } = compaction;
489
+ return {
490
+ compaction: rest,
491
+ ...(compaction.trigger !== undefined ? { trigger: compaction.trigger } : {}),
492
+ ...(event === 'pre_compact' ? { custom_instructions: instructions ?? null } : {}),
493
+ ...(event === 'post_compact' && summary !== undefined ? { compact_summary: summary } : {}),
494
+ };
495
+ }
353
496
  /** Run one hook command to completion, bounded by its deadline and capture. */
354
497
  export function runShellHook(entry, input, signal) {
355
498
  const timeoutMs = Math.min(MAX_SHELL_HOOK_TIMEOUT_MS, Math.max(1, entry.timeoutMs ?? DEFAULT_SHELL_HOOK_TIMEOUT_MS));
@@ -367,7 +510,9 @@ export function runShellHook(entry, input, signal) {
367
510
  NAMZU_SESSION_ID: input.sessionId,
368
511
  NAMZU_PROJECT_DIR: input.projectDir ?? input.cwd,
369
512
  ...(input.turnId !== undefined ? { NAMZU_TURN_ID: input.turnId } : {}),
370
- ...(input.toolName !== undefined ? { NAMZU_TOOL_NAME: input.toolName } : {}),
513
+ ...(input.toolName !== undefined
514
+ ? { NAMZU_TOOL_NAME: shellHookToolName(input.toolName, input.toolNames) }
515
+ : {}),
371
516
  ...(toolPath !== undefined ? { NAMZU_TOOL_PATH: toolPath } : {}),
372
517
  };
373
518
  // Its own process group, so the deadline can kill everything the
@@ -492,6 +637,56 @@ export function runShellHook(entry, input, signal) {
492
637
  child.stdin?.end(payload);
493
638
  });
494
639
  }
640
+ /**
641
+ * The tools whose input names a file or folder `path`. A moved script reads
642
+ * that file under the key `file_path`, so the input a hook receives carries
643
+ * both, with the same value.
644
+ */
645
+ const FILE_PATH_TOOLS = new Set([
646
+ 'read',
647
+ 'write',
648
+ 'edit',
649
+ 'grep',
650
+ 'glob',
651
+ 'ls',
652
+ 'view_image',
653
+ 'lsp',
654
+ ]);
655
+ /** `toolInput` as a hook receives it: a file tool's `path` is also `file_path`. */
656
+ function withFilePathKey(toolName, toolInput) {
657
+ if (toolName === undefined || !FILE_PATH_TOOLS.has(toolName.toLowerCase()))
658
+ return toolInput;
659
+ if (!isRecord(toolInput))
660
+ return toolInput;
661
+ const path = toolInput.path;
662
+ if (typeof path !== 'string' || 'file_path' in toolInput)
663
+ return toolInput;
664
+ return { ...toolInput, file_path: path };
665
+ }
666
+ /**
667
+ * A rewritten input read back. An echoed `file_path` is never a reason to
668
+ * refuse the rewrite: when only one of the two keys was changed that change is
669
+ * the new path; when both were changed to different values the answer cannot be
670
+ * read (`undefined`). The tool never sees `file_path`.
671
+ */
672
+ function readRewrittenInput(toolName, original, rewritten) {
673
+ if (toolName === undefined || !FILE_PATH_TOOLS.has(toolName.toLowerCase()))
674
+ return rewritten;
675
+ if (!('file_path' in rewritten))
676
+ return rewritten;
677
+ const { file_path: echoed, ...rest } = rewritten;
678
+ const before = isRecord(original) ? original.path : undefined;
679
+ const pathChanged = 'path' in rest && rest.path !== before;
680
+ const echoChanged = echoed !== before;
681
+ if (pathChanged && echoChanged && echoed !== rest.path)
682
+ return undefined;
683
+ if (!pathChanged && echoChanged) {
684
+ if (typeof echoed !== 'string')
685
+ return undefined;
686
+ return { ...rest, path: echoed };
687
+ }
688
+ return rest;
689
+ }
495
690
  function pathOf(toolInput) {
496
691
  if (toolInput === null || typeof toolInput !== 'object')
497
692
  return undefined;
@@ -504,7 +699,7 @@ function pathOf(toolInput) {
504
699
  return undefined;
505
700
  }
506
701
  /** The PascalCase spelling of each event name, which a hook's JSON may use. */
507
- const PASCAL_EVENT_NAMES = {
702
+ export const SHELL_HOOK_PASCAL_EVENT_NAMES = {
508
703
  user_prompt_submit: 'UserPromptSubmit',
509
704
  session_start: 'SessionStart',
510
705
  session_end: 'SessionEnd',
@@ -522,6 +717,31 @@ const PASCAL_EVENT_NAMES = {
522
717
  turn_failure: 'StopFailure',
523
718
  turn_end: 'Stop',
524
719
  };
720
+ /** The keys that carry a replacement for a tool's result. */
721
+ const REPLACEMENT_KEYS = ['updatedToolOutput', 'updatedMCPToolOutput'];
722
+ /**
723
+ * A replacement for a tool's result, whatever its JSON type. A string is the
724
+ * text; an object that has the shape of the result object a hook is sent
725
+ * (`output` a string, `success` a boolean and `error` a string when present)
726
+ * is read as that; anything else (another object, an array, a number, a
727
+ * boolean) is shown as its JSON text. The call's own success is not changed.
728
+ */
729
+ function replacementText(value) {
730
+ if (typeof value === 'string')
731
+ return value;
732
+ if (isRecord(value) &&
733
+ typeof value.output === 'string' &&
734
+ (value.success === undefined || typeof value.success === 'boolean') &&
735
+ (value.error === undefined || typeof value.error === 'string')) {
736
+ if (value.success === false && typeof value.error === 'string' && value.error !== '') {
737
+ return value.output === ''
738
+ ? `Error: ${value.error}`
739
+ : `${value.output}\n\nError: ${value.error}`;
740
+ }
741
+ return value.output;
742
+ }
743
+ return JSON.stringify(value);
744
+ }
525
745
  function isRecord(value) {
526
746
  return value !== null && typeof value === 'object' && !Array.isArray(value);
527
747
  }
@@ -531,7 +751,7 @@ function isRecord(value) {
531
751
  * sets the whole object aside rather than half-obeying it, so a typo in a
532
752
  * guard's `decision` is reported instead of silently becoming "continue".
533
753
  */
534
- function readShellHookJson(event, stdout) {
754
+ function readShellHookJson(event, stdout, call = {}) {
535
755
  const text = stdout.trim();
536
756
  if (!text.startsWith('{'))
537
757
  return undefined;
@@ -570,6 +790,20 @@ function readShellHookJson(event, stdout) {
570
790
  parsed.decision !== 'block') {
571
791
  return { invalid: '`decision` must be "approve" or "block"' };
572
792
  }
793
+ // A replacement written where it is not read (at the top level of the answer
794
+ // instead of under `hookSpecificOutput`) is a hook that clearly tried to say
795
+ // something we cannot read: set the answer aside as unreadable, so
796
+ // `onFailure: block` withholds the result rather than showing the original.
797
+ if (event === 'post_tool_use' || event === 'post_tool_use_failure') {
798
+ const misplaced = REPLACEMENT_KEYS.find((key) => parsed[key] !== undefined);
799
+ const inSpecific = isRecord(parsed.hookSpecificOutput) &&
800
+ REPLACEMENT_KEYS.some((key) => parsed.hookSpecificOutput[key] !== undefined);
801
+ if (misplaced !== undefined && !inSpecific) {
802
+ return {
803
+ invalid: `\`${misplaced}\` is read under \`hookSpecificOutput\`, not at the top level of the answer`,
804
+ };
805
+ }
806
+ }
573
807
  const reading = {};
574
808
  if (parsed.suppressOutput === true)
575
809
  reading.suppressOutput = true;
@@ -595,7 +829,7 @@ function readShellHookJson(event, stdout) {
595
829
  if (typeof named !== 'string') {
596
830
  return { invalid: '`hookSpecificOutput` is missing its `hookEventName`' };
597
831
  }
598
- if (named !== event && named !== PASCAL_EVENT_NAMES[event]) {
832
+ if (named !== event && named !== SHELL_HOOK_PASCAL_EVENT_NAMES[event]) {
599
833
  return {
600
834
  invalid: `\`hookSpecificOutput.hookEventName\` is "${named}" but this hook ran for ${event}`,
601
835
  };
@@ -632,14 +866,25 @@ function readShellHookJson(event, stdout) {
632
866
  if (specific.updatedInput !== undefined) {
633
867
  if (!isRecord(specific.updatedInput))
634
868
  return { invalid: '`updatedInput` must be an object' };
635
- reading.updatedInput = specific.updatedInput;
869
+ const rewritten = readRewrittenInput(call.toolName, call.toolInput, specific.updatedInput);
870
+ if (rewritten === undefined) {
871
+ return {
872
+ invalid: '`updatedInput` changes both `path` and `file_path`, to different values: say which file is meant',
873
+ };
874
+ }
875
+ reading.updatedInput = rewritten;
636
876
  }
637
877
  }
638
- if (event === 'post_tool_use' && specific.updatedToolOutput !== undefined) {
639
- if (typeof specific.updatedToolOutput !== 'string') {
640
- return { invalid: 'hookSpecificOutput: `updatedToolOutput` must be a string' };
878
+ if (event === 'post_tool_use' || event === 'post_tool_use_failure') {
879
+ // For a tool-server tool the common contract has a second field, read
880
+ // when `updatedToolOutput` is absent or null. `updatedToolOutput` wins when
881
+ // both carry a value: it is the field for every tool, so a script that
882
+ // sets both has said the general thing last.
883
+ const toolServer = call.toolName?.startsWith('mcp__') === true;
884
+ const chosen = specific.updatedToolOutput ?? (toolServer ? specific.updatedMCPToolOutput : undefined);
885
+ if (chosen !== undefined && chosen !== null) {
886
+ reading.replacement = boundHookReplacement(replacementText(chosen));
641
887
  }
642
- reading.replacement = boundHookReplacement(specific.updatedToolOutput);
643
888
  }
644
889
  if (event === 'permission_request') {
645
890
  const decision = specific.decision;
@@ -667,7 +912,7 @@ function readShellHookJson(event, stdout) {
667
912
  * so the mapping — the part that decides what the model is told — is
668
913
  * testable without a process.
669
914
  */
670
- export function shellHookVerdict(event, entry, outcome, log = NOOP_LOGGER) {
915
+ export function shellHookVerdict(event, entry, outcome, log = NOOP_LOGGER, call = {}) {
671
916
  const label = describeShellHook(entry);
672
917
  const attrs = {
673
918
  'namzu.hook.event': event,
@@ -692,7 +937,7 @@ export function shellHookVerdict(event, entry, outcome, log = NOOP_LOGGER) {
692
937
  disclose: true,
693
938
  };
694
939
  }
695
- return shellHookVerdict(event, { ...entry, onFailure: 'continue' }, { exitCode: 2, timedOut: false, stdout: '', stderr: `hook failed: ${reason}` }, log);
940
+ return shellHookVerdict(event, { ...entry, onFailure: 'continue' }, { exitCode: 2, timedOut: false, stdout: '', stderr: `hook failed: ${reason}` }, log, call);
696
941
  };
697
942
  if (outcome.spawnError !== undefined) {
698
943
  log.warn('hook could not start', {
@@ -745,12 +990,27 @@ export function shellHookVerdict(event, entry, outcome, log = NOOP_LOGGER) {
745
990
  ? {
746
991
  invalid: `answer is too large: the hook printed more than the ${MAX_CAPTURE_BYTES} characters of stdout that are read`,
747
992
  }
748
- : readShellHookJson(event, outcome.stdout);
993
+ : readShellHookJson(event, outcome.stdout, call);
749
994
  if (reading === undefined) {
995
+ // A post-tool hook whose plain output (or whose stderr) names a replacement
996
+ // key tried to replace the result and put the answer where it is not read
997
+ // (a log line before the JSON, the JSON on stderr). That is an answer that
998
+ // does not read, so it follows `onFailure` like one.
999
+ if ((event === 'post_tool_use' || event === 'post_tool_use_failure') &&
1000
+ REPLACEMENT_KEYS.some((key) => outcome.stdout.includes(key) || outcome.stderr.includes(key))) {
1001
+ log.warn('hook output ignored', {
1002
+ ...attrs,
1003
+ 'namzu.hook.problem': 'a replacement is named but the answer is not a JSON object on stdout',
1004
+ });
1005
+ return failed('its answer names a replacement but is not a JSON object that starts stdout');
1006
+ }
750
1007
  // On the prompt event, what the hook printed is context for the
751
1008
  // model — the branch, the time, a ticket. Everywhere else stdout is
752
1009
  // the operator's to read and the model's business not at all.
753
- if ((event === 'user_prompt_submit' || event === 'subagent_start' || event === 'session_start') &&
1010
+ if ((event === 'user_prompt_submit' ||
1011
+ event === 'subagent_start' ||
1012
+ event === 'session_start' ||
1013
+ event === 'pre_compact') &&
754
1014
  text.length > 0)
755
1015
  return { action: 'annotate', text };
756
1016
  return { action: 'continue' };
@@ -871,6 +1131,8 @@ export function shellHookVerdict(event, entry, outcome, log = NOOP_LOGGER) {
871
1131
  };
872
1132
  }
873
1133
  case 'pre_compact':
1134
+ // What the hook said is for the summariser: it rides as `context` on
1135
+ // either answer (a refusal a forced pass overrides is still heard).
874
1136
  return reading.block !== undefined
875
1137
  ? { action: 'skip', reason: reading.block, ...notes }
876
1138
  : { action: 'continue', ...notes };
@@ -1009,21 +1271,30 @@ export function createShellHook(event, configured, options) {
1009
1271
  if (spent)
1010
1272
  return { action: 'continue' };
1011
1273
  // A notification is matched on its type; every other event on the tool.
1012
- const namesATool = !(event === 'notification' ||
1274
+ const namesATool = !(event === 'pre_compact' ||
1275
+ event === 'post_compact' ||
1276
+ event === 'notification' ||
1013
1277
  event === 'subagent_start' ||
1014
1278
  event === 'subagent_stop' ||
1015
1279
  event === 'session_start' ||
1016
1280
  event === 'session_end');
1017
1281
  const subject = event === 'notification'
1018
1282
  ? context.notification?.type
1019
- : event === 'subagent_start' || event === 'subagent_stop'
1020
- ? context.agentType
1021
- : event === 'session_start'
1022
- ? context.source
1023
- : event === 'session_end'
1024
- ? context.endReason
1025
- : context.toolName;
1026
- if (!shellHookMatches(entry.matcher, subject, { tool: namesATool }))
1283
+ : event === 'pre_compact' || event === 'post_compact'
1284
+ ? context.compaction?.trigger
1285
+ : event === 'subagent_start' || event === 'subagent_stop'
1286
+ ? context.agentType
1287
+ : event === 'session_start'
1288
+ ? context.source
1289
+ : event === 'session_end'
1290
+ ? context.endReason
1291
+ : context.toolName;
1292
+ // An event that has no name to match (`turn_end`, `user_prompt_submit`,
1293
+ // `turn_start`, `post_tool_batch`) ignores the matcher and the hook
1294
+ // fires: a matcher cannot be read against a name that is not there, and
1295
+ // a guard that never fires is the failure to avoid. The compaction
1296
+ // events are matched on `trigger`.
1297
+ if (subject !== undefined && !shellHookMatches(entry.matcher, subject, { tool: namesATool }))
1027
1298
  return { action: 'continue' };
1028
1299
  if (!shellHookIfMatches(entry.if, context.toolName, context.toolInput, options.pathFilter)) {
1029
1300
  return { action: 'continue' };
@@ -1050,6 +1321,8 @@ export function createShellHook(event, configured, options) {
1050
1321
  ...(context.prompt !== undefined ? { prompt: context.prompt } : {}),
1051
1322
  ...(context.compaction !== undefined ? { compaction: context.compaction } : {}),
1052
1323
  ...(context.toolName !== undefined ? { toolName: context.toolName } : {}),
1324
+ ...(entry.toolNames !== undefined ? { toolNames: entry.toolNames } : {}),
1325
+ ...(context.toolCallId !== undefined ? { toolCallId: context.toolCallId } : {}),
1053
1326
  ...(context.toolInput !== undefined ? { toolInput: context.toolInput } : {}),
1054
1327
  ...(context.toolResult !== undefined
1055
1328
  ? {
@@ -1067,9 +1340,16 @@ export function createShellHook(event, configured, options) {
1067
1340
  ...(context.error !== undefined ? { error: context.error } : {}),
1068
1341
  ...(context.notification !== undefined ? { notification: context.notification } : {}),
1069
1342
  ...(context.stopHookActive === true ? { stopHookActive: true } : {}),
1343
+ ...(context.lastAssistantMessage !== undefined
1344
+ ? { lastAssistantMessage: context.lastAssistantMessage }
1345
+ : {}),
1070
1346
  ...(transcriptPath !== undefined ? { transcriptPath } : {}),
1071
1347
  ...(permissionMode !== undefined ? { permissionMode } : {}),
1072
1348
  };
1349
+ const verdictCall = {
1350
+ ...(context.toolName !== undefined ? { toolName: context.toolName } : {}),
1351
+ ...(context.toolInput !== undefined ? { toolInput: context.toolInput } : {}),
1352
+ };
1073
1353
  if (entry.type === 'http') {
1074
1354
  const response = await postHookJson({
1075
1355
  url: entry.url,
@@ -1087,7 +1367,7 @@ export function createShellHook(event, configured, options) {
1087
1367
  stdout: ok ? response.body : '',
1088
1368
  stderr: ok ? '' : response.body.slice(0, 500),
1089
1369
  ...(response.error !== undefined ? { spawnError: response.error } : {}),
1090
- }, log);
1370
+ }, log, verdictCall);
1091
1371
  }
1092
1372
  if (entry.type === 'prompt') {
1093
1373
  return judgePromptHook(event, entry, hookInput, options.judge, context.signal, log);
@@ -1097,14 +1377,21 @@ export function createShellHook(event, configured, options) {
1097
1377
  // for it and its own failure is only written down.
1098
1378
  void runShellHook(entry, hookInput)
1099
1379
  .then((outcome) => {
1100
- shellHookVerdict(event, entry, outcome, log);
1380
+ const verdict = shellHookVerdict(event, entry, outcome, log, verdictCall);
1381
+ if (verdict.action !== 'continue') {
1382
+ log.warn('an async hook answered, and its answer is not applied', {
1383
+ 'namzu.hook.event': event,
1384
+ 'namzu.hook.command': describeShellHook(entry),
1385
+ 'namzu.hook.action': verdict.action,
1386
+ });
1387
+ }
1101
1388
  })
1102
1389
  .catch(() => undefined);
1103
1390
  return { action: 'continue' };
1104
1391
  }
1105
1392
  const outcome = await runShellHook(entry, hookInput, context.signal);
1106
1393
  reportTimeout(outcome.timedOut);
1107
- return shellHookVerdict(event, entry, outcome, log);
1394
+ return shellHookVerdict(event, entry, outcome, log, verdictCall);
1108
1395
  },
1109
1396
  };
1110
1397
  }