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.
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/dist/claude-agent-broker.js +21641 -0
- package/dist/codex-app-server-daemon.js +354 -0
- package/dist/dev-child.js +40319 -0
- package/dist/frayui.js +8738 -0
- package/package.json +46 -0
- package/runtime/cc/scripts/fray/agent-bindings.mjs +287 -0
- package/runtime/cc/scripts/fray/agent-liveness.mjs +367 -0
- package/runtime/cc/scripts/fray/agent-status.mjs +178 -0
- package/runtime/cc/scripts/fray/config.mjs +982 -0
- package/runtime/cc/scripts/fray/decisions.mjs +97 -0
- package/runtime/cc/scripts/fray/index.mjs +699 -0
- package/runtime/cc/scripts/fray/notify-shared.mjs +90 -0
- package/runtime/cc/scripts/fray/notify.mjs +81 -0
- package/runtime/cc/scripts/fray/ownership.mjs +120 -0
- package/runtime/cc/scripts/fray/rest-detect.mjs +213 -0
- package/runtime/cc/scripts/fray/thread-excerpt.mjs +162 -0
- package/runtime/cc/scripts/fray/thread-update.mjs +285 -0
- package/runtime/cc-worker/.claude-plugin/plugin.json +10 -0
- package/runtime/cc-worker/DECISIONS.md +923 -0
- package/runtime/cc-worker/LICENSE +21 -0
- package/runtime/cc-worker/agents/fable-high.md +8 -0
- package/runtime/cc-worker/agents/fable-low.md +8 -0
- package/runtime/cc-worker/agents/fable-max.md +8 -0
- package/runtime/cc-worker/agents/fable-medium.md +8 -0
- package/runtime/cc-worker/agents/fable-xhigh.md +8 -0
- package/runtime/cc-worker/agents/haiku.md +7 -0
- package/runtime/cc-worker/agents/opus-high.md +8 -0
- package/runtime/cc-worker/agents/opus-low.md +8 -0
- package/runtime/cc-worker/agents/opus-max.md +8 -0
- package/runtime/cc-worker/agents/opus-medium.md +8 -0
- package/runtime/cc-worker/agents/opus-xhigh.md +8 -0
- package/runtime/cc-worker/agents/sonnet-high.md +8 -0
- package/runtime/cc-worker/agents/sonnet-low.md +8 -0
- package/runtime/cc-worker/agents/sonnet-max.md +8 -0
- package/runtime/cc-worker/agents/sonnet-medium.md +8 -0
- package/runtime/cc-worker/agents/sonnet-xhigh.md +8 -0
- package/runtime/cc-worker/bin/fray +17 -0
- package/runtime/cc-worker/bin/fray-mcp.mjs +248 -0
- package/runtime/cc-worker/bin/fray-update +18 -0
- package/runtime/cc-worker/hooks/agent-bind.mjs +40 -0
- package/runtime/cc-worker/hooks/agent-dispatch.mjs +74 -0
- package/runtime/cc-worker/hooks/bash-background.d.mts +6 -0
- package/runtime/cc-worker/hooks/bash-background.mjs +188 -0
- package/runtime/cc-worker/hooks/deny-ask.mjs +38 -0
- package/runtime/cc-worker/hooks/deny-plan.mjs +61 -0
- package/runtime/cc-worker/hooks/hooks.json +111 -0
- package/runtime/cc-worker/hooks/perm-policy.mjs +211 -0
- package/runtime/cc-worker/hooks/precompact-instructions.mjs +122 -0
- package/runtime/cc-worker/hooks/scratchpad-stop.mjs +125 -0
- package/runtime/cc-worker/hooks/scratchpad.mjs +419 -0
- package/runtime/cc-worker/hooks/session-seed.mjs +105 -0
- package/runtime/cc-worker/scripts/fray/agent-bindings.mjs +9 -0
- package/runtime/cc-worker/scripts/fray/config.mjs +12 -0
- package/runtime/cc-worker/skills/gh/SKILL.md +132 -0
- package/runtime/cc-worker/skills/gh/scripts/ci-watch.mjs +60 -0
- package/runtime/cc-worker/skills/gh/scripts/github-watch.mjs +130 -0
- package/runtime/cc-worker/skills/gh/scripts/review-watch.mjs +54 -0
- package/runtime/cc-worker/skills/handoff/SKILL.md +203 -0
- package/runtime/cc-worker/skills/waits/SKILL.md +83 -0
- package/web-dist/apple-touch-icon.png +0 -0
- package/web-dist/assets/TerminalPane-fMGrgB-a.js +7 -0
- package/web-dist/assets/abnfDiagram-VRR7QNED-BtlrDuYX.js +1 -0
- package/web-dist/assets/arc-BSyeo0Gb.js +1 -0
- package/web-dist/assets/architecture-TIHT7OUA-CuD8jpr-.js +1 -0
- package/web-dist/assets/architectureDiagram-ZJ3FMSHR-CdLQ71be.js +36 -0
- package/web-dist/assets/array-BifhSqXX.js +1 -0
- package/web-dist/assets/blockDiagram-677ZJIJ3-CBk2FHEj.js +132 -0
- package/web-dist/assets/c4Diagram-LMCZKHZV-mZ3HQ6WX.js +10 -0
- package/web-dist/assets/channel-C2eUWc74.js +1 -0
- package/web-dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/web-dist/assets/chunk-32BRIVSS-Daxvi7f5.js +1 -0
- package/web-dist/assets/chunk-52WLFC77-BGJoZvry.js +10 -0
- package/web-dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/web-dist/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
- package/web-dist/assets/chunk-C7G6YPKG-Ycd1yheh.js +1 -0
- package/web-dist/assets/chunk-EX3LRPZG-BPFhlsHp.js +231 -0
- package/web-dist/assets/chunk-FWX5IMBZ--xy8DTon.js +2 -0
- package/web-dist/assets/chunk-HOUHSVGY-Dq3zoygp.js +1 -0
- package/web-dist/assets/chunk-ICXQ74PX-FfKP-7yM.js +2 -0
- package/web-dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/web-dist/assets/chunk-KEIR6QF5-BfrZ3jm6.js +161 -0
- package/web-dist/assets/chunk-MOJQB5TN-BeiHnsLF.js +88 -0
- package/web-dist/assets/chunk-OGEWGWER-CjDU_9fC.js +1 -0
- package/web-dist/assets/chunk-PUDLZKDR-BO7tm5QX.js +156 -0
- package/web-dist/assets/chunk-Q4XR5HBZ-BPefovOg.js +70 -0
- package/web-dist/assets/chunk-RYQCIY6F-Cu_KplZW.js +1 -0
- package/web-dist/assets/chunk-V7JOEXUC-DWnebFHh.js +206 -0
- package/web-dist/assets/chunk-VAUOI2AC-BL6qWFhW.js +1 -0
- package/web-dist/assets/chunk-VR4S4FIN-Mfs__7L9.js +1 -0
- package/web-dist/assets/chunk-WYO6CB5R-Bf2IYbEU.js +127 -0
- package/web-dist/assets/chunk-XXDRQBXY-DYlTP5J-.js +1 -0
- package/web-dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/web-dist/assets/chunk-ZGVPDNZ5-CJ9ZHZgU.js +62 -0
- package/web-dist/assets/chunk-ZIRB5QZD-C6fEPe3t.js +32 -0
- package/web-dist/assets/classDiagram-OUVF2IWQ-GaOJNgfE.js +1 -0
- package/web-dist/assets/classDiagram-v2-EOCWNBFH-GaOJNgfE.js +1 -0
- package/web-dist/assets/cose-bilkent-JH36ORCC-BUIsLrGc.js +1 -0
- package/web-dist/assets/cynefin-VYW2F7L2-COSC0oNL.js +1 -0
- package/web-dist/assets/cynefinDiagram-TSTJHNR4-mInhJdg1.js +62 -0
- package/web-dist/assets/cytoscape.esm-B3I8pqwA.js +321 -0
- package/web-dist/assets/dagre-CXRCoUWR.js +1 -0
- package/web-dist/assets/dagre-VKFMJZFB-D-T5z05M.js +4 -0
- package/web-dist/assets/defaultLocale-C8Fc0cco.js +1 -0
- package/web-dist/assets/diagram-FQU43EPY-HbMkSb3j.js +3 -0
- package/web-dist/assets/diagram-G47NLZAW-DBwZLcZh.js +24 -0
- package/web-dist/assets/diagram-NH7WQ7WH-DG_Mhays.js +24 -0
- package/web-dist/assets/diagram-OA4YK3LP-hH39gqpu.js +30 -0
- package/web-dist/assets/diagram-WEI45ONY-DhKZuwxW.js +41 -0
- package/web-dist/assets/dist-DoH_9pyS.js +1 -0
- package/web-dist/assets/ebnfDiagram-CCIWWBDH-B7BiO-NR.js +1 -0
- package/web-dist/assets/erDiagram-Q63AITRT-DHjG5RDQ.js +85 -0
- package/web-dist/assets/eventmodeling-45OFAUF4-C0eV8RBx.js +1 -0
- package/web-dist/assets/flowDiagram-23GEKE2U-BbldRZUK.js +1 -0
- package/web-dist/assets/ganttDiagram-NO4QXBWP-BZ-98wTa.js +292 -0
- package/web-dist/assets/gitGraph-TEB2WS4Q-nF22R2jO.js +1 -0
- package/web-dist/assets/gitGraphDiagram-IHSO6WYX-CoYQQ22V.js +106 -0
- package/web-dist/assets/graphlib-B8gBHxth.js +1 -0
- package/web-dist/assets/index-BHzIN-tQ.js +357 -0
- package/web-dist/assets/index-Duiy4w7C.css +1 -0
- package/web-dist/assets/info-DKCQHKI2-Cy2BCbBW.js +1 -0
- package/web-dist/assets/infoDiagram-FWYZ7A6U-CmhR2R1x.js +2 -0
- package/web-dist/assets/init-D6jRqBbL.js +1 -0
- package/web-dist/assets/ishikawaDiagram-FXEZZL3T-CfG59afl.js +70 -0
- package/web-dist/assets/journeyDiagram-5HDEW3XC-BvFmG20q.js +139 -0
- package/web-dist/assets/kanban-definition-HUTT4EX6-BHGEecsY.js +89 -0
- package/web-dist/assets/katex-CddkPoXu.js +257 -0
- package/web-dist/assets/line-Ds4xvN3d.js +1 -0
- package/web-dist/assets/linear-z2V0wJk9.js +1 -0
- package/web-dist/assets/map-DsCK-0Cs.js +1 -0
- package/web-dist/assets/mermaid-parser.core-Gow4kgYJ.js +7 -0
- package/web-dist/assets/mermaid.core-B7Qc_kbm.js +11 -0
- package/web-dist/assets/mindmap-definition-LN4V7U3C-ayXKGcy_.js +96 -0
- package/web-dist/assets/ordinal-hYBb2elL.js +1 -0
- package/web-dist/assets/packet-7NZHBO7P-oLBdtkD8.js +1 -0
- package/web-dist/assets/path-BWPyau1x.js +1 -0
- package/web-dist/assets/pegDiagram-2B236MQR-DwJyc_1B.js +1 -0
- package/web-dist/assets/pie-RZYD4A2V-CXzad3fT.js +1 -0
- package/web-dist/assets/pieDiagram-ENE6RG2P-CZzgSGtM.js +39 -0
- package/web-dist/assets/quadrantDiagram-ABIIQ3AL--IPIp8yo.js +7 -0
- package/web-dist/assets/radar-I7S5WNFK-DldySvtZ.js +1 -0
- package/web-dist/assets/railroad-3IZDKUUU-CiWFZfXM.js +1 -0
- package/web-dist/assets/railroad-abnf-AHOZXSZD-bXMYUOCD.js +1 -0
- package/web-dist/assets/railroad-ebnf-EBAXGLYW-D11ZwZby.js +1 -0
- package/web-dist/assets/railroad-peg-LSFZ7HO6-DD1AC5X-.js +1 -0
- package/web-dist/assets/railroadDiagram-RFXS5EU6-DcCMNGGb.js +1 -0
- package/web-dist/assets/requirementDiagram-TGXJPOKE-C-6j8urk.js +84 -0
- package/web-dist/assets/rolldown-runtime-Bh1tDfsg.js +1 -0
- package/web-dist/assets/rough.esm-CSKSodPl.js +1 -0
- package/web-dist/assets/sankeyDiagram-HTMAVEWB-CPtiOpdz.js +40 -0
- package/web-dist/assets/sequenceDiagram-DBY2YBRQ-B8FnQTdn.js +162 -0
- package/web-dist/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
- package/web-dist/assets/src-C4XfhTaE.js +1 -0
- package/web-dist/assets/stateDiagram-2N3HPSRC-CxG9nX2P.js +1 -0
- package/web-dist/assets/stateDiagram-v2-6OUMAXLB-SGarOFIy.js +1 -0
- package/web-dist/assets/swimlanes-5IMT3BWC-D49-t0hV.js +2 -0
- package/web-dist/assets/swimlanesDiagram-G3AALYLV-krF0bh4b.js +8 -0
- package/web-dist/assets/timeline-definition-FHXFAJF6-C9OEd9SE.js +120 -0
- package/web-dist/assets/treeView-QDETBFTQ-D1AKqVPa.js +1 -0
- package/web-dist/assets/treemap-6X3UGDF4-BRssB7hM.js +1 -0
- package/web-dist/assets/vennDiagram-L72KCM5P-CftkAbY0.js +34 -0
- package/web-dist/assets/wardley-OPB4EBWU-RLMgzRq4.js +1 -0
- package/web-dist/assets/wardleyDiagram-EHGQE667-BLytgXPS.js +78 -0
- package/web-dist/assets/xychartDiagram-FW5EYKEG-BYgK1Y3H.js +7 -0
- package/web-dist/favicon-16.png +0 -0
- package/web-dist/favicon-32.png +0 -0
- package/web-dist/favicon.svg +78 -0
- package/web-dist/icon-192.png +0 -0
- package/web-dist/icon-512.png +0 -0
- package/web-dist/icon-maskable-512.png +0 -0
- package/web-dist/index.html +33 -0
- 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
|
+
}
|