frayui 0.1.1 → 0.1.3

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 (102) hide show
  1. package/dist/claude-agent-broker.js +19 -3
  2. package/dist/codex-app-server-daemon.js +1 -1
  3. package/dist/dev-child.js +786 -331
  4. package/dist/frayui.js +1291 -193
  5. package/package.json +8 -7
  6. package/runtime/cc-worker/DECISIONS.md +86 -0
  7. package/runtime/cc-worker/hooks/agent-dispatch.mjs +18 -4
  8. package/runtime/cc-worker/hooks/fence-stop.mjs +290 -0
  9. package/runtime/cc-worker/hooks/hooks.json +2 -1
  10. package/runtime/cc-worker/hooks/scratchpad.mjs +10 -9
  11. package/runtime/cc-worker/hooks/session-seed.mjs +2 -1
  12. package/runtime/cc-worker/skills/gh/SKILL.md +22 -0
  13. package/runtime/cc-worker/skills/handoff/SKILL.md +6 -0
  14. package/web-dist/assets/{TerminalPane-C3CC9EFZ.js → TerminalPane-hMf6ifuS.js} +1 -1
  15. package/web-dist/assets/{abnfDiagram-VRR7QNED-DqX9eyjt.js → abnfDiagram-VRR7QNED-CQj6fwXy.js} +1 -1
  16. package/web-dist/assets/architecture-TIHT7OUA-JX8_jS4I.js +1 -0
  17. package/web-dist/assets/{architectureDiagram-ZJ3FMSHR-M8pzerdN.js → architectureDiagram-ZJ3FMSHR-CoE5ct03.js} +1 -1
  18. package/web-dist/assets/{blockDiagram-677ZJIJ3-DqR6Y2LC.js → blockDiagram-677ZJIJ3-CEudy3zd.js} +1 -1
  19. package/web-dist/assets/{c4Diagram-LMCZKHZV-BVYFjsdW.js → c4Diagram-LMCZKHZV-ChW62IOH.js} +1 -1
  20. package/web-dist/assets/channel-y3u1yiAC.js +1 -0
  21. package/web-dist/assets/{chunk-32BRIVSS-DFjDHOoM.js → chunk-32BRIVSS-Bh-A2le2.js} +1 -1
  22. package/web-dist/assets/{chunk-52WLFC77-CgT8QhTR.js → chunk-52WLFC77-7Gia-F6f.js} +1 -1
  23. package/web-dist/assets/{chunk-C7G6YPKG-CzJ6LiOY.js → chunk-C7G6YPKG-C0MCv0Ym.js} +1 -1
  24. package/web-dist/assets/{chunk-EX3LRPZG-BxLQFQHC.js → chunk-EX3LRPZG-DPRO7Ss0.js} +1 -1
  25. package/web-dist/assets/{chunk-FWX5IMBZ-Dph1CDtO.js → chunk-FWX5IMBZ-Cod9MA7c.js} +2 -2
  26. package/web-dist/assets/{chunk-HOUHSVGY-DkAureev.js → chunk-HOUHSVGY-D1oHrXQx.js} +1 -1
  27. package/web-dist/assets/{chunk-ICXQ74PX-Cz4NMI__.js → chunk-ICXQ74PX-DBLMA0ZN.js} +1 -1
  28. package/web-dist/assets/{chunk-MOJQB5TN-CsLU1DoU.js → chunk-MOJQB5TN-jiLvxNvx.js} +1 -1
  29. package/web-dist/assets/{chunk-OGEWGWER-Aylt7fzg.js → chunk-OGEWGWER-w1RtF7G0.js} +1 -1
  30. package/web-dist/assets/{chunk-PUDLZKDR-BFErObxQ.js → chunk-PUDLZKDR-DQlFMKm5.js} +1 -1
  31. package/web-dist/assets/{chunk-Q4XR5HBZ-Bv6INKwj.js → chunk-Q4XR5HBZ-CfD6vdr0.js} +1 -1
  32. package/web-dist/assets/{chunk-V7JOEXUC-DfztwFA4.js → chunk-V7JOEXUC-BRB1iO1P.js} +1 -1
  33. package/web-dist/assets/{chunk-VAUOI2AC-B0f7ZK3T.js → chunk-VAUOI2AC-CCeJEpxW.js} +1 -1
  34. package/web-dist/assets/{chunk-VR4S4FIN-G-Gcy_AM.js → chunk-VR4S4FIN-DfPRj9a1.js} +1 -1
  35. package/web-dist/assets/{chunk-WYO6CB5R-CMp0iowL.js → chunk-WYO6CB5R-BWd84n6L.js} +1 -1
  36. package/web-dist/assets/{chunk-ZGVPDNZ5-CSWMiX8t.js → chunk-ZGVPDNZ5-Bm-3TA9N.js} +1 -1
  37. package/web-dist/assets/classDiagram-OUVF2IWQ-BL8mggXA.js +1 -0
  38. package/web-dist/assets/classDiagram-v2-EOCWNBFH-BL8mggXA.js +1 -0
  39. package/web-dist/assets/{cynefin-VYW2F7L2-CUMD23BD.js → cynefin-VYW2F7L2-BylEqg5y.js} +1 -1
  40. package/web-dist/assets/{cynefinDiagram-TSTJHNR4-fZTm6GyW.js → cynefinDiagram-TSTJHNR4-CRfGS7Ou.js} +1 -1
  41. package/web-dist/assets/{dagre-VKFMJZFB-CUCi5JnH.js → dagre-VKFMJZFB-CNeTu13Z.js} +1 -1
  42. package/web-dist/assets/{diagram-FQU43EPY-C87Fqvwa.js → diagram-FQU43EPY-BYZKetkJ.js} +1 -1
  43. package/web-dist/assets/{diagram-G47NLZAW-CDxQ-BXA.js → diagram-G47NLZAW-DSOzyl5U.js} +1 -1
  44. package/web-dist/assets/{diagram-NH7WQ7WH-Uceffn93.js → diagram-NH7WQ7WH-DFZCvfJN.js} +1 -1
  45. package/web-dist/assets/{diagram-OA4YK3LP-CSSp0Fa1.js → diagram-OA4YK3LP-m0qY8Zlg.js} +1 -1
  46. package/web-dist/assets/{diagram-WEI45ONY-DS5sAZPp.js → diagram-WEI45ONY-Cg7Qz-0T.js} +1 -1
  47. package/web-dist/assets/{ebnfDiagram-CCIWWBDH-aM331oVj.js → ebnfDiagram-CCIWWBDH-DGvTjoXK.js} +1 -1
  48. package/web-dist/assets/{erDiagram-Q63AITRT-d0i1kydx.js → erDiagram-Q63AITRT-BFqTK5TB.js} +1 -1
  49. package/web-dist/assets/eventmodeling-45OFAUF4-BaCHCepY.js +1 -0
  50. package/web-dist/assets/flowDiagram-23GEKE2U-Cjl6weoT.js +1 -0
  51. package/web-dist/assets/{ganttDiagram-NO4QXBWP-Cd0OpzcB.js → ganttDiagram-NO4QXBWP-KbR9IE0O.js} +1 -1
  52. package/web-dist/assets/{gitGraph-TEB2WS4Q-BeI6iDaZ.js → gitGraph-TEB2WS4Q-hCbzZO_l.js} +1 -1
  53. package/web-dist/assets/{gitGraphDiagram-IHSO6WYX-QvePTKQE.js → gitGraphDiagram-IHSO6WYX-CNPRTwQG.js} +1 -1
  54. package/web-dist/assets/index-BUY5ijyI.css +1 -0
  55. package/web-dist/assets/{index-CJ-cmbhi.js → index-Bj6YWd3g.js} +68 -68
  56. package/web-dist/assets/{info-DKCQHKI2-WwdGk87J.js → info-DKCQHKI2-Zd_TXMfr.js} +1 -1
  57. package/web-dist/assets/{infoDiagram-FWYZ7A6U-C3ARX7jm.js → infoDiagram-FWYZ7A6U-DQ9zmVhJ.js} +1 -1
  58. package/web-dist/assets/{ishikawaDiagram-FXEZZL3T-C_zMKLif.js → ishikawaDiagram-FXEZZL3T-BFyJ5E_L.js} +1 -1
  59. package/web-dist/assets/{journeyDiagram-5HDEW3XC-BcIZu4Cm.js → journeyDiagram-5HDEW3XC-BgjbY7_T.js} +1 -1
  60. package/web-dist/assets/{kanban-definition-HUTT4EX6-wwc4iI2z.js → kanban-definition-HUTT4EX6-mpUEl8xx.js} +1 -1
  61. package/web-dist/assets/{line-BCQpqxAq.js → line-CM_FUYGm.js} +1 -1
  62. package/web-dist/assets/{mermaid-parser.core-8y_8tv09.js → mermaid-parser.core-CTVz2V70.js} +3 -3
  63. package/web-dist/assets/{mermaid.core-gXHP6zd5.js → mermaid.core-BCLNmVvR.js} +3 -3
  64. package/web-dist/assets/{mindmap-definition-LN4V7U3C-DtTZ-e8A.js → mindmap-definition-LN4V7U3C-CgJU-6O7.js} +1 -1
  65. package/web-dist/assets/{packet-7NZHBO7P-DZbgcVcI.js → packet-7NZHBO7P-rkLFb4ZX.js} +1 -1
  66. package/web-dist/assets/{pegDiagram-2B236MQR-DHtrQw0R.js → pegDiagram-2B236MQR-Sy2oYuu1.js} +1 -1
  67. package/web-dist/assets/{pie-RZYD4A2V-CBwiKeCv.js → pie-RZYD4A2V-0yTxNtqZ.js} +1 -1
  68. package/web-dist/assets/{pieDiagram-ENE6RG2P-B-gKjdFc.js → pieDiagram-ENE6RG2P-CQ7YFWhH.js} +1 -1
  69. package/web-dist/assets/{quadrantDiagram-ABIIQ3AL-C3h180Er.js → quadrantDiagram-ABIIQ3AL-ClCTxrOj.js} +1 -1
  70. package/web-dist/assets/{radar-I7S5WNFK-BTAfmGFj.js → radar-I7S5WNFK-JUOW5mdN.js} +1 -1
  71. package/web-dist/assets/{railroad-3IZDKUUU-DjkPg0H7.js → railroad-3IZDKUUU-DJ2IS1Jg.js} +1 -1
  72. package/web-dist/assets/railroad-abnf-AHOZXSZD-CU4HK7Ek.js +1 -0
  73. package/web-dist/assets/railroad-ebnf-EBAXGLYW-CarBAd7R.js +1 -0
  74. package/web-dist/assets/railroad-peg-LSFZ7HO6-ByEhVLyD.js +1 -0
  75. package/web-dist/assets/{railroadDiagram-RFXS5EU6-FVI9rArP.js → railroadDiagram-RFXS5EU6-BNsVGeA3.js} +1 -1
  76. package/web-dist/assets/{requirementDiagram-TGXJPOKE-Cp3pp65R.js → requirementDiagram-TGXJPOKE-BHa3IX0k.js} +1 -1
  77. package/web-dist/assets/{sankeyDiagram-HTMAVEWB-B-EVqI0F.js → sankeyDiagram-HTMAVEWB-CSbsLbtw.js} +1 -1
  78. package/web-dist/assets/{sequenceDiagram-DBY2YBRQ-Bov70rRs.js → sequenceDiagram-DBY2YBRQ-BgVWj1nN.js} +1 -1
  79. package/web-dist/assets/{stateDiagram-2N3HPSRC-BW7fwttw.js → stateDiagram-2N3HPSRC-BKHYNxA3.js} +1 -1
  80. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-C-k0bbcG.js +1 -0
  81. package/web-dist/assets/{swimlanes-5IMT3BWC-BUOdjOgW.js → swimlanes-5IMT3BWC-BluloqBJ.js} +1 -1
  82. package/web-dist/assets/swimlanesDiagram-G3AALYLV-DVjLYZbp.js +8 -0
  83. package/web-dist/assets/{timeline-definition-FHXFAJF6-C20Ho0e9.js → timeline-definition-FHXFAJF6-Bj1WKNgB.js} +1 -1
  84. package/web-dist/assets/{treeView-QDETBFTQ-DHg1NMOK.js → treeView-QDETBFTQ-Dj5T24Qr.js} +1 -1
  85. package/web-dist/assets/{treemap-6X3UGDF4-B-aFFjeG.js → treemap-6X3UGDF4-BPqewrXp.js} +1 -1
  86. package/web-dist/assets/{vennDiagram-L72KCM5P-CEWY6HTS.js → vennDiagram-L72KCM5P-BhggGhQv.js} +1 -1
  87. package/web-dist/assets/{wardley-OPB4EBWU-BpAKElbJ.js → wardley-OPB4EBWU-Di9GWKYt.js} +1 -1
  88. package/web-dist/assets/{wardleyDiagram-EHGQE667-CYer_Ry4.js → wardleyDiagram-EHGQE667-D-mtWLKf.js} +1 -1
  89. package/web-dist/assets/{xychartDiagram-FW5EYKEG-BxMRGqds.js → xychartDiagram-FW5EYKEG-CU0us6D_.js} +1 -1
  90. package/web-dist/index.html +2 -2
  91. package/web-dist/assets/architecture-TIHT7OUA-Dp81OE9d.js +0 -1
  92. package/web-dist/assets/channel-BLJ4T4FR.js +0 -1
  93. package/web-dist/assets/classDiagram-OUVF2IWQ-BQSt_EhF.js +0 -1
  94. package/web-dist/assets/classDiagram-v2-EOCWNBFH-BQSt_EhF.js +0 -1
  95. package/web-dist/assets/eventmodeling-45OFAUF4-qO9fgTC2.js +0 -1
  96. package/web-dist/assets/flowDiagram-23GEKE2U-BUHZX4Ft.js +0 -1
  97. package/web-dist/assets/index-LON0oNCJ.css +0 -1
  98. package/web-dist/assets/railroad-abnf-AHOZXSZD-DlUkYI_B.js +0 -1
  99. package/web-dist/assets/railroad-ebnf-EBAXGLYW-DptyGTmD.js +0 -1
  100. package/web-dist/assets/railroad-peg-LSFZ7HO6-xPHpoacS.js +0 -1
  101. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-BtaoEJDP.js +0 -1
  102. package/web-dist/assets/swimlanesDiagram-G3AALYLV-Bpiu68M1.js +0 -8
