@namzu/sdk 63.1.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 (101) hide show
  1. package/CHANGELOG.md +53 -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.map +1 -1
  56. package/dist/runtime/query/executor.js +5 -3
  57. package/dist/runtime/query/executor.js.map +1 -1
  58. package/dist/runtime/query/finalize-turn.d.ts.map +1 -1
  59. package/dist/runtime/query/finalize-turn.js +9 -2
  60. package/dist/runtime/query/finalize-turn.js.map +1 -1
  61. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  62. package/dist/runtime/query/iteration/index.js +2 -1
  63. package/dist/runtime/query/iteration/index.js.map +1 -1
  64. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  65. package/dist/runtime/query/iteration/phases/compaction.js +24 -12
  66. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  67. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  68. package/dist/runtime/query/iteration/phases/tool-review.js +2 -0
  69. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  70. package/dist/runtime/query/plugin-hooks.d.ts +13 -0
  71. package/dist/runtime/query/plugin-hooks.d.ts.map +1 -1
  72. package/dist/runtime/query/plugin-hooks.js +29 -2
  73. package/dist/runtime/query/plugin-hooks.js.map +1 -1
  74. package/dist/types/agent/query.d.ts +8 -0
  75. package/dist/types/agent/query.d.ts.map +1 -1
  76. package/dist/types/plugin/index.d.ts +55 -11
  77. package/dist/types/plugin/index.d.ts.map +1 -1
  78. package/dist/types/plugin/index.js.map +1 -1
  79. package/package.json +1 -1
  80. package/src/agents/QueryAgent.ts +2 -1
  81. package/src/compaction/hooks.ts +104 -0
  82. package/src/compaction/manual.ts +117 -4
  83. package/src/compaction/model-summary.ts +19 -3
  84. package/src/compaction/verifier.ts +33 -3
  85. package/src/plugin/hook-context.ts +46 -0
  86. package/src/plugin/index.ts +14 -0
  87. package/src/plugin/shell-hook-command.ts +215 -0
  88. package/src/plugin/shell-hook-tool-names.ts +46 -0
  89. package/src/plugin/shell-hook.ts +405 -79
  90. package/src/public-runtime.ts +12 -0
  91. package/src/public-types.ts +2 -0
  92. package/src/runtime/query/compact-session.ts +16 -4
  93. package/src/runtime/query/executor/tool-call-admission.ts +10 -2
  94. package/src/runtime/query/executor.ts +5 -2
  95. package/src/runtime/query/finalize-turn.ts +9 -2
  96. package/src/runtime/query/iteration/index.ts +2 -1
  97. package/src/runtime/query/iteration/phases/compaction.ts +31 -12
  98. package/src/runtime/query/iteration/phases/tool-review.ts +2 -0
  99. package/src/runtime/query/plugin-hooks.ts +32 -2
  100. package/src/types/agent/query.ts +9 -0
  101. package/src/types/plugin/index.ts +66 -22
