frayui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +186 -0
  3. package/dist/claude-agent-broker.js +21641 -0
  4. package/dist/codex-app-server-daemon.js +354 -0
  5. package/dist/dev-child.js +40319 -0
  6. package/dist/frayui.js +8738 -0
  7. package/package.json +46 -0
  8. package/runtime/cc/scripts/fray/agent-bindings.mjs +287 -0
  9. package/runtime/cc/scripts/fray/agent-liveness.mjs +367 -0
  10. package/runtime/cc/scripts/fray/agent-status.mjs +178 -0
  11. package/runtime/cc/scripts/fray/config.mjs +982 -0
  12. package/runtime/cc/scripts/fray/decisions.mjs +97 -0
  13. package/runtime/cc/scripts/fray/index.mjs +699 -0
  14. package/runtime/cc/scripts/fray/notify-shared.mjs +90 -0
  15. package/runtime/cc/scripts/fray/notify.mjs +81 -0
  16. package/runtime/cc/scripts/fray/ownership.mjs +120 -0
  17. package/runtime/cc/scripts/fray/rest-detect.mjs +213 -0
  18. package/runtime/cc/scripts/fray/thread-excerpt.mjs +162 -0
  19. package/runtime/cc/scripts/fray/thread-update.mjs +285 -0
  20. package/runtime/cc-worker/.claude-plugin/plugin.json +10 -0
  21. package/runtime/cc-worker/DECISIONS.md +923 -0
  22. package/runtime/cc-worker/LICENSE +21 -0
  23. package/runtime/cc-worker/agents/fable-high.md +8 -0
  24. package/runtime/cc-worker/agents/fable-low.md +8 -0
  25. package/runtime/cc-worker/agents/fable-max.md +8 -0
  26. package/runtime/cc-worker/agents/fable-medium.md +8 -0
  27. package/runtime/cc-worker/agents/fable-xhigh.md +8 -0
  28. package/runtime/cc-worker/agents/haiku.md +7 -0
  29. package/runtime/cc-worker/agents/opus-high.md +8 -0
  30. package/runtime/cc-worker/agents/opus-low.md +8 -0
  31. package/runtime/cc-worker/agents/opus-max.md +8 -0
  32. package/runtime/cc-worker/agents/opus-medium.md +8 -0
  33. package/runtime/cc-worker/agents/opus-xhigh.md +8 -0
  34. package/runtime/cc-worker/agents/sonnet-high.md +8 -0
  35. package/runtime/cc-worker/agents/sonnet-low.md +8 -0
  36. package/runtime/cc-worker/agents/sonnet-max.md +8 -0
  37. package/runtime/cc-worker/agents/sonnet-medium.md +8 -0
  38. package/runtime/cc-worker/agents/sonnet-xhigh.md +8 -0
  39. package/runtime/cc-worker/bin/fray +17 -0
  40. package/runtime/cc-worker/bin/fray-mcp.mjs +248 -0
  41. package/runtime/cc-worker/bin/fray-update +18 -0
  42. package/runtime/cc-worker/hooks/agent-bind.mjs +40 -0
  43. package/runtime/cc-worker/hooks/agent-dispatch.mjs +74 -0
  44. package/runtime/cc-worker/hooks/bash-background.d.mts +6 -0
  45. package/runtime/cc-worker/hooks/bash-background.mjs +188 -0
  46. package/runtime/cc-worker/hooks/deny-ask.mjs +38 -0
  47. package/runtime/cc-worker/hooks/deny-plan.mjs +61 -0
  48. package/runtime/cc-worker/hooks/hooks.json +111 -0
  49. package/runtime/cc-worker/hooks/perm-policy.mjs +211 -0
  50. package/runtime/cc-worker/hooks/precompact-instructions.mjs +122 -0
  51. package/runtime/cc-worker/hooks/scratchpad-stop.mjs +125 -0
  52. package/runtime/cc-worker/hooks/scratchpad.mjs +419 -0
  53. package/runtime/cc-worker/hooks/session-seed.mjs +105 -0
  54. package/runtime/cc-worker/scripts/fray/agent-bindings.mjs +9 -0
  55. package/runtime/cc-worker/scripts/fray/config.mjs +12 -0
  56. package/runtime/cc-worker/skills/gh/SKILL.md +132 -0
  57. package/runtime/cc-worker/skills/gh/scripts/ci-watch.mjs +60 -0
  58. package/runtime/cc-worker/skills/gh/scripts/github-watch.mjs +130 -0
  59. package/runtime/cc-worker/skills/gh/scripts/review-watch.mjs +54 -0
  60. package/runtime/cc-worker/skills/handoff/SKILL.md +203 -0
  61. package/runtime/cc-worker/skills/waits/SKILL.md +83 -0
  62. package/web-dist/apple-touch-icon.png +0 -0
  63. package/web-dist/assets/TerminalPane-fMGrgB-a.js +7 -0
  64. package/web-dist/assets/abnfDiagram-VRR7QNED-BtlrDuYX.js +1 -0
  65. package/web-dist/assets/arc-BSyeo0Gb.js +1 -0
  66. package/web-dist/assets/architecture-TIHT7OUA-CuD8jpr-.js +1 -0
  67. package/web-dist/assets/architectureDiagram-ZJ3FMSHR-CdLQ71be.js +36 -0
  68. package/web-dist/assets/array-BifhSqXX.js +1 -0
  69. package/web-dist/assets/blockDiagram-677ZJIJ3-CBk2FHEj.js +132 -0
  70. package/web-dist/assets/c4Diagram-LMCZKHZV-mZ3HQ6WX.js +10 -0
  71. package/web-dist/assets/channel-C2eUWc74.js +1 -0
  72. package/web-dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  73. package/web-dist/assets/chunk-32BRIVSS-Daxvi7f5.js +1 -0
  74. package/web-dist/assets/chunk-52WLFC77-BGJoZvry.js +10 -0
  75. package/web-dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  76. package/web-dist/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
  77. package/web-dist/assets/chunk-C7G6YPKG-Ycd1yheh.js +1 -0
  78. package/web-dist/assets/chunk-EX3LRPZG-BPFhlsHp.js +231 -0
  79. package/web-dist/assets/chunk-FWX5IMBZ--xy8DTon.js +2 -0
  80. package/web-dist/assets/chunk-HOUHSVGY-Dq3zoygp.js +1 -0
  81. package/web-dist/assets/chunk-ICXQ74PX-FfKP-7yM.js +2 -0
  82. package/web-dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  83. package/web-dist/assets/chunk-KEIR6QF5-BfrZ3jm6.js +161 -0
  84. package/web-dist/assets/chunk-MOJQB5TN-BeiHnsLF.js +88 -0
  85. package/web-dist/assets/chunk-OGEWGWER-CjDU_9fC.js +1 -0
  86. package/web-dist/assets/chunk-PUDLZKDR-BO7tm5QX.js +156 -0
  87. package/web-dist/assets/chunk-Q4XR5HBZ-BPefovOg.js +70 -0
  88. package/web-dist/assets/chunk-RYQCIY6F-Cu_KplZW.js +1 -0
  89. package/web-dist/assets/chunk-V7JOEXUC-DWnebFHh.js +206 -0
  90. package/web-dist/assets/chunk-VAUOI2AC-BL6qWFhW.js +1 -0
  91. package/web-dist/assets/chunk-VR4S4FIN-Mfs__7L9.js +1 -0
  92. package/web-dist/assets/chunk-WYO6CB5R-Bf2IYbEU.js +127 -0
  93. package/web-dist/assets/chunk-XXDRQBXY-DYlTP5J-.js +1 -0
  94. package/web-dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  95. package/web-dist/assets/chunk-ZGVPDNZ5-CJ9ZHZgU.js +62 -0
  96. package/web-dist/assets/chunk-ZIRB5QZD-C6fEPe3t.js +32 -0
  97. package/web-dist/assets/classDiagram-OUVF2IWQ-GaOJNgfE.js +1 -0
  98. package/web-dist/assets/classDiagram-v2-EOCWNBFH-GaOJNgfE.js +1 -0
  99. package/web-dist/assets/cose-bilkent-JH36ORCC-BUIsLrGc.js +1 -0
  100. package/web-dist/assets/cynefin-VYW2F7L2-COSC0oNL.js +1 -0
  101. package/web-dist/assets/cynefinDiagram-TSTJHNR4-mInhJdg1.js +62 -0
  102. package/web-dist/assets/cytoscape.esm-B3I8pqwA.js +321 -0
  103. package/web-dist/assets/dagre-CXRCoUWR.js +1 -0
  104. package/web-dist/assets/dagre-VKFMJZFB-D-T5z05M.js +4 -0
  105. package/web-dist/assets/defaultLocale-C8Fc0cco.js +1 -0
  106. package/web-dist/assets/diagram-FQU43EPY-HbMkSb3j.js +3 -0
  107. package/web-dist/assets/diagram-G47NLZAW-DBwZLcZh.js +24 -0
  108. package/web-dist/assets/diagram-NH7WQ7WH-DG_Mhays.js +24 -0
  109. package/web-dist/assets/diagram-OA4YK3LP-hH39gqpu.js +30 -0
  110. package/web-dist/assets/diagram-WEI45ONY-DhKZuwxW.js +41 -0
  111. package/web-dist/assets/dist-DoH_9pyS.js +1 -0
  112. package/web-dist/assets/ebnfDiagram-CCIWWBDH-B7BiO-NR.js +1 -0
  113. package/web-dist/assets/erDiagram-Q63AITRT-DHjG5RDQ.js +85 -0
  114. package/web-dist/assets/eventmodeling-45OFAUF4-C0eV8RBx.js +1 -0
  115. package/web-dist/assets/flowDiagram-23GEKE2U-BbldRZUK.js +1 -0
  116. package/web-dist/assets/ganttDiagram-NO4QXBWP-BZ-98wTa.js +292 -0
  117. package/web-dist/assets/gitGraph-TEB2WS4Q-nF22R2jO.js +1 -0
  118. package/web-dist/assets/gitGraphDiagram-IHSO6WYX-CoYQQ22V.js +106 -0
  119. package/web-dist/assets/graphlib-B8gBHxth.js +1 -0
  120. package/web-dist/assets/index-BHzIN-tQ.js +357 -0
  121. package/web-dist/assets/index-Duiy4w7C.css +1 -0
  122. package/web-dist/assets/info-DKCQHKI2-Cy2BCbBW.js +1 -0
  123. package/web-dist/assets/infoDiagram-FWYZ7A6U-CmhR2R1x.js +2 -0
  124. package/web-dist/assets/init-D6jRqBbL.js +1 -0
  125. package/web-dist/assets/ishikawaDiagram-FXEZZL3T-CfG59afl.js +70 -0
  126. package/web-dist/assets/journeyDiagram-5HDEW3XC-BvFmG20q.js +139 -0
  127. package/web-dist/assets/kanban-definition-HUTT4EX6-BHGEecsY.js +89 -0
  128. package/web-dist/assets/katex-CddkPoXu.js +257 -0
  129. package/web-dist/assets/line-Ds4xvN3d.js +1 -0
  130. package/web-dist/assets/linear-z2V0wJk9.js +1 -0
  131. package/web-dist/assets/map-DsCK-0Cs.js +1 -0
  132. package/web-dist/assets/mermaid-parser.core-Gow4kgYJ.js +7 -0
  133. package/web-dist/assets/mermaid.core-B7Qc_kbm.js +11 -0
  134. package/web-dist/assets/mindmap-definition-LN4V7U3C-ayXKGcy_.js +96 -0
  135. package/web-dist/assets/ordinal-hYBb2elL.js +1 -0
  136. package/web-dist/assets/packet-7NZHBO7P-oLBdtkD8.js +1 -0
  137. package/web-dist/assets/path-BWPyau1x.js +1 -0
  138. package/web-dist/assets/pegDiagram-2B236MQR-DwJyc_1B.js +1 -0
  139. package/web-dist/assets/pie-RZYD4A2V-CXzad3fT.js +1 -0
  140. package/web-dist/assets/pieDiagram-ENE6RG2P-CZzgSGtM.js +39 -0
  141. package/web-dist/assets/quadrantDiagram-ABIIQ3AL--IPIp8yo.js +7 -0
  142. package/web-dist/assets/radar-I7S5WNFK-DldySvtZ.js +1 -0
  143. package/web-dist/assets/railroad-3IZDKUUU-CiWFZfXM.js +1 -0
  144. package/web-dist/assets/railroad-abnf-AHOZXSZD-bXMYUOCD.js +1 -0
  145. package/web-dist/assets/railroad-ebnf-EBAXGLYW-D11ZwZby.js +1 -0
  146. package/web-dist/assets/railroad-peg-LSFZ7HO6-DD1AC5X-.js +1 -0
  147. package/web-dist/assets/railroadDiagram-RFXS5EU6-DcCMNGGb.js +1 -0
  148. package/web-dist/assets/requirementDiagram-TGXJPOKE-C-6j8urk.js +84 -0
  149. package/web-dist/assets/rolldown-runtime-Bh1tDfsg.js +1 -0
  150. package/web-dist/assets/rough.esm-CSKSodPl.js +1 -0
  151. package/web-dist/assets/sankeyDiagram-HTMAVEWB-CPtiOpdz.js +40 -0
  152. package/web-dist/assets/sequenceDiagram-DBY2YBRQ-B8FnQTdn.js +162 -0
  153. package/web-dist/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
  154. package/web-dist/assets/src-C4XfhTaE.js +1 -0
  155. package/web-dist/assets/stateDiagram-2N3HPSRC-CxG9nX2P.js +1 -0
  156. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-SGarOFIy.js +1 -0
  157. package/web-dist/assets/swimlanes-5IMT3BWC-D49-t0hV.js +2 -0
  158. package/web-dist/assets/swimlanesDiagram-G3AALYLV-krF0bh4b.js +8 -0
  159. package/web-dist/assets/timeline-definition-FHXFAJF6-C9OEd9SE.js +120 -0
  160. package/web-dist/assets/treeView-QDETBFTQ-D1AKqVPa.js +1 -0
  161. package/web-dist/assets/treemap-6X3UGDF4-BRssB7hM.js +1 -0
  162. package/web-dist/assets/vennDiagram-L72KCM5P-CftkAbY0.js +34 -0
  163. package/web-dist/assets/wardley-OPB4EBWU-RLMgzRq4.js +1 -0
  164. package/web-dist/assets/wardleyDiagram-EHGQE667-BLytgXPS.js +78 -0
  165. package/web-dist/assets/xychartDiagram-FW5EYKEG-BYgK1Y3H.js +7 -0
  166. package/web-dist/favicon-16.png +0 -0
  167. package/web-dist/favicon-32.png +0 -0
  168. package/web-dist/favicon.svg +78 -0
  169. package/web-dist/icon-192.png +0 -0
  170. package/web-dist/icon-512.png +0 -0
  171. package/web-dist/icon-maskable-512.png +0 -0
  172. package/web-dist/index.html +33 -0
  173. package/web-dist/manifest.webmanifest +16 -0
