frizz-server 0.15.20 → 0.15.23

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 (112) hide show
  1. package/dist/claude-agent-broker.js +371 -39
  2. package/dist/dev-child.js +8255 -6173
  3. package/package.json +1 -1
  4. package/runtime/cc-worker/DECISIONS.md +42 -2
  5. package/runtime/cc-worker/bin/frizz-mcp.mjs +11 -1848
  6. package/runtime/cc-worker/bin/frizzctl +14 -0
  7. package/runtime/cc-worker/bin/frizzctl.cmd +9 -0
  8. package/runtime/cc-worker/bin/frizzctl.mjs +438 -0
  9. package/runtime/cc-worker/hooks/agent-dispatch.mjs +2 -2
  10. package/runtime/cc-worker/hooks/deny-ask.mjs +3 -3
  11. package/runtime/cc-worker/hooks/deny-plan.mjs +2 -2
  12. package/runtime/cc-worker/hooks/perm-policy.mjs +52 -1
  13. package/runtime/cc-worker/hooks/scratchpad.mjs +16 -10
  14. package/runtime/cc-worker/hooks/session-seed.mjs +1 -1
  15. package/runtime/cc-worker/lib/frizz-tools.mjs +2241 -0
  16. package/runtime/cc-worker/skills/gh/SKILL.md +6 -6
  17. package/web-dist/assets/{abnfDiagram-VRR7QNED-B61nQEjt.js → abnfDiagram-VRR7QNED-BW_CIF9J.js} +1 -1
  18. package/web-dist/assets/{arc-CR8_qWI3.js → arc-B475yv46.js} +1 -1
  19. package/web-dist/assets/architecture-TIHT7OUA-wAP7tOoN.js +1 -0
  20. package/web-dist/assets/{architectureDiagram-ZJ3FMSHR-6sc4SDOh.js → architectureDiagram-ZJ3FMSHR-DkPvFjFp.js} +1 -1
  21. package/web-dist/assets/{blockDiagram-677ZJIJ3-p-DPTDGB.js → blockDiagram-677ZJIJ3-Rls7Nttu.js} +1 -1
  22. package/web-dist/assets/{c4Diagram-LMCZKHZV-cqvAqZE2.js → c4Diagram-LMCZKHZV-maV0q2bv.js} +1 -1
  23. package/web-dist/assets/channel-Clg0HJPA.js +1 -0
  24. package/web-dist/assets/{chunk-32BRIVSS-BNnoKILR.js → chunk-32BRIVSS-Coi7L0de.js} +1 -1
  25. package/web-dist/assets/{chunk-52WLFC77-BmHIWiVu.js → chunk-52WLFC77-DZaDe-48.js} +1 -1
  26. package/web-dist/assets/{chunk-C7G6YPKG-D7XYFno8.js → chunk-C7G6YPKG-DNQ-JhD9.js} +1 -1
  27. package/web-dist/assets/{chunk-EX3LRPZG-BUViIha7.js → chunk-EX3LRPZG-BQCebwoA.js} +1 -1
  28. package/web-dist/assets/{chunk-FWX5IMBZ-3x5Zky3W.js → chunk-FWX5IMBZ-CqiHtpEB.js} +2 -2
  29. package/web-dist/assets/{chunk-HOUHSVGY-C6_RBSov.js → chunk-HOUHSVGY-Bop69PFg.js} +1 -1
  30. package/web-dist/assets/{chunk-ICXQ74PX-NbVxQ4i1.js → chunk-ICXQ74PX-D3PkeZ6B.js} +1 -1
  31. package/web-dist/assets/{chunk-MOJQB5TN-nRMfcSHe.js → chunk-MOJQB5TN-lidXw8-C.js} +1 -1
  32. package/web-dist/assets/{chunk-OGEWGWER-BbSXfbEB.js → chunk-OGEWGWER-CzD91FAh.js} +1 -1
  33. package/web-dist/assets/{chunk-PUDLZKDR-Byubn9VY.js → chunk-PUDLZKDR-Cilkzvck.js} +1 -1
  34. package/web-dist/assets/{chunk-Q4XR5HBZ-DLq-aZ3t.js → chunk-Q4XR5HBZ-C37UQCfb.js} +1 -1
  35. package/web-dist/assets/{chunk-RYQCIY6F-Dbt66M6V.js → chunk-RYQCIY6F-C0qnX-Ux.js} +1 -1
  36. package/web-dist/assets/{chunk-V7JOEXUC-DNFTmXz6.js → chunk-V7JOEXUC-Da5vOGHX.js} +1 -1
  37. package/web-dist/assets/{chunk-VAUOI2AC-QWrszv3X.js → chunk-VAUOI2AC-FrJadrJY.js} +1 -1
  38. package/web-dist/assets/{chunk-VR4S4FIN-CJdyjwam.js → chunk-VR4S4FIN-DYxK2Z50.js} +1 -1
  39. package/web-dist/assets/{chunk-WYO6CB5R-D_786t-3.js → chunk-WYO6CB5R-C164KTfT.js} +1 -1
  40. package/web-dist/assets/{chunk-XXDRQBXY-Bd_2jBj_.js → chunk-XXDRQBXY-D4wHtxet.js} +1 -1
  41. package/web-dist/assets/{chunk-ZGVPDNZ5-DklPG-LG.js → chunk-ZGVPDNZ5-BXKkVljT.js} +1 -1
  42. package/web-dist/assets/classDiagram-OUVF2IWQ-23bCXfvg.js +1 -0
  43. package/web-dist/assets/classDiagram-v2-EOCWNBFH-23bCXfvg.js +1 -0
  44. package/web-dist/assets/{cose-bilkent-JH36ORCC-C4MtSvEs.js → cose-bilkent-JH36ORCC-BFSgVzNT.js} +1 -1
  45. package/web-dist/assets/{cynefin-VYW2F7L2-Dr0CmMQz.js → cynefin-VYW2F7L2-BSoEbT-s.js} +1 -1
  46. package/web-dist/assets/{cynefinDiagram-TSTJHNR4-BpHXKyDO.js → cynefinDiagram-TSTJHNR4-BBZgLbdX.js} +1 -1
  47. package/web-dist/assets/{dagre-VKFMJZFB-CWAtKn43.js → dagre-VKFMJZFB-Dx6giO7u.js} +1 -1
  48. package/web-dist/assets/{diagram-FQU43EPY-B_6PhpPG.js → diagram-FQU43EPY-BMI3qRca.js} +1 -1
  49. package/web-dist/assets/{diagram-G47NLZAW-DPNrX-3a.js → diagram-G47NLZAW-CJY-MA3L.js} +1 -1
  50. package/web-dist/assets/{diagram-NH7WQ7WH-BL4nqAUm.js → diagram-NH7WQ7WH-CbQVFJ3r.js} +1 -1
  51. package/web-dist/assets/{diagram-OA4YK3LP-CcFbtuCf.js → diagram-OA4YK3LP-CPnrakdY.js} +1 -1
  52. package/web-dist/assets/{diagram-WEI45ONY-Bkl0nXre.js → diagram-WEI45ONY-D9DNwzlo.js} +1 -1
  53. package/web-dist/assets/{dist-krVNNn4H.js → dist-CO93_xVa.js} +1 -1
  54. package/web-dist/assets/{ebnfDiagram-CCIWWBDH-C_51YQqw.js → ebnfDiagram-CCIWWBDH-DNHudB8r.js} +1 -1
  55. package/web-dist/assets/{erDiagram-Q63AITRT-D8oBhiQB.js → erDiagram-Q63AITRT-Cwt63Xz8.js} +1 -1
  56. package/web-dist/assets/eventmodeling-45OFAUF4-Q3Nq1ecc.js +1 -0
  57. package/web-dist/assets/flowDiagram-23GEKE2U-B2snV3ot.js +1 -0
  58. package/web-dist/assets/{ganttDiagram-NO4QXBWP-BOJJvkcQ.js → ganttDiagram-NO4QXBWP-DiFpSJc2.js} +1 -1
  59. package/web-dist/assets/{gitGraph-TEB2WS4Q-DrZGs6X0.js → gitGraph-TEB2WS4Q-2-hTvDOu.js} +1 -1
  60. package/web-dist/assets/{gitGraphDiagram-IHSO6WYX-C0pqDVhD.js → gitGraphDiagram-IHSO6WYX-mbc4Il71.js} +1 -1
  61. package/web-dist/assets/index-C6AXnNpg.css +2 -0
  62. package/web-dist/assets/index-DewBdeY0.js +352 -0
  63. package/web-dist/assets/{info-DKCQHKI2-0OcIuVxc.js → info-DKCQHKI2-BO0ZUEnn.js} +1 -1
  64. package/web-dist/assets/{infoDiagram-FWYZ7A6U-CRAc-N7_.js → infoDiagram-FWYZ7A6U-Cn3DmnjS.js} +1 -1
  65. package/web-dist/assets/{ishikawaDiagram-FXEZZL3T-D_bHCCxh.js → ishikawaDiagram-FXEZZL3T-Ckwh42aC.js} +1 -1
  66. package/web-dist/assets/{journeyDiagram-5HDEW3XC-Dbvh7D1e.js → journeyDiagram-5HDEW3XC-D26ySLLD.js} +1 -1
  67. package/web-dist/assets/{kanban-definition-HUTT4EX6-CxOWhFpB.js → kanban-definition-HUTT4EX6-CXKwXGxe.js} +1 -1
  68. package/web-dist/assets/{line-BGqbhtvK.js → line-BHqhqZTw.js} +1 -1
  69. package/web-dist/assets/{linear-DRvKJ3WJ.js → linear-Xq8ICqCe.js} +1 -1
  70. package/web-dist/assets/{mermaid-parser.core-C5lIBam8.js → mermaid-parser.core-ByagDKoG.js} +3 -3
  71. package/web-dist/assets/{mermaid.core-ChLUqNEE.js → mermaid.core-CEwbkqrb.js} +3 -3
  72. package/web-dist/assets/{mindmap-definition-LN4V7U3C-BfPv_GOf.js → mindmap-definition-LN4V7U3C-C3S3nEo5.js} +1 -1
  73. package/web-dist/assets/{packet-7NZHBO7P-CH3B2i7_.js → packet-7NZHBO7P-BjcgqWf7.js} +1 -1
  74. package/web-dist/assets/{pegDiagram-2B236MQR-DLuCHUaK.js → pegDiagram-2B236MQR-Due7wPzY.js} +1 -1
  75. package/web-dist/assets/{pie-RZYD4A2V-C-hDlp0h.js → pie-RZYD4A2V-C1X-Fm2K.js} +1 -1
  76. package/web-dist/assets/{pieDiagram-ENE6RG2P-CKVu3PTC.js → pieDiagram-ENE6RG2P-CbKS5jFv.js} +1 -1
  77. package/web-dist/assets/{quadrantDiagram-ABIIQ3AL--tZ3JdyA.js → quadrantDiagram-ABIIQ3AL-CVF1CNNO.js} +1 -1
  78. package/web-dist/assets/{radar-I7S5WNFK-C10EMwWx.js → radar-I7S5WNFK-Co7Otr5p.js} +1 -1
  79. package/web-dist/assets/{railroad-3IZDKUUU-BYegvtJS.js → railroad-3IZDKUUU-BTwZqHtN.js} +1 -1
  80. package/web-dist/assets/railroad-abnf-AHOZXSZD-CEkmgARz.js +1 -0
  81. package/web-dist/assets/railroad-ebnf-EBAXGLYW-D2dXzrFd.js +1 -0
  82. package/web-dist/assets/railroad-peg-LSFZ7HO6-yA2XVaxN.js +1 -0
  83. package/web-dist/assets/{railroadDiagram-RFXS5EU6-CfpRdkA8.js → railroadDiagram-RFXS5EU6-DCymlvVU.js} +1 -1
  84. package/web-dist/assets/{requirementDiagram-TGXJPOKE-CUajgDbc.js → requirementDiagram-TGXJPOKE-CEFbAu30.js} +1 -1
  85. package/web-dist/assets/{sankeyDiagram-HTMAVEWB-DRs5oKTx.js → sankeyDiagram-HTMAVEWB-fnX7WrAj.js} +1 -1
  86. package/web-dist/assets/{sequenceDiagram-DBY2YBRQ-BTeEqLFC.js → sequenceDiagram-DBY2YBRQ-BUvsAYkQ.js} +1 -1
  87. package/web-dist/assets/{src-Cf1DXZ3M.js → src-V5VjKILh.js} +1 -1
  88. package/web-dist/assets/{stateDiagram-2N3HPSRC-BPcqMj-B.js → stateDiagram-2N3HPSRC-C9LJKbHM.js} +1 -1
  89. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-Bm1cPGFT.js +1 -0
  90. package/web-dist/assets/{swimlanes-5IMT3BWC-CJTd58TJ.js → swimlanes-5IMT3BWC-aq9B5aBs.js} +1 -1
  91. package/web-dist/assets/swimlanesDiagram-G3AALYLV-DR6Yx_Ic.js +8 -0
  92. package/web-dist/assets/{timeline-definition-FHXFAJF6-CqbaBVpe.js → timeline-definition-FHXFAJF6-DhJ9Jf4f.js} +1 -1
  93. package/web-dist/assets/{treeView-QDETBFTQ-B2OQ8r59.js → treeView-QDETBFTQ-D42FrEH4.js} +1 -1
  94. package/web-dist/assets/{treemap-6X3UGDF4-DGpAdWTA.js → treemap-6X3UGDF4-BV_9JyG6.js} +1 -1
  95. package/web-dist/assets/{vennDiagram-L72KCM5P-BAS_eWHm.js → vennDiagram-L72KCM5P-BkwHzxsN.js} +1 -1
  96. package/web-dist/assets/{wardley-OPB4EBWU-DpNHIH3W.js → wardley-OPB4EBWU-fwm52ei3.js} +1 -1
  97. package/web-dist/assets/{wardleyDiagram-EHGQE667-2N7X1vj8.js → wardleyDiagram-EHGQE667-D_UqvgcE.js} +1 -1
  98. package/web-dist/assets/{xychartDiagram-FW5EYKEG-C8mfLCei.js → xychartDiagram-FW5EYKEG-tKdmcTfS.js} +1 -1
  99. package/web-dist/index.html +2 -2
  100. package/web-dist/assets/architecture-TIHT7OUA-CXyjbDft.js +0 -1
  101. package/web-dist/assets/channel-uFMfT294.js +0 -1
  102. package/web-dist/assets/classDiagram-OUVF2IWQ-D1koMbqS.js +0 -1
  103. package/web-dist/assets/classDiagram-v2-EOCWNBFH-D1koMbqS.js +0 -1
  104. package/web-dist/assets/eventmodeling-45OFAUF4-tWFB9ALb.js +0 -1
  105. package/web-dist/assets/flowDiagram-23GEKE2U-Dz_Opd7B.js +0 -1
  106. package/web-dist/assets/index-BwC7xvWu.js +0 -336
  107. package/web-dist/assets/index-CuXKWysP.css +0 -2
  108. package/web-dist/assets/railroad-abnf-AHOZXSZD-DLmknJDm.js +0 -1
  109. package/web-dist/assets/railroad-ebnf-EBAXGLYW-BoZF0VpA.js +0 -1
  110. package/web-dist/assets/railroad-peg-LSFZ7HO6-Dib7YVqg.js +0 -1
  111. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-DiV9dOER.js +0 -1
  112. package/web-dist/assets/swimlanesDiagram-G3AALYLV-BDeobZmn.js +0 -8