@@ -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`
@@ -130,7 +131,9 @@ import { NOOP_LOGGER } from '../utils/log/create-logger.js'
130
131
  import type { Logger } from '../utils/logger.js'
131
132
  import { boundHookReplacement } from './hook-context.js'
132
133
  import type { PluginLifecycleManager } from './lifecycle.js'
134
+ import { readHookCommand } from './shell-hook-command.js'
133
135
  import { postHookJson } from './shell-hook-http.js'
136
+ import { type ShellHookToolNames, shellHookToolName } from './shell-hook-tool-names.js'
134
137
 
135
138
  /**
136
139
  * The events a shell hook may attach to: the ones an operator can act on
@@ -223,6 +226,15 @@ interface ShellHookEntryBase {
223
226
  * here are the same entry.
224
227
  */
225
228
  readonly source?: string
229
+ /**
230
+ * Which spelling of a tool's name the hook is sent as `tool_name` (and
231
+ * `NAMZU_TOOL_NAME`): `namzu`, the name the tool is registered under
232
+ * (`bash`; the default), or `written`, the capitalised name a person writes
233
+ * in a matcher (`Bash`). A tool with no written name, and every tool-server
234
+ * tool, keeps its own. `matcher` and `if` read either spelling whatever this
235
+ * says. An entry read from the common hook contract is `written`.
236
+ */
237
+ readonly toolNames?: ShellHookToolNames
226
238
  }
227
239
 
228
240
  /** One shell command at one event. */
@@ -318,12 +330,29 @@ const WRITTEN_TOOL_NAMES: Readonly<Record<string, string>> = Object.freeze({
318
330
  tasklist: 'TaskList',
319
331
  })
320
332
 
333
+ /**
334
+ * The tools a written name stands for. The skill alias table sends `Task` and
335
+ * `Agent` to `create_task`, but the command line's sub-agent tool is named
336
+ * `Agent`, and a hook written for either spelling means it: both name it.
337
+ */
338
+ function toolsNamedBy(written: string): string[] {
339
+ const alias = SKILL_TOOL_NAME_ALIASES[written]
340
+ const names = alias !== undefined ? [alias] : []
341
+ if (written === 'task' || written === 'agent') names.push('agent')
342
+ return names
343
+ }
344
+
345
+ /** A name with the separators dropped: `ViewImage` and `view_image` are one tool. */
346
+ function squashed(name: string): string {
347
+ return name.toLowerCase().replace(/[_-]/g, '')
348
+ }
349
+
321
350
  /** The names a skill writes for the tool called `toolName` here. */
322
351
  function writtenNamesFor(toolName: string): string[] {
323
352
  const lower = toolName.toLowerCase()
324
353
  const names: string[] = []
325
- for (const [written, tool] of Object.entries(SKILL_TOOL_NAME_ALIASES)) {
326
- if (tool !== lower) continue
354
+ for (const written of new Set([...Object.keys(SKILL_TOOL_NAME_ALIASES), 'agent', 'task'])) {
355
+ if (!toolsNamedBy(written).includes(lower)) continue
327
356
  names.push(written)
328
357
  const spelled = WRITTEN_TOOL_NAMES[written]
329
358
  if (spelled !== undefined) names.push(spelled)
@@ -331,27 +360,73 @@ function writtenNamesFor(toolName: string): string[] {
331
360
  return names
332
361
  }
333
362
 
363
+ /** A glob over names: `*` matches any run of characters, the rest is literal. */
364
+ function globMatches(pattern: string, subject: string): boolean {
365
+ const source = pattern
366
+ .split('*')
367
+ .map((piece) => piece.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
368
+ .join('.*')
369
+ return new RegExp(`^${source}$`, 'i').test(subject)
370
+ }
371
+
372
+ /**
373
+ * Whether a list part (`Edit`, `Web*`, `mcp__*__search`) names `subject`, as a
374
+ * tool name or not. A `*` at the end is a prefix; a `*` anywhere else is a
375
+ * glob. For a tool, a bare tool-server name (`mcp__github`) names every tool
376
+ * of that server.
377
+ */
378
+ function listPartNames(part: string, subject: string, tool: boolean): boolean {
379
+ const prefix = part.endsWith('*')
380
+ const core = prefix ? part.slice(0, -1) : part
381
+ const written = core.toLowerCase()
382
+ const lower = subject.toLowerCase()
383
+ if (core.includes('*')) {
384
+ if (globMatches(part, subject)) return true
385
+ return tool && writtenNamesFor(subject).some((name) => globMatches(part, name))
386
+ }
387
+ const spellings = [written]
388
+ if (tool) spellings.push(...toolsNamedBy(written))
389
+ return spellings.some((name) => {
390
+ if (prefix ? lower.startsWith(name) : lower === name) return true
391
+ if (!tool) return false
392
+ // A tool's name also compares without its `_` and `-`.
393
+ if (prefix ? squashed(lower).startsWith(squashed(name)) : squashed(lower) === squashed(name)) {
394
+ return true
395
+ }
396
+ return !prefix && name.startsWith('mcp__') && !name.slice(5).includes('__')
397
+ ? lower.startsWith(`${name}__`)
398
+ : false
399
+ })
400
+ }
401
+
402
+ const MATCHER_LIST_PART = /^[A-Za-z0-9_.:*-]+$/
403
+
334
404
  /**
335
405
  * `matcher` against a name (a tool's, or a notification's type).
336
406
  *
337
407
  * Empty or `*` matches everything. A list of names separated by `|` or `,`
338
408
  * matches exactly, each part optionally ending in `*` as a prefix (the
339
409
  * spelling this adapter has always taken). Anything else is a regular
340
- * expression, tested unanchored against the name; one that does not compile
341
- * matches nothing. A matcher on an event that has no name (`turn_start`)
342
- * matches nothing unless it is `*`.
410
+ * expression, tested unanchored against the name. This function called with no
411
+ * name matches only `*`; a hook on an event that has no name (`turn_end`)
412
+ * ignores its matcher and fires (see `createShellHook`).
413
+ *
414
+ * A text that reads both ways (it is a list, and it holds a `.*`
415
+ * that a regular expression reads differently: `Bash.*`,
416
+ * `mcp__.*__search`) fires when either reading matches. A pattern that does
417
+ * not compile cannot be read, and a guard that silently matches nothing is
418
+ * the failure to avoid, so it matches every name; `/hooks` and
419
+ * `namzu doctor` say that it cannot be read (`shellHookMatcherProblem`).
343
420
  *
344
421
  * Names in a list compare without regard to case. With `tool: true` (the
345
422
  * event's name is a tool: the tool events and `permission_request`) they also
346
- * take the names skills write, the way an entry's `if` does: `Bash` matches
347
- * the tool `bash`, `MultiEdit` matches `edit`, and a prefix such as `Web*`
348
- * matches `web_fetch` by its written name too. Where the name is not a tool
349
- * (a notification's type, an agent's name, a session's `source` or `reason`)
350
- * no tool alias applies, since those words are not tool names; case is still
351
- * ignored there, because a guard that silently never runs over a capital
352
- * letter is worse than one that also runs for a name differing only in case.
353
- *
354
- * A list part ending in `.*` is also tried as a regular expression.
423
+ * take the names skills write, the way an entry's `if` does, and a tool name
424
+ * compares without its `_` and `-`: `Bash` matches the tool `bash`,
425
+ * `MultiEdit` matches `edit`, `ViewImage` matches `view_image`, `Task` and
426
+ * `Agent` match the sub-agent tool `Agent`, and a prefix such as `Web*`
427
+ * matches `web_fetch`. Where the name is not a tool (a notification's type,
428
+ * an agent's name, a session's `source` or `reason`) no tool alias applies,
429
+ * since those words are not tool names; case is still ignored there.
355
430
  *
356
431
  * A regular expression is tested, case-sensitively as written, against the
357
432
  * real name and, with `tool: true`, against each name skills write for that
@@ -367,37 +442,65 @@ export function shellHookMatches(
367
442
  if (toolName === undefined) return false
368
443
  const tool = options.tool === true
369
444
  const parts = matcher.split(/[|,]/).map((part) => part.trim())
370
- if (parts.every((part) => /^[A-Za-z0-9_.:-]+\*?$/.test(part))) {
371
- const subject = toolName.toLowerCase()
372
- const listed = parts.some((candidate) => {
373
- const prefix = candidate.endsWith('*')
374
- const written = (prefix ? candidate.slice(0, -1) : candidate).toLowerCase()
375
- const spellings = [written]
376
- if (tool) {
377
- const alias = SKILL_TOOL_NAME_ALIASES[written]
378
- if (alias !== undefined) spellings.push(alias)
379
- }
380
- return spellings.some((name) => (prefix ? subject.startsWith(name) : subject === name))
381
- })
382
- // `Bash.*` reads as the prefix `bash.` above, which no tool has. A part
383
- // ending in `.*` is also a regular expression, so it is tried as one.
384
- if (listed || !matcher.includes('.*')) return listed
445
+ if (parts.every((part) => MATCHER_LIST_PART.test(part))) {
446
+ if (parts.some((part) => listPartNames(part, toolName, tool))) return true
447
+ // A list of names is exact (a `*` in it is a prefix or a glob). Only `.*` reads differently
448
+ // as a regular expression, so only then is the second reading tried.
449
+ if (!matcher.includes('.*')) return false
385
450
  }
386
451
  try {
387
452
  const expression = new RegExp(matcher)
388
453
  if (expression.test(toolName)) return true
389
454
  return tool && writtenNamesFor(toolName).some((name) => expression.test(name))
390
455
  } catch {
391
- return false
456
+ return true
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Why a `matcher` cannot be read, or `undefined` when it can. A matcher that
462
+ * is not a list of names and does not compile as a regular expression fires
463
+ * for every name (see {@link shellHookMatches}).
464
+ */
465
+ export function shellHookMatcherProblem(matcher: string | undefined): string | undefined {
466
+ if (matcher === undefined || matcher.trim() === '' || matcher.trim() === '*') return undefined
467
+ const parts = matcher.split(/[|,]/).map((part) => part.trim())
468
+ if (parts.every((part) => MATCHER_LIST_PART.test(part))) return undefined
469
+ // `Bash(git push *)` is a tool and a pattern, which is what an `if` reads. As a
470
+ // matcher it is a regular expression that no tool name satisfies, so the hook
471
+ // would never fire; say so, and name the `if`.
472
+ if (/^[A-Za-z0-9_.:*-]+\([\s\S]*\)$/.test(matcher.trim())) {
473
+ 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`
474
+ }
475
+ try {
476
+ new RegExp(matcher)
477
+ return undefined
478
+ } catch (error) {
479
+ return `the matcher "${matcher}" is not a list of names and is not a regular expression (${(error as Error).message}), so it cannot be read and the hook fires for every call`
392
480
  }