@@ -0,0 +1,90 @@
1
+ // @ts-check
2
+ // Shared storage + rendering for the fray durable notification queue. Imported by both the
3
+ // CLI (`notify.mjs`) and the Stop hook (`fray-notify-surface.mjs`) so the on-disk shape and
4
+ // the human-facing markdown render are defined in ONE place and can't drift.
5
+ //
6
+ // Queue item shape (one JSON object per line in `.fray/notify-queue.jsonl`):
7
+ // { id, ts, kind, title, body, status: "open"|"dismissed", surfaced: bool }
8
+ // `title` is a short heading; `body` is the prose context the human reads to decide.
9
+ // Legacy items carry a single `text` field instead of title/body — the renderer degrades
10
+ // gracefully (derives a heading from the first clause, keeps the rest as the body).
11
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
12
+ import { existsSync } from 'node:fs';
13
+ import { dirname } from 'node:path';
14
+
15
+ export const KINDS = new Set(['WIN', 'DECISION', 'BLOCKER', 'FYI']);
16
+
17
+ // Section order is deliberate: what the human must ACT on first (blockers, decisions),
18
+ // then what's purely informational (wins, fyi).
19
+ const SECTIONS = [
20
+ ['BLOCKER', 'Blockers'],
21
+ ['DECISION', 'Decisions'],
22
+ ['WIN', 'Wins'],
23
+ ['FYI', 'FYI'],
24
+ ];
25
+
26
+ /** @param {string} file @returns {any[]} */
27
+ export function readQueue(file) {
28
+ if (!existsSync(file)) return [];
29
+ return readFileSync(file, 'utf8')
30
+ .split('\n')
31
+ .filter((l) => l.trim())
32
+ .map((l) => {
33
+ try {
34
+ return JSON.parse(l);
35
+ } catch {
36
+ return null;
37
+ }
38
+ })
39
+ .filter(Boolean);
40
+ }
41
+
42
+ /** @param {string} file @param {any[]} items */
43
+ export function writeQueue(file, items) {
44
+ mkdirSync(dirname(file), { recursive: true });
45
+ writeFileSync(file, items.map((i) => JSON.stringify(i)).join('\n') + (items.length ? '\n' : ''));
46
+ }
47
+
48
+ function titleOf(i) {
49
+ if (i.title) return String(i.title).trim();
50
+ const t = String(i.text || '').trim();
51
+ const head = t.split(/ — | – |: |\. /)[0] || t; // legacy: first clause as the heading
52
+ return head.slice(0, 90).trim();
53
+ }
54
+
55
+ function bodyOf(i) {
56
+ if (i.body != null) return String(i.body).trim();
57
+ if (i.title) return '';
58
+ const t = String(i.text || '').trim();
59
+ const ti = titleOf(i);
60
+ return t.startsWith(ti) ? t.slice(ti.length).replace(/^[\s—–:.-]+/, '').trim() : t;
61
+ }
62
+
63
+ /**
64
+ * Render the OPEN items as scannable, sectioned markdown for the human — grouped by kind,
65
+ * each item a `###` heading + its prose body + a subtle id tag. This is the surface the
66
+ * human reads to decide; it is NOT a terse log line. (Authors should write each item's
67
+ * title/body per the prose skill — terse, factual, enough context to decide.)
68
+ * @param {any[]} open @returns {string}
69
+ */
70
+ export function renderMarkdown(open) {
71
+ const n = open.length;
72
+ const intro =
73
+ `📌 ${n} item${n === 1 ? '' : 's'} waiting on you — surfaced here so they don't scroll away. ` +
74
+ `Nothing to run in a terminal: just tell me your call in chat and I'll clear each one.`;
75
+
76
+ const parts = [intro];
77
+ for (const [kind, heading] of SECTIONS) {
78
+ const rows = open.filter((i) => i.kind === kind);
79
+ if (!rows.length) continue;
80
+ parts.push(`\n## ${heading}`);
81
+ for (const i of rows) {
82
+ const title = titleOf(i);
83
+ const body = bodyOf(i);
84
+ let block = `\n### ${title} · ${i.id}`;
85
+ if (body) block += `\n\n${body}`;
86
+ parts.push(block);
87
+ }
88
+ }
89
+ return parts.join('\n');
90
+ }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // fray notify — a DURABLE, human-facing notification queue for the orchestrator.
4
+ //
5
+ // The problem it fixes: in a long autonomous session, the things the human most needs to
6
+ // see — a headline WIN that landed, a DECISION that's genuinely theirs, a BLOCKER — get
7
+ // buried under per-turn status churn and scroll out of reach. This queue is written the
8
+ // instant something noteworthy happens, persists until DISMISSED, and is re-surfaced when
9
+ // the orchestrator goes idle by the companion Stop hook (`hooks/fray-notify-surface.mjs`).
10
+ //
11
+ // The hook SURFACES each item to the human directly (rich, sectioned markdown via
12
+ // `systemMessage`). The orchestrator does NOT relay or regurgitate the queue in chat — the
13
+ // human already sees it. The orchestrator's only job is to DISMISS an item once the human
14
+ // has addressed it in conversation (the human has no terminal): `fray-notify dismiss <id>`.
15
+ //
16
+ // Each item carries a short `title` (the heading the human scans) and a prose `body` (the
17
+ // context they read to decide) — author BOTH per the prose skill: terse, factual, enough to
18
+ // make the call without opening a file. Storage: the PROJECT's `.fray/notify-queue.jsonl`
19
+ // (resolved from CLAUDE_PROJECT_DIR, or cwd when run by hand), one JSON object per line:
20
+ // {id, ts, kind, title, body, status: "open"|"dismissed", surfaced: bool}
21
+ // kind ∈ WIN | DECISION | BLOCKER | FYI. `surfaced` is stamped by the Stop hook once it has
22
+ // shown the item, so a new item interrupts idle exactly once, then persists quietly.
23
+ //
24
+ // Usage (bare command on PATH via bin/fray-notify while the plugin is enabled):
25
+ // fray-notify add <WIN|DECISION|BLOCKER|FYI> "<title>" "<body>" # body optional; prints the id
26
+ // fray-notify list [--all] # rendered markdown (open, or all)
27
+ // fray-notify dismiss <id>[ <id> ...] | --all
28
+ // Robust: a malformed queue line is skipped, never fatal.
29
+ import { join } from 'node:path';
30
+ import { KINDS, readQueue, writeQueue, renderMarkdown } from './notify-shared.mjs';
31
+
32
+ const PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR || process.cwd();
33
+ const QUEUE = join(PROJECT_DIR, '.fray', 'notify-queue.jsonl');
34
+
35
+ function newId(items) {
36
+ const n = items.reduce((m, i) => Math.max(m, Number(String(i.id).replace(/\D/g, '')) || 0), 0);
37
+ return 'n' + (n + 1);
38
+ }
39
+
40
+ function main() {
41
+ const [cmd, ...rest] = process.argv.slice(2);
42
+ const items = readQueue(QUEUE);
43
+
44
+ if (cmd === 'add') {
45
+ const kind = (rest[0] || '').toUpperCase();
46
+ const title = (rest[1] || '').trim();
47
+ const body = rest.slice(2).join(' ').trim();
48
+ if (!KINDS.has(kind) || !title) {
49
+ console.error('usage: fray-notify add <WIN|DECISION|BLOCKER|FYI> "<title>" "<body>"');
50
+ process.exit(1);
51
+ }
52
+ const id = newId(items);
53
+ items.push({ id, ts: new Date().toISOString(), kind, title, body, status: 'open', surfaced: false });
54
+ writeQueue(QUEUE, items);
55
+ console.log(id);
56
+ } else if (cmd === 'list') {
57
+ const all = rest.includes('--all');
58
+ const rows = items.filter((i) => all || i.status === 'open');
59
+ console.log(rows.length ? renderMarkdown(rows) : '(no notifications)');
60
+ } else if (cmd === 'dismiss') {
61
+ const all = rest.includes('--all');
62
+ const ids = new Set(rest);
63
+ let n = 0;
64
+ for (const i of items) {
65
+ if (i.status === 'open' && (all || ids.has(i.id))) {
66
+ i.status = 'dismissed';
67
+ n++;
68
+ }
69
+ }
70
+ writeQueue(QUEUE, items);
71
+ console.log(`dismissed ${n}`);
72
+ } else {
73
+ console.error('usage: fray-notify <add|list|dismiss> …');
74
+ process.exit(1);
75
+ }
76
+ }
77
+
78
+ // Run the CLI. This module is the command entry point (invoked directly as `node notify.mjs`
79
+ // or via the bin shim's dynamic import); helpers live in notify-shared.mjs, so nothing imports
80
+ // this file for its functions — running unconditionally is correct.
81
+ main();
@@ -0,0 +1,120 @@
1
+ // @ts-check
2
+ /**
3
+ * fray — per-thread SESSION OWNERSHIP. A thread carries an optional `owner_session: <id>`
4
+ * frontmatter field naming the Claude Code session RESPONSIBLE for it, so multiple fray
5
+ * sessions can share one repo, each driving its own set of threads without stepping on the
6
+ * others. This module is the read/write + state-derivation layer; the `fray claim/disown/
7
+ * owners` subcommands (index.mjs) and the board annotation sit on top.
8
+ *
9
+ * TWO HARD DESIGN CHOICES (both deliberate — see the fray thread / report):
10
+ *
11
+ * 1. LIVENESS IS DERIVED, ownership is only WRITTEN by an explicit gesture. Whether an owner
12
+ * is alive comes from its heartbeat freshness (config.mjs `sessionLive`), NOT a stored
13
+ * flag. The `owner_session` STRING is physically written ONLY by `fray claim`/`disown`
14
+ * (a human/orchestrator action) and cleared by `fray owners --gc` — NEVER by an automatic
15
+ * per-turn hook. That is the safety guarantee: no hook ever rewrites a thread `.md` body,
16
+ * so there is no clobber race against a sub-agent editing the same thread. A dead owner's
17
+ * lingering `owner_session` string is INERT — derivation reads it as orphaned/claimable,
18
+ * and the next `claim` overwrites it.
19
+ *
20
+ * 2. OWNERSHIP IS ADVISORY, not a hard lock. It surfaces "another live session is on this,
21
+ * don't touch" and "this is orphaned, take it" — it does not physically prevent an edit.
22
+ * A hard lock would let a crashed session permanently freeze its threads; advisory +
23
+ * heartbeat-staleness + `--force` is the safe shape.
24
+ */
25
+ import { readFileSync, writeFileSync, renameSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+
28
+ /** The frontmatter key that records a thread's owning session id. */
29
+ export const OWNER_KEY = 'owner_session';
30
+
31
+ /**
32
+ * A session id is a bare, filesystem-safe token (uuid-ish: word chars, dots, hyphens). Reject
33
+ * anything else so a stray value can never inject frontmatter or a newline.
34
+ * @param {string} sid
35
+ * @returns {boolean}
36
+ */
37
+ export function isValidSessionId(sid) {
38
+ return typeof sid === 'string' && /^[\w.-]+$/.test(sid) && sid.length <= 200;
39
+ }
40
+
41
+ /**
42
+ * Read a thread's `owner_session` (or null when unset/empty/missing thread). Reads only the
43
+ * frontmatter block, matching the board's flat `key: value` shape.
44
+ * @param {string} projectDir
45
+ * @param {string} slug
46
+ * @returns {string|null}
47
+ */
48
+ export function readOwner(projectDir, slug) {
49
+ try {
50
+ const src = readFileSync(join(projectDir, '.fray', `${slug}.md`), 'utf8');
51
+ const fmEnd = src.indexOf('\n---', 4); // frontmatter is `---\n … \n---`
52
+ const fm = fmEnd === -1 ? src : src.slice(0, fmEnd);
53
+ const m = fm.match(/^owner_session:[ \t]*(.*)$/m);
54
+ if (!m) return null;
55
+ const v = m[1].trim().replace(/^["']|["']$/g, '');
56
+ return v || null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Set (or, with `sid === null`, REMOVE) a thread's `owner_session` frontmatter field, atomically
64
+ * and byte-for-byte-preserving everything else — the SAME split/rename discipline as the thread
65
+ * updater. Throws on a missing thread / no frontmatter / an invalid session id (callers report).
66
+ * @param {string} projectDir
67
+ * @param {string} slug
68
+ * @param {string|null} sid the owning session id, or null to clear ownership
69
+ */
70
+ export function setOwner(projectDir, slug, sid) {
71
+ if (sid !== null && !isValidSessionId(sid)) throw new Error(`invalid session id: ${sid}`);
72
+ const path = join(projectDir, '.fray', `${slug}.md`);
73
+ const original = readFileSync(path, 'utf8');
74
+ const lines = original.split('\n');
75
+ if (lines[0] !== '---') throw new Error(`thread ${slug}.md has no YAML frontmatter block`);
76
+ let end = -1;
77
+ for (let i = 1; i < lines.length; i++) {
78
+ if (lines[i] === '---') { end = i; break; }
79
+ }
80
+ if (end === -1) throw new Error(`thread ${slug}.md frontmatter is not closed`);
81
+
82
+ // Locate an existing owner_session line within [1, end).
83
+ let ownerAt = -1;
84
+ for (let i = 1; i < end; i++) {
85
+ if (/^owner_session:/.test(lines[i])) { ownerAt = i; break; }
86
+ }
87
+ if (sid === null) {
88
+ if (ownerAt !== -1) lines.splice(ownerAt, 1); // remove the line entirely
89
+ } else if (ownerAt !== -1) {
90
+ lines[ownerAt] = `owner_session: ${sid}`; // update in place, preserving order
91
+ } else {
92
+ lines.splice(end, 0, `owner_session: ${sid}`); // append as the last frontmatter line
93
+ }
94
+
95
+ const out = lines.join('\n');
96
+ const tmp = `${path}.tmp.${process.pid}`;
97
+ writeFileSync(tmp, out);
98
+ renameSync(tmp, path);
99
+ }
100
+
101
+ /**
102
+ * @typedef {'unowned'|'mine'|'other-live'|'orphaned'} OwnershipState
103
+ */
104
+
105
+ /**
106
+ * Derive the EFFECTIVE ownership state of a thread for the current session.
107
+ * - no owner → `unowned`
108
+ * - owner === me → `mine`
109
+ * - owner alive (other) → `other-live` (don't touch; `--force` to take it)
110
+ * - owner dead (other) → `orphaned` (freely claimable)
111
+ * @param {string|null} owner the thread's `owner_session`
112
+ * @param {string|null} currentSid this session's id
113
+ * @param {boolean} ownerLive whether `owner` is currently live (heartbeat-derived)
114
+ * @returns {OwnershipState}
115
+ */
116
+ export function effectiveOwnership(owner, currentSid, ownerLive) {
117
+ if (!owner) return 'unowned';
118
+ if (currentSid && owner === currentSid) return 'mine';
119
+ return ownerLive ? 'other-live' : 'orphaned';
120
+ }
@@ -0,0 +1,213 @@
1
+ // @ts-check
2
+ /**
3
+ * fray — "rest-on-waiter" detection. PURE, dependency-free, and unit-tested
4
+ * (rest-detect.test.mjs) so the SubagentStop guard (hooks/fray-rest-guard.mjs)
5
+ * can stay a thin I/O shell around this judgment.
6
+ *
7
+ * THE PROBLEM it detects: a background sub-agent backgrounds a long op (a build, a
8
+ * test run, a CI watch, an install) and then RESTS — going idle and handing control
9
+ * back — instead of running the op to completion. That strands the task: the
10
+ * orchestrator must manually resume it. The guard BLOCKS that stop and redirects the
11
+ * agent to poll the op inline and finish.
12
+ *
13
+ * TWO SIGNALS, OR-ed (defense in depth). A stop is blocked if EITHER fires:
14
+ *
15
+ * 1. STRUCTURAL (primary, high-confidence) — {@link detectStructuralRest}: keyed on
16
+ * what the agent DID, not how it phrased it. The agent's LAST meaningful tool_use
17
+ * in the transcript was a BACKGROUND-LAUNCH (a `run_in_background` Bash/Agent/Task,
18
+ * or a Monitor/ScheduleWakeup waiter/timer) and nothing substantive ran after it —
19
+ * i.e. it kicked off a background op and ended its turn. This keys on the tool name
20
+ * + the `run_in_background` param, so it is immune to phrasing drift (the failure
21
+ * mode of the prose matcher below). See `isBackgroundLaunch`.
22
+ *
23
+ * 2. PROSE (fallback) — {@link detectWaiterRest}: the original phrasing matcher on the
24
+ * final assistant TEXT. Catches a waiter-rest the structural signal misses (e.g. the
25
+ * bg-launch was an earlier turn, or the harness shape changed). Demoted to secondary
26
+ * because string-matching the prose is fragile; kept because it costs nothing and the
27
+ * two signals fail in different directions.
28
+ *
29
+ * Why structural beats prose: the prose matcher hinges on the agent SAYING "I'll await
30
+ * the monitor"; an agent that backgrounds-and-rests SILENTLY (no tell) slips it, and a
31
+ * reworded tell slips it. The structural signal sees the bg-launch tool call regardless.
32
+ *
33
+ * (A fully-deterministic "is the launched bg task STILL RUNNING?" signal was investigated
34
+ * and is NOT feasible from a hook: the harness `tasks/<id>.output` files are raw stdout
35
+ * streams / jsonl symlinks with no authoritative running-vs-terminal status or exit-code
36
+ * sidecar, and the SubagentStop stdin carries no task registry. mtime-liveness is
37
+ * load-dependent and non-deterministic. So we rely on the tool-use structure, which is
38
+ * deterministic from the transcript the hook already reads.)
39
+ */
40
+ import { readFileSync } from 'node:fs';
41
+
42
+ /**
43
+ * FUTURE/ONGOING parking intent — the agent declaring it will idle and be woken.
44
+ * Deliberately excludes bare "complete"/"waited for" (past tense = done, not parking).
45
+ */
46
+ const PARK =
47
+ /\b(?:await(?:ing)?|waiting for|i'?ll wait|i'?ll await|holding(?: here)?|pausing|paused here|standing by|letting it (?:complete|finish|run)|will notify me|notify me when|re-?invoke(?:s|d)? me|holding until|until then|rather than poll(?:ing)?)\b/i;
48
+
49
+ /** BACKGROUND-MECHANISM noun — the thing it's resting ON. */
50
+ const MECH =
51
+ /\b(?:monitor|waiter|watcher|background(?:ed)?|completion notification|notify(?:ing)? me|re-?invoke)\b/i;
52
+
53
+ /**
54
+ * High-confidence single phrases that ALONE prove a waiter-rest (so a message that
55
+ * trips only one of PARK/MECH but contains one of these still blocks). Kept tight.
56
+ */
57
+ const STRONG = [
58
+ /\bthe (?:background )?(?:waiter|monitor|watcher)\b[^.]*\b(?:will|to) (?:notify|re-?invoke|wake)\b/i,
59
+ /\brather than poll(?:ing)?\b/i,
60
+ /\bstanding by\b/i,
61
+ /\bwill (?:notify|re-?invoke|ping|wake) me\b/i,
62
+ ];
63
+
64
+ /**
65
+ * Does this final-assistant text read as RESTING ON A WAITER/MONITOR (a false rest
66
+ * that should be blocked) rather than a genuine deliverable?
67
+ * @param {string|null|undefined} text the agent's last assistant message text
68
+ * @returns {boolean} true ONLY on a high-confidence waiter-rest match
69
+ */
70
+ export function detectWaiterRest(text) {
71
+ if (!text || typeof text !== 'string') return false;
72
+ // Scan a bounded tail — the parking tell is always at the END of the message, and
73
+ // scanning the whole of a long report risks a stray match in earlier narration.
74
+ const tail = text.length > 1200 ? text.slice(-1200) : text;
75
+ if (STRONG.some((re) => re.test(tail))) return true;
76
+ return PARK.test(tail) && MECH.test(tail);
77
+ }
78
+
79
+ /**
80
+ * Extract the LAST assistant message's joined text from a transcript .jsonl.
81
+ * Each line is a JSON event; an assistant turn has `type:"assistant"` and
82
+ * `message.content` is an array of blocks; text blocks are `{type:"text", text}`.
83
+ * Returns the joined text of the latest assistant turn that HAS any text block, or
84
+ * null if none/unreadable (→ caller fails open).
85
+ * @param {string|null|undefined} transcriptPath
86
+ * @returns {string|null}
87
+ */
88
+ export function lastAssistantText(transcriptPath) {
89
+ try {
90
+ if (!transcriptPath || typeof transcriptPath !== 'string') return null;
91
+ const raw = readFileSync(transcriptPath, 'utf8');
92
+ const lines = raw.split('\n');
93
+ for (let i = lines.length - 1; i >= 0; i--) {
94
+ const line = lines[i].trim();
95
+ if (!line) continue;
96
+ let o;
97
+ try {
98
+ o = JSON.parse(line);
99
+ } catch {
100
+ continue;
101
+ }
102
+ if (!o || o.type !== 'assistant' || !o.message || !Array.isArray(o.message.content)) continue;
103
+ const texts = o.message.content
104
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
105
+ .map((b) => b.text);
106
+ if (texts.length) return texts.join('\n').trim();
107
+ }
108
+ return null;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ // ─────────────────────────────────────────────────────────────────────────────
115
+ // STRUCTURAL SIGNAL — key on the agent's TOOL-USE history, not its prose.
116
+ // ─────────────────────────────────────────────────────────────────────────────
117
+
118
+ /**
119
+ * BACKGROUND-LAUNCH tool names that are ALWAYS a waiter/timer launch by their very
120
+ * nature — no parameter check needed. A Monitor (poll-until loop) or a ScheduleWakeup
121
+ * (timer) IS, definitionally, "kick off a background wait." (Verified against real
122
+ * transcripts: `{name:"Monitor", input:{command,timeout_ms,persistent}}` and
123
+ * `{name:"ScheduleWakeup", input:{delaySeconds,reason,prompt}}`.)
124
+ * @type {ReadonlySet<string>}
125
+ */
126
+ const ALWAYS_BG_TOOLS = new Set(['Monitor', 'ScheduleWakeup']);
127
+
128
+ /**
129
+ * Is this content block a BACKGROUND-LAUNCH tool_use — the structural tell that the
130
+ * agent kicked off a background op rather than running it to completion inline?
131
+ *
132
+ * Two ways a tool_use qualifies:
133
+ * - it is an ALWAYS_BG tool (Monitor / ScheduleWakeup), OR
134
+ * - it carries `input.run_in_background === true` (the param real Bash/Agent/Task
135
+ * background launches set — verified against transcripts: a backgrounded `Bash`
136
+ * has `input.run_in_background:true`, and a backgrounded `Agent` dispatch likewise).
137
+ *
138
+ * The `run_in_background` check is GENERIC (any tool name) so a future background-capable
139
+ * tool is covered without a code change; the named set covers the waiter/timer tools that
140
+ * take no such flag.
141
+ * @param {any} block a transcript content block
142
+ * @returns {boolean}
143
+ */
144
+ export function isBackgroundLaunch(block) {
145
+ if (!block || block.type !== 'tool_use' || typeof block.name !== 'string') return false;
146
+ if (ALWAYS_BG_TOOLS.has(block.name)) return true;
147
+ const input = block && typeof block.input === 'object' && block.input ? block.input : {};
148
+ return input.run_in_background === true;
149
+ }
150
+
151
+ /**
152
+ * The STRUCTURAL waiter-rest judgment, as a PURE function of the agent's ordered
153
+ * tool_use blocks (so it is unit-testable with synthetic inputs, no transcript file).
154
+ *
155
+ * Fires when the LAST tool_use the agent made was a background-launch. Because it is the
156
+ * LAST tool_use, nothing substantive ran after it — the agent kicked off a background op
157
+ * and then ended its turn (emitting at most a final text message). That is exactly the
158
+ * "backgrounded-and-rested" pathology. A genuine finish polls the op inline first, so its
159
+ * last tool_use is a FOREGROUND read/commit (run_in_background falsy) — not a bg-launch —
160
+ * and this returns false.
161
+ *
162
+ * @param {any[]} toolUseBlocks ordered (transcript-order) list of tool_use content blocks
163
+ * @returns {boolean} true ONLY when the last tool_use is a background-launch
164
+ */
165
+ export function detectStructuralRestFromToolUses(toolUseBlocks) {
166
+ if (!Array.isArray(toolUseBlocks) || toolUseBlocks.length === 0) return false;
167
+ return isBackgroundLaunch(toolUseBlocks[toolUseBlocks.length - 1]);
168
+ }
169
+
170
+ /**
171
+ * Read a transcript .jsonl and return the LAST tool_use content block the agent made
172
+ * (across the whole transcript), or null when there is none / it is unreadable. Scans
173
+ * from the END backward for efficiency (we only need the final tool_use) and stops at
174
+ * the first assistant turn that carries any tool_use, returning that turn's LAST
175
+ * tool_use block (a turn may batch several). Fail-CLOSED to null on any error so the
176
+ * caller fails open (no block).
177
+ * @param {string|null|undefined} transcriptPath
178
+ * @returns {any|null}
179
+ */
180
+ export function lastToolUse(transcriptPath) {
181
+ try {
182
+ if (!transcriptPath || typeof transcriptPath !== 'string') return null;
183
+ const raw = readFileSync(transcriptPath, 'utf8');
184
+ const lines = raw.split('\n');
185
+ for (let i = lines.length - 1; i >= 0; i--) {
186
+ const line = lines[i].trim();
187
+ if (!line) continue;
188
+ let o;
189
+ try {
190
+ o = JSON.parse(line);
191
+ } catch {
192
+ continue;
193
+ }
194
+ if (!o || o.type !== 'assistant' || !o.message || !Array.isArray(o.message.content)) continue;
195
+ const tus = o.message.content.filter((b) => b && b.type === 'tool_use');
196
+ if (tus.length) return tus[tus.length - 1];
197
+ }
198
+ return null;
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * STRUCTURAL waiter-rest detection straight from a transcript path: true when the agent's
206
+ * last tool_use was a background-launch. Fail-open (false) on any read/parse failure.
207
+ * @param {string|null|undefined} transcriptPath
208
+ * @returns {boolean}
209
+ */
210
+ export function detectStructuralRest(transcriptPath) {
211
+ const last = lastToolUse(transcriptPath);
212
+ return isBackgroundLaunch(last);
213
+ }
@@ -0,0 +1,162 @@
1
+ // @ts-check
2
+ /**
3
+ * fray — thread-excerpt: read a `.fray/<slug>.md` and render a capped, key-section
4
+ * excerpt for the ORCHESTRATOR to read.
5
+ *
6
+ * WHY THIS EXISTS / where it's used. When a dispatched sub-agent FINISHES and the
7
+ * orchestrator goes idle, the Stop hook (fray-stop-reminder.mjs) rest-reconciliation
8
+ * guard hands the orchestrator the CONTENTS of that agent's bound thread — so it can
9
+ * square the agent's REPORTED results (the task return) against what the thread now
10
+ * SAYS (the agent should have updated its own thread before resting; this is the
11
+ * orchestrator's verification surface). We surface the thread HERE rather than via a
12
+ * SubagentStop hook because a SubagentStop hook's additionalContext continues the
13
+ * SUB-AGENT's turn, not the orchestrator's — the orchestrator's own Stop hook is the
14
+ * only channel that actually lands thread text in the orchestrator's context.
15
+ *
16
+ * WHAT IT EXTRACTS — the at-a-glance current-truth of a thread, not the whole file:
17
+ * the frontmatter status line (status + status_text + title) plus the `## Status`,
18
+ * `## Decisions`, and `## Next step` sections (the single-voice current state). The
19
+ * rest of the file (Steps checklists, long write-ups) stays behind the path pointer.
20
+ *
21
+ * CAPPING — a huge thread must never blow up the orchestrator's context: each thread
22
+ * is capped to PER_THREAD_CAP chars and the combined surface to TOTAL_CAP; on either
23
+ * cap we truncate and note the `.fray/<slug>.md` path for the full read.
24
+ *
25
+ * FAIL-OPEN ABSOLUTELY: a missing/unreadable file, an unresolved slug, or any parse
26
+ * error yields no excerpt for that thread (the caller degrades to the bare pointer).
27
+ * Never throws.
28
+ */
29
+ import { readFileSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+
32
+ const PER_THREAD_CAP = 1200; // max chars of excerpt body per thread
33
+ const TOTAL_CAP = 4000; // max chars across ALL just-finished threads combined
34
+
35
+ // The body sections that carry a thread's single-voice CURRENT truth — the same set
36
+ // the dispatch epilogue tells an agent to keep current. Order is the surfaced order.
37
+ const KEY_SECTIONS = ['Status', 'Decisions', 'Next step'];
38
+
39
+ /**
40
+ * Pull the content under a `## <name>` heading up to the next h1/h2 (h3+ stays in the
41
+ * section). Case-insensitive on the heading text; trailing blank lines trimmed.
42
+ * @param {string} src
43
+ * @param {string} name
44
+ * @returns {string} the section body (no heading line), or '' if absent/empty
45
+ */
46
+ function section(src, name) {
47
+ const lines = src.split('\n');
48
+ const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
49
+ const head = new RegExp(`^##\\s+${esc}\\s*$`, 'i');
50
+ const i = lines.findIndex((l) => head.test(l));
51
+ if (i === -1) return '';
52
+ const body = [];
53
+ for (let j = i + 1; j < lines.length; j++) {
54
+ if (/^#{1,2}\s/.test(lines[j])) break; // next h1/h2 ends the section
55
+ body.push(lines[j]);
56
+ }
57
+ return body.join('\n').replace(/\s+$/, '').replace(/^\s*\n/, '');
58
+ }
59
+
60
+ /**
61
+ * Parse the leading `--- … ---` frontmatter into a flat scalar map (same shape the
62
+ * board parser reads). Returns {} when there's no frontmatter.
63
+ * @param {string} src
64
+ * @returns {Record<string,string>}
65
+ */
66
+ function frontmatter(src) {
67
+ /** @type {Record<string,string>} */
68
+ const out = {};
69
+ const m = src.match(/^---\n([\s\S]*?)\n---/);
70
+ if (!m) return out;
71
+ for (const line of m[1].split('\n')) {
72
+ const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
73
+ if (kv) out[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, '');
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /**
79
+ * Build a capped excerpt of one thread's current truth, or null if the file can't be
80
+ * read / has no useful content. The returned string is the BODY only (the caller adds
81
+ * the `--- .fray/<slug>.md ---` banner) so callers can frame it uniformly.
82
+ * @param {string} projectDir
83
+ * @param {string} slug
84
+ * @returns {string|null}
85
+ */
86
+ export function threadExcerpt(projectDir, slug) {
87
+ if (!slug || typeof slug !== 'string') return null;
88
+ const rel = `.fray/${slug}.md`;
89
+ let src;
90
+ try {
91
+ src = readFileSync(join(projectDir, '.fray', `${slug}.md`), 'utf8');
92
+ } catch {
93
+ return null; // missing/unreadable → caller degrades to the bare pointer
94
+ }
95
+
96
+ const fm = frontmatter(src);
97
+ const parts = [];
98
+ // Compact frontmatter header — the at-a-glance status, never the whole block.
99
+ const statusLine = [
100
+ fm.status ? `status: ${fm.status}` : null,
101
+ fm.status_text ? `status_text: ${fm.status_text}` : null,
102
+ ]
103
+ .filter(Boolean)
104
+ .join('\n');
105
+ if (statusLine) parts.push(statusLine);
106
+
107
+ for (const name of KEY_SECTIONS) {
108
+ const body = section(src, name);
109
+ if (body) parts.push(`## ${name}\n${body}`);
110
+ }
111
+
112
+ if (!parts.length) return null; // nothing useful to surface
113
+
114
+ let body = parts.join('\n\n');
115
+ if (body.length > PER_THREAD_CAP) {
116
+ body = body.slice(0, PER_THREAD_CAP).replace(/\s+\S*$/, '') + `\n… [truncated — full thread at ${rel}]`;
117
+ }
118
+ return body;
119
+ }
120
+
121
+ /**
122
+ * Render the combined "thread contents for the agent(s) that just finished" block for a
123
+ * set of just-completed thread slugs — the orchestrator's verification surface. Dedupes
124
+ * slugs, skips any that can't be excerpted, and caps the COMBINED size at TOTAL_CAP
125
+ * (threads past the cap are listed by path only). Returns '' when nothing surfaces, so
126
+ * the caller can append it unconditionally and degrade to the bare rest pointer.
127
+ * @param {string} projectDir
128
+ * @param {string[]} slugs
129
+ * @returns {string}
130
+ */
131
+ export function threadExcerptsBlock(projectDir, slugs) {
132
+ try {
133
+ const seen = new Set();
134
+ /** @type {string[]} */
135
+ const blocks = [];
136
+ /** @type {string[]} */
137
+ const overflow = [];
138
+ let used = 0;
139
+ for (const slug of slugs || []) {
140
+ if (!slug || seen.has(slug)) continue;
141
+ seen.add(slug);
142
+ const ex = threadExcerpt(projectDir, slug);
143
+ const rel = `.fray/${slug}.md`;
144
+ if (!ex) continue; // unreadable/empty → silently skip (bare pointer still names the thread)
145
+ const rendered = `--- ${rel} ---\n${ex}`;
146
+ if (used + rendered.length > TOTAL_CAP && blocks.length) {
147
+ overflow.push(rel); // already surfaced at least one; defer the rest to a path list
148
+ continue;
149
+ }
150
+ blocks.push(rendered);
151
+ used += rendered.length;
152
+ }
153
+ if (!blocks.length) return '';
154
+ let out =
155
+ '\n\nfray — thread contents for the agent(s) that just finished (the agent should have updated its thread before resting; square its reported results against what the thread now says):\n\n' +
156
+ blocks.join('\n\n');
157
+ if (overflow.length) out += `\n\n… also finished (read in full): ${overflow.join(', ')}`;
158
+ return out;
159
+ } catch {
160
+ return ''; // fail-open — never break the Stop hook over an excerpt
161
+ }
162
+ }