@@ -0,0 +1,14 @@
1
+ #!/bin/sh
2
+ # frizzctl — the Frizz command line a worker runs from its own shell. frizzctl.mjs, beside this file,
3
+ # is all of it; this only finds a node to run it with.
4
+ #
5
+ # FRIZZ_NODE is the node Frizz itself runs on, handed to every worker's environment, because a
6
+ # worker's PATH varies by how Frizz was launched (a GUI-launched app, a login-shell difference) and a
7
+ # bare `node` that is not on it would take every command with it — the same reason the MCP mount this
8
+ # replaced pinned an absolute path. A stale one (the worker outlived an update that moved it) falls
9
+ # back to whatever `node` the shell has.
10
+ here=$(dirname "$0")
11
+ if [ -n "$FRIZZ_NODE" ] && [ -x "$FRIZZ_NODE" ]; then
12
+ exec "$FRIZZ_NODE" "$here/frizzctl.mjs" "$@"
13
+ fi
14
+ exec node "$here/frizzctl.mjs" "$@"
@@ -0,0 +1,9 @@
1
+ @echo off
2
+ rem frizzctl for a Windows shell that is not Git Bash (cmd, PowerShell). See frizzctl, beside this
3
+ rem file, for why FRIZZ_NODE is tried before a bare node.
4
+ if defined FRIZZ_NODE if exist "%FRIZZ_NODE%" (
5
+ "%FRIZZ_NODE%" "%~dp0frizzctl.mjs" %*
6
+ exit /b %ERRORLEVEL%
7
+ )
8
+ node "%~dp0frizzctl.mjs" %*
9
+ exit /b %ERRORLEVEL%
@@ -0,0 +1,438 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * frizzctl — the Frizz command line a worker runs from its own shell. THE worker surface: every
5
+ * capability Frizz hands a worker is a command here, and none of them is a tool in the model's registry.
6
+ *
7
+ * WHY A COMMAND LINE AND NOT TOOLS (maintainer 2026-10-11). Sixteen MCP tools cost about 12k tokens of
8
+ * schema that each provider loads its own way, could not be composed with anything, and had to be
9
+ * mounted three different ways (a `--mcp-config` file, `-c mcp_servers.…`, ACP `session/new`). A command
10
+ * on the worker's PATH is one surface for every provider, composes with `gh`, `jq` and a shell loop,
11
+ * and discloses itself incrementally: `frizzctl --help` is forty lines, and the long reference for a
12
+ * command is printed only when that command's own `--help` is asked for.
13
+ *
14
+ * It is a FRONT-END and nothing else. What each command is, takes and does lives in
15
+ * ../lib/frizz-tools.mjs; this file turns an argv into one handler call and its answer into stdout and
16
+ * an exit code.
17
+ *
18
+ * ARGUMENTS ARRIVE THREE WAYS, and they combine (a flag wins over JSON):
19
+ * flags `--for 2h`, `--in-seconds 600`, `--stop-hook` / `--no-stop-hook`; the leading ones may
20
+ * be bare where the registry says so (`frizzctl title "Fix queue focus"`).
21
+ * JSON `--input <file>`, `--input -` for stdin, or a JSON object as the first argument — the
22
+ * same object the command's schema describes. This is `gh api --input`, and it is what
23
+ * makes a nested payload (`ask`) or a long one sayable at all.
24
+ * text files any text argument also takes `--<name>-file <path|->` — `gh pr create --body-file`.
25
+ *
26
+ * STDIN IS NEVER READ UNLESS `-` ASKS FOR IT. An agent's shell hands a command an open pipe more often
27
+ * than a terminal, and a command that waits on it never returns (`codex exec` does exactly this, and
28
+ * it reads as the command being broken).
29
+ *
30
+ * EXIT CODES: 0 the command did what it says; 1 it failed, or was called wrongly; 2 it REFUSED — a gate
31
+ * that held (`done` with a question still open), so `frizzctl done … || …` can tell the two apart.
32
+ */
33
+ import { readFileSync } from "node:fs"
34
+ import { CLI, HANDLERS, Refused, TOOLS } from "../lib/frizz-tools.mjs"
35
+
36
+ const NAME = "frizzctl"
37
+ const WIDTH = 100
38
+ const GROUPS = ["Sign off", "Waiting", "This thread", "Other threads"]
39
+
40
+ /** A call that could not be understood, as opposed to one that ran and failed. Same exit code; the
41
+ * message ends by pointing at the command's own help, because the fix is always in there. */
42
+ class UsageError extends Error {}
43
+
44
+ /** `watch_pr` ⇄ `watch-pr`: a command and a flag are spelled with hyphens, a schema key with underscores.
45
+ * Either spelling is accepted going in, so a worker that remembers the old tool name is not punished. */
46
+ const hyphen = (/** @type {string} */ s) => s.replace(/_/g, "-")
47
+ const under = (/** @type {string} */ s) => s.replace(/-/g, "_")
48
+
49
+ /** @param {string} raw */
50
+ function toolNamed(raw) {
51
+ const want = under(raw.toLowerCase())
52
+ return TOOLS.find((t) => t.name === want)
53
+ }
54
+
55
+ /** @param {{ name: string }} tool */
56
+ const propsOf = (tool) => /** @type {Record<string, any>} */ (/** @type {any} */ (tool).inputSchema?.properties ?? {})
57
+
58
+ let stdinTaken = false
59
+ /** The text at `source`: a path, or `-` for stdin. @param {string} source @param {string} flag */
60
+ function readSource(source, flag) {
61
+ if (source === "-") {
62
+ if (stdinTaken) throw new UsageError(`stdin (\`-\`) can feed only one argument per call, and ${flag} is the second to ask`)
63
+ stdinTaken = true
64
+ try {
65
+ return readFileSync(0, "utf8")
66
+ } catch (err) {
67
+ throw new UsageError(`${flag} - could not read stdin: ${err instanceof Error ? err.message : err}`)
68
+ }
69
+ }
70
+ try {
71
+ return readFileSync(source, "utf8")
72
+ } catch (err) {
73
+ throw new UsageError(`${flag} could not read ${source}: ${err instanceof Error ? err.message : err}`)
74
+ }
75
+ }
76
+
77
+ /** One flag's raw text as the type its schema names. @param {any} tool @param {string} key @param {string} raw */
78
+ function coerce(tool, key, raw) {
79
+ const type = propsOf(tool)[key]?.type
80
+ const flag = `--${hyphen(key)}`
81
+ if (type === "integer" || type === "number") {
82
+ const n = Number(raw)
83
+ if (raw.trim() === "" || !Number.isFinite(n) || (type === "integer" && !Number.isInteger(n))) {
84
+ throw new UsageError(`${flag} takes ${type === "integer" ? "a whole number" : "a number"}, not ${JSON.stringify(raw)}`)
85
+ }
86
+ return n
87
+ }
88
+ if (type === "boolean") {
89
+ if (/^(true|yes|1)$/i.test(raw)) return true
90
+ if (/^(false|no|0)$/i.test(raw)) return false
91
+ throw new UsageError(`${flag} is a switch: give it alone, as --no-${hyphen(key)}, or as ${flag}=true|false — not ${JSON.stringify(raw)}`)
92
+ }
93
+ if (type === "array" || type === "object") {
94
+ try {
95
+ return JSON.parse(raw)
96
+ } catch (err) {
97
+ throw new UsageError(`${flag} takes JSON (${type === "array" ? "an array" : "an object"}): ${err instanceof Error ? err.message : err}`)
98
+ }
99
+ }
100
+ return raw
101
+ }
102
+
103
+ /** Whether a JSON value is of a JSON Schema `type`. @param {unknown} v @param {string | string[]} type @returns {boolean} */
104
+ function hasType(v, type) {
105
+ if (Array.isArray(type)) return type.some((t) => hasType(v, t))
106
+ switch (type) {
107
+ case "string": return typeof v === "string"
108
+ case "boolean": return typeof v === "boolean"
109
+ case "number": return typeof v === "number" && Number.isFinite(v)
110
+ case "integer": return Number.isInteger(v)
111
+ case "array": return Array.isArray(v)
112
+ case "object": return v !== null && typeof v === "object" && !Array.isArray(v)
113
+ case "null": return v === null
114
+ default: return true
115
+ }
116
+ }
117
+
118
+ /** The flags a command takes, for the "you wrote a flag it does not have" message. @param {any} tool */
119
+ function flagList(tool) {
120
+ const flags = Object.keys(propsOf(tool)).map((k) => `--${hyphen(k)}`)
121
+ return flags.length ? flags.join(", ") : "no arguments at all"
122
+ }
123
+
124
+ /**
125
+ * A JSON payload as the argument object. A bare array, or a single item, is taken to be the command's
126
+ * one array argument where the registry names one (`ask`: a list of questions, or one question) —
127
+ * the wrapper object is pure ceremony there, and a worker that leaves it off meant the obvious thing.
128
+ * An unknown key is REFUSED rather than dropped: `heartbeat` for `heartbeat_seconds` would otherwise
129
+ * arm a goal with no clock and say nothing.
130
+ * @param {any} tool @param {string} text @param {string} what
131
+ */
132
+ function jsonInput(tool, text, what) {
133
+ let value
134
+ try {
135
+ value = JSON.parse(text)
136
+ } catch (err) {
137
+ throw new UsageError(`${what} is not valid JSON: ${err instanceof Error ? err.message : err}`)
138
+ }
139
+ const keys = Object.keys(propsOf(tool))
140
+ const wrap = CLI[tool.name]?.wrap
141
+ if (Array.isArray(value)) {
142
+ if (wrap) return { [wrap]: value }
143
+ throw new UsageError(`${what} must be a JSON object with the command's arguments (${keys.join(", ") || "none"}), not an array`)
144
+ }
145
+ if (value === null || typeof value !== "object") throw new UsageError(`${what} must be a JSON object, not ${JSON.stringify(value)?.slice(0, 40)}`)
146
+ if (wrap && !(wrap in value) && !Object.keys(value).some((k) => keys.includes(k))) return { [wrap]: [value] }
147
+ const unknown = Object.keys(value).filter((k) => !keys.includes(k))
148
+ if (unknown.length) {
149
+ throw new UsageError(
150
+ `${what} has ${unknown.length === 1 ? "a key" : "keys"} \`${NAME} ${hyphen(tool.name)}\` does not take: ` +
151
+ `${unknown.join(", ")}. It takes: ${keys.join(", ") || "nothing"}.`,
152
+ )
153
+ }
154
+ // The type, too, for the reason the key is checked: `{"stop_hook": "false"}` is not false to a
155
+ // handler that tests for `true`, and it would arm a goal with no trigger and say nothing.
156
+ for (const [key, v] of Object.entries(value)) {
157
+ const want = propsOf(tool)[key]?.type
158
+ if (want && !hasType(v, want)) {
159
+ throw new UsageError(`${what}: ${key} must be ${Array.isArray(want) ? want.join(" or ") : want === "integer" ? "a whole number" : `a ${want}`}, not ${JSON.stringify(v)}`)
160
+ }
161
+ }
162
+ return value
163
+ }
164
+
165
+ /**
166
+ * argv → the argument object one handler takes. Nothing is validated beyond SPELLING and TYPE: which
167
+ * arguments are required, and for which action, is the handler's to say, in the words it already has.
168
+ * @param {any} tool @param {string[]} argv
169
+ */
170
+ function parseArgs(tool, argv) {
171
+ const props = propsOf(tool)
172
+ const spec = CLI[tool.name] ?? {}
173
+ /** @type {Record<string, unknown>} */
174
+ let fromJson = {}
175
+ /** @type {Record<string, unknown>} */
176
+ const fromFlags = {}
177
+ /** @type {string[]} */
178
+ const bare = []
179
+ let flagsOver = false
180
+
181
+ for (let i = 0; i < argv.length; i++) {
182
+ const tok = argv[i]
183
+ if (flagsOver || !tok.startsWith("--")) { bare.push(tok); continue }
184
+ if (tok === "--") { flagsOver = true; continue }
185
+ const eq = tok.indexOf("=")
186
+ const flag = under((eq < 0 ? tok : tok.slice(0, eq)).slice(2).toLowerCase())
187
+ const inline = eq < 0 ? undefined : tok.slice(eq + 1)
188
+ const take = () => {
189
+ if (inline !== undefined) return inline
190
+ if (i + 1 >= argv.length) throw new UsageError(`--${hyphen(flag)} needs a value after it`)
191
+ // `goal start --prompt --post-compaction` left out the prompt; taking the next flag as the text
192
+ // would arm a goal reading "--post-compaction" on the wrong trigger and report success.
193
+ if (/^--[A-Za-z][\w-]*(=.*)?$/.test(argv[i + 1])) {
194
+ throw new UsageError(`--${hyphen(flag)} needs a value, and the next argument is the flag ${argv[i + 1].split("=")[0]}. A value that really begins with "--" is written --${hyphen(flag)}=<value>.`)
195
+ }
196
+ return argv[++i]
197
+ }
198
+ if (flag === "input") {
199
+ fromJson = { ...fromJson, ...jsonInput(tool, readSource(take(), "--input"), "--input") }
200
+ } else if (flag in props) {
201
+ if (props[flag].type !== "boolean") fromFlags[flag] = coerce(tool, flag, take())
202
+ else if (inline !== undefined) fromFlags[flag] = coerce(tool, flag, inline)
203
+ else if (i + 1 < argv.length && /^(true|false)$/i.test(argv[i + 1])) fromFlags[flag] = coerce(tool, flag, argv[++i])
204
+ else fromFlags[flag] = true
205
+ } else if (flag.startsWith("no_") && props[flag.slice(3)]?.type === "boolean") {
206
+ fromFlags[flag.slice(3)] = false
207
+ } else if (flag.endsWith("_file") && props[flag.slice(0, -5)]?.type === "string") {
208
+ fromFlags[flag.slice(0, -5)] = readSource(take(), `--${hyphen(flag)}`)
209
+ } else {
210
+ throw new UsageError(`\`${NAME} ${hyphen(tool.name)}\` has no ${tok.split("=")[0]}. It takes: ${flagList(tool)}.`)
211
+ }
212
+ }
213
+
214
+ // A JSON object (or, where the registry allows, an array) as the FIRST bare argument is the payload.
215
+ // Only when it actually parses as one: a title that happens to start with `[` is still a title.
216
+ if (bare.length && /^\s*[[{]/.test(bare[0])) {
217
+ let parsed = false
218
+ try { parsed = typeof JSON.parse(bare[0]) === "object" } catch {}
219
+ if (parsed) fromJson = { ...fromJson, ...jsonInput(tool, /** @type {string} */ (bare.shift()), "the JSON argument") }
220
+ }
221
+
222
+ // Bare arguments fill the registry's positional slots in order, skipping any a flag or the JSON
223
+ // already filled — so `--action drop prw_1` and `drop prw_1` mean the same thing.
224
+ const given = { ...fromJson, ...fromFlags }
225
+ /** @type {string[]} */
226
+ const declared = spec.positional ?? []
227
+ const slots = declared.filter((k) => !(k in given))
228
+ const extend = (/** @type {unknown} */ lead) => {
229
+ for (const k of spec.then?.[String(lead)] ?? []) if (!(k in given) && !slots.includes(k)) slots.push(k)
230
+ }
231
+ if (declared[0] && declared[0] in given) extend(given[declared[0]])
232
+ /** @type {Record<string, unknown>} */
233
+ const fromBare = {}
234
+ for (const [n, tok] of bare.entries()) {
235
+ if (n >= slots.length) {
236
+ throw new UsageError(
237
+ slots.length === 0
238
+ ? `\`${NAME} ${hyphen(tool.name)}\` takes no bare argument here, and got ${JSON.stringify(tok)}. Its arguments are flags: ${flagList(tool)}.`
239
+ : `\`${NAME} ${hyphen(tool.name)}\` takes ${slots.length} bare argument${slots.length === 1 ? "" : "s"} here (${slots.join(", ")}), and got an extra ${JSON.stringify(tok)}. Quote a value that has spaces.`,
240
+ )
241
+ }
242
+ fromBare[slots[n]] = coerce(tool, slots[n], tok)
243
+ if (slots[n] === declared[0]) extend(fromBare[slots[n]])
244
+ }
245
+ return { ...fromJson, ...fromBare, ...fromFlags }
246
+ }
247
+
248
+ /** Wrap prose to WIDTH under an indent, paragraph by paragraph. A line that starts with whitespace is
249
+ * already laid out (a table, a code sample) and is passed through. @param {string} text @param {string} indent */
250
+ function wrap(text, indent = "") {
251
+ const out = []
252
+ for (const line of text.split("\n")) {
253
+ if (line.trim() === "" || /^\s/.test(line)) { out.push(line.trim() === "" ? "" : indent + line); continue }
254
+ let row = indent
255
+ for (const word of line.split(/\s+/)) {
256
+ if (row.length > indent.length && row.length + 1 + word.length > WIDTH) { out.push(row); row = indent }
257
+ row += (row.length > indent.length ? " " : "") + word
258
+ }
259
+ out.push(row)
260
+ }
261
+ return out.join("\n")
262
+ }
263
+
264
+ function topHelp() {
265
+ const pad = Math.max(...TOOLS.map((t) => t.name.length)) + 2
266
+ const sections = GROUPS.map((group) => {
267
+ // In the registry's own order within a group, which is the order a worker reaches for them in.
268
+ const rows = Object.entries(CLI).filter(([name, spec]) => spec.group === group && toolNamed(name))
269
+ .map(([name, spec]) => ` ${hyphen(name).padEnd(pad)}${spec.summary}`)
270
+ return rows.length ? `${group}\n${rows.join("\n")}` : ""
271
+ }).filter(Boolean)
272
+ return [
273
+ `${NAME} — the Frizz command line for the worker of one thread.`,
274
+ "Every command acts on the thread whose worker runs it; there is no way to name another.",
275
+ "",
276
+ "Usage:",
277
+ ` ${NAME} <command> [arguments]`,
278
+ ` ${NAME} <command> --help the full reference for one command — read it before its first use`,
279
+ "",
280
+ sections.join("\n\n"),
281
+ "",
282
+ "Arguments arrive three ways, and they combine (a flag wins over JSON):",
283
+ ` flags ${NAME} watch shell bzvtnt3ig --for 2h`,
284
+ ` JSON ${NAME} ask --input questions.json (--input - reads stdin; a '{…}' first argument works too)`,
285
+ ` text files any text argument takes --<name>-file <path|->: ${NAME} done --body-file handoff.md`,
286
+ "",
287
+ "Text holding backticks, $ or quotes goes through a file or a QUOTED heredoc, never a double-quoted",
288
+ "argument: inside double quotes the shell runs `…` and $(…) before this command sees them.",
289
+ ` ${NAME} done --body-file - <<'EOF'`,
290
+ " - **Fixed the lookup** in `src/resolver.ts`; `npm test` green.",
291
+ " EOF",
292
+ "",
293
+ "The answer is on stdout. Exit 0: done. 1: failed or misused, and the message says which. 2: refused.",
294
+ ].join("\n")
295
+ }
296
+
297
+ /** One argument's line(s) in a command's help, and those of whatever it nests.
298
+ * @param {string} label @param {any} spec @param {boolean} required @param {string} indent @param {string[][]} outer */
299
+ function describeArg(label, spec, required, indent, outer) {
300
+ const type = spec.enum ? spec.enum.join(" | ") : spec.type === "array" ? `JSON array of ${spec.items?.type ?? "value"}s` : spec.type === "object" ? "JSON object" : spec.type
301
+ const lines = [`${indent}${label} <${type}>${required ? " (required)" : ""}`]
302
+ if (spec.description) lines.push(wrap(spec.description, `${indent} `))
303
+ const inner = spec.type === "array" ? spec.items : spec
304
+ const nested = inner?.type === "object" ? inner.properties : undefined
305
+ if (nested) {
306
+ // A schema that nests itself (`ask`: a question's option carries follow-up questions) is described
307
+ // ONCE. Printing it at every depth is three screens of the same paragraphs.
308
+ const shape = Object.keys(nested).sort().join(",")
309
+ const again = outer.find(([, seen]) => seen === shape)
310
+ if (again) {
311
+ lines.push(`${indent} Each one has the same shape as ${again[0]}, above.`)
312
+ } else {
313
+ const req = new Set(inner.required ?? [])
314
+ for (const [key, sub] of Object.entries(nested)) {
315
+ lines.push(describeArg(key, sub, req.has(key), `${indent} `, [...outer, [label, shape]]))
316
+ }
317
+ }
318
+ }
319
+ return lines.join("\n")
320
+ }
321
+
322
+ /** @param {any} tool */
323
+ function commandHelp(tool) {
324
+ const spec = CLI[tool.name] ?? {}
325
+ const props = propsOf(tool)
326
+ const required = new Set(tool.inputSchema?.required ?? [])
327
+ const command = `${NAME} ${hyphen(tool.name)}`
328
+ const bare = [...(spec.positional ?? []).map((k) => `<${hyphen(k)}>`)]
329
+ const usage = [` ${command}${bare.length ? ` ${bare.join(" ")}` : ""}${Object.keys(props).length > bare.length ? " [--flags]" : ""}`]
330
+ for (const [lead, more] of Object.entries(spec.then ?? {})) usage.push(` ${command} ${lead} ${more.map((k) => `<${hyphen(k)}>`).join(" ")}`)
331
+ if (Object.keys(props).length) usage.push(` ${command} --input <file|-> the arguments below as one JSON object`)
332
+ const args = Object.entries(props).map(([key, sub]) => describeArg(`--${hyphen(key)}`, sub, required.has(key), " ", []))
333
+ const textArgs = Object.entries(props).filter(([, sub]) => sub.type === "string" && !sub.enum).map(([key]) => `--${hyphen(key)}-file`)
334
+ return [
335
+ `${command} — ${spec.summary ?? ""}`,
336
+ "",
337
+ "Usage:",
338
+ usage.join("\n"),
339
+ "",
340
+ wrap(tool.description ?? ""),
341
+ "",
342
+ args.length ? `Arguments:\n${args.join("\n\n")}` : "Arguments: none.",
343
+ ...(textArgs.length ? ["", wrap(`Text from a file or stdin instead of the command line: ${textArgs.join(", ")} <path|->.`)] : []),
344
+ ...(spec.examples?.length ? ["", `Examples:\n${spec.examples.map((e) => e.split("\n").map((l) => ` ${l}`).join("\n")).join("\n\n")}`] : []),
345
+ "",
346
+ `${command} --schema prints the arguments as a JSON Schema.`,
347
+ ].join("\n")
348
+ }
349
+
350
+ /** Whether the arguments ask for help, read the way parseArgs reads them: a `-h` that is a flag's
351
+ * VALUE (`steer other --message -h`) is the message, not a request for help, and the command must run
352
+ * rather than print help and exit 0 having sent nothing. Its own pass, with no side effects, so that
353
+ * `--input - --help` prints help without first reading stdin.
354
+ * @param {any} tool @param {string[]} argv @returns {"help" | "schema" | undefined} */
355
+ function helpAsked(tool, argv) {
356
+ for (let i = 0; i < argv.length; i++) {
357
+ const tok = argv[i]
358
+ if (tok === "--") return undefined
359
+ if (tok === "--help" || tok === "-h") return "help"
360
+ if (tok === "--schema") return "schema"
361
+ if (tok.startsWith("--") && !tok.includes("=") && takesValue(tool, under(tok.slice(2).toLowerCase()))) i++
362
+ }
363
+ return undefined
364
+ }
365
+
366
+ /** Whether `--<flag>` consumes the next argument as its value. @param {any} tool @param {string} flag */
367
+ function takesValue(tool, flag) {
368
+ const props = propsOf(tool)
369
+ if (flag === "input") return true
370
+ if (flag in props) return props[flag].type !== "boolean"
371
+ return flag.endsWith("_file") && props[flag.slice(0, -5)]?.type === "string"
372
+ }
373
+
374
+ /** @param {string[]} argv @returns {Promise<{ code: number, out?: string }>} */
375
+ async function main(argv) {
376
+ const [first, ...rest] = argv
377
+ if (!first || first === "--help" || first === "-h" || first === "help") {
378
+ const about = first === "help" && rest[0] ? toolNamed(rest[0]) : undefined
379
+ if (first === "help" && rest[0] && !about) throw new UsageError(unknownCommand(rest[0]))
380
+ return { code: 0, out: about ? commandHelp(about) : topHelp() }
381
+ }
382
+ const tool = toolNamed(first)
383
+ if (!tool) throw new UsageError(unknownCommand(first))
384
+ const asked = helpAsked(tool, rest)
385
+ if (asked === "help") return { code: 0, out: commandHelp(tool) }
386
+ if (asked === "schema") return { code: 0, out: JSON.stringify(tool.inputSchema, null, 2) }
387
+ let args
388
+ try {
389
+ args = parseArgs(tool, rest)
390
+ } catch (err) {
391
+ if (err instanceof UsageError) err.message += `\nRun \`${NAME} ${hyphen(tool.name)} --help\` for what it takes.`
392
+ throw err
393
+ }
394
+ let answer
395
+ try {
396
+ answer = await HANDLERS[tool.name](args)
397
+ } catch (err) {
398
+ throw new Error(`${NAME} ${hyphen(tool.name)} failed: ${err instanceof Error ? err.message : String(err)}`)
399
+ }
400
+ return answer instanceof Refused ? { code: 2, out: answer.text } : { code: 0, out: String(answer) }
401
+ }
402
+
403
+ /** Edit distance, for "did you mean". @param {string} a @param {string} b */
404
+ function distance(a, b) {
405
+ let row = Array.from({ length: b.length + 1 }, (_, j) => j)
406
+ for (let i = 1; i <= a.length; i++) {
407
+ const next = [i]
408
+ for (let j = 1; j <= b.length; j++) next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
409
+ row = next
410
+ }
411
+ return row[b.length]
412
+ }
413
+
414
+ /** @param {string} raw */
415
+ function unknownCommand(raw) {
416
+ const want = under(raw.toLowerCase())
417
+ const near = TOOLS.filter((t) => t.name.includes(want) || want.includes(t.name) || distance(t.name, want) <= 2).map((t) => hyphen(t.name))
418
+ return (
419
+ `\`${NAME}\` has no command ${JSON.stringify(raw)}.` +
420
+ (near.length ? ` Did you mean: ${near.join(", ")}?` : "") +
421
+ `\nCommands: ${TOOLS.map((t) => hyphen(t.name)).join(", ")}.\nRun \`${NAME} --help\` for what each one does.`
422
+ )
423
+ }
424
+
425
+ /** Write, THEN exit: a piped stdout is asynchronous, and exiting first truncates a long answer — while
426
+ * not exiting at all leaves the process up for as long as fetch keeps its socket open.
427
+ * @param {NodeJS.WriteStream} stream @param {string} text @param {number} code */
428
+ function finish(stream, text, code) {
429
+ process.exitCode = code
430
+ // A reader that stopped reading (`frizzctl activity | head -1`) is not an error worth a stack trace.
431
+ stream.on("error", () => process.exit(code))
432
+ stream.write(text.endsWith("\n") ? text : `${text}\n`, () => process.exit(code))
433
+ }
434
+
435
+ main(process.argv.slice(2)).then(
436
+ ({ code, out }) => finish(process.stdout, out ?? "", code),
437
+ (err) => finish(process.stderr, err instanceof Error ? err.message : String(err), 1),
438
+ )
@@ -9,7 +9,7 @@
9
9
  // 3) AUTO-APPEND a short ORCHESTRATION EPILOGUE carrying only what a helper cannot discover on
10
10
  // its own: that its final message is the return value, that nothing can WAKE it so it must not
11
11
  // end a turn on a waiter, the `SendMessage({to: "main"})` upward channel and its sideways forms (a
12
- // sibling by agent id, another thread by `mcp__frizz__steer`), the own-file rule for
12
+ // sibling by agent id, another thread by `frizzctl steer`), the own-file rule for
13
13
  // the scratch directory, no fan-out of its own unless asked, and how a helper it DID dispatch
14
14
  // is collected. It carries no handoff format and no build, test, git or process policy — the
15
15
  // maintainer had the handoff-format doctrine cut on 2026-08-26.
@@ -71,7 +71,7 @@ const EPILOGUE = `
71
71
  [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.
72
72
  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.
73
73
  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.
74
- 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. The same tool reaches a SIBLING sub-agent by the full agent id your prompt gives you (a message from one arrives as \`<agent-message from="<id>">\`; reply to that id), and \`mcp__frizz__steer\` reaches another Frizz THREAD by its slug: when your prompt says your result belongs to another thread or sibling, send it there yourself before you return, and say in your final message that you did.
74
+ 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. The same tool reaches a SIBLING sub-agent by the full agent id your prompt gives you (a message from one arrives as \`<agent-message from="<id>">\`; reply to that id), and \`frizzctl steer <slug> --message-file <file|->\`, run from your shell, reaches another Frizz THREAD by its slug: when your prompt says your result belongs to another thread or sibling, send it there yourself before you return, and say in your final message that you did.
75
75
  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.
76
76
  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.`;
77
77
 
@@ -3,7 +3,7 @@
3
3
  // PreToolUse hook on AskUserQuestion (frizz-worker). A frizz worker runs under a dashboard, not a
4
4
  // live chat: an interactive question prompt would hang the session invisibly (nobody is at the
5
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
6
+ // `frizzctl ask` (a row; the free-form ```question fence was retired 2026-09-11), keep working, and
7
7
  // rest normally; the answer arrives as its own wake.
8
8
  // GATE: inert unless FRIZZ_THREAD is set. FAIL OPEN on parse errors.
9
9
  //
@@ -13,7 +13,7 @@
13
13
  // operator answers and which returns the chosen labels to the tool. The broker bridge sets that var
14
14
  // only when an InteractionStore is actually wired to render and resolve the card, so a denial here
15
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.
16
+ // deny plus the `frizzctl ask` redirect.
17
17
  import { readFileSync } from 'node:fs';
18
18
 
19
19
  const slug = process.env.FRIZZ_THREAD;
@@ -32,7 +32,7 @@ process.stdout.write(
32
32
  hookEventName: 'PreToolUse',
33
33
  permissionDecision: 'deny',
34
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.',
35
+ 'Interactive prompts freeze headless workers (no one is at the keyboard to answer). Register the question with `frizzctl ask` instead (a command you run from your shell; `frizzctl ask --help` has its arguments) — several in one command, 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
36
  },
37
37
  }),