package/package.json CHANGED
@@ -1,15 +1,16 @@
1
1
  {
2
2
  "name": "frayui",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "private": false,
5
- "description": "Local orchestration surface for Fray projects a per-repo web UI over your .fray/ agent threads",
5
+ "description": "A local dashboard for running many coding agents at once",
6
6
  "keywords": [
7
7
  "fray",
8
- "claude",
9
- "claude-code",
10
8
  "agents",
9
+ "claude-code",
10
+ "codex",
11
11
  "orchestration",
12
- "tui"
12
+ "dashboard",
13
+ "ai"
13
14
  ],
14
15
  "license": "MIT",
15
16
  "author": "Colin McDonnell",
@@ -37,13 +38,13 @@
37
38
  "fray-dev:uninstall": "nub scripts/install-global-cli.mjs --uninstall",
38
39
  "install:global": "nub scripts/install-global-cli.mjs",
39
40
  "typecheck": "tsc -b packages/shared packages/rpc packages/server . && pnpm --filter @fray-ui/web typecheck",
40
- "test": "nub --test --test-force-exit 'scripts/**/*.test.mjs' 'src/**/*.test.ts' 'packages/shared/src/**/*.test.ts' 'packages/server/src/**/*.test.ts' 'packages/web/src/**/*.test.ts'",
41
+ "test": "nub --test --test-force-exit 'board/*.test.mjs' 'scripts/**/*.test.mjs' 'src/**/*.test.ts' 'packages/shared/src/**/*.test.ts' 'packages/server/src/**/*.test.ts' 'packages/web/src/**/*.test.ts'",
41
42
  "prepack": "node ./scripts/prepare-package.mjs && node ./scripts/build-package.mjs && node ./scripts/publish-manifest.mjs --strip",
42
43
  "postpack": "node ./scripts/publish-manifest.mjs --restore"
43
44
  },
