frizz 0.0.1 → 0.2.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.
- package/LICENSE +21 -0
- package/README.md +276 -0
- package/dist/claude-agent-broker.js +21714 -0
- package/dist/codex-app-server-daemon.js +354 -0
- package/dist/dev-child.js +39342 -0
- package/dist/frizz.js +10517 -0
- package/package.json +54 -20
- package/runtime/board/agent-bindings.mjs +287 -0
- package/runtime/board/agent-liveness.mjs +367 -0
- package/runtime/board/agent-status.mjs +178 -0
- package/runtime/board/config.mjs +982 -0
- package/runtime/board/decisions.mjs +97 -0
- package/runtime/board/index.mjs +699 -0
- package/runtime/board/notify-shared.mjs +90 -0
- package/runtime/board/notify.mjs +81 -0
- package/runtime/board/ownership.mjs +120 -0
- package/runtime/board/rest-detect.mjs +213 -0
- package/runtime/board/thread-excerpt.mjs +162 -0
- package/runtime/board/thread-update.mjs +285 -0
- package/runtime/cc-worker/.claude-plugin/plugin.json +10 -0
- package/runtime/cc-worker/DECISIONS.md +1025 -0
- package/runtime/cc-worker/LICENSE +21 -0
- package/runtime/cc-worker/agents/fable-high.md +8 -0
- package/runtime/cc-worker/agents/fable-low.md +8 -0
- package/runtime/cc-worker/agents/fable-max.md +8 -0
- package/runtime/cc-worker/agents/fable-medium.md +8 -0
- package/runtime/cc-worker/agents/fable-xhigh.md +8 -0
- package/runtime/cc-worker/agents/haiku.md +7 -0
- package/runtime/cc-worker/agents/opus-high.md +8 -0
- package/runtime/cc-worker/agents/opus-low.md +8 -0
- package/runtime/cc-worker/agents/opus-max.md +8 -0
- package/runtime/cc-worker/agents/opus-medium.md +8 -0
- package/runtime/cc-worker/agents/opus-xhigh.md +8 -0
- package/runtime/cc-worker/agents/sonnet-high.md +8 -0
- package/runtime/cc-worker/agents/sonnet-low.md +8 -0
- package/runtime/cc-worker/agents/sonnet-max.md +8 -0
- package/runtime/cc-worker/agents/sonnet-medium.md +8 -0
- package/runtime/cc-worker/agents/sonnet-xhigh.md +8 -0
- package/runtime/cc-worker/bin/frizz +17 -0
- package/runtime/cc-worker/bin/frizz-mcp.mjs +564 -0
- package/runtime/cc-worker/bin/frizz-update +18 -0
- package/runtime/cc-worker/hooks/agent-bind.mjs +40 -0
- package/runtime/cc-worker/hooks/agent-dispatch.mjs +98 -0
- package/runtime/cc-worker/hooks/bash-background.d.mts +6 -0
- package/runtime/cc-worker/hooks/bash-background.mjs +236 -0
- package/runtime/cc-worker/hooks/deny-ask.mjs +38 -0
- package/runtime/cc-worker/hooks/deny-plan.mjs +61 -0
- package/runtime/cc-worker/hooks/hooks.json +111 -0
- package/runtime/cc-worker/hooks/perm-policy.mjs +211 -0
- package/runtime/cc-worker/hooks/precompact-instructions.mjs +122 -0
- package/runtime/cc-worker/hooks/scratchpad-stop.mjs +125 -0
- package/runtime/cc-worker/hooks/scratchpad.mjs +446 -0
- package/runtime/cc-worker/hooks/session-seed.mjs +106 -0
- package/runtime/cc-worker/scripts/frizz/agent-bindings.mjs +9 -0
- package/runtime/cc-worker/scripts/frizz/config.mjs +12 -0
- package/runtime/cc-worker/skills/gh/SKILL.md +154 -0
- package/runtime/cc-worker/skills/gh/scripts/ci-watch.mjs +60 -0
- package/runtime/cc-worker/skills/gh/scripts/github-watch.mjs +130 -0
- package/runtime/cc-worker/skills/gh/scripts/review-watch.mjs +54 -0
- package/runtime/cc-worker/skills/handoff/SKILL.md +209 -0
- package/runtime/cc-worker/skills/waits/SKILL.md +83 -0
- package/web-dist/apple-touch-icon.png +0 -0
- package/web-dist/assets/TerminalPane-ROKHp1ib.js +7 -0
- package/web-dist/assets/abnfDiagram-VRR7QNED-DcpdhBs3.js +1 -0
- package/web-dist/assets/arc-BSyeo0Gb.js +1 -0
- package/web-dist/assets/architecture-TIHT7OUA-CAviNivx.js +1 -0
- package/web-dist/assets/architectureDiagram-ZJ3FMSHR-CUAKf0mn.js +36 -0
- package/web-dist/assets/array-BifhSqXX.js +1 -0
- package/web-dist/assets/blockDiagram-677ZJIJ3-BPwpJIzx.js +132 -0
- package/web-dist/assets/c4Diagram-LMCZKHZV-1lptuHzZ.js +10 -0
- package/web-dist/assets/channel-CqKDIFQF.js +1 -0
- package/web-dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/web-dist/assets/chunk-32BRIVSS-CFR9AKjY.js +1 -0
- package/web-dist/assets/chunk-52WLFC77-CM9uct7m.js +10 -0
- package/web-dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/web-dist/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
- package/web-dist/assets/chunk-C7G6YPKG-DiveJARw.js +1 -0
- package/web-dist/assets/chunk-EX3LRPZG-BE1CBw8F.js +231 -0
- package/web-dist/assets/chunk-FWX5IMBZ-DL42uXiO.js +2 -0
- package/web-dist/assets/chunk-HOUHSVGY-DPhJWgDw.js +1 -0
- package/web-dist/assets/chunk-ICXQ74PX-CwYy-6AP.js +2 -0
- package/web-dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/web-dist/assets/chunk-KEIR6QF5-BfrZ3jm6.js +161 -0
- package/web-dist/assets/chunk-MOJQB5TN-Ds5I9wxq.js +88 -0
- package/web-dist/assets/chunk-OGEWGWER-DHiZJwQD.js +1 -0
- package/web-dist/assets/chunk-PUDLZKDR-B-eyQTsF.js +156 -0
- package/web-dist/assets/chunk-Q4XR5HBZ-DK7dB3Ti.js +70 -0
- package/web-dist/assets/chunk-RYQCIY6F-Cu_KplZW.js +1 -0
- package/web-dist/assets/chunk-V7JOEXUC-Dn59m74L.js +206 -0
- package/web-dist/assets/chunk-VAUOI2AC-DK7x36hd.js +1 -0
- package/web-dist/assets/chunk-VR4S4FIN-D7-CI3Yl.js +1 -0
- package/web-dist/assets/chunk-WYO6CB5R-B3l-mLCs.js +127 -0
- package/web-dist/assets/chunk-XXDRQBXY-DYlTP5J-.js +1 -0
- package/web-dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/web-dist/assets/chunk-ZGVPDNZ5-Dobxlxie.js +62 -0
- package/web-dist/assets/chunk-ZIRB5QZD-C6fEPe3t.js +32 -0
- package/web-dist/assets/classDiagram-OUVF2IWQ-B_-6iXYY.js +1 -0
- package/web-dist/assets/classDiagram-v2-EOCWNBFH-B_-6iXYY.js +1 -0
- package/web-dist/assets/cose-bilkent-JH36ORCC-BUIsLrGc.js +1 -0
- package/web-dist/assets/cynefin-VYW2F7L2-Dh7RuEUJ.js +1 -0
- package/web-dist/assets/cynefinDiagram-TSTJHNR4-ClPi2mZZ.js +62 -0
- package/web-dist/assets/cytoscape.esm-B3I8pqwA.js +321 -0
- package/web-dist/assets/dagre-CXRCoUWR.js +1 -0
- package/web-dist/assets/dagre-VKFMJZFB-52_WP1QV.js +4 -0
- package/web-dist/assets/defaultLocale-C8Fc0cco.js +1 -0
- package/web-dist/assets/diagram-FQU43EPY-D_1zVsTL.js +3 -0
- package/web-dist/assets/diagram-G47NLZAW-CdZxuGUy.js +24 -0
- package/web-dist/assets/diagram-NH7WQ7WH-C8pSFu0P.js +24 -0
- package/web-dist/assets/diagram-OA4YK3LP-C5bjZLre.js +30 -0
- package/web-dist/assets/diagram-WEI45ONY-Bxzhiuzn.js +41 -0
- package/web-dist/assets/dist-DoH_9pyS.js +1 -0
- package/web-dist/assets/ebnfDiagram-CCIWWBDH-g-Z0J2wP.js +1 -0
- package/web-dist/assets/erDiagram-Q63AITRT-DVNkgIHp.js +85 -0
- package/web-dist/assets/eventmodeling-45OFAUF4-MpmeH5YZ.js +1 -0
- package/web-dist/assets/flowDiagram-23GEKE2U-D-QgjjhF.js +1 -0
- package/web-dist/assets/ganttDiagram-NO4QXBWP-_71pQYEK.js +292 -0
- package/web-dist/assets/gitGraph-TEB2WS4Q-ChIZiGZS.js +1 -0
- package/web-dist/assets/gitGraphDiagram-IHSO6WYX-DCHAFI0l.js +106 -0
- package/web-dist/assets/graphlib-B8gBHxth.js +1 -0
- package/web-dist/assets/index-w4v-GZEc.js +358 -0
- package/web-dist/assets/index-zyi22LPz.css +1 -0
- package/web-dist/assets/info-DKCQHKI2-BW-n_T1j.js +1 -0
- package/web-dist/assets/infoDiagram-FWYZ7A6U-CgDYsKi9.js +2 -0
- package/web-dist/assets/init-D6jRqBbL.js +1 -0
- package/web-dist/assets/ishikawaDiagram-FXEZZL3T-ClzGNt9N.js +70 -0
- package/web-dist/assets/journeyDiagram-5HDEW3XC-DSCQxkHC.js +139 -0
- package/web-dist/assets/kanban-definition-HUTT4EX6-CdrdX9N8.js +89 -0
- package/web-dist/assets/katex-CddkPoXu.js +257 -0
- package/web-dist/assets/line-ha38Dc-1.js +1 -0
- package/web-dist/assets/linear-z2V0wJk9.js +1 -0
- package/web-dist/assets/map-DsCK-0Cs.js +1 -0
- package/web-dist/assets/mermaid-parser.core-Z4uMcpip.js +7 -0
- package/web-dist/assets/mermaid.core-iZRq3hbu.js +11 -0
- package/web-dist/assets/mindmap-definition-LN4V7U3C-DmhInJO_.js +96 -0
- package/web-dist/assets/ordinal-hYBb2elL.js +1 -0
- package/web-dist/assets/packet-7NZHBO7P-DBPB36Kl.js +1 -0
- package/web-dist/assets/path-BWPyau1x.js +1 -0
- package/web-dist/assets/pegDiagram-2B236MQR-CAH3ljfj.js +1 -0
- package/web-dist/assets/pie-RZYD4A2V-_h_eX4Ca.js +1 -0
- package/web-dist/assets/pieDiagram-ENE6RG2P-DFBPus8j.js +39 -0
- package/web-dist/assets/quadrantDiagram-ABIIQ3AL-DMvOCjt8.js +7 -0
- package/web-dist/assets/radar-I7S5WNFK-2EzoPHEZ.js +1 -0
- package/web-dist/assets/railroad-3IZDKUUU-BPJnn-hm.js +1 -0
- package/web-dist/assets/railroad-abnf-AHOZXSZD-YeUoiySk.js +1 -0
- package/web-dist/assets/railroad-ebnf-EBAXGLYW-Ddw1SuGG.js +1 -0
- package/web-dist/assets/railroad-peg-LSFZ7HO6-Dd8BOGeW.js +1 -0
- package/web-dist/assets/railroadDiagram-RFXS5EU6-DKq5FagA.js +1 -0
- package/web-dist/assets/requirementDiagram-TGXJPOKE-BJ5tGazp.js +84 -0
- package/web-dist/assets/rolldown-runtime-Bh1tDfsg.js +1 -0
- package/web-dist/assets/rough.esm-CSKSodPl.js +1 -0
- package/web-dist/assets/sankeyDiagram-HTMAVEWB-XSJjcBhX.js +40 -0
- package/web-dist/assets/sequenceDiagram-DBY2YBRQ-CBb8emSe.js +162 -0
- package/web-dist/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
- package/web-dist/assets/src-C4XfhTaE.js +1 -0
- package/web-dist/assets/stateDiagram-2N3HPSRC-DDfRW94V.js +1 -0
- package/web-dist/assets/stateDiagram-v2-6OUMAXLB-hc41W5Lx.js +1 -0
- package/web-dist/assets/swimlanes-5IMT3BWC-DvRYbkZi.js +2 -0
- package/web-dist/assets/swimlanesDiagram-G3AALYLV-BZyGdgSG.js +8 -0
- package/web-dist/assets/timeline-definition-FHXFAJF6-BNUa_DwI.js +120 -0
- package/web-dist/assets/treeView-QDETBFTQ-I6-IW6nJ.js +1 -0
- package/web-dist/assets/treemap-6X3UGDF4-CWWmEUYJ.js +1 -0
- package/web-dist/assets/vennDiagram-L72KCM5P-DTDrPGLk.js +34 -0
- package/web-dist/assets/wardley-OPB4EBWU-CNsdgXXA.js +1 -0
- package/web-dist/assets/wardleyDiagram-EHGQE667-YE0tq3Kh.js +78 -0
- package/web-dist/assets/xychartDiagram-FW5EYKEG-D0ofMX8C.js +7 -0
- package/web-dist/favicon-16.png +0 -0
- package/web-dist/favicon-32.png +0 -0
- package/web-dist/favicon.svg +78 -0
- package/web-dist/icon-192.png +0 -0
- package/web-dist/icon-512.png +0 -0
- package/web-dist/icon-maskable-512.png +0 -0
- package/web-dist/index.html +33 -0
- package/web-dist/manifest.webmanifest +16 -0
- package/index.d.ts +0 -1
- package/index.js +0 -2
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
// PermissionRequest hook (frizz worker), matcher "*" — the worker's permission POLICY, and the durable
|
|
4
|
+
// structured signal the tailer reads instead of scraping the tmux pane.
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS DECIDES (it used to only observe): a frizz worker runs under a dashboard with nobody at the
|
|
7
|
+
// keyboard, so a tool-approval prompt parks the thread invisibly until a human happens to look. frizz
|
|
8
|
+
// dispatches Claude workers at `--permission-mode auto` (dispatch.ts WORKER_DISPATCH_PERMISSION), and
|
|
9
|
+
// `auto` is NOT non-interactive — its classifier still raises a prompt for anything it deems risky
|
|
10
|
+
// (a `git push`, a publish), which is exactly how a worker silently wedges for hours.
|
|
11
|
+
//
|
|
12
|
+
// The blunt fix would be to dispatch at `bypassPermissions`. This is deliberately NOT that: bypass
|
|
13
|
+
// removes the decision POINT, so nothing can ever inspect a request again. Keeping `auto` + deciding
|
|
14
|
+
// here preserves the seam — the same request that is auto-approved today can be routed to a policy,
|
|
15
|
+
// or to a human, without changing how workers launch. Claude Code labels the outcome in the
|
|
16
|
+
// transcript ("Allowed by PermissionRequest hook"), so an auto-approval stays visible rather than
|
|
17
|
+
// being indistinguishable from bypass.
|
|
18
|
+
//
|
|
19
|
+
// THREE OUTCOMES:
|
|
20
|
+
// allow — auto-approve; the worker proceeds with no prompt.
|
|
21
|
+
// deny — auto-refuse with a reason the model reads (rides top-level `additionalContext`).
|
|
22
|
+
// defer — emit NOTHING; the normal prompt is raised and a human answers it. This is the ONLY
|
|
23
|
+
// outcome the tailer treats as a human block (see permMarkerBlocks in tailer.ts).
|
|
24
|
+
//
|
|
25
|
+
// SCOPE: this plugin loads for EVERY project frizz drives, so the built-in table carries only
|
|
26
|
+
// UNIVERSAL rules. Nothing repo-specific belongs here — a rule that is right for one repo (e.g. "never
|
|
27
|
+
// open a PR") is wrong for the next.
|
|
28
|
+
//
|
|
29
|
+
// KNOWN LIMIT — an explicit `ask` RULE outranks this hook (verified 2026-07-25). A project or user
|
|
30
|
+
// settings entry like `"permissions": {"ask": ["Bash(git push:*)"]}` raises a prompt that an `allow`
|
|
31
|
+
// from here does NOT override: Claude Code says so on the prompt itself ("Ask rule … overrides auto
|
|
32
|
+
// mode for this command"). This was isolated against a hook that allows unconditionally — it prompted
|
|
33
|
+
// too — so it is Claude Code precedence, not a defect here, and it is arguably the right precedence
|
|
34
|
+
// (an explicit human rule should beat a blanket policy). The practical consequence: a repo whose
|
|
35
|
+
// settings carry `ask` rules can still park a worker, and the fix for that repo is to relax its own
|
|
36
|
+
// rule, not to change this file. Mode-driven asks (the `default`-mode prompt) ARE overridden.
|
|
37
|
+
//
|
|
38
|
+
// GATE: inert unless FRIZZ_THREAD is set, so a foreign/non-frizz session is never affected.
|
|
39
|
+
// FAIL-SAFE: any error at all → emit nothing → the prompt is raised and the human decides. Note this
|
|
40
|
+
// inverts the old observer's "fail open": for a hook that can APPROVE, the safe failure is to fall
|
|
41
|
+
// back to asking, never to allow.
|
|
42
|
+
import { readFileSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
|
|
43
|
+
import { join } from 'node:path';
|
|
44
|
+
|
|
45
|
+
const slug = process.env.FRIZZ_THREAD;
|
|
46
|
+
if (!slug) process.exit(0);
|
|
47
|
+
|
|
48
|
+
// Top-level targets whose recursive deletion is unrecoverable. `/tmp/x` and `./build` are NOT here —
|
|
49
|
+
// only paths that take the machine or the home directory with them.
|
|
50
|
+
const ROOTISH =
|
|
51
|
+
/^(\/|\/\*|~|~\/|~\/\*|\$\{?HOME\}?|\$\{?HOME\}?\/\*?|\/(usr|etc|bin|sbin|var|lib|opt|System|Library|Applications|Users|home|boot|dev|proc)\/?\*?)$/;
|
|
52
|
+
|
|
53
|
+
// True when `cmd` contains an `rm` that is BOTH recursive and forced AND aimed at a root-ish target.
|
|
54
|
+
// Scans each pipeline/list segment so `cd /tmp && rm -rf /` is caught as readily as a bare `rm -rf /`.
|
|
55
|
+
function isCatastrophicRm(cmd) {
|
|
56
|
+
for (const seg of cmd.split(/[|;&\n]+/)) {
|
|
57
|
+
const m = /(?:^|\s)rm(\s.*)$/.exec(seg);
|
|
58
|
+
if (!m) continue;
|
|
59
|
+
const rest = m[1];
|
|
60
|
+
const flags = (rest.match(/(?:^|\s)-[a-zA-Z]+/g) || []).join('');
|
|
61
|
+
if (!/[rR]/.test(flags) || !/f/.test(flags)) continue;
|
|
62
|
+
const targets = rest.split(/\s+/).filter((t) => t && !t.startsWith('-'));
|
|
63
|
+
if (targets.some((t) => ROOTISH.test(t.replace(/["']/g, '')))) return true;
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Writes straight to a raw device / formats a filesystem — unrecoverable, and never something a
|
|
69
|
+
// worker needs to do unattended.
|
|
70
|
+
function isDiskWrite(cmd) {
|
|
71
|
+
return /\bmkfs(\.\w+)?\b/.test(cmd) || /\bdd\b[^|;&]*\bof=\/dev\/(disk|r?disk|sd|nvme|hd)/.test(cmd);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The ordered policy table: FIRST MATCH WINS. Each rule returns a decision plus the `rule` id and
|
|
75
|
+
// `reason` that get recorded on the marker, so frizz can always say WHICH rule decided and WHY.
|
|
76
|
+
// `deny` reasons are written to be read by the MODEL (they become additionalContext).
|
|
77
|
+
const RULES = [
|
|
78
|
+
{
|
|
79
|
+
id: 'catastrophic-delete',
|
|
80
|
+
test: (i) => i.tool_name === 'Bash' && isCatastrophicRm(String(i.tool_input?.command ?? '')),
|
|
81
|
+
decision: 'deny',
|
|
82
|
+
reason:
|
|
83
|
+
'Refused: this recursively force-deletes a root-level or home directory, which is unrecoverable. If you genuinely need to remove a large tree, target an explicit project-relative path instead, and never `/`, `~`, or a top-level system directory.',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'raw-disk-write',
|
|
87
|
+
test: (i) => i.tool_name === 'Bash' && isDiskWrite(String(i.tool_input?.command ?? '')),
|
|
88
|
+
decision: 'deny',
|
|
89
|
+
reason:
|
|
90
|
+
'Refused: this formats a filesystem or writes directly to a raw block device, which destroys data irrecoverably and is never required of an unattended worker.',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
// Respect a DELIBERATELY restrictive mode. frizz dispatches workers at `auto`; a thread sitting at
|
|
94
|
+
// `default`/`plan` got there because a human moved it there (the live per-thread permission
|
|
95
|
+
// control), and auto-approving would silently overrule that intent. This is what makes a genuine
|
|
96
|
+
// lower-permission mode usable today: switch a thread to `default` and its prompts come back.
|
|
97
|
+
id: 'restrictive-mode',
|
|
98
|
+
test: (i) => typeof i.permission_mode === 'string' && i.permission_mode !== 'auto',
|
|
99
|
+
decision: 'defer',
|
|
100
|
+
reason: 'The thread is in a restrictive permission mode, so this request is left for a human to answer.',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
// Escape hatch for review-style operation without changing how workers launch.
|
|
104
|
+
id: 'review-policy',
|
|
105
|
+
test: () => (process.env.FRIZZ_PERM_POLICY ?? 'auto').toLowerCase() === 'review',
|
|
106
|
+
decision: 'defer',
|
|
107
|
+
reason: 'FRIZZ_PERM_POLICY=review — every request is left for a human to answer.',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: 'worker-autonomy',
|
|
111
|
+
test: () => true,
|
|
112
|
+
decision: 'allow',
|
|
113
|
+
reason: 'Unattended frizz worker: approved automatically because no human is watching the terminal to answer a prompt.',
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
function evaluate(input) {
|
|
118
|
+
for (const rule of RULES) {
|
|
119
|
+
let hit = false;
|
|
120
|
+
try {
|
|
121
|
+
hit = rule.test(input);
|
|
122
|
+
} catch {
|
|
123
|
+
continue; // a throwing rule is skipped, never fatal
|
|
124
|
+
}
|
|
125
|
+
if (hit) return { decision: rule.decision, rule: rule.id, reason: rule.reason };
|
|
126
|
+
}
|
|
127
|
+
return { decision: 'defer', rule: 'no-rule-matched', reason: 'No policy rule matched.' };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let input;
|
|
131
|
+
try {
|
|
132
|
+
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
133
|
+
} catch {
|
|
134
|
+
process.exit(0); // unparseable payload → defer to the human
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ExitPlanMode is always auto-denied by the sibling deny-plan.mjs (a frizz worker is never in plan
|
|
138
|
+
// mode), so it never becomes a real human block — leave it entirely alone, marker included.
|
|
139
|
+
if (input.tool_name === 'ExitPlanMode') process.exit(0);
|
|
140
|
+
|
|
141
|
+
// AskUserQuestion is not an authorization request — it is the agent ASKING, and the permission
|
|
142
|
+
// decision is where the ANSWER travels. `worker-autonomy` would allow it with `updatedInput` set to
|
|
143
|
+
// the untouched tool input, i.e. the questions and NO answers, and claude's own result mapper then
|
|
144
|
+
// tells the model "The user did not answer the questions." So the auto-approval that keeps a worker
|
|
145
|
+
// moving for every other tool is, for this one, a guaranteed wasted turn.
|
|
146
|
+
//
|
|
147
|
+
// Frizz's broker intercepts this call at canUseTool and renders it as a real question card the
|
|
148
|
+
// operator answers (claude-permission-interactions.ts), so the right move here is to say NOTHING —
|
|
149
|
+
// no decision AND no marker. A `defer` verdict would write a marker the tailer reads as a human
|
|
150
|
+
// permission block, stacking a second "needs you" surface on top of the card already asking.
|
|
151
|
+
// (Verified 2026-07-27 on a promoted artifact: workers dispatch at --permission-mode auto, so
|
|
152
|
+
// `restrictive-mode` does not catch this and `worker-autonomy` did allow it. A dev-stack harness at
|
|
153
|
+
// the default permission mode deferred and looked fine, which is exactly why this needed an
|
|
154
|
+
// artifact run to find.)
|
|
155
|
+
if (input.tool_name === 'AskUserQuestion') process.exit(0);
|
|
156
|
+
|
|
157
|
+
const verdict = evaluate(input);
|
|
158
|
+
|
|
159
|
+
// Record the decision BEFORE acting on it, best-effort. The marker is frizz's only structured view of
|
|
160
|
+
// what happened here: `decision` tells the tailer whether a human is actually blocked, and
|
|
161
|
+
// rule/reason/command are what the dashboard shows the human afterwards. A failed write must not
|
|
162
|
+
// hold up the worker, so this swallows its own errors — telemetry loss, not a stall.
|
|
163
|
+
const dir = process.env.FRIZZ_PERM_DIR;
|
|
164
|
+
if (dir) {
|
|
165
|
+
try {
|
|
166
|
+
const command = input.tool_name === 'Bash' ? String(input.tool_input?.command ?? '') : '';
|
|
167
|
+
const marker = {
|
|
168
|
+
slug,
|
|
169
|
+
tool: typeof input.tool_name === 'string' ? input.tool_name : null,
|
|
170
|
+
promptId: typeof input.prompt_id === 'string' ? input.prompt_id : null,
|
|
171
|
+
permissionMode: typeof input.permission_mode === 'string' ? input.permission_mode : null,
|
|
172
|
+
at: new Date().toISOString(),
|
|
173
|
+
decision: verdict.decision,
|
|
174
|
+
rule: verdict.rule,
|
|
175
|
+
reason: verdict.reason,
|
|
176
|
+
// Truncated: this is display text for the dashboard, not a re-executable command.
|
|
177
|
+
...(command ? { command: command.length > 300 ? `${command.slice(0, 300)}…` : command } : {}),
|
|
178
|
+
};
|
|
179
|
+
mkdirSync(dir, { recursive: true });
|
|
180
|
+
// Write to a temp sibling then rename, so the tailer never reads a half-written marker.
|
|
181
|
+
const dest = join(dir, `${slug}.json`);
|
|
182
|
+
const tmp = `${dest}.${process.pid}.tmp`;
|
|
183
|
+
writeFileSync(tmp, JSON.stringify(marker));
|
|
184
|
+
renameSync(tmp, dest);
|
|
185
|
+
} catch {
|
|
186
|
+
// telemetry only — never block the worker on a marker write
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (verdict.decision === 'allow') {
|
|
191
|
+
process.stdout.write(
|
|
192
|
+
JSON.stringify({
|
|
193
|
+
hookSpecificOutput: {
|
|
194
|
+
hookEventName: 'PermissionRequest',
|
|
195
|
+
decision: { behavior: 'allow', updatedInput: input.tool_input ?? {} },
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
} else if (verdict.decision === 'deny') {
|
|
200
|
+
// On a PermissionRequest DENY the `decision` object carries ONLY `{behavior:"deny"}`; the reason the
|
|
201
|
+
// model reads rides top-level `additionalContext` (same contract as deny-plan.mjs). Exit 0 with this
|
|
202
|
+
// JSON on stdout — exit 2 would make Claude Code ignore the JSON, so never mix the two.
|
|
203
|
+
process.stdout.write(
|
|
204
|
+
JSON.stringify({
|
|
205
|
+
hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior: 'deny' } },
|
|
206
|
+
additionalContext: verdict.reason,
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
// defer → emit nothing: the normal prompt is raised and the human answers it.
|
|
211
|
+
process.exit(0);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
// PreCompact hook (frizz-worker) — steers WHAT SURVIVES a compaction. Run directly with node (zero
|
|
4
|
+
// deps, max Node compat), mirroring cc's hook idiom.
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS EXISTS: compaction is the single largest source of context loss in a long worker session.
|
|
7
|
+
// A ~967k-token session collapses to a ~10-15k summary, and what gets dropped first is the HIGH-LEVEL
|
|
8
|
+
// approach — the plan, the alternatives rejected, the reasoning — leaving a worker that remembers
|
|
9
|
+
// mechanics but has lost the thread. `session-seed.mjs` already re-grounds AFTER the fact on
|
|
10
|
+
// SessionStart(compact); this hook is the other half, steering the summary itself BEFORE it is written.
|
|
11
|
+
//
|
|
12
|
+
// THE CHANNEL — and it is unusual, so do not "fix" it to JSON: unlike every other hook in this
|
|
13
|
+
// plugin, a PreCompact hook's PLAIN STDOUT becomes the compaction instructions. Claude Code collects
|
|
14
|
+
// the trimmed stdout of every succeeding, non-blocking PreCompact hook, joins them with a blank line,
|
|
15
|
+
// and splices the result into the summarization prompt as a literal `Additional Instructions:`
|
|
16
|
+
// section (verified live: cli 2.1.220, `FTe`/`Ysd`). Emitting `hookSpecificOutput` JSON here would
|
|
17
|
+
// send the summarizer a blob of JSON as its instructions. The matcher is the TRIGGER string
|
|
18
|
+
// (`auto` / `manual`), which is why hooks.json registers this twice.
|
|
19
|
+
//
|
|
20
|
+
// LENGTH IS ONLY STEERABLE BY AN EXPLICIT NUMBER — measured, so do not soften WORD_TARGET into prose.
|
|
21
|
+
// Single-variable differential, three `--fork-session` branches off one identical 345,898-token seed:
|
|
22
|
+
//
|
|
23
|
+
// no hook (control) postTokens 4,060
|
|
24
|
+
// qualitative "prefer a long summary" postTokens 4,429 (+9% — noise)
|
|
25
|
+
// explicit ">= 15,000 words" postTokens 20,339 (5.0x the control) <-- WORD_TARGET
|
|
26
|
+
// explicit ">= 35,000 words" postTokens 5,182 (WORSE, and slower + dearer)
|
|
27
|
+
//
|
|
28
|
+
// DO NOT RAISE WORD_TARGET without re-measuring: the response is NON-MONOTONIC. 35,000 words is past
|
|
29
|
+
// what the model will produce in one response and asking for it degraded compliance badly — a quarter
|
|
30
|
+
// the output of the 15,000 ask (2,174 words vs 9,829), at 11.2 min / $3.73 vs 8.6 min / $2.04. Three
|
|
31
|
+
// single samples, so treat the exact figures as indicative, but 15,000 was clearly the best of them.
|
|
32
|
+
//
|
|
33
|
+
// Adjectives do nothing; a hard number does the work. The COST is latency: the 15,000-word run
|
|
34
|
+
// produced no compaction result at all inside a 10-minute cap and only completed on a longer budget.
|
|
35
|
+
// That is acceptable here because frizz workers run with `precomputeCompactionEnabled`, which arms
|
|
36
|
+
// summary generation in a background sidecar at ~80% of the window and swaps it in at the real
|
|
37
|
+
// threshold — so on an `auto` trigger the wait is largely hidden. A `manual` /compact pays it in
|
|
38
|
+
// front of the human.
|
|
39
|
+
//
|
|
40
|
+
// HARD CEILING: the summary is ONE model response, so it is bounded by max output tokens (64k for
|
|
41
|
+
// Opus 5 / Sonnet 5, ~48k words). A 200k-token summary is unreachable at any prompt.
|
|
42
|
+
//
|
|
43
|
+
// TONE MATTERS: an instruction that reads like injected prompt-hijacking gets REFUSED by the
|
|
44
|
+
// summarizer — measured: an early probe demanding a sentinel token produced a summary that explicitly
|
|
45
|
+
// declined to comply and called the instructions fake. Keep this legible as an ordinary editorial
|
|
46
|
+
// brief about what to retain.
|
|
47
|
+
//
|
|
48
|
+
// GATE: everything is gated on FRIZZ_THREAD, so the plugin stays inert when loaded outside a
|
|
49
|
+
// frizz worker. Sub-agent contexts are skipped, matching session-seed.mjs — the scratchpad path
|
|
50
|
+
// below is only guaranteed correct for the top-level worker session.
|
|
51
|
+
import { readFileSync } from 'node:fs';
|
|
52
|
+
import { currentSessionId } from '../scripts/frizz/config.mjs';
|
|
53
|
+
|
|
54
|
+
// See "LENGTH IS ONLY STEERABLE BY AN EXPLICIT NUMBER" above before changing this.
|
|
55
|
+
const WORD_TARGET = 15000;
|
|
56
|
+
|
|
57
|
+
/** @type {{ agent_id?: unknown, agentId?: unknown, trigger?: string, session_id?: string }} */
|
|
58
|
+
let input = {};
|
|
59
|
+
try {
|
|
60
|
+
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
61
|
+
} catch {
|
|
62
|
+
/* no stdin / not JSON → input stays {} → proceed with the generic brief */
|
|
63
|
+
}
|
|
64
|
+
// Skip inside sub-agent contexts (they carry agent_id) — see GATE above.
|
|
65
|
+
if (input.agent_id ?? input.agentId) process.exit(0);
|
|
66
|
+
|
|
67
|
+
// WORKER GATE — inert unless this is a frizz worker session.
|
|
68
|
+
const thread = (process.env.FRIZZ_THREAD ?? '').trim();
|
|
69
|
+
if (!thread) process.exit(0);
|
|
70
|
+
|
|
71
|
+
let sid = null;
|
|
72
|
+
try {
|
|
73
|
+
sid = currentSessionId(input.session_id);
|
|
74
|
+
} catch {
|
|
75
|
+
/* best-effort — fall back to the generic path shape below */
|
|
76
|
+
}
|
|
77
|
+
const scratch = sid
|
|
78
|
+
? '.frizz/threads/' + sid + '/scratch.md'
|
|
79
|
+
: '.frizz/threads/<session-id>/scratch.md';
|
|
80
|
+
|
|
81
|
+
// Written as an editorial brief, not as a command block — see TONE MATTERS above.
|
|
82
|
+
const brief = [
|
|
83
|
+
'This is a frizz worker session driving one engineering effort (`' + thread + '`). Three things ' +
|
|
84
|
+
'matter more here than brevity does, because they are what actually gets lost in compaction:',
|
|
85
|
+
'',
|
|
86
|
+
'1. LENGTH. Write an EXHAUSTIVE summary, not a condensed one: target at least ' +
|
|
87
|
+
WORD_TARGET.toLocaleString('en-US') + ' words. There is ample room for it, so do not compress. ' +
|
|
88
|
+
'A short summary is the failure mode here — omitting something load-bearing is far worse than ' +
|
|
89
|
+
'including something redundant.',
|
|
90
|
+
'',
|
|
91
|
+
'2. THE HIGH-LEVEL APPROACH, AT HIGH FIDELITY. Preserve the shape of the work, not just its ' +
|
|
92
|
+
'mechanics: what problem is being solved, the approach chosen, the approaches considered and ' +
|
|
93
|
+
'REJECTED and why, and the reasoning that led there. Reproduce this in full rather than ' +
|
|
94
|
+
'condensing it into a sentence — a summary that lists edits but loses the plan leaves the next ' +
|
|
95
|
+
'turn unable to judge whether a step is still the right one. Include substantial verbatim ' +
|
|
96
|
+
'excerpts of the code and output that matter rather than describing them from memory.',
|
|
97
|
+
'',
|
|
98
|
+
'Carry forward, in as much detail as you can:',
|
|
99
|
+
'- The task list and its state — every item, marked done / in progress / not started / blocked.',
|
|
100
|
+
'- Decisions made and the rationale for each, especially any the human made, approved, or reversed.',
|
|
101
|
+
'- Constraints, conventions, and explicit human instructions or corrections — in the human\'s own ' +
|
|
102
|
+
'wording where you can, since paraphrase is where intent gets lost.',
|
|
103
|
+
'- Paths of every file created, read, or modified, and what changed in each.',
|
|
104
|
+
'- Commands that matter (build / test / run / verification) and their OBSERVED results.',
|
|
105
|
+
'- What has been VERIFIED by actually running it versus what is merely believed to work. Keep that ' +
|
|
106
|
+
'distinction explicit — it is routinely lost in compaction and its loss causes false claims of ' +
|
|
107
|
+
'completion.',
|
|
108
|
+
'- Open questions, known failures, dead ends already ruled out, and anything left unfinished.',
|
|
109
|
+
'',
|
|
110
|
+
'3. RE-GROUNDING. End the summary with a final section headed exactly "Re-grounding before ' +
|
|
111
|
+
'continuing:" that tells the next turn what to re-establish BEFORE it asserts anything or resumes ' +
|
|
112
|
+
'editing. Make it concrete and specific to this session — not generic advice:',
|
|
113
|
+
'- Re-read the scratchpad at `' + scratch + '` first. It is the durable working state and it ' +
|
|
114
|
+
'outlives this summary.',
|
|
115
|
+
'- Name the specific files to re-read before describing or changing them, rather than relying on ' +
|
|
116
|
+
'remembered contents.',
|
|
117
|
+
'- Name any verification that was in flight and should be re-run rather than assumed.',
|
|
118
|
+
'- State the single next action to take.',
|
|
119
|
+
].join('\n');
|
|
120
|
+
|
|
121
|
+
process.stdout.write(brief + '\n');
|
|
122
|
+
process.exit(0);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
// Optional rest-time reminder carried by the thread's canonical scratchpad. A worker registers one
|
|
4
|
+
// by writing `stop_hook:` in scratch.md's YAML front matter and deregisters it by removing that key.
|
|
5
|
+
// The hook passes the message back verbatim when the worker tries to stop. A persisted two-minute
|
|
6
|
+
// cooldown prevents a forgotten reminder from creating a tight stop/block loop.
|
|
7
|
+
import { readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { currentSessionId } from '../scripts/frizz/config.mjs';
|
|
10
|
+
|
|
11
|
+
const COOLDOWN_MS = 2 * 60 * 1000;
|
|
12
|
+
const MAX_MESSAGE_CHARS = 8_000;
|
|
13
|
+
|
|
14
|
+
/** @param {string[]} argv @param {string} flag */
|
|
15
|
+
function flagValue(argv, flag) {
|
|
16
|
+
const hit = argv.find((arg) => arg.startsWith(flag + '='));
|
|
17
|
+
return hit ? hit.slice(flag.length + 1) : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @param {string} value */
|
|
21
|
+
function inlineYamlString(value) {
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
if (!trimmed) return '';
|
|
24
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(trimmed);
|
|
27
|
+
return typeof parsed === 'string' ? parsed : '';
|
|
28
|
+
} catch {
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
33
|
+
return trimmed.slice(1, -1).replace(/''/g, "'");
|
|
34
|
+
}
|
|
35
|
+
return trimmed;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reads only the reserved `stop_hook` key from top-of-file YAML front matter. The scratchpad body
|
|
40
|
+
* remains free-form Markdown; accepting the common literal/folded block forms keeps multi-line
|
|
41
|
+
* reminders readable without pulling a YAML runtime into a zero-dependency hook.
|
|
42
|
+
* @param {string} source
|
|
43
|
+
*/
|
|
44
|
+
export function scratchpadStopMessage(source) {
|
|
45
|
+
const frontmatter = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
|
|
46
|
+
if (!frontmatter) return null;
|
|
47
|
+
const lines = frontmatter.split(/\r?\n/);
|
|
48
|
+
for (let i = 0; i < lines.length; i++) {
|
|
49
|
+
const match = lines[i].match(/^stop_hook:\s*(.*?)\s*$/);
|
|
50
|
+
if (!match) continue;
|
|
51
|
+
const value = match[1];
|
|
52
|
+
if (value === '|' || value === '>-'
|
|
53
|
+
|| value === '>' || value === '|-' || value === '|+' || value === '>+') {
|
|
54
|
+
const block = [];
|
|
55
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
56
|
+
const line = lines[j];
|
|
57
|
+
if (line.trim() && !/^\s/.test(line)) break;
|
|
58
|
+
block.push(line.replace(/^(?: {2}|\t)/, ''));
|
|
59
|
+
}
|
|
60
|
+
const text = value.startsWith('>')
|
|
61
|
+
? block.map((line) => line.trim()).join(' ').trim()
|
|
62
|
+
: block.join('\n').trim();
|
|
63
|
+
return text ? text.slice(0, MAX_MESSAGE_CHARS) : null;
|
|
64
|
+
}
|
|
65
|
+
const text = inlineYamlString(value);
|
|
66
|
+
return text ? text.slice(0, MAX_MESSAGE_CHARS) : null;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {unknown} input
|
|
73
|
+
* @param {{ projectDir: string, sessionId: string, now?: number }} context
|
|
74
|
+
*/
|
|
75
|
+
export function evaluateScratchpadStop(input, context) {
|
|
76
|
+
if (!input || typeof input !== 'object' || !context.sessionId) return {};
|
|
77
|
+
const threadDir = join(context.projectDir, '.frizz', 'threads', context.sessionId);
|
|
78
|
+
let message;
|
|
79
|
+
try {
|
|
80
|
+
message = scratchpadStopMessage(readFileSync(join(threadDir, 'scratch.md'), 'utf8'));
|
|
81
|
+
} catch {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
if (!message) return {};
|
|
85
|
+
|
|
86
|
+
const now = context.now ?? Date.now();
|
|
87
|
+
const statePath = join(threadDir, '.stop-hook-state.json');
|
|
88
|
+
let lastFiredAt = 0;
|
|
89
|
+
try {
|
|
90
|
+
const state = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
91
|
+
if (Number.isFinite(state?.lastFiredAt)) lastFiredAt = state.lastFiredAt;
|
|
92
|
+
} catch {
|
|
93
|
+
// A missing/corrupt state is an unfired registration.
|
|
94
|
+
}
|
|
95
|
+
if (now - lastFiredAt < COOLDOWN_MS) return {};
|
|
96
|
+
|
|
97
|
+
// Persist BEFORE blocking. If the state cannot be recorded, fail open rather than creating a stop
|
|
98
|
+
// loop whose cooldown can never advance.
|
|
99
|
+
const tempPath = `${statePath}.${process.pid}.tmp`;
|
|
100
|
+
try {
|
|
101
|
+
writeFileSync(tempPath, JSON.stringify({ lastFiredAt: now }) + '\n', { mode: 0o600 });
|
|
102
|
+
renameSync(tempPath, statePath);
|
|
103
|
+
} catch {
|
|
104
|
+
try {
|
|
105
|
+
rmSync(tempPath, { force: true });
|
|
106
|
+
} catch {
|
|
107
|
+
// best effort
|
|
108
|
+
}
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
return { decision: 'block', reason: message };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (process.argv[1]?.endsWith('scratchpad-stop.mjs')) {
|
|
115
|
+
try {
|
|
116
|
+
const input = JSON.parse(readFileSync(0, 'utf8'));
|
|
117
|
+
const argv = process.argv.slice(2);
|
|
118
|
+
const explicitSession = flagValue(argv, '--session');
|
|
119
|
+
const sessionId = explicitSession || currentSessionId(input?.session_id);
|
|
120
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
121
|
+
process.stdout.write(JSON.stringify(evaluateScratchpadStop(input, { projectDir, sessionId })));
|
|
122
|
+
} catch {
|
|
123
|
+
process.stdout.write('{}');
|
|
124
|
+
}
|
|
125
|
+
}
|