frizz-server 0.13.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 (155) hide show
  1. package/dist/claude-agent-broker.js +35911 -0
  2. package/dist/codex-app-server-daemon.js +423 -0
  3. package/dist/dev-child.js +56233 -0
  4. package/package.json +34 -0
  5. package/runtime/board/agent-bindings.mjs +287 -0
  6. package/runtime/board/agent-liveness.mjs +367 -0
  7. package/runtime/board/agent-status.mjs +178 -0
  8. package/runtime/board/config.mjs +993 -0
  9. package/runtime/board/decisions.mjs +97 -0
  10. package/runtime/board/index.mjs +704 -0
  11. package/runtime/board/notify-shared.mjs +90 -0
  12. package/runtime/board/notify.mjs +81 -0
  13. package/runtime/board/ownership.mjs +120 -0
  14. package/runtime/board/rest-detect.mjs +213 -0
  15. package/runtime/board/thread-excerpt.mjs +162 -0
  16. package/runtime/board/thread-update.mjs +289 -0
  17. package/runtime/cc-worker/.claude-plugin/plugin.json +10 -0
  18. package/runtime/cc-worker/DECISIONS.md +1172 -0
  19. package/runtime/cc-worker/LICENSE +21 -0
  20. package/runtime/cc-worker/agents/high.md +7 -0
  21. package/runtime/cc-worker/agents/low.md +7 -0
  22. package/runtime/cc-worker/agents/max.md +7 -0
  23. package/runtime/cc-worker/agents/medium.md +7 -0
  24. package/runtime/cc-worker/agents/xhigh.md +7 -0
  25. package/runtime/cc-worker/bin/frizz +17 -0
  26. package/runtime/cc-worker/bin/frizz-mcp.mjs +1612 -0
  27. package/runtime/cc-worker/bin/frizz-update +18 -0
  28. package/runtime/cc-worker/hooks/agent-bind.mjs +40 -0
  29. package/runtime/cc-worker/hooks/agent-dispatch.mjs +121 -0
  30. package/runtime/cc-worker/hooks/bash-background.d.mts +6 -0
  31. package/runtime/cc-worker/hooks/bash-background.mjs +247 -0
  32. package/runtime/cc-worker/hooks/deny-ask.mjs +39 -0
  33. package/runtime/cc-worker/hooks/deny-plan.mjs +62 -0
  34. package/runtime/cc-worker/hooks/hooks.json +102 -0
  35. package/runtime/cc-worker/hooks/perm-policy.mjs +211 -0
  36. package/runtime/cc-worker/hooks/scratchpad.mjs +417 -0
  37. package/runtime/cc-worker/hooks/session-seed.mjs +107 -0
  38. package/runtime/cc-worker/scripts/frizz/agent-bindings.mjs +9 -0
  39. package/runtime/cc-worker/scripts/frizz/config.mjs +12 -0
  40. package/runtime/cc-worker/skills/gh/SKILL.md +141 -0
  41. package/runtime/cc-worker/skills/gh/scripts/ci-watch.mjs +60 -0
  42. package/runtime/cc-worker/skills/gh/scripts/github-watch.mjs +130 -0
  43. package/runtime/cc-worker/skills/gh/scripts/review-watch.mjs +54 -0
  44. package/web-dist/apple-touch-icon.png +0 -0
  45. package/web-dist/assets/TerminalPane-DyLvW_rQ.js +7 -0
  46. package/web-dist/assets/abnfDiagram-VRR7QNED-DIPgkiM8.js +1 -0
  47. package/web-dist/assets/arc-BSyeo0Gb.js +1 -0
  48. package/web-dist/assets/architecture-TIHT7OUA-B8qUD5-C.js +1 -0
  49. package/web-dist/assets/architectureDiagram-ZJ3FMSHR-DBAKToiy.js +36 -0
  50. package/web-dist/assets/array-BifhSqXX.js +1 -0
  51. package/web-dist/assets/blockDiagram-677ZJIJ3-Ba0xt8st.js +132 -0
  52. package/web-dist/assets/c4Diagram-LMCZKHZV-DFham1h_.js +10 -0
  53. package/web-dist/assets/channel-5l10tOPT.js +1 -0
  54. package/web-dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  55. package/web-dist/assets/chunk-32BRIVSS-Bl-817K-.js +1 -0
  56. package/web-dist/assets/chunk-52WLFC77-DBLTDz2W.js +10 -0
  57. package/web-dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  58. package/web-dist/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
  59. package/web-dist/assets/chunk-C7G6YPKG-ClL6Ebv8.js +1 -0
  60. package/web-dist/assets/chunk-EX3LRPZG--3vJLCZP.js +231 -0
  61. package/web-dist/assets/chunk-FWX5IMBZ-DMOdhcCP.js +2 -0
  62. package/web-dist/assets/chunk-HOUHSVGY-DkgTGLCa.js +1 -0
  63. package/web-dist/assets/chunk-ICXQ74PX-7X6iir1H.js +2 -0
  64. package/web-dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  65. package/web-dist/assets/chunk-KEIR6QF5-BfrZ3jm6.js +161 -0
  66. package/web-dist/assets/chunk-MOJQB5TN-OpO5flE4.js +88 -0
  67. package/web-dist/assets/chunk-OGEWGWER-BbAMAzTZ.js +1 -0
  68. package/web-dist/assets/chunk-PUDLZKDR-avcvDgZl.js +156 -0
  69. package/web-dist/assets/chunk-Q4XR5HBZ-BaiGN1cd.js +70 -0
  70. package/web-dist/assets/chunk-RYQCIY6F-Cu_KplZW.js +1 -0
  71. package/web-dist/assets/chunk-V7JOEXUC-CKVakdOJ.js +206 -0
  72. package/web-dist/assets/chunk-VAUOI2AC-DzG-rM3_.js +1 -0
  73. package/web-dist/assets/chunk-VR4S4FIN-t3j3HHQF.js +1 -0
  74. package/web-dist/assets/chunk-WYO6CB5R-SnP0NDTw.js +127 -0
  75. package/web-dist/assets/chunk-XXDRQBXY-DYlTP5J-.js +1 -0
  76. package/web-dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  77. package/web-dist/assets/chunk-ZGVPDNZ5-pXn3giwS.js +62 -0
  78. package/web-dist/assets/chunk-ZIRB5QZD-C6fEPe3t.js +32 -0
  79. package/web-dist/assets/classDiagram-OUVF2IWQ-vIfzHupB.js +1 -0
  80. package/web-dist/assets/classDiagram-v2-EOCWNBFH-vIfzHupB.js +1 -0
  81. package/web-dist/assets/cose-bilkent-JH36ORCC-BUIsLrGc.js +1 -0
  82. package/web-dist/assets/cynefin-VYW2F7L2-C4qNLMkm.js +1 -0
  83. package/web-dist/assets/cynefinDiagram-TSTJHNR4-2vzWUUfl.js +62 -0
  84. package/web-dist/assets/cytoscape.esm-B3I8pqwA.js +321 -0
  85. package/web-dist/assets/dagre-CXRCoUWR.js +1 -0
  86. package/web-dist/assets/dagre-VKFMJZFB-DUdNHEM9.js +4 -0
  87. package/web-dist/assets/defaultLocale-C8Fc0cco.js +1 -0
  88. package/web-dist/assets/diagram-FQU43EPY-C_EHNL09.js +3 -0
  89. package/web-dist/assets/diagram-G47NLZAW-DOt98NB-.js +24 -0
  90. package/web-dist/assets/diagram-NH7WQ7WH-uIgVP9iZ.js +24 -0
  91. package/web-dist/assets/diagram-OA4YK3LP-CGWe4oxq.js +30 -0
  92. package/web-dist/assets/diagram-WEI45ONY-C-5f7o9T.js +41 -0
  93. package/web-dist/assets/dist-DoH_9pyS.js +1 -0
  94. package/web-dist/assets/ebnfDiagram-CCIWWBDH-DoSFLtL-.js +1 -0
  95. package/web-dist/assets/erDiagram-Q63AITRT-DsCLMzEE.js +85 -0
  96. package/web-dist/assets/eventmodeling-45OFAUF4-D7GQYhiK.js +1 -0
  97. package/web-dist/assets/flowDiagram-23GEKE2U-CR371xZs.js +1 -0
  98. package/web-dist/assets/ganttDiagram-NO4QXBWP-D8UNGcBR.js +292 -0
  99. package/web-dist/assets/gitGraph-TEB2WS4Q-mC-XQzTE.js +1 -0
  100. package/web-dist/assets/gitGraphDiagram-IHSO6WYX-CkpPggS7.js +106 -0
  101. package/web-dist/assets/graphlib-B8gBHxth.js +1 -0
  102. package/web-dist/assets/index-CT6k_A5y.css +1 -0
  103. package/web-dist/assets/index-Dmo0zJc8.js +319 -0
  104. package/web-dist/assets/info-DKCQHKI2-Drg-xVbr.js +1 -0
  105. package/web-dist/assets/infoDiagram-FWYZ7A6U-CsGMTGpl.js +2 -0
  106. package/web-dist/assets/init-D6jRqBbL.js +1 -0
  107. package/web-dist/assets/ishikawaDiagram-FXEZZL3T-DNgGBlL6.js +70 -0
  108. package/web-dist/assets/journeyDiagram-5HDEW3XC-BFN2bObi.js +139 -0
  109. package/web-dist/assets/kanban-definition-HUTT4EX6-BnDPclXf.js +89 -0
  110. package/web-dist/assets/katex-CddkPoXu.js +257 -0
  111. package/web-dist/assets/line-DmLw74JM.js +1 -0
  112. package/web-dist/assets/linear-z2V0wJk9.js +1 -0
  113. package/web-dist/assets/map-DsCK-0Cs.js +1 -0
  114. package/web-dist/assets/mermaid-parser.core-DGJk39E-.js +7 -0
  115. package/web-dist/assets/mermaid.core-8aee8nsf.js +11 -0
  116. package/web-dist/assets/mindmap-definition-LN4V7U3C-CGWK_Qbm.js +96 -0
  117. package/web-dist/assets/ordinal-hYBb2elL.js +1 -0
  118. package/web-dist/assets/packet-7NZHBO7P-C5HYQyS5.js +1 -0
  119. package/web-dist/assets/path-BWPyau1x.js +1 -0
  120. package/web-dist/assets/pegDiagram-2B236MQR-WiQm887Q.js +1 -0
  121. package/web-dist/assets/pie-RZYD4A2V-DKBNMtMn.js +1 -0
  122. package/web-dist/assets/pieDiagram-ENE6RG2P-F1A8_3DO.js +39 -0
  123. package/web-dist/assets/quadrantDiagram-ABIIQ3AL-bf6a3f_f.js +7 -0
  124. package/web-dist/assets/radar-I7S5WNFK-AOKDUn-C.js +1 -0
  125. package/web-dist/assets/railroad-3IZDKUUU-DNHhkFAC.js +1 -0
  126. package/web-dist/assets/railroad-abnf-AHOZXSZD-DOXbu4iv.js +1 -0
  127. package/web-dist/assets/railroad-ebnf-EBAXGLYW-C1oE2RHD.js +1 -0
  128. package/web-dist/assets/railroad-peg-LSFZ7HO6-B4GD-bq-.js +1 -0
  129. package/web-dist/assets/railroadDiagram-RFXS5EU6-Be52T90z.js +1 -0
  130. package/web-dist/assets/requirementDiagram-TGXJPOKE-BqdMvEGK.js +84 -0
  131. package/web-dist/assets/rolldown-runtime-Bh1tDfsg.js +1 -0
  132. package/web-dist/assets/rough.esm-CSKSodPl.js +1 -0
  133. package/web-dist/assets/sankeyDiagram-HTMAVEWB-BP3X6Ofp.js +40 -0
  134. package/web-dist/assets/sequenceDiagram-DBY2YBRQ-yDHhaUzc.js +162 -0
  135. package/web-dist/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
  136. package/web-dist/assets/src-C4XfhTaE.js +1 -0
  137. package/web-dist/assets/stateDiagram-2N3HPSRC-xvctsgCU.js +1 -0
  138. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-xFk0N3Cq.js +1 -0
  139. package/web-dist/assets/swimlanes-5IMT3BWC-DZMLgrjk.js +2 -0
  140. package/web-dist/assets/swimlanesDiagram-G3AALYLV-BoWrxkxy.js +8 -0
  141. package/web-dist/assets/timeline-definition-FHXFAJF6-n8sU0qlT.js +120 -0
  142. package/web-dist/assets/treeView-QDETBFTQ-Su8KloaY.js +1 -0
  143. package/web-dist/assets/treemap-6X3UGDF4-CNgRuVWf.js +1 -0
  144. package/web-dist/assets/vennDiagram-L72KCM5P-B5I9YxaY.js +34 -0
  145. package/web-dist/assets/wardley-OPB4EBWU-khMe_Wbq.js +1 -0
  146. package/web-dist/assets/wardleyDiagram-EHGQE667-d8LsqhTO.js +78 -0
  147. package/web-dist/assets/xychartDiagram-FW5EYKEG-b0CH_-wy.js +7 -0
  148. package/web-dist/favicon-16.png +0 -0
  149. package/web-dist/favicon-32.png +0 -0
  150. package/web-dist/favicon.svg +34 -0
  151. package/web-dist/icon-192.png +0 -0
  152. package/web-dist/icon-512.png +0 -0
  153. package/web-dist/icon-maskable-512.png +0 -0
  154. package/web-dist/index.html +44 -0
  155. package/web-dist/manifest.webmanifest +16 -0
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * frizz-update — the structured thread updater, on the worker's Bash PATH while the frizz-worker
5
+ * plugin is enabled. This is the worker's PRIMARY tool for owning its one thread file: it writes
6
+ * `.frizz/<slug>.md` through the same code path frizz uses (never hand-rolled markdown edits).
7
+ * Execs the repo board's bundled updater directly (NOT a copy) — cc-worker never forks updater logic.
8
+ *
9
+ * Resolving the script: this file is <plugin>/bin/frizz-update, board/ is a sibling of the plugin root,
10
+ * so the updater is at ../../board/thread-update.mjs relative to THIS file. Imported via
11
+ * a file:// URL so argv/process.env flow straight through.
12
+ */
13
+ import { fileURLToPath, pathToFileURL } from 'node:url';
14
+ import { dirname, join } from 'node:path';
15
+
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+ const updater = join(here, '..', '..', 'board', 'thread-update.mjs');
18
+ await import(pathToFileURL(updater).href);
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // PostToolUse hook on the `Agent` tool (frizz-worker) — the AUTOMATIC thread↔agent binding, kept
4
+ // COMPATIBLE with cc's board. When a worker dispatches a helper tagged `THREAD: <slug>` at the top
5
+ // of the prompt, this records `agentId → thread` into `.frizz/.agent-bindings.jsonl` in the exact
6
+ // shape cc's board (`bindingsByThread`) reads for per-thread sub-agent liveness — so frizz renders
7
+ // a worker's helper activity the same way the cc board does. An untagged helper writes nothing.
8
+ //
9
+ // GATE: inert unless FRIZZ_THREAD is set. FAIL-OPEN ABSOLUTELY: any error → exit 0, no output.
10
+ // A missed binding just means one helper isn't surfaced; a PostToolUse hook must never disturb the turn.
11
+ import { readFileSync } from 'node:fs';
12
+ import { recordBinding, threadFromPrompt } from '../scripts/frizz/agent-bindings.mjs';
13
+
14
+ try {
15
+ // WORKER GATE.
16
+ if (!(process.env.FRIZZ_THREAD ?? '').trim()) process.exit(0);
17
+
18
+ const input = JSON.parse(readFileSync(0, 'utf8'));
19
+ const dir = process.env.CLAUDE_PROJECT_DIR ?? '.';
20
+
21
+ const ti = input.tool_input ?? {};
22
+ const tr = input.tool_response ?? {};
23
+ const trInner = tr.toolUseResult ?? tr;
24
+
25
+ const agentId = trInner.agentId ?? tr.agentId ?? null;
26
+ const prompt =
27
+ (typeof trInner.prompt === 'string' && trInner.prompt) ||
28
+ (typeof tr.prompt === 'string' && tr.prompt) ||
29
+ (typeof ti.prompt === 'string' && ti.prompt) ||
30
+ '';
31
+ const thread = threadFromPrompt(prompt);
32
+ const label = (typeof ti.description === 'string' && ti.description) || trInner.description || null;
33
+
34
+ if (agentId && thread) {
35
+ recordBinding(dir, { agentId, thread, label, session: input.session_id ?? null });
36
+ }
37
+ } catch {
38
+ /* fail-open — never disturb the turn */
39
+ }
40
+ process.exit(0);
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // PreToolUse hook on the `Agent` tool (frizz-worker). A worker MAY spin up its own helper
4
+ // sub-agents; this hook holds them to the same rules cc enforces for the orchestrator:
5
+ // 1) ENFORCE background dispatch — deny any Agent call lacking run_in_background:true (a
6
+ // foreground agent blocks the worker's turn; a human interjection orphans it).
7
+ // 2) STRIP `name`/`team_name` — setting either strands a nested dispatch (its result routes
8
+ // wrong and never returns cleanly), so scrub both silently.
9
+ // 3) AUTO-APPEND a short ORCHESTRATION EPILOGUE carrying only what a helper cannot discover on
10
+ // its own: that its final message is the return value, that nothing can WAKE it so it must not
11
+ // end a turn on a waiter, the `SendMessage({to: "main"})` upward channel, the own-file rule for
12
+ // the scratch directory, no fan-out of its own unless asked, and how a helper it DID dispatch
13
+ // is collected. It carries no handoff format and no build, test, git or process policy — the
14
+ // maintainer had the handoff-format doctrine cut on 2026-08-26.
15
+ //
16
+ // WHY THE LIFECYCLE PARAGRAPH EXISTS (2026-09-05): a child cannot be re-invoked, and nothing told it
17
+ // so. Claude's own task-notification states the rule from the dispatcher's side — "a task-notification
18
+ // fires each time this agent stops with no live background children of its own" — which means a child
19
+ // that backgrounds a shell and ends its turn has RETURNED: the completion is delivered to the
20
+ // dispatcher, and the child is never resumed to read it. A Fable helper on nubjs/nub hit this four
21
+ // times in one effort, each time handing back the same sentence ("Three background jobs are
22
+ // outstanding … I am waiting for their completion notifications before writing"), and its entire
23
+ // 118-tool-call investigation lived only in those transient returns until the dispatcher captured them
24
+ // by hand. `DECISIONS.md` has claimed since the SubagentStop hooks were dropped that "the
25
+ // worker-facing half of the rest-guard's lesson (run long ops inline, don't rest on a waiter) is
26
+ // carried in the dispatch epilogue instead" — it was not; this paragraph is that lesson.
27
+ //
28
+ // The same paragraph CORRECTED the nested-collection sentence below, which had promised since
29
+ // 2026-07-31 that a helper's "completion is delivered to you automatically". Measured 2026-09-05: a
30
+ // child instructed to dispatch one helper and stop the instant the Agent tool returned came back
31
+ // `DISPATCHED: yes / SAW_HELPER_REPLY: no`, and was never resumed to read the reply. The promise was
32
+ // false in precisely the direction that strands a child — it is the sentence a helper reasons its way
33
+ // back to after being told not to wait — so it now says a helper cannot wake you either. The
34
+ // anti-polling rule it was written to carry is untouched; only its premise was.
35
+ //
36
+ // WHY NESTING IS DEFAULT-OFF (2026-08-04, maintainer's call): the epilogue used to speak about a
37
+ // helper's own helper only in the conditional ("if you dispatch a helper of your own…"), which reads
38
+ // as neutral permission, so a depth-1 child could decompose again purely because it could. A child is
39
+ // already one prong of a fan-out; another layer splits the context its dispatcher assembled and moves
40
+ // the real work further from the board. So the paragraph now LEADS with "do the work yourself unless
41
+ // your prompt says otherwise" and keeps the collection rules for the case where it does. This is a
42
+ // PROMPT-level default, not the hook-level depth-2 DENY rejected on 2026-07-31 as too blunt — an
43
+ // explicit instruction to fan out still dispatches, unmodified.
44
+ //
45
+ // WHY THE NESTED-DISPATCH PARAGRAPH EXISTS (2026-07-31): this hook fires at EVERY depth, but the
46
+ // frizz worker contract reaches only the ROOT worker — so its "keep fan-out shallow / a rested agent
47
+ // is not reliably re-woken by grandchildren" rule was delivered exclusively to the one agent that
48
+ // does not spawn grandchildren, and withheld from the depth-1 child that does. Into that silence a
49
+ // user-level CLAUDE.md ("the parent stays awake and polls the child's transcript") supplied a
50
+ // hand-rolled polling recipe, and it failed: the `.output` path is a SYMLINK, so `stat` without -L
51
+ // returns the LINK's size (= the length of its target path) and its frozen creation mtime, while
52
+ // the `"type":"result"` record is never reliably written. Both halves of the predicate
53
+ // false-negatived at once, a live 413KB helper read as "size=153 age=325s results=0", and the
54
+ // dispatcher discarded it and redid the work itself. This paragraph is the only place that reaches
55
+ // a nested dispatcher without depending on its parent remembering to restate the norm.
56
+ //
57
+ // GATE: inert unless FRIZZ_THREAD is set (not a frizz worker → allow every dispatch unmodified).
58
+ //
59
+ // DROPPED vs cc's agent-dispatch.mjs (see DECISIONS.md): the `.dispatch-count` bump (it only gates
60
+ // cc's SubagentStop rest-recorder, which cc-worker does not ship) and the THREAD:-ledger write +
61
+ // thread-existence DENY gate (that guards the orchestrator's "file the thread before dispatching"
62
+ // discipline; a worker owns exactly one already-existing thread and its helpers own no thread).
63
+ //
64
+ // FAIL OPEN: any parse error → allow unmodified. A broken dispatch hook must never halt work.
65
+ import { readFileSync } from 'node:fs';
66
+
67
+ const EPILOGUE = `
68
+
69
+ ---
70
+ [ORCHESTRATION EPILOGUE — auto-appended by the frizz worker dispatch hook] You are a helper sub-agent for a frizz worker. Your final message is the handoff your dispatcher reads: what you did, what you verified, what remains.
71
+ NOTHING CAN WAKE YOU — ending your turn IS your return. A job you backgrounded (Bash \`run_in_background:true\` above all) reports to your DISPATCHER, never to you, so a turn that ends while one is outstanding hands back unfinished work and leaves its result somewhere you will never read. So never end a turn waiting for something to finish: run long commands in the FOREGROUND, and where one would outrun the foreground limit, make the command itself loop until its own terminal condition rather than backgrounding it. If you did background something, collect it in the SAME turn. And if your dispatcher named a scratch directory, write anything you would not want to lose into your own file there AS YOU GO — a turn that ends early takes with it everything you had neither written down nor said.
72
+ If the worker names its scratch directory: Write your OWN file there — never edit or delete a file another agent wrote. That file is Frizz coordination state, not a project deliverable or source edit, so it stays allowed under limits such as "write only <path>" or "do not modify the repo"; never classify it as unauthorized or roll it back. Every other file is governed by your delegated authority; its location alone neither permits nor forbids editing. Do not edit other \`.frizz/\` state unless your prompt asks.
73
+ Upward channel: \`SendMessage({to: "main", summary: "<5-10 words>", message: "…"})\` reaches your dispatcher while you work (load it first with ToolSearch \`select:SendMessage\` if it is deferred). Use it only when the dispatcher acting before you finish would change the outcome — a blocker, a milestone another task needs, instructions that should change — not for progress updates.
74
+ Do the work yourself: do NOT dispatch sub-agents of your own unless your dispatch prompt explicitly tells you to. You are already one prong of someone else's fan-out; a slice that feels large is still yours to work through in your own turn.
75
+ If your prompt DOES ask you to dispatch a helper, the rule above still binds — a helper cannot wake you either: a dispatcher that stops is never resumed to read the reply. So collect it before your turn ends, or do not dispatch it. Never hand-roll a wait loop over a helper's transcript or \`.output\` path: that path is a SYMLINK (\`stat\` without \`-L\` reports the link's own ~150-byte size and frozen mtime) and the \`"type":"result"\` record is not reliably written, so a helper working hard reads as tiny, stale and dead, and you will discard live work and redo it. Judge a helper only by what it actually returns to you, and give it a \`description\` naming its narrower slice.`;
76
+
77
+ /** @param {unknown} obj @returns {never} */
78
+ function emit(obj) {
79
+ process.stdout.write(JSON.stringify(obj));
80
+ process.exit(0);
81
+ }
82
+
83
+ try {
84
+ // WORKER GATE — inert outside a frizz worker session.
85
+ if (!(process.env.FRIZZ_THREAD ?? '').trim()) emit({});
86
+
87
+ const input = JSON.parse(readFileSync(0, 'utf8'));
88
+ const ti = input.tool_input ?? {};
89
+
90
+ if (ti.run_in_background !== true) {
91
+ emit({
92
+ hookSpecificOutput: {
93
+ hookEventName: 'PreToolUse',
94
+ permissionDecision: 'deny',
95
+ permissionDecisionReason:
96
+ 'frizz worker (hook-enforced): Agent sub-agents MUST be dispatched with run_in_background:true — never foreground/blocking. A foreground agent blocks the worker turn and a human interjection orphans its work. Re-send this Agent call with run_in_background:true.',
97
+ },
98
+ });
99
+ }
100
+
101
+ // Strip name/team_name (they strand nested dispatches), then append the epilogue once.
102
+ const { name: _droppedName, team_name: _droppedTeam, ...tiStripped } = ti;
103
+ // Idempotence is "this prompt ALREADY ENDS WITH the epilogue", not "this prompt mentions the
104
+ // marker anywhere". A substring test silently ate the epilogue for any prompt that merely QUOTED
105
+ // the marker — e.g. a worker asking a helper to report whether the epilogue reached it, which is
106
+ // exactly how this was caught. endsWith still catches a genuine double-fire (the only real case).
107
+ const prompt = typeof ti.prompt === 'string' ? ti.prompt : '';
108
+ const updatedInput = prompt.endsWith(EPILOGUE)
109
+ ? tiStripped
110
+ : { ...tiStripped, prompt: prompt + EPILOGUE };
111
+
112
+ emit({
113
+ hookSpecificOutput: {
114
+ hookEventName: 'PreToolUse',
115
+ permissionDecision: 'allow',
116
+ updatedInput,
117
+ },
118
+ });
119
+ } catch {
120
+ emit({}); // fail open — allow unmodified
121
+ }
@@ -0,0 +1,6 @@
1
+ export function hasEscapingBackgroundJob(raw: unknown): boolean
2
+ export function evaluateBashBackgroundHook(
3
+ input: unknown,
4
+ env?: Record<string, string | undefined>,
5
+ ): Record<string, unknown>
6
+ export function isDirectHookExecution(argv1: unknown, moduleUrl: string, realpath?: (path: string) => string): boolean
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // PreToolUse hook on `Bash` (frizz-worker). Claude's native `run_in_background` flag registers a
4
+ // task, output file, terminal notification, and wake. Shell job control (`cmd &`) does none of those:
5
+ // the child can survive after the Bash tool returns, but Claude and frizz have no lifecycle identity
6
+ // for it. A worker can then rest forever waiting for a notification that cannot exist.
7
+ //
8
+ // Block only an ESCAPING local background job. Self-contained shell concurrency remains valid when
9
+ // the command explicitly waits for its children or owns them with an EXIT trap before Bash returns.
10
+ //
11
+ // GATE: inert unless FRIZZ_THREAD is set (ordinary Claude sessions keep their native behavior).
12
+ // FAIL OPEN: malformed hook input allows the command rather than wedging a worker.
13
+ import { readFileSync, realpathSync } from 'node:fs';
14
+ import { basename } from 'node:path';
15
+ import { fileURLToPath, pathToFileURL } from 'node:url';
16
+
17
+ /** @param {unknown} obj @returns {never} */
18
+ function emit(obj) {
19
+ process.stdout.write(JSON.stringify(obj));
20
+ process.exit(0);
21
+ }
22
+
23
+ /**
24
+ * Blank heredoc bodies while preserving line/character positions. A script being WRITTEN may contain
25
+ * `&`; only the shell currently executing this tool call is relevant to the lifecycle escape.
26
+ * @param {string} command
27
+ */
28
+ function withoutHeredocBodies(command) {
29
+ const lines = command.split('\n');
30
+ /** @type {string[]} */
31
+ const pending = [];
32
+ let active;
33
+ return lines.map((line) => {
34
+ if (active !== undefined) {
35
+ if (line.trim() === active) {
36
+ active = pending.shift();
37
+ return line;
38
+ }
39
+ return ' '.repeat(line.length);
40
+ }
41
+
42
+ // Shell accepts quoted and bare heredoc delimiters. Multiple heredocs on one command line are
43
+ // consumed in declaration order.
44
+ for (const match of line.matchAll(/<<-?\s*(?:'([^']+)'|"([^"]+)"|([A-Za-z_][A-Za-z0-9_-]*))/g)) {
45
+ const delimiter = match[1] ?? match[2] ?? match[3];
46
+ if (delimiter) pending.push(delimiter);
47
+ }
48
+ active = pending.shift();
49
+ return line;
50
+ }).join('\n');
51
+ }
52
+
53
+ /**
54
+ * Replace quoted regions with spaces. Operators sent inside `ssh host '…'` are remote, and quoted
55
+ * prose such as `printf '&'` is not local shell job control. Backticks are also synchronous command
56
+ * substitutions from the outer shell's perspective.
57
+ * @param {string} command
58
+ */
59
+ function withoutQuotedRegions(command) {
60
+ let quote = '';
61
+ let escaped = false;
62
+ let out = '';
63
+ for (let i = 0; i < command.length; i++) {
64
+ const c = command[i];
65
+ if (escaped) {
66
+ out += quote ? ' ' : c;
67
+ escaped = false;
68
+ continue;
69
+ }
70
+ if (c === '\\' && quote !== "'") {
71
+ out += quote ? ' ' : c;
72
+ escaped = true;
73
+ continue;
74
+ }
75
+ if (quote) {
76
+ // Double quotes may contain a complete command substitution with its OWN quote grammar:
77
+ // `"$(python -c 'print(f"{x&255}")')"`. Treating the f-string's double quote as the end of the
78
+ // outer region exposed arithmetic `&` as fake job control in a real corpus call. The outer shell
79
+ // waits for command substitution, so blank the balanced substitution as part of the quote.
80
+ if (quote === '"' && c === '$' && command[i + 1] === '(') {
81
+ out += ' ';
82
+ i += 2;
83
+ let depth = 1;
84
+ let innerQuote = '';
85
+ let innerEscaped = false;
86
+ for (; i < command.length; i++) {
87
+ const inner = command[i];
88
+ out += inner === '\n' ? '\n' : ' ';
89
+ if (innerEscaped) {
90
+ innerEscaped = false;
91
+ continue;
92
+ }
93
+ if (inner === '\\' && innerQuote !== "'") {
94
+ innerEscaped = true;
95
+ continue;
96
+ }
97
+ if (innerQuote) {
98
+ if (inner === innerQuote) innerQuote = '';
99
+ continue;
100
+ }
101
+ if (inner === "'" || inner === '"' || inner === '`') {
102
+ innerQuote = inner;
103
+ continue;
104
+ }
105
+ if (inner === '(') depth++;
106
+ else if (inner === ')' && --depth === 0) break;
107
+ }
108
+ continue;
109
+ }
110
+ if (c === quote) quote = '';
111
+ out += c === '\n' ? '\n' : ' ';
112
+ continue;
113
+ }
114
+ if (c === "'" || c === '"' || c === '`') {
115
+ quote = c;
116
+ out += ' ';
117
+ continue;
118
+ }
119
+ out += c;
120
+ }
121
+ return out;
122
+ }
123
+
124
+ // A LOCAL shell wrapper runs its script in THIS machine's process tree, so `bash -c 'job &'` escapes
125
+ // exactly as a bare `job &` does — the wrapper forks the job and exits. Blanking quoted regions is
126
+ // what exempts `ssh host '… &'` (that job is remote and the ssh client still waits), but it hid the
127
+ // local wrapper along with it, leaving a one-token bypass of the whole guard. So scan the sanitized
128
+ // text for a wrapper, then re-read its script from the ORIGINAL command and recurse.
129
+ //
130
+ // Only in COMMAND POSITION. A wrapper passed as an ARGUMENT belongs to whatever program precedes it,
131
+ // and that program decides where the script runs: `docker run img bash -c …` and
132
+ // `limactl shell vm bash -lc …` both appear in the real corpus, and neither backgrounds anything
133
+ // local. That is the same call the `ssh` exemption already makes. It costs the genuinely-local
134
+ // `xargs sh -c 'job &'`, which the corpus never shows, and under-blocking is the safer miss here.
135
+ const LOCAL_SHELL_SCRIPT = /(?:^|[\n;|&({])\s*(?:\/\S*\/)?(?:bash|sh|zsh|dash|ksh)(?:\s+-[A-Za-z]+)*\s+-[A-Za-z]*c(?=\s)/g;
136
+
137
+ /**
138
+ * Read the quoted argument that begins at `from` in the original command, undoing only the escaping
139
+ * the surrounding quote itself performs.
140
+ * @param {string} raw @param {number} from
141
+ */
142
+ function quotedArgumentAt(raw, from) {
143
+ let i = from;
144
+ while (raw[i] === ' ' || raw[i] === '\t') i++;
145
+ const quote = raw[i];
146
+ if (quote !== "'" && quote !== '"') return '';
147
+ let out = '';
148
+ for (i++; i < raw.length; i++) {
149
+ if (raw[i] === '\\' && quote === '"' && i + 1 < raw.length) {
150
+ out += raw[++i];
151
+ continue;
152
+ }
153
+ if (raw[i] === quote) return out;
154
+ out += raw[i];
155
+ }
156
+ return '';
157
+ }
158
+
159
+ /**
160
+ * Return true when this Bash call starts a local background job and can return without joining or
161
+ * terminating it. This is deliberately a small shell-lifecycle recognizer, not a general parser.
162
+ * @param {unknown} raw
163
+ * @param {number} depth
164
+ */
165
+ export function hasEscapingBackgroundJob(raw, depth = 0) {
166
+ if (typeof raw !== 'string' || !raw.trim()) return false;
167
+ const command = withoutQuotedRegions(withoutHeredocBodies(raw));
168
+ /** @type {number[]} */
169
+ const operators = [];
170
+ for (let i = 0; i < command.length; i++) {
171
+ if (command[i] !== '&') continue;
172
+ const before = command[i - 1] ?? '';
173
+ const after = command[i + 1] ?? '';
174
+ // `&&`, redirects (`2>&1`, `&>`), and an escaped literal are not background operators.
175
+ if (before === '&' || after === '&' || before === '>' || before === '<' || after === '>' || before === '\\') continue;
176
+ operators.push(i);
177
+ }
178
+ if (operators.length > 0) {
179
+ // A lifecycle action AFTER the last launch makes the command self-contained. `kill` alone does
180
+ // not: the signal is asynchronous, so the shell still needs a wait before returning. An EXIT trap
181
+ // owns cleanup at the shell boundary.
182
+ const tail = command.slice(operators[operators.length - 1] + 1);
183
+ if (!(/\bwait\b/.test(tail) || /\btrap\b[^\n;]*\b(?:EXIT|0)\b/.test(tail))) return true;
184
+ }
185
+
186
+ // Both sanitizers substitute character-for-character, so a match position in the sanitized text
187
+ // still indexes the original. A length mismatch would mean that invariant broke: fail open rather
188
+ // than slice the wrong bytes. The depth cap bounds `sh -c 'sh -c …'` nesting.
189
+ if (depth >= 3 || command.length !== raw.length) return false;
190
+ // `matchAll` iterates a CLONE, so the module-level regex keeps no cursor for a nested call to reset
191
+ // out from under this loop — `exec` on the shared object spins forever on `bash -c "sh -c '…'"`.
192
+ for (const match of command.matchAll(LOCAL_SHELL_SCRIPT)) {
193
+ const script = quotedArgumentAt(raw, match.index + match[0].length);
194
+ if (hasEscapingBackgroundJob(script, depth + 1)) return true;
195
+ }
196
+ return false;
197
+ }
198
+
199
+ export function evaluateBashBackgroundHook(input, env = process.env) {
200
+ if (!String(env.FRIZZ_THREAD ?? '').trim()) return {};
201
+ const command = input && typeof input === 'object'
202
+ ? String(input.tool_input?.command ?? '')
203
+ : '';
204
+ if (!hasEscapingBackgroundJob(command)) return {};
205
+ const codex = typeof input?.model === 'string';
206
+ return {
207
+ hookSpecificOutput: {
208
+ hookEventName: 'PreToolUse',
209
+ permissionDecision: 'deny',
210
+ permissionDecisionReason:
211
+ codex
212
+ ? 'Frizz blocked an untracked shell background job (`&`). Shell job control can return without a Codex lifecycle handle, so Frizz cannot report completion or wake this agent. For work that must continue while you do something else, remove `&` and use the managed unified exec pattern: start `tools.exec_command(...)`, call `yield_control()`, then await and fully drain that same process. A returned `session_id` alone is only foreground continuation. For bounded parallel work inside one shell call, finish with `wait` (after `kill`, if used) or own cleanup with an EXIT trap.'
213
+ : 'Frizz blocked an untracked shell background job (`&`). Shell job control can return from Bash without a Claude task ID, so Frizz cannot report completion or wake this agent. For a long-running local command, remove `&` and call Bash with `run_in_background:true`. For bounded parallel work inside one Bash call, finish with `wait` (after `kill`, if used) or own cleanup with an EXIT trap.',
214
+ },
215
+ };
216
+ }
217
+
218
+ // Compared through `realpath` on BOTH sides. Node realpaths the main entry, so `import.meta.url` is
219
+ // the long, resolved spelling, while `argv[1]` is whatever CLAUDE_PLUGIN_ROOT spelled — an 8.3 short
220
+ // name (`C:\Users\RUNNER~1\…`, common when a profile or %LOCALAPPDATA% is one), a junction, or a
221
+ // symlink. A plain URL comparison then fails and the hook emits NOTHING, which Claude reads as
222
+ // "allowed": the guard silently switched off (Windows audit 2026-09-11, finding 15). The cheap URL
223
+ // equality is kept as the first test; realpath is the tie-breaker, and a path that cannot be
224
+ // realpath'd is not this file.
225
+ export function isDirectHookExecution(argv1, moduleUrl, realpath = realpathSync) {
226
+ if (typeof argv1 !== 'string' || basename(argv1) !== 'bash-background.mjs') return false;
227
+ if (pathToFileURL(argv1).href === moduleUrl) return true;
228
+ try {
229
+ return realpath(argv1) === realpath(fileURLToPath(moduleUrl));
230
+ } catch {
231
+ return false;
232
+ }
233
+ }
234
+
235
+ // The server imports `hasEscapingBackgroundJob` and its production build bundles this module into
236
+ // `src/index.js`. esbuild rewrites `import.meta.url` to that bundle URL, so URL equality alone would
237
+ // mistake the whole server for this executable and block startup reading hook JSON from stdin.
238
+ if (isDirectHookExecution(process.argv[1], import.meta.url)) {
239
+ try {
240
+ const env = process.argv.includes('--frizz-thread')
241
+ ? { ...process.env, FRIZZ_THREAD: process.env.FRIZZ_THREAD || 'codex-worker' }
242
+ : process.env;
243
+ emit(evaluateBashBackgroundHook(JSON.parse(readFileSync(0, 'utf8')), env));
244
+ } catch {
245
+ emit({});
246
+ }
247
+ }
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // PreToolUse hook on AskUserQuestion (frizz-worker). A frizz worker runs under a dashboard, not a
4
+ // live chat: an interactive question prompt would hang the session invisibly (nobody is at the
5
+ // keyboard to click it). Deny with a redirect to the async pattern: register the question with
6
+ // `mcp__frizz__ask` (a row; the free-form ```question fence was retired 2026-09-11), keep working, and
7
+ // rest normally; the answer arrives as its own wake.
8
+ // GATE: inert unless FRIZZ_THREAD is set. FAIL OPEN on parse errors.
9
+ //
10
+ // SECOND GATE: also inert when FRIZZ_NATIVE_ASK=1. The premise above — "nobody is at the keyboard" —
11
+ // is only true where the question has nowhere to go. On the Claude session-broker path frizz now
12
+ // intercepts the call at canUseTool and renders it as a real question card on the dashboard, which the
13
+ // operator answers and which returns the chosen labels to the tool. The broker bridge sets that var
14
+ // only when an InteractionStore is actually wired to render and resolve the card, so a denial here
15
+ // would block a question the operator CAN see. Any other path leaves the var unset, so it keeps the
16
+ // deny plus the `mcp__frizz__ask` redirect.
17
+ import { readFileSync } from 'node:fs';
18
+
19
+ const slug = process.env.FRIZZ_THREAD;
20
+ if (!slug) process.exit(0);
21
+ if (process.env.FRIZZ_NATIVE_ASK === '1') process.exit(0);
22
+
23
+ try {
24
+ JSON.parse(readFileSync(0, 'utf8'));
25
+ } catch {
26
+ process.exit(0); // fail open — a broken hook must never halt work
27
+ }
28
+
29
+ process.stdout.write(
30
+ JSON.stringify({
31
+ hookSpecificOutput: {
32
+ hookEventName: 'PreToolUse',
33
+ permissionDecision: 'deny',
34
+ permissionDecisionReason:
35
+ 'Interactive prompts freeze headless workers (no one is at the keyboard to answer). Register the question with `mcp__frizz__ask` instead — several in one call, each self-contained (context + the specific question + options with a one-line trade-off each, the recommended one first), written actor-explicit — never "I" or "you", which flip meaning when the human clicks an option; the frizz Queue renders each as a card. Do NOT write it as a ```question fence: that fence is retired, and a question in a fence body is plain prose nobody can answer. Registering does not end your turn — keep doing what does not depend on the answer, then rest normally: an open registered question IS the handback (no done/awaiting fence beside it, and do NOT invoke this tool again). The answer arrives as your next user message.',
36
+ },
37
+ }),
38
+ );
39
+ process.exit(0);
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // PermissionRequest hook on ExitPlanMode (frizz) — a frizz worker runs under a dashboard, not a
4
+ // live chat, so the plan-APPROVAL prompt (shown when the model calls ExitPlanMode to present a plan
5
+ // and ask to proceed) would hang the session invisibly. Deny it and redirect the worker to encode
6
+ // its plan/ask in the thread file instead.
7
+ //
8
+ // WHY PermissionRequest, not PreToolUse: ExitPlanMode is a PERMISSION surface, not a plain tool —
9
+ // it is denied via PermissionRequest (AskUserQuestion, a real tool, is denied via PreToolUse in the
10
+ // sibling deny-ask.mjs). CAVEAT: PermissionRequest hooks do NOT fire under `claude -p` (headless
11
+ // print mode); a frizz worker runs as an INTERACTIVE `claude` session inside its session-broker
12
+ // daemon, where they DO fire. So this protects the real worker path; the `-p` smoke test cannot
13
+ // exercise it (documented).
14
+ //
15
+ // PLAN-MODE SOFTLOCK — why this denies UNCONDITIONALLY when gated: a session genuinely IN plan mode
16
+ // is read-only until ExitPlanMode is approved, so denying ExitPlanMode there would SOFTLOCK it
17
+ // (can't exit → can't edit → can't even follow the "write to the thread" redirect). This hook does not
18
+ // branch on the session's mode. (An earlier note here claimed the PermissionRequest input carries no
19
+ // permission-mode signal — that is FALSE and was corrected 2026-07-25: the payload carries
20
+ // `permission_mode`, `tool_name`, and `tool_input`, which is exactly what the sibling perm-policy.mjs
21
+ // keys its rules on. The unconditional deny is kept because the SOURCE fix below makes plan mode
22
+ // unreachable for a frizz worker anyway, not because the signal is unavailable.) The fix is at the SOURCE
23
+ // instead — frizz NEVER spawns a worker in plan mode (dispatch.ts coerces `--permission-mode plan`
24
+ // → `auto` in both command builders), so a real frizz worker is never in plan mode and this deny
25
+ // only ever meets a SPURIOUS ExitPlanMode call (nothing to exit → deny + redirect is correct). A
26
+ // worker "plans" by writing a durable plan file and asking via `mcp__frizz__ask`,
27
+ // never via interactive plan mode.
28
+ // RESIDUAL GAP (accepted, documented): the deny could softlock only a FOREIGN session that is
29
+ // simultaneously in plan mode AND running with FRIZZ_THREAD set AND this plugin loaded — a combo
30
+ // frizz never produces (FRIZZ_THREAD is set only by frizz dispatch, which coerces plan away).
31
+ // A normal plan-mode session outside frizz is untouched (this hook is inert without FRIZZ_THREAD).
32
+ //
33
+ // GATE: inert unless FRIZZ_THREAD is set. FAIL OPEN on any parse error — a broken hook must never
34
+ // halt work.
35
+ import { readFileSync } from 'node:fs';
36
+
37
+ const slug = process.env.FRIZZ_THREAD;
38
+ if (!slug) process.exit(0);
39
+
40
+ try {
41
+ JSON.parse(readFileSync(0, 'utf8'));
42
+ } catch {
43
+ process.exit(0); // fail open — a broken hook must never halt work
44
+ }
45
+
46
+ // The instructive redirect rides TOP-LEVEL `additionalContext` (per the Claude Code hooks docs) —
47
+ // on a PermissionRequest DENY the `decision` object carries ONLY `{behavior:"deny"}`; the reason
48
+ // the model reads is `additionalContext`, injected as a plain-text system-reminder. Exit 0 with
49
+ // this JSON on stdout (exit 2 would make Claude Code ignore the JSON — never mix).
50
+ const reason =
51
+ 'Interactive plan-approval prompts freeze headless workers (no one is at the keyboard to approve). Do NOT present a plan for approval. Instead: if the plan is settled, just proceed with the work. If the plan is the deliverable, write it into a durable plan file — wherever the dispatch or the project\'s conventions name; your scratch directory works when nothing names one. If it needs a human call before you build, register a two-option question with `mcp__frizz__ask` stating what you need approved (never a ```question fence — that fence is retired), then come to rest — the human reviews it from the frizz queue.';
52
+
53
+ process.stdout.write(
54
+ JSON.stringify({
55
+ hookSpecificOutput: {
56
+ hookEventName: 'PermissionRequest',
57
+ decision: { behavior: 'deny' },
58
+ },
59
+ additionalContext: reason,
60
+ }),
61
+ );
62
+ process.exit(0);
@@ -0,0 +1,102 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "Bash",
6
+ "hooks": [
7
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/bash-background.mjs\"" }
8
+ ]
9
+ },
10
+ {
11
+ "matcher": "Agent",
12
+ "hooks": [
13
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/agent-dispatch.mjs\"" }
14
+ ]
15
+ },
16
+ {
17
+ "matcher": "AskUserQuestion",
18
+ "hooks": [
19
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/deny-ask.mjs\"" }
20
+ ]
21
+ }
22
+ ],
23
+ "PostToolUse": [
24
+ {
25
+ "matcher": "Agent",
26
+ "hooks": [
27
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/agent-bind.mjs\"" }
28
+ ]
29
+ },
30
+ {
31
+ "hooks": [
32
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad.mjs\" --mode=nudge --event=PostToolUse" }
33
+ ]
34
+ }
35
+ ],
36
+ "UserPromptSubmit": [
37
+ {
38
+ "hooks": [
39
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad.mjs\" --mode=nudge" }
40
+ ]
41
+ }
42
+ ],
43
+ "PermissionRequest": [
44
+ {
45
+ "matcher": "ExitPlanMode",
46
+ "hooks": [
47
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/deny-plan.mjs\"" }
48
+ ]
49
+ },
50
+ {
51
+ "matcher": "*",
52
+ "hooks": [
53
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/perm-policy.mjs\"" }
54
+ ]
55
+ }
56
+ ],
57
+ "PreCompact": [
58
+ {
59
+ "matcher": "auto",
60
+ "hooks": [
61
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad.mjs\" --mode=precompact" }
62
+ ]
63
+ },
64
+ {
65
+ "matcher": "manual",
66
+ "hooks": [
67
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad.mjs\" --mode=precompact" }
68
+ ]
69
+ }
70
+ ],
71
+ "SessionStart": [
72
+ {
73
+ "matcher": "startup",
74
+ "hooks": [
75
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-seed.mjs\"" },
76
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad.mjs\" --mode=session-start" }
77
+ ]
78
+ },
79
+ {
80
+ "matcher": "resume",
81
+ "hooks": [
82
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-seed.mjs\"" },
83
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad.mjs\" --mode=session-start" }
84
+ ]
85
+ },
86
+ {
87
+ "matcher": "clear",
88
+ "hooks": [
89
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-seed.mjs\"" },
90
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad.mjs\" --mode=session-start" }
91
+ ]
92
+ },
93
+ {
94
+ "matcher": "compact",
95
+ "hooks": [
96
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-seed.mjs\"" },
97
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad.mjs\" --mode=session-start" }
98
+ ]
99
+ }
100
+ ]
101
+ }
102
+ }