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,699 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @ts-check
|
|
3
|
+
/**
|
|
4
|
+
* fray — the board + validator. There is NO stored board file: the board/status
|
|
5
|
+
* view is COMPUTED ON DEMAND from the independent per-thread `.fray/<slug>.md`
|
|
6
|
+
* files (the filename slug IS the thread id — the filesystem guarantees uniqueness,
|
|
7
|
+
* so there is no `id` frontmatter field and nothing to dedupe) plus `.fray/config.yml`
|
|
8
|
+
* (globals). Each thread's frontmatter is validated against the schema; the
|
|
9
|
+
* `fray-reminder` hook runs `--validate` every turn so malformed frontmatter surfaces
|
|
10
|
+
* to the orchestrator immediately.
|
|
11
|
+
*
|
|
12
|
+
* Usage (the `fray` command is the bin/ shim that runs this script against the
|
|
13
|
+
* project's `.fray/`, regardless of cwd or where the plugin is installed):
|
|
14
|
+
* fray # print the LIVE board (⚖ awaiting-you + active/planning/blocked; planned hidden)
|
|
15
|
+
* fray --all # print all threads (every status)
|
|
16
|
+
* fray --status planned # print only threads in one status (aliases todo/plan/enqueued/needs-decision accepted)
|
|
17
|
+
* fray reconcile # stamp .fray/.last-reconcile = now (records a completed board reconcile)
|
|
18
|
+
* fray --validate # print ONLY validation errors; exit 1 if any (for the hook / CI). --check is an alias.
|
|
19
|
+
* fray --json # machine-readable {config, threads, errors} — ALWAYS complete, never filtered
|
|
20
|
+
*
|
|
21
|
+
* A `blocked` thread's RESOLUTION MECHANISM (which frontmatter field is set) drives its color +
|
|
22
|
+
* placement — see `blockMechanism` in config.mjs. HUMAN-blocked (no machine field) → the hoisted
|
|
23
|
+
* `⚖ awaiting you` queue. MACHINE-blocked → the de-emphasized `blocked` group. The two machine
|
|
24
|
+
* fields:
|
|
25
|
+
* - `blocking_threads: [entry, …]` (the RENAME of `depends_on`, still accepted as a read-alias
|
|
26
|
+
* FIELD). Each entry (see `classifyDep`): a BARE THREAD SLUG auto-fires `▶ READY` when it goes
|
|
27
|
+
* terminal (a dangling slug is a validation error); a TYPED EXTERNAL gate `pr:owner/repo#N` /
|
|
28
|
+
* `issue:…` / `ci:…` / `external:<desc>` PARKS the thread (`⏳ waiting on: <ext>`, never dangles).
|
|
29
|
+
* - `revalidate_at: <ISO>` — an external TIMER; parked quiet until due, then surfaces for recheck.
|
|
30
|
+
* Computed on demand from the scanned statuses — there is no stored dependency graph.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { closeSync, constants, openSync, readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
import {
|
|
36
|
+
loadConfig,
|
|
37
|
+
STATUS,
|
|
38
|
+
TERMINAL,
|
|
39
|
+
ACCEPTED_STATUSES,
|
|
40
|
+
normalizeStatus,
|
|
41
|
+
isValidStatus,
|
|
42
|
+
setSessionOverride,
|
|
43
|
+
clearSessionOverride,
|
|
44
|
+
sessionOverride,
|
|
45
|
+
currentSessionId,
|
|
46
|
+
frayActive,
|
|
47
|
+
writeLastReconcile,
|
|
48
|
+
revalidateState,
|
|
49
|
+
formatEta,
|
|
50
|
+
classifyDep,
|
|
51
|
+
parseDeps,
|
|
52
|
+
blockMechanism,
|
|
53
|
+
isHumanBlocked,
|
|
54
|
+
effectiveStatus,
|
|
55
|
+
touchSessionHeartbeat,
|
|
56
|
+
clearSessionHeartbeat,
|
|
57
|
+
readSessionHeartbeat,
|
|
58
|
+
ownerStaleMin,
|
|
59
|
+
sessionLive,
|
|
60
|
+
} from './config.mjs';
|
|
61
|
+
import { readOwner, setOwner, effectiveOwnership } from './ownership.mjs';
|
|
62
|
+
import { newestBindingByThread, downstreamThreads, restedAgentIds } from './agent-bindings.mjs';
|
|
63
|
+
import { deriveAgentState, findAgentOutputAge, IDLE_MIN, DROPPED_MIN } from './agent-status.mjs';
|
|
64
|
+
import { collectDecisions } from './decisions.mjs';
|
|
65
|
+
|
|
66
|
+
// The project root comes from the environment, NOT from this script's own path: the
|
|
67
|
+
// board ships inside the fray PLUGIN (and, after a marketplace install, lives in
|
|
68
|
+
// ~/.claude/plugins/cache/…), so a script-relative `../../` root would point at the
|
|
69
|
+
// PLUGIN, never the project. CLAUDE_PROJECT_DIR is exported to hook processes and set
|
|
70
|
+
// by the bin/fray shim; when run by hand from the repo root, process.cwd() is correct.
|
|
71
|
+
const PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
72
|
+
const FRAY_DIR = join(PROJECT_DIR, '.fray');
|
|
73
|
+
|
|
74
|
+
// STATUS/TERMINAL are imported from ./config.mjs — the single shared source the hooks
|
|
75
|
+
// also use, so the vocab can never drift between the tool and the reminder hook.
|
|
76
|
+
const REQUIRED = ['title', 'status']; // created / last_update are optional.
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse a top-of-file `--- … ---` YAML frontmatter block (flat `key: value` only).
|
|
80
|
+
* @param {string} src
|
|
81
|
+
* @returns {Record<string, string> | null}
|
|
82
|
+
*/
|
|
83
|
+
function frontmatter(src) {
|
|
84
|
+
const m = src.match(/^---\n([\s\S]*?)\n---/);
|
|
85
|
+
if (!m) return null; // no frontmatter at all
|
|
86
|
+
/** @type {Record<string, string>} */
|
|
87
|
+
const out = {};
|
|
88
|
+
for (const line of m[1].split('\n')) {
|
|
89
|
+
const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
|
|
90
|
+
if (kv) out[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, '');
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* First non-blank line under `## Next step`, collapsed to one cell.
|
|
97
|
+
* @param {string} src
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
function nextStep(src) {
|
|
101
|
+
const lines = src.split('\n');
|
|
102
|
+
const i = lines.findIndex((l) => /^##\s+Next step\s*$/i.test(l));
|
|
103
|
+
if (i === -1) return '';
|
|
104
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
105
|
+
if (/^#{1,6}\s/.test(lines[j])) break;
|
|
106
|
+
if (lines[j].trim()) return lines[j].trim();
|
|
107
|
+
}
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
// PER-SESSION TOGGLE — `fray on` / `fray off` / `fray status` flip (or report) fray
|
|
113
|
+
// enablement for THIS Claude Code session, keyed on CLAUDE_CODE_SESSION_ID (the same id
|
|
114
|
+
// the hooks gate on — verified equal). Activation is OPT-IN: a session is dormant by
|
|
115
|
+
// default, so `fray on` is what an agent (the `/fray` skill's step 0) OR a human runs to
|
|
116
|
+
// ACTIVATE the current session; `fray off` silences it; both write the sentinel, no
|
|
117
|
+
// relaunch. These are handled BEFORE the board renders, since they are not board queries.
|
|
118
|
+
{
|
|
119
|
+
const sub = process.argv[2];
|
|
120
|
+
if (sub === 'on' || sub === 'off' || sub === 'enable' || sub === 'disable') {
|
|
121
|
+
const sid = currentSessionId();
|
|
122
|
+
if (!sid) {
|
|
123
|
+
console.error('fray: no session id (CLAUDE_CODE_SESSION_ID unset) — cannot toggle this session.');
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
const state = sub === 'on' || sub === 'enable' ? 'on' : 'off';
|
|
127
|
+
const path = setSessionOverride(PROJECT_DIR, sid, state);
|
|
128
|
+
// Heartbeat: `on` stamps it (this session is live + can own threads NOW); `off` clears it
|
|
129
|
+
// (the session stops participating → its owned threads become orphaned/claimable at once).
|
|
130
|
+
if (state === 'on') touchSessionHeartbeat(PROJECT_DIR, sid);
|
|
131
|
+
else clearSessionHeartbeat(PROJECT_DIR, sid);
|
|
132
|
+
console.log(`fray: ${state === 'on' ? 'ENABLED' : 'DISABLED'} for this session (${sid}).`);
|
|
133
|
+
console.log(` sentinel: ${path}`);
|
|
134
|
+
console.log(` revert to default (dormant) with: fray reset`);
|
|
135
|
+
process.exit(0);
|
|
136
|
+
}
|
|
137
|
+
// `fray claim <slug> [--force]` / `fray disown <slug> [--force]` / `fray owners [--gc]` —
|
|
138
|
+
// per-thread session OWNERSHIP. Claiming is EASY when a thread is unowned or its owner is
|
|
139
|
+
// DEAD (heartbeat stale/absent); claiming a thread owned by a DIFFERENT, still-LIVE session
|
|
140
|
+
// is DISCOURAGED and requires `--force`. Ownership is stored as `owner_session:` frontmatter,
|
|
141
|
+
// written only by these explicit gestures — never by an automatic hook (see ownership.mjs).
|
|
142
|
+
if (sub === 'claim' || sub === 'disown' || sub === 'owners') {
|
|
143
|
+
const rest = process.argv.slice(3);
|
|
144
|
+
const force = rest.includes('--force');
|
|
145
|
+
const gc = rest.includes('--gc');
|
|
146
|
+
const slug = rest.find((a) => !a.startsWith('-'));
|
|
147
|
+
const sid = currentSessionId();
|
|
148
|
+
const cfgO = loadConfig(PROJECT_DIR);
|
|
149
|
+
const staleMin = ownerStaleMin(cfgO);
|
|
150
|
+
const id8 = (s) => (s ? s.slice(0, 9) : '(none)');
|
|
151
|
+
const seenAgo = (owner) => {
|
|
152
|
+
const hb = readSessionHeartbeat(PROJECT_DIR, owner);
|
|
153
|
+
return hb == null ? 'never' : `${Math.round((Date.now() - hb) / 60_000)}m ago`;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
if (sub === 'owners') {
|
|
157
|
+
// Full per-thread ownership view. `--gc` additionally CLEARS every orphaned owner (the
|
|
158
|
+
// explicit reconciliation-path clear — safe because it is orchestrator-driven, not a hook).
|
|
159
|
+
let entries;
|
|
160
|
+
try {
|
|
161
|
+
entries = readdirSync(FRAY_DIR).filter((f) => f.endsWith('.md') && !f.startsWith('_') && !f.startsWith('.')).sort();
|
|
162
|
+
} catch {
|
|
163
|
+
console.log(`No .fray/ in ${PROJECT_DIR}.`);
|
|
164
|
+
process.exit(0);
|
|
165
|
+
}
|
|
166
|
+
let cleared = 0;
|
|
167
|
+
console.log(`fray owners — this session: ${id8(sid)} · owner-stale window: ${staleMin}m\n`);
|
|
168
|
+
for (const f of entries) {
|
|
169
|
+
const s = f.replace(/\.md$/, '');
|
|
170
|
+
const owner = readOwner(PROJECT_DIR, s);
|
|
171
|
+
const live = owner ? sessionLive(PROJECT_DIR, owner, staleMin) : false;
|
|
172
|
+
const st = effectiveOwnership(owner, sid, live);
|
|
173
|
+
let note = 'unowned';
|
|
174
|
+
if (st === 'mine') note = `yours (${id8(owner)})`;
|
|
175
|
+
else if (st === 'other-live') note = `session ${id8(owner)} — LIVE, last seen ${seenAgo(owner)}`;
|
|
176
|
+
else if (st === 'orphaned') note = `session ${id8(owner)} — DEAD (last seen ${seenAgo(owner)})${gc ? ' → cleared' : ' → claimable'}`;
|
|
177
|
+
if (st === 'orphaned' && gc) {
|
|
178
|
+
try { setOwner(PROJECT_DIR, s, null); cleared++; } catch { /* best-effort */ }
|
|
179
|
+
}
|
|
180
|
+
console.log(` ${s} — ${note}`);
|
|
181
|
+
}
|
|
182
|
+
if (gc) console.log(`\ncleared ${cleared} orphaned owner${cleared === 1 ? '' : 's'}.`);
|
|
183
|
+
process.exit(0);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// claim / disown both need a slug + a session id + the thread to exist.
|
|
187
|
+
if (!sid) { console.error(`fray ${sub}: no session id (CLAUDE_CODE_SESSION_ID unset).`); process.exit(1); }
|
|
188
|
+
if (!slug) { console.error(`usage: fray ${sub} <slug> [--force]`); process.exit(1); }
|
|
189
|
+
if (!existsSync(join(FRAY_DIR, `${slug}.md`))) { console.error(`fray ${sub}: no thread .fray/${slug}.md`); process.exit(1); }
|
|
190
|
+
const owner = readOwner(PROJECT_DIR, slug);
|
|
191
|
+
const live = owner ? sessionLive(PROJECT_DIR, owner, staleMin) : false;
|
|
192
|
+
const st = effectiveOwnership(owner, sid, live);
|
|
193
|
+
|
|
194
|
+
if (sub === 'claim') {
|
|
195
|
+
if (st === 'mine') {
|
|
196
|
+
touchSessionHeartbeat(PROJECT_DIR, sid);
|
|
197
|
+
console.log(`fray: ${slug} is already yours (${id8(sid)}).`);
|
|
198
|
+
process.exit(0);
|
|
199
|
+
}
|
|
200
|
+
if (st === 'other-live' && !force) {
|
|
201
|
+
console.error(`fray: ${slug} is owned by a DIFFERENT, LIVE session ${id8(owner)} (last seen ${seenAgo(owner)}).`);
|
|
202
|
+
console.error(` Another session is on this thread — coordinate first. To take it anyway: fray claim ${slug} --force`);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
try { setOwner(PROJECT_DIR, slug, sid); } catch (e) { console.error(`fray claim: ${e instanceof Error ? e.message : e}`); process.exit(1); }
|
|
206
|
+
touchSessionHeartbeat(PROJECT_DIR, sid);
|
|
207
|
+
const from = st === 'unowned' ? 'was unowned'
|
|
208
|
+
: st === 'orphaned' ? `previous owner ${id8(owner)} was DEAD (last seen ${seenAgo(owner)})`
|
|
209
|
+
: `FORCE-taken from LIVE session ${id8(owner)}`;
|
|
210
|
+
console.log(`fray: CLAIMED ${slug} for this session (${id8(sid)}) — ${from}.`);
|
|
211
|
+
process.exit(0);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// disown
|
|
215
|
+
if (st === 'unowned') { console.log(`fray: ${slug} is not owned — nothing to disown.`); process.exit(0); }
|
|
216
|
+
if (st === 'other-live' && !force) {
|
|
217
|
+
console.error(`fray: ${slug} is owned by a DIFFERENT, LIVE session ${id8(owner)}. To release it anyway: fray disown ${slug} --force`);
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
try { setOwner(PROJECT_DIR, slug, null); } catch (e) { console.error(`fray disown: ${e instanceof Error ? e.message : e}`); process.exit(1); }
|
|
221
|
+
console.log(`fray: released ownership of ${slug}${st === 'orphaned' ? ` (cleared dead owner ${id8(owner)})` : ''}.`);
|
|
222
|
+
process.exit(0);
|
|
223
|
+
}
|
|
224
|
+
// `fray reconcile` — RECORD a completed board reconcile by stamping `.fray/.last-reconcile`
|
|
225
|
+
// to now. The reconcile nudge (Stop-hook rest path + per-turn backstop) fires when a thread
|
|
226
|
+
// moved since this stamp (dirty-gate) or a long backstop elapses; a reconcile sub-agent (or a
|
|
227
|
+
// human) runs this AS ITS LAST STEP, AFTER re-grounding + editing every non-terminal thread —
|
|
228
|
+
// stamping last is REQUIRED, or its own edits leave the board dirty. Pure timestamp write.
|
|
229
|
+
if (sub === 'reconcile') {
|
|
230
|
+
const f = writeLastReconcile(PROJECT_DIR);
|
|
231
|
+
console.log('fray: board reconcile recorded — staleness clock reset.');
|
|
232
|
+
console.log(` stamped: ${f}`);
|
|
233
|
+
process.exit(0);
|
|
234
|
+
}
|
|
235
|
+
if (sub === 'reset' || sub === 'default') {
|
|
236
|
+
const sid = currentSessionId();
|
|
237
|
+
if (sid) clearSessionOverride(PROJECT_DIR, sid);
|
|
238
|
+
console.log(`fray: session override cleared for ${sid ?? '(no session id)'} — back to the default (DORMANT; run \`fray on\` to activate this session).`);
|
|
239
|
+
process.exit(0);
|
|
240
|
+
}
|
|
241
|
+
if (sub === 'status') {
|
|
242
|
+
const sid = currentSessionId();
|
|
243
|
+
const ov = sessionOverride(PROJECT_DIR, sid);
|
|
244
|
+
const active = frayActive(PROJECT_DIR, sid);
|
|
245
|
+
console.log(`fray: ${active ? 'ACTIVE' : 'INACTIVE'} this session (${sid ?? 'no session id'})`);
|
|
246
|
+
console.log(` override: ${ov ?? 'none (default — DORMANT; run `fray on` to activate)'}`);
|
|
247
|
+
process.exit(0);
|
|
248
|
+
}
|
|
249
|
+
// `fray decisions` — the rich inline-reading view of every HUMAN-blocked thread's FULL
|
|
250
|
+
// write-up (collectDecisions — `blocked` with no `blocking_threads`/`revalidate_at`). The same
|
|
251
|
+
// queue the thread updater prints after each edit; surfaced here as a board subcommand.
|
|
252
|
+
if (sub === 'decisions') {
|
|
253
|
+
const items = collectDecisions();
|
|
254
|
+
if (items.length === 0) {
|
|
255
|
+
console.log('✓ no pending decisions');
|
|
256
|
+
} else {
|
|
257
|
+
console.log(`⚖ ${items.length} decision(s) awaiting you:\n`);
|
|
258
|
+
items.forEach((d, i) => {
|
|
259
|
+
console.log(`[${d.slug}]`);
|
|
260
|
+
console.log(d.status_text || '(no status_text written up)');
|
|
261
|
+
if (i < items.length - 1) console.log('');
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
process.exit(0);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// .fray/config.yml globals — parsed by the shared, type-safe loadConfig (autonomous_mode + state).
|
|
269
|
+
const cfg = loadConfig(PROJECT_DIR);
|
|
270
|
+
// Session-ownership context for the board: THIS session's id (from CLAUDE_CODE_SESSION_ID,
|
|
271
|
+
// present in Bash tool calls) + the owner-staleness window. Used to annotate each thread with
|
|
272
|
+
// its effective ownership (mine / another live session's / orphaned) — DERIVED, never stored.
|
|
273
|
+
const CURRENT_SID = currentSessionId();
|
|
274
|
+
const OWNER_STALE_MIN = ownerStaleMin(cfg);
|
|
275
|
+
|
|
276
|
+
// No `.fray/` here → fray is not active in this project. Print a friendly pointer instead
|
|
277
|
+
// of crashing on a missing directory (the board ships globally and may be run anywhere).
|
|
278
|
+
let frayEntries;
|
|
279
|
+
try {
|
|
280
|
+
frayEntries = readdirSync(FRAY_DIR, { withFileTypes: true });
|
|
281
|
+
} catch {
|
|
282
|
+
console.log(`No .fray/ in ${PROJECT_DIR} — fray is not active here. Run the /fray skill to bootstrap it (creates .fray/ + a default config.yml).`);
|
|
283
|
+
process.exit(0);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// AUTOMATIC thread↔agent binding (`.fray/.agent-bindings.jsonl`, written by the agent-bind
|
|
287
|
+
// hook at dispatch) — the replacement for the old hand-maintained `agents:` frontmatter.
|
|
288
|
+
// Liveness keys on each thread's NEWEST agent only (a superseded older one is never flagged),
|
|
289
|
+
// suppresses threads with a PR landing via the merge cascade, and uses the rest log to tell a
|
|
290
|
+
// parked agent apart from one still inside a long tool call.
|
|
291
|
+
const newestBindings = newestBindingByThread(PROJECT_DIR);
|
|
292
|
+
const downstream = downstreamThreads(PROJECT_DIR);
|
|
293
|
+
const restedIds = restedAgentIds(PROJECT_DIR);
|
|
294
|
+
|
|
295
|
+
const threads = frayEntries
|
|
296
|
+
// Only direct regular files are thread documents. `Dirent.isFile()` deliberately excludes
|
|
297
|
+
// symlinks: a board refresh must never follow `.fray/foo.md -> /outside/secret` merely to decide
|
|
298
|
+
// whether `foo` is adoptable or renderable.
|
|
299
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md') && !entry.name.startsWith('_') && !entry.name.startsWith('.')) // `_`-prefixed = non-thread meta (e.g. a stray _board.md); `.`-prefixed = hook-internal scratch (e.g. `.stop-context.md`), never a thread
|
|
300
|
+
.map((entry) => entry.name)
|
|
301
|
+
.sort()
|
|
302
|
+
.map((f) => {
|
|
303
|
+
const id = f.replace(/\.md$/, ''); // the filename slug IS the id
|
|
304
|
+
// Keep the Dirent check true at open time too. O_NOFOLLOW turns a file→symlink swap between
|
|
305
|
+
// readdir and read into a skipped row instead of an out-of-project read.
|
|
306
|
+
let fd;
|
|
307
|
+
let src;
|
|
308
|
+
try {
|
|
309
|
+
fd = openSync(join(FRAY_DIR, f), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
310
|
+
src = readFileSync(fd, 'utf8');
|
|
311
|
+
} catch {
|
|
312
|
+
return null;
|
|
313
|
+
} finally {
|
|
314
|
+
if (fd !== undefined) closeSync(fd);
|
|
315
|
+
}
|
|
316
|
+
const fm = frontmatter(src);
|
|
317
|
+
/** @type {string[]} */
|
|
318
|
+
const errors = [];
|
|
319
|
+
if (!fm) {
|
|
320
|
+
errors.push('no YAML frontmatter');
|
|
321
|
+
} else {
|
|
322
|
+
for (const k of REQUIRED) if (!fm[k]) errors.push(`missing required field: ${k}`);
|
|
323
|
+
if (fm.status && !isValidStatus(fm.status))
|
|
324
|
+
errors.push(`invalid status "${fm.status}" (expected one of: ${ACCEPTED_STATUSES.join(', ')})`);
|
|
325
|
+
}
|
|
326
|
+
// The dependency field is `blocking_threads` (the RENAME of `depends_on`, 2026-07-01);
|
|
327
|
+
// `depends_on` stays accepted as a read-alias FIELD so old threads still resolve. Both mean
|
|
328
|
+
// the same array — bare THREAD-slug deps (drive READY/auto-fire + dangling validation) plus
|
|
329
|
+
// typed EXTERNAL gates `pr:`/`ci:`/`external:` (park the thread as "waiting on"; never dangle).
|
|
330
|
+
// Read deps from RAW src (not the flat frontmatter map) so a block-form YAML list is not
|
|
331
|
+
// dropped — the shared parser is what keeps the board's machine/human classification in
|
|
332
|
+
// lock-step with the reminder hook (both call the SAME parseDeps).
|
|
333
|
+
const dependsOn = parseDeps(src);
|
|
334
|
+
const classified = dependsOn.map(classifyDep);
|
|
335
|
+
const threadDeps = classified.filter((d) => d.kind === 'thread').map((d) => d.slug);
|
|
336
|
+
/** @type {{type:string,label:string}[]} */
|
|
337
|
+
const externalDeps = classified.filter((d) => d.kind === 'external').map((d) => ({ type: d.type, label: d.label }));
|
|
338
|
+
// REVALIDATE timer — the parsed `revalidate_at`/`last_checked` state (null when unset/
|
|
339
|
+
// malformed). `revalidateMalformed` is set when the field is PRESENT but didn't parse, so
|
|
340
|
+
// a typo'd timestamp surfaces as a non-fatal warning below rather than silently never firing.
|
|
341
|
+
const revalidate = revalidateState(fm?.revalidate_at, fm?.last_checked);
|
|
342
|
+
const revalidateMalformed = Boolean(fm?.revalidate_at) && !revalidate;
|
|
343
|
+
const hasBlockingThreads = dependsOn.length > 0;
|
|
344
|
+
const hasTimer = Boolean(revalidate);
|
|
345
|
+
// Bucket on the EFFECTIVE status: legacy string aliases normalized, PLUS the one contextual
|
|
346
|
+
// alias — a legacy `blocked` thread with no machine field reads as `needs-human` (see
|
|
347
|
+
// config.effectiveStatus). Unknown/garbage passes through so it still lands in `(invalid status)`.
|
|
348
|
+
const status = effectiveStatus(fm?.status, { hasBlockingThreads, hasTimer }) ?? '?';
|
|
349
|
+
// Status-specific validation (needs the effective status + the mechanism fields):
|
|
350
|
+
/** @type {string[]} */ const warnings = [];
|
|
351
|
+
if (fm) {
|
|
352
|
+
// `needs-human` (incl. a legacy blocked-with-no-machine-field thread that reads as
|
|
353
|
+
// needs-human) MUST carry a status_text — that line IS the ask surfaced in the ⚖ queue.
|
|
354
|
+
if (status === 'needs-human' && !(fm.status_text ?? '').trim())
|
|
355
|
+
errors.push('needs-human requires a status_text stating the ask');
|
|
356
|
+
// `blocked` now means a MACHINE wait; with no machine field it is mis-encoded (reads as
|
|
357
|
+
// needs-human). WARN, suggesting the canonical needs-human — never a hard error (legacy). Only
|
|
358
|
+
// when a status_text is present: otherwise the needs-human-requires-status_text ERROR above
|
|
359
|
+
// already covers this thread, so we don't double-report it.
|
|
360
|
+
if (normalizeStatus(fm.status) === 'blocked' && !hasBlockingThreads && !hasTimer && (fm.status_text ?? '').trim())
|
|
361
|
+
warnings.push('blocked with no machine field (blocking_threads/revalidate_at) reads as needs-human — write `status: needs-human`, or add a machine field');
|
|
362
|
+
}
|
|
363
|
+
// Mechanism (meaningful only for a machine-`blocked` thread: threads vs timer, both gray).
|
|
364
|
+
// `humanBlocked` — the ⚖ awaiting-you predicate — is now simply `status === 'needs-human'`
|
|
365
|
+
// (effectiveStatus already folded the legacy blocked-no-field human case into needs-human).
|
|
366
|
+
const mechanism = blockMechanism({ hasBlockingThreads, hasTimer });
|
|
367
|
+
const humanBlocked = status === 'needs-human';
|
|
368
|
+
const next = nextStep(src);
|
|
369
|
+
// DERIVED plan marker (NOT a frontmatter flag — deliberately rejected): a thread whose body keeps
|
|
370
|
+
// a `## Plan` section carries a plan document, so the UI can render a quiet PLAN badge. Purely
|
|
371
|
+
// derived from doc content (like `next`), read-side only, additive — the validator never sees it.
|
|
372
|
+
// A `\b` after Plan so "## Planning notes"/"## Planned work" DON'T match; "## Plan"/"## Plan (v2)" do.
|
|
373
|
+
const hasPlan = /^##\s+Plan\b/im.test(src);
|
|
374
|
+
const threadTerminal = TERMINAL.includes(status);
|
|
375
|
+
const threadActive = status === 'active'; // only `active` threads flag dropped/idle agents; parked phases are expected to hold done agents
|
|
376
|
+
const threadDownstream = downstream.has(id); // PR landing via the cascade → legitimately active, suppress
|
|
377
|
+
// Agent liveness is DERIVED, never read from a stored per-agent flag: the AUTOMATIC
|
|
378
|
+
// binding carries only immutable `{id, label}`; state comes from the NEWEST agent's
|
|
379
|
+
// output-file age (ground truth) + the thread's own status + the rest log, via the SAME
|
|
380
|
+
// derivation the Stop hook uses. Newest-only: a superseded older agent is never flagged.
|
|
381
|
+
const binding = newestBindings.get(id);
|
|
382
|
+
const agents = binding
|
|
383
|
+
? [{
|
|
384
|
+
...binding,
|
|
385
|
+
state: deriveAgentState({ ageMin: findAgentOutputAge(binding.id), threadTerminal, threadActive, threadDownstream, hasRested: restedIds.has(binding.id), idleMin: IDLE_MIN, droppedMin: DROPPED_MIN }),
|
|
386
|
+
}]
|
|
387
|
+
: [];
|
|
388
|
+
return {
|
|
389
|
+
id,
|
|
390
|
+
title: fm?.title ?? '',
|
|
391
|
+
status, // CANONICAL (legacy aliases normalized); unknown passes through as-is
|
|
392
|
+
status_text: fm?.status_text ?? '',
|
|
393
|
+
// A form-constrained gerund ACTIVITY label (single line, ≤100 chars, e.g. "Awaiting CI on
|
|
394
|
+
// PR #391") that workers maintain — the at-a-glance "what it's doing right now" gloss the UI
|
|
395
|
+
// listing row shows. Distinct from status_text (the fuller board/queue gloss). Additive,
|
|
396
|
+
// read-side only; absent on old threads (undefined → omitted from JSON, renders nothing).
|
|
397
|
+
activity: fm?.activity ?? undefined,
|
|
398
|
+
next,
|
|
399
|
+
hasPlan, // derived: body has a `## Plan` section (drives the UI's quiet PLAN badge)
|
|
400
|
+
dependsOn,
|
|
401
|
+
threadDeps,
|
|
402
|
+
externalDeps,
|
|
403
|
+
mechanism,
|
|
404
|
+
humanBlocked,
|
|
405
|
+
hasBlockingThreads: dependsOn.length > 0,
|
|
406
|
+
owner: fm?.owner_session || null,
|
|
407
|
+
revalidate,
|
|
408
|
+
revalidateMalformed,
|
|
409
|
+
agents,
|
|
410
|
+
text: src,
|
|
411
|
+
errors,
|
|
412
|
+
warnings, // seeded with any status-specific validation warnings computed above; the pass below appends more
|
|
413
|
+
dropRisk: false, // set true when the blocked-machine-but-all-deps-terminal heuristic fires
|
|
414
|
+
};
|
|
415
|
+
})
|
|
416
|
+
.filter((thread) => thread !== null);
|
|
417
|
+
|
|
418
|
+
// THREAD-slug deps reference other threads — validate they resolve. A dangling slug (no
|
|
419
|
+
// matching `.fray/<slug>.md`) is an error, surfaced like any frontmatter error so the
|
|
420
|
+
// orchestrator notices the stale dependency. EXTERNAL deps (`pr:`/`ci:`/`external:`…) are
|
|
421
|
+
// NOT checked — there is nothing in `.fray/` to resolve them to. Everything is COMPUTED from
|
|
422
|
+
// the scanned set; there is no external registry to consult.
|
|
423
|
+
const slugs = new Set(threads.map((t) => t.id));
|
|
424
|
+
const statusOf = new Map(threads.map((t) => [t.id, t.status]));
|
|
425
|
+
for (const t of threads) {
|
|
426
|
+
for (const dep of t.threadDeps) {
|
|
427
|
+
if (!slugs.has(dep)) t.errors.push(`blocking_threads references unknown thread "${dep}"`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* A thread's blockers: the subset of its THREAD-slug `depends_on` targets not yet terminal.
|
|
433
|
+
* Empty ⇒ all thread deps clear. Unknown slugs are skipped here (already an error). External
|
|
434
|
+
* deps are NOT blockers in this sense — they park the thread separately (see renderThread).
|
|
435
|
+
* @param {{ threadDeps: string[] }} t
|
|
436
|
+
* @returns {string[]}
|
|
437
|
+
*/
|
|
438
|
+
function blockers(t) {
|
|
439
|
+
return t.threadDeps.filter((dep) => slugs.has(dep) && !TERMINAL.includes(statusOf.get(dep) ?? '?'));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Render one thread's board block (slug line + status_text gloss + next-step + dep state +
|
|
444
|
+
* agent-liveness + warnings) into `out`. Shared by the per-status groups AND the dedicated
|
|
445
|
+
* `⚖ awaiting you` (blocked) section so the two can never drift. status_text is emitted IN
|
|
446
|
+
* FULL — the board NEVER truncates it (the length cap is a soft scan-loop warning, itself
|
|
447
|
+
* exempted for `blocked`, so a blocker's open question is always fully readable on a wide term).
|
|
448
|
+
* @param {(typeof threads)[number]} t
|
|
449
|
+
* @param {string[]} out
|
|
450
|
+
*/
|
|
451
|
+
function renderThread(t, out) {
|
|
452
|
+
out.push(`- ${t.id} — ${t.title}`);
|
|
453
|
+
if (t.status_text) out.push(` » ${t.status_text}`);
|
|
454
|
+
out.push(` → ${t.next}`);
|
|
455
|
+
if (t.threadDeps.length) {
|
|
456
|
+
const b = blockers(t);
|
|
457
|
+
// READY only when thread deps AND external deps are all clear — a pending external gate
|
|
458
|
+
// still parks the thread even after its in-board thread deps go terminal. When a
|
|
459
|
+
// `revalidate_at` timer is ALSO set (the mis-configured >1-mechanism case the validator
|
|
460
|
+
// warns about), the timer is the GOVERNING mechanism (precedence timer > threads), so the
|
|
461
|
+
// timer block below owns the surfacing — never claim `▶ READY` here or the board would print
|
|
462
|
+
// a "dispatch now" line contradicting the parked timer.
|
|
463
|
+
const readyBlocked = b.length
|
|
464
|
+
? ` ⏳ blocked on: ${b.join(', ')}`
|
|
465
|
+
: t.externalDeps.length
|
|
466
|
+
? ` ⏳ thread deps clear — still waiting on external`
|
|
467
|
+
: t.mechanism === 'timer'
|
|
468
|
+
? null // a timer governs — the ⏰ line below is the single surfacing
|
|
469
|
+
: ` ▶ READY — dependencies clear, dispatch now`;
|
|
470
|
+
if (readyBlocked) out.push(readyBlocked);
|
|
471
|
+
}
|
|
472
|
+
// EXTERNAL deps park the thread; surface them as a "waiting on" line (they resolve via a
|
|
473
|
+
// `revalidate_at` re-poll or a manual edit, never auto-fired from the board).
|
|
474
|
+
if (t.externalDeps.length) {
|
|
475
|
+
out.push(` ⏳ waiting on: ${t.externalDeps.map((d) => d.label).join(', ')}`);
|
|
476
|
+
}
|
|
477
|
+
// SESSION OWNERSHIP — annotate ONLY the actionable coordination cases (owned by another live
|
|
478
|
+
// session → don't touch; orphaned → claimable). `mine`/`unowned` stay unmarked to keep the
|
|
479
|
+
// board lean (`fray owners` gives the full per-thread view). Derived from the owner's
|
|
480
|
+
// heartbeat freshness, never a stored flag.
|
|
481
|
+
if (t.owner) {
|
|
482
|
+
const ownerLive = sessionLive(PROJECT_DIR, t.owner, OWNER_STALE_MIN);
|
|
483
|
+
const st = effectiveOwnership(t.owner, CURRENT_SID, ownerLive);
|
|
484
|
+
const id8 = t.owner.slice(0, 9);
|
|
485
|
+
if (st === 'other-live') {
|
|
486
|
+
const hb = readSessionHeartbeat(PROJECT_DIR, t.owner);
|
|
487
|
+
const ago = hb == null ? 'unknown' : `${Math.round((Date.now() - hb) / 60_000)}m ago`;
|
|
488
|
+
out.push(` 👤 owned by another LIVE session ${id8} (last seen ${ago}) — don't touch; \`fray claim ${t.id} --force\` to take it`);
|
|
489
|
+
} else if (st === 'orphaned') {
|
|
490
|
+
out.push(` 👤 orphaned — owner session ${id8} is dead; \`fray claim ${t.id}\` to take it`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
// REVALIDATE timer status — `⏰ revalidate due` once the timer fired (re-poll the external
|
|
494
|
+
// state), or a quiet `next check in <eta>` while parked, so the board reflects the timer.
|
|
495
|
+
if (t.revalidate) {
|
|
496
|
+
out.push(t.revalidate.due
|
|
497
|
+
? ` ⏰ revalidate due — re-poll the external state (last checked ${t.revalidate.lastChecked || 'never'})`
|
|
498
|
+
: ` ⏰ next check in ${formatEta(t.revalidate.etaMin)} (last checked ${t.revalidate.lastChecked || 'never'})`);
|
|
499
|
+
}
|
|
500
|
+
// Dispatched-agent liveness — DERIVED (newest-agent output age + thread status + rest log),
|
|
501
|
+
// never stored. Newest agent only; downstream (mid-merge) threads already suppressed.
|
|
502
|
+
for (const a of t.agents) {
|
|
503
|
+
if (a.state === 'fresh' || a.state === 'terminal' || a.state === 'unknown') continue;
|
|
504
|
+
const who = `${a.label ? `${a.label} ` : ''}[${a.id.slice(0, 9)}]`;
|
|
505
|
+
out.push(a.state === 'dropped'
|
|
506
|
+
? ` ⚠ ACTIVE THREAD, NO LIVE AGENT — newest agent ${who} quiet, no PR/merge in flight; fold + flip to done, or resume it`
|
|
507
|
+
: ` · idle agent ${who} — no recent output; fine if watching CI/mid-build, else poke`);
|
|
508
|
+
}
|
|
509
|
+
for (const w of t.warnings) out.push(` ⚠ ${w}`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ── Stall-suspect WARNINGS (drop-risk heuristics) ───────────────────────────────
|
|
513
|
+
// These are CONSERVATIVE warnings, NOT hard errors — they never fail `--validate`'s
|
|
514
|
+
// exit code (that stays gated on real frontmatter errors so the per-turn hook + CI
|
|
515
|
+
// don't break on a heuristic). They exist because a ready thread was once parked with
|
|
516
|
+
// a transient blocker encoded as PROSE (not `depends_on`) and silently DROPPED turn
|
|
517
|
+
// after turn. The canonical drop shape post-`planned`-removal: an `enqueued` thread
|
|
518
|
+
// whose `depends_on` are ALL terminal — it SHOULD have auto-fired and didn't. The
|
|
519
|
+
// `dropRisk` flag below is the SAME signal the per-turn fray-reminder hook surfaces by
|
|
520
|
+
// name. Self-contained: every signal is read off the thread's own frontmatter; no
|
|
521
|
+
// external state. Keep these PRECISE — never false-flag a legitimately-waiting `todo`.
|
|
522
|
+
for (const t of threads) {
|
|
523
|
+
if (TERMINAL.includes(t.status)) continue; // terminal threads are done — never a drop-risk
|
|
524
|
+
|
|
525
|
+
// (1) DROP-RISK: a `blocked` thread with the THREADS mechanism (`blocking_threads` set) whose
|
|
526
|
+
// thread deps are ALL terminal — its auto-trigger fired and it was never dispatched. A
|
|
527
|
+
// pending EXTERNAL gate or a live timer legitimately parks it, so the heuristic requires
|
|
528
|
+
// thread deps present, all clear, no external gate outstanding, AND no `revalidate_at`.
|
|
529
|
+
if (t.status === 'blocked' && t.threadDeps.length > 0 && blockers(t).length === 0 && t.externalDeps.length === 0 && !t.revalidate) {
|
|
530
|
+
t.dropRisk = true;
|
|
531
|
+
t.warnings.push('drop risk: `blocked` on `blocking_threads` that are ALL terminal — it should have auto-fired; re-read the thread and dispatch/advance it now');
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// (1b) ONE MECHANISM PER BLOCKED THREAD — warn on ambiguity. A machine-`blocked` thread should
|
|
535
|
+
// carry exactly one resolution mechanism; both `blocking_threads` AND `revalidate_at` set is
|
|
536
|
+
// ambiguous (which unblocks it?). (The needs-human-requires-status_text rule is a HARD ERROR
|
|
537
|
+
// raised in the parse pass above, not a warning here.)
|
|
538
|
+
if (t.status === 'blocked' && t.hasBlockingThreads && t.revalidate) {
|
|
539
|
+
t.warnings.push('blocked with BOTH `blocking_threads` and `revalidate_at` — set exactly ONE resolution mechanism (the board treats it as the timer)');
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// status_text is a 1-2 sentence English status note (frontmatter); flag overlong ones —
|
|
543
|
+
// anything past ~2 sentences belongs in the body, not the at-a-glance board field.
|
|
544
|
+
// EXEMPT a HUMAN-blocked thread: its status_text IS the ⚖ awaiting-you queue entry (the
|
|
545
|
+
// concise decision needed), surfaced UNTRUNCATED on the board + reminder — never warn/clip it.
|
|
546
|
+
if (!t.humanBlocked && t.status_text && t.status_text.length > 280) {
|
|
547
|
+
t.warnings.push(`status_text is ${t.status_text.length} chars — keep it to 1-2 sentences; move detail into the body`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Present-but-unparseable `revalidate_at`: the hook degrades it to not-set (fail-safe), but
|
|
551
|
+
// a typo'd timestamp means the timer will NEVER fire — surface it as a non-fatal warning so
|
|
552
|
+
// the bad value is caught instead of silently stranding the thread.
|
|
553
|
+
if (t.revalidateMalformed) {
|
|
554
|
+
t.warnings.push('revalidate_at present but not a parseable ISO-8601 timestamp — the timer will NOT fire; fix it (e.g. 2026-06-27T18:00:00Z) or remove the field');
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Soft warning: non-terminal threads without a status_text have no at-a-glance board note.
|
|
558
|
+
if (!t.status_text && t.id !== 'backlog') {
|
|
559
|
+
t.warnings.push('no status_text — add a 1-2 sentence gloss of the current state (shown on the board as the » line)');
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// (2) An EMPTY `## Next step` on a non-terminal thread — the board's "→" cell goes
|
|
563
|
+
// blank, so the thread has no stated next action and is easy to lose track of.
|
|
564
|
+
// `backlog` is the documented parking-lot (a curated list, not a single-effort
|
|
565
|
+
// thread), so it legitimately has no `## Next step` — exempt it.
|
|
566
|
+
if (!t.next && t.id !== 'backlog') {
|
|
567
|
+
t.warnings.push('empty `## Next step` — no stated next action (the board "→" cell is blank)');
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const allErrors = threads.filter((t) => t.errors.length).map((t) => ` ${t.id}.md: ${t.errors.join('; ')}`);
|
|
572
|
+
const allWarnings = threads.filter((t) => t.warnings.length).map((t) => ` ${t.id}.md: ${t.warnings.join('; ')}`);
|
|
573
|
+
// STRUCTURED, per-file mirror of `allErrors` for the UI (--json only). The parser knows exactly WHY
|
|
574
|
+
// it rejected a file, so it classifies here: a thread whose sole barrier is a missing frontmatter
|
|
575
|
+
// block is one-click REPAIRABLE (`kind: 'no-frontmatter'`); anything else is `other` (dangling dep,
|
|
576
|
+
// bad status, missing field) and carries no repair affordance. `file` is the .md basename. Additive
|
|
577
|
+
// — `allErrors` (the legacy string array) is emitted unchanged alongside it.
|
|
578
|
+
const errorItems = threads
|
|
579
|
+
.filter((t) => t.errors.length)
|
|
580
|
+
.map((t) => ({
|
|
581
|
+
file: `${t.id}.md`,
|
|
582
|
+
kind: t.errors.includes('no YAML frontmatter') ? 'no-frontmatter' : 'other',
|
|
583
|
+
message: t.errors.join('; '),
|
|
584
|
+
}));
|
|
585
|
+
|
|
586
|
+
if (process.argv.includes('--validate') || process.argv.includes('--check')) {
|
|
587
|
+
// Warnings print but DO NOT affect the exit code — they're conservative drop-risk
|
|
588
|
+
// heuristics, not schema errors. Only real frontmatter errors fail the hook/CI.
|
|
589
|
+
if (allWarnings.length) console.error(`fray drop-risk WARNINGS (advisory, non-fatal):\n${allWarnings.join('\n')}`);
|
|
590
|
+
if (allErrors.length) {
|
|
591
|
+
console.error(`fray frontmatter validation FAILED:\n${allErrors.join('\n')}`);
|
|
592
|
+
process.exit(1);
|
|
593
|
+
}
|
|
594
|
+
console.log(`fray frontmatter OK${allWarnings.length ? ` (${allWarnings.length} drop-risk warning${allWarnings.length === 1 ? '' : 's'} above)` : ''}`);
|
|
595
|
+
process.exit(0);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (process.argv.includes('--json')) {
|
|
599
|
+
const dump = threads.map(({ text, ...t }) => {
|
|
600
|
+
const b = blockers(t);
|
|
601
|
+
// READY = thread deps present + all clear + no external gate outstanding.
|
|
602
|
+
return { ...t, blockers: b, ready: t.threadDeps.length > 0 && b.length === 0 && t.externalDeps.length === 0 };
|
|
603
|
+
});
|
|
604
|
+
// Await the write, THEN exit — stdout to a PIPE is async past 64KB, and console.log +
|
|
605
|
+
// process.exit truncated the payload mid-JSON (a ~1MB board reached consumers as exactly 64KB of
|
|
606
|
+
// unparseable prefix). The drain callback fires only once the pipe has the whole payload.
|
|
607
|
+
// Compact — this is a machine interface, and pretty-printing added ~90KB on a large board.
|
|
608
|
+
const payload = JSON.stringify({ config: cfg, threads: dump, errors: allErrors, warnings: allWarnings, errorItems }) + "\n";
|
|
609
|
+
await new Promise((res) => process.stdout.write(payload, res));
|
|
610
|
+
process.exit(0);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Substring search across id + title + body — find a thread when you can't recall its slug.
|
|
614
|
+
const qi = process.argv.indexOf('--search');
|
|
615
|
+
if (qi !== -1) {
|
|
616
|
+
const q = (process.argv[qi + 1] ?? '').toLowerCase();
|
|
617
|
+
const hits = threads.filter((t) => `${t.id} ${t.title} ${t.text}`.toLowerCase().includes(q));
|
|
618
|
+
console.log(
|
|
619
|
+
hits.length
|
|
620
|
+
? hits.map((t) => `${t.id} [${t.status}] — ${t.title}`).join('\n')
|
|
621
|
+
: `no threads match "${q}"`,
|
|
622
|
+
);
|
|
623
|
+
process.exit(0);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Default: the board. `--status <s>` narrows to one status. `--all` shows everything.
|
|
627
|
+
// A legacy alias (`todo`/`plan`/`needs-decision`) is accepted and normalized to its
|
|
628
|
+
// canonical target, so `fray --status todo` shows `planned` threads.
|
|
629
|
+
const si = process.argv.indexOf('--status');
|
|
630
|
+
const onlyRaw = si !== -1 ? process.argv[si + 1] : null;
|
|
631
|
+
if (onlyRaw && !isValidStatus(onlyRaw)) {
|
|
632
|
+
console.error(`unknown status "${onlyRaw}" (expected one of: ${ACCEPTED_STATUSES.join(', ')})`);
|
|
633
|
+
process.exit(2);
|
|
634
|
+
}
|
|
635
|
+
const only = onlyRaw ? normalizeStatus(onlyRaw) : null; // canonical filter value
|
|
636
|
+
const showAll = process.argv.includes('--all');
|
|
637
|
+
|
|
638
|
+
// Statuses hidden from the default board (non-actionable): the PARKED `planned` phase + the
|
|
639
|
+
// terminals. `planning` (active design) is SURFACED, so it is NOT hidden. Read defensively
|
|
640
|
+
// from config.mjs STATUS so an absent status is simply skipped.
|
|
641
|
+
const HIDDEN_BY_DEFAULT = new Set(['planned', 'done', 'dismissed'].filter((s) => STATUS.includes(s)));
|
|
642
|
+
|
|
643
|
+
// When `--all` or `--status <s>` is given, show the requested set; otherwise show
|
|
644
|
+
// only the live/actionable statuses.
|
|
645
|
+
const showStatuses = only
|
|
646
|
+
? [only]
|
|
647
|
+
: showAll
|
|
648
|
+
? STATUS
|
|
649
|
+
: STATUS.filter((s) => !HIDDEN_BY_DEFAULT.has(s));
|
|
650
|
+
|
|
651
|
+
const out = [];
|
|
652
|
+
out.push(`fray board — autonomous_mode: ${cfg.autonomousMode ? 'on' : 'off'}${only ? ` — status:${only}` : showAll ? ' — all' : ' — live'}`);
|
|
653
|
+
if (allErrors.length) out.push(`\n⚠ VALIDATION ERRORS:\n${allErrors.join('\n')}`);
|
|
654
|
+
if (allWarnings.length) out.push(`\n⚠ DROP-RISK WARNINGS (advisory):\n${allWarnings.join('\n')}`);
|
|
655
|
+
|
|
656
|
+
// ⚖ AWAITING YOU — the COMPUTED queue of every `needs-human` thread (including a legacy `blocked`
|
|
657
|
+
// thread with NO machine field, which reads as needs-human), surfaced by its FULL status_text (the
|
|
658
|
+
// concise ask). HOISTED to the top of the board (not buried mid-status-list) and rendered
|
|
659
|
+
// UNTRUNCATED. The machine/timer-`blocked` threads are NOT here — they wait on non-human work and
|
|
660
|
+
// render (gray, last) in the `blocked` group below. Nothing is stored: filtered live from the
|
|
661
|
+
// scanned threads. Shown in the live/default + `--all` views and when `--status needs-human` (or
|
|
662
|
+
// its `needs-decision` alias) is requested.
|
|
663
|
+
if (!only || normalizeStatus(only) === 'needs-human') {
|
|
664
|
+
const pendingDecisions = threads.filter((t) => t.humanBlocked);
|
|
665
|
+
if (pendingDecisions.length) {
|
|
666
|
+
out.push(`\n## ⚖ awaiting you (${pendingDecisions.length}) — needs-human`);
|
|
667
|
+
for (const t of pendingDecisions) renderThread(t, out);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Per-status groups, in SURFACE-PRIORITY order (⚖ human-blocked hoisted above; then active,
|
|
672
|
+
// planning, and machine/timer-`blocked` LAST/de-emphasized) — not raw STATUS order — so the
|
|
673
|
+
// board reads awaiting-you → active → planning → blocked top-to-bottom.
|
|
674
|
+
const GROUP_ORDER = ['active', 'planning', 'blocked', 'planned'];
|
|
675
|
+
const orderedStatuses = [...showStatuses].sort((a, b) => {
|
|
676
|
+
const ia = GROUP_ORDER.indexOf(a); const ib = GROUP_ORDER.indexOf(b);
|
|
677
|
+
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
|
|
678
|
+
});
|
|
679
|
+
for (const s of orderedStatuses) {
|
|
680
|
+
// `blocked` renders ONLY its MACHINE/timer threads here — the HUMAN-blocked ones are in the
|
|
681
|
+
// ⚖ section above (skip them to avoid duplication).
|
|
682
|
+
const group = threads.filter((t) => t.status === s && !t.humanBlocked);
|
|
683
|
+
if (!group.length) continue;
|
|
684
|
+
out.push(`\n## ${s} (${group.length})`);
|
|
685
|
+
for (const t of group) renderThread(t, out);
|
|
686
|
+
}
|
|
687
|
+
const unknown = threads.filter((t) => !STATUS.includes(t.status));
|
|
688
|
+
if (unknown.length) out.push(`\n## (invalid status) (${unknown.length})\n${unknown.map((t) => `- ${t.id} [${t.status}]`).join('\n')}`);
|
|
689
|
+
|
|
690
|
+
// Footer: when threads are hidden in the default view, tell the user how many.
|
|
691
|
+
if (!only && !showAll) {
|
|
692
|
+
const hiddenCount = threads.filter((t) => HIDDEN_BY_DEFAULT.has(t.status)).length;
|
|
693
|
+
if (hiddenCount > 0) {
|
|
694
|
+
const hiddenLabels = [...HIDDEN_BY_DEFAULT].filter((s) => threads.some((t) => t.status === s)).join('/');
|
|
695
|
+
out.push(`\n… ${hiddenCount} hidden (${hiddenLabels}) — \`--all\` to show`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
console.log(out.join('\n'));
|