frizz-server 0.15.25 → 0.15.27

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 (106) hide show
  1. package/dist/claude-agent-broker.js +14743 -13917
  2. package/dist/dev-child.js +19221 -16124
  3. package/package.json +1 -1
  4. package/runtime/cc-worker/bin/frizzctl.mjs +18 -5
  5. package/runtime/cc-worker/hooks/deny-ask.mjs +5 -5
  6. package/runtime/cc-worker/hooks/deny-plan.mjs +2 -2
  7. package/runtime/cc-worker/hooks/perm-policy.mjs +3 -2
  8. package/runtime/cc-worker/hooks/session-seed.mjs +1 -1
  9. package/runtime/cc-worker/lib/frizz-tools.mjs +203 -308
  10. package/runtime/cc-worker/skills/gh/SKILL.md +2 -2
  11. package/web-dist/assets/{abnfDiagram-VRR7QNED-BUGGhGcL.js → abnfDiagram-VRR7QNED-C1yPcG1z.js} +1 -1
  12. package/web-dist/assets/{arc-DlmiB5Kw.js → arc-DCZhAMyd.js} +1 -1
  13. package/web-dist/assets/architecture-TIHT7OUA-B1iuHL_Y.js +1 -0
  14. package/web-dist/assets/{architectureDiagram-ZJ3FMSHR-m4afIjbT.js → architectureDiagram-ZJ3FMSHR-BEVy-3f-.js} +1 -1
  15. package/web-dist/assets/{blockDiagram-677ZJIJ3-Kz2humn5.js → blockDiagram-677ZJIJ3-C1XryTx5.js} +1 -1
  16. package/web-dist/assets/{c4Diagram-LMCZKHZV-SeHCCIRv.js → c4Diagram-LMCZKHZV-BxzRPmz9.js} +1 -1
  17. package/web-dist/assets/channel-DmzWEsVb.js +1 -0
  18. package/web-dist/assets/{chunk-32BRIVSS-Cs4BaciL.js → chunk-32BRIVSS-BOhxnrmS.js} +1 -1
  19. package/web-dist/assets/{chunk-52WLFC77-Hrl54hxN.js → chunk-52WLFC77-DHFFeOOt.js} +1 -1
  20. package/web-dist/assets/{chunk-C7G6YPKG-C_LwXwMT.js → chunk-C7G6YPKG-Bf7iTkYg.js} +1 -1
  21. package/web-dist/assets/{chunk-EX3LRPZG-s0-pq_ja.js → chunk-EX3LRPZG-Tj-fNey7.js} +1 -1
  22. package/web-dist/assets/{chunk-FWX5IMBZ-Ci_L6bRR.js → chunk-FWX5IMBZ-B_587R9r.js} +2 -2
  23. package/web-dist/assets/{chunk-HOUHSVGY-Cn43tXaT.js → chunk-HOUHSVGY-CWYb4IRw.js} +1 -1
  24. package/web-dist/assets/{chunk-ICXQ74PX-DaehSUDb.js → chunk-ICXQ74PX-Jy4FGOLb.js} +1 -1
  25. package/web-dist/assets/{chunk-MOJQB5TN-BOLvHrOY.js → chunk-MOJQB5TN-Doyk0ws3.js} +1 -1
  26. package/web-dist/assets/{chunk-OGEWGWER-B7N0H72R.js → chunk-OGEWGWER-X6cWpyUd.js} +1 -1
  27. package/web-dist/assets/{chunk-PUDLZKDR-D9Tpv5xu.js → chunk-PUDLZKDR-CNCT6yjC.js} +1 -1
  28. package/web-dist/assets/{chunk-Q4XR5HBZ-CZxzEfvh.js → chunk-Q4XR5HBZ-BcAtfHf9.js} +1 -1
  29. package/web-dist/assets/{chunk-RYQCIY6F-CH35JHMA.js → chunk-RYQCIY6F-8CUiRz24.js} +1 -1
  30. package/web-dist/assets/{chunk-V7JOEXUC-28qGatq3.js → chunk-V7JOEXUC-C7UqD_CN.js} +1 -1
  31. package/web-dist/assets/{chunk-VAUOI2AC-CkO9u5CM.js → chunk-VAUOI2AC-Dg3Nzy4F.js} +1 -1
  32. package/web-dist/assets/{chunk-VR4S4FIN-BFszczwO.js → chunk-VR4S4FIN-BrkonRYO.js} +1 -1
  33. package/web-dist/assets/{chunk-WYO6CB5R-CuBoterc.js → chunk-WYO6CB5R-CtzxQkcZ.js} +1 -1
  34. package/web-dist/assets/{chunk-XXDRQBXY-8nlDi-YO.js → chunk-XXDRQBXY-0TbCJaHg.js} +1 -1
  35. package/web-dist/assets/{chunk-ZGVPDNZ5-B5WfIiLa.js → chunk-ZGVPDNZ5-CRkGfJ8p.js} +1 -1
  36. package/web-dist/assets/classDiagram-OUVF2IWQ-BzUHMuoR.js +1 -0
  37. package/web-dist/assets/classDiagram-v2-EOCWNBFH-BzUHMuoR.js +1 -0
  38. package/web-dist/assets/{cose-bilkent-JH36ORCC-Csxg0M3P.js → cose-bilkent-JH36ORCC-CjL8X4g9.js} +1 -1
  39. package/web-dist/assets/{cynefin-VYW2F7L2-DY-oaly-.js → cynefin-VYW2F7L2-2pg1obUS.js} +1 -1
  40. package/web-dist/assets/{cynefinDiagram-TSTJHNR4-Baev5OIz.js → cynefinDiagram-TSTJHNR4-CXst9IDq.js} +1 -1
  41. package/web-dist/assets/{dagre-VKFMJZFB-VV2KSHL4.js → dagre-VKFMJZFB-DayLnbRm.js} +1 -1
  42. package/web-dist/assets/{diagram-FQU43EPY-rLuhqzIC.js → diagram-FQU43EPY-6yRM--yJ.js} +1 -1
  43. package/web-dist/assets/{diagram-G47NLZAW-BBRVU5Kr.js → diagram-G47NLZAW-eNczVIx_.js} +1 -1
  44. package/web-dist/assets/{diagram-NH7WQ7WH-BMSjTTHf.js → diagram-NH7WQ7WH-B_b4Cb9a.js} +1 -1
  45. package/web-dist/assets/{diagram-OA4YK3LP-7NO_QsSx.js → diagram-OA4YK3LP-DHW8gg6j.js} +1 -1
  46. package/web-dist/assets/{diagram-WEI45ONY-evQWfr1h.js → diagram-WEI45ONY-CaBjDDII.js} +1 -1
  47. package/web-dist/assets/{dist-BoiR_HZa.js → dist-CTtNsFkL.js} +1 -1
  48. package/web-dist/assets/{ebnfDiagram-CCIWWBDH-BGCXILB-.js → ebnfDiagram-CCIWWBDH-CtmnW6EM.js} +1 -1
  49. package/web-dist/assets/{erDiagram-Q63AITRT-CJSi0YB2.js → erDiagram-Q63AITRT-N214SxuY.js} +1 -1
  50. package/web-dist/assets/eventmodeling-45OFAUF4-C3Xuk6Lf.js +1 -0
  51. package/web-dist/assets/flowDiagram-23GEKE2U-DdLuCY6R.js +1 -0
  52. package/web-dist/assets/{ganttDiagram-NO4QXBWP-DfKGoGJe.js → ganttDiagram-NO4QXBWP-CzuKeYZd.js} +1 -1
  53. package/web-dist/assets/{gitGraph-TEB2WS4Q-DDOEbCVa.js → gitGraph-TEB2WS4Q-CNQpTl8i.js} +1 -1
  54. package/web-dist/assets/{gitGraphDiagram-IHSO6WYX-tKMl1T5T.js → gitGraphDiagram-IHSO6WYX-Bo3w9_BM.js} +1 -1
  55. package/web-dist/assets/index-BJngxAUp.js +372 -0
  56. package/web-dist/assets/index-CWk594R-.css +2 -0
  57. package/web-dist/assets/{info-DKCQHKI2-BqMXJJEF.js → info-DKCQHKI2-CQ5sLj3R.js} +1 -1
  58. package/web-dist/assets/{infoDiagram-FWYZ7A6U-DXpmORxt.js → infoDiagram-FWYZ7A6U-H6Ct-2Yz.js} +1 -1
  59. package/web-dist/assets/{ishikawaDiagram-FXEZZL3T-D9yCb_iG.js → ishikawaDiagram-FXEZZL3T-rUTDcs-s.js} +1 -1
  60. package/web-dist/assets/{journeyDiagram-5HDEW3XC-DwyXsO1X.js → journeyDiagram-5HDEW3XC-pET45cR6.js} +1 -1
  61. package/web-dist/assets/{kanban-definition-HUTT4EX6-C-BupP2L.js → kanban-definition-HUTT4EX6-CMEmImiX.js} +1 -1
  62. package/web-dist/assets/{line-CVohBkYV.js → line-DmbABxm7.js} +1 -1
  63. package/web-dist/assets/{linear-CUS0dsDf.js → linear-BV686rjE.js} +1 -1
  64. package/web-dist/assets/{mermaid-parser.core-BaUI30hf.js → mermaid-parser.core-BhfkgmJj.js} +3 -3
  65. package/web-dist/assets/{mermaid.core-DRwFVBM9.js → mermaid.core-CjApJjX5.js} +3 -3
  66. package/web-dist/assets/{mindmap-definition-LN4V7U3C-Dwz7wXjl.js → mindmap-definition-LN4V7U3C-C-1ivbvY.js} +1 -1
  67. package/web-dist/assets/{packet-7NZHBO7P-Cc8S-JIW.js → packet-7NZHBO7P-BWeKd3YB.js} +1 -1
  68. package/web-dist/assets/{pegDiagram-2B236MQR-DEvuejFX.js → pegDiagram-2B236MQR-CMt6u9H9.js} +1 -1
  69. package/web-dist/assets/{pie-RZYD4A2V-lDSko9Ox.js → pie-RZYD4A2V-CT1Y-9Bd.js} +1 -1
  70. package/web-dist/assets/{pieDiagram-ENE6RG2P-DoIHahh_.js → pieDiagram-ENE6RG2P-WctsTMEX.js} +1 -1
  71. package/web-dist/assets/{quadrantDiagram-ABIIQ3AL-ttYUswX_.js → quadrantDiagram-ABIIQ3AL-detmDaKK.js} +1 -1
  72. package/web-dist/assets/{radar-I7S5WNFK-Dlai2VWL.js → radar-I7S5WNFK-DNFNnoP7.js} +1 -1
  73. package/web-dist/assets/{railroad-3IZDKUUU-CfOvvQCL.js → railroad-3IZDKUUU-Bzhtn1VK.js} +1 -1
  74. package/web-dist/assets/railroad-abnf-AHOZXSZD-D4qAcwJF.js +1 -0
  75. package/web-dist/assets/railroad-ebnf-EBAXGLYW-C9BHZsDg.js +1 -0
  76. package/web-dist/assets/railroad-peg-LSFZ7HO6-CnYxGTOV.js +1 -0
  77. package/web-dist/assets/{railroadDiagram-RFXS5EU6-BRpMN8zD.js → railroadDiagram-RFXS5EU6-C0ehB7_S.js} +1 -1
  78. package/web-dist/assets/{requirementDiagram-TGXJPOKE-C1f5KNrx.js → requirementDiagram-TGXJPOKE-g7iubHbK.js} +1 -1
  79. package/web-dist/assets/{sankeyDiagram-HTMAVEWB-CjXPLZ2A.js → sankeyDiagram-HTMAVEWB-DIYvCsv4.js} +1 -1
  80. package/web-dist/assets/{sequenceDiagram-DBY2YBRQ-BxVxwb4e.js → sequenceDiagram-DBY2YBRQ-7GQEb58j.js} +1 -1
  81. package/web-dist/assets/{src-DjcREoQq.js → src-BwwZrn6Z.js} +1 -1
  82. package/web-dist/assets/{stateDiagram-2N3HPSRC-CCx9rSmu.js → stateDiagram-2N3HPSRC-DRgMko33.js} +1 -1
  83. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-BytUD2US.js +1 -0
  84. package/web-dist/assets/{swimlanes-5IMT3BWC-L3Legvfa.js → swimlanes-5IMT3BWC-BleEENNi.js} +1 -1
  85. package/web-dist/assets/swimlanesDiagram-G3AALYLV-BT_dw9UO.js +8 -0
  86. package/web-dist/assets/{timeline-definition-FHXFAJF6-0p22547Z.js → timeline-definition-FHXFAJF6-CnadbNaJ.js} +1 -1
  87. package/web-dist/assets/{treeView-QDETBFTQ-Xv4-kZ7I.js → treeView-QDETBFTQ-By5b6e2R.js} +1 -1
  88. package/web-dist/assets/{treemap-6X3UGDF4-MhycNmte.js → treemap-6X3UGDF4-K3mpMY_i.js} +1 -1
  89. package/web-dist/assets/{vennDiagram-L72KCM5P-P33JCPMu.js → vennDiagram-L72KCM5P-B-0Xnf8Y.js} +1 -1
  90. package/web-dist/assets/{wardley-OPB4EBWU-CS41M7I-.js → wardley-OPB4EBWU-DtS5at8O.js} +1 -1
  91. package/web-dist/assets/{wardleyDiagram-EHGQE667-CbQaI_DG.js → wardleyDiagram-EHGQE667-Rs7Ad73f.js} +1 -1
  92. package/web-dist/assets/{xychartDiagram-FW5EYKEG-tg1lQQ6H.js → xychartDiagram-FW5EYKEG-BSoig2A4.js} +1 -1
  93. package/web-dist/index.html +2 -2
  94. package/web-dist/assets/architecture-TIHT7OUA-DvYaN-Hu.js +0 -1
  95. package/web-dist/assets/channel-Og7O24Xl.js +0 -1
  96. package/web-dist/assets/classDiagram-OUVF2IWQ-DBua7nWT.js +0 -1
  97. package/web-dist/assets/classDiagram-v2-EOCWNBFH-DBua7nWT.js +0 -1
  98. package/web-dist/assets/eventmodeling-45OFAUF4-CMWWvyUk.js +0 -1
  99. package/web-dist/assets/flowDiagram-23GEKE2U-CFOr0Vr_.js +0 -1
  100. package/web-dist/assets/index-DYhjAs_a.js +0 -354
  101. package/web-dist/assets/index-htGvcLfn.css +0 -2
  102. package/web-dist/assets/railroad-abnf-AHOZXSZD-Br3ohVd5.js +0 -1
  103. package/web-dist/assets/railroad-ebnf-EBAXGLYW-DFaY0AnY.js +0 -1
  104. package/web-dist/assets/railroad-peg-LSFZ7HO6-Cy2FBxXb.js +0 -1
  105. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-bSyE7VSt.js +0 -1
  106. package/web-dist/assets/swimlanesDiagram-G3AALYLV-n0v1Z0Xu.js +0 -8