393
481
  }
394
482
 
483
+ const IF_SHAPE = /^([A-Za-z0-9_.:*|, -]+)(?:\(([\s\S]*)\))?$/
484
+
485
+ /**
486
+ * Why an `if` cannot be read, or `undefined` when it can. A filter that
487
+ * cannot be read runs the hook.
488
+ */
489
+ export function shellHookIfProblem(filter: string | undefined): string | undefined {
490
+ if (filter === undefined || filter.trim() === '') return undefined
491
+ return IF_SHAPE.test(filter.trim())
492
+ ? undefined
493
+ : `the if "${filter}" is not Tool or Tool(pattern), so it cannot be read and the hook runs for every call`
494
+ }
495
+
395
496
  /**
396
497
  * A hook's `if` against one tool call: `Tool` or `Tool(pattern)`. The tool
397
- * part is case-insensitive and takes the names skills write (`Bash`,
398
- * `Read`). A pattern is matched against a shell tool's command line and only
399
- * there; for a tool with no command line the filter cannot be read, which
400
- * runs the hook.
498
+ * part takes every spelling a `matcher` takes as a list: names skills write
499
+ * (`Bash`, `Read`, `Agent`), separated by `|` or `,`, a prefix ending in `*`,
500
+ * case ignored; a regular expression is not read here (use `matcher`). A
501
+ * pattern is matched against a shell tool's command line (one command at a
502
+ * time) or, for a file tool, a path the host reads; for a tool with neither
503
+ * the filter cannot be read, which runs the hook.
401
504
  */
