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.
- package/dist/claude-agent-broker.js +19 -3
- package/dist/codex-app-server-daemon.js +1 -1
- package/dist/dev-child.js +786 -331
- package/dist/frayui.js +1291 -193
- package/package.json +8 -7
- package/runtime/cc-worker/DECISIONS.md +86 -0
- package/runtime/cc-worker/hooks/agent-dispatch.mjs +18 -4
- package/runtime/cc-worker/hooks/fence-stop.mjs +290 -0
- package/runtime/cc-worker/hooks/hooks.json +2 -1
- package/runtime/cc-worker/hooks/scratchpad.mjs +10 -9
- package/runtime/cc-worker/hooks/session-seed.mjs +2 -1
- package/runtime/cc-worker/skills/gh/SKILL.md +22 -0
- package/runtime/cc-worker/skills/handoff/SKILL.md +6 -0
- package/web-dist/assets/{TerminalPane-C3CC9EFZ.js → TerminalPane-hMf6ifuS.js} +1 -1
- package/web-dist/assets/{abnfDiagram-VRR7QNED-DqX9eyjt.js → abnfDiagram-VRR7QNED-CQj6fwXy.js} +1 -1
- package/web-dist/assets/architecture-TIHT7OUA-JX8_jS4I.js +1 -0
- package/web-dist/assets/{architectureDiagram-ZJ3FMSHR-M8pzerdN.js → architectureDiagram-ZJ3FMSHR-CoE5ct03.js} +1 -1
- package/web-dist/assets/{blockDiagram-677ZJIJ3-DqR6Y2LC.js → blockDiagram-677ZJIJ3-CEudy3zd.js} +1 -1
- package/web-dist/assets/{c4Diagram-LMCZKHZV-BVYFjsdW.js → c4Diagram-LMCZKHZV-ChW62IOH.js} +1 -1
- package/web-dist/assets/channel-y3u1yiAC.js +1 -0
- package/web-dist/assets/{chunk-32BRIVSS-DFjDHOoM.js → chunk-32BRIVSS-Bh-A2le2.js} +1 -1
- package/web-dist/assets/{chunk-52WLFC77-CgT8QhTR.js → chunk-52WLFC77-7Gia-F6f.js} +1 -1
- package/web-dist/assets/{chunk-C7G6YPKG-CzJ6LiOY.js → chunk-C7G6YPKG-C0MCv0Ym.js} +1 -1
- package/web-dist/assets/{chunk-EX3LRPZG-BxLQFQHC.js → chunk-EX3LRPZG-DPRO7Ss0.js} +1 -1
- package/web-dist/assets/{chunk-FWX5IMBZ-Dph1CDtO.js → chunk-FWX5IMBZ-Cod9MA7c.js} +2 -2
- package/web-dist/assets/{chunk-HOUHSVGY-DkAureev.js → chunk-HOUHSVGY-D1oHrXQx.js} +1 -1
- package/web-dist/assets/{chunk-ICXQ74PX-Cz4NMI__.js → chunk-ICXQ74PX-DBLMA0ZN.js} +1 -1
- package/web-dist/assets/{chunk-MOJQB5TN-CsLU1DoU.js → chunk-MOJQB5TN-jiLvxNvx.js} +1 -1
- package/web-dist/assets/{chunk-OGEWGWER-Aylt7fzg.js → chunk-OGEWGWER-w1RtF7G0.js} +1 -1
- package/web-dist/assets/{chunk-PUDLZKDR-BFErObxQ.js → chunk-PUDLZKDR-DQlFMKm5.js} +1 -1
- package/web-dist/assets/{chunk-Q4XR5HBZ-Bv6INKwj.js → chunk-Q4XR5HBZ-CfD6vdr0.js} +1 -1
- package/web-dist/assets/{chunk-V7JOEXUC-DfztwFA4.js → chunk-V7JOEXUC-BRB1iO1P.js} +1 -1
- package/web-dist/assets/{chunk-VAUOI2AC-B0f7ZK3T.js → chunk-VAUOI2AC-CCeJEpxW.js} +1 -1
- package/web-dist/assets/{chunk-VR4S4FIN-G-Gcy_AM.js → chunk-VR4S4FIN-DfPRj9a1.js} +1 -1
- package/web-dist/assets/{chunk-WYO6CB5R-CMp0iowL.js → chunk-WYO6CB5R-BWd84n6L.js} +1 -1
- package/web-dist/assets/{chunk-ZGVPDNZ5-CSWMiX8t.js → chunk-ZGVPDNZ5-Bm-3TA9N.js} +1 -1
- package/web-dist/assets/classDiagram-OUVF2IWQ-BL8mggXA.js +1 -0
- package/web-dist/assets/classDiagram-v2-EOCWNBFH-BL8mggXA.js +1 -0
- package/web-dist/assets/{cynefin-VYW2F7L2-CUMD23BD.js → cynefin-VYW2F7L2-BylEqg5y.js} +1 -1
- package/web-dist/assets/{cynefinDiagram-TSTJHNR4-fZTm6GyW.js → cynefinDiagram-TSTJHNR4-CRfGS7Ou.js} +1 -1
- package/web-dist/assets/{dagre-VKFMJZFB-CUCi5JnH.js → dagre-VKFMJZFB-CNeTu13Z.js} +1 -1
- package/web-dist/assets/{diagram-FQU43EPY-C87Fqvwa.js → diagram-FQU43EPY-BYZKetkJ.js} +1 -1
- package/web-dist/assets/{diagram-G47NLZAW-CDxQ-BXA.js → diagram-G47NLZAW-DSOzyl5U.js} +1 -1
- package/web-dist/assets/{diagram-NH7WQ7WH-Uceffn93.js → diagram-NH7WQ7WH-DFZCvfJN.js} +1 -1
- package/web-dist/assets/{diagram-OA4YK3LP-CSSp0Fa1.js → diagram-OA4YK3LP-m0qY8Zlg.js} +1 -1
- package/web-dist/assets/{diagram-WEI45ONY-DS5sAZPp.js → diagram-WEI45ONY-Cg7Qz-0T.js} +1 -1
- package/web-dist/assets/{ebnfDiagram-CCIWWBDH-aM331oVj.js → ebnfDiagram-CCIWWBDH-DGvTjoXK.js} +1 -1
- package/web-dist/assets/{erDiagram-Q63AITRT-d0i1kydx.js → erDiagram-Q63AITRT-BFqTK5TB.js} +1 -1
- package/web-dist/assets/eventmodeling-45OFAUF4-BaCHCepY.js +1 -0
- package/web-dist/assets/flowDiagram-23GEKE2U-Cjl6weoT.js +1 -0
- package/web-dist/assets/{ganttDiagram-NO4QXBWP-Cd0OpzcB.js → ganttDiagram-NO4QXBWP-KbR9IE0O.js} +1 -1
- package/web-dist/assets/{gitGraph-TEB2WS4Q-BeI6iDaZ.js → gitGraph-TEB2WS4Q-hCbzZO_l.js} +1 -1
- package/web-dist/assets/{gitGraphDiagram-IHSO6WYX-QvePTKQE.js → gitGraphDiagram-IHSO6WYX-CNPRTwQG.js} +1 -1
- package/web-dist/assets/index-BUY5ijyI.css +1 -0
- package/web-dist/assets/{index-CJ-cmbhi.js → index-Bj6YWd3g.js} +68 -68
- package/web-dist/assets/{info-DKCQHKI2-WwdGk87J.js → info-DKCQHKI2-Zd_TXMfr.js} +1 -1
- package/web-dist/assets/{infoDiagram-FWYZ7A6U-C3ARX7jm.js → infoDiagram-FWYZ7A6U-DQ9zmVhJ.js} +1 -1
- package/web-dist/assets/{ishikawaDiagram-FXEZZL3T-C_zMKLif.js → ishikawaDiagram-FXEZZL3T-BFyJ5E_L.js} +1 -1
- package/web-dist/assets/{journeyDiagram-5HDEW3XC-BcIZu4Cm.js → journeyDiagram-5HDEW3XC-BgjbY7_T.js} +1 -1
- package/web-dist/assets/{kanban-definition-HUTT4EX6-wwc4iI2z.js → kanban-definition-HUTT4EX6-mpUEl8xx.js} +1 -1
- package/web-dist/assets/{line-BCQpqxAq.js → line-CM_FUYGm.js} +1 -1
- package/web-dist/assets/{mermaid-parser.core-8y_8tv09.js → mermaid-parser.core-CTVz2V70.js} +3 -3
- package/web-dist/assets/{mermaid.core-gXHP6zd5.js → mermaid.core-BCLNmVvR.js} +3 -3
- package/web-dist/assets/{mindmap-definition-LN4V7U3C-DtTZ-e8A.js → mindmap-definition-LN4V7U3C-CgJU-6O7.js} +1 -1
- package/web-dist/assets/{packet-7NZHBO7P-DZbgcVcI.js → packet-7NZHBO7P-rkLFb4ZX.js} +1 -1
- package/web-dist/assets/{pegDiagram-2B236MQR-DHtrQw0R.js → pegDiagram-2B236MQR-Sy2oYuu1.js} +1 -1
- package/web-dist/assets/{pie-RZYD4A2V-CBwiKeCv.js → pie-RZYD4A2V-0yTxNtqZ.js} +1 -1
- package/web-dist/assets/{pieDiagram-ENE6RG2P-B-gKjdFc.js → pieDiagram-ENE6RG2P-CQ7YFWhH.js} +1 -1
- package/web-dist/assets/{quadrantDiagram-ABIIQ3AL-C3h180Er.js → quadrantDiagram-ABIIQ3AL-ClCTxrOj.js} +1 -1
- package/web-dist/assets/{radar-I7S5WNFK-BTAfmGFj.js → radar-I7S5WNFK-JUOW5mdN.js} +1 -1
- package/web-dist/assets/{railroad-3IZDKUUU-DjkPg0H7.js → railroad-3IZDKUUU-DJ2IS1Jg.js} +1 -1
- package/web-dist/assets/railroad-abnf-AHOZXSZD-CU4HK7Ek.js +1 -0
- package/web-dist/assets/railroad-ebnf-EBAXGLYW-CarBAd7R.js +1 -0
- package/web-dist/assets/railroad-peg-LSFZ7HO6-ByEhVLyD.js +1 -0
- package/web-dist/assets/{railroadDiagram-RFXS5EU6-FVI9rArP.js → railroadDiagram-RFXS5EU6-BNsVGeA3.js} +1 -1
- package/web-dist/assets/{requirementDiagram-TGXJPOKE-Cp3pp65R.js → requirementDiagram-TGXJPOKE-BHa3IX0k.js} +1 -1
- package/web-dist/assets/{sankeyDiagram-HTMAVEWB-B-EVqI0F.js → sankeyDiagram-HTMAVEWB-CSbsLbtw.js} +1 -1
- package/web-dist/assets/{sequenceDiagram-DBY2YBRQ-Bov70rRs.js → sequenceDiagram-DBY2YBRQ-BgVWj1nN.js} +1 -1
- package/web-dist/assets/{stateDiagram-2N3HPSRC-BW7fwttw.js → stateDiagram-2N3HPSRC-BKHYNxA3.js} +1 -1
- package/web-dist/assets/stateDiagram-v2-6OUMAXLB-C-k0bbcG.js +1 -0
- package/web-dist/assets/{swimlanes-5IMT3BWC-BUOdjOgW.js → swimlanes-5IMT3BWC-BluloqBJ.js} +1 -1
- package/web-dist/assets/swimlanesDiagram-G3AALYLV-DVjLYZbp.js +8 -0
- package/web-dist/assets/{timeline-definition-FHXFAJF6-C20Ho0e9.js → timeline-definition-FHXFAJF6-Bj1WKNgB.js} +1 -1
- package/web-dist/assets/{treeView-QDETBFTQ-DHg1NMOK.js → treeView-QDETBFTQ-Dj5T24Qr.js} +1 -1
- package/web-dist/assets/{treemap-6X3UGDF4-B-aFFjeG.js → treemap-6X3UGDF4-BPqewrXp.js} +1 -1
- package/web-dist/assets/{vennDiagram-L72KCM5P-CEWY6HTS.js → vennDiagram-L72KCM5P-BhggGhQv.js} +1 -1
- package/web-dist/assets/{wardley-OPB4EBWU-BpAKElbJ.js → wardley-OPB4EBWU-Di9GWKYt.js} +1 -1
- package/web-dist/assets/{wardleyDiagram-EHGQE667-CYer_Ry4.js → wardleyDiagram-EHGQE667-D-mtWLKf.js} +1 -1
- package/web-dist/assets/{xychartDiagram-FW5EYKEG-BxMRGqds.js → xychartDiagram-FW5EYKEG-CU0us6D_.js} +1 -1
- package/web-dist/index.html +2 -2
- package/web-dist/assets/architecture-TIHT7OUA-Dp81OE9d.js +0 -1
- package/web-dist/assets/channel-BLJ4T4FR.js +0 -1
- package/web-dist/assets/classDiagram-OUVF2IWQ-BQSt_EhF.js +0 -1
- package/web-dist/assets/classDiagram-v2-EOCWNBFH-BQSt_EhF.js +0 -1
- package/web-dist/assets/eventmodeling-45OFAUF4-qO9fgTC2.js +0 -1
- package/web-dist/assets/flowDiagram-23GEKE2U-BUHZX4Ft.js +0 -1
- package/web-dist/assets/index-LON0oNCJ.css +0 -1
- package/web-dist/assets/railroad-abnf-AHOZXSZD-DlUkYI_B.js +0 -1
- package/web-dist/assets/railroad-ebnf-EBAXGLYW-DptyGTmD.js +0 -1
- package/web-dist/assets/railroad-peg-LSFZ7HO6-xPHpoacS.js +0 -1
- package/web-dist/assets/stateDiagram-v2-6OUMAXLB-BtaoEJDP.js +0 -1
- 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.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "
|
|
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
|
-
"
|
|
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": "^
|
|
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
|
-
//
|
|
11
|
-
// compilation, or process-lifecycle policy on
|
|
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 + '`.
|
|
407
|
-
'the approach
|
|
408
|
-
'believed
|
|
409
|
-
'
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
'
|
|
413
|
-
'
|
|
414
|
-
'
|
|
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 + '` —
|
|
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 |
|