@@ -463,184 +463,97 @@ const UNWATCH = {
463
463
  },
464
464
  }
465
465
 
466
- // ---- `ask` / `unask`: a question the human owes an answer to, as a ROW ------------------------------
467
-
468
- // The question tree, generated rather than written three times over. MCP tool schemas are JSON Schema,
469
- // and a `$ref` cycle is the natural way to express a recursive shape — but client support for one is
470
- // uneven, and a schema a client silently drops is a tool a worker cannot call. ASK_MAX_DEPTH is 3, so
471
- // the nesting is INLINED to exactly that depth: `followUps` simply does not exist on the deepest level,
472
- // which makes the limit visible in the schema instead of being a refusal the worker meets at runtime.
473
- const ASK_MAX_DEPTH = 3
474
- /** @param {number} depth 1 = the root question. @returns {Record<string, unknown>} */
475
- function questionSchema(depth) {
476
- const option = {
477
- type: "object",
478
- properties: {
479
- label: { type: "string", description: "The choice itself, short — this is what the answer hands back to you." },
480
- description: {
481
- type: "string",
482
- description:
483
- "The trade-off, or the evidence — and it renders INSIDE the option, always visible, so this " +
484
- "is where the human learns what they are choosing BEFORE they pick anything. ONE LINE is the " +
485
- "default and it is right most of the time: name the trade-off and stop. Earn more than that " +
486
- "and spend it on a shape they can SCAN — a short list, a table, a code block, the diff the " +
487
- "option would produce, the exact message that would be posted. Never on a RUN OF " +
488
- "ONE-SENTENCE PARAGRAPHS: four single sentences stacked with blank lines between them is the " +
489
- "shape that keeps arriving, and it is the least readable one in a card this narrow. An " +
490
- "option with no trade-off makes the human reconstruct your reasoning before they can choose; " +
491
- "an option with four paragraphs makes them read an essay to answer one question.",
492
- },
493
- recommended: {
494
- type: "boolean",
495
- description:
496
- "Mark the ONE option you would take, and put it first. At most one per pick-one question — a " +
497
- "recommendation on two of three choices says nothing. ON A `multi`, MARK EVERY OPTION YOU " +
498
- "WOULD TICK, or none: the human ticks several there, so one mark alone reads as \"pick only " +
499
- "this\". IF YOU CAN MARK ONE, ASK YOURSELF WHY " +
500
- "YOU ARE ASKING: you already know the answer, so implement it and say which way you went. " +
501
- "This is for the fork you genuinely cannot take yourself.",
502
- },
503
- // `preview` (markdown revealed under the option once picked) is RETIRED from this schema
504
- // (2026-09-01): detail that decides a choice must be visible before the choice, so it belongs in
505
- // a rich `description` now. The server still ACCEPTS the field — an in-flight worker dispatched
506
- // against the old schema keeps working, and the card folds it into the same always-visible body.
507
- ...(depth < ASK_MAX_DEPTH
508
- ? {
509
- // No `maxItems` here either (four until 2026-09-03): the tree is bounded by its DEPTH, not
510
- // by how many branches hang off one option.
511
- followUps: {
512
- type: "array",
513
- description:
514
- "Questions that become live ONLY if the human picks this option — the conditional " +
515
- "branch. A branch nobody takes is never asked and never answered, so this is how you " +
516
- "ask \"and if so, which?\" without asking it of somebody who said no. A `multi` " +
517
- "question cannot carry these (several picked options would open several branches at " +
518
- "once) and neither can a free-text one (there is no answer to branch on).",
519
- items: questionSchema(depth + 1),
520
- },
521
- }
522
- : {}),
523
- },
524
- required: ["label"],
525
- }
526
- return {
527
- type: "object",
528
- properties: {
529
- question: {
530
- type: "string",
531
- description:
532
- "THE QUESTION, on one line, in the human's own vocabulary. They have their original prompt " +
533
- "and nothing else — not your plan, not your notes, not the names you coined while working. " +
534
- "Lead with the behaviour, not the identifier. NO \"I\" AND NO \"you\": clicking an option is " +
535
- "the HUMAN speaking, so first and second person flip between writer and reader. Name the " +
536
- "actor outright instead.",
537
- },
538
- header: { type: "string", description: "A very short chip label for the card, 12 characters or so — \"Auth method\", \"Storage\"." },
539
- kind: {
540
- type: "string",
541
- enum: ["question", "multi"],
542
- description:
543
- "`question` = pick ONE. `multi` = pick SEVERAL, for choices that are not mutually exclusive. " +
544
- "A question with NO options at all is a free-text box, which is the right shape when you need " +
545
- "a name, a value or a sentence rather than a decision between things you have enumerated.",
546
- },
547
- danger: {
548
- type: "boolean",
549
- description:
550
- "The DESTRUCTIVE gate, and nothing softer: a force-push, a deletion, a history rewrite, a " +
551
- "production rollback. It changes two things — the card wears the risk tone, and the human's " +
552
- "x cannot dismiss it, because a generic close icon is not consent for something irreversible. " +
553
- "Declining must therefore be one of your own options.",
554
- },
555
- // No `maxItems`: the count is the worker's to choose (maintainer 2026-09-03 — "allow arbitrary
556
- // numbers of options"). A `multi` over a long list is a real shape; the card numbers every row (1–9 are its keys).
557
- options: {
558
- type: "array",
559
- description:
560
- "As many as the choice actually has — a fork of two, or a `multi` over twenty findings. " +
561
- "Omit entirely for a free-text question.",
562
- items: option,
563
- },
564
- },
565
- required: ["question", "kind"],
566
- }
567
- }
466
+ // ---- `ask` / `secret`: RETIRED, and `unask` -------------------------------------------------------------
467
+ //
468
+ // A QUESTION IS A ```question FENCE IN THE WORKER'S OWN MESSAGE (2026-10-13). The server reads every
469
+ // assistant message, registers each new `id:` as a `thread_question` row, and reads an `id:`-only fence as
470
+ // a reference to one that exists (packages/shared/src/question-fence.ts). `ask` worked — about 4 failures
471
+ // in 220 calls — but 61% of those calls were the last tool call before the rest: a whole request spent
472
+ // re-reading the context to register, and then a handoff that copied the minted id back into a placement
473
+ // marker. See plans/rest-by-registration.md.
474
+ //
475
+ // BOTH COMMANDS STAY, AND REFUSE. A worker whose contract predates the fence still reaches for them, and
476
+ // so does one that read `frizzctl ask` in a project's own docs; refusing at that moment, with the fence in
477
+ // the refusal, teaches better than a command that is simply gone ("no such command" teaches nothing). The
478
+ // refusal makes no RPC: the `ask` RPC itself stays for the server's own registrar, its seeds and its tests.
479
+ // Neither is listed by `frizzctl --help`, and each MCP description is one line.
480
+
481
+ /** What a refused `ask` prints, and what `frizzctl ask --help` prints: the fence, whole enough to write one
482
+ * without the contract that taught it, because the worker reading it may have a contract that never did. */
483
+ const ASK_RETIRED =
484
+ "NOT registered — `frizzctl ask` is retired (2026-10-13), and nothing was sent. Ask the human by writing " +
485
+ "a ```question fence in your own message instead: Frizz registers it when the message lands and draws " +
486
+ "its card exactly where you wrote it.\n\n" +
487
+ "```question\n" +
488
+ "id: settings-store\n" +
489
+ "kind: choice\n" +
490
+ "question: Should the settings store use SQLite or a JSON file?\n" +
491
+ "options:\n" +
492
+ " - label: SQLite\n" +
493
+ " description: transactional, and one store to back up\n" +
494
+ " recommended: true\n" +
495
+ " - label: JSON file\n" +
496
+ " description: no dependency, hand-editable\n" +
497
+ "---\n" +
498
+ "Optional context, in any Markdown: a table, a diff, a callout, a <details> section.\n" +
499
+ "```\n\n" +
500
+ "- `id:` is yours to choose: a lowercase slug, unique in this thread for good.\n" +
501
+ "- `kind:` is `choice` (pick one), `multi` (pick several), `text` (free text) or `secret` (one masked " +
502
+ "box, for a token or a one-time code).\n" +
503
+ "- `question:` is one line. Each option takes `label:`, `description:` (one line), `recommended: true` " +
504
+ "and `detail: |` (an indented Markdown block). The question may also carry `danger: true`.\n" +
505
+ "- Every value is read verbatim to the end of its line. A code block inside the fence needs a longer " +
506
+ "outer fence (````question).\n" +
507
+ "- Several questions are several fences in one message. A follow-up is its own fence in the same message " +
508
+ "with `when: <parent id> = <option label>`.\n" +
509
+ "- At a later rest that still needs an answer, write a fence carrying that question's `id:` alone. " +
510
+ "Withdraw one you no longer need with `frizzctl unask <id>`.\n\n" +
511
+ "The question is your rest's sign-off and it does not end your turn: keep doing what does not depend " +
512
+ "on the answer, then rest normally. A fence Frizz cannot register comes back to you as a correction " +
513
+ "naming the fault."
514
+
515
+ const SECRET_RETIRED =
516
+ "NOT registered — `frizzctl secret` is retired (2026-10-13), and nothing was sent. Ask for a value only " +
517
+ "the human holds with a `kind: secret` question fence in your own message:\n\n" +
518
+ "```question\n" +
519
+ "id: npm-otp\n" +
520
+ "kind: secret\n" +
521
+ "question: The npm one-time code for the maintainer account, to publish frizz-server\n" +
522
+ "```\n\n" +
523
+ "The card is one masked box, and what the human pastes never enters the transcript or your context. The " +
524
+ "answer's wake gives you the path of a private pipe that reads ONCE, within 15m: read it inside the " +
525
+ "command that uses it — `npm publish --otp \"$(cat '<path>')\"` — and never cat, echo or copy it on its " +
526
+ "own. A one-time code expires within a minute, so have the command ready before you rest. " +
527
+ "`frizzctl ask --help` shows the rest of the question fence."
568
528
 
