frayui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +186 -0
  3. package/dist/claude-agent-broker.js +21641 -0
  4. package/dist/codex-app-server-daemon.js +354 -0
  5. package/dist/dev-child.js +40319 -0
  6. package/dist/frayui.js +8738 -0
  7. package/package.json +46 -0
  8. package/runtime/cc/scripts/fray/agent-bindings.mjs +287 -0
  9. package/runtime/cc/scripts/fray/agent-liveness.mjs +367 -0
  10. package/runtime/cc/scripts/fray/agent-status.mjs +178 -0
  11. package/runtime/cc/scripts/fray/config.mjs +982 -0
  12. package/runtime/cc/scripts/fray/decisions.mjs +97 -0
  13. package/runtime/cc/scripts/fray/index.mjs +699 -0
  14. package/runtime/cc/scripts/fray/notify-shared.mjs +90 -0
  15. package/runtime/cc/scripts/fray/notify.mjs +81 -0
  16. package/runtime/cc/scripts/fray/ownership.mjs +120 -0
  17. package/runtime/cc/scripts/fray/rest-detect.mjs +213 -0
  18. package/runtime/cc/scripts/fray/thread-excerpt.mjs +162 -0
  19. package/runtime/cc/scripts/fray/thread-update.mjs +285 -0
  20. package/runtime/cc-worker/.claude-plugin/plugin.json +10 -0
  21. package/runtime/cc-worker/DECISIONS.md +923 -0
  22. package/runtime/cc-worker/LICENSE +21 -0
  23. package/runtime/cc-worker/agents/fable-high.md +8 -0
  24. package/runtime/cc-worker/agents/fable-low.md +8 -0
  25. package/runtime/cc-worker/agents/fable-max.md +8 -0
  26. package/runtime/cc-worker/agents/fable-medium.md +8 -0
  27. package/runtime/cc-worker/agents/fable-xhigh.md +8 -0
  28. package/runtime/cc-worker/agents/haiku.md +7 -0
  29. package/runtime/cc-worker/agents/opus-high.md +8 -0
  30. package/runtime/cc-worker/agents/opus-low.md +8 -0
  31. package/runtime/cc-worker/agents/opus-max.md +8 -0
  32. package/runtime/cc-worker/agents/opus-medium.md +8 -0
  33. package/runtime/cc-worker/agents/opus-xhigh.md +8 -0
  34. package/runtime/cc-worker/agents/sonnet-high.md +8 -0
  35. package/runtime/cc-worker/agents/sonnet-low.md +8 -0
  36. package/runtime/cc-worker/agents/sonnet-max.md +8 -0
  37. package/runtime/cc-worker/agents/sonnet-medium.md +8 -0
  38. package/runtime/cc-worker/agents/sonnet-xhigh.md +8 -0
  39. package/runtime/cc-worker/bin/fray +17 -0
  40. package/runtime/cc-worker/bin/fray-mcp.mjs +248 -0
  41. package/runtime/cc-worker/bin/fray-update +18 -0
  42. package/runtime/cc-worker/hooks/agent-bind.mjs +40 -0
  43. package/runtime/cc-worker/hooks/agent-dispatch.mjs +74 -0
  44. package/runtime/cc-worker/hooks/bash-background.d.mts +6 -0
  45. package/runtime/cc-worker/hooks/bash-background.mjs +188 -0
  46. package/runtime/cc-worker/hooks/deny-ask.mjs +38 -0
  47. package/runtime/cc-worker/hooks/deny-plan.mjs +61 -0
  48. package/runtime/cc-worker/hooks/hooks.json +111 -0
  49. package/runtime/cc-worker/hooks/perm-policy.mjs +211 -0
  50. package/runtime/cc-worker/hooks/precompact-instructions.mjs +122 -0
  51. package/runtime/cc-worker/hooks/scratchpad-stop.mjs +125 -0
  52. package/runtime/cc-worker/hooks/scratchpad.mjs +419 -0
  53. package/runtime/cc-worker/hooks/session-seed.mjs +105 -0
  54. package/runtime/cc-worker/scripts/fray/agent-bindings.mjs +9 -0
  55. package/runtime/cc-worker/scripts/fray/config.mjs +12 -0
  56. package/runtime/cc-worker/skills/gh/SKILL.md +132 -0
  57. package/runtime/cc-worker/skills/gh/scripts/ci-watch.mjs +60 -0
  58. package/runtime/cc-worker/skills/gh/scripts/github-watch.mjs +130 -0
  59. package/runtime/cc-worker/skills/gh/scripts/review-watch.mjs +54 -0
  60. package/runtime/cc-worker/skills/handoff/SKILL.md +203 -0
  61. package/runtime/cc-worker/skills/waits/SKILL.md +83 -0
  62. package/web-dist/apple-touch-icon.png +0 -0
  63. package/web-dist/assets/TerminalPane-fMGrgB-a.js +7 -0
  64. package/web-dist/assets/abnfDiagram-VRR7QNED-BtlrDuYX.js +1 -0
  65. package/web-dist/assets/arc-BSyeo0Gb.js +1 -0
  66. package/web-dist/assets/architecture-TIHT7OUA-CuD8jpr-.js +1 -0
  67. package/web-dist/assets/architectureDiagram-ZJ3FMSHR-CdLQ71be.js +36 -0
  68. package/web-dist/assets/array-BifhSqXX.js +1 -0
  69. package/web-dist/assets/blockDiagram-677ZJIJ3-CBk2FHEj.js +132 -0
  70. package/web-dist/assets/c4Diagram-LMCZKHZV-mZ3HQ6WX.js +10 -0
  71. package/web-dist/assets/channel-C2eUWc74.js +1 -0
  72. package/web-dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  73. package/web-dist/assets/chunk-32BRIVSS-Daxvi7f5.js +1 -0
  74. package/web-dist/assets/chunk-52WLFC77-BGJoZvry.js +10 -0
  75. package/web-dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  76. package/web-dist/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
  77. package/web-dist/assets/chunk-C7G6YPKG-Ycd1yheh.js +1 -0
  78. package/web-dist/assets/chunk-EX3LRPZG-BPFhlsHp.js +231 -0
  79. package/web-dist/assets/chunk-FWX5IMBZ--xy8DTon.js +2 -0
  80. package/web-dist/assets/chunk-HOUHSVGY-Dq3zoygp.js +1 -0
  81. package/web-dist/assets/chunk-ICXQ74PX-FfKP-7yM.js +2 -0
  82. package/web-dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  83. package/web-dist/assets/chunk-KEIR6QF5-BfrZ3jm6.js +161 -0
  84. package/web-dist/assets/chunk-MOJQB5TN-BeiHnsLF.js +88 -0
  85. package/web-dist/assets/chunk-OGEWGWER-CjDU_9fC.js +1 -0
  86. package/web-dist/assets/chunk-PUDLZKDR-BO7tm5QX.js +156 -0
  87. package/web-dist/assets/chunk-Q4XR5HBZ-BPefovOg.js +70 -0
  88. package/web-dist/assets/chunk-RYQCIY6F-Cu_KplZW.js +1 -0
  89. package/web-dist/assets/chunk-V7JOEXUC-DWnebFHh.js +206 -0
  90. package/web-dist/assets/chunk-VAUOI2AC-BL6qWFhW.js +1 -0
  91. package/web-dist/assets/chunk-VR4S4FIN-Mfs__7L9.js +1 -0
  92. package/web-dist/assets/chunk-WYO6CB5R-Bf2IYbEU.js +127 -0
  93. package/web-dist/assets/chunk-XXDRQBXY-DYlTP5J-.js +1 -0
  94. package/web-dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  95. package/web-dist/assets/chunk-ZGVPDNZ5-CJ9ZHZgU.js +62 -0
  96. package/web-dist/assets/chunk-ZIRB5QZD-C6fEPe3t.js +32 -0
  97. package/web-dist/assets/classDiagram-OUVF2IWQ-GaOJNgfE.js +1 -0
  98. package/web-dist/assets/classDiagram-v2-EOCWNBFH-GaOJNgfE.js +1 -0
  99. package/web-dist/assets/cose-bilkent-JH36ORCC-BUIsLrGc.js +1 -0
  100. package/web-dist/assets/cynefin-VYW2F7L2-COSC0oNL.js +1 -0
  101. package/web-dist/assets/cynefinDiagram-TSTJHNR4-mInhJdg1.js +62 -0
  102. package/web-dist/assets/cytoscape.esm-B3I8pqwA.js +321 -0
  103. package/web-dist/assets/dagre-CXRCoUWR.js +1 -0
  104. package/web-dist/assets/dagre-VKFMJZFB-D-T5z05M.js +4 -0
  105. package/web-dist/assets/defaultLocale-C8Fc0cco.js +1 -0
  106. package/web-dist/assets/diagram-FQU43EPY-HbMkSb3j.js +3 -0
  107. package/web-dist/assets/diagram-G47NLZAW-DBwZLcZh.js +24 -0
  108. package/web-dist/assets/diagram-NH7WQ7WH-DG_Mhays.js +24 -0
  109. package/web-dist/assets/diagram-OA4YK3LP-hH39gqpu.js +30 -0
  110. package/web-dist/assets/diagram-WEI45ONY-DhKZuwxW.js +41 -0
  111. package/web-dist/assets/dist-DoH_9pyS.js +1 -0
  112. package/web-dist/assets/ebnfDiagram-CCIWWBDH-B7BiO-NR.js +1 -0
  113. package/web-dist/assets/erDiagram-Q63AITRT-DHjG5RDQ.js +85 -0
  114. package/web-dist/assets/eventmodeling-45OFAUF4-C0eV8RBx.js +1 -0
  115. package/web-dist/assets/flowDiagram-23GEKE2U-BbldRZUK.js +1 -0
  116. package/web-dist/assets/ganttDiagram-NO4QXBWP-BZ-98wTa.js +292 -0
  117. package/web-dist/assets/gitGraph-TEB2WS4Q-nF22R2jO.js +1 -0
  118. package/web-dist/assets/gitGraphDiagram-IHSO6WYX-CoYQQ22V.js +106 -0
  119. package/web-dist/assets/graphlib-B8gBHxth.js +1 -0
  120. package/web-dist/assets/index-BHzIN-tQ.js +357 -0
  121. package/web-dist/assets/index-Duiy4w7C.css +1 -0
  122. package/web-dist/assets/info-DKCQHKI2-Cy2BCbBW.js +1 -0
  123. package/web-dist/assets/infoDiagram-FWYZ7A6U-CmhR2R1x.js +2 -0
  124. package/web-dist/assets/init-D6jRqBbL.js +1 -0
  125. package/web-dist/assets/ishikawaDiagram-FXEZZL3T-CfG59afl.js +70 -0
  126. package/web-dist/assets/journeyDiagram-5HDEW3XC-BvFmG20q.js +139 -0
  127. package/web-dist/assets/kanban-definition-HUTT4EX6-BHGEecsY.js +89 -0
  128. package/web-dist/assets/katex-CddkPoXu.js +257 -0
  129. package/web-dist/assets/line-Ds4xvN3d.js +1 -0
  130. package/web-dist/assets/linear-z2V0wJk9.js +1 -0
  131. package/web-dist/assets/map-DsCK-0Cs.js +1 -0
  132. package/web-dist/assets/mermaid-parser.core-Gow4kgYJ.js +7 -0
  133. package/web-dist/assets/mermaid.core-B7Qc_kbm.js +11 -0
  134. package/web-dist/assets/mindmap-definition-LN4V7U3C-ayXKGcy_.js +96 -0
  135. package/web-dist/assets/ordinal-hYBb2elL.js +1 -0
  136. package/web-dist/assets/packet-7NZHBO7P-oLBdtkD8.js +1 -0
  137. package/web-dist/assets/path-BWPyau1x.js +1 -0
  138. package/web-dist/assets/pegDiagram-2B236MQR-DwJyc_1B.js +1 -0
  139. package/web-dist/assets/pie-RZYD4A2V-CXzad3fT.js +1 -0
  140. package/web-dist/assets/pieDiagram-ENE6RG2P-CZzgSGtM.js +39 -0
  141. package/web-dist/assets/quadrantDiagram-ABIIQ3AL--IPIp8yo.js +7 -0
  142. package/web-dist/assets/radar-I7S5WNFK-DldySvtZ.js +1 -0
  143. package/web-dist/assets/railroad-3IZDKUUU-CiWFZfXM.js +1 -0
  144. package/web-dist/assets/railroad-abnf-AHOZXSZD-bXMYUOCD.js +1 -0
  145. package/web-dist/assets/railroad-ebnf-EBAXGLYW-D11ZwZby.js +1 -0
  146. package/web-dist/assets/railroad-peg-LSFZ7HO6-DD1AC5X-.js +1 -0
  147. package/web-dist/assets/railroadDiagram-RFXS5EU6-DcCMNGGb.js +1 -0
  148. package/web-dist/assets/requirementDiagram-TGXJPOKE-C-6j8urk.js +84 -0
  149. package/web-dist/assets/rolldown-runtime-Bh1tDfsg.js +1 -0
  150. package/web-dist/assets/rough.esm-CSKSodPl.js +1 -0
  151. package/web-dist/assets/sankeyDiagram-HTMAVEWB-CPtiOpdz.js +40 -0
  152. package/web-dist/assets/sequenceDiagram-DBY2YBRQ-B8FnQTdn.js +162 -0
  153. package/web-dist/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
  154. package/web-dist/assets/src-C4XfhTaE.js +1 -0
  155. package/web-dist/assets/stateDiagram-2N3HPSRC-CxG9nX2P.js +1 -0
  156. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-SGarOFIy.js +1 -0
  157. package/web-dist/assets/swimlanes-5IMT3BWC-D49-t0hV.js +2 -0
  158. package/web-dist/assets/swimlanesDiagram-G3AALYLV-krF0bh4b.js +8 -0
  159. package/web-dist/assets/timeline-definition-FHXFAJF6-C9OEd9SE.js +120 -0
  160. package/web-dist/assets/treeView-QDETBFTQ-D1AKqVPa.js +1 -0
  161. package/web-dist/assets/treemap-6X3UGDF4-BRssB7hM.js +1 -0
  162. package/web-dist/assets/vennDiagram-L72KCM5P-CftkAbY0.js +34 -0
  163. package/web-dist/assets/wardley-OPB4EBWU-RLMgzRq4.js +1 -0
  164. package/web-dist/assets/wardleyDiagram-EHGQE667-BLytgXPS.js +78 -0
  165. package/web-dist/assets/xychartDiagram-FW5EYKEG-BYgK1Y3H.js +7 -0
  166. package/web-dist/favicon-16.png +0 -0
  167. package/web-dist/favicon-32.png +0 -0
  168. package/web-dist/favicon.svg +78 -0
  169. package/web-dist/icon-192.png +0 -0
  170. package/web-dist/icon-512.png +0 -0
  171. package/web-dist/icon-maskable-512.png +0 -0
  172. package/web-dist/index.html +33 -0
  173. package/web-dist/manifest.webmanifest +16 -0