44
45
  "dependencies": {
45
46
  "@parcel/watcher": "^2.5",
46
- "better-sqlite3": "^12",
47
+ "better-sqlite3": "^13",
47
48
  "node-pty": "^1.1"
48
49
  },
49
50
  "devDependencies": {
@@ -921,3 +921,89 @@ sub-agent should merge its own scoped progress as it works. Deliverable file own
921
921
  the Fray scratchpad, and a root must reconcile concurrent scoped updates rather than act as sole
922
922
  writer. The existing merge-safety rules remain unchanged: re-read first, preserve all other content,
923
923
  and never delete, truncate, reinitialize, move, or replace the pad.
924
+
925
+ ## 2026-07-31: the epilogue teaches a helper how to collect a helper of its OWN
926
+
927
+ A three-level dispatch tree read, on the board, as one job done three times — root "Improve server
928
+ logging…", child "Researching dev server readouts", grandchild "Survey dev server readouts". The
929
+ topology was actually sound: the root's task had five research prongs, the child kept four (Vite,
930
+ Next and wrangler source, repaint techniques, log-dir conventions, fray's own state-dir convention)
931
+ and delegated only the pure-web prong. The grandchild's prompt was a strict subset, not a copy.
932
+
933
+ What was broken was COLLECTION. The child backgrounded its helper, then hand-rolled a wait loop over
934
+ `/private/tmp/claude-<uid>/<proj>/<session>/tasks/<agentId>.output`. That path is a **symlink** into
935
+ `~/.claude/projects/<proj>/<session>/subagents/agent-<id>.jsonl`, so `stat -f %z` without `-L`
936
+ returned the LINK's size — 153, the length of its target path — and `stat -f %m` returned the link's
937
+ frozen creation mtime. Its other predicate, `grep -c '"type":"result"'`, is a known false negative
938
+ because that record is not reliably written. Both halves false-negatived at once: a helper that was
939
+ 195 records and 413,723 bytes deep read as `size=153 age=325s results=0`. The child concluded "the
940
+ helper's transcript is stale at only 153 bytes", discarded a live agent, and redid its work — which
941
+ is precisely why all three levels appeared to be doing the same thing.
942
+
943
+ The audience was the root cause. `workerPrompt.ts` does carry the rule ("keep fan-out shallow: a
944
+ rested sub-agent is not reliably re-woken by grandchildren"), but that contract reaches only the ROOT
945
+ worker — the one agent that does not spawn grandchildren — and the contract itself states children
946
+ inherit none of it. Into that silence an inherited user-level `CLAUDE.md` ("the parent stays awake and
947
+ polls the child's transcript inside its own turn") supplied the polling recipe, overriding the Agent
948
+ tool's own result text ("you will be notified automatically when it completes").
949
+
950
+ `hooks/agent-dispatch.mjs` is the fix's home because it fires at EVERY depth — verified against the
951
+ real transcripts: both the depth-1 and depth-2 children received the epilogue. It is the only seam
952
+ that reaches a nested dispatcher without depending on its parent remembering to restate the norm. The
953
+ new paragraph states that a helper's completion arrives automatically, forbids hand-rolled wait loops
954
+ over a transcript or `.output` path, names the symlink and `"type":"result"` failure modes concretely
955
+ enough that a model cannot rationalize its way back into polling, and asks a dispatcher to give its
956
+ helper a `description` naming the narrower slice so the tree stays readable.
957
+
958
+ This stays within the universal-coordination boundary set on 2026-07-30: it is agent-lifecycle
959
+ coordination, like the handoff contract and the `SendMessage` upward channel already in the epilogue,
960
+ and imposes no build, test, git, or repo-specific policy. Rejected: denying depth-2 dispatch in the
961
+ hook (too blunt — the decomposition was good, and this hook exists because a worker MAY spin up
962
+ helpers), and rewriting the deliberate "keep fan-out shallow" line in `workerPrompt.ts`.
963
+
964
+ `packages/server/src/agent-dispatch-hook.test.ts` is new — this hook had no test at all despite being
965
+ load-bearing. It spawns the real hook over the real stdin contract and pins the foreground denial,
966
+ `name`/`team_name` stripping, endsWith-idempotence (including the prompt that merely QUOTES the
967
+ marker), the nested-dispatch paragraph, the retained coordination text, the non-worker inert path,
968
+ and fail-open on bad input.
969
+
970
+ ## 2026-07-31: the fence check at rest — a Stop hook for a decision handed back in prose
971
+
972
+ `hooks/fence-stop.mjs` is new, wired on `Stop` for both backends (plugin `hooks.json`, and
973
+ `codexScratchpadHookConfig` in `packages/server/src/dispatch.ts`). It blocks ONCE when a worker comes
974
+ to rest with no ` ```question `/` ```done `/` ```awaiting ` fence and its closing text hands a decision
975
+ back to the human, and asks it to classify: fence it as a real question, decide it, or rest again.
976
+
977
+ MEASURED, then built. A scan of 532 real worker transcripts (session ids cross-referenced against every
978
+ `~/.fray/projects/*/ui.db`), 4,709 rest turns, shows fence use decaying monotonically with session
979
+ depth — ` ```question ` 23% at the first rest against 9% past the twentieth, ` ```done ` 31% against
980
+ 2%, fenceless 45% against 83%. The cause is DEPTH, not compaction: turns before a compaction boundary
981
+ are already 82% fenceless, so the decay is complete before any summary is written, and compaction only
982
+ correlates because only long sessions reach it. The contract is in the system prompt and survives
983
+ compaction, so restating it earlier cannot help; something has to read the actual final message.
984
+
985
+ ASK-ONLY, on the numbers. 9.8% of fenceless rests close by deferring a decision ("your call", "want me
986
+ to …?"), and a prose ask is the expensive miss — it renders as an ordinary handoff card with nothing to
987
+ click and does not break through a Snooze. Only 3% carry a landed claim, and a "that looked done" nudge
988
+ is actively dangerous, since ` ```done ` is a DISMISSAL and "uncertain is not done" is the contract's
989
+ own rule. A done detector was built, measured, and dropped. A trailing-question-mark rule was also
990
+ dropped: over 3,239 fenceless rests it added exactly one hit the deferral phrases had not caught.
991
+
992
+ NOT the 2026-07-02 blocking-Stop mistake. That gate demanded a FILE EDIT and forced trivial workers
993
+ into Read/Edit dances. This one demands nothing — its third branch is "it was rhetorical, just rest
994
+ again" — and it fires at most once per human turn.
995
+
996
+ THE PAYLOAD, NOT THE TRANSCRIPT. First cut read the final message off `transcript_path` and fired on
997
+ only two of four live broker workers: the transcript is written asynchronously, so at Stop time the
998
+ message that just ended the turn may not be on disk. The Stop payload carries `last_assistant_message`
999
+ and `prompt_id` (verified on the wire, cli 2.1.220); using those is exact and race-free, and `prompt_id`
1000
+ is the natural one-shot key because it is stable across a blocked continuation. Transcript parsing
1001
+ remains only as the fallback for a payload that lacks them (codex, or an older shape). After the switch,
1002
+ 3/3 live workers fired.
1003
+
1004
+ VERIFIED END TO END, not just unit-tested: real broker workers on an isolated stack, driven to a
1005
+ fenceless prose ask, were blocked and re-emitted answerable cards — two as a ` ```question ` fence, one
1006
+ by calling the native `AskUserQuestion` tool (equally answerable on the broker path, where
1007
+ `FRAY_NATIVE_ASK=1` renders it as a card). This supersedes the never-built `.fray/fenceless-rest-nudge.md`
1008
+ design, whose premise — poke EVERY fenceless rest — is wrong under the current contract, whose bare rest
1009
+ is the legitimate ordinary handoff.
@@ -6,9 +6,22 @@
6
6
  // foreground agent blocks the worker's turn; a human interjection orphans it).
7
7
  // 2) STRIP `name`/`team_name` — setting either strands a nested dispatch (its result routes
8
8
  // wrong and never returns cleanly), so scrub both silently.
9
- // 3) AUTO-APPEND a repo-neutral ORCHESTRATION EPILOGUE so helpers return a useful handoff
10
- // and know how to reach their dispatcher mid-flight without imposing build, test, git,
11
- // compilation, or process-lifecycle policy on arbitrary repos.
9
+ // 3) AUTO-APPEND a repo-neutral ORCHESTRATION EPILOGUE so helpers return a useful handoff,
10
+ // know how to reach their dispatcher mid-flight, and collect a helper of their OWN correctly
11
+ // — without imposing build, test, git, compilation, or process-lifecycle policy on
12
+ // arbitrary repos.
13
+ //
14
+ // WHY THE NESTED-DISPATCH PARAGRAPH EXISTS (2026-07-31): this hook fires at EVERY depth, but the
15
+ // fray worker contract reaches only the ROOT worker — so its "keep fan-out shallow / a rested agent
16
+ // is not reliably re-woken by grandchildren" rule was delivered exclusively to the one agent that
17
+ // does not spawn grandchildren, and withheld from the depth-1 child that does. Into that silence a
18
+ // user-level CLAUDE.md ("the parent stays awake and polls the child's transcript") supplied a
19
+ // hand-rolled polling recipe, and it failed: the `.output` path is a SYMLINK, so `stat` without -L
20
+ // returns the LINK's size (= the length of its target path) and its frozen creation mtime, while
21
+ // the `"type":"result"` record is never reliably written. Both halves of the predicate
22
+ // false-negatived at once, a live 413KB helper read as "size=153 age=325s results=0", and the
23
+ // dispatcher discarded it and redid the work itself. This paragraph is the only place that reaches
24
+ // a nested dispatcher without depending on its parent remembering to restate the norm.
12
25
  //
13
26
  // GATE: inert unless FRAY_UI_THREAD is set (not a fray-ui worker → allow every dispatch unmodified).
14
27
  //
@@ -25,7 +38,8 @@ const EPILOGUE = `
25
38
  ---
26
39
  [ORCHESTRATION EPILOGUE — auto-appended by the fray worker dispatch hook] You are a helper sub-agent for a fray-ui worker. Your final message is the handoff: report your outcome/status; what you did; changed files, artifacts, and commit SHA when applicable; verification or evidence and its result; caveats or unresolved work; and the next action when one remains. A bare "done" or progress-only message is not a complete handoff.
27
40
  The worker's scratchpad is shared: merge your own scoped progress into it as you work rather than leaving the root as its sole writer. Re-read before every edit and preserve every other agent's content. This exact scratchpad is Fray coordination state, not a project deliverable or source edit: a scoped merge is an explicit exception to delegated phrases such as "write only <path>" or "do not modify the repo"; never classify it as unauthorized or roll it back. Never delete, truncate, reinitialize, move, or replace the whole file. Other project files, including repository-root files, remain governed by your delegated authority; their location alone neither permits nor forbids editing. Do not edit other \`.fray/\` state unless your prompt explicitly asks you to.
28
- You have an upward channel while you work: \`SendMessage({to: "main", summary: "<5-10 words>", message: "…"})\` delivers to your dispatcher. Use it when the dispatcher acting before you finish could change the outcome—for example, when you hit an unresolved blocker, complete a milestone another task needs, or discover that your instructions should change. Do not use it for routine progress updates.`;
41
+ You have an upward channel while you work: \`SendMessage({to: "main", summary: "<5-10 words>", message: "…"})\` delivers to your dispatcher. Use it when the dispatcher acting before you finish could change the outcome—for example, when you hit an unresolved blocker, complete a milestone another task needs, or discover that your instructions should change. Do not use it for routine progress updates.
42
+ If you dispatch a helper of your own, its completion is delivered to you automatically. Never hand-roll a wait loop over a helper's transcript or \`.output\` path to decide whether it finished: that path is a SYMLINK, so \`stat\` without \`-L\` reports the link's own size (the length of its target path, ~150 bytes) and its frozen creation mtime, and the \`"type":"result"\` record is not reliably written — so a helper that is working hard reads as tiny, stale, and dead, and you will discard live work and redo it. Judge a helper only by its completion notification or the text it returns. Give it a \`description\` naming its narrower slice rather than restating your own, so the dispatch tree stays readable.`;
29
43
 
30
44
  /** @param {unknown} obj @returns {never} */
31
45
  function emit(obj) {
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // Stop hook (fray-worker) — catches a rest that hands a DECISION to the human in prose instead of in
4
+ // a ```question fence. Run directly with node (zero deps, max Node compat), mirroring the other hooks.
5
+ //
6
+ // WHY THIS EXISTS — measured, not felt. Scanning 532 real worker transcripts (session ids
7
+ // cross-referenced against every `~/.fray/projects/*/ui.db`), 4,709 rest turns, fence use decays
8
+ // monotonically with how deep the session is:
9
+ //
10
+ // rest turn #1 #2-3 #4-6 #7-10 #11-20 #21+
11
+ // ```question 23% 22% 20% 20% 16% 9%
12
+ // ```done 31% 23% 17% 13% 10% 2%
13
+ // no fence 45% 53% 58% 60% 68% 83%
14
+ //
15
+ // It is DEPTH, not compaction: turns BEFORE a compaction boundary are already 82% fenceless, so the
16
+ // decay is fully present before any summary is written (compaction only correlates because only long
17
+ // sessions compact). The contract lives in the system prompt and does survive compaction — what decays
18
+ // is attention to it. So re-stating the rule earlier in the context cannot fix this; something has to
19
+ // look at the ACTUAL final message at the moment of rest. That is this hook.
20
+ //
21
+ // SCOPE — asks only, never completion. 9.8% of fenceless rests close by deferring a decision to the
22
+ // human ("your call", "want me to …?"); those are the expensive miss, because a prose ask renders as an
23
+ // ordinary handoff card with nothing to click and does not break through a Snooze, so the human has to
24
+ // read the whole message to discover they are blocking. Only 3% of fenceless rests carry a landed
25
+ // claim, and telling a worker "that looked done" is actively dangerous — ```done is a DISMISSAL that
26
+ // files the thread away, and "uncertain is not done" is the contract's own rule. Measured cost/benefit
27
+ // said ask-only; do not add a done detector without re-measuring.
28
+ //
29
+ // NOT COERCIVE, AND ONE-SHOT. A blocking Stop gate was tried and removed once before (2026-07-02: the
30
+ // block-until-file-edited nag forced trivial workers into Read/Edit dances that render as chat noise),
31
+ // so this one never demands work: its third branch is explicitly "this was rhetorical — just rest
32
+ // again". It fires at most ONCE per human turn (keyed on the turn ordinal, not on the message, so the
33
+ // re-emitted message cannot re-trigger it) and additionally stands down when `stop_hook_active` says a
34
+ // Stop hook is already driving the continuation.
35
+ import { readFileSync, writeFileSync, renameSync, rmSync, statSync, openSync, readSync, closeSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import { currentSessionId } from '../scripts/fray/config.mjs';
38
+
39
+ /** How much of the message's CLOSING is searched for the ask. The deferral lives in the sign-off, and
40
+ * a whole-message search fires on a "say the word" from three paragraphs up in a report that then
41
+ * ends on a completion note. Tuned on the corpus: 900 chars keeps 316 of the 349 whole-message hits. */
42
+ const TAIL_CHARS = 900;
43
+
44
+ /** Below this, a message is a greeting or a one-liner, not a handback ("Hi! What would you like to
45
+ * work on?" was the only false positive the corpus sample turned up). */
46
+ const MIN_CHARS = 200;
47
+
48
+ /** Bytes of transcript tail read to find the final assistant message. Transcripts reach tens of
49
+ * megabytes; the last few records are all this needs. */
50
+ const TAIL_BYTES = 512 * 1024;
51
+
52
+ // ---- fence grammar ----------------------------------------------------------------------------
53
+ // Mirrors the server's scanners (packages/server/src/tailer.ts `QUESTION_BLOCK_RE` /
54
+ // `SIGNAL_FENCE_RE`) by hand — a hook cannot import from the app. Kept deliberately SIMPLE: a false
55
+ // "already fenced" reading only costs a missed nudge, which is the safe direction.
56
+ const QUESTION_BLOCK_RE = /^```question(?:[ \t]+[A-Za-z][^\r\n]*?)?[ \t]*\n[\s\S]*?\n```[ \t]*$/m;
57
+ const SIGNAL_FENCE_RE = /^```(done|awaiting)[ \t]*\n[\s\S]*?\n```[ \t]*$/gm;
58
+
59
+ /** The signal/question fence a final message already carries, or null. @param {string} text */
60
+ export function fenceOf(text) {
61
+ const norm = String(text ?? '').replace(/\r\n/g, '\n');
62
+ if (QUESTION_BLOCK_RE.test(norm)) return 'question';
63
+ SIGNAL_FENCE_RE.lastIndex = 0;
64
+ let kind = null;
65
+ let end = 0;
66
+ for (let m = SIGNAL_FENCE_RE.exec(norm); m !== null; m = SIGNAL_FENCE_RE.exec(norm)) {
67
+ kind = m[1]; // last-fence-wins, matching the server
68
+ end = m.index + m[0].length;
69
+ }
70
+ // The fence only signals when it CLOSES the message — prose after it means it was quoted.
71
+ return kind && norm.slice(end).trim() === '' ? kind : null;
72
+ }
73
+
74
+ // ---- the ask detector -------------------------------------------------------------------------
75
+ // Phrases that hand a decision back. Derived from the corpus, not invented: `say the word` (100),
76
+ // `your call` (100), `want me to` (84), `if you'd rather` (33) are the bulk of real misses. A
77
+ // trailing-question-mark rule was tried and DROPPED — over 3,239 fenceless rests it added exactly one
78
+ // hit these phrases had not already caught, for a whole extra false-positive surface.
79
+ const DEFERRALS = [
80
+ /\byour call\b/i,
81
+ /\bup to you\b/i,
82
+ /\bwant me to\b/i,
83
+ /\bwould you (?:like|prefer|rather)\b/i,
84
+ /\bdo you want\b/i,
85
+ /\bshall I\b/i,
86
+ /\bsay the word\b/i,
87
+ /\bif you(?:'d| would)\s+(?:prefer|rather|like)\b/i,
88
+ /\btell me which\b/i,
89
+ /\bwhich (?:would you|do you|one would|of these)\b/i,
90
+ /\byour (?:preference|decision)\b/i,
91
+ /\blet me know\b/i,
92
+ /\bshould I\b[^.?!\n]*\?/i,
93
+ ];
94
+
95
+ /** The deferral phrase in a message's closing, or null. @param {string} text */
96
+ export function askPhrase(text) {
97
+ const norm = String(text ?? '').replace(/\r\n/g, '\n').trim();
98
+ if (norm.length < MIN_CHARS) return null;
99
+ const close = norm.length > TAIL_CHARS ? norm.slice(-TAIL_CHARS) : norm;
100
+ for (const re of DEFERRALS) {
101
+ const m = close.match(re);
102
+ if (m) return m[0];
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /** @param {string} phrase */
108
+ function nudge(phrase) {
109
+ return (
110
+ '⟦fence check⟧ You are coming to rest with no ```question / ```done / ```awaiting fence, and your ' +
111
+ 'closing text hands a decision back to the human ("' + phrase + '"). A prose ask is INVISIBLE in ' +
112
+ 'the queue: it renders as an ordinary handoff card with nothing to answer, it does not break ' +
113
+ 'through a Snooze, and the human has to read the whole message to discover they are blocking you. ' +
114
+ 'Decide which of these it actually is, then do exactly one:\n' +
115
+ '1. It is genuinely the human\'s to call (irreversible, external-facing, or their taste to set) — ' +
116
+ 're-send your final message with the ask at the very END in a ```question block: the question on ' +
117
+ 'ONE line, lettered `- A. …` options with a one-line tradeoff each, your recommendation FIRST and ' +
118
+ 'marked `(recommended)`, and enough context to answer cold. One block per independent question.\n' +
119
+ '2. It was yours to decide all along (reversible, derivable from the code or ordinary engineering ' +
120
+ 'judgment) — make the call, say which way you went, and carry on with the work.\n' +
121
+ '3. It was a rhetorical aside or an offer of optional extra work, not a real blocker — just rest ' +
122
+ 'again as you were.\n' +
123
+ 'This note fires at most once per turn and will not repeat.'
124
+ );
125
+ }
126
+
127
+ // ---- transcript ---------------------------------------------------------------------------------
128
+ /** The tail of a JSONL transcript, parsed, oldest-first. Partial first line is dropped.
129
+ * @param {string} path */
130
+ function tailRecords(path) {
131
+ let fd = null;
132
+ try {
133
+ const size = statSync(path).size;
134
+ const want = Math.min(size, TAIL_BYTES);
135
+ const buf = Buffer.alloc(want);
136
+ fd = openSync(path, 'r');
137
+ readSync(fd, buf, 0, want, size - want);
138
+ const lines = buf.toString('utf8').split('\n');
139
+ if (want < size) lines.shift(); // the read may have cut the first line in half
140
+ const out = [];
141
+ for (const line of lines) {
142
+ if (!line.trim()) continue;
143
+ try {
144
+ out.push(JSON.parse(line));
145
+ } catch {
146
+ /* a truncated/garbled record is simply skipped */
147
+ }
148
+ }
149
+ return out;
150
+ } catch {
151
+ return [];
152
+ } finally {
153
+ if (fd !== null) {
154
+ try {
155
+ closeSync(fd);
156
+ } catch {
157
+ /* ignore */
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ /** @param {any} rec */
164
+ function assistantText(rec) {
165
+ // Claude: an assistant record's text blocks. Codex: a rollout `event_msg`/`agent_message`.
166
+ if (rec?.type === 'event_msg' && rec?.payload?.type === 'agent_message') return String(rec.payload.message ?? '');
167
+ const c = rec?.message?.content;
168
+ if (typeof c === 'string') return c;
169
+ if (!Array.isArray(c)) return '';
170
+ return c.filter((b) => b?.type === 'text').map((b) => String(b.text ?? '')).join('\n');
171
+ }
172
+
173
+ /** @param {any} rec */
174
+ function isToolResult(rec) {
175
+ const c = rec?.message?.content;
176
+ return Array.isArray(c) && c.some((b) => b?.type === 'tool_result');
177
+ }
178
+
179
+ /**
180
+ * The final assistant text of the top-level conversation, plus the ordinal of the human turn it
181
+ * closes. FALLBACK ONLY — see `evaluateFenceStop`: the transcript is written asynchronously, so at
182
+ * Stop time the message that just ended the turn may not be on disk yet. Measured live: of four real
183
+ * broker workers driven to a fenceless prose ask, two had the final message flushed by the time the
184
+ * hook ran and two did not, which is exactly the flakiness this is no longer the primary source for.
185
+ * @param {any[]} records
186
+ */
187
+ export function finalMessage(records) {
188
+ const conv = records.filter(
189
+ (r) => (r?.type === 'user' || r?.type === 'assistant' || r?.type === 'event_msg') && !r?.isSidechain
190
+ );
191
+ let text = null;
192
+ for (let i = conv.length - 1; i >= 0; i--) {
193
+ if (conv[i].type === 'user') break; // a trailing user record means the turn is not at rest
194
+ const t = assistantText(conv[i]);
195
+ if (t.trim()) {
196
+ text = t;
197
+ break;
198
+ }
199
+ }
200
+ // Human turns in the window read. Tool results are the model's own loop, not a human turn; a
201
+ // truncated window just yields a smaller (but still monotonic within a session) ordinal.
202
+ const turn = conv.filter((r) => r.type === 'user' && !isToolResult(r)).length;
203
+ return { text, turn };
204
+ }
205
+
206
+ // ---- one-shot state ------------------------------------------------------------------------------
207
+ /** @param {string} statePath */
208
+ function readState(statePath) {
209
+ try {
210
+ const s = JSON.parse(readFileSync(statePath, 'utf8'));
211
+ return s && typeof s === 'object' ? s : {};
212
+ } catch {
213
+ return {}; // missing/corrupt state is an unfired guard
214
+ }
215
+ }
216
+
217
+ /** Persist BEFORE blocking: if the guard cannot be recorded, fail open rather than build a loop whose
218
+ * key can never advance. @param {string} statePath @param {string} key @param {number} now */
219
+ function writeState(statePath, key, now) {
220
+ const tmp = `${statePath}.${process.pid}.tmp`;
221
+ try {
222
+ writeFileSync(tmp, JSON.stringify({ key, firedAt: new Date(now).toISOString() }) + '\n', { mode: 0o600 });
223
+ renameSync(tmp, statePath);
224
+ return true;
225
+ } catch {
226
+ try {
227
+ rmSync(tmp, { force: true });
228
+ } catch {
229
+ /* best effort */
230
+ }
231
+ return false;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * @param {{ transcript_path?: string, stop_hook_active?: boolean, last_assistant_message?: unknown, prompt_id?: unknown }} input
237
+ * @param {{ projectDir: string, sessionId: string, now?: number }} context
238
+ * @returns {{ decision?: 'block', reason?: string }}
239
+ */
240
+ export function evaluateFenceStop(input, context) {
241
+ if (!input || typeof input !== 'object' || !context.sessionId) return {};
242
+ // Another Stop hook is already driving the continuation — adding a second instruction on top of it
243
+ // is how stop loops get built.
244
+ if (input.stop_hook_active) return {};
245
+
246
+ // THE MESSAGE. `last_assistant_message` is the harness's own copy of the text that just ended the
247
+ // turn (verified on the wire, cli 2.1.220), so it is exact and race-free. The transcript is the
248
+ // fallback for a payload that lacks it — codex, or a future/older shape.
249
+ const promptId = typeof input.prompt_id === 'string' ? input.prompt_id : '';
250
+ const direct = typeof input.last_assistant_message === 'string' ? input.last_assistant_message : '';
251
+ // Only touch the transcript when something is actually missing from the payload.
252
+ const fromDisk = direct.trim() && promptId ? null : finalMessage(tailRecords(input.transcript_path ?? ''));
253
+ const text = direct.trim() ? direct : fromDisk?.text;
254
+ if (!text) return {};
255
+ if (fenceOf(text)) return {};
256
+ const phrase = askPhrase(text);
257
+ if (!phrase) return {};
258
+
259
+ // THE ONE-SHOT KEY — one poke per human turn, so the message the worker re-sends cannot re-trigger
260
+ // it. `prompt_id` identifies the prompt being answered and is stable across a blocked continuation,
261
+ // which is exactly the boundary wanted; the transcript's human-turn ordinal is the fallback. Both
262
+ // are STABLE within a rest, which is what makes a loop impossible — no timed cooldown is needed on
263
+ // top (and one would silently swallow the poke on a rapid second turn).
264
+ const key = promptId || `turn:${fromDisk?.turn ?? 0}`;
265
+
266
+ const statePath = join(context.projectDir, '.fray', 'threads', context.sessionId, '.fence-stop-state.json');
267
+ if (readState(statePath).key === key) return {};
268
+ if (!writeState(statePath, key, context.now ?? Date.now())) return {};
269
+
270
+ return { decision: 'block', reason: nudge(phrase) };
271
+ }
272
+
273
+ if (process.argv[1]?.endsWith('fence-stop.mjs')) {
274
+ try {
275
+ const argv = process.argv.slice(2);
276
+ const explicit = argv.find((a) => a.startsWith('--session='));
277
+ const input = JSON.parse(readFileSync(0, 'utf8'));
278
+ // WORKER GATE — inert outside a fray-ui worker. Codex reports its own rollout id, so fray bakes
279
+ // `--session=<fray sessionId>` into the codex hook command and that always wins.
280
+ const sessionId = explicit ? explicit.slice('--session='.length) : currentSessionId(input?.session_id);
281
+ if (!sessionId || !(explicit || (process.env.FRAY_UI_THREAD ?? '').trim())) {
282
+ process.stdout.write('{}');
283
+ } else {
284
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
285
+ process.stdout.write(JSON.stringify(evaluateFenceStop(input, { projectDir, sessionId })));
286
+ }
287
+ } catch {
288
+ process.stdout.write('{}');
289
+ }
290
+ }
@@ -43,7 +43,8 @@
43
43
  "Stop": [
44
44
  {
45
45
  "hooks": [
46
- { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad-stop.mjs\"" }
46
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/scratchpad-stop.mjs\"" },
47
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/fence-stop.mjs\"" }
47
48
  ]
48
49
  }
49
50
  ],
@@ -403,15 +403,16 @@ if (mode === 'nudge') {
403
403
  emitJson(
404
404
  mtimeMs
405
405
  ? '⟦scratchpad stale⟧ Your context has grown ~' + k + 'k tokens since you last wrote `' +
406
- relPath + '`. Bring it up to date now, before a compaction forces the issue the problem, ' +
407
- 'the approach and what you rejected, the human\'s decisions, what is verified versus ' +
408
- 'believed, and the next action. Its head is injected back automatically after a compaction, ' +
409
- 'so it is the one thing you are guaranteed to still have.'
410
- : '⟦scratchpad empty⟧ This session is ~' + k + 'k tokens deep and `' + relPath + '` still has ' +
411
- 'nothing substantive in it. Write it now: the problem, the approach and the approaches you ' +
412
- 'rejected, the human\'s decisions, what is verified versus merely believed, and the next ' +
413
- 'action. Its head is injected back automatically after a compaction, so it is what survives ' +
414
- 'when the rest of this context does not.',
406
+ relPath + '`. If this effort is long enough that losing your reasoning would hurt, top it up ' +
407
+ 'in passing — the approach, what you rejected, the human\'s decisions, what is verified ' +
408
+ 'versus believed. Its head is injected back after a compaction. This is a background note, ' +
409
+ 'NOT a task and NOT a reason to pause: do not stop working to service it, and never end a ' +
410
+ 'turn on it while the human\'s instruction still has parts left.'
411
+ : '⟦scratchpad empty⟧ This session is ~' + k + 'k tokens deep and `' + relPath + '` is empty. ' +
412
+ 'That is fine for a single direct task the pad is optional and writing in it is not doing ' +
413
+ 'the work. If this effort is long or branching, a few lines on the approach and the human\'s ' +
414
+ 'decisions will survive a compaction. This is a background note, NOT a task and NOT a reason ' +
415
+ 'to pause: keep going with what you were asked to do.',
415
416
  event,
416
417
  );
417
418
  }
@@ -64,7 +64,8 @@ const scratch = sid
64
64
  // the compaction re-read nudge, gh guidance, and the defensive cc-orchestrator off-sentinel.
65
65
  const core =
66
66
  '⟦fray worker contract⟧ You are a fray-ui WORKER driving EXACTLY ONE effort. Your FULL operating contract — the end-of-turn signal fences, scratchpad rules, sub-agent rules, and the runtime release gate — lives in your SYSTEM PROMPT; follow it there (this is a runtime re-grounding, not a second copy). The human + the fray-ui app are the ORCHESTRATOR; you drive ONE effort and never scan the board or touch other efforts. There is no orchestrator mode and no fleet to run: doing the work yourself is the default, and you dispatch a sub-agent only when the work genuinely decomposes into independent prongs.\n' +
67
- 'SCRATCHPAD: `' + scratch + '` — the CANONICAL record of this thread, your compaction-survival mechanism, and your sub-agents\' shared blackboard. Write your task list, the approach and what you rejected, and anything that must outlive your context there AS YOU GO; re-read it after any compaction or resume, and pass its PATH into every sub-agent prompt.\n' +
67
+ 'SCRATCHPAD (OPTIONAL): `' + scratch + '` — a scratch file kept FOR YOU, not a deliverable, and never a substitute for doing the work. A single direct task usually needs nothing in it. On a long effort it is crash insurance and your sub-agents\' shared blackboard: write the approach and what you rejected there AS YOU GO, mid-work, then KEEP WORKING; re-read it after any compaction or resume, and pass its PATH into every sub-agent prompt.\n' +
68
+ 'DO NOT REST WHILE THE INSTRUCTION HAS PARTS LEFT — finish them in THIS turn; a milestone, a green test run and a long turn are none of them stopping points, and announcing the next step or recording it in the scratchpad is not doing it.\n' +
68
69
  'SIGNAL AT REST through your FINAL MESSAGE, per the fence rules in your system prompt: bare rest is the ordinary handoff and queues for the human; ```done only when the effort\'s real work is COMPLETE (code LANDED on the mainline — an open PR is NOT done, park it on ```awaiting until it MERGES) and is a DISMISSAL (its card files the thread away where nobody looks again), so if the thread points at future work AT ALL — a pre-fix investigation, a live code-change discussion — bare rest instead, and uncertain is not done; the ONE exception is a planning session whose plan file is fully written and persisted, because that artifact outlives the thread; ```awaiting parks only a human:/timer:/pr-watch: gate, never CI/releases/merge progression (those stay ACTIVE); ```question is the operator ask. Load `fray:handoff` for the full fence reference.\n' +
69
70
  'DECIDE rather than ask: anything derivable from the code, the conventions, or ordinary engineering judgment is YOURS to settle — asking permission to do the work you were dispatched to do is not a question, it is the job. Reserve the operator for the irreversible and the genuinely human-owned.';
70
71
 
@@ -58,6 +58,28 @@ gh pr view N -R OWNER/REPO --comments # review threads + conversation
58
58
  ```
59
59
  Read the changed files **in context**, not just the hunks — `gh pr diff` shows what changed, but correctness lives in the surrounding code.
60
60
 
61
+ **Reading ONE review (what a `pr-watch` wake hands you)**
62
+
63
+ A wake permalink ending `#pullrequestreview-<id>` is a **review**, and a review's `body` is routinely
64
+ **empty** — review apps (pullfrog, coderabbit) and humans doing an inline pass put every word in the
65
+ review's *inline comments*. Reading the body and concluding the review is empty is the wrong turn here.
66
+ One endpoint answers it in one call:
67
+
68
+ ```bash
69
+ gh api --paginate repos/OWNER/REPO/pulls/N/reviews/REVIEW_ID/comments \
70
+ --jq '.[] | "\(.path):\(.line // .original_line // "file")\n\(.body)\n"'
71
+ ```
72
+
73
+ Do **not** sweep `…/pulls/N/comments` and filter by `pull_request_review_id` — it pulls the whole PR's
74
+ history to find a handful of lines. Add the review's own body only if you need it
75
+ (`gh api repos/OWNER/REPO/pulls/N/reviews/REVIEW_ID --jq .body`). A `#issuecomment-<id>` permalink is
76
+ the other shape and *does* carry its substance in its body:
77
+ `gh api repos/OWNER/REPO/issues/comments/ID --jq .body`.
78
+
79
+ **`--paginate` is the default for any list endpoint.** `gh api` returns **30** items per page and caps
80
+ `per_page` at **100**, silently — a truncated page reads exactly like "that's all there is," so a
81
+ missing `--paginate` becomes a wrong answer rather than an error.
82
+
61
83
  **CI / runs / releases**
62
84
  ```bash
63
85
  gh run list -R OWNER/REPO --branch BRANCH --limit 10
@@ -8,6 +8,12 @@ description: The full fray end-of-turn signal reference for a fray-ui worker (in
8
8
  Your system prompt states the fence rules. This is the elaboration: the exact shapes, the tags, and
9
9
  worked examples. Nothing here overrides the contract.
10
10
 
11
+ **Before you use any of it: is the turn actually over?** Every shape below is for a turn that has
12
+ genuinely ended. If the human's instruction still has parts left, none of them apply — you do not pick
13
+ a fence, you make the next tool call. Reaching for this reference at a milestone is the most common way
14
+ an effort dies half-finished; a verified increment, a green test run and a long turn are not endings,
15
+ and neither is announcing the next step or recording it in the scratchpad.
16
+
11
17
  ## Which fence?
12
18
 
13
19
  | Situation | Fence |