569
529
  const ASK = {
570
530
  name: "ask",
571
- description:
572
- "ASK THE HUMAN SOMETHING YOU CANNOT DECIDE, as a ROW they still owe an answer to. THIS IS THE ONLY " +
573
- "WAY TO ASK: the free-form ```question fence is retired (2026-09-11), and a fence with a question " +
574
- "written in its body is plain prose — no card, no answer, no sign-off. The row renders as an " +
575
- "answerable card on the board and in the thread, and it STAYS there: it survives your turn ending, " +
576
- "a compaction, a restart, and the transcript scrolling past. A fence has the lifetime of the " +
577
- "message carrying it, which is why a question written into one was unanswerable an hour later.\n\n" +
578
- "YOUR DEFAULT IS TO DECIDE, AND THIS COMMAND DOES NOT CHANGE THAT. A reversible call costs minutes to " +
579
- "redo; a round-trip to the human costs hours with the whole effort idle. Anything derivable from " +
580
- "the code, the conventions or ordinary engineering judgement is yours: make it, say which way you " +
581
- "went, and keep moving. THE TEST THAT CATCHES ALMOST EVERY BAD QUESTION: if you are about to mark " +
582
- "one option `recommended`, you already know the answer — so implement it instead of asking.\n\n" +
583
- "ASK WHEN A WRONG GUESS WOULD BE BOTH COSTLY AND HARD TO UNDO — something destructive or " +
584
- "irreversible, an external-facing commitment, a security posture with real exposure, product or UX " +
585
- "direction that is genuinely the human's taste to set. And ask when you KNOW the answer but cannot " +
586
- "ACT on it: a merge, a publish, a spend, a comment that goes out under their name. Then the " +
587
- "recommendation is the point, and it goes first.\n\n" +
588
- "ASKING DOES NOT END YOUR TURN. A question waits on a person, so it carries no timeout and expires " +
589
- "never — but you keep working. Do everything that does NOT depend on the answer first, and register " +
590
- "the question at the moment you find it rather than saving it for the end.\n\n" +
591
- "AND WHEN YOU DO STOP, THE QUESTION IS THAT REST'S SIGN-OFF — rest normally. Frizz draws the card " +
592
- "at the rest that asked it whether you mention it or not, so nothing you write can hide one. The " +
593
- "card draws itself at the tail of the rest the question was asked — never write the question into " +
594
- "your handoff (one question, one card). AT EVERY LATER REST it is no longer your sign-off and frizz " +
595
- "does not redraw it under your newer handoff: name it under `questions:` in an ```awaiting fence " +
596
- "while you still need the answer (its card is drawn there), or withdraw it with `frizzctl unask <id>`. TO PLACE IT " +
597
- "INSIDE YOUR PROSE instead, write " +
598
- "an EMPTY fence naming the id this command printed — ```question qst_ab12cd34 on one line, ``` on " +
599
- "the next — and the card renders there. One marker per question; nothing in the body; placement is " +
600
- "optional, and every answer of the rest still sends together.\n\n" +
601
- "SEVERAL AT ONCE IS ONE COMMAND. The card sends every answer as a unit, so a second `frizzctl ask` for a second " +
602
- "question just makes the human send twice. Register them together.\n\n" +
603
- "The answer comes back to you as its own wake, restating what was asked. Withdraw one you no longer " +
604
- "need with `frizzctl unask <id>` — a question you have since answered yourself, still sitting on the human's " +
605
- "board, is worse than never having asked it.\n\n" +
606
- "ON AN AUTONOMOUS THREAD THIS REFUSES, and tells you the standing instruction you are working " +
607
- "under. A thread carrying a rest Goal has already been told to keep going and decide for itself, " +
608
- "so the refusal is that instruction arriving at the moment it matters. Decide, and say which way " +
609
- "you went in your write-up. If the call is genuinely the human's — destructive or irreversible — " +
610
- "put it in your FINAL MESSAGE instead of here; autonomous does not mean nobody is reading.\n\n" +
611
- "AN ACT IS NOT A QUESTION. When the human must PERFORM something you cannot — sign in, approve, " +
612
- "merge, press a button you may not — do not ask whether they will: list the steps under `steps:` " +
613
- "in your ```awaiting fence. That works on an autonomous thread too, and their reply wakes you.",
614
- inputSchema: {
615
- type: "object",
616
- properties: {
617
- // No `maxItems` (four until 2026-09-03): "several at once is one command" above, and a cap here told
618
- // a worker with six to batch them and then refused the batch.
619
- questions: {
620
- type: "array",
621
- minItems: 1,
622
- description: "The questions to register, together. Each becomes its own card and its own row.",
623
- items: questionSchema(1),
624
- },
625
- },
626
- required: ["questions"],
627
- },
531
+ description: "RETIRED (2026-10-13): ask the human by writing a ```question fence in your own message. This only refuses, and the refusal shows the fence.",
532
+ inputSchema: { type: "object", properties: {} },
628
533
  }