@@ -0,0 +1,923 @@
1
+ # cc-worker — design decisions
2
+
3
+ The **fray** worker-side plugin (dir `cc-worker/`, manifest `name: "fray"` since 2026-07-08) is
4
+ consumed by fray-ui worker sessions: one interactive top-level
5
+ `claude` per `.fray/` thread, loaded via `claude --plugin-dir <repo>/cc-worker`. Each session is a
6
+ **worker bound to ONE thread** (slug in env `FRAY_UI_THREAD` + a `THREAD:` line in its prompt). The
7
+ human + the fray-ui app are the orchestrator; the worker just drives its one thread. This records
8
+ what was ported from the orchestrator `cc/` plugin, what was dropped, and why.
9
+
10
+ ## Shared source, bundled runtime closure
11
+
12
+ - **`scripts/fray/config.mjs` and `scripts/fray/agent-bindings.mjs` are THIN SHIMS** that
13
+ `export *` from `../../../cc/scripts/fray/*.mjs`. cc-worker never copies config/vocab/binding
14
+ logic — there is exactly one source of truth (cc's). This assumes cc is a sibling dir (`../../cc/`
15
+ from the plugin root), the same assumption fray-ui's server makes (`ARCHITECTURE.md`: it imports
16
+ the board logic from `../../cc/scripts/fray/*.mjs`).
17
+ - **`bin/fray` and `bin/fray-update`** are cc's exact shim pattern, resolving cc's real scripts at
18
+ `../../cc/scripts/fray/{index,thread-update}.mjs` relative to the bin file (cwd-independent). They
19
+ land on the worker's Bash PATH the way cc's do. `fray-update` is the worker's primary tool for
20
+ owning its one thread file; `fray` lets it read/validate the board.
21
+ - **Portable artifact rule:** `packages/cli/src/artifacts.ts` copies the exact sibling
22
+ `cc/scripts/fray/` module closure to `runtime/cc/scripts/fray/`, beside `runtime/cc-worker/`.
23
+ The existing shims therefore resolve inside an immutable artifact when the source checkout is gone;
24
+ both the worker and cc closure are hashed in `manifest.runtimeFiles` and required at read time.
25
+ - **`agents/*.md`** are copied UNCHANGED from `cc/agents/` (16 profiles) — a worker dispatches its
26
+ own helpers at the same model/effort cells as the orchestrator.
27
+
28
+ ## Hooks ported (all gated on `FRAY_UI_THREAD` — inert if the plugin is loaded anywhere else)
29
+
30
+ | Hook | Event | Ported from | What it does for a worker |
31
+ | --- | --- | --- | --- |
32
+ | `session-seed.mjs` | SessionStart (startup/resume/clear/compact) | cc `session-seed.mjs` | Injects the single-thread worker contract + the bound slug + its file path; re-grounds on compact. Also writes cc's `off` sentinel defensively (see interplay). |
33
+ | `precompact-instructions.mjs` | PreCompact (auto/manual) | new — no cc equivalent | Steers WHAT SURVIVES compaction: emits an editorial brief asking the summarizer to preserve the high-level approach (plan, alternatives rejected, rationale) at high fidelity, plus a closing "Re-grounding before continuing:" section naming the scratchpad path and the files to re-read. **PLAIN STDOUT is the channel** — cc's usual `hookSpecificOutput` JSON would be handed to the summarizer as its literal instructions. Skips sub-agent contexts (the derived scratchpad path is only guaranteed for the top-level worker). |
34
+ | `scratchpad.mjs` | SessionStart (startup/resume/clear/compact) + PreCompact (auto/manual) + UserPromptSubmit + PostToolUse | new — no cc equivalent | Keeps the ONE per-thread scratchpad (`.fray/threads/<sid>/scratch.md`) written and re-grounded across compaction. `--mode=session-start` injects the pad's HEAD back verbatim on the context-losing sources (compact/resume/clear) plus a re-read pointer, and teaches the contract when the pad is still an unwritten skeleton; `--mode=precompact` (**plain stdout**) hands the head to the summarizer; `--mode=nudge` fires on BOTH UserPromptSubmit and PostToolUse (mid-turn — the one that matters for long autonomous turns) once context has grown past `STALE_TOKENS` since the last write. Both nudge channels share one state file, so mid-turn firing does not multiply reminders. NOT gated on `FRAY_UI_THREAD`; `--via=project` marks the repo-local registration and defers to this one inside a fray worker. |
35
+ | `agent-dispatch.mjs` | PreToolUse(Agent) | cc `agent-dispatch.mjs` | Enforces `run_in_background:true`, strips `name`/`team_name`, appends a worker-flavored orchestration epilogue. |
36
+ | `bash-background.mjs` | PreToolUse(Bash) | new | Denies a local shell job that escapes through `&` without a later `wait` or EXIT trap. Shell job control bypasses Claude's task registry, so Fray cannot track or wake it; self-contained probe concurrency remains allowed. |
37
+ | `agent-bind.mjs` | PostToolUse(Agent) | cc `agent-bind.mjs` (verbatim behavior) | Records `agentId → thread` into `.fray/.agent-bindings.jsonl` in cc's exact format, so a worker's THREAD-tagged helper renders on the fray-ui board's per-thread liveness. |
38
+ | `stop-flush.mjs` | Stop | cc `fray-stop-reminder.mjs` (dirty-check idea only) | If the worker's ONE thread file wasn't edited since the last rest, nudges it to flush its state (mtime dirty-check, cooldown-limited, least-alarming `additionalContext` channel). |
39
+
40
+ ## cc hooks DROPPED — one line each on why
41
+
42
+ - **`fray-reminder.mjs` (UserPromptSubmit per-turn pulse)** — DROPPED. It nags the *orchestrator*
43
+ about the whole board (pending-by-status, reconcile-stale, un-drained follow-ups, revalidate-due).
44
+ A worker owns one thread and does not orchestrate a board; a per-turn board pulse is pure noise.
45
+ - **`fray-stop-reminder.mjs` (board-wide Stop reconcile + pop-one decision queue)** — DROPPED as-is;
46
+ only its per-thread dirty-check idea is reused in `stop-flush.mjs`. The rest (reconcile every
47
+ rested agent, pop the next human-blocked thread and present it) is orchestrator decision-queue work
48
+ the fray-ui app + human own, not the worker.
49
+ - **`fray-notify-surface.mjs` (Stop) + `fray-notify` bin/`notify.mjs`** — DROPPED. The durable
50
+ WIN/DECISION/BLOCKER notification queue is an orchestrator surfacing channel; in fray-ui the UI
51
+ surfaces "awaiting you" from thread `status: blocked` + `status_text` directly, so the worker needs
52
+ no separate notify queue.
53
+ - **`fray-subagent-rest.mjs` (SubagentStop recorder) + `fray-rest-guard.mjs` (SubagentStop guard)**
54
+ — DROPPED. These feed the orchestrator's board-wide "reconcile every rested dispatched agent"
55
+ machinery (`.rested-agents.jsonl`, the `.dispatch-count` gate). A worker actively collects its own
56
+ handful of helpers before resting (contract in SKILL.md); it does not need the board-scale
57
+ rest-reconciliation guard. The worker-facing half of the rest-guard's lesson (run long ops inline,
58
+ don't rest on a waiter) is carried in the dispatch epilogue instead.
59
+ - **`fray-thread-edit-steer.mjs` (PostToolUse Edit/Write)** — DROPPED. It's an orchestrator
60
+ convenience that steers an in-flight agent when the orchestrator hand-edits a thread; a worker edits
61
+ its OWN thread and dispatches its OWN helpers, so there's nothing to cross-steer.
62
+ - **`session-end.mjs` (SessionEnd heartbeat clear)** — DROPPED. It clears the session-ownership
63
+ HEARTBEAT so a dead orchestrator's threads orphan. Workers don't participate in cc's multi-session
64
+ ownership model (no `owner_session` claims, no heartbeat) — the fray-ui app tracks which session
65
+ drives which thread — so there's no heartbeat to clear.
66
+ - **The `.dispatch-ledger.jsonl` write + THREAD-existence DENY gate + `.dispatch-count` bump**
67
+ (inside cc's `agent-dispatch.mjs`) — DROPPED from the worker's PreToolUse. The ledger is a
68
+ compaction-durable orchestrator record of which-agent-serves-which-thread across MANY threads; the
69
+ THREAD-existence gate enforces the orchestrator's "file the thread before dispatching" discipline;
70
+ the count only gates the (dropped) SubagentStop recorder. A worker owns exactly one, already-created
71
+ thread and its helpers own no thread, so none apply. The `.agent-bindings.jsonl` write — the piece
72
+ that actually renders sub-agent liveness on the board — IS kept (via `agent-bind.mjs`).
73
+
74
+ ## Interplay with the orchestrator `cc` plugin (double-hook analysis)
75
+
76
+ **Question:** if the user has `cc` (the fray orchestrator plugin) enabled globally AND a fray-ui
77
+ worker session starts in the same repo, do both plugins' hooks fire (double-hook)?
78
+
79
+ **Finding — NO, not by default. cc is inert in a fresh worker session.** cc's every hook is gated on
80
+ `frayActive(projectDir, sessionId)` (`cc/scripts/fray/config.mjs`). That gate is **opt-IN per
81
+ session**: it requires `.fray/` to exist AND a per-session sentinel at
82
+ `.fray/.session-state/<session_id>` containing `on` (written by `fray on` / the orchestrator fray
83
+ skill's Step 0). With no sentinel it returns **false** — the documented default:
84
+
85
+ ```
86
+ // config.mjs frayActive(), final line:
87
+ return false; // DEFAULT: OPT-IN — dormant until this session runs `fray on`
88
+ ```
89
+
90
+ A freshly-spawned fray-ui worker has a distinct `CLAUDE_CODE_SESSION_ID` and never runs `fray on`, so
91
+ cc's `frayActive()` is false for it → **all cc hooks are silent no-ops in the worker.** Meanwhile
92
+ cc-worker gates on the orthogonal `FRAY_UI_THREAD` env, so the two plugins key off different signals
93
+ and do not both activate. No double-hook by default.
94
+
95
+ **Residual risk:** if, inside a worker session, someone runs `fray on` or loads the orchestrator
96
+ `fray` skill (whose Step 0 runs `fray on`), cc's `frayActive()` flips true AND cc-worker is active →
97
+ both fire (you'd get orchestrator board nags inside a worker — wrong).
98
+
99
+ **Mitigation implemented (cheap + safe + reversible):** `session-seed.mjs` writes cc's OWN per-session
100
+ `off` sentinel for the worker's session id via cc's shared `setSessionOverride(dir, sid, 'off')` on
101
+ every worker SessionStart. `frayActive()` short-circuits to false on an `off` override
102
+ (`if (override === 'off') return false`), so cc is **guaranteed dormant** in a worker session even if
103
+ something later attempts to activate it — unless the human deliberately runs `fray on` afterward
104
+ (which overwrites the sentinel), the explicit "I want this session to orchestrate too" escape hatch.
105
+ This uses cc's own public API (identical to what `fray off` does), touches only gitignored runtime
106
+ state keyed on this worker's session id, and does NOT disable cc-worker (which gates on
107
+ `FRAY_UI_THREAD`, not the sentinel). It's the safest cheap option: it neutralizes the other plugin
108
+ without a UI-side plugin-disable flag (Claude Code has no per-invocation "disable plugin X" flag), and
109
+ the worker SKILL.md additionally tells the worker not to run `fray on` / load the orchestrator skill.
110
+
111
+ ## plugin.json
112
+
113
+ `name: "fray"` (renamed from `fray-worker` on 2026-07-08 — see the follow-up note), `version: "0.1.2"`,
114
+ `license: "MIT"`. Hooks are auto-discovered from `hooks/hooks.json` (same as cc — plugin.json carries
115
+ no explicit hooks reference); every hook command is wired via `${CLAUDE_PLUGIN_ROOT}`.
116
+
117
+ ## Claude settings-source isolation — deliberately deferred
118
+
119
+ The portable worker launch passes its per-session plugin with `--plugin-dir` on both spawn and
120
+ resume, and clears only `CLAUDE_CODE_SUBAGENT_MODEL` plus `CLAUDE_CODE_EFFORT_LEVEL`: those inherited
121
+ variables would silently defeat Fray's selected worker/profile. It deliberately does **not** replace
122
+ `HOME`, `CLAUDE_CONFIG_DIR`, or Claude's settings sources. Doing so would also change authentication,
123
+ user-approved permissions, MCP configuration, and global plugin behavior; that is a product-policy
124
+ decision, not an artifact-portability implementation detail. A future isolation policy must specify
125
+ which settings/auth surfaces are preserved before adding `--settings`, a config-home override, or a
126
+ global-plugin disable mechanism.
127
+
128
+ ## 2026-07-02: Stop hook removed
129
+ stop-flush.mjs is no longer wired (script kept for reference). User call: under fray-ui the
130
+ tailer/board already surface worker state live, and the block-until-file-edited nag forced even
131
+ trivial workers into Read/Edit dances that render as noise in the chat UI. Thread-file discipline
132
+ remains a prompt-level contract (worker system prompt + SKILL), not a hook-enforced gate.
133
+
134
+ ## 2026-07-08: Developer-experience port — doctrine, thread-type presets, dialectic
135
+ Goal: carry the old cc/ plugin's developer experience (minus the orchestrator machinery) into
136
+ fray-ui workers. What landed:
137
+ - **`skills/dialectic/SKILL.md`** — ported from `cc/skills/dialectic/` (self-contained dueling-
138
+ sub-agents methodology; no board/reconcile dependency). Its model-tier references use this
139
+ plugin's namespace (`fray:opus-high`, etc. — see the naming note below).
140
+ - **`skills/worker/SKILL.md`** — added two sections: "Choosing a helper's model + effort" (the
141
+ full Haiku/Sonnet/Opus tiering doctrine + effort ladder + bias-to-Opus corollary, adapted from
142
+ `cc/skills/fray/SKILL.md:218-234` for a worker dispatching its OWN helpers) and "Thread-type
143
+ presets" (research / audit / implementation / planning — deliverable shape + "done" bar each,
144
+ derived from that skill's `:92-150`/`:242-317`/`:463` framing). Also added a status-field
145
+ discipline section (later split into `activity` + `status_text` — see the follow-up) and an
146
+ "awaiting your OWN sub-agent is NOT blocked" clarification.
147
+ - **`ui/WORKER_PROMPT.md`** (fray-ui system prompt, not in this plugin) — carries the terse version
148
+ of the same three: a "Status discipline" block, the model/effort doctrine in the Sub-agents
149
+ section, and a "Thread types" section. This is the maintainer's explicit ask that the preset
150
+ vocabulary ride in the SYSTEM prompt passed to every worker. No dispatch.ts change, so no fray-ui
151
+ server restart is needed — `loadWorkerPrompt()` re-reads the file per dispatch and each spawned
152
+ `claude` rescans this plugin dir fresh.
153
+
154
+ ### Naming: subagent_type is `fray:<model>-<effort>` (plugin renamed to `fray` on 2026-07-08)
155
+ The plugin's manifest `name` (`.claude-plugin/plugin.json`) drives the subagent-type NAMESPACE, and
156
+ each agent file's frontmatter `name` drives the agent name — verified empirically with a throwaway
157
+ plugin (`name:"fray"` + agent `name:opus-high` → `fray:opus-high`); the plugin DIRECTORY name does
158
+ not matter. So the profiles dispatch as `fray:opus-high` / `fray:sonnet-medium` / `fray:haiku`, and
159
+ the skills as `fray:worker` + `fray:dialectic`. A BARE name (`opus-high`, `haiku`) does NOT resolve
160
+ — the Agent tool returns "Agent type '…' not found. Available agents: … fray:haiku …" — so every
161
+ doctrine/dialectic reference uses the `fray:`-prefixed form (confirmed end-to-end: `subagent_type:
162
+ fray:haiku` returns PONG). See the follow-up note below for why the name is `fray`, not `fray-worker`.
163
+
164
+ ## 2026-07-08 (follow-up): status split, validation hook, `fray:` namespace rename
165
+ Three maintainer refinements landed on top of the port:
166
+
167
+ **1. `status_text` split into `activity` + `status_text`.** The single overloaded field became two:
168
+ `activity` = the form-constrained LIVE label the UI renders beside the spinner (single line, ≤100
169
+ chars, present-progressive gerund); `status_text` = the classic 1–2-sentence human gloss that also
170
+ doubles as THE ask on a human-`blocked` thread (queue cards headline it; no gerund constraint). The
171
+ gerund/≤100 discipline moved OFF `status_text` and ONTO `activity` in `ui/WORKER_PROMPT.md` +
172
+ `skills/worker/SKILL.md`. (UI-side `activity` plumbing/rendering — board JSON → ThreadView → listing
173
+ row — is a SIBLING agent's scope; not touched here beyond `ui/WORKER_PROMPT.md`.)
174
+
175
+ **2. New PostToolUse validation hook — `hooks/thread-frontmatter-validate.mjs`** (matcher
176
+ `Edit|Write|MultiEdit`, wired in `hooks/hooks.json`). Gated on FRAY_UI_THREAD + a top-level
177
+ `.fray/<slug>.md` path (dotfiles + `.findings/` sidecars skipped via the vendored `threadSlug`). On
178
+ every thread-file edit it re-reads + validates the frontmatter and, on a HARD violation, returns
179
+ `{"decision":"block","reason":…}` (PostToolUse block — the worker sees the quoted reason and
180
+ re-edits); soft issues warn via `systemMessage`; a clean edit is silent; ANY error fails OPEN
181
+ (exit 0). Rules (mirroring cc's board validator `index.mjs:302-335`): required `title`+`status`;
182
+ `status` ∈ the vocab (legacy aliases warn, not block); `activity` single-line/≤100/gerund-heuristic
183
+ (first word ends in "ing"); `blocked` ⇒ at most ONE of `blocking_threads`/`revalidate_at`, and
184
+ human-blocked (neither) ⇒ `status_text` required; `status_text` >240 chars warns. SELF-CONTAINED: it
185
+ VENDORS minimal copies of cc's frontmatter parser (`index.mjs:82`), path matcher
186
+ (`fray-thread-edit-steer.mjs:67`), and vocab (`config.mjs:291`/`:301`) — cc-worker must not import cc
187
+ at runtime. Verified: 10 direct unit cases (pass/block/warn/inert) + a real headless worker whose bad
188
+ Write was blocked with the exact quoted reason.
189
+
190
+ **3. Plugin renamed `fray-worker` → `fray`; worker skill renamed `fray-worker` → `worker`.** The
191
+ maintainer disliked the old worker-prefixed dispatch namespace. Renamed the manifest `name` to `fray` and the 16 agent
192
+ files + their frontmatter from `fray-<model>-<effort>` to `<model>-<effort>` (bare `haiku`), so
193
+ dispatches read `fray:opus-high` etc. Renamed the worker skill dir + frontmatter `fray-worker` →
194
+ `worker` (giving `fray:worker`, not the stutter `fray:fray-worker`). The plugin DIRECTORY stays
195
+ `cc-worker/` (dispatch.ts points at that path — unchanged, no server restart). Every reference in
196
+ `ui/WORKER_PROMPT.md`, both skills, the hooks, and this file was updated; a grep for the old prefix token is clean.
197
+
198
+ **Accepted name collision.** The old GLOBAL orchestrator plugin (`cc/`) is ALSO named `fray`. This is
199
+ accepted as harmless: `cc/` is disabled in `~/.claude/settings.json` (`"fray@fray": false`) and, even
200
+ when enabled, loads only in ORCHESTRATOR sessions (a different process class) — never in a fray-ui
201
+ worker, which loads ONLY this plugin via `--plugin-dir cc-worker`. Verified: the headless worker env
202
+ (same settings.json) registers a clean `fray:*` set with no `cc/` agents present. If a user ever
203
+ loaded BOTH plugins in one session, Claude Code would namespace-collide two `fray` plugins — but that
204
+ combination does not occur on the worker path.
205
+
206
+ ## 2026-07-08 (campaign): `needs-human` first-class status + interactive-prompt deny hooks
207
+ Part of the board-wide redesign (owned jointly with the fray-ui UI half): `needs-human` becomes a
208
+ first-class fray status — the declared "awaiting a human" state and the queue's definition — while
209
+ `blocked` narrows to MACHINE-waits only. The vocab/parser/validator changes live in the shared cc/
210
+ scripts (`config.mjs` STATUS/STATUS_ALIASES + new `effectiveStatus()`, `index.mjs`, `decisions.mjs`,
211
+ `statusline-fray.mjs`, `thread-update.mjs`, `fray-reminder.mjs`) — consumed by fray-ui's readBoard
212
+ shell-out, so they take effect on the next board rebuild with NO server restart. cc-worker-side:
213
+
214
+ - **`hooks/thread-frontmatter-validate.mjs`** — vendored vocab bumped: `needs-human` is canonical,
215
+ `needs-decision` aliases to it. New rules: a `needs-human` thread (incl. a legacy `blocked` with no
216
+ machine field, which reads as needs-human via the inlined `effectiveStatus`) REQUIRES a
217
+ `status_text` (hard BLOCK); a machine-`blocked` thread with no mechanism field is a WARN suggesting
218
+ needs-human (not a block — legacy tolerance); >1 mechanism stays a block.
219
+ - **`skills/worker/SKILL.md` + `ui/WORKER_PROMPT.md` + `hooks/session-seed.mjs`** — the worker status
220
+ guidance rewritten to the new contract: an ask OR a result needing review → `status: needs-human`
221
+ with a `status_text` ask ("Review: …"); `blocked` is machine-only; `done` means NOTHING is left for
222
+ the human (Mark-as-done is the human's acknowledgment — never jump straight to `done` when review
223
+ is pending).
224
+ - **`hooks/deny-ask.mjs`** (existing, PreToolUse `AskUserQuestion`) — deny reason re-pointed at
225
+ `--status needs-human`. `AskUserQuestion` is a real tool → cleanly deniable via PreToolUse; verified
226
+ by piping the hook its exact payload (deny + reason) and confirming inert when FRAY_UI_THREAD unset.
227
+ - **`hooks/deny-plan.mjs`** (NEW, PermissionRequest `ExitPlanMode`) — denies the plan-approval prompt.
228
+ MECHANISM (per the Claude Code hooks docs): `ExitPlanMode` is a PERMISSION surface, not a plain
229
+ tool, so it is denied via a **PermissionRequest** hook (matcher exactly `ExitPlanMode`), NOT
230
+ PreToolUse. The deny JSON is `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":
231
+ {"behavior":"deny"}}, "additionalContext":"<redirect>"}` — the instructive redirect rides
232
+ **top-level `additionalContext`** (NOT `decision.message`), which Claude injects to the model as a
233
+ plain-text system-reminder; exit 0 with the JSON on stdout (exit 2 makes Claude ignore it). CAVEAT:
234
+ PermissionRequest hooks do NOT fire under `claude -p` (headless print mode), so the `-p` smoke test
235
+ can't exercise it — but fray-ui workers run as INTERACTIVE tmux `claude` sessions, where they DO
236
+ fire. Both deny hooks are FRAY_UI_THREAD-gated and fail-open. (`AskUserQuestion` was not surfaced as
237
+ an invokable tool in the `-p` harness either, so both hooks were verified by piping their exact hook
238
+ payloads rather than by driving a live prompt.)
239
+
240
+ ### `hooks/perm-policy.mjs` — the worker's permission policy (2026-07-25)
241
+
242
+ Replaces the observe-only `perm-observe.mjs`. Same registration (PermissionRequest, matcher `*`), but
243
+ it now DECIDES: allow / deny / defer, first match wins.
244
+
245
+ WHY IT DECIDES. fray dispatches Claude workers at `--permission-mode auto`
246
+ (`WORKER_DISPATCH_PERMISSION`), and `auto` is **not** non-interactive — its classifier still raises a
247
+ prompt for anything it judges risky. With nobody at the keyboard that parks a thread invisibly; one sat
248
+ blocked on a `git push` for over a day. The blunt alternative was dispatching at `bypassPermissions`,
249
+ which was deliberately NOT chosen: bypass removes the decision POINT, so nothing can ever inspect a
250
+ request again. Keeping `auto` and deciding here preserves the seam — and Claude Code labels the outcome
251
+ in the pane ("Allowed by PermissionRequest hook"), so an auto-approval stays attributable rather than
252
+ being indistinguishable from bypass.
253
+
254
+ THE TABLE ships UNIVERSAL rules only; this plugin loads for every project fray drives, so a rule that
255
+ is right for one repo is wrong for the next. `catastrophic-delete` and `raw-disk-write` deny outright
256
+ (strictly safer than bypass, which would have allowed both). `restrictive-mode` DEFERS whenever
257
+ `permission_mode !== "auto"`, which is what makes a genuine lower-permission mode usable: move a thread
258
+ to `default` with the live permission control and its prompts come back. `FRAY_PERM_POLICY=review`
259
+ defers everything. Fail-safe INVERTS the old observer's fail-open — for a hook that can APPROVE, any
260
+ error must fall back to asking, never to allowing.
261
+
262
+ PAYLOAD (verified live, correcting an earlier note that claimed otherwise): the PermissionRequest hook
263
+ input carries `session_id`, `transcript_path`, `cwd`, `prompt_id`, `permission_mode`, `effort`,
264
+ `hook_event_name`, `tool_name`, `tool_input`, and `permission_suggestions`. That is enough for a real
265
+ per-tool/per-command policy. Allow JSON is
266
+ `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow","updatedInput":<tool_input>}}}`.
267
+
268
+ KNOWN LIMIT: an explicit `ask` RULE outranks this hook. `"permissions":{"ask":["Bash(git push:*)"]}`
269
+ raises a prompt an `allow` here does not override — Claude Code says so on the prompt itself. Isolated
270
+ against a hook that allows unconditionally (it prompted too), so this is Claude Code precedence, not a
271
+ defect, and arguably correct: an explicit human rule should beat a blanket policy. A repo carrying
272
+ `ask` rules can therefore still park a worker; the fix is that repo's settings.
273
+
274
+ MARKER: `<stateDir>/perm-requests/<slug>.json` now carries `decision` / `rule` / `reason` / `command`
275
+ alongside the original fields. The tailer treats ONLY a deferred request as a human block (an
276
+ auto-resolved one must never card as "Needs you"), and surfaces the last allow/deny as `permPolicy` —
277
+ the only durable record an approval leaves anywhere, since allows are never written to the transcript.
278
+ A marker with no `decision` (an older plugin build) still blocks exactly as before.
279
+
280
+ VERIFICATION: `scripts/verify-perm-marker.mjs` runs the REAL hook the way Claude Code does and
281
+ asserts every branch. The allow path was additionally driven against a REAL interactive `claude` (it
282
+ cannot be exercised under `claude -p`, where PermissionRequest hooks do not fire).
283
+
284
+ ### The `--settings` permission floor — BUILT, verified, then deliberately REMOVED (2026-07-08)
285
+ A `--settings` permission FLOOR was added to `ui/dispatch.ts` (`WORKER_DENY_SETTINGS =
286
+ {"permissions":{"deny":["AskUserQuestion","ExitPlanMode"]}}`, both command builders) and verified to
287
+ work: `claude -p --settings '{deny:[AskUserQuestion,ExitPlanMode,Bash]}'` → the model reports
288
+ `Bash: No` (control — a bare-name deny removes a normally-present tool from context), `Read/Write:
289
+ Yes`, `AskUserQuestion/ExitPlanMode: No`. It was then **removed on the maintainer's call**, and the
290
+ reasoning is worth keeping (it corrects the earlier "floor first, hooks failover" story):
291
+
292
+ - A bare-name deny removes the tool from the tool LIST, but a model **knows `AskUserQuestion` /
293
+ `ExitPlanMode` from TRAINING** and can still attempt them. With the floor in place, that attempt
294
+ hits a generic "no such tool" permission error — NOT the hook's instructive `needs-human` redirect
295
+ (a permission `deny` is evaluated regardless of hook output and takes precedence, so the model sees
296
+ the generic denial). So the floor can actually **prevent the tool-block education process**.
297
+ - Therefore: **hooks-only enforcement.** The deny HOOKS are themselves a hard deny AND they teach on
298
+ contact (their `needs-human` redirect reaches the model — verified: deny-ask emits a PreToolUse
299
+ `permissionDecisionReason`, deny-plan a PermissionRequest top-level `additionalContext`). The floor
300
+ is gone; `dispatch.ts` + `server.test.ts` are reverted to clean.
301
+
302
+ ### Plan-mode softlock fix (2026-07-08)
303
+ A worker in plan mode that calls `ExitPlanMode` would be denied by deny-plan — but plan mode ALSO
304
+ blocks file edits, so the redirect ("write the plan into your thread") is impossible to follow: a
305
+ softlock. deny-plan does not branch on the session's mode, so it cannot pass plan mode through. Fixed
306
+ at the SOURCE instead: `ui/dispatch.ts` `workerPermissionMode()` coerces `--permission-mode plan` → `auto` inside
307
+ BOTH command builders (so dispatch, adopt, AND resume never spawn a worker in plan mode). Workers plan
308
+ by writing the plan into the thread + `status: needs-human` (the contract), which has no plan-mode
309
+ requirement — so nothing is lost. deny-plan then denies `ExitPlanMode` UNCONDITIONALLY when gated:
310
+ for a real fray-ui worker (never in plan mode) that is always a spurious call → deny + redirect is
311
+ correct. RESIDUAL GAP (accepted, documented): the deny could softlock only a FOREIGN session that is
312
+ simultaneously in plan mode AND running with FRAY_UI_THREAD set AND this plugin loaded — a combination
313
+ fray-ui never produces. A normal plan-mode session outside fray-ui is untouched (deny-plan is inert
314
+ without FRAY_UI_THREAD). Follow-up for UI honesty: `web/src/lib/options.ts` still offers "plan" in the
315
+ dispatch permission-mode dropdown (coerced to `auto` at spawn) — the sibling can drop it there.
316
+
317
+ ### Malformed-thread one-click repair — read-side recovery (2026-07-09)
318
+ INCIDENT: a worker that spawned before the frontmatter-validation write-hook existed wrote
319
+ `nub/.fray/sandbox-windows-backend.md` with its metadata in **bold prose** instead of YAML
320
+ frontmatter. The board banner correctly reported "sandbox-windows-backend.md: no YAML frontmatter",
321
+ but the thread was INVISIBLE to the queue/status system (the parser can't read its title/status →
322
+ `status: ?`) until the orchestrator hand-edited YAML. Maintainer directive: "make sure that doesn't
323
+ happen again."
324
+
325
+ TWO-SIDED DEFENSE:
326
+ - **Write-side (already existed):** the frontmatter-validation file-tool hook blocks a compliant
327
+ worker from writing a thread `.md` with no frontmatter in the first place.
328
+ - **Read-side (this change):** RECOVERY for any straggler the write-hook can't catch — pre-hook
329
+ files, hand edits, and (the residual, by design) **Bash-written files that bypass the file-tool
330
+ hooks entirely**. The board now ships STRUCTURED, classified errors so the UI can offer one-click
331
+ repair.
332
+
333
+ MECHANISM:
334
+ - `cc/scripts/fray/index.mjs` `--json` gains a parallel `errorItems: [{file, kind, message}]`
335
+ (`kind: 'no-frontmatter'` = repairable, else `'other'`). The parser classifies — it knows exactly
336
+ why it rejected the file. The legacy `errors: string[]` array is emitted UNCHANGED alongside it.
337
+ - `errorItems` flows through `readBoard`/`fray.ts` → `board.ts assemble()` → `BoardSnapshot` +
338
+ `BoardMeta` (so the repair affordance survives a board delta, not just the keyframe) → the
339
+ TodosView banner, which renders a **Repair** button per `no-frontmatter` item.
340
+ - `repairThread({file})` RPC (`repair.ts`) validates the file is a real `.md` DIRECTLY under `.fray/`
341
+ (resolve + dirname===root guard; rejects `../`, `sub/`, absolute paths), refuses any file that
342
+ already has a `---` block (repair is ONLY the missing-frontmatter case), then PREPENDS minimal
343
+ frontmatter: `title` from the first `# H1` (else the filename slug), `status: active`, and a
344
+ standing `status_text` flag that the status is unverified. Then a board rebuild.
345
+
346
+ CONSERVATIVE ON PURPOSE: repair NEVER infers status from prose. This morning's file said "DONE" in
347
+ bold — guessing wrong silently is worse than surfacing. `status: active` makes the thread visible;
348
+ if its agent is gone, the runtime crash-net cards it for human attention — the correct escalation.
349
+
350
+ RESIDUAL (accepted, documented): a Bash-written `.fray/*.md` bypasses the file-tool hooks by design,
351
+ so the write-side can't prevent it — the read-side repair is the safety net that heals it in one
352
+ click.
353
+
354
+ ## 2026-07-09: v2 worker contract — the session-first rebuild (fences + scratchpad; thread-file contract DELETED)
355
+ The maintainer settled fray-ui on a SESSION-FIRST model: threads ARE claude sessions and the human's
356
+ dashboard shows the session TRANSCRIPT. Queue membership is explicit: `question` hands off to the
357
+ human, `done` queues a checked completion, process-level blocks surface themselves, `awaiting`
358
+ excuses a machine wait, and bare rest stays quiet. The entire `.fray/<slug>.md` ownership contract is
359
+ GONE: no thread files, no frontmatter, no `status`/`activity`/`status_text`, no `needs-human`, no
360
+ `blocked` machine fields, no `hasPlan`/`## Plan`, no `fray-update`. Workers now SIGNAL through their
361
+ FINAL MESSAGE and PERSIST through a SCRATCHPAD. This is the cc-worker-side realignment.
362
+
363
+ **The new signal model (taught in `ui/WORKER_PROMPT.md` §"End-of-turn signals" + `skills/worker/SKILL.md`):**
364
+ - **Bare rest is quiet** — a rested thread with no fence does NOT enter Needs-you and is not a human
365
+ handoff. Human handoff is explicit via `question` (or a real process-level block).
366
+ - **` ```done `** — work complete + stands; body = 1–4 lines of what shipped + where. Renders a
367
+ checked success card in the queue until explicit Archive; the fence MUTATES NOTHING
368
+ (maintainer-settled), and a follow-up may still wake the worker.
369
+ - **` ```awaiting `** — waiting on a MACHINE (CI/PR/timer/session); body may lead with `kind: value`
370
+ hint lines, kind ∈ pr|ci|timer|session. NEVER for a human wait.
371
+ - **` ```question `** — unchanged grammar (question / approval / multi / danger; trailing `- A. …`
372
+ options + `Recommendation:`), now the ONLY handback-for-input; no status flip accompanies it.
373
+ - CONSISTENCY: the taught grammar matches the shared parser (`packages/shared/src/index.ts`
374
+ `ThreadFence` kind ∈ done|awaiting, `AwaitingHint` kind ∈ pr|ci|timer|session). Opening line is
375
+ exactly ` ```done `/` ```awaiting ` (nothing after the language word); exactly one fence, at the end.
376
+
377
+ **The scratchpad (`.fray/threads/<session-id>/scratch.md`) — new §"Scratchpad" in both docs:** free-form
378
+ markdown, NO schema, NO validation. It is the worker's compaction-proof working memory (survive-
379
+ compaction to-do lists / work queues / Ralph-style epic checklists live here, not in ephemeral
380
+ context) AND the shared blackboard for parallel sub-agents (shared state is written into it; its PATH
381
+ is passed into every sub-agent prompt; helpers READ it, the worker folds their results back in). The
382
+ path is server-established convention already wired through `shared` (`scratchpadPath`), `router.ts`
383
+ (reads `.fray/threads/<session_id>/scratch.md`), and `dispatch.ts`.
384
+
385
+ **Hooks changed:**
386
+ - **DELETED `hooks/thread-frontmatter-validate.mjs`** + its `hooks.json` PostToolUse `Edit|Write|
387
+ MultiEdit` entry. It validated thread-file frontmatter (status vocab / gerund `activity` / machine
388
+ fields) — a contract that no longer exists. Nothing left to validate on file edits.
389
+ - **DELETED `hooks/stop-flush.mjs`** (already UNWIRED since the 2026-07-02 Stop-hook removal; kept
390
+ "for reference" then). Its sole job was nudging the worker to flush state into its thread FILE —
391
+ dead with the thread-file contract gone. No `hooks.json` entry to remove (it was never re-wired).
392
+ - **`hooks/session-seed.mjs`** — reseeded to the v2 contract: signal via the final message (bare rest
393
+ is quiet; done queues checked completion; awaiting excuses a machine wait; question asks) + the
394
+ scratchpad, whose concrete path is derived from
395
+ the SessionStart `session_id` (`currentSessionId(input.session_id)` → `.fray/threads/<sid>/scratch.md`) and
396
+ named in the seed. FRAY_UI_THREAD gating + the cc double-hook `off`-sentinel defense KEPT verbatim;
397
+ the compact re-grounding now points at the scratchpad, not a thread file.
398
+ - **`hooks/agent-dispatch.mjs`** — epilogue no longer says "don't edit `.fray/` thread files or
399
+ config.yml"; now "don't edit the dispatcher's scratchpad (`.fray/threads/<session-id>/scratch.md`) — READ it for shared
400
+ context if its path is in your prompt, report in your FINAL MESSAGE." Background/name-strip
401
+ enforcement unchanged.
402
+ - **`hooks/deny-ask.mjs`** — redirect retargeted off `fray-update … --status needs-human`: now "ask
403
+ in your FINAL MESSAGE with ```question blocks, then rest; a question IS the handback (no extra
404
+ fence)." Still PreToolUse(AskUserQuestion), FRAY_UI_THREAD-gated, fail-open.
405
+ - **`hooks/deny-plan.mjs`** — redirect retargeted off "write the plan into your thread `.fray/<slug>.md`
406
+ + status: needs-human": now "write the plan into `.fray/plans/<topic>.md` and/or the scratchpad;
407
+ ask via a ```question approval block." PermissionRequest(ExitPlanMode) mechanism + the plan-mode
408
+ softlock reasoning (dispatch.ts coerces plan→auto) unchanged.
409
+ - **`hooks/agent-bind.mjs`** — UNCHANGED (functionally). It records `agentId → thread` into
410
+ `.fray/.agent-bindings.jsonl` from a helper's `THREAD: <slug>` tag; that tag still rides the
411
+ per-thread dispatch and references no dead status/frontmatter contract. NOTE for the server/tailer
412
+ verticals: session-first sub-agent liveness is now TAILER-derived (`ThreadView.subAgents`), so the
413
+ `.agent-bindings.jsonl` binding this hook writes may be VESTIGIAL. Left in place (harmless,
414
+ fail-open, out of this vertical's delete scope) — a candidate for removal once the tailer path is
415
+ confirmed to fully supersede board-side `bindingsByThread`.
416
+
417
+ **`skills/worker/SKILL.md`** — rewritten to the same pillars (version 0.2.0): the signal model
418
+ replaces the status-vocabulary sections; a scratchpad section replaces the "own ONE thread file"
419
+ sections; thread-type presets keep the research/audit/implementation/planning taxonomy but strip
420
+ every status reference (planning now delivers a `.fray/plans/<topic>.md` artifact). The sub-agents
421
+ section adds "pass the scratchpad path into helper prompts." `skills/dialectic` untouched.
422
+
423
+ **`plugin.json`** description updated: no more "validate thread-file frontmatter"; now fence signals
424
+ + scratchpad blackboard + sub-agent profiles + deny-ask/deny-plan.
425
+
426
+ **What the server/web verticals must know:** (a) FRAY_UI_THREAD must keep being passed at spawn — every
427
+ hook still gates on it. (b) The scratchpad path convention is `.fray/threads/<session-id>/scratch.md` where
428
+ `<session-id>` is the pinned `--session-id` (the same id the SessionStart hook sees as `session_id`);
429
+ the seed hook NAMES that concrete path to the worker, so dispatch must keep pinning `--session-id`.
430
+ (c) `packages/server/src/dispatch.ts` `composePrompt()` STILL emits the dead per-thread contract
431
+ ("You own `.fray/<slug>.md` … set `status: blocked` … Set `status: done`") — that is server-vertical
432
+ scope (not editable here), but it now CONTRADICTS the v2 WORKER_PROMPT and must be rewritten to the
433
+ fence/scratchpad model (or dropped) by the dispatch owner. Flagged to server-core.
434
+
435
+ ## 2026-07-12: awaiting reversal — park only human/timestamp gates; keep automation active
436
+
437
+ The v2 rule above made `awaiting` a broad machine-wait bucket. In practice workers emitted a fence
438
+ for CI, bots, releases, and merge progression, then returned with no process actually owning the
439
+ next transition. The rail's hourglass therefore implied a watcher that often did not exist. The
440
+ contract is now narrower:
441
+
442
+ - `awaiting` is a deliberate PARK for either `human: <actor + exact external review/approval>` or
443
+ `timer: <ISO-8601 instant>`. The dashboard operator's own decision remains `question`.
444
+ - CI, automated review, releases/deploys, merge queues, and already-authorized merge progression
445
+ stay ACTIVE. Claude workers use a background `Bash` one-shot or `Monitor`; Codex workers keep a
446
+ blocking exec session alive and poll it. Their completion/event re-invokes the worker.
447
+ - `pr:` / `ci:` / `session:` continue to parse so old transcripts do not break. The existing PR/CI
448
+ waker remains a compatibility bridge, but workers must not create new waits with those hints.
449
+ `timer:` remains the durable scheduler path across process/session restart. `human:` is descriptive
450
+ and intentionally not auto-fired.
451
+ - Every follow-up clears the old fence. “Back to awaiting” requires a fresh check: re-emit a current
452
+ human/timer fence, or re-arm automation and remain active.
453
+
454
+ Claude Code 2.1.207 was audited before teaching this. fray-ui does not pass `--tools`,
455
+ `--allowedTools`, or `--disallowedTools`, and its helper profiles only select model/effort, so wait
456
+ tools are available to top-level workers and helpers. `Monitor` defaults to 300,000 ms (maximum
457
+ 3,600,000 ms); `persistent:true` runs until `TaskStop` or session end. Background Bash reports an
458
+ output path; `TaskOutput` exists but is deprecated, so workers should `Read` that path for diagnostics.
459
+ Both Monitor and background Bash are session/process-bound, which is why durable wall-clock checks
460
+ remain `timer:` fences. Helpers must not return a final handoff while they still own a live watcher;
461
+ the top-level worker owns long-lived CI/PR/merge progression.
462
+
463
+ ## 2026-07-13: ordinary rest returns to Queue; human Snooze/Archive own triage
464
+
465
+ The quiet-bare-rest rule above was reversed after live use showed that an owned Fray worker could
466
+ come to rest without choosing a fence and disappear from the only surface the operator routinely
467
+ triages. Queue membership is now server-derived from process rest, not dependent on perfect worker
468
+ signaling:
469
+
470
+ - Every owned, open session whose top-level turn is genuinely at rest enters Queue by default.
471
+ - A live child/Monitor still counts as in-flight work. A truthful external-human or future-timer
472
+ `awaiting` fence remains dimmed in Held. Legacy CI/PR/session and hintless waits do not excuse rest.
473
+ - A human may durably Snooze an ordinary handoff (default one day, presets/custom exact instant) or
474
+ Archive it. Due snoozes automatically re-enter Queue; Archive never does.
475
+ - Questions, permissions/native approvals, typed interactions, and crashes break through Snooze so a
476
+ provider cannot be stranded behind an invisible hard gate. `done` remains the checked presentation,
477
+ but it is still a resting handoff and can be snoozed.
478
+
479
+ The worker contract still teaches explicit `question`, `done`, and narrow `awaiting` fences because
480
+ they improve priority and presentation. A fence is no longer required merely to make a rested worker
481
+ discoverable.
482
+
483
+ ## 2026-07-12: runtime release gate — real CDP evidence plus independent review
484
+
485
+ Major UI, server, and control-plane work may no longer reach `done` from unit/integration/mocked
486
+ evidence alone. The canonical `ui/WORKER_PROMPT.md` contract now requires real Chrome CDP QA against
487
+ a disposable full stack, relevant active/idle/error/restart coverage, desktop+narrow screenshots,
488
+ console/network inspection, and an explicit correctness+aesthetics assessment. Chrome DevTools MCP is
489
+ preferred when it is available to the current provider; `agent-browser` or the repository Puppeteer
490
+ harness are explicit fallbacks. Mocked DOM/routes can supplement but cannot be the sole evidence.
491
+
492
+ Completion also requires two distinct review passes: the implementer's self-review of diff+evidence,
493
+ then an independent fresh-context adversarial review; confirmed findings are fixed and affected gates
494
+ rerun. The exception is proportional and narrow: trivial non-runtime docs-only or provably mechanical
495
+ changes may skip CDP/independent review, while uncertainty applies the gate. This rule is mirrored in
496
+ `skills/worker/SKILL.md` (v0.2.2) and the SessionStart seed; the backend-aware prompt contract test pins
497
+ all four delivered surfaces, and the Claude expansion golden changes intentionally. The Codex addendum
498
+ no longer mislabels an author's inline second read as independent review: use delegation when available,
499
+ or report the gate unmet.
500
+
501
+ ## 2026-07-21: Plugin slim-down — one contract copy, gh is the only injected skill
502
+
503
+ The plugin stops shipping three of its four skills. `skills/worker` is DELETED: it was a second copy
504
+ of the worker contract whose single source is `packages/server/src/workerPrompt.ts` (the system
505
+ prompt, rebuilt on every dispatch/resume and compaction-immune) — every contract edit had to be made
506
+ twice, and the copies drifted. The session-seed pointer sentences that said "Load the `fray:worker`
507
+ skill for the full contract" now point at the system-prompt contract only, and the contract tests pin
508
+ the two backend prompts (not a skill copy). `skills/dialectic` is dropped from the plugin (generic
509
+ methodology nobody wired into the seed or prompt; workers on other people's projects never asked for
510
+ it). `skills/adhoc-cdp` MOVED to the fray repo's own `.agents/skills/adhoc-cdp` (agent-neutral; `.claude/skills/adhoc-cdp` is a symlink to it so Claude and Codex share one copy) — its content is
511
+ fray-ui-specific (adhoc-stack.mjs / shot.mjs), so it is a project skill, not global plugin cargo; the
512
+ generic "verify in a real browser" principle already lives in the prompt's runtime-gate section.
513
+ `skills/gh` remains the ONE injected skill: bulky, conditionally relevant, and its pointer is already
514
+ auth-gated in the seed — exactly the on-demand shape skills are for.
515
+
516
+ ## 2026-07-22: The no-PR rule now also lives in AGENTS.md (docs, not a hook)
517
+
518
+ Root cause of "agents keep opening PRs despite FRAY.md": the worker contract + injected FRAY.md are a
519
+ SNAPSHOT frozen at session creation. The Codex worker that opened PR #17 and #18 (thread `862831cf`,
520
+ born 09:50 Jul 21) spawned minutes before FRAY.md injection first landed and hours before the no-PR
521
+ rule existed, so it never saw the rule and carried the base contract's "open a PR and report its URL"
522
+ on every turn. Editing FRAY.md does nothing for a session already in flight, and sub-agents never
523
+ receive FRAY.md at all.
524
+
525
+ Mitigation (docs only): the no-PR rule was added to `AGENTS.md` — the agent-neutral home Codex
526
+ re-reads FRESH every session and sub-agents load. That reaches NEW sessions of both backends without a
527
+ frozen snapshot. It does NOT retroactively reach an already-running frozen session; restart such a
528
+ session to pick up a rule change. A tool-layer enforcement hook (`deny-pr` PreToolUse) was built and
529
+ then deliberately reverted as overkill for a single-user repo — the doc reach is the intended fix.
530
+
531
+ ## 2026-07-30: Carryover — a per-session brief the harness re-injects, so context survives compaction
532
+
533
+ The two existing mitigations for compaction loss both routed the context through a DECISION, and that
534
+ is where they leaked. The scratchpad survives on disk but only helps if the post-compaction turn
535
+ chooses to read it — a model decision, and it gets skipped. `precompact-instructions.mjs` steers the
536
+ summarizer, but the summary is a lossy retelling by a model that never saw the reasoning, and it is
537
+ regenerated from scratch every time. `carryover.mjs` removes the decision: whatever is in
538
+ `.fray/threads/<sid>/carryover.md` is spliced into the context window by the harness, before the
539
+ model's first token. The model cannot forget to read it and the summarizer cannot paraphrase it away.
540
+
541
+ **The file is authored by the AGENT, not by the hook** — it is a plain markdown file written with the
542
+ Write tool. Rejected: a CLI (`fray carryover set …`) and a dedicated MCP tool. Both add something to
543
+ learn and to keep installed, and neither buys anything over a file the agent already knows how to
544
+ write and a human can hand-edit mid-session (an edit is just an mtime change, which the nudge treats
545
+ exactly like an agent's write — pinned by a test).
546
+
547
+ **Keyed by session id**, taken from the hook's stdin `session_id` and falling back to
548
+ `CLAUDE_CODE_SESSION_ID` via the shared `currentSessionId`. Those two are equal (already relied on by
549
+ the activation sentinel), which is what lets the AGENT compute its own path from a Bash/Write call.
550
+ Stored beside the scratchpad rather than under `.fray/.session-state/` because the agent is already
551
+ told the thread dir. `.fray/` is gitignored in full, so a brief never reaches a commit, and nothing
552
+ enumerates `.fray/threads/*` to build the board (threads come from the DB), so writing a directory
553
+ there for a non-fray session cannot conjure a phantom thread card.
554
+
555
+ **Staleness is measured in context TOKENS, and as GROWTH, never an absolute.** The transcript's
556
+ newest usage record gives live context fill (`input + cache_creation + cache_read`); the hook reads
557
+ only the last 256 KB of the file, since transcripts reach tens of megabytes here. An absolute
558
+ threshold is unusable because the window is not knowable from a hook — a real compaction in this
559
+ project fired at preTokens 935,291 on a 1M-window session while a 200k session compacts near 160k.
560
+ Growth since the last write is window-independent and self-resetting. Wall clock and transcript BYTES
561
+ were both rejected: neither tracks context pressure (one large tool result adds megabytes to the file
562
+ without moving the window much).
563
+
564
+ **Why a nudge at all.** Without it the file is never written and the whole mechanism is decorative —
565
+ an agent that is never reminded does not stop to journal. It is the one part that is a heuristic, so
566
+ it is the one part with a cheap escape hatch: `FRAY_CARRYOVER_STALE_TOKENS` retunes it and
567
+ `FRAY_CARRYOVER=off` disables every mode.
568
+
569
+ **Registered twice, deduped deterministically.** The plugin registration ships to every fray worker
570
+ everywhere. The repo's own `.claude/settings.json` carries the same three registrations with
571
+ `--via=project` so plain (non-fray) `claude` sessions in this repo get the behavior too; that flag
572
+ exits when `FRAY_UI_THREAD` is set, because such a session already loads the plugin and would
573
+ otherwise inject the brief twice. A flag plus an env check — no lock file, no race.
574
+
575
+ VERIFIED LIVE against cli 2.1.220, in an isolated `/tmp` project, not by proxy:
576
+ - `SessionStart:startup` fired (exit 0, 63 ms) and a real session quoted a sentinel that existed ONLY
577
+ in the brief — with **zero tool calls** in the transcript, so it came from context, not a file read.
578
+ - A real `/compact` produced a `compact_boundary`, and `SessionStart:compact` then delivered a brief
579
+ whose sentinel had been swapped to a value that had NEVER appeared in that session's context — so
580
+ the post-compaction injection demonstrably re-reads the file rather than echoing the summary.
581
+ - `PreCompact:manual [carryover.mjs --mode=precompact] completed with status 0` in the hook debug log.
582
+ - The `UserPromptSubmit` nudge fired live and reported ~31k tokens computed from the real transcript,
583
+ confirming the usage parser works against Claude Code's actual format and not just a fixture.
584
+
585
+ Regression net: `packages/server/src/carryover-hook.test.ts` executes the real script over its
586
+ wire contract (argv + stdin JSON + stdout) rather than asserting on its source.
587
+
588
+ ## 2026-07-30 (same day, revised): carryover COLLAPSED into the scratchpad; the nudge moved mid-turn
589
+
590
+ The `carryover.md` brief shipped earlier today was redundant with the scratchpad and made the worker
591
+ maintain two overlapping documents (maintainer's call, and correct). ONE doc per thread is the rule.
592
+ `carryover.mjs` is DELETED and replaced by `scratchpad.mjs`, which does the same three things to
593
+ `scratch.md` — the pad the dispatcher already provisions, already names in the system prompt, and
594
+ already forbids sub-agents from writing.
595
+
596
+ Two behavior changes fell out of the retarget:
597
+
598
+ **Injection is scoped to the context-losing sources.** A brand-new `startup` has lost nothing, so it
599
+ gets the contract text only; `compact`/`resume`/`clear` get the pad's head injected verbatim PLUS an
600
+ explicit "re-read the full file" pointer. Injecting the head rather than only pointing at the file is
601
+ deliberate: a bare reminder routes recovery through a decision the model can skip, which is the exact
602
+ failure being fixed. The head is the floor; the pointer is the ceiling. The cap (12k chars, was 24k)
603
+ keeps that floor affordable now that the target is unbounded working memory rather than a bounded brief.
604
+
605
+ **"Present" no longer means "written".** fray provisions scratch.md with a skeleton, so file-absence
606
+ is gone as the unwritten signal. `substanceLength()` strips headings, the provisioned orientation line
607
+ and empty task boxes, and measures what remains. It is a heuristic on purpose — it only decides
608
+ whether to NUDGE, so a wrong call costs one redundant reminder, never correctness.
609
+
610
+ **The nudge now also fires on PostToolUse, and that is the real fix.** UserPromptSubmit alone only
611
+ fires at turn boundaries, and a fray worker runs enormous autonomous turns — dozens of tool calls
612
+ between human prompts — so a whole session's work could compact unpersisted without a single nudge.
613
+ PostToolUse `additionalContext` was verified live against cli 2.1.220 (a real session quoted a
614
+ sentinel injected after a Bash call, and through the plugin the model received the nudge mid-turn and
615
+ said it would update the pad). Both channels share one state file, so the interval is global: firing
616
+ per tool call makes the existing budget land SOONER and mid-turn, it does not multiply reminders.
617
+
618
+ ### Researched: there is NO context-pressure hook, on either backend
619
+
620
+ Measured, so do not go looking again. Claude Code 2.1.220 exposes **31 hook events** and not one
621
+ signals an approaching context limit; **no hook input carries a token count at all**, and the docs say
622
+ plainly to poll the transcript yourself. Codex is the same. So the fill is computed here from the
623
+ transcript's newest usage record (`input + cache_creation + cache_read`), reading only the file's tail
624
+ because transcripts reach tens of megabytes. Growth-since-last-write remains the metric because the
625
+ window is not knowable from a hook.
626
+
627
+ Useful events noticed while enumerating, none wired yet: `PostToolBatch` (fires after a batch of
628
+ parallel tool calls resolves, before the next model call), `SubagentStart` (could carry the
629
+ read-only-pad rule structurally instead of via the dispatch epilogue's prose), `PostCompact`
630
+ (receives the summary), and `SessionStart`'s `initialUserMessage` output (injects a VISIBLE user
631
+ message rather than a system reminder) and `fork` matcher.
632
+
633
+ ### Rejected: a blocking Stop gate
634
+
635
+ A Stop hook could refuse to let the worker rest until it writes. That was tried and removed on
636
+ 2026-07-02 (maintainer's call): the block-until-file-edited nag forced even trivial workers into
637
+ Read/Edit dances that render as noise in the chat UI. This nudges; it never blocks.
638
+
639
+ ### Verified feasible, NOT yet built: the background checkpoint fork
640
+
641
+ `claude -p --resume <sid> --fork-session` was tested end to end. The fork carries the FULL original
642
+ conversation (it reproduced a human quote that existed only in that transcript), it wrote to the
643
+ ORIGINAL thread's scratchpad when handed the absolute path, the original transcript was
644
+ **byte-identical before and after** (15,078 → 15,078 — the live session is never interrupted), the
645
+ original continued normally afterwards, and it is cheap because it HITS THE PROMPT CACHE (28,904
646
+ cache-read vs 413 cache-create). `SessionStart` has a `fork` matcher so hooks can tell they are in one.
647
+
648
+ Open problems before this could ship: the fork gets a NEW session id, so it must be handed the
649
+ ORIGINAL pad path explicitly (its own hooks would derive a different one); concurrent writes against
650
+ the live worker need an ownership rule; the spawn needs a single-flight lock so one threshold crossing
651
+ cannot fan out into many forks; and fray's discover/tailer must not adopt the fork's transcript as a
652
+ board thread.
653
+
654
+ ### Codex parity gap
655
+
656
+ Codex 0.144.6 (installed) HAS a hooks system with nearly the same schema — SessionStart, SessionEnd,
657
+ PreToolUse, PostToolUse, PermissionRequest, UserPromptSubmit, Stop, PreCompact, PostCompact,
658
+ SubagentStart, SubagentStop — plus `additionalContext`, all confirmed present in the installed binary.
659
+ Config lives at `~/.codex/hooks.json` or `[hooks]` in config.toml, repo-level `.codex/hooks.json`, or
660
+ plugin-bundled. SessionStart distinguishes a `compact` source. Codex is BETTER in one respect: it lets
661
+ you SET the compaction threshold (`model_auto_compact_token_limit`, with
662
+ `model_auto_compact_token_limit_scope` = `total` | `body_after_prefix`), so compaction can be made
663
+ predictable with headroom rather than merely detected. Wrinkle: codex enforces hook TRUST
664
+ (`--dangerously-bypass-hook-trust` exists for automation). **fray currently wires ZERO hooks for
665
+ codex**, so a codex worker has only prompt-level scratchpad discipline — the largest remaining gap.
666
+
667
+ ## 2026-07-30 (third pass): reinforcement is OPT-IN, and the two backends are gated differently
668
+
669
+ > **SUPERSEDED the same day by the fourth pass below** — the opt-in gate was REMOVED. Re-grounding is
670
+ > unconditional now. The measured codex findings in this entry all still hold; only the gating does not.
671
+
672
+ Maintainer's call: the mechanism is opinionated, so it should be chosen rather than inherited. New
673
+ `scratchpadReinforcement` setting, **OFF by default** — the inverse of `runtimeGate` /
674
+ `autoResumeOnLimit`, which are opt-OUT. Toggle sits in the Settings drawer under "Auto-resume after
675
+ usage limits". `scratchpad.mjs` inverted its kill switch into an opt-in gate: absence means off.
676
+
677
+ **The two backends cannot be gated in the same place**, which is the whole design constraint here:
678
+
679
+ - **Claude → a worker ENV VAR.** `hooks.json` is static, so `FRAY_SCRATCHPAD_HOOK=on` is what decides
680
+ whether the registered hooks do anything. Stamped on the tmux spawn, and on the broker/SDK path via
681
+ a new per-fork `extraWorkerEnv` on `ClaudeAgentBrokerBridge` — evaluated per fork, so flipping the
682
+ setting reaches the next dispatch or cold-resume without a server restart.
683
+ - **Codex → per-conversation CONFIG**, on the `config` override `CodexAppServerBridge` already
684
+ supported, plus a `--enabled` flag. The env var is unusable there because the `codex app-server`
685
+ daemon is SHARED per project and its environment cannot express a per-conversation decision;
686
+ building the config at all already means the setting is on.
687
+
688
+ ### Measured, so nobody re-derives it (codex-cli 0.144.6)
689
+
690
+ - **`codex exec` runs NO lifecycle hooks, from any discovery path** — not `<repo>/.codex/hooks.json`,
691
+ not `$CODEX_HOME/hooks.json`, not `-c hooks.…`, with or without `bypass_hook_trust=true`, inside a
692
+ git repo or outside one — even though the `hooks` feature flag reports as enabled. Probed with a
693
+ marker file so "did the hook RUN" stayed separate from "did its output reach the model".
694
+ - **`codex app-server` DOES run them** when they arrive as config overrides. Verified through the real
695
+ `CodexAppServerBridge` with the real `scratchpad.mjs`: SessionStart, UserPromptSubmit and Stop all
696
+ fired. Probe kept at `backend/_live_codex_hooks.mts`.
697
+ - **`bypass_hook_trust` is required** — codex SILENTLY SKIPS untrusted hook definitions, so without it
698
+ the config is delivered and ignored, which looks exactly like a broken feature.
699
+ - **Codex reports its OWN rollout session id to the hook** (e.g. `019fb427-…`, `transcript_path` under
700
+ `~/.codex/sessions`), NOT fray's thread id. Hence the mandatory `--session=<fray sessionId>`:
701
+ deriving the path would address a scratchpad that does not exist, and the worker would look
702
+ unreinforced for a reason nobody could see. Codex does send `source` (`"startup"`), same field name
703
+ as Claude, so the compact/resume branch works unchanged.
704
+ - **Codex has no PreCompact/PostCompact context-injection wire type** (only SessionStart /
705
+ UserPromptSubmit / PostToolUse / PreToolUse / PermissionRequest / SubagentStart), so the
706
+ summarizer-steering channel stays Claude-only.
707
+
708
+ ### Known gap, deliberately not papered over
709
+
710
+ The codex NUDGE cannot fire yet: staleness is computed from Claude's transcript shape
711
+ (`message.usage`), and a codex rollout is a different format, so `contextTokens()` returns null and
712
+ the nudge degrades to SILENCE rather than to a wrong number — pinned by a test. Closing it needs a
713
+ rollout-aware token parser. The load-bearing channel (restoring the pad on SessionStart) works on both.
714
+
715
+ ## 2026-07-30 (fourth pass): the gate comes OFF — the scratchpad is canonical, so re-grounding is not optional
716
+
717
+ Maintainer's correction, and it reverses the third pass's central decision: the thing that deserves an
718
+ opt-in is the FORK-based auto-updating (still unbuilt, and called "a little overkill"), NOT the
719
+ re-grounding. The scratchpad is the CANONICAL document for a thread, so reading it back after a
720
+ compaction is what makes the pad worth writing at all — a posture no project should have to opt into.
721
+
722
+ The bug this fixes is worse than a mis-scoped setting: `scratchpadReinforcement` defaulted to FALSE, so
723
+ the DEFAULT worker — every worker, in practice — got nothing back after a compaction. The mechanism was
724
+ shipped and inert. A feature that is off by default is a feature that does not exist.
725
+
726
+ Removed entirely: the `scratchpadReinforcement` setting (shared schema, server defaults, Settings
727
+ drawer toggle), the `FRAY_SCRATCHPAD_HOOK=on` env plumbing (`scratchpadHookEnv`, the tmux spawn stamp,
728
+ the broker bridge's `extraWorkerEnv` dep), and codex's `--enabled` flag. A dead toggle that gates
729
+ nothing is worse than no toggle; when the fork checkpointer is built it brings its own setting.
730
+
731
+ What survives: `--session=<fray sessionId>` on the codex path stays MANDATORY (codex reports its own
732
+ rollout session id, so the derived path would address a scratchpad that does not exist), and
733
+ `bypass_hook_trust` stays required (codex silently skips untrusted hook definitions). The escape hatch
734
+ is now `FRAY_SCRATCHPAD_HOOK=off` — env only, and only an explicit off value disables, because it is
735
+ for a one-off session, not a project posture.
736
+
737
+ Behavior change beyond ungating: **an EMPTY pad now re-grounds too.** Previously a compaction with an
738
+ unwritten pad fell through to the generic contract text; now it says plainly that context was just
739
+ lost, that the pad is the canonical record, to re-read it NOW, and that it currently has nothing in it
740
+ so it must be written. "You lost your context and your pad is empty" is the most actionable thing the
741
+ next turn can hear, and staying quiet about it left the worker with nothing at the exact moment it had
742
+ nothing.
743
+
744
+ VERIFIED against a real `/compact` with NO env var and NO flag set anywhere: `SessionStart:compact`
745
+ exit 0, delivering the re-grounding lead plus the pad head carrying a sentinel from the file. 17 hook
746
+ tests; full suite 2419 pass / 0 fail; typecheck clean.
747
+
748
+ ## 2026-07-30 (fifth pass): the worker CONTRACT now explains what the scratchpad is FOR
749
+
750
+ The hooks made the scratchpad load-bearing, but the shipped worker contract still described it as a
751
+ place that "survives compaction" and handed over a path. That undersells it in the way that matters:
752
+ a worker told "here is a file that survives compaction" writes a task list; a worker told "this is the
753
+ canonical record of the thread and your compaction-survival mechanism" writes the REASONING, which is
754
+ precisely what compaction destroys and what a summary cannot reconstruct.
755
+
756
+ Rewritten in all four places a worker meets the pad, so the framing is consistent wherever it lands:
757
+
758
+ - `workerPrompt.ts` `SCRATCHPAD` (both backends) — now headed "the canonical record of this thread",
759
+ and says plainly WHY: compaction drops the reasoning first (the plan, the alternatives ruled out,
760
+ why the human chose what they chose), a summary preserves what you did and not why, so write that
761
+ here AS YOU GO and re-read the file after any compaction or resume. Enumerates what belongs in it.
762
+ The claude variant keeps the sub-agent blackboard job and now states that helpers READ but never
763
+ EDIT it — the one-scratchpad rule, said where the worker actually reads it.
764
+ - `dispatch.ts` `scratchpadOrientation()` (system-level, rebuilt on every resume) and the first
765
+ user-message line — same framing, one sentence each.
766
+ - `dispatch.ts` `scratchpadContent()` — the provisioned skeleton's own orientation line.
767
+ - `cc-worker/hooks/session-seed.mjs` — the runtime `SCRATCHPAD:` line.
768
+
769
+ **Deliberately NOT promised: automatic re-injection.** The prompt says fray "helps by feeding the head
770
+ of this file back into your context", and the IMPERATIVE it gives is unconditional — re-read the file
771
+ yourself after any compaction or resume. A contract that leans on a runtime guarantee degrades badly
772
+ wherever that channel is absent, and one such gap is known: codex's `thread/resume` does NOT re-send
773
+ the per-conversation `config`, so a COLD-resumed codex thread (fray restart, app-server daemon death)
774
+ may lose its hooks. Unverified either way — flagged, not assumed.
775
+
776
+ Fallout fixed in the same change (the hook's own template detector): `substanceLength()` recognised the
777
+ provisioned skeleton by a `^(Your|SCRATCHPAD:)` prefix plus the old wording. The new orientation line
778
+ starts differently, so a freshly provisioned pad read as WRITTEN — which made an empty pad skip its
779
+ re-grounding branch and made the summarizer swallow a skeleton. It now matches on the concept
780
+ (`compaction-survival mechanism|compaction-proof working memory`), which is also backward compatible
781
+ with pads already on disk under the old wording.
782
+
783
+ Goldens regenerated (a deliberate contract change); the diff is the scratchpad section and nothing
784
+ else. Two `dispatch.test.ts` assertions re-anchored — they pin the claude-keeps/codex-drops blackboard
785
+ asymmetry and were using the retired phrase as their anchor; the behavior they check is unchanged.
786
+ Suite 2413 pass / 0 fail, typecheck clean.
787
+
788
+ ## 2026-07-30 (sixth pass): keep the helper epilogue universal
789
+
790
+ `hooks/agent-dispatch.mjs` appends its epilogue to every Claude `Agent` helper launched by a fray-ui
791
+ worker, regardless of the repository or kind of task. That makes it the wrong layer for requirements
792
+ about compilation, shared build locks, build/test ownership, or how long-running operations should
793
+ be managed.
794
+
795
+ The epilogue keeps only universal coordination: return a useful handoff, do not mutate the owning
796
+ worker's `.fray/` state unless explicitly assigned, and use the always-available `SendMessage`
797
+ upward channel when the dispatcher acting mid-flight could change the outcome. Task-specific
798
+ verification and process-lifecycle instructions belong in the dispatch prompt; repository-specific
799
+ ones belong in the repository's own guidance. Background dispatch enforcement and `name` /
800
+ `team_name` stripping are unchanged.
801
+
802
+ Correction during implementation: the first reduction also removed `SendMessage`. That was too
803
+ aggressive. Every helper receives the tool, and reaching the dispatcher before returning is
804
+ universally useful orchestration rather than repository policy. The restored wording treats it as
805
+ available directly and therefore drops the old deferred-tool / `ToolSearch` caveat.
806
+
807
+ ## 2026-07-30 (sixth pass): the operator's Settings instructions move to the SYSTEM prompt
808
+
809
+ Question from the maintainer: is FRAY.md eagerly loaded, and did the Settings prompt get replaced by
810
+ it? Answer, from the code: **both exist, neither replaced the other** — and they were being treated
811
+ very differently.
812
+
813
+ - **FRAY.md** → `frayConfigBlock(projectDir)` → `extraSystemPrompt` at EVERY site (claude dispatch,
814
+ codex dispatch, adopt, resume, broker follow-up, router follow-up). System-level, so it already
815
+ survives compaction and is rebuilt on every resume. Nothing to fix.
816
+ - **The Settings preamble** (drawer label "Subagent instructions", schema field `dispatchPreamble`) →
817
+ `composePrompt()` → the **FIRST USER MESSAGE** only. Never re-applied on resume, and the first user
818
+ message is exactly what compaction replaces with a summary. So the repo's conventions survived while
819
+ the operator's own standing instructions quietly did not.
820
+
821
+ Fixed by giving it the same treatment as FRAY.md: new `operatorInstructionsBlock(preamble)`, added to
822
+ the system-prompt composition at all six sites, and REMOVED from `composePrompt` (which loses its
823
+ `customInstructions` parameter). Moved rather than duplicated — leaving it in both would carry a second
824
+ copy of a potentially long preamble in context for the whole pre-compaction window, and the block sits
825
+ ABOVE the task banner either way, so the visible chat bubble is unchanged.
826
+
827
+ Two things fall out for free: an edited setting now reaches a thread that is ALREADY RUNNING (the
828
+ system prompt is rebuilt on every resume), and the block states its relationship to FRAY.md — follow
829
+ both, prefer the more specific where they genuinely conflict — so a worker meeting two operator-authored
830
+ surfaces knows how to reconcile them.
831
+
832
+ VERIFIED with a real `/compact`: a sentinel placed in `--append-system-prompt` was still readable by the
833
+ model after the compaction boundary (`compact_boundary` present in the transcript, model returned
834
+ `SORREL-EGRET-4402`). That is the property the move depends on, tested rather than assumed.
835
+
836
+ One test needed real rework rather than a signature patch: `server.test.ts` asserted the preamble
837
+ appeared in the composed first message — the exact behavior being moved — so it now asserts the
838
+ opposite plus a dedicated `operatorInstructionsBlock` test. A second (`dispatch.test.ts`'s banner test)
839
+ used `PROJECT INSTRUCTIONS` as its above-the-banner anchor; on an ABSENT string `indexOf(...) < banner`
840
+ passes VACUOUSLY (-1 < banner), so it was re-anchored on the scratchpad line and given an explicit
841
+ `doesNotMatch` instead. Suite 2460 pass / 0 fail, typecheck clean.
842
+
843
+ ## 2026-07-30 (seventh pass): converge on FRAY.md — the Settings preamble is GONE
844
+
845
+ Maintainer's call, one pass after making the preamble durable: rather than maintain two
846
+ operator-authored surfaces, keep the one that is already versioned with the repo. `dispatchPreamble`
847
+ (drawer label "Subagent instructions") is deleted outright — schema, server default, the drawer field
848
+ and its draft plumbing, `operatorInstructionsBlock` and all six of its call sites.
849
+
850
+ **FRAY.md is now the ONLY place project conventions live.** It was already the better surface and
851
+ needed no work: `frayConfigBlock()` puts it in `extraSystemPrompt` at every site (claude dispatch,
852
+ codex dispatch, adopt, resume, broker follow-up, router follow-up), so it survives compaction and is
853
+ rebuilt on every resume. It is also reviewable in a diff, travels with a clone, and can differ per
854
+ branch — none of which a value in a local SQLite settings blob can do.
855
+
856
+ Checked before deleting rather than after: `DEFAULT_PREAMBLE` shipped as `""`, and a read of every
857
+ `~/.fray/projects/*/ui.db` found no project with a non-empty `dispatchPreamble`. So there is nothing to
858
+ migrate and no operator text is silently dropped. `Settings` is a plain `z.object`, so an older blob
859
+ that still carries the key parses fine — zod strips the unknown field.
860
+
861
+ Fallout fixed in the same change, since it was this change that made them false: two dispatch.ts
862
+ header comments still described the preamble as the prompt's "orchestration wisdom", and
863
+ `SettingsDrawer.test.ts` iterated a help-key list containing `subagentInstructions`. The
864
+ `operatorInstructionsBlock` test added one pass earlier was removed with the function.
865
+
866
+ Suite 2419 pass / 0 fail (two timing-sensitive tests — `app-socket` coalescing and the tmux SIGKILL
867
+ buffer — flaked under parallel load and pass in isolation; neither touches this change), typecheck
868
+ clean, and the drawer was re-driven in a real browser: the field is gone, and the Prompts section
869
+ measures as exactly two children at the standard 24px `gap-6` with no orphaned container left behind.
870
+
871
+ ## 2026-07-30 (eighth pass): native Codex children merge the shared scratchpad
872
+
873
+ An apparent post-compaction continuity failure in thread `9540edc3-4807-4bb0-8e36-5940f92b452b`
874
+ was not path loss and was not Claude's on-disk token broker. The affected thread was a Codex session.
875
+ At its first compaction the worker immediately read the correct exact path; at a later compaction it
876
+ again addressed the correct path and got `ENOENT`. The directory and sibling artifacts remained.
877
+
878
+ The missing file was child corruption. Native child `/root/auditstatus_prepare`, launched with
879
+ `fork_turns:"none"`, still inherited the parent's developer-level `SCRATCHPAD:` mandate. It reasoned
880
+ "Planning isolated scratch creation" and replaced the parent's exact pad with its own `# SEA corpus
881
+ preparation` notes. Two minutes later it noticed the mistake, reasoned "Reverting unauthorized root
882
+ scratch changes", and tried to remove its replacement. A shell `rm -f` was denied, but its fallback
883
+ `apply_patch` `*** Delete File` succeeded. It could not restore the content it had overwritten, so the
884
+ pad remained absent until the root compacted nearly an hour later. `fork_turns:"none"` removes
885
+ conversational turns; it does not remove the root worker's base/developer instructions.
886
+
887
+ The deliberately prompt-level fix preserves the useful part of that inheritance:
888
+
889
+ - The Codex worker contract, system orientation, and first task message now make the pad explicitly
890
+ collaborative. Children may read it and persist their own progress, but every edit is a merge:
891
+ re-read first, patch only a scoped agent/task section, and preserve all existing state. Every
892
+ native-child dispatch restates that rule.
893
+ - Codex registers `scratchpad.mjs --mode=subagent-start` as a `SubagentStart` hook. It injects the
894
+ child-only epilogue structurally on every native dispatch: the child may update its scoped progress,
895
+ but may never delete, truncate, reinitialize, move, or replace the whole file — including as cleanup
896
+ or rollback after a mistake. This fixes the observed failure without a filesystem guard or a
897
+ single-writer restriction.
898
+ - Newly provisioned pads recommend a light structure rather than enforce a machine schema: Goal,
899
+ Task list, Decisions, Shared context, per-agent progress, Verification, and Next action. A visible
900
+ legend uses `[ ]` pending, `[/]` in progress, `[x]` complete, `[-]` cancelled, and `[?]` blocked.
901
+ Fray's Markdown renderer recognizes all five while the source stays readable in Obsidian/plain text.
902
+ - If a pad is unexpectedly absent or empty after compaction/resume, the re-grounding injection says
903
+ the exact path is authoritative, forbids searching neighboring thread pads or broadly reloading repo
904
+ docs, and tells the root to reconstruct from the retained summary plus directly named handoffs.
905
+
906
+ The root's apparently confused recovery is now explained too. The post-compaction hook correctly
907
+ reported that the pad had no substantive content, and the root's first call addressed the correct
908
+ exact path and got `ENOENT`. Only then did it search worktrees, list the surviving thread directory,
909
+ and read older handoffs to reconstruct state. Its "Writing initial scratch file" reasoning label was
910
+ model narration for recreating a currently absent file, not evidence that no pad had existed before.
911
+
912
+ ## 2026-07-31: shared scratchpad updates are expected, not merely permitted
913
+
914
+ The collaborative contract above said children *may* update the shared scratchpad. A root worker then
915
+ treated batch file ownership and a dynamic-orchestrator mega-doc's single-writer rule as reasons to
916
+ centralize scratchpad writes and even make the pad immutable. That was the opposite of the intended
917
+ blackboard pattern.
918
+
919
+ The worker contract, dispatch orientation, Codex child hook, and Claude dispatch epilogue now say each
920
+ sub-agent should merge its own scoped progress as it works. Deliverable file ownership never includes
921
+ the Fray scratchpad, and a root must reconcile concurrent scoped updates rather than act as sole
922
+ writer. The existing merge-safety rules remain unchanged: re-read first, preserve all other content,
923
+ and never delete, truncate, reinitialize, move, or replace the pad.