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,982 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* fray — the SHARED, type-safe config + vocab module. Every fray hook
|
|
4
|
+
* (hooks/*.mjs) and the board tool (scripts/fray/index.mjs) import from here, so
|
|
5
|
+
* there is exactly ONE source of truth for: the activation gate, the config schema
|
|
6
|
+
* + parse, and the thread-status vocabulary.
|
|
7
|
+
*
|
|
8
|
+
* Dependency-free by design (no `yaml` package): Node ships no built-in YAML
|
|
9
|
+
* parser, and fray must stay portable + runnable by bare `node` with zero install.
|
|
10
|
+
* We hand-parse the SMALL, FLAT shape of `.fray/config.yml` (top-level scalars
|
|
11
|
+
* plus the one nested `state:` block) — not a general YAML parser, just enough.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* THE PER-SESSION SENTINEL — how fray is toggled on/off for ONE Claude Code session.
|
|
19
|
+
*
|
|
20
|
+
* fray enablement is keyed on the Claude Code SESSION ID, not a repo-global flag, so
|
|
21
|
+
* it can be scoped to (and toggled mid-) a single session without affecting other
|
|
22
|
+
* concurrent sessions in the same repo. The session id is the same value the hooks
|
|
23
|
+
* receive in their stdin JSON (`session_id`) AND that a Bash/Write tool call reads from
|
|
24
|
+
* `process.env.CLAUDE_CODE_SESSION_ID` — verified equal — so an agent or human can flip
|
|
25
|
+
* the current session by writing/removing the sentinel via a single tool call.
|
|
26
|
+
*
|
|
27
|
+
* Sentinel path: `.fray/.session-state/<session_id>`. Its presence + content encodes an
|
|
28
|
+
* EXPLICIT per-session override:
|
|
29
|
+
* - file contains `off` (or `false`/`no`/`0`/`disabled`) → fray FORCED OFF this session
|
|
30
|
+
* - file contains `on` (or `true`/`yes`/`1`/`enabled`) → fray FORCED ON this session
|
|
31
|
+
* - file ABSENT → no override → fall back to the default (DORMANT — opt-in: a session
|
|
32
|
+
* is active only after it explicitly runs `fray on`)
|
|
33
|
+
*
|
|
34
|
+
* @param {string} projectDir
|
|
35
|
+
* @param {string|undefined|null} sessionId
|
|
36
|
+
* @returns {'on'|'off'|null} explicit override, or null when none is set
|
|
37
|
+
*/
|
|
38
|
+
export function sessionOverride(projectDir, sessionId) {
|
|
39
|
+
if (!projectDir || !sessionId) return null;
|
|
40
|
+
try {
|
|
41
|
+
const f = join(projectDir, '.fray', '.session-state', sessionId);
|
|
42
|
+
if (!existsSync(f)) return null;
|
|
43
|
+
const v = readFileSync(f, 'utf8').trim().toLowerCase();
|
|
44
|
+
if (v === 'off' || v === 'false' || v === 'no' || v === '0' || v === 'disabled') return 'off';
|
|
45
|
+
if (v === 'on' || v === 'true' || v === 'yes' || v === '1' || v === 'enabled') return 'on';
|
|
46
|
+
// Any other / empty content → treat presence as an explicit OFF (sentinel = quiet this session).
|
|
47
|
+
return 'off';
|
|
48
|
+
} catch {
|
|
49
|
+
return null; // unreadable → no override
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Write the per-session sentinel for `sessionId` to force fray ON or OFF this session.
|
|
55
|
+
* Creates `.fray/.session-state/` as needed. Returns the sentinel path.
|
|
56
|
+
* @param {string} projectDir
|
|
57
|
+
* @param {string} sessionId
|
|
58
|
+
* @param {'on'|'off'} state
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export function setSessionOverride(projectDir, sessionId, state) {
|
|
62
|
+
const dir = join(projectDir, '.fray', '.session-state');
|
|
63
|
+
mkdirSync(dir, { recursive: true });
|
|
64
|
+
const f = join(dir, sessionId);
|
|
65
|
+
writeFileSync(f, state + '\n');
|
|
66
|
+
return f;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Remove the per-session sentinel for `sessionId` (revert to the default). No-op if absent.
|
|
71
|
+
* @param {string} projectDir
|
|
72
|
+
* @param {string} sessionId
|
|
73
|
+
*/
|
|
74
|
+
export function clearSessionOverride(projectDir, sessionId) {
|
|
75
|
+
try {
|
|
76
|
+
rmSync(join(projectDir, '.fray', '.session-state', sessionId), { force: true });
|
|
77
|
+
} catch {
|
|
78
|
+
/* already gone */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── PER-SESSION LIVENESS HEARTBEAT — the crux of thread OWNERSHIP ───────────────────
|
|
83
|
+
// A fray thread can be OWNED by a session (frontmatter `owner_session: <id>`), so several
|
|
84
|
+
// sessions can share one repo, each driving its own set of threads (see ownership.mjs). The
|
|
85
|
+
// failure to avoid: a thread owned by a session that then TERMINATES → nobody can touch it.
|
|
86
|
+
// Claude Code's `SessionEnd` hook is a best-effort eager signal but is NOT guaranteed on a
|
|
87
|
+
// crash / kill / terminal-close (verified against the hooks docs), so it cannot be the SOLE
|
|
88
|
+
// liveness signal. The robust fallback is a HEARTBEAT: every fray-active session stamps a
|
|
89
|
+
// `.seen` sidecar each turn, and ownership liveness is DERIVED from its freshness (never a
|
|
90
|
+
// stored "alive" flag — same compute-don't-store discipline as agent liveness). A dead owner's
|
|
91
|
+
// heartbeat goes stale → its threads read as ORPHANED → freely claimable. The heartbeat lives
|
|
92
|
+
// ALONGSIDE the on/off sentinel under `.fray/.session-state/` (a sibling `<id>.seen` file), so
|
|
93
|
+
// it composes with — and never collides with — the activation sentinel `<id>`.
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Path to a session's liveness heartbeat sidecar: `.fray/.session-state/<sid>.seen`.
|
|
97
|
+
* @param {string} projectDir
|
|
98
|
+
* @param {string} sessionId
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
export function sessionHeartbeatPath(projectDir, sessionId) {
|
|
102
|
+
return join(projectDir, '.fray', '.session-state', `${sessionId}.seen`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Stamp `sessionId`'s heartbeat to `ts` (default now) — the "this session is alive NOW" mark
|
|
107
|
+
* refreshed each turn by the UserPromptSubmit + SessionStart hooks. Creates the state dir as
|
|
108
|
+
* needed. Best-effort: any write error is swallowed (a missed beat just risks one stale read).
|
|
109
|
+
* @param {string} projectDir
|
|
110
|
+
* @param {string} sessionId
|
|
111
|
+
* @param {number} [ts]
|
|
112
|
+
*/
|
|
113
|
+
export function touchSessionHeartbeat(projectDir, sessionId, ts = Date.now()) {
|
|
114
|
+
if (!projectDir || !sessionId) return;
|
|
115
|
+
try {
|
|
116
|
+
const dir = join(projectDir, '.fray', '.session-state');
|
|
117
|
+
mkdirSync(dir, { recursive: true });
|
|
118
|
+
writeFileSync(join(dir, `${sessionId}.seen`), String(ts) + '\n');
|
|
119
|
+
} catch {
|
|
120
|
+
/* best-effort — a missed heartbeat is tolerated by the staleness window */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Read `sessionId`'s last-heartbeat epoch-ms, or `null` when absent/unreadable/garbage.
|
|
126
|
+
* @param {string} projectDir
|
|
127
|
+
* @param {string} sessionId
|
|
128
|
+
* @returns {number|null}
|
|
129
|
+
*/
|
|
130
|
+
export function readSessionHeartbeat(projectDir, sessionId) {
|
|
131
|
+
if (!projectDir || !sessionId) return null;
|
|
132
|
+
try {
|
|
133
|
+
const raw = readFileSync(sessionHeartbeatPath(projectDir, sessionId), 'utf8').trim();
|
|
134
|
+
if (/^\d+$/.test(raw)) {
|
|
135
|
+
const n = parseInt(raw, 10);
|
|
136
|
+
return Number.isFinite(n) ? n : null;
|
|
137
|
+
}
|
|
138
|
+
const t = Date.parse(raw); // tolerate a hand-written ISO stamp
|
|
139
|
+
return Number.isFinite(t) ? t : null;
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Remove `sessionId`'s heartbeat (mark it dead IMMEDIATELY). Called by the SessionEnd hook
|
|
147
|
+
* (graceful exit) and by `fray off`/`fray reset`. No-op if absent.
|
|
148
|
+
* @param {string} projectDir
|
|
149
|
+
* @param {string} sessionId
|
|
150
|
+
*/
|
|
151
|
+
export function clearSessionHeartbeat(projectDir, sessionId) {
|
|
152
|
+
if (!projectDir || !sessionId) return;
|
|
153
|
+
try {
|
|
154
|
+
rmSync(sessionHeartbeatPath(projectDir, sessionId), { force: true });
|
|
155
|
+
} catch {
|
|
156
|
+
/* already gone */
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Default owner-staleness window (minutes): how long since a session's last heartbeat before
|
|
161
|
+
* its owned threads are treated as ORPHANED (auto-claimable without `--force`). Generous on
|
|
162
|
+
* purpose — an idle-at-prompt session heartbeats only on activity, so a short window would
|
|
163
|
+
* mis-declare a live-but-idle session dead. `--force` covers the "I KNOW it's dead, take it
|
|
164
|
+
* now" case, so the window only governs the no-force auto-claim threshold. */
|
|
165
|
+
export const DEFAULT_OWNER_STALE_MIN = 180;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Resolve the owner-staleness window (minutes) from config's `state.owner_stale_min`, falling
|
|
169
|
+
* back to {@link DEFAULT_OWNER_STALE_MIN}. Non-positive / unparseable → the default.
|
|
170
|
+
* @param {FrayConfig} cfg
|
|
171
|
+
* @returns {number}
|
|
172
|
+
*/
|
|
173
|
+
export function ownerStaleMin(cfg) {
|
|
174
|
+
const raw = cfg?.state?.owner_stale_min;
|
|
175
|
+
const n = parseInt(String(raw ?? ''), 10);
|
|
176
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_OWNER_STALE_MIN;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Is `sessionId` currently LIVE — heartbeat present AND within `staleMin`, AND not explicitly
|
|
181
|
+
* turned OFF this session? DERIVED (never a stored flag). A session with no heartbeat, a stale
|
|
182
|
+
* one, or an `off` sentinel is dead (its owned threads are orphaned / claimable).
|
|
183
|
+
* @param {string} projectDir
|
|
184
|
+
* @param {string} sessionId
|
|
185
|
+
* @param {number} staleMin
|
|
186
|
+
* @param {number} [now]
|
|
187
|
+
* @returns {boolean}
|
|
188
|
+
*/
|
|
189
|
+
export function sessionLive(projectDir, sessionId, staleMin, now = Date.now()) {
|
|
190
|
+
if (!projectDir || !sessionId) return false;
|
|
191
|
+
if (sessionOverride(projectDir, sessionId) === 'off') return false; // explicitly silenced → not owning
|
|
192
|
+
const hb = readSessionHeartbeat(projectDir, sessionId);
|
|
193
|
+
if (hb == null) return false;
|
|
194
|
+
return now - hb <= staleMin * 60_000;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Resolve the canonical session id for the CURRENT process: the env the hooks +
|
|
199
|
+
* tool calls share (`CLAUDE_CODE_SESSION_ID`), with an optional explicit override
|
|
200
|
+
* (a hook's stdin `session_id`) taking precedence. Both are the same value in
|
|
201
|
+
* practice (verified), so either works; the explicit arg lets a hook pass the id
|
|
202
|
+
* it already parsed.
|
|
203
|
+
* @param {string} [explicit]
|
|
204
|
+
* @returns {string|null}
|
|
205
|
+
*/
|
|
206
|
+
export function currentSessionId(explicit) {
|
|
207
|
+
return (explicit && String(explicit)) || process.env.CLAUDE_CODE_SESSION_ID || null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* THE ACTIVATION GATE — is fray active in this project, FOR THIS SESSION?
|
|
212
|
+
*
|
|
213
|
+
* fray ships as a GLOBALLY-loaded Claude Code plugin, so its hooks fire in EVERY
|
|
214
|
+
* project. They must be a SILENT no-op until a project opts in, or a virgin repo
|
|
215
|
+
* gets fray noise it never asked for.
|
|
216
|
+
*
|
|
217
|
+
* The gate, in order:
|
|
218
|
+
* 1. `.fray/` directory EXISTS — the project has been bootstrapped (the `/fray`
|
|
219
|
+
* skill creates it on first invocation). No `.fray/` → fray is dormant here.
|
|
220
|
+
* 2. The PER-SESSION SENTINEL (`.fray/.session-state/<session_id>`) — the EXPLICIT
|
|
221
|
+
* per-session opt-in. `on` → fray active for THIS session; `off` → silenced.
|
|
222
|
+
* ABSENT → the default below.
|
|
223
|
+
* 3. DEFAULT (no sentinel): fray is DORMANT — activation is OPT-IN PER SESSION. A
|
|
224
|
+
* fresh session in a `.fray/` repo stays silent (every hook a no-op) until it
|
|
225
|
+
* explicitly opts in via an `on` sentinel — written by the `/fray` skill's step 0
|
|
226
|
+
* or a manual `fray on`. No sentinel → dormant, EVEN THOUGH `.fray/` exists. (This
|
|
227
|
+
* is the opt-IN model; the former default was opt-OUT — active whenever `.fray/`
|
|
228
|
+
* existed — which contradicted the plugin's "dormant until you run /fray" contract.)
|
|
229
|
+
*
|
|
230
|
+
* This replaces the former repo-global `enabled:` flag in `.fray/config.yml`: that
|
|
231
|
+
* flag was repo-wide (hit every concurrent session, couldn't be scoped) and could
|
|
232
|
+
* not be toggled mid-session. The sentinel is per-session and writable by a tool
|
|
233
|
+
* call, so a session can be activated (or quieted) without a relaunch.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} projectDir The repo root (e.g. `process.env.CLAUDE_PROJECT_DIR`).
|
|
236
|
+
* @param {string} [sessionId] The session id (defaults to `CLAUDE_CODE_SESSION_ID`).
|
|
237
|
+
* @returns {boolean} whether fray is active here, for this session.
|
|
238
|
+
*/
|
|
239
|
+
export function frayActive(projectDir, sessionId) {
|
|
240
|
+
if (!projectDir) return false;
|
|
241
|
+
try {
|
|
242
|
+
if (!existsSync(join(projectDir, '.fray'))) return false; // not bootstrapped → dormant
|
|
243
|
+
} catch {
|
|
244
|
+
return false; // unreadable → treat as dormant (silent, fail-safe for a virgin repo)
|
|
245
|
+
}
|
|
246
|
+
const override = sessionOverride(projectDir, currentSessionId(sessionId));
|
|
247
|
+
if (override === 'off') return false; // explicit per-session silence
|
|
248
|
+
if (override === 'on') return true; // explicit per-session enable
|
|
249
|
+
return false; // DEFAULT: OPT-IN — dormant until this session runs `fray on`
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The CANONICAL thread-status vocabulary — listed in LIFECYCLE order. This is the ONLY set
|
|
254
|
+
* written to disk going forward; legacy spellings (`todo`/`plan`/`enqueued`/`needs-decision`)
|
|
255
|
+
* are accepted on read via {@link STATUS_ALIASES} + {@link normalizeStatus}, never written.
|
|
256
|
+
*
|
|
257
|
+
* THE WAITING MODEL (2026-07-08, refining the 2026-07-01 unified model): "awaiting a human" is
|
|
258
|
+
* now its OWN first-class status — `needs-human` — no longer an encoding of `blocked`. `blocked`
|
|
259
|
+
* narrows to MACHINE-waits ONLY and REQUIRES a resolution-mechanism field. So the human(yellow)/
|
|
260
|
+
* machine(gray) split is back in the WORD (`needs-human` vs `blocked`); the mechanism field now
|
|
261
|
+
* only distinguishes the two MACHINE flavors of `blocked` (threads vs timer). BACK-COMPAT: a
|
|
262
|
+
* legacy `blocked` thread with NO machine field reads as `needs-human` (see {@link
|
|
263
|
+
* effectiveStatus}); the legacy `needs-decision` spelling aliases to `needs-human`.
|
|
264
|
+
*
|
|
265
|
+
* - `planning` — ACTIVE design discussion happening RIGHT NOW: the deliverable is the DESIGN
|
|
266
|
+
* itself, not an implementation. Open questions in motion; worked WITH the human or a Plan/
|
|
267
|
+
* architect agent, NEVER an implementer. SURFACED. Flips to `planned` at a design stopping
|
|
268
|
+
* point (or straight to `active` when you dispatch the build).
|
|
269
|
+
* - `planned` — PARKED: scoped/designed but NOT actively worked. NOT surfaced in the nag (the
|
|
270
|
+
* board shows it; the pulse stays quiet). A thread waiting on a TRANSIENT blocker is NOT
|
|
271
|
+
* `planned` — it is `blocked` + the right mechanism field. (RENAME of the old `todo`; also
|
|
272
|
+
* absorbs the old `plan`.)
|
|
273
|
+
* - `active` — building NOW; a live agent is on it. SURFACED.
|
|
274
|
+
* - `needs-human` — AWAITING A HUMAN: a question / decision / approval, OR a finished result that
|
|
275
|
+
* needs human review, that ONLY the maintainer can resolve. REQUIRES a `status_text` stating
|
|
276
|
+
* the ask. YELLOW, hoisted to the top of the `⚖ awaiting you` queue, surfaced in the nag by its
|
|
277
|
+
* `status_text` (untruncated), the ONLY thing the Stop hook pops. (Absorbs the old
|
|
278
|
+
* `needs-decision` spelling AND the old `blocked`-with-no-machine-field human encoding.)
|
|
279
|
+
* - `blocked` — CANNOT run, waiting on NON-human work. REQUIRES exactly ONE machine mechanism
|
|
280
|
+
* ({@link blockMechanism}); a `blocked` thread with no machine field is mis-encoded and reads
|
|
281
|
+
* as `needs-human` (the validator warns). The mechanism is one of:
|
|
282
|
+
* (1) `blocking_threads: [slug, …]` — blocked on other THREADS going terminal. GRAY,
|
|
283
|
+
* de-emphasized, AUTO-FIRES: the instant every listed thread is done/dismissed the board
|
|
284
|
+
* flips it `▶ READY — dispatch now` (+ the DROP-RISK callout). (Old `enqueued`; the field
|
|
285
|
+
* is the RENAME of `depends_on`, which is still accepted as a read-alias field. Entries
|
|
286
|
+
* may also be typed EXTERNAL gates `pr:`/`ci:`/`external:` — those park, they don't fire.)
|
|
287
|
+
* (2) `revalidate_at: <ISO>` (+ `last_checked`) — blocked on an external event with a TIMER.
|
|
288
|
+
* GRAY/dim, parked + quiet until due, then surfaces loudly for a recheck.
|
|
289
|
+
* Machine-blocked threads do NOT clamor for attention — GRAY, rendered LAST among live groups,
|
|
290
|
+
* EXCLUDED from the `⚖ awaiting you` queue + the Stop-hook pop.
|
|
291
|
+
* - `done` / `dismissed` — TERMINAL (completed / decided-against): kept, never deleted,
|
|
292
|
+
* excluded from the active board's pending views.
|
|
293
|
+
* @type {readonly string[]}
|
|
294
|
+
*/
|
|
295
|
+
export const STATUS = ['planning', 'planned', 'active', 'needs-human', 'blocked', 'done', 'dismissed'];
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* BACK-COMPAT READ ALIASES — legacy status spellings, accepted FOREVER on read (never a
|
|
299
|
+
* validation error) and normalized to their canonical target. `todo`/`plan` → `planned`;
|
|
300
|
+
* `enqueued` → `blocked` (dep-blocked, a machine wait); `needs-decision` → `needs-human` (the
|
|
301
|
+
* human-decision state, promoted to its own first-class status on 2026-07-08). A thread still
|
|
302
|
+
* carrying any of these validates fine and buckets as its canonical target; the CLI writes the
|
|
303
|
+
* canonical word. NOTE: a legacy `blocked` thread with no machine field is a SEPARATE contextual
|
|
304
|
+
* alias to `needs-human` — that one depends on the mechanism fields, so it lives in
|
|
305
|
+
* {@link effectiveStatus}, not this pure string map.
|
|
306
|
+
* @type {Readonly<Record<string,string>>}
|
|
307
|
+
*/
|
|
308
|
+
export const STATUS_ALIASES = { todo: 'planned', plan: 'planned', enqueued: 'blocked', 'needs-decision': 'needs-human' };
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The full set ACCEPTED by the validator: canonical statuses plus the read-aliases. Used
|
|
312
|
+
* for the "expected one of …" error message and the `--status <s>` filter.
|
|
313
|
+
* @type {readonly string[]}
|
|
314
|
+
*/
|
|
315
|
+
export const ACCEPTED_STATUSES = [...STATUS, ...Object.keys(STATUS_ALIASES)];
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Normalize a raw `status:` value to its CANONICAL form: a legacy alias maps to its target,
|
|
319
|
+
* a canonical value passes through, anything else (incl. unknown/garbage) returns unchanged
|
|
320
|
+
* so the caller's validation can still reject it. Apply this wherever a thread's raw status
|
|
321
|
+
* is read, validated, or bucketed.
|
|
322
|
+
* @param {string|undefined|null} raw
|
|
323
|
+
* @returns {string|undefined|null}
|
|
324
|
+
*/
|
|
325
|
+
export function normalizeStatus(raw) {
|
|
326
|
+
if (raw == null) return raw;
|
|
327
|
+
const v = String(raw).trim();
|
|
328
|
+
return STATUS_ALIASES[v] ?? v;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Is `raw` an accepted status — a canonical value OR a read-alias? (Validation accepts both;
|
|
333
|
+
* rejects anything else.)
|
|
334
|
+
* @param {string|undefined|null} raw
|
|
335
|
+
* @returns {boolean}
|
|
336
|
+
*/
|
|
337
|
+
export function isValidStatus(raw) {
|
|
338
|
+
if (raw == null) return false;
|
|
339
|
+
const v = String(raw).trim();
|
|
340
|
+
return STATUS.includes(v) || Object.prototype.hasOwnProperty.call(STATUS_ALIASES, v);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The terminal subset of {@link STATUS}: completed OR decided-against. Both are
|
|
345
|
+
* kept on disk and both are excluded from the pending/board views.
|
|
346
|
+
* @type {readonly string[]}
|
|
347
|
+
*/
|
|
348
|
+
export const TERMINAL = ['done', 'dismissed'];
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The PARKED (non-terminal but not-yet-actively-worked) subset of canonical {@link STATUS}:
|
|
352
|
+
* just `planned`. It is a real, live status the on-demand `fray` board DOES show — but it is
|
|
353
|
+
* EXCLUDED from the AUTO-INJECTED per-turn / stop-hook "pending threads" nag, because nagging
|
|
354
|
+
* the orchestrator every turn about parked work is noise. Pull parked work up deliberately
|
|
355
|
+
* via `fray` when you choose to action it. (`planning` is NOT parked — active design counts
|
|
356
|
+
* as in-flight and IS surfaced.)
|
|
357
|
+
* @type {readonly string[]}
|
|
358
|
+
*/
|
|
359
|
+
export const PARKED = ['planned'];
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The SURFACED (auto-nagged) subset of canonical {@link STATUS} — the actionable/in-flight
|
|
363
|
+
* statuses, in PRIORITY order. `needs-human` is FIRST (top-priority — the maintainer must act;
|
|
364
|
+
* the ⚖ awaiting-you queue). `blocked` is now MACHINE-only (gray, de-emphasized), rendered after
|
|
365
|
+
* `active`. Equals `STATUS − PARKED − TERMINAL`.
|
|
366
|
+
* @type {readonly string[]}
|
|
367
|
+
*/
|
|
368
|
+
export const SURFACED = ['needs-human', 'active', 'blocked', 'planning'];
|
|
369
|
+
|
|
370
|
+
// ── the RESOLUTION MECHANISM of a `blocked` thread — the crux of the unified waiting model ──
|
|
371
|
+
// A `blocked` thread carries EXACTLY ONE mechanism describing HOW it unblocks; the board derives
|
|
372
|
+
// color / urgency / ordering / auto-fire from this, NOT from the status word. Precedence when
|
|
373
|
+
// mis-configured with more than one machine field: timer > threads > human (and the validator
|
|
374
|
+
// warns on the ambiguity). This is a PURE function of already-parsed booleans so both the board
|
|
375
|
+
// and the hooks compute it identically.
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* @typedef {'human'|'threads'|'timer'} BlockMechanism
|
|
379
|
+
*/
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Derive a blocked thread's resolution mechanism from which fields are set.
|
|
383
|
+
* - `timer` — a (parseable) `revalidate_at` is present → re-poll on a timer. GRAY/dim.
|
|
384
|
+
* - `threads` — `blocking_threads`/`depends_on` is non-empty → auto-fires when its thread
|
|
385
|
+
* deps go terminal (external gate entries park it). GRAY.
|
|
386
|
+
* - `human` — neither → only the maintainer can unblock it. YELLOW, the ⚖ awaiting-you queue.
|
|
387
|
+
* @param {{hasBlockingThreads?: boolean, hasTimer?: boolean}} f
|
|
388
|
+
* @returns {BlockMechanism}
|
|
389
|
+
*/
|
|
390
|
+
export function blockMechanism({ hasBlockingThreads, hasTimer }) {
|
|
391
|
+
if (hasTimer) return 'timer';
|
|
392
|
+
if (hasBlockingThreads) return 'threads';
|
|
393
|
+
return 'human';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Is a `blocked` thread a HUMAN-decision block (the ⚖ awaiting-you case)? True ⟺ its mechanism
|
|
398
|
+
* is `human` — no `blocking_threads`/`depends_on` and no `revalidate_at`. The single predicate
|
|
399
|
+
* the board / decisions / reminder / stop-hook / statusline share so "awaiting you" can never
|
|
400
|
+
* drift between them.
|
|
401
|
+
* @param {{hasBlockingThreads?: boolean, hasTimer?: boolean}} f
|
|
402
|
+
* @returns {boolean}
|
|
403
|
+
*/
|
|
404
|
+
export function isHumanBlocked(f) {
|
|
405
|
+
return blockMechanism(f) === 'human';
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The EFFECTIVE (bucketing) status of a thread, given its raw `status:` and its mechanism fields.
|
|
410
|
+
* Applies the pure string aliases ({@link normalizeStatus}) AND the ONE contextual alias that
|
|
411
|
+
* needs the fields: a legacy `blocked` thread with NO machine field (the old pre-`needs-human`
|
|
412
|
+
* human-wait encoding) reads as `needs-human`. A `blocked` thread WITH a machine field stays
|
|
413
|
+
* `blocked` (a machine wait). Everything else passes through its normalized form. This is the
|
|
414
|
+
* single predicate the board, `fray decisions`, the reminder/stop hooks, and the statusline share
|
|
415
|
+
* so "awaiting a human" can never drift between them — a thread is awaiting-you IFF
|
|
416
|
+
* `effectiveStatus(...) === 'needs-human'`.
|
|
417
|
+
* @param {string|undefined|null} rawStatus
|
|
418
|
+
* @param {{hasBlockingThreads?: boolean, hasTimer?: boolean}} [fields]
|
|
419
|
+
* @returns {string|undefined|null}
|
|
420
|
+
*/
|
|
421
|
+
export function effectiveStatus(rawStatus, { hasBlockingThreads, hasTimer } = {}) {
|
|
422
|
+
const s = normalizeStatus(rawStatus);
|
|
423
|
+
if (s === 'blocked' && !hasBlockingThreads && !hasTimer) return 'needs-human';
|
|
424
|
+
return s;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* @typedef {Object} FrayConfig
|
|
429
|
+
* @property {boolean} autonomousMode Whether autonomous mode is on. Default `false`.
|
|
430
|
+
* @property {Record<string, string>} state The `state:` block — cross-cutting "what's true now" globals. Default `{}`.
|
|
431
|
+
*/
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The type-safe DEFAULTS, returned when `.fray/config.yml` is absent. Individual
|
|
435
|
+
* malformed lines are simply skipped (we keep whatever parsed), so a partially
|
|
436
|
+
* broken file still yields a fully-populated config.
|
|
437
|
+
*
|
|
438
|
+
* NOTE: enablement is NO LONGER a config field. It moved to the per-session sentinel
|
|
439
|
+
* (see {@link frayActive} / {@link sessionOverride}). config.yml carries only
|
|
440
|
+
* `autonomous_mode` + the `state:` block now.
|
|
441
|
+
* @returns {FrayConfig}
|
|
442
|
+
*/
|
|
443
|
+
function defaults() {
|
|
444
|
+
return { autonomousMode: false, state: {} };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Coerce a YAML-ish scalar to a boolean. Accepts the YAML 1.1 truthy/falsey
|
|
449
|
+
* spellings fray actually uses (`true`/`false`, `on`/`off`, `yes`/`no`).
|
|
450
|
+
* Anything else returns `fallback` so an unparseable value can't flip a default.
|
|
451
|
+
* @param {string} raw
|
|
452
|
+
* @param {boolean} fallback
|
|
453
|
+
* @returns {boolean}
|
|
454
|
+
*/
|
|
455
|
+
function toBool(raw, fallback) {
|
|
456
|
+
const v = raw.trim().toLowerCase();
|
|
457
|
+
if (v === 'true' || v === 'on' || v === 'yes') return true;
|
|
458
|
+
if (v === 'false' || v === 'off' || v === 'no') return false;
|
|
459
|
+
return fallback;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Strip surrounding single/double quotes and trailing inline `# …` comments.
|
|
464
|
+
* @param {string} raw
|
|
465
|
+
* @returns {string}
|
|
466
|
+
*/
|
|
467
|
+
function scalar(raw) {
|
|
468
|
+
// Drop an inline comment only when the `#` is preceded by whitespace (so a `#`
|
|
469
|
+
// inside a quoted value or a bare token isn't clobbered). Then trim + unquote.
|
|
470
|
+
let v = raw.replace(/\s+#.*$/, '').trim();
|
|
471
|
+
return v.replace(/^["']|["']$/g, '');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Read + parse `.fray/config.yml` from `projectDir` into a fully-populated,
|
|
476
|
+
* type-safe {@link FrayConfig}. The file is absent/unreadable → DEFAULTS.
|
|
477
|
+
* A single malformed line → that line is skipped; everything else still parses.
|
|
478
|
+
*
|
|
479
|
+
* ENABLEMENT is NOT read here — it lives in the per-session sentinel now (see
|
|
480
|
+
* {@link frayActive}). This parses only `autonomous_mode` + the `state:` block.
|
|
481
|
+
*
|
|
482
|
+
* Parser shape (intentionally narrow — matches fray's flat config, NOT general YAML):
|
|
483
|
+
* - `key: value` top-level scalar (e.g. `autonomous_mode: off`)
|
|
484
|
+
* - `state:` opens the one nested block
|
|
485
|
+
* ` key: "value"` two-space-indented entries become `state[key] = value`
|
|
486
|
+
* - `# …` lines + blanks are ignored.
|
|
487
|
+
*
|
|
488
|
+
* @param {string} projectDir The repo root (e.g. `process.env.CLAUDE_PROJECT_DIR`).
|
|
489
|
+
* @returns {FrayConfig}
|
|
490
|
+
*/
|
|
491
|
+
export function loadConfig(projectDir) {
|
|
492
|
+
const cfg = defaults();
|
|
493
|
+
|
|
494
|
+
let src;
|
|
495
|
+
try {
|
|
496
|
+
src = readFileSync(join(projectDir, '.fray', 'config.yml'), 'utf8');
|
|
497
|
+
} catch {
|
|
498
|
+
return cfg; // absent / unreadable → type-safe defaults
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
let inState = false;
|
|
502
|
+
for (const line of src.split('\n')) {
|
|
503
|
+
if (!line.trim() || line.trim().startsWith('#')) continue; // blank / comment
|
|
504
|
+
|
|
505
|
+
// A nested `state:` entry: two-or-more leading spaces + `key: value`.
|
|
506
|
+
const nested = line.match(/^[ \t]+([\w-]+):\s*(.*)$/);
|
|
507
|
+
if (inState && nested) {
|
|
508
|
+
cfg.state[nested[1]] = scalar(nested[2]);
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// A top-level `key: value` (or bare `key:` opening a block).
|
|
513
|
+
const top = line.match(/^([\w-]+):\s*(.*)$/);
|
|
514
|
+
if (!top) continue; // malformed → skip this line, keep parsing
|
|
515
|
+
|
|
516
|
+
const key = top[1];
|
|
517
|
+
const val = top[2];
|
|
518
|
+
|
|
519
|
+
if (key === 'state') {
|
|
520
|
+
inState = true; // open the nested block; `val` is empty for `state:`
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
inState = false; // any other top-level key closes the state block
|
|
524
|
+
|
|
525
|
+
// scalar() FIRST — strip any trailing inline `# …` comment before coercing,
|
|
526
|
+
// else `autonomous_mode: on # note` reads as garbage → silently falls back to
|
|
527
|
+
// the default. (Bug found 2026-06-14: an inline comment flipped autonomous mode
|
|
528
|
+
// back off. The nested `state:` entries already go through scalar(); the
|
|
529
|
+
// top-level bools must too.)
|
|
530
|
+
if (key === 'autonomous_mode') cfg.autonomousMode = toBool(scalar(val), cfg.autonomousMode);
|
|
531
|
+
// unrecognized top-level keys are ignored by design (forward-compatible)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return cfg;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ── ANTI-DRIFT RECONCILE FORCING-FUNCTION ─────────────────────────────────────────
|
|
538
|
+
// The board is COMPUTED from per-thread frontmatter, so a thread whose status drifted
|
|
539
|
+
// from reality (a PR merged, but the thread never flipped to done) is surfaced as live
|
|
540
|
+
// truth until SOMETHING re-grounds it. "Reconcile" historically only meant "fold agent
|
|
541
|
+
// returns" — nothing forced a periodic re-grounding of the whole board against the actual
|
|
542
|
+
// code/PRs. This forcing-function fixes that: a LAST-COMPLETE-RECONCILE timestamp persists
|
|
543
|
+
// in `.fray/.last-reconcile` (gitignored runtime, like the rest of `.fray`); the per-turn
|
|
544
|
+
// hook decides whether to nag via a TWO-TRIGGER gate (see shouldNagReconcile) and, when hot,
|
|
545
|
+
// emits a LOUD instruction to spin up a reconcile sub-agent. The hook does only mtime/timestamp
|
|
546
|
+
// math — the actual PR-liveness checking belongs to the dispatched reconcile sub-agent, NEVER
|
|
547
|
+
// this every-turn hook.
|
|
548
|
+
//
|
|
549
|
+
// WHY TWO TRIGGERS (2026-07-01, replacing the pure elapsed-time gate). The old gate was pure
|
|
550
|
+
// wall-clock (`now − last_reconcile > threshold`); it fired every turn once the clock ran out
|
|
551
|
+
// EVEN WHEN NOTHING CHANGED, so the orchestrator tuned it out. The new gate fires only when
|
|
552
|
+
// there is a real reason to re-ground:
|
|
553
|
+
// (1) DIRTY-GATE (primary, precise): the newest mtime among NON-TERMINAL threads is NEWER
|
|
554
|
+
// than the last reconcile → a thread moved since we last re-grounded, so re-ground it.
|
|
555
|
+
// Silent when nothing moved. This is the no-cry-wolf trigger.
|
|
556
|
+
// (2) EXTERNAL-DRIFT BACKSTOP (secondary): a LONG wall-clock backstop that exists purely to
|
|
557
|
+
// catch drift the dirty-gate structurally CANNOT see — a PR merging or CI flipping with
|
|
558
|
+
// NO thread edit (touches no file → bumps no mtime). Long by design so it never becomes
|
|
559
|
+
// the every-turn nag the dirty-gate replaced.
|
|
560
|
+
|
|
561
|
+
/** Default EXTERNAL-DRIFT BACKSTOP (minutes): the long wall-clock fallback that catches drift
|
|
562
|
+
* producing NO thread-file change (a PR merge, a CI flip). Long on purpose — the precise,
|
|
563
|
+
* every-change trigger is the dirty-gate; this only backstops the file-invisible cases. */
|
|
564
|
+
export const DEFAULT_RECONCILE_BACKSTOP_MIN = 120;
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Resolve the external-drift BACKSTOP (minutes) from the parsed config's `state:` block
|
|
568
|
+
* (`reconcile_threshold_min`), falling back to {@link DEFAULT_RECONCILE_BACKSTOP_MIN}. A
|
|
569
|
+
* non-positive / unparseable value falls back too. (The config KEY keeps its historical name
|
|
570
|
+
* `reconcile_threshold_min` — it now tunes the backstop half of the gate; the dirty-gate is a
|
|
571
|
+
* strict mtime comparison and is NOT time-configurable.)
|
|
572
|
+
* @param {FrayConfig} cfg
|
|
573
|
+
* @returns {number}
|
|
574
|
+
*/
|
|
575
|
+
export function reconcileBackstopMin(cfg) {
|
|
576
|
+
const raw = cfg?.state?.reconcile_threshold_min;
|
|
577
|
+
const n = parseInt(String(raw ?? ''), 10);
|
|
578
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_RECONCILE_BACKSTOP_MIN;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Path to the last-complete-reconcile timestamp file (epoch-ms) under `.fray/`.
|
|
583
|
+
* @param {string} projectDir
|
|
584
|
+
* @returns {string}
|
|
585
|
+
*/
|
|
586
|
+
export function lastReconcilePath(projectDir) {
|
|
587
|
+
return join(projectDir, '.fray', '.last-reconcile');
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Read the persisted last-reconcile epoch-ms, or `null` when absent/unreadable/garbage.
|
|
592
|
+
* @param {string} projectDir
|
|
593
|
+
* @returns {number|null}
|
|
594
|
+
*/
|
|
595
|
+
export function readLastReconcile(projectDir) {
|
|
596
|
+
try {
|
|
597
|
+
const raw = readFileSync(lastReconcilePath(projectDir), 'utf8').trim();
|
|
598
|
+
// Canonical form is epoch-ms (what writeLastReconcile emits). Match it strictly — a bare
|
|
599
|
+
// parseInt would accept an ISO string like "2026-07-01T..." as 2026 (parseInt stops at the
|
|
600
|
+
// '-'), yielding a ~56-year-stale age that nags every turn. Agents demonstrably hand-write
|
|
601
|
+
// this file instead of running `fray reconcile`, so also accept a parseable ISO date.
|
|
602
|
+
if (/^\d+$/.test(raw)) {
|
|
603
|
+
const n = parseInt(raw, 10);
|
|
604
|
+
return Number.isFinite(n) ? n : null;
|
|
605
|
+
}
|
|
606
|
+
const t = Date.parse(raw);
|
|
607
|
+
return Number.isFinite(t) ? t : null;
|
|
608
|
+
} catch {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Stamp the last-complete-reconcile timestamp to `ts` (default now). Creates `.fray/` as
|
|
615
|
+
* needed. Returns the file path.
|
|
616
|
+
* @param {string} projectDir
|
|
617
|
+
* @param {number} [ts]
|
|
618
|
+
* @returns {string}
|
|
619
|
+
*/
|
|
620
|
+
export function writeLastReconcile(projectDir, ts = Date.now()) {
|
|
621
|
+
const dir = join(projectDir, '.fray');
|
|
622
|
+
mkdirSync(dir, { recursive: true });
|
|
623
|
+
const f = lastReconcilePath(projectDir);
|
|
624
|
+
writeFileSync(f, String(ts) + '\n');
|
|
625
|
+
return f;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* @typedef {'first'|'dirty'|'backstop'|null} ReconcileNagReason
|
|
630
|
+
*/
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* THE RECONCILE-NAG GATE — pure (no I/O) so it is trivially testable. Decides whether the
|
|
634
|
+
* per-turn hook should nag for a reconcile, and WHY, via the two-trigger model above. Fires
|
|
635
|
+
* (nag=true) when ANY of:
|
|
636
|
+
* - `first` — no last-reconcile stamp (`lastReconcileMs == null`) → instruct a FIRST reconcile.
|
|
637
|
+
* - `dirty` — the newest NON-TERMINAL thread mtime is NEWER than the last reconcile → a
|
|
638
|
+
* thread moved since we re-grounded (the precise, no-cry-wolf trigger).
|
|
639
|
+
* - `backstop` — `now − lastReconcile > backstopMin` → long wall-clock fallback for external
|
|
640
|
+
* drift (PR/CI) that changed no file. Checked only when NOT already dirty.
|
|
641
|
+
* Otherwise `{ nag: false, reason: null }` — SILENT when the board is clean, which is the point.
|
|
642
|
+
* A missing `newestNonTerminalMtimeMs` (null — no non-terminal threads, or none stat-able) simply
|
|
643
|
+
* can't trip the dirty-gate; `first`/`backstop` still apply.
|
|
644
|
+
* @param {{newestNonTerminalMtimeMs: number|null, lastReconcileMs: number|null, backstopMin: number, now?: number}} f
|
|
645
|
+
* @returns {{nag: boolean, reason: ReconcileNagReason}}
|
|
646
|
+
*/
|
|
647
|
+
export function shouldNagReconcile({ newestNonTerminalMtimeMs, lastReconcileMs, backstopMin, now = Date.now() }) {
|
|
648
|
+
if (lastReconcileMs == null) return { nag: true, reason: 'first' };
|
|
649
|
+
if (newestNonTerminalMtimeMs != null && newestNonTerminalMtimeMs > lastReconcileMs) {
|
|
650
|
+
return { nag: true, reason: 'dirty' };
|
|
651
|
+
}
|
|
652
|
+
if (now - lastReconcileMs > backstopMin * 60_000) return { nag: true, reason: 'backstop' };
|
|
653
|
+
return { nag: false, reason: null };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* The STAMP-LAST instruction — shared by every surface that nudges a reconcile (the
|
|
658
|
+
* UserPromptSubmit backstop AND the Stop-hook rest path) so the ordering rule never drifts
|
|
659
|
+
* between them. The reconcile agent EDITS threads (flips drifted statuses), which bumps their
|
|
660
|
+
* mtime; if it stamped `.fray/.last-reconcile` BEFORE those edits, its own edits would leave
|
|
661
|
+
* the board dirty forever (the dirty-gate would re-fire next turn). So `fray reconcile` (the
|
|
662
|
+
* stamp) MUST be its LAST action, after every thread edit. Reconcile is a JUDGMENT task →
|
|
663
|
+
* dispatch at Opus, high effort.
|
|
664
|
+
*
|
|
665
|
+
* SCOPED (2026-07-06): when the dirty-gate knows exactly WHICH thread(s) drifted, pass their
|
|
666
|
+
* slugs so the instruction says "re-ground <those>" instead of "re-ground EVERY non-terminal
|
|
667
|
+
* thread" — a precise nudge, not a whole-board sweep. An empty/absent scope keeps the full-sweep
|
|
668
|
+
* phrasing (the backstop/first-reconcile case, where drift is file-invisible so no slug is known).
|
|
669
|
+
* @param {string[]} [scopeSlugs]
|
|
670
|
+
* @returns {string}
|
|
671
|
+
*/
|
|
672
|
+
export function reconcileStampLastInstruction(scopeSlugs) {
|
|
673
|
+
const scope = Array.isArray(scopeSlugs) && scopeSlugs.length
|
|
674
|
+
? `re-ground ${scopeSlugs.join(', ')} (plus any thread whose EXTERNAL state — a referenced PR/CI — moved)`
|
|
675
|
+
: 're-ground EVERY non-terminal thread';
|
|
676
|
+
return `AUTO-DISPATCH a BACKGROUND reconcile sub-agent (reflexively — don't deliberate; reconcile is a JUDGMENT task → Opus, high effort): ${scope} against ground truth (PR merged? symbol exists? work shipped?), flip drifted statuses to match, then — as its LAST step, AFTER every thread edit — run \`fray reconcile\` to stamp \`.fray/.last-reconcile\`. Stamping LAST is REQUIRED: the agent's own edits bump thread mtimes, so stamping before them leaves the board dirty forever.`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ── STAMP-ON-AGENT-COMPLETION — the write-ownership treadmill fix (2026-07-06) ──────
|
|
680
|
+
// fray MANDATES write-ownership: a dispatched agent edits its OWN thread. But every such edit
|
|
681
|
+
// bumps that thread's mtime past `.last-reconcile`, so the dirty-gate reads the board stale and
|
|
682
|
+
// nags the orchestrator to re-ground a thread the agent JUST reconciled — the system fighting its
|
|
683
|
+
// own design. Fix: when a thread-bound agent rests, the SubagentStop hook records the thread's
|
|
684
|
+
// current mtime here as "reconciled by its owning agent up to this point." The dirty-gate then
|
|
685
|
+
// treats a thread whose current mtime is NOT NEWER than its owner-mark as CLEAN (an owning-agent
|
|
686
|
+
// edit is folded via the REST path, never the reconcile path). Only NON-owning drift — an
|
|
687
|
+
// orchestrator edit, or external state moving a referenced PR/CI — trips the reconcile nag.
|
|
688
|
+
|
|
689
|
+
/** Path to the per-thread owner-reconcile marks: `{ [slug]: mtimeMs }` under `.fray/`. */
|
|
690
|
+
export function ownerReconciledPath(projectDir) {
|
|
691
|
+
return join(projectDir, '.fray', '.owner-reconciled.json');
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Read the `{ [slug]: mtimeMs }` owner-reconcile map, or `{}` when absent/unreadable/garbage. */
|
|
695
|
+
export function readOwnerReconciled(projectDir) {
|
|
696
|
+
try {
|
|
697
|
+
const o = JSON.parse(readFileSync(ownerReconciledPath(projectDir), 'utf8'));
|
|
698
|
+
return o && typeof o === 'object' && !Array.isArray(o) ? o : {};
|
|
699
|
+
} catch {
|
|
700
|
+
return {};
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Record that `slug`'s owning agent reconciled it AT `mtimeMs` (its thread file's mtime at rest).
|
|
706
|
+
* Merge-writes, capped so a long-lived board can't grow the map without bound. Best-effort.
|
|
707
|
+
* @param {string} projectDir @param {string} slug @param {number} mtimeMs
|
|
708
|
+
*/
|
|
709
|
+
export function stampOwnerReconciled(projectDir, slug, mtimeMs) {
|
|
710
|
+
if (!slug || !(mtimeMs > 0)) return;
|
|
711
|
+
try {
|
|
712
|
+
const map = readOwnerReconciled(projectDir);
|
|
713
|
+
map[slug] = mtimeMs;
|
|
714
|
+
const entries = Object.entries(map);
|
|
715
|
+
const capped = entries.length > 400
|
|
716
|
+
? Object.fromEntries(entries.sort((a, b) => b[1] - a[1]).slice(0, 400))
|
|
717
|
+
: map;
|
|
718
|
+
mkdirSync(join(projectDir, '.fray'), { recursive: true });
|
|
719
|
+
writeFileSync(ownerReconciledPath(projectDir), JSON.stringify(capped) + '\n');
|
|
720
|
+
} catch {
|
|
721
|
+
/* best-effort — a missed stamp just risks one spurious reconcile nag */
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Is a thread's current mtime already accounted for by its OWNING AGENT's reconcile mark? True ⟺
|
|
727
|
+
* a mark exists and the current mtime is NOT NEWER than it — i.e. the latest edit was the owning
|
|
728
|
+
* agent's own thread write (folded via the rest path), NOT orchestrator-facing drift. Such a
|
|
729
|
+
* thread must NOT trip the dirty-gate. Pure predicate so the reminder + stop hook agree.
|
|
730
|
+
* @param {number} currentMtimeMs @param {number|undefined} ownerMarkMs
|
|
731
|
+
* @returns {boolean}
|
|
732
|
+
*/
|
|
733
|
+
export function ownerCleanMtime(currentMtimeMs, ownerMarkMs) {
|
|
734
|
+
return typeof ownerMarkMs === 'number' && currentMtimeMs > 0 && currentMtimeMs <= ownerMarkMs;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* STRUCTURED un-drained-queued-follow-up detection. The OLD test — `\bQUEUED\b` anywhere in the
|
|
739
|
+
* source — false-fired on historical CHECKED-OFF items and any prose mention. This matches ONLY an
|
|
740
|
+
* UNCHECKED todo checkbox (`- [ ]`) whose text carries the live-follow-up shape (a `QUEUED` marker
|
|
741
|
+
* or a "dispatch … return" instruction). A checked `- [x]` is DONE and never flags; prose never
|
|
742
|
+
* flags. (A future structured frontmatter field could replace this; the precise checkbox matcher
|
|
743
|
+
* needs no thread-authoring change.)
|
|
744
|
+
* @param {string} src
|
|
745
|
+
* @returns {boolean}
|
|
746
|
+
*/
|
|
747
|
+
export function hasQueuedFollowup(src) {
|
|
748
|
+
if (typeof src !== 'string') return false;
|
|
749
|
+
for (const line of src.split('\n')) {
|
|
750
|
+
const m = line.match(/^\s*[-*]\s*\[ \]\s+(.*)$/); // UNCHECKED checkbox only ("[ ]", never "[x]")
|
|
751
|
+
if (!m) continue;
|
|
752
|
+
if (/\bQUEUED\b/.test(m[1]) || /\bdispatch\b[^.]*\breturn\b/i.test(m[1])) return true;
|
|
753
|
+
}
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* ASSESS BOARD DRIFT — pure (no I/O), so it is trivially testable. Given per-thread records +
|
|
759
|
+
* the owner-reconcile marks + the last-reconcile stamp, returns whether the board needs
|
|
760
|
+
* re-grounding, WHY (via {@link shouldNagReconcile}), and the SPECIFIC drifted slugs (for the
|
|
761
|
+
* scoped nudge). A non-terminal thread contributes ONLY when it is not owner-clean — an
|
|
762
|
+
* owning-agent edit is not drift (see the STAMP-ON-AGENT-COMPLETION note above).
|
|
763
|
+
* @param {{records:{slug:string,status:string|undefined,mtimeMs:number}[], ownerReconciled:Record<string,number>, lastReconcileMs:number|null, backstopMin:number, now?:number}} a
|
|
764
|
+
* @returns {{nag:boolean, reason:ReconcileNagReason, dirtySlugs:string[]}}
|
|
765
|
+
*/
|
|
766
|
+
export function assessDrift({ records, ownerReconciled, lastReconcileMs, backstopMin, now = Date.now() }) {
|
|
767
|
+
let newest = null;
|
|
768
|
+
/** @type {string[]} */
|
|
769
|
+
const dirtySlugs = [];
|
|
770
|
+
for (const r of records) {
|
|
771
|
+
if (TERMINAL.includes(r.status ?? '')) continue;
|
|
772
|
+
if (!(r.mtimeMs > 0)) continue;
|
|
773
|
+
if (ownerCleanMtime(r.mtimeMs, ownerReconciled?.[r.slug])) continue; // owning-agent edit — not drift
|
|
774
|
+
if (lastReconcileMs == null || r.mtimeMs > lastReconcileMs) dirtySlugs.push(r.slug);
|
|
775
|
+
if (newest == null || r.mtimeMs > newest) newest = r.mtimeMs;
|
|
776
|
+
}
|
|
777
|
+
const { nag, reason } = shouldNagReconcile({ newestNonTerminalMtimeMs: newest, lastReconcileMs, backstopMin, now });
|
|
778
|
+
return { nag, reason, dirtySlugs };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* COMPUTE BOARD DRIFT — the I/O wrapper over {@link assessDrift} the STOP hook uses (the per-turn
|
|
783
|
+
* reminder reuses its own richer scan). Scans `.fray/*.md`, builds records (status + mtime),
|
|
784
|
+
* flags un-drained queued follow-ups ({@link hasQueuedFollowup}), and returns the drift verdict
|
|
785
|
+
* plus the queued slugs — the "is there GENUINE work before idle?" signal for the self-satisfying
|
|
786
|
+
* stop. Fail-open: any error → clean ({nag:false, empty lists}).
|
|
787
|
+
* @param {string} projectDir @param {{backstopMin:number, now?:number}} opts
|
|
788
|
+
* @returns {{nag:boolean, reason:ReconcileNagReason, dirtySlugs:string[], queuedSlugs:string[], lastReconcileMs:number|null}}
|
|
789
|
+
*/
|
|
790
|
+
export function computeBoardDrift(projectDir, { backstopMin, now = Date.now() }) {
|
|
791
|
+
const lastReconcileMs = readLastReconcile(projectDir);
|
|
792
|
+
try {
|
|
793
|
+
const ownerReconciled = readOwnerReconciled(projectDir);
|
|
794
|
+
const frayDir = join(projectDir, '.fray');
|
|
795
|
+
let files;
|
|
796
|
+
try {
|
|
797
|
+
files = readdirSync(frayDir).filter((f) => f.endsWith('.md') && !f.startsWith('_') && !f.startsWith('.'));
|
|
798
|
+
} catch {
|
|
799
|
+
return { nag: false, reason: null, dirtySlugs: [], queuedSlugs: [], lastReconcileMs };
|
|
800
|
+
}
|
|
801
|
+
/** @type {{slug:string,status:string|undefined,mtimeMs:number}[]} */
|
|
802
|
+
const records = [];
|
|
803
|
+
/** @type {string[]} */
|
|
804
|
+
const queuedSlugs = [];
|
|
805
|
+
for (const f of files) {
|
|
806
|
+
const slug = f.replace(/\.md$/, '');
|
|
807
|
+
let src;
|
|
808
|
+
let mtimeMs = 0;
|
|
809
|
+
try {
|
|
810
|
+
const fp = join(frayDir, f);
|
|
811
|
+
src = readFileSync(fp, 'utf8');
|
|
812
|
+
mtimeMs = statSync(fp).mtimeMs;
|
|
813
|
+
} catch {
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
const status = normalizeStatus(src.match(/^status:\s*(\S+)/m)?.[1]) ?? undefined;
|
|
817
|
+
records.push({ slug, status, mtimeMs });
|
|
818
|
+
if (!TERMINAL.includes(status ?? '') && hasQueuedFollowup(src)) queuedSlugs.push(slug);
|
|
819
|
+
}
|
|
820
|
+
const { nag, reason, dirtySlugs } = assessDrift({ records, ownerReconciled, lastReconcileMs, backstopMin, now });
|
|
821
|
+
return { nag, reason, dirtySlugs, queuedSlugs, lastReconcileMs };
|
|
822
|
+
} catch {
|
|
823
|
+
return { nag: false, reason: null, dirtySlugs: [], queuedSlugs: [], lastReconcileMs };
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ── DEBOUNCE the 'dirty' reconcile nag (2026-07-06) ─────────────────────────────────
|
|
828
|
+
// A burst of return-folding is several quick turns, each editing a non-terminal thread — every
|
|
829
|
+
// one of which would otherwise trip the dirty-gate and nag a reconcile. Debounce it: a `dirty`
|
|
830
|
+
// signal must PERSIST both > DEBOUNCE_MIN minutes AND > DEBOUNCE_TURNS turns before it nags.
|
|
831
|
+
// `first`/`backstop` are inherently non-bursty (a baseline / a 2-hour fallback) and nag at once.
|
|
832
|
+
|
|
833
|
+
/** Default debounce: a `dirty` reconcile nag holds until it has persisted this long (minutes)… */
|
|
834
|
+
export const DEFAULT_RECONCILE_DEBOUNCE_MIN = 3;
|
|
835
|
+
/** …AND this many turns. Both must be exceeded — the AND is what survives a fast fold-burst. */
|
|
836
|
+
export const DEFAULT_RECONCILE_DEBOUNCE_TURNS = 2;
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* DEBOUNCE gate — pure. `reason == null` (board clean) resets the window. `first`/`backstop` nag
|
|
840
|
+
* immediately (no window). A `dirty` reason starts a window on first sight (no nag yet) and nags
|
|
841
|
+
* only once the window has aged past BOTH thresholds. The caller owns reading/incrementing/
|
|
842
|
+
* persisting `turns` + the window state.
|
|
843
|
+
* @param {{reason:ReconcileNagReason, now:number, turns:number, state:{dirty_since_ms?:number,dirty_since_turn?:number}, debounceMin:number, debounceTurns:number}} a
|
|
844
|
+
* @returns {{nag:boolean, state:{dirty_since_ms?:number,dirty_since_turn?:number}}}
|
|
845
|
+
*/
|
|
846
|
+
export function debounceReconcileNag({ reason, now, turns, state, debounceMin, debounceTurns }) {
|
|
847
|
+
if (reason == null) return { nag: false, state: {} }; // clean → clear the window
|
|
848
|
+
if (reason !== 'dirty') return { nag: true, state: {} }; // first/backstop → immediate, no window
|
|
849
|
+
const since = state?.dirty_since_ms;
|
|
850
|
+
const sinceTurn = state?.dirty_since_turn;
|
|
851
|
+
if (typeof since !== 'number' || typeof sinceTurn !== 'number') {
|
|
852
|
+
return { nag: false, state: { dirty_since_ms: now, dirty_since_turn: turns } }; // start the window
|
|
853
|
+
}
|
|
854
|
+
const aged = now - since > debounceMin * 60_000 && turns - sinceTurn >= debounceTurns;
|
|
855
|
+
return { nag: aged, state: { dirty_since_ms: since, dirty_since_turn: sinceTurn } };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ── REVALIDATE — time-based recheck for threads waiting on EXTERNAL state ──────────
|
|
859
|
+
// A thread `blocked` on something with no in-session auto-trigger (an external-repo PR
|
|
860
|
+
// awaiting a maintainer, an un-watchable CI, a third-party response) would otherwise sit
|
|
861
|
+
// silently or need a brittle live-polling shell (which dies on session end). Instead it
|
|
862
|
+
// carries a DURABLE `revalidate_at: <ISO8601 UTC>` frontmatter timestamp: while that time
|
|
863
|
+
// is in the FUTURE the thread is "parked on a timer" (quiet — NOT in the per-turn nag); once
|
|
864
|
+
// `now ≥ revalidate_at` it is "due" and the fray-reminder hook + board surface it LOUDLY for
|
|
865
|
+
// a recheck. The optional `last_checked: <ISO8601>` records the previous poll. This is the
|
|
866
|
+
// SINGLE source of the timer semantics, shared by the hook and the board so they never drift.
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* @typedef {Object} RevalidateState
|
|
870
|
+
* @property {number} atMs `revalidate_at` parsed to epoch-ms.
|
|
871
|
+
* @property {string|null} lastChecked the raw `last_checked` scalar, or null when unset.
|
|
872
|
+
* @property {boolean} due whether `now ≥ revalidate_at` (the thread is due for recheck).
|
|
873
|
+
* @property {number} etaMin minutes until due (negative once due); for the board's "next check in".
|
|
874
|
+
*/
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Compute a thread's {@link RevalidateState} from its raw frontmatter scalars. ROBUST BY
|
|
878
|
+
* CONTRACT: a missing / empty / unparseable `revalidate_at` → `null` ("no timer set"), never
|
|
879
|
+
* a throw — so a thread WITHOUT the field behaves exactly as it always has, and a malformed
|
|
880
|
+
* value degrades to not-set rather than crashing the hook. (The board separately surfaces a
|
|
881
|
+
* present-but-unparseable value as a non-fatal warning so a typo'd timestamp isn't silently
|
|
882
|
+
* swallowed.) Quotes are stripped to match the frontmatter quoter; `Date.parse` accepts the
|
|
883
|
+
* ISO-8601 UTC form fray writes.
|
|
884
|
+
* @param {string|undefined|null} revalidateAtRaw raw `revalidate_at` scalar (ISO-8601 UTC)
|
|
885
|
+
* @param {string|undefined|null} lastCheckedRaw raw optional `last_checked` scalar
|
|
886
|
+
* @param {number} [now]
|
|
887
|
+
* @returns {RevalidateState | null}
|
|
888
|
+
*/
|
|
889
|
+
export function revalidateState(revalidateAtRaw, lastCheckedRaw, now = Date.now()) {
|
|
890
|
+
if (revalidateAtRaw == null) return null;
|
|
891
|
+
const v = String(revalidateAtRaw).trim().replace(/^["']|["']$/g, '');
|
|
892
|
+
if (!v) return null;
|
|
893
|
+
const atMs = Date.parse(v);
|
|
894
|
+
if (!Number.isFinite(atMs)) return null; // malformed → treat as not-set (never crash)
|
|
895
|
+
const lcRaw = lastCheckedRaw == null ? '' : String(lastCheckedRaw).trim().replace(/^["']|["']$/g, '');
|
|
896
|
+
return { atMs, lastChecked: lcRaw || null, due: now >= atMs, etaMin: Math.round((atMs - now) / 60_000) };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Humanize a minutes-until-due into a compact ETA (`45m`, `7h`, `2d`) for the board's
|
|
901
|
+
* "next check in" line. Clamps negatives (an already-due timer) to `0m`.
|
|
902
|
+
* @param {number} etaMin
|
|
903
|
+
* @returns {string}
|
|
904
|
+
*/
|
|
905
|
+
export function formatEta(etaMin) {
|
|
906
|
+
const m = Math.max(0, etaMin);
|
|
907
|
+
if (m < 60) return `${m}m`;
|
|
908
|
+
if (m < 60 * 24) return `${Math.round(m / 60)}h`;
|
|
909
|
+
return `${Math.round(m / (60 * 24))}d`;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// ── depends_on classification — thread-slug deps vs typed EXTERNAL deps ─────────────
|
|
913
|
+
// `depends_on` was historically an array of THREAD SLUGS only. It is now LOOSENED to also
|
|
914
|
+
// express dependencies on state OUTSIDE the fray board — a GitHub PR/issue, an external CI
|
|
915
|
+
// run, or a free-form gate — via a `<type>:<ref>` PREFIX on the entry. Backward-compat is the
|
|
916
|
+
// contract: a BARE entry (no recognized prefix) stays a thread slug and behaves exactly as
|
|
917
|
+
// before. A thread slug is a filename base and cannot contain a colon, so a recognized
|
|
918
|
+
// `<type>:` prefix is unambiguous. The two kinds drive DIFFERENT board behavior:
|
|
919
|
+
// - THREAD deps drive READY/blocked — the board auto-fires the thread when every thread dep
|
|
920
|
+
// goes terminal, and the validator flags a dangling thread-slug dep.
|
|
921
|
+
// - EXTERNAL deps PARK the thread ("waiting on <ext>") — they have no in-board terminal
|
|
922
|
+
// signal, so they resolve via `revalidate_at` re-polling or a manual edit (drop the dep).
|
|
923
|
+
// They are NEVER flagged as dangling (there is nothing in `.fray/` to resolve them to).
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Recognized EXTERNAL dep types. An entry prefixed with one of these is an external dep;
|
|
927
|
+
* `external` is the free-form catch-all. An UNRECOGNIZED prefix is deliberately NOT treated
|
|
928
|
+
* as external — it stays a thread slug so a typo surfaces via the dangling-dep validator
|
|
929
|
+
* (rather than silently becoming an inert external gate).
|
|
930
|
+
* @type {readonly string[]}
|
|
931
|
+
*/
|
|
932
|
+
export const EXTERNAL_DEP_TYPES = ['pr', 'issue', 'ci', 'external'];
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* @typedef {{kind:'thread', slug:string}} ThreadDep
|
|
936
|
+
* @typedef {{kind:'external', type:string, label:string}} ExternalDep
|
|
937
|
+
*/
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Classify one raw `depends_on` entry. A `<type>:<ref>` prefix with a RECOGNIZED type
|
|
941
|
+
* ({@link EXTERNAL_DEP_TYPES}) → an external dep (label is the full entry, e.g.
|
|
942
|
+
* `pr:vercel/turborepo#13187`); anything else → a thread-slug dep (the backward-compatible
|
|
943
|
+
* default). Never throws.
|
|
944
|
+
* @param {string} raw
|
|
945
|
+
* @returns {ThreadDep | ExternalDep}
|
|
946
|
+
*/
|
|
947
|
+
export function classifyDep(raw) {
|
|
948
|
+
const s = String(raw ?? '').trim();
|
|
949
|
+
const c = s.indexOf(':');
|
|
950
|
+
if (c > 0) {
|
|
951
|
+
const type = s.slice(0, c).toLowerCase();
|
|
952
|
+
if (EXTERNAL_DEP_TYPES.includes(type)) return { kind: 'external', type, label: s };
|
|
953
|
+
}
|
|
954
|
+
return { kind: 'thread', slug: s };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Parse a thread's dependency array from RAW file source. Reads `blocking_threads:` (the
|
|
959
|
+
* 2026-07-01 rename) OR `depends_on:` (still accepted as a read-alias FIELD), and handles BOTH
|
|
960
|
+
* the inline-array form (`[a, b]`) and the YAML block form (`- a` on following indented lines) —
|
|
961
|
+
* the flat `key: value` frontmatter reader drops block-form list items, so dep-reading MUST go
|
|
962
|
+
* through this src-level parser, not the flat map. This is the SINGLE source of dep-parse truth
|
|
963
|
+
* shared by the board (`index.mjs`) and the reminder hook (`fray-reminder.mjs`) so the two can
|
|
964
|
+
* never disagree on whether a thread is machine- vs human-blocked (the statusline keeps its own
|
|
965
|
+
* inlined copy — it is deployed standalone and cannot import from the plugin). Entries may be
|
|
966
|
+
* bare thread slugs OR typed external gates; classification is {@link classifyDep}'s job.
|
|
967
|
+
* @param {string} src
|
|
968
|
+
* @returns {string[]}
|
|
969
|
+
*/
|
|
970
|
+
export function parseDeps(src) {
|
|
971
|
+
for (const field of ['blocking_threads', 'depends_on']) {
|
|
972
|
+
const inline = String(src).match(new RegExp(`^${field}:\\s*\\[([^\\]]*)\\]`, 'm'));
|
|
973
|
+
if (inline) {
|
|
974
|
+
return inline[1].split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
975
|
+
}
|
|
976
|
+
const block = String(src).match(new RegExp(`^${field}:\\s*\\n((?:[ \\t]+-[ \\t]*.+\\n?)+)`, 'm'));
|
|
977
|
+
if (block) {
|
|
978
|
+
return block[1].split('\n').map((l) => l.replace(/^[ \t]+-[ \t]*/, '').trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
return [];
|
|
982
|
+
}
|