629
534
 
630
535
  const UNASK = {
631
536
  name: "unask",
632
537
  description:
633
- "WITHDRAW A QUESTION you registered with `frizzctl ask`, by its id. Its card disappears and the human is " +
634
- "never asked.\n\n" +
538
+ "WITHDRAW A QUESTION you asked, by its id — the `id:` you wrote in its ```question fence. Its card " +
539
+ "disappears and the human is never asked.\n\n" +
635
540
  "Use it the moment the question stops mattering: you worked out the answer yourself, the code moved " +
636
541
  "and the fork is gone, or you are about to finish. A stale question on someone's board is worse " +
637
- "than no question — they answer it, and the answer is about a decision that no longer exists.\n\n" +
638
- "You do NOT need this for a question that gets answered; that settles itself and wakes you. " +
639
- "Withdrawing is YOUR move and is never reported back to you as news.",
542
+ "than no question — they answer it, and the answer is about a decision that no longer exists. An " +
543
+ "open question also blocks `frizzctl done` until it is answered or withdrawn.\n\n" +
544
+ "You do NOT need this for a question that gets answered; that settles itself and wakes you. Nor for " +
545
+ "one a later rest simply does not mention: an older question stays answerable where it was asked. " +
546
+ "Withdrawing is YOUR move and is never reported back to you as news. A withdrawn id is never asked " +
547
+ "again — a new question takes a new id.",
640
548
  inputSchema: {
641
549
  type: "object",
642
550
  properties: {
643
- id: { type: "string", description: "The question id `frizzctl ask` printed, or that `frizzctl activity` lists. Only your own thread's." },
551
+ id: {
552
+ type: "string",
553
+ description:
554
+ "The question's id: the `id:` of its ```question fence, as `frizzctl activity` lists it — or a " +
555
+ "`qst_…` id an older `frizzctl ask` printed. Only your own thread's.",
556
+ },
644
557
  },
645
558
  required: ["id"],
646
559
  },
@@ -648,39 +561,8 @@ const UNASK = {
648
561
 
649
562
  const SECRET = {
650
563
  name: "secret",
651
- description:
652
- "ASK THE HUMAN FOR A SECRET VALUE — a one-time 2FA code, an API token, a password — that a command " +
653
- "needs and you must never see. The human may be on a phone, far from this machine: this is how a " +
654
- "code reaches a command without anyone typing into a terminal here.\n\n" +
655
- "It registers a question like `frizzctl ask`, drawn as a card with ONE MASKED BOX. What the human pastes is " +
656
- "held in Frizz's memory — never on disk — and served through a private named pipe whose path this " +
657
- "command prints NOW. It never enters the transcript, the database or your context: the answer you are " +
658
- "woken with names the path, not the value.\n\n" +
659
- "THE PATH CAN BE READ ONCE, within 15 minutes of the answer; then it is gone. So read it INSIDE the " +
660
- "command that uses it: `npm publish --otp \"$(cat '<path>')\"`, `<cmd> < '<path>'`. A value several " +
661
- "commands need goes into a variable at the start of ONE Bash call: " +
662
- "`T=\"$(cat '<path>')\"; cmd1 --token \"$T\"; cmd2 --token \"$T\"`. Never `cat` it bare, echo it, " +
663
- "copy it into a file, or paste it into a message — any of those spends the one read and puts the " +
664
- "value in the transcript. To discard an unread value, `rm` the path.\n\n" +
665
- "A one-time code expires in about thirty seconds, so have the command ready BEFORE you rest: when the " +
666
- "answer wakes you, run it at once.\n\n" +
667
- "Like a question, it is your rest's sign-off and it gates `done` until answered; name it under " +
668
- "`questions:` at later rests, withdraw it with `frizzctl unask <id>`. It is allowed on an autonomous thread — a " +
669
- "credential is not something you can decide.",
670
- inputSchema: {
671
- type: "object",
672
- properties: {
673
- question: {
674
- type: "string",
675
- description:
676
- "What to paste, said so the human can answer cold: which account or service, which kind of " +
677
- "value, and what it unlocks — \"The npm one-time code for the maintainer account, to publish " +
678
- "frizz 0.16.0.\" NO \"I\" AND NO \"you\": name the actor outright.",
679
- },
680
- header: { type: "string", description: "A very short chip label for the card, 12 characters or so — \"npm OTP\", \"API token\"." },
681
- },
682
- required: ["question"],
683
- },
564
+ description: "RETIRED (2026-10-13): ask for a value only the human holds with a `kind: secret` ```question fence in your own message. This only refuses, and the refusal shows the fence.",
565
+ inputSchema: { type: "object", properties: {} },
684
566
  }
685
567
 
686
568
  // STEER (2026-10-09): one thread messaging ANOTHER thread of the same project. Claude Code's own
@@ -754,7 +636,8 @@ const DONE = {
754
636
  "plan, doc or commissioned report written INTO A FILE. An open pull request is not done — the " +
755
637
  "merge is. An investigation headed for a fix is not done — the fix is. And a verdict that ends in " +
756
638
  "SOMEBODY SHOULD NOW DO SOMETHING (merge it, post this, pick one of these) is not done either: " +
757
- "that is a `frizzctl ask`, carrying your recommendation as the first option.\n\n" +
639
+ "that is a question — a ```question fence in your message — carrying your recommendation as the " +
640
+ "first option.\n\n" +
758
641
  "THE TEST IS NEVER \"HAVE I STOPPED WORKING\". It is: WHAT IS LOST IF NOBODY EVER OPENS THIS " +
759
642
  "THREAD AGAIN? Name one thing and you are not done. Uncertain is not done.",
760
643
  inputSchema: {
@@ -770,6 +653,14 @@ const DONE = {
770
653
  "sentence that would read the same in both places belongs in exactly one of them. Nothing " +
771
654
  "here may point vaguely forward — no \"a follow-up could…\". Do it, ask about it, or drop it.",
772
655
  },
656
+ title: {
657
+ type: "string",
658
+ description:
659
+ "OPTIONAL. The card's heading: a short phrase in sentence case naming what LANDED (\"Resolver " +
660
+ "cache keyed on the normalized id\"), not the handoff. It wraps, so name the work fully; past " +
661
+ "120 characters it is trimmed on a word boundary. Without it the card is headed \"Done\", which " +
662
+ "is true of every completion and specific to none. It wins over a `title:` line the body opens with.",
663
+ },
773
664
  },
774
665
  required: ["body"],
775
666
  },
@@ -824,12 +715,11 @@ const ACTIVITY = {
824
715
  "one of them, every QUESTION still owed an answer, and saved links/files with their lnk_ ids.\n\n" +
825
716
  "WHY YOU NEED IT: an ```awaiting fence names what you are waiting on BY ID, and frizz checks every " +
826
717
  "one against what is actually live. A name that matches nothing is not a park — you are bumped and " +
827
- "your thread queues. The same goes for the ids `unwatch` and `unask` take, and for the id you put in " +
828
- "an EMPTY ```question fence to PLACE a registered question in your handoff (a marker naming no open " +
829
- "question of yours draws nothing). So if you have lost one (a " +
830
- "compaction, a long turn, a wake you did not expect), run this rather than guessing. Guessing is " +
831
- "the failure this command exists to remove — and it is the only way to read your open questions " +
832
- "WITHOUT registering or withdrawing one.\n\n" +
718
+ "your thread queues. The same goes for the ids `unwatch` and `unask` take, and for the `id:` a " +
719
+ "```question fence references when a later rest still needs that question's answer. So if you have " +
720
+ "lost one (a compaction, a long turn, a wake you did not expect), run this rather than guessing. " +
721
+ "Guessing is the failure this command exists to remove — and it is how you read back your open " +
722
+ "questions, by the id you gave each one.\n\n" +
833
723
  "It takes nothing and changes nothing. You can only ever read your OWN thread.",
834
724
  inputSchema: { type: "object", properties: {}, required: [] },
835
725
  }
@@ -1029,24 +919,24 @@ async function activity() {
1029
919
  const linksBlock = links.length === 0 ? "" : "\n\nSaved links and files (not running work; remove with `frizzctl unlink <id>`):\n" +
1030
920
  links.map((link) => ` ${link.id} ${link.kind}: ${link.label}\n ${link.target}`).join("\n")
1031
921
  // THE QUESTIONS GET THEIR OWN SECTION, because they are not running work: a question waits on a
1032
- // person. Since 2026-10-05 a fence names the ones still needed under `questions:` (and must name every
1033
- // open one), so the ready-to-paste fence below carries them too.
922
+ // person. Each is listed by the worker's own id (its fence's `id:`), the one `unask` takes and a
923
+ // reference fence names; a `qst_…` row `frizzctl ask` registered has no other. Since 2026-10-13 no
924
+ // fence has to name them — `questions:` is still read for older contracts, and taught nowhere — so the
925
+ // ready-to-paste fence below no longer carries them.
926
+ const questionId = (q) => String(q?.handle ?? q?.spec?.handle ?? q?.id ?? "")
1034
927
  const askedBlock = questions.length === 0 ? "" : (
1035
928
  `\n\n${questions.length} question${questions.length === 1 ? "" : "s"} still owed an answer:\n\n` +
1036
- questions.map((q) => ` question: ${q.id}\n ${String(q?.spec?.question ?? "").replace(/\s+/g, " ").slice(0, 160)}`).join("\n") +
1037
- "\n\nEach one blocks `done` until it is answered or withdrawn, and draws its own card at the rest " +
1038
- "it was asked — never write it into a handoff; an EMPTY ```question fence naming its id places it " +
1039
- "inside your prose. `frizzctl unask <id>` the ones since decided. Any ```awaiting fence names EVERY open one " +
1040
- "under `questions:` or is refused, and at a rest after the one that asked, a question you neither " +
1041
- "name nor withdraw gets you bumped. In a fence: `questions: [" +
1042
- questions.map((q) => q.id).join(", ") + "]`"
929
+ questions.map((q) => ` question: ${questionId(q)}\n ${String(q?.spec?.question ?? "").replace(/\s+/g, " ").slice(0, 160)}`).join("\n") +
930
+ "\n\nEach one blocks `done` until it is answered or withdrawn — `frizzctl unask <id>` the ones since " +
931
+ "decided. Its card stays where you asked it, and a later rest owes it nothing; a rest that still needs " +
932
+ "the answer writes a ```question fence carrying its `id:` alone, which draws the card there."
1043
933
  )
1044
934
  if (!items.length) {
1045
935
  if (questions.length > 0) {
1046
936
  return (
1047
937
  "Nothing is RUNNING on this thread — no background shells, no sub-agents, no armed timers, no " +
1048
- "registered PRs. An ```awaiting fence can still wait on your open questions alone — " +
1049
- "`questions:` names the human as the wait, so it needs no other name and no `for:`." +
938
+ "registered PRs. Your open questions are not running work: they need no ```awaiting fence, and a " +
939
+ "rest that still needs an answer references its question instead." +
1050
940
  askedBlock + linksBlock
1051
941
  )
1052
942
  }
@@ -1054,7 +944,7 @@ async function activity() {
1054
944
  "Nothing is running on this thread — no background shells, no sub-agents, no armed timers, no " +
1055
945
  "registered PRs, and no open questions.\n\nSo there is nothing to wait on: an ```awaiting fence " +
1056
946
  "would have nothing to name, and a fence naming nothing is not a park. End with ```done, or " +
1057
- "register a question with `frizzctl ask` if you need the human." + linksBlock
947
+ "ask a ```question if you need the human." + linksBlock
1058
948
  )
1059
949
  }
1060
950
  const lines = items.map((i) => {
@@ -1073,13 +963,9 @@ async function activity() {
1073
963
  const block = Object.entries({ shells: byKind.shell, agents: byKind.agent, timers: byKind.timer, prs: byKind.pr, issues: byKind.issue, commands: byKind.command })
1074
964
  .filter(([, ids]) => ids.length > 0)
1075
965
  .map(([key, ids]) => ` ${key}: [${ids.join(", ")}]`)
1076
- // Every open question rides the fence too: a fence that leaves one out is refused, and a fence on
1077
- // questions always queues, so its `status:` answer is `needs_input` whatever else it names. Otherwise
1078
- // the template guesses from the kinds — a shell or a sub-agent is usually work that finishes by itself,
1079
- // a PR, an issue or a timer a watch — and the prose tells the worker to correct it.
1080
- const questionIds = questions.map((q) => q.id).filter(Boolean)
1081
- if (questionIds.length > 0) block.push(` questions: [${questionIds.join(", ")}]`)
1082
- const status = questionIds.length > 0 ? "needs_input" : byKind.shell.length + byKind.agent.length > 0 ? "working" : "watching"
966
+ // The template guesses `status:` from the kinds — a shell or a sub-agent is usually work that finishes
967
+ // by itself, a PR, an issue or a timer a watch — and the prose tells the worker to correct it.
968
+ const status = byKind.shell.length + byKind.agent.length > 0 ? "working" : "watching"
1083
969
  return (
1084
970
  `${items.length} thing${items.length === 1 ? "" : "s"} running on this thread:\n\n${lines.join("\n")}\n\n` +
1085
971
  "Name the ones you are ACTUALLY waiting on in your ```awaiting fence. The frontmatter is YAML — one " +
@@ -1781,58 +1667,20 @@ async function unwatch(args) {
1781
1667
  function openQuestionList(result) {
1782
1668
  const open = Array.isArray(result?.open) ? result.open : []
1783
1669
  if (!open.length) return "Nothing else is open on this thread — the human owes you no answer."
1784
- const lines = open.map((q) => ` ${q.id} ${(q.spec?.question ?? "").split("\n")[0]}`)
1670
+ const lines = open.map((q) => ` ${q.handle ?? q.spec?.handle ?? q.id} ${(q.spec?.question ?? "").split("\n")[0]}`)
1785
1671
  return `Open on this thread now:\n${lines.join("\n")}`
1786
1672
  }
1787
1673
 
1788
- /** The `ask` handler: register one or more questions the human owes an answer to.
1789
- * @param {Record<string, unknown>} args @returns {Promise<string>} */
1790
- async function ask(args) {
1791
- const slug = threadSlug()
1792
- const questions = Array.isArray(args.questions) ? args.questions : []
1793
- if (!questions.length) throw new Error("`questions` is required — at least one question to register")
1794
- const result = (await callRpc("ask", { slug, questions }))?.result
1795
- const registered = Array.isArray(result?.registered) ? result.registered : []
1796
- const lines = registered.map((q) => ` ${q.id} ${(q.spec?.question ?? "").split("\n")[0]}`)
1797
- const head = registered.length === 1
1798
- ? `Registered 1 question. It is on the human's board now and it will stay there until they answer it.`
1799
- : `Registered ${registered.length} questions. They are on the human's board now and they will stay ` +
1800
- "there until answered — the card sends every answer as one batch."
1801
- return (
1802
- `${head}\n${lines.join("\n")}\n\n` +
1803
- "KEEP WORKING. A question waits on a person, carries no timeout and does not end your turn — do " +
1804
- "everything that does not depend on the answer while it sits there. The answer arrives as its own " +
1805
- "wake, restating what was asked.\n\n" +
1806
- "WITHDRAW ONE THE MOMENT IT STOPS MATTERING (`frizzctl unask <id>`), above all if you work the answer out " +
1807
- `yourself.\n\n${openQuestionList(result)}`
1808
- )
1674
+ /** The `ask` handler: RETIRED (2026-10-13). It refuses without calling Frizz, and the refusal is the fence.
1675
+ * @returns {Promise<Refused>} */
1676
+ async function ask() {
1677
+ return new Refused(ASK_RETIRED)
1809
1678
  }
1810
1679
 
1811
- /** The `secret` handler: register ONE masked question, and hand back the pipe its value will be read from.
1812
- * It rides the `ask` RPC with `secret: true` — the card, the wake, the `done` gate and the `questions:`
1813
- * naming are all a question's — and the server is what keeps the value out of every one of them.
1814
- * @param {Record<string, unknown>} args @returns {Promise<string>} */
1815
- async function secret(args) {
1816
- const slug = threadSlug()
1817
- const question = typeof args.question === "string" ? args.question.trim() : ""
1818
- if (!question) throw new Error("`question` is required — say what the human should paste, and for what")
1819
- const header = typeof args.header === "string" && args.header.trim() ? args.header.trim() : undefined
1820
- const result = (await callRpc("ask", { slug, questions: [{ question, kind: "question", secret: true, ...(header ? { header } : {}) }] }))?.result
1821
- const registered = Array.isArray(result?.registered) ? result.registered[0] : undefined
1822
- const path = typeof registered?.secretPath === "string" ? registered.secretPath : ""
1823
- if (!registered?.id || !path) throw new Error("Frizz did not return the secret's file — this server predates `secret`; restart Frizz and retry")
1824
- // Quoted, because the macOS state dir is `~/Library/Application Support/…` and an unquoted path
1825
- // splits at the space.
1826
- const quoted = `'${path.replace(/'/g, `'\\''`)}'`
1827
- return (
1828
- `Registered secret request ${registered.id}. The human sees a masked box on the board.\n\n` +
1829
- `Once answered, the value can be read ONCE, within 15 minutes, at:\n ${path}\n\n` +
1830
- `Read it INSIDE the command that uses it — \`--otp "$(cat ${quoted})"\` — and never cat, echo or copy ` +
1831
- "it on its own: that spends the one read. Several commands share it through a variable set at the " +
1832
- "start of ONE Bash call. Get the command ready now: the answer wakes you, and a one-time code expires " +
1833
- "within a minute.\n\n" +
1834
- `${openQuestionList(result)}`
1835
- )
1680
+ /** The `secret` handler: RETIRED with `ask` — a `kind: secret` question fence is how a value is asked for.
1681
+ * @returns {Promise<Refused>} */
1682
+ async function secret() {
1683
+ return new Refused(SECRET_RETIRED)
1836
1684
  }
1837
1685
 
1838
1686
  /** The `unask` handler: withdraw one registered question by id.
@@ -1840,23 +1688,86 @@ async function secret(args) {
1840
1688
  async function unask(args) {
1841
1689
  const slug = threadSlug()
1842
1690
  const id = typeof args.id === "string" ? args.id.trim() : ""
1843
- if (!id) throw new Error("`id` is required — take it from `frizzctl ask` or `frizzctl activity`")
1691
+ if (!id) throw new Error("`id` is required — the `id:` of the question's fence, or what `frizzctl activity` lists")
1844
1692
  const result = (await callRpc("unask", { slug, id }))?.result
1845
1693
  // A withdrawal that matched nothing is reported rather than swallowed: the id was wrong, the human
1846
1694
  // already answered it, or it is another thread's — and a worker that believes it withdrew a question
1847
1695
  // the human is still looking at will get an answer it has stopped expecting.
1696
+ //
1697
+ // A FOLLOW-UP IS NOT WITHDRAWN ON ITS OWN. It rides inside its parent's row, so the server answers
1698
+ // `unask sqlite-path` with the `settings-store` it follows and withdraws nothing: taking the parent
1699
+ // back for an id the worker did not type cost it a question it may still have wanted answered.
1700
+ const root = typeof result?.id === "string" && result.id ? result.id : id
1701
+ const viaFollowUp = root.toLowerCase() !== id.toLowerCase()
1702
+ const settled = {
1703
+ answered: `Question ${root} was already answered, so nothing was withdrawn — the answer reaches you as its own message.`,
1704
+ dismissed: `Question ${root} was already dismissed by the human, so nothing was withdrawn — decide it yourself.`,
1705
+ withdrawn: `Question ${root} was already withdrawn.`,
1706
+ }
1848
1707
  const head = result?.withdrawn
1849
- ? `Question ${id} withdrawn. Its card is gone and the human will not be asked.`
1850
- : `No OPEN question ${id} on this thread — it was already answered or dismissed, or the id is not one of yours.`
1708
+ ? `Question ${root} withdrawn. Its card is gone and the human will not be asked.`
1709
+ : viaFollowUp && result?.state === "open"
1710
+ ? `Nothing was withdrawn: ${id} is a follow-up of ${root}, and a follow-up cannot be withdrawn on its own. ` +
1711
+ `The human is only asked it after picking the option it hangs on. To take the whole question back, ` +
1712
+ `follow-ups and all, run \`frizzctl unask ${root}\`.`
1713
+ : settled[result?.state] ??
1714
+ `No OPEN question ${id} on this thread — it was already answered or dismissed, or the id is not one of yours.`
1851
1715
  return `${head}\n\n${openQuestionList(result)}`
1852
1716
  }
1853
1717
 
1718
+ /** A body's own leading `title:` block — the key line and any indented lines under it, then a `---`
1719
+ * if one follows — cut off, so `--title` can replace it rather than stack a second heading above it.
1720
+ * The SAME rule as `splitDoneFrontmatter` in @frizz/shared, which this plain .mjs cannot import (it
1721
+ * ships loose beside the server with no build step): only a FIRST non-blank line counts, and a body
1722
+ * that does not open on one comes back untouched.
1723
+ * @param {string} body @returns {string} */
1724
+ function withoutLeadingTitle(body) {
1725
+ const lines = body.split("\n").map((l) => l.replace(/\r$/, ""))
1726
+ let i = 0
1727
+ while (i < lines.length && lines[i].trim() === "") i++
1728
+ if (i >= lines.length || !/^title:/i.test(lines[i])) return body
1729
+ i++
1730
+ while (i < lines.length && /^\s+\S/.test(lines[i])) i++
1731
+ let next = i
1732
+ while (next < lines.length && lines[next].trim() === "") next++
1733
+ if (next < lines.length && /^\s*---+\s*$/.test(lines[next])) i = next + 1
1734
+ return lines.slice(i).join("\n").trim()
1735
+ }
1736
+
1737
+ /**
1738
+ * What an open question does to a sign-off as done: how many are open, each by the worker's own id with
1739
+ * its first line, and the two ways out.
1740
+ *
1741
+ * A COPY, AND IT MUST STAY ONE. The same paragraph is sent by the server when a worker signs off with a
1742
+ * ```done FENCE over an open question (scheduler SOURCE 15), built by the function of this name in
1743
+ * `packages/shared/src/index.ts`. This file imports nothing but node, because it ships to a worker as
1744
+ * plain .mjs, so the words live in both places; `packages/server/src/question-done.test.ts` fails when
1745
+ * the two outputs differ. Change one, change the other.
1746
+ * @param {{ id: string, question?: string }[]} questions @returns {string} */
1747
+ function doneOpenQuestionsRefusal(questions) {
1748
+ const lines = questions.map((q) => ` ${q.id} ${(q.question ?? "").split("\n")[0]}`)
1749
+ return (
1750
+ `${lines.length} question${lines.length === 1 ? "" : "s"} the human has not answered:\n${lines.join("\n")}\n` +
1751
+ "Each one dies unread with a done card. Decide it yourself and withdraw it (`frizzctl unask <id>`), or leave it " +
1752
+ "open and keep working until it is answered."
1753
+ )
1754
+ }
1755
+
1854
1756
  /** The `done` handler: declare the effort finished, or report exactly what refuses to let it.
1757
+ *
1758
+ * A `--title` is sent INSIDE the body, in the ```done fence's own grammar (`title: …`, `---`, then the
1759
+ * ledger), rather than as a field of its own: the server stores one body and splits it with the same
1760
+ * function that splits a fence, so a registered done and a fenced one can never head their cards two
1761
+ * different ways (2026-10-13).
1855
1762
  * @param {Record<string, unknown>} args @returns {Promise<string>} */
1856
1763
  async function done(args) {
1857
1764
  const slug = threadSlug()
1858
- const body = typeof args.body === "string" ? args.body.trim() : ""
1765
+ let body = typeof args.body === "string" ? args.body.trim() : ""
1859
1766
  if (!body) throw new Error("`body` is required — the write-up the human reads on the card")
1767
+ // One line, because a heading is one: a title with a newline in it would end the `title:` line and
1768
+ // spill the rest into the ledger.
1769
+ const title = typeof args.title === "string" ? args.title.replace(/\s+/g, " ").trim() : ""
1770
+ if (title) body = `title: ${title}\n---\n${withoutLeadingTitle(body)}`
1860
1771
  const result = (await callRpc("markOwnDone", { slug, body }))?.result
1861
1772
  if (result?.done) {
1862
1773
  return (
@@ -1867,16 +1778,10 @@ async function done(args) {
1867
1778
  }
1868
1779
  // REFUSED, with everything that refuses it named by id, so the next move is a command and not a
1869
1780
  // guess. Reported as an ordinary result rather than thrown: this is a gate doing its job, not a fault.
1870
- const questions = (result?.blockingQuestions ?? []).map((q) => ` ${q.id} ${(q.question ?? "").split("\n")[0]}`)
1781
+ const questions = (result?.blockingQuestions ?? []).map((q) => ({ id: q.handle ?? q.id, question: q.question }))
1871
1782
  const watches = (result?.blockingWatches ?? []).map((w) => ` ${w.id} ${w.what}`)
1872
1783
  const parts = ["NOT marked done. This thread still holds work open."]
1873
- if (questions.length) {
1874
- parts.push(
1875
- `${questions.length} question${questions.length === 1 ? "" : "s"} the human has not answered:\n${questions.join("\n")}\n` +
1876
- "Each one dies unread with a done card. Decide it yourself and withdraw it (`frizzctl unask <id>`), or leave it " +
1877
- "open and keep working until it is answered.",
1878
- )
1879
- }
1784
+ if (questions.length) parts.push(doneOpenQuestionsRefusal(questions))
1880
1785
  if (watches.length) {
1881
1786
  parts.push(
1882
1787
  `${watches.length} registration${watches.length === 1 ? "" : "s"} still armed:\n${watches.join("\n")}\n` +
@@ -2091,42 +1996,31 @@ async function watchCommand(args) {
2091
1996
  * Every argument is ALSO a `--flag`, and the whole set a JSON object through `--input`.
2092
1997
  * then — further positionals that depend on the first one's value, for the commands that
2093
1998
  * switch on an `action` (`frizzctl timer cancel tmr_ab12`).
2094
- * wrap — the one array argument a bare JSON array, or a single item, is taken to be
2095
- * (`ask` with one question object, or a list of them).
1999
+ * wrap — the one array argument a bare JSON array, or a single item, is taken to be.
2000
+ * retired — the text a retired command prints instead of running: its refusal, and its `--help`.
2096
2001
  * examples — real invocations, shown under the command's own help.
2097
2002
  *
2098
2003
  * `group` only orders the top-level listing.
2099
2004
  */
2100
2005
  const CLI = {
2006
+ // RETIRED (2026-10-13): a question is a ```question fence. `retired` is the text the command prints in
2007
+ // place of running — as its refusal (exit 2) and as its `--help` — and it keeps the command out of the
2008
+ // top-level listing.
2101
2009
  ask: {
2102
- group: "Sign off",
2103
- summary: "Ask the human a question; registers an answerable card",
2104
- wrap: "questions",
2105
- examples: [
2106
- "frizzctl ask --input - <<'JSON'\n" +
2107
- '{"questions": [{\n' +
2108
- ' "question": "Should the settings store use SQLite or a JSON file?",\n' +
2109
- ' "kind": "question",\n' +
2110
- ' "options": [\n' +
2111
- ' {"label": "SQLite", "description": "transactional, matches how sessions are stored", "recommended": true},\n' +
2112
- ' {"label": "JSON file", "description": "zero deps, human-editable, racy under concurrent writes"}\n' +
2113
- " ]\n" +
2114
- "}]}\n" +
2115
- "JSON",
2116
- "frizzctl ask --input questions.json",
2117
- ],
2010
+ summary: "Retired: a question is a ```question fence in your own message",
2011
+ retired: ASK_RETIRED,
2012
+ examples: [],
2118
2013
  },
2119
2014
  unask: {
2120
2015
  group: "Sign off",
2121
- summary: "Withdraw a question you asked",
2016
+ summary: "Withdraw a question you asked, by its id",
2122
2017
  positional: ["id"],
2123
- examples: ["frizzctl unask qst_ab12cd34"],
2018
+ examples: ["frizzctl unask settings-store"],
2124
2019
  },
2125
2020
  secret: {
2126
- group: "Sign off",
2127
- summary: "Ask the human for a value only they hold (a token, a 2FA code)",
2128
- positional: ["question"],
2129
- examples: ['frizzctl secret "The npm one-time code for publishing frizz 0.14.0" --header "npm OTP"'],
2021
+ summary: "Retired: a value only the human holds is a `kind: secret` question fence",
2022
+ retired: SECRET_RETIRED,
2023
+ examples: [],
2130
2024
  },
2131
2025
  done: {
2132
2026
  group: "Sign off",
@@ -2138,6 +2032,7 @@ const CLI = {
2138
2032
  "- **Added a regression test**; `npm test` green.\n" +
2139
2033
  "EOF",
2140
2034
  "frizzctl done --body-file handoff.md",
2035
+ "frizzctl done --title \"Resolver cache keyed on the normalized id\" --body-file handoff.md",
2141
2036
  ],
2142
2037
  },
2143
2038
  activity: {
@@ -2238,4 +2133,4 @@ const CLI = {
2238
2133
  },
2239
2134
  }
2240
2135
 
2241
- export { TOOLS, HANDLERS, CLI, Refused }
2136
+ export { TOOLS, HANDLERS, CLI, Refused, doneOpenQuestionsRefusal }