38
38
  );
@@ -23,7 +23,7 @@
23
23
  // instead — frizz NEVER spawns a worker in plan mode (dispatch.ts coerces `--permission-mode plan`
24
24
  // → `auto` in both command builders), so a real frizz worker is never in plan mode and this deny
25
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`,
26
+ // worker "plans" by writing a durable plan file and asking via `frizzctl ask`,
27
27
  // never via interactive plan mode.
28
28
  // RESIDUAL GAP (accepted, documented): the deny could softlock only a FOREIGN session that is
29
29
  // simultaneously in plan mode AND running with FRIZZ_THREAD set AND this plugin loaded — a combo
@@ -48,7 +48,7 @@ try {
48
48
  // the model reads is `additionalContext`, injected as a plain-text system-reminder. Exit 0 with
49
49
  // this JSON on stdout (exit 2 would make Claude Code ignore the JSON — never mix).
50
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.';
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 `frizzctl 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
52
 
53
53
  process.stdout.write(
54
54
  JSON.stringify({
@@ -71,6 +71,40 @@ function isDiskWrite(cmd) {
71
71
  return /\bmkfs(\.\w+)?\b/.test(cmd) || /\bdd\b[^|;&]*\bof=\/dev\/(disk|r?disk|sd|nvme|hd)/.test(cmd);
72
72
  }
73
73
 
74
+ // The worker's OWN Frizz command fed by a quoted heredoc, and provably nothing else:
75
+ //
76
+ // frizzctl ask --input - <<'EOF'
77
+ // {"question": "…", "options": […]}
78
+ // EOF
79
+ //
80
+ // Dispatch pre-approves `Bash(frizzctl:*)` (dispatch.ts claudeCliConfig), which is the promise the Frizz
81
+ // MCP server's own pre-approval made before 2026-10-11: a worker's sign-off, its question and its
82
+ // registrations are never something a human has to approve. Claude Code honours that rule for every
83
+ // form but one. Measured on 2.1.296 against a real worker on a `default`-mode thread
84
+ // (`_live_broker_frizzctl.mts`): a bare call, an inline JSON argument, `--input <file>`, `< file`,
85
+ // `frizzctl … && frizzctl …` and a quoted heredoc of PROSE all ran unasked, and a quoted heredoc whose
86
+ // body holds a JSON object raised a prompt — Claude Code's own command checks read the braces and
87
+ // quotes of a body the shell never interprets. That is the one shape `ask` most needs, since a
88
+ // question's options do not fit on a command line.
89
+ //
90
+ // So this recognises exactly that shape, and it is deliberately narrower than the rule it backs up.
91
+ // The first line is `frizzctl`, then words made only of characters the shell does not interpret, then
92
+ // `<<'WORD'` and the end of the line: no quoting to get wrong, no expansion, no operator, no second
93
+ // command. A QUOTED delimiter makes the body literal, so nothing in it runs however it reads. The body
94
+ // ends at the first line that is exactly WORD, which is bash's own rule, and only blank lines may
95
+ // follow. Anything else, `<<EOF` unquoted included, is not this and falls through to the table.
96
+ const FRIZZCTL_HEREDOC = /^frizzctl(?: +[A-Za-z0-9_.\/:=@%+,-]+)* +<<'([A-Za-z_][A-Za-z0-9_]*)'[ \t]*\n([\s\S]*)$/;
97
+ function isOwnFrizzctlHeredoc(cmd) {
98
+ // Blanks are the SHELL's — space, tab, newline — and not JavaScript's `\s`, which also takes a
99
+ // no-break space or a form feed: to bash and zsh those are ordinary characters of a word, so
100
+ // `\u00a0frizzctl` is a different program.
101
+ const m = FRIZZCTL_HEREDOC.exec(cmd.replace(/^[ \t\n]+/, ''));
102
+ if (!m) return false;
103
+ const lines = m[2].split('\n');
104
+ const end = lines.indexOf(m[1]);
105
+ return end !== -1 && lines.slice(end + 1).every((line) => /^[ \t]*$/.test(line));
106
+ }
107
+
74
108
  // The ordered policy table: FIRST MATCH WINS. Each rule returns a decision plus the `rule` id and
75
109
  // `reason` that get recorded on the marker, so frizz can always say WHICH rule decided and WHY.
76
110
  // `deny` reasons are written to be read by the MODEL (they become additionalContext).
@@ -89,13 +123,30 @@ const RULES = [
89
123
  reason:
90
124
  '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
125
  },
126
+ {
127
+ // Ahead of `restrictive-mode` on purpose: a thread a human moved to `default` still has to be able
128
+ // to ask them a question and sign off without first asking permission to. See isOwnFrizzctlHeredoc.
129
+ id: 'own-frizzctl',
130
+ test: (i) => i.tool_name === 'Bash' && isOwnFrizzctlHeredoc(String(i.tool_input?.command ?? '')),
131
+ decision: 'allow',
132
+ reason: "The worker's own Frizz command, fed by a quoted heredoc: its questions, registrations and sign-off are pre-approved in every permission mode.",
133
+ },
92
134
  {
93
135
  // Respect a DELIBERATELY restrictive mode. frizz dispatches workers at `auto`; a thread sitting at
94
136
  // `default`/`plan` got there because a human moved it there (the live per-thread permission
95
137
  // control), and auto-approving would silently overrule that intent. This is what makes a genuine
96
138
  // lower-permission mode usable today: switch a thread to `default` and its prompts come back.
139
+ //
140
+ // `bypassPermissions` IS NOT ONE OF THEM. It is the one mode MORE permissive than `auto`, and the
141
+ // operator chose it (Settings, or the same per-thread control) precisely to stop being asked — but
142
+ // Claude Code still raises a prompt for some commands under bypass, and this rule read "not auto"
143
+ // as "restrictive", so a bypass thread parked on approval cards an auto thread never showed
144
+ // (maintainer 2026-10-10: all four markers on the machine that day were bypass-mode defers). It
145
+ // falls through to `worker-autonomy` now, behind the two refusals above. Any OTHER mode string,
146
+ // including one this table has never heard of, still defers: unknown must mean ask.
97
147
  id: 'restrictive-mode',
98
- test: (i) => typeof i.permission_mode === 'string' && i.permission_mode !== 'auto',
148
+ test: (i) =>
149
+ typeof i.permission_mode === 'string' && i.permission_mode !== 'auto' && i.permission_mode !== 'bypassPermissions',
99
150
  decision: 'defer',
100
151
  reason: 'The thread is in a restrictive permission mode, so this request is left for a human to answer.',
101
152
  },