402
505
  export function shellHookIfMatches(
403
506
  filter: string | undefined,
@@ -407,22 +510,36 @@ export function shellHookIfMatches(
407
510
  ): boolean {
408
511
  if (filter === undefined || filter.trim() === '') return true
409
512
  if (toolName === undefined) return true
410
- const shape = /^([A-Za-z0-9_.-]+)(?:\(([\s\S]*)\))?$/.exec(filter.trim())
513
+ const shape = IF_SHAPE.exec(filter.trim())
411
514
  if (!shape) return true
412
- const written = (shape[1] ?? '').toLowerCase()
413
- const tool = SKILL_TOOL_NAME_ALIASES[written] ?? written
414
515
  const specifier = shape[2]?.trim()
415
516
  const hasPattern =
416
517
  specifier !== undefined && specifier !== '' && specifier.replaceAll('*', '') !== ''
417
- // A path pattern is the permission rules' own: the host that has them says
418
- // whether this call's path is named. It decides for the tools it knows.
419
- if (hasPattern && pathFilter !== undefined) {
420
- const answer = pathFilter(tool, specifier, toolName, toolInput)
421
- if (answer !== undefined) return answer
422
- }
423
- if (tool !== toolName.toLowerCase()) return false
424
- if (!hasPattern) return true
425
- if (tool !== 'bash') return true
518
+ const parts = (shape[1] ?? '')
519
+ .split(/[|,]/)
520
+ .map((part) => part.trim())
521
+ .filter((part) => part !== '')
522
+ if (parts.length === 0) return true
523
+ if (parts.some((part) => part === '*')) return true
524
+ const callTool = toolName.toLowerCase()
525
+ return parts.some((part) => {
526
+ const written = part.replace(/\*$/, '').toLowerCase()
527
+ const tool = SKILL_TOOL_NAME_ALIASES[written] ?? written
528
+ // A path pattern is the permission rules' own: the host that has them says
529
+ // whether this call's path is named. It decides for the tools it knows.
530
+ if (hasPattern && pathFilter !== undefined && !part.endsWith('*')) {
531
+ const answer = pathFilter(tool, specifier, toolName, toolInput)
532
+ if (answer !== undefined) return answer
533
+ }
534
+ if (!listPartNames(part, toolName, true)) return false
535
+ if (!hasPattern) return true
536
+ if (callTool !== 'bash') return true
537
+ return commandMatches(specifier, toolInput)
538
+ })
539
+ }
540
+
541
+ /** A shell tool's command line against a permission-rule pattern, per command. */
542
+ function commandMatches(specifier: string, toolInput: unknown): boolean {
426
543
  const line =
427
544
  toolInput !== null && typeof toolInput === 'object'
428
545
  ? (toolInput as Record<string, unknown>).command
@@ -436,9 +553,16 @@ export function shellHookIfMatches(
436
553
  // filter names, so the hook runs. A filter only ever narrows when the
437
554
  // whole line is visible and none of its commands matches.
438
555
  if (decomposed.opaque) return true
439
- const candidates = [...decomposed.segments, ...decodedCommands(line, 'sh')].map((each) =>
440
- each.trim(),
441
- )
556
+ // Wrappers, a program's full path and git's global options do not change
557
+ // which command runs; a command whose name is only known at run time
558
+ // cannot be compared, so the hook runs.
559
+ const reading = readHookCommand(line)
560
+ if (reading.unknowable) return true
561
+ const candidates = [
562
+ ...decomposed.segments,
563
+ ...decodedCommands(line, 'sh'),
564
+ ...reading.candidates,
565
+ ].map((each) => each.trim())
442
566
  return candidates.some((each) => matcher.test(each))
443
567
  } catch {
444
568
  return true
@@ -493,6 +617,10 @@ export interface ShellHookInput {
493
617
  readonly prompt?: string
494
618
  readonly compaction?: PluginCompactionInfo
495
619
  readonly toolName?: string
620
+ /** The spelling `toolName` (and each `toolCalls` name) is sent in; absent is `namzu`. */
621
+ readonly toolNames?: ShellHookToolNames
622
+ /** The call's id, on the single-call tool events. Sent as `tool_use_id`. */
623
+ readonly toolCallId?: string
496
624
  readonly toolInput?: unknown
497
625
  readonly toolResult?: {
498
626
  readonly success: boolean
@@ -509,6 +637,8 @@ export interface ShellHookInput {
509
637
  readonly notification?: PluginNotificationInfo
510
638
  /** On `turn_end` and `subagent_stop`: an earlier answer already kept this turn going. */
511
639
  readonly stopHookActive?: boolean
640
+ /** On `turn_end` and `subagent_stop`: the model's last message of the turn. Sent as `last_assistant_message`. */
641
+ readonly lastAssistantMessage?: string
512
642
  /** The session's transcript file, when the host has one. Sent as `transcript_path`. */
513
643
  readonly transcriptPath?: string
514
644
  /** How tool calls are being decided right now. Sent as `permission_mode`. */
@@ -519,7 +649,7 @@ export interface ShellHookInput {
519
649
  export function shellHookPayload(input: ShellHookInput): string {
520
650
  return JSON.stringify({
521
651
  event: input.event,
522
- hook_event_name: PASCAL_EVENT_NAMES[input.event] ?? input.event,
652
+ hook_event_name: SHELL_HOOK_PASCAL_EVENT_NAMES[input.event] ?? input.event,
523
653
  cwd: input.cwd,
524
654
  session_id: input.sessionId,
525
655
  ...(input.transcriptPath !== undefined ? { transcript_path: input.transcriptPath } : {}),
@@ -532,9 +662,14 @@ export function shellHookPayload(input: ShellHookInput): string {
532
662
  ? { agent_id: input.sessionId, agent_type: input.agentType }
533
663
  : {}),
534
664
  ...(input.prompt !== undefined ? { prompt: input.prompt } : {}),
535
- ...(input.compaction !== undefined ? { compaction: input.compaction } : {}),
536
- ...(input.toolName !== undefined ? { tool_name: input.toolName } : {}),
537
- ...(input.toolInput !== undefined ? { tool_input: input.toolInput } : {}),
665
+ ...(input.compaction !== undefined ? compactionFields(input.event, input.compaction) : {}),
666
+ ...(input.toolName !== undefined
667
+ ? { tool_name: shellHookToolName(input.toolName, input.toolNames) }
668
+ : {}),
669
+ ...(input.toolCallId !== undefined ? { tool_use_id: input.toolCallId } : {}),
670
+ ...(input.toolInput !== undefined
671
+ ? { tool_input: withFilePathKey(input.toolName, input.toolInput) }
672
+ : {}),
538
673
  ...(input.toolResult !== undefined
539
674
  ? { tool_result: input.toolResult, tool_response: input.toolResult }
540
675
  : {}),
@@ -544,8 +679,8 @@ export function shellHookPayload(input: ShellHookInput): string {
544
679
  ...(input.toolCalls !== undefined
545
680
  ? {
546
681
  tool_calls: input.toolCalls.map((call) => ({
547
- tool_name: call.toolName,
548
- tool_input: call.toolInput,
682
+ tool_name: shellHookToolName(call.toolName, input.toolNames),
683
+ tool_input: withFilePathKey(call.toolName, call.toolInput),
549
684
  tool_use_id: call.toolCallId,
550
685
  tool_response: call.output,
551
686
  is_error: call.isError,
@@ -565,9 +700,31 @@ export function shellHookPayload(input: ShellHookInput): string {
565
700
  }
566
701
  : {}),
567
702
  ...(input.stopHookActive === true ? { stop_hook_active: true } : {}),
703
+ ...(input.lastAssistantMessage !== undefined
704
+ ? { last_assistant_message: input.lastAssistantMessage }
705
+ : {}),
568
706
  })
569
707
  }
570
708
 
709
+ /**
710
+ * A compaction hook's fields. `compaction` keeps what it always had and gains
711
+ * `trigger`; the person's instructions and the summary are top-level
712
+ * (`custom_instructions`, null when there are none, and `compact_summary`),
713
+ * where a script reads them without opening the object.
714
+ */
715
+ function compactionFields(
716
+ event: ShellHookEvent,
717
+ compaction: PluginCompactionInfo,
718
+ ): Record<string, unknown> {
719
+ const { instructions, summary, ...rest } = compaction
720
+ return {
721
+ compaction: rest,
722
+ ...(compaction.trigger !== undefined ? { trigger: compaction.trigger } : {}),
723
+ ...(event === 'pre_compact' ? { custom_instructions: instructions ?? null } : {}),
724
+ ...(event === 'post_compact' && summary !== undefined ? { compact_summary: summary } : {}),
725
+ }
726
+ }
727
+
571
728
  /** Run one hook command to completion, bounded by its deadline and capture. */
572
729
  export function runShellHook(
573
730
  entry: ShellHookEntry,
@@ -592,7 +749,9 @@ export function runShellHook(
592
749
  NAMZU_SESSION_ID: input.sessionId,
593
750
  NAMZU_PROJECT_DIR: input.projectDir ?? input.cwd,
594
751
  ...(input.turnId !== undefined ? { NAMZU_TURN_ID: input.turnId } : {}),
595
- ...(input.toolName !== undefined ? { NAMZU_TOOL_NAME: input.toolName } : {}),
752
+ ...(input.toolName !== undefined
753
+ ? { NAMZU_TOOL_NAME: shellHookToolName(input.toolName, input.toolNames) }
754
+ : {}),
596
755
  ...(toolPath !== undefined ? { NAMZU_TOOL_PATH: toolPath } : {}),
597
756
  }
598
757
  // Its own process group, so the deadline can kill everything the
@@ -713,6 +872,56 @@ export function runShellHook(
713
872
  })
714
873
  }
715
874
 
875
+ /**
876
+ * The tools whose input names a file or folder `path`. A moved script reads
877
+ * that file under the key `file_path`, so the input a hook receives carries
878
+ * both, with the same value.
879
+ */
880
+ const FILE_PATH_TOOLS: ReadonlySet<string> = new Set([
881
+ 'read',
882
+ 'write',
883
+ 'edit',
884
+ 'grep',
885
+ 'glob',
886
+ 'ls',
887
+ 'view_image',
888
+ 'lsp',
889
+ ])
890
+
891
+ /** `toolInput` as a hook receives it: a file tool's `path` is also `file_path`. */
892
+ function withFilePathKey(toolName: string | undefined, toolInput: unknown): unknown {
893
+ if (toolName === undefined || !FILE_PATH_TOOLS.has(toolName.toLowerCase())) return toolInput
894
+ if (!isRecord(toolInput)) return toolInput
895
+ const path = toolInput.path
896
+ if (typeof path !== 'string' || 'file_path' in toolInput) return toolInput
897
+ return { ...toolInput, file_path: path }
898
+ }
899
+
900
+ /**
901
+ * A rewritten input read back. An echoed `file_path` is never a reason to
902
+ * refuse the rewrite: when only one of the two keys was changed that change is
903
+ * the new path; when both were changed to different values the answer cannot be
904
+ * read (`undefined`). The tool never sees `file_path`.
905
+ */
906
+ function readRewrittenInput(
907
+ toolName: string | undefined,
908
+ original: unknown,
909
+ rewritten: Record<string, unknown>,
910
+ ): Record<string, unknown> | undefined {
911
+ if (toolName === undefined || !FILE_PATH_TOOLS.has(toolName.toLowerCase())) return rewritten
912
+ if (!('file_path' in rewritten)) return rewritten
913
+ const { file_path: echoed, ...rest } = rewritten
914
+ const before = isRecord(original) ? original.path : undefined
915
+ const pathChanged = 'path' in rest && rest.path !== before
916
+ const echoChanged = echoed !== before
917
+ if (pathChanged && echoChanged && echoed !== rest.path) return undefined
918
+ if (!pathChanged && echoChanged) {
919
+ if (typeof echoed !== 'string') return undefined
920
+ return { ...rest, path: echoed }
921
+ }
922
+ return rest
923
+ }
924
+
716
925
  function pathOf(toolInput: unknown): string | undefined {
717
926
  if (toolInput === null || typeof toolInput !== 'object') return undefined
718
927
  const record = toolInput as Record<string, unknown>
@@ -724,7 +933,7 @@ function pathOf(toolInput: unknown): string | undefined {
724
933
  }
725
934
 
726
935
  /** The PascalCase spelling of each event name, which a hook's JSON may use. */
727
- const PASCAL_EVENT_NAMES: Readonly<Partial<Record<ShellHookEvent, string>>> = {
936
+ export const SHELL_HOOK_PASCAL_EVENT_NAMES: Readonly<Partial<Record<ShellHookEvent, string>>> = {
728
937
  user_prompt_submit: 'UserPromptSubmit',
729
938
  session_start: 'SessionStart',
730
939
  session_end: 'SessionEnd',
@@ -760,6 +969,40 @@ interface ShellHookReading {
760
969
  invalid?: string
761
970
  }
762
971
 
972
+ /** The keys that carry a replacement for a tool's result. */
973
+ const REPLACEMENT_KEYS = ['updatedToolOutput', 'updatedMCPToolOutput'] as const
974
+
975
+ /** The tool tied to an answer: what a rewrite or a replacement is read against. */
976
+ export interface ShellHookCall {
977
+ readonly toolName?: string
978
+ readonly toolInput?: unknown
979
+ }
980
+
981
+ /**
982
+ * A replacement for a tool's result, whatever its JSON type. A string is the
983
+ * text; an object that has the shape of the result object a hook is sent
984
+ * (`output` a string, `success` a boolean and `error` a string when present)
985
+ * is read as that; anything else (another object, an array, a number, a
986
+ * boolean) is shown as its JSON text. The call's own success is not changed.
987
+ */
988
+ function replacementText(value: unknown): string {
989
+ if (typeof value === 'string') return value
990
+ if (
991
+ isRecord(value) &&
992
+ typeof value.output === 'string' &&
993
+ (value.success === undefined || typeof value.success === 'boolean') &&
994
+ (value.error === undefined || typeof value.error === 'string')
995
+ ) {
996
+ if (value.success === false && typeof value.error === 'string' && value.error !== '') {
997
+ return value.output === ''
998
+ ? `Error: ${value.error}`
999
+ : `${value.output}\n\nError: ${value.error}`
1000
+ }
1001
+ return value.output
1002
+ }
1003
+ return JSON.stringify(value)
1004
+ }
1005
+
763
1006
  function isRecord(value: unknown): value is Record<string, unknown> {
764
1007
  return value !== null && typeof value === 'object' && !Array.isArray(value)
765
1008
  }
@@ -770,7 +1013,11 @@ function isRecord(value: unknown): value is Record<string, unknown> {
770
1013
  * sets the whole object aside rather than half-obeying it, so a typo in a
771
1014
  * guard's `decision` is reported instead of silently becoming "continue".
772
1015
  */
773
- function readShellHookJson(event: ShellHookEvent, stdout: string): ShellHookReading | undefined {
1016
+ function readShellHookJson(
1017
+ event: ShellHookEvent,
1018
+ stdout: string,
1019
+ call: ShellHookCall = {},
1020
+ ): ShellHookReading | undefined {
774
1021
  const text = stdout.trim()
775
1022
  if (!text.startsWith('{')) return undefined
776
1023
  let parsed: unknown
@@ -815,6 +1062,24 @@ function readShellHookJson(event: ShellHookEvent, stdout: string): ShellHookRead
815
1062
  return { invalid: '`decision` must be "approve" or "block"' }
816
1063
  }
817
1064
 
1065
+ // A replacement written where it is not read (at the top level of the answer
1066
+ // instead of under `hookSpecificOutput`) is a hook that clearly tried to say
1067
+ // something we cannot read: set the answer aside as unreadable, so
1068
+ // `onFailure: block` withholds the result rather than showing the original.
1069
+ if (event === 'post_tool_use' || event === 'post_tool_use_failure') {
1070
+ const misplaced = REPLACEMENT_KEYS.find((key) => parsed[key] !== undefined)
1071
+ const inSpecific =
1072
+ isRecord(parsed.hookSpecificOutput) &&
1073
+ REPLACEMENT_KEYS.some(
1074
+ (key) => (parsed.hookSpecificOutput as Record<string, unknown>)[key] !== undefined,
1075
+ )
1076
+ if (misplaced !== undefined && !inSpecific) {
1077
+ return {
1078
+ invalid: `\`${misplaced}\` is read under \`hookSpecificOutput\`, not at the top level of the answer`,
1079
+ }
1080
+ }
1081
+ }
1082
+
818
1083
  const reading: ShellHookReading = {}
819
1084
  if (parsed.suppressOutput === true) reading.suppressOutput = true
820
1085
  const systemMessage = optionalString('systemMessage')
@@ -836,7 +1101,7 @@ function readShellHookJson(event: ShellHookEvent, stdout: string): ShellHookRead
836
1101
  if (typeof named !== 'string') {
837
1102
  return { invalid: '`hookSpecificOutput` is missing its `hookEventName`' }
838
1103
  }
839
- if (named !== event && named !== PASCAL_EVENT_NAMES[event]) {
1104
+ if (named !== event && named !== SHELL_HOOK_PASCAL_EVENT_NAMES[event]) {
840
1105
  return {
841
1106
  invalid: `\`hookSpecificOutput.hookEventName\` is "${named}" but this hook ran for ${event}`,
842
1107
  }
@@ -869,14 +1134,27 @@ function readShellHookJson(event: ShellHookEvent, stdout: string): ShellHookRead
869
1134
  }
870
1135
  if (specific.updatedInput !== undefined) {
871
1136
  if (!isRecord(specific.updatedInput)) return { invalid: '`updatedInput` must be an object' }
872
- reading.updatedInput = specific.updatedInput
1137
+ const rewritten = readRewrittenInput(call.toolName, call.toolInput, specific.updatedInput)
1138
+ if (rewritten === undefined) {
1139
+ return {
1140
+ invalid:
1141
+ '`updatedInput` changes both `path` and `file_path`, to different values: say which file is meant',
1142
+ }
1143
+ }
1144
+ reading.updatedInput = rewritten
873
1145
  }
874
1146
  }
875
- if (event === 'post_tool_use' && specific.updatedToolOutput !== undefined) {
876
- if (typeof specific.updatedToolOutput !== 'string') {
877
- return { invalid: 'hookSpecificOutput: `updatedToolOutput` must be a string' }
1147
+ if (event === 'post_tool_use' || event === 'post_tool_use_failure') {
1148
+ // For a tool-server tool the common contract has a second field, read
1149
+ // when `updatedToolOutput` is absent or null. `updatedToolOutput` wins when
1150
+ // both carry a value: it is the field for every tool, so a script that
1151
+ // sets both has said the general thing last.
1152
+ const toolServer = call.toolName?.startsWith('mcp__') === true
1153
+ const chosen =
1154
+ specific.updatedToolOutput ?? (toolServer ? specific.updatedMCPToolOutput : undefined)
1155
+ if (chosen !== undefined && chosen !== null) {
1156
+ reading.replacement = boundHookReplacement(replacementText(chosen))
878
1157
  }
879
- reading.replacement = boundHookReplacement(specific.updatedToolOutput)
880
1158
  }
881
1159
  if (event === 'permission_request') {
882
1160
  const decision = specific.decision
@@ -907,6 +1185,7 @@ export function shellHookVerdict(
907
1185
  entry: ShellHookHandler,
908
1186
  outcome: ShellHookOutcome,
909
1187
  log: Logger = NOOP_LOGGER,
1188
+ call: ShellHookCall = {},
910
1189
  ): PluginHookResult {
911
1190
  const label = describeShellHook(entry)
912
1191
  const attrs = {
@@ -937,6 +1216,7 @@ export function shellHookVerdict(
937
1216
  { ...entry, onFailure: 'continue' },
938
1217
  { exitCode: 2, timedOut: false, stdout: '', stderr: `hook failed: ${reason}` },
939
1218
  log,
1219
+ call,
940
1220
  )
941
1221
  }
942
1222
  if (outcome.spawnError !== undefined) {
@@ -992,13 +1272,31 @@ export function shellHookVerdict(
992
1272
  ? {
993
1273
  invalid: `answer is too large: the hook printed more than the ${MAX_CAPTURE_BYTES} characters of stdout that are read`,
994
1274
  }
995
- : readShellHookJson(event, outcome.stdout)
1275
+ : readShellHookJson(event, outcome.stdout, call)
996
1276
  if (reading === undefined) {
1277
+ // A post-tool hook whose plain output (or whose stderr) names a replacement
1278
+ // key tried to replace the result and put the answer where it is not read
1279
+ // (a log line before the JSON, the JSON on stderr). That is an answer that
1280
+ // does not read, so it follows `onFailure` like one.
1281
+ if (
1282
+ (event === 'post_tool_use' || event === 'post_tool_use_failure') &&
1283
+ REPLACEMENT_KEYS.some((key) => outcome.stdout.includes(key) || outcome.stderr.includes(key))
1284
+ ) {
1285
+ log.warn('hook output ignored', {
1286
+ ...attrs,
1287
+ 'namzu.hook.problem':
1288
+ 'a replacement is named but the answer is not a JSON object on stdout',
1289
+ })
1290
+ return failed('its answer names a replacement but is not a JSON object that starts stdout')
1291
+ }
997
1292
  // On the prompt event, what the hook printed is context for the
998
1293
  // model — the branch, the time, a ticket. Everywhere else stdout is
999
1294
  // the operator's to read and the model's business not at all.
1000
1295
  if (
1001
- (event === 'user_prompt_submit' || event === 'subagent_start' || event === 'session_start') &&
1296
+ (event === 'user_prompt_submit' ||
1297
+ event === 'subagent_start' ||
1298
+ event === 'session_start' ||
1299
+ event === 'pre_compact') &&
1002
1300
  text.length > 0
1003
1301
  )
1004
1302
  return { action: 'annotate', text }
@@ -1118,6 +1416,8 @@ export function shellHookVerdict(
1118
1416
  }
1119
1417
  }
1120
1418
  case 'pre_compact':
1419
+ // What the hook said is for the summariser: it rides as `context` on
1420
+ // either answer (a refusal a forced pass overrides is still heard).
1121
1421
  return reading.block !== undefined
1122
1422
  ? { action: 'skip', reason: reading.block, ...notes }
1123
1423
  : { action: 'continue', ...notes }
@@ -1332,6 +1632,8 @@ export function createShellHook(
1332
1632
  if (spent) return { action: 'continue' }
1333
1633
  // A notification is matched on its type; every other event on the tool.
1334
1634
  const namesATool = !(
1635
+ event === 'pre_compact' ||
1636
+ event === 'post_compact' ||
1335
1637
  event === 'notification' ||
1336
1638
  event === 'subagent_start' ||
1337
1639
  event === 'subagent_stop' ||
@@ -1341,14 +1643,21 @@ export function createShellHook(
1341
1643
  const subject =
1342
1644
  event === 'notification'
1343
1645
  ? context.notification?.type
1344
- : event === 'subagent_start' || event === 'subagent_stop'
1345
- ? context.agentType
1346
- : event === 'session_start'
1347
- ? context.source
1348
- : event === 'session_end'
1349
- ? context.endReason
1350
- : context.toolName
1351
- if (!shellHookMatches(entry.matcher, subject, { tool: namesATool }))
1646
+ : event === 'pre_compact' || event === 'post_compact'
1647
+ ? context.compaction?.trigger
1648
+ : event === 'subagent_start' || event === 'subagent_stop'
1649
+ ? context.agentType
1650
+ : event === 'session_start'
1651
+ ? context.source
1652
+ : event === 'session_end'
1653
+ ? context.endReason
1654
+ : context.toolName
1655
+ // An event that has no name to match (`turn_end`, `user_prompt_submit`,
1656
+ // `turn_start`, `post_tool_batch`) ignores the matcher and the hook
1657
+ // fires: a matcher cannot be read against a name that is not there, and
1658
+ // a guard that never fires is the failure to avoid. The compaction
1659
+ // events are matched on `trigger`.
1660
+ if (subject !== undefined && !shellHookMatches(entry.matcher, subject, { tool: namesATool }))
1352
1661
  return { action: 'continue' }
1353
1662
  if (!shellHookIfMatches(entry.if, context.toolName, context.toolInput, options.pathFilter)) {
1354
1663
  return { action: 'continue' }
@@ -1374,6 +1683,8 @@ export function createShellHook(
1374
1683
  ...(context.prompt !== undefined ? { prompt: context.prompt } : {}),
1375
1684
  ...(context.compaction !== undefined ? { compaction: context.compaction } : {}),
1376
1685
  ...(context.toolName !== undefined ? { toolName: context.toolName } : {}),
1686
+ ...(entry.toolNames !== undefined ? { toolNames: entry.toolNames } : {}),
1687
+ ...(context.toolCallId !== undefined ? { toolCallId: context.toolCallId } : {}),
1377
1688
  ...(context.toolInput !== undefined ? { toolInput: context.toolInput } : {}),
1378
1689
  ...(context.toolResult !== undefined
1379
1690
  ? {
@@ -1391,9 +1702,16 @@ export function createShellHook(
1391
1702
  ...(context.error !== undefined ? { error: context.error } : {}),
1392
1703
  ...(context.notification !== undefined ? { notification: context.notification } : {}),
1393
1704
  ...(context.stopHookActive === true ? { stopHookActive: true } : {}),
1705
+ ...(context.lastAssistantMessage !== undefined
1706
+ ? { lastAssistantMessage: context.lastAssistantMessage }
1707
+ : {}),
1394
1708
  ...(transcriptPath !== undefined ? { transcriptPath } : {}),
1395
1709
  ...(permissionMode !== undefined ? { permissionMode } : {}),
1396
1710
  }
1711
+ const verdictCall: ShellHookCall = {
1712
+ ...(context.toolName !== undefined ? { toolName: context.toolName } : {}),
1713
+ ...(context.toolInput !== undefined ? { toolInput: context.toolInput } : {}),
1714
+ }
1397
1715
  if (entry.type === 'http') {
1398
1716
  const response = await postHookJson({
1399
1717
  url: entry.url,
@@ -1416,6 +1734,7 @@ export function createShellHook(
1416
1734
  ...(response.error !== undefined ? { spawnError: response.error } : {}),
1417
1735
  },
1418
1736
  log,
1737
+ verdictCall,
1419
1738
  )
1420
1739
  }
1421
1740
  if (entry.type === 'prompt') {
@@ -1426,14 +1745,21 @@ export function createShellHook(
1426
1745
  // for it and its own failure is only written down.
1427
1746
  void runShellHook(entry, hookInput)
1428
1747
  .then((outcome) => {
1429
- shellHookVerdict(event, entry, outcome, log)
1748
+ const verdict = shellHookVerdict(event, entry, outcome, log, verdictCall)
1749
+ if (verdict.action !== 'continue') {
1750
+ log.warn('an async hook answered, and its answer is not applied', {
1751
+ 'namzu.hook.event': event,
1752
+ 'namzu.hook.command': describeShellHook(entry),
1753
+ 'namzu.hook.action': verdict.action,
1754
+ })
1755
+ }
1430
1756
  })
1431
1757
  .catch(() => undefined)
1432
1758
  return { action: 'continue' }
1433
1759
  }
1434
1760
  const outcome = await runShellHook(entry, hookInput, context.signal)
1435
1761
  reportTimeout(outcome.timedOut)
1436
- return shellHookVerdict(event, entry, outcome, log)
1762
+ return shellHookVerdict(event, entry, outcome, log, verdictCall)
1437
1763
  },
1438
1764
  } as PluginHookDefinition
1439
1765
  }