A Claude Code mod for GSD projects: a live pane (roadmap, agent tree with forks, cost and context, work streams, session log, pace and fit forecast, timeline…

A live dashboard for GSD projects, as a Claude Code mod: the roadmap with the current phase marked, an agent tree with forks and sub-agents and live clocks, context and cost, work streams, and a built-in markdown reader for .planning. Read-only, no dependencies, and it draws nothing outside a GSD project.

Install (Claude Code 2.1.287 or later):
claude plugin marketplace add helenkwok/gsd-status-mod claude plugin install gsd-status-mod@helenkwok-mods
It is a Claude Code mod. It adds a live pane and four smaller things, each showing what the GSD statusline does not:
GSD · stopped: PAUSED 2026-09-09 after the schema review · 9/14 phases ⚠ ~124 commits since STATE.md <- above the prompt ❯ /gsd-execute-phase 2 <- dim suggestion, Tab to take ⏵⏵ auto mode on (shift+tab to cycle) · next: Run /gsd-execute-phase 2 · handoff 5d ago <- end of the line under the prompt
GSD · line (above the prompt): shown only for a GSD project (a .planning/STATE.md whose frontmatter has gsd_state_version). stopped_at cut to its first sentence, plus phases done. It never repeats status or the plan counts, which the GSD statusline already shows, and it shows no age: STATE.md's last_updated is touched without the content changing.⚠ ~N commits since STATE.md: on that line, when the commit in state_head is 5 or more commits behind this clone's HEAD, so you know STATE.md is stale. Counted from the git reflog, so approximate (it undercounts: 124 against 217 from git rev-list on one repo); hidden when state_head is absent or not in the reflog.next: hint (end of the line under the prompt): next_action from .planning/HANDOFF.json, with the handoff's own age. It is added as a dim tail, so the engine's line and its pills stay, and it is hidden while you type or while a turn runs. GSD deletes the file after a resume, so it goes away when it is no longer needed.next: hint names a command. It offers that command, plus a phase number or version if one follows (/gsd-execute-phase 2), so it always matches the hint beside it. Prose-only actions (Nothing is pending) and actions that say to go elsewhere (Open a session in ~/other-project and run ...) get no suggestion. Tab only fills the box; nothing runs until you press Enter. The engine proposes its own suggestion after each turn, so ours shows at session start only./gsd-status command: the full resume report on demand, read fresh from disk, with no terminal-height limit. Phase and status, phase and plan progress, the whole stopped_at, the drift warning, and from HANDOFF.json the next action and its command, blockers, human_actions_pending ("Needs a person"), remaining tasks and how many files were uncommitted at pause. In a project that is not GSD it says so./gsd-board opens or closes it, and the openOnStart option turns the auto-open off). Bordered boxes that update as events happen, not only per turn:ROADMAP.md with the current phase marked. The current phase comes from STATE.md (its current_phase, or the label that starts current_phase_name); it is never guessed, so a project whose state names no phase shows the list with no marker. Only - [x] Phase N: name lines are read.wave: in the phase's *-PLAN.md frontmatter; a plan with a NN-MM-SUMMARY.md is done. p shows the wave-by-wave list, this phase's time by stage, and the time by type. History: each finished agent leaves one small record, kept in the plugin's own store across sessions (the last 300 per project): type, phase, duration, number of model responses, token counts and model. No description, no prompt, no text. With five or more past runs of a kind, a running agent's clock shows typ 0:20 beside it, in amber once it is over twice the typical. t: ▸ trends opens three charts: executor time per run (a sparkline of the last 30), time by phase split into planning, executing and checking, and agent time per day for 14 days, each with its numbers beside it. History starts from the day you update; it is per project and never read from transcripts. Fit: with three or more past executor runs, a left line says how long the phase's open waves should take (one typical executor run per wave, so plans that can run together count once), the 5h line gives the window and when it resets, and the quota line adds how far the rest of the phase should take the window. Quota is measured, not guessed: while executors run, the plugin notes how far the 5-hour window rose and divides it by how many ran together, so a single executor and a wave of three both give a per-executor figure. It needs five such measurements, shows a range (the middle half of them), and leaves out any stretch with a reviewer or planner in it or where the window reset. The account is shared with the main conversation and other sessions, so read it as a range. When the next wave runs plans together, a next line shows what that wave alone should add. g: ▸ timeline draws one bar per agent over the latest working stretch (until a gap of 30 minutes, at most 12 agents, running ones shaded): agents that ran together sit on top of each other and agents that ran one after another form a staircase, with the executors' parallelism underneath.⑂) and sub-agents under their parent, and a live clock. Finished agents fold away while others run.o: ▸ read .planning (or any entry under work streams) swaps the dashboard for a browser of .planning, one folder at a time (so any depth), and then for the file itself, drawn by Claude Code's own Markdown element: the same typography as a reply, at the pane's width, scrolled with the wheel or PageDown/End. The YAML header is hidden, - [x] shows as ✓ and - [ ] as ○, and a relative link to another .md file under .planning opens it. A path opens only if its real location, symlinks followed, is still under .planning's own, so a symlink inside .planning cannot lead the reader elsewhere. b goes back one level, and d: ⌂ dashboard returns to the dashboard from any depth, and t: ↑ back to top ends every file. Only .planning is readable. A file with three or more headings gets c: ▸ contents: a list of its headings (up to 30, levels 1 to 3) that jumps to one. In a fullscreen terminal, a Read, Write or Edit of a .planning markdown file also gets a line under its tool row in the transcript, open in the GSD reader, that opens the pane on that file.Everything with a ▸ is clickable. With the pane focused (ctrl+x, then Tab) hotkeys work too: 1-6 agent rows, b blockers, r roadmap, p pace, t trends, g timeline, w workstream, o read .planning, c contents (in a file), f finished agents, l log. The pane is read-only. The cost is whatever Claude Code reports for the session, shown as is; it is an estimate, not a bill. The band above the prompt is hidden while the pane is open, so the same line is not drawn twice.
Workstream mode. When .planning/STATE.md is missing but .planning/workstreams/<name>/STATE.md exists, the pane reads that workstream's STATE.md, ROADMAP.md and work streams, plus the project-wide folders left at .planning/ (threads, spikes, seeds...), merged into the same counts (and its HANDOFF.json, else the top-level one). GSD's own choice of active workstream is per session and a mod cannot see it, so the plugin uses the name in .planning/active-workstream if there is one, else the workstream whose STATE.md changed most recently. With more than one, a w: ⇄ workstream … button (click, or w) switches to the next. The workstream name shows in the header.
Everything follows a .planning symlink and draws nothing when there is nothing to show. The band and hint refresh at session start and after each turn; the pane also refreshes on tool calls and agent events.
Toasts (the toasts option, on by default): short notices, each said once: an agent that has run more than twice its typical time, the rest of the phase getting tight against the 5-hour window (about 90% or more by the end, or the work outlasting the window when quota is not measured yet), and drift appearing between STATE.md and the commits.
The band does not show plan usage (5-hour / weekly limits), since quota-meter and limit-watch already do; the pane's main box does show them, for the one-glance view.
The eight below are from a made-up demo project (acme-portal). The five pane images are the pane's own output from a live Claude Code session with three background agents and a seeded made-up history, drawn to PNG from the terminal text (cropped to the pane); the three band images are from a terminal at least 16 rows tall.
The pane. From the top: the main box (context, cost, limits, blockers), the roadmap with the current phase marked, the pace box (the plans left and their waves, how long the rest should take and how far it should take the 5-hour window, and what the next wave of parallel plans adds), the agents with a typical time beside each running clock (typ 0:40, from past runs), the last turn, and the work streams. The history behind the pace box (22 executor runs, with quota measured on them) is made up for the demo, and the 5h and 7d figures are the demo session's own.

The pane with the pace details opened. Pressing p opened the wave-by-wave plan list, the time by agent type, and this phase's time by stage. The other ▸ rows open the same way, by click or key.

The markdown reader. A phase plan opened from o: ▸ read .planning: the YAML header is hidden, tasks show as ✓ and ○, and the two blue links are relative links that open the next plan and the roadmap in the same pane. c: ▾ contents lists the headings; a click jumps to one.

The trends view. Opened with t: ▸ trends. The numbers behind it are made up for the demo (38 agents over four phases), since a fresh demo project has no history: executor time per run, time by phase split into planning, executing and checking, and agent time per day.

The timeline. Opened with g: ▸ timeline: one bar per agent over the latest working stretch, coloured by stage. The two executors that ran together sit on top of each other, the third waited for them, and the three short bars at the right are agents still running or just done. The runs are made up for the demo.

The band, in the three images below. The ◐ medium · /effort row, the Sonnet 5.5 │ v1.0 · paused │ acme-portal statusline and the auto mode on text are Claude Code's and the GSD statusline's own; what this plugin adds is the cyan and yellow line above the prompt, the dim text in the prompt box, and · next: … · handoff 2d ago at the end of the last row.
A handoff that names a command. The GSD · line, the drift warning (~8 commits since STATE.md), the dim suggestion in the prompt box (Tab to take it), and the same command in the next: hint beside it.

A handoff that names no command. The hint still shows the next action, but no suggestion is made, so the prompt box keeps Claude Code's own Try "…" text.

Not a GSD project. A .planning/STATE.md without gsd_state_version is ignored: nothing is added.

Read-only, and nothing leaves the machine:
$.fs.read, $.fs.list, $.fs.stat): any .md under .planning/ the reader opens (only when you click it), and .planning/STATE.md, ROADMAP.md, HANDOFF.json, the entry names in .planning/phases, spikes, threads and the other work-stream folders, and the git reflog (.git/logs/HEAD, or the worktree's git dir named in a .git file). Paths resolve from the session root ($.session.root), not the current folder, so a Bash cd does not lose the project. In a linked git worktree with no .planning of its own, .planning is read from the main checkout.$.session.usage (context, cost, limits), $.agent.list (the live agents), $.store (the history above, in the plugin's own store) and $.clock.now / $.clock.every (a 1-second redraw timer, active only while something runs and the pane is open).$.ui.open, $.ui.close, $.ui.invalidate and $.ui.resolve for the pane and band, $.command.register for /gsd-status and /gsd-board, and $.prompt.suggest (a dim suggestion in the empty prompt box: it cannot send anything).session.start, turn.start, turn.step, turn.complete, tool.call and agent.spawn (to count edits and errors, count an agent's responses and record how long it ran, and note forks; it never changes or blocks a call), command.run, ui.render and ui.close.It writes no files, runs no processes and makes no network calls. Check it yourself: claude plugin validate .claude-plugin/plugin.json prints the hooks and the $ calls the module makes.
Tested on Claude Code 2.1.288 with quota-meter and limit-watch loaded together with this plugin: all three load without errors and each draws in its own place (they use the pinned status line and a pane; this uses the AbovePrompt band).
The AbovePrompt band and the hint line are shared. This plugin keeps whatever other mods draw there: it draws what is below it in the chain and puts its line on top, and it appends its hint tail after another mod's tail. A mod that answers without calling next hides every mod after it in load order; if such a mod loads before this one, its band is the only one shown, and that is the other mod's behaviour.
claude plugin marketplace add helenkwok/gsd-status-mod claude plugin install gsd-status-mod@helenkwok-mods
After that it loads in every session, with no flag. In a project that is not a GSD project it draws nothing. Update with claude plugin update gsd-status-mod@helenkwok-mods; remove with claude plugin uninstall gsd-status-mod@helenkwok-mods. Add --scope project to install it for one project only.
Needs Claude Code 2.1.287 or later (claude --version) and Node 20+ only if you want to run the tests. There is nothing to build or install: the plugin is loaded from its folder for one session.
git clone https://github.com/helenkwok/gsd-status-mod ~/gsd-status-mod cd <a GSD project> # it reads .planning from the folder you start claude in (or its main checkout, in a worktree) claude --plugin-dir ~/gsd-status-mod
To check the plugin itself: cd ~/gsd-status-mod, then claude plugin validate . (the marketplace file), claude plugin validate .claude-plugin/plugin.json (the hooks and $ calls) and node --test tests/*.test.mjs.
The band above the prompt is not drawn in a very short terminal window: it appeared at 16 rows and above and not at 13 (the engine drops it). The hint tail and the suggestion are unaffected.
On Windows the pane has been used in a workstream project (it showed the roadmap and workstream); the other features have not been tried there. A session in a worktree shows the main checkout's .planning, which is wrong if that worktree is on a different phase. Background: open-gsd/gsd-core#5174 (a maintainer asked to revisit in-tree support in November 2026; this plugin is the out-of-tree route).
hooks/gsd-status.mjs 569 lines1import { frontmatter, isGsdState, resumeLine, handoffInfo, driftNote, statusReport, pickWorkstream } from "./state-line.mjs";
2import { addRun, typical, forecast, dur } from "./history.mjs";
3import { panelModel, streamInfo, commitCount, commitFeed, shortPath, planShape, clock } from "./panel.mjs";
4
5// The command named by the handoff's next_action (see nextCommand) is offered as a dim suggestion: Tab puts it in the
6// prompt box and nothing runs until the person presses Enter. No command named, no suggestion.
7
8// Module state: the render hooks only read these, never touch the file system.
9let rows = { resume: null, drift: null, handoff: null, command: null };
10let suggested = false;
11let panePlaced = false; // true while our pane is on screen: the band above the prompt would only repeat it
12let openOnStart = true;
13let isGsd = false;
14let live = freshLive(); // what the pane draws: events of this session plus what the last refresh read
15let lastRefreshAt = 0;
16const seen = new Map(); // agent id -> { since, status, endedAt }: when this module first saw it, and how it last stood
17let hist = []; // what finished agents of this project left behind (see history.mjs), kept in $.store across sessions
18let histLoaded = false;
19const steps = new Map(); // agent id -> how many model responses it has made so far
20const forks = new Set(); // ids of agents started as forks of their parent (only the spawn event says so)
21const PANE = "gsd-board";
22// What the person has opened in the pane by clicking (or pressing a hotkey): agent rows, finished agents, blockers, streams, log.
23let reader = null; // the markdown reader: { path, isFile, entries | text }, or null for the dashboard
24const expand = { agents: new Set(), roadmap: false, pace: false, trends: false, timeline: false, toc: false, finished: false, blockers: false, streams: new Set(), log: false };
25let toasts = true; // short notices (an agent far over its usual time, a phase that will not fit the window, drift)
26const told = new Set(); // agent ids already toasted about
27let lastLevel; // how the phase fit the 5-hour window at the last look: undefined (not yet seen), null, "amber" or "warn"
28let lastDrift; // the drift note at the last look
29let cluster = null; // the agents that overlapped, for measuring quota: { p0, r0, ids, ok }; it closes when none is running
30const qOf = new Map(); // agent id -> { q, n } once its cluster has closed
31const runOf = new Map(); // agent id -> its row in `hist`, so a quota measured after the row was written can be filled in
32const PANE_SIZE = { columns: 64, rows: 16 };
33const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
34const STREAMS = ["phases", "spikes", "threads", "quick", "todos", "seeds", "notes"];
35
36// A press on one of the pane's buttons: flip what it names. The key says which: "agent:<id>", "stream:<name>", or a word.
37function flip(key) {
38 if (key === "ws") {
39 const n = wsInfo?.names ?? [];
40 if (n.length > 1) wsChoice = n[(n.indexOf(wsInfo.name) + 1) % n.length];
41 return;
42 }
43 const [kind, id] = String(key).split(/:(.*)/s);
44 const set = kind === "agent" ? expand.agents : kind === "stream" ? expand.streams : null;
45 if (set) { if (!set.delete(id)) set.add(id); return; }
46 if (kind in expand && typeof expand[kind] === "boolean") expand[kind] = !expand[kind];
47}
48
49function freshLive() {
50 return { state: null, roadmap: null, plans: [], workstream: null, handoff: null, usage: null, agents: [], streams: [], log: [], isRunning: false, turn: null, receipt: null,
51 commits: 0, newest: null, startCommits: 0, startCost: null };
52}
53
54function note(kind, text, at) {
55 live.log = [...live.log, { at, kind, text }].slice(-40);
56}
57
58// Paths are read from the session root, not the current directory: Bash can `cd` anywhere, and a cwd-relative
59// ".planning/STATE.md" then goes missing and the pane flips to "No GSD project here".
60let root = "";
61let planRoot = ""; // where .planning lives: the session root, or the main checkout when the session is in a linked worktree
62const at = (path) => {
63 const base = path.startsWith(".planning") ? planRoot || root : root;
64 return base ? `${base}/${path}` : path;
65};
66// Workstream mode: STATE.md, ROADMAP.md and the work streams live under .planning/workstreams/<name>/.
67let wsBase = ""; // "" for a normal project, else ".planning/workstreams/<name>"
68let wsChoice = ""; // the workstream the person picked in the pane
69let wsInfo = null; // { name, index, total, names }
70const plan = (name) => `${wsBase || ".planning"}/${name}`;
71const read = ($, path) => $.fs.read(at(path)).catch(() => null);
72async function resolvePlan($) {
73 wsBase = ""; wsInfo = null;
74 if ((await read($, ".planning/STATE.md")) != null) return;
75 const dirs = (await $.fs.list(at(".planning/workstreams")).catch(() => [])).filter((e) => e.kind === "dir" && !e.name.startsWith("."));
76 const cands = [];
77 for (const d of dirs) {
78 const st = await $.fs.stat(at(`.planning/workstreams/${d.name}/STATE.md`)).catch(() => null);
79 if (st) cands.push({ name: d.name, mtimeMs: st.mtimeMs ?? 0 });
80 }
81 const pick = pickWorkstream(cands, (await read($, ".planning/active-workstream")) ?? "", wsChoice);
82 if (pick) { wsInfo = pick; wsBase = `.planning/workstreams/${pick.name}`; }
83}
84
85async function findRoot($) {
86 if (root) return;
87 root = String((await $.session.root().catch(() => "")) ?? "").replace(/\/+$/, "");
88 // A linked worktree has no .planning of its own: its .git file points into <main>/.git/worktrees/<name>.
89 if (root && (await read($, ".planning/STATE.md")) == null) {
90 const ptr = await read($, ".git");
91 const main = /^gitdir:\s*(.+?)[\\/]\.git[\\/]worktrees[\\/][^\\/\s]+\s*$/m.exec(ptr ?? "")?.[1];
92 if (main && ((await $.fs.read(`${main}/.planning/STATE.md`).catch(() => null)) != null || (await $.fs.list(`${main}/.planning/workstreams`).catch(() => [])).length)) planRoot = main;
93 }
94}
95
96// The reflog lives in .git/logs/HEAD; in a linked worktree .git is a file pointing at the real git dir.
97async function reflog($) {
98 const direct = await read($, ".git/logs/HEAD");
99 if (direct != null) return direct;
100 const ptr = await read($, ".git");
101 const dir = ptr && /^gitdir:\s*(.+)$/m.exec(ptr)?.[1]?.trim();
102 return dir ? read($, `${dir}/logs/HEAD`) : null;
103}
104
105async function readUsage($) {
106 const u = await $.session.usage().catch(() => null);
107 if (!u) return null;
108 return { pct: u.context?.percent ?? null, tokens: u.context?.tokens ?? null, window: u.context?.window ?? null, costUsd: u.cost?.usd ?? null,
109 limits: (u.rateLimits ?? []).map((r) => ({ kind: r.kind, pct: r.percentUsed, resetsAt: r.resetsAt })) };
110}
111
112const five = (u) => (u?.limits ?? []).find((l) => /five|5h/i.test(l.kind) && Number.isFinite(l.pct)) ?? null;
113
114// Quota per executor, measured over the stretch in which executors overlapped: how far the 5-hour window rose, divided by how many
115// ran. One executor alone and a wave of three both give a per-executor figure. Anything but executors in the stretch (or a window
116// reset, or no window) and it is not measured. ponytail: the main conversation and other sessions add to the rise too, so the
117// figure is shown as a range once enough runs agree; per-agent token weighting would need the usage of each run's own rows.
118async function trackCluster($, agents) {
119 const running = agents.filter((a) => a.status === "running" && a.type);
120 if (running.length && !cluster) {
121 const f = five(live.usage);
122 cluster = { p0: f?.pct ?? null, r0: f?.resetsAt ?? null, ids: new Set(), ok: f != null };
123 qOf.clear(); runOf.clear();
124 }
125 if (cluster) for (const a of running) { cluster.ids.add(a.id); if (String(a.type).replace(/^gsd-/, "") !== "executor") cluster.ok = false; }
126 if (running.length || !cluster) return;
127 const f = five(live.usage), n = cluster.ids.size;
128 const q = cluster.ok && n && f && f.resetsAt === cluster.r0 && f.pct >= cluster.p0 ? Math.round(((f.pct - cluster.p0) / n) * 100) / 100 : -1;
129 let patched = false;
130 for (const id of cluster.ids) {
131 qOf.set(id, { q, n });
132 const row = runOf.get(id);
133 if (row) { row[8] = q; row[9] = n; patched = true; }
134 }
135 cluster = null;
136 if (patched) await $.store.set(histKey(), { v: 1, runs: hist }).catch(() => {});
137}
138
139// Short notices, each said once: an agent more than twice its usual time, the phase's fit to the window getting worse, drift appearing.
140function toast($, text) {
141 if (toasts) void Promise.resolve($.ui.toast(String(text).slice(0, 200))).catch(() => {});
142}
143function alerts($, now) {
144 for (const a of live.agents) {
145 if (a.status !== "running" || !a.since || told.has(a.id)) continue;
146 const typ = typical(hist, a.type);
147 if (typ && now - a.since > 2 * typ && now - a.since > 60000) { told.add(a.id); toast($, `${String(a.type).replace(/^gsd-/, "")} has run ${clock(now - a.since)}, usually ${clock(typ)}`); }
148 }
149 const fc = forecast(planShape(live.plans), hist, live.agents, now, live.usage?.limits);
150 const level = fc?.level ?? null;
151 if (level && level !== lastLevel) {
152 toast($, fc.quota ? `${fc.open} plans left would take the 5h window to about ${Math.round(fc.quota.end)}%${level === "warn" ? ", it may run out" : ""}`
153 : `about ${dur(fc.ms)} of work left, the 5h window resets in ${dur(Math.max(0, fc.resetMs))}`);
154 }
155 lastLevel = level;
156 if (lastDrift !== undefined && rows.drift && !lastDrift) toast($, rows.drift);
157 lastDrift = rows.drift;
158}
159
160// Live agents, with when each was first seen and, as they finish, a line in the log.
161async function readAgents($, now) {
162 const listed = await $.agent.list().catch(() => []);
163 const agents = listed.map((a) => {
164 const before = seen.get(a.id);
165 const rec = before ?? { since: now, status: a.status, endedAt: null };
166 if (before && before.status === "running" && a.status !== "running") {
167 rec.endedAt = now;
168 const bad = a.status === "failed" || a.status === "killed";
169 note(bad ? "fail" : "done", `${String(a.type).replace(/^gsd-/, "")} ${Math.round((now - rec.since) / 1000)}s`, now);
170 }
171 rec.status = a.status;
172 seen.set(a.id, rec);
173 return { ...a, since: rec.since, endedAt: rec.endedAt, isFork: forks.has(a.id) };
174 });
175 return agents;
176}
177
178// The current phase's plans and whether each is done, for the pace box. The phase folder is matched by number (04 is 4,
179// 02.1 is 2.1); a plan is done when its NN-MM-SUMMARY.md exists; a plan with no `wave:` is left out.
180async function readPlans($, state) {
181 const num = String(frontmatter(state)?.current_phase ?? "").replace(/^0+(?=\d)/, "");
182 if (!num) return [];
183 const dirs = await $.fs.list(at(plan("phases"))).catch(() => []);
184 const dir = dirs.find((e) => e.kind === "dir" && e.name.split("-")[0].replace(/^0+(?=\d)/, "") === num);
185 if (!dir) return [];
186 const base = `${plan("phases")}/${dir.name}`;
187 const files = await $.fs.list(at(base)).catch(() => []);
188 const out = [];
189 for (const f of files) {
190 const id = /^(.*)-PLAN\.md$/.exec(f.name)?.[1];
191 if (!id) continue;
192 const wave = Number(frontmatter((await read($, `${base}/${f.name}`)) ?? "")?.wave);
193 if (wave) out.push({ id, wave, done: files.some((x) => x.name === `${id}-SUMMARY.md`) });
194 }
195 return out;
196}
197
198const histKey = () => `runs:${root}`;
199
200async function loadHist($) {
201 if (histLoaded) return;
202 histLoaded = true;
203 const h = await $.store.get(histKey()).catch(() => null);
204 hist = Array.isArray(h?.runs) ? h.runs : [];
205}
206
207// An agent's run ended: keep its numbers (no description, no text). Internal agents have no type and are skipped.
208async function recordRun($, e) {
209 const a = live.agents.find((x) => x.id === e.agentId);
210 if (!a?.type || !(e.durationMs > 0)) return;
211 const u = e.usage ?? {};
212 const now = await $.clock.now(); // awaited first: agents that end together must not read `hist` before one another's update lands
213 const m = qOf.get(e.agentId); // the quota, when this agent's cluster closed before its row was written
214 const row = [now, a.type, String(frontmatter(live.state)?.current_phase ?? ""), e.durationMs, steps.get(e.agentId) ?? 0,
215 (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0), u.output_tokens ?? 0, u.model ?? "", m?.q ?? -1, m?.n ?? 0];
216 hist = addRun(hist, row);
217 runOf.set(e.agentId, row);
218 steps.delete(e.agentId);
219 await $.store.set(histKey(), { v: 1, runs: hist }).catch(() => {});
220}
221
222async function readStreams($) {
223 const out = [];
224 for (const name of STREAMS) {
225 // In workstream mode the project-wide folders (threads, spikes, seeds...) stay at .planning/, beside the workstream's own.
226 const here = await $.fs.list(at(plan(name))).catch(() => []);
227 const top = wsBase ? await $.fs.list(at(`.planning/${name}`)).catch(() => []) : [];
228 const info = streamInfo([...here.map((e) => ({ ...e, from: plan(name) })), ...top.map((e) => ({ ...e, from: `.planning/${name}` }))]);
229 if (info) out.push({ name, ...info });
230 }
231 return out;
232}
233
234// Everything the band, the hint and the pane need. The work streams are listed only when `full` (they change rarely).
235async function refresh($, full = true) {
236 await findRoot($);
237 await loadHist($);
238 await resolvePlan($);
239 const now = await $.clock.now();
240 lastRefreshAt = now;
241 const state = await read($, plan("STATE.md"));
242 isGsd = state != null && isGsdState(state);
243 const handoffText = isGsd ? (await read($, plan("HANDOFF.json"))) ?? (wsBase ? await read($, ".planning/HANDOFF.json") : null) : null;
244 const handoff = handoffText == null ? null : handoffInfo(handoffText, now);
245 rows = { resume: isGsd ? resumeLine(state) : null, handoff: handoff?.line ?? null, command: handoff?.command ?? null, drift: null };
246 if (!isGsd) { live = freshLive(); $.ui.invalidate("ui.render"); return; }
247
248 let parsed = null;
249 try { parsed = handoffText == null ? null : JSON.parse(handoffText); } catch { /* unreadable: no handoff rows */ }
250 const log = await reflog($);
251 if (log != null) {
252 if (rows.resume && /^state_head:/m.test(state)) rows.drift = driftNote(state, log);
253 const feed = commitFeed(log, 5);
254 if (live.newest && feed.length && feed[0].hash !== live.newest) {
255 const fresh = [];
256 for (const c of feed) { if (c.hash === live.newest) break; fresh.push(c); }
257 for (const c of fresh.reverse()) note("commit", `${c.hash} ${c.msg}`, now);
258 }
259 live.newest = feed[0]?.hash ?? live.newest;
260 live.commits = commitCount(log);
261 }
262 live.state = state;
263 live.workstream = wsInfo;
264 live.handoff = parsed;
265 live.usage = await readUsage($);
266 live.agents = await readAgents($, now);
267 await trackCluster($, live.agents);
268 if (full) { live.streams = await readStreams($); live.roadmap = await read($, plan("ROADMAP.md")); live.plans = await readPlans($, state); }
269 alerts($, now);
270 $.ui.invalidate("ui.render"); // render output is cached until invalidated
271}
272
273// /gsd-status: the full report, read fresh from disk each time so it is never behind the band.
274async function report($) {
275 await findRoot($);
276 await resolvePlan($);
277 const state = await read($, plan("STATE.md"));
278 const ok = state != null && isGsdState(state);
279 const handoff = ok ? (await read($, plan("HANDOFF.json"))) ?? (wsBase ? await read($, ".planning/HANDOFF.json") : null) : null;
280 let drift = null;
281 if (ok && /^state_head:/m.test(state)) {
282 const log = await reflog($);
283 if (log != null) drift = driftNote(state, log);
284 }
285 return statusReport(state, handoff, await $.clock.now(), drift).text;
286}
287
288// At most every 2 seconds, and not awaited by the hooks that call it, so it never slows a tool call down.
289async function soon($) {
290 const now = await $.clock.now();
291 if (!isGsd || now - lastRefreshAt < 2000) return;
292 await refresh($, false).catch(() => {});
293}
294
295// Once a second while the pane is open and something is running: redraw so the clocks move, and every 5 seconds
296// re-read agents and commits, since an agent can end without a hook of ours firing.
297async function tick($) {
298 if (!isGsd || !panePlaced) return;
299 if (!live.isRunning && !live.agents.some((a) => a.status === "running")) return;
300 const now = await $.clock.now();
301 if (now - lastRefreshAt >= 5000) await refresh($, false).catch(() => {});
302 else $.ui.invalidate("ui.render");
303}
304
305// The reader opens a folder listing or one file; path is a ".planning/..." path, so it can never leave .planning.
306// A symlink inside .planning may point anywhere, so the reader opens a path only if its real location is still under
307// .planning's own real location (.planning itself is often a symlink to the external store).
308async function insidePlanning($, p) {
309 const norm = (s) => String(s ?? "").replace(/\\/g, "/");
310 const [base, target] = await Promise.all([".planning", p].map((x) => $.fs.stat(at(x), { resolve: true }).catch(() => null)));
311 const b = norm(base?.realPath), r = norm(target?.realPath);
312 return Boolean(b && r && (r === b || r.startsWith(b + "/")));
313}
314
315async function openReader($, path) {
316 const parts = String(path).split("/").filter((x) => x && x !== ".");
317 if (parts[0] !== ".planning" || parts.includes("..")) return;
318 const p = parts.join("/");
319 if (!(await insidePlanning($, p))) return;
320 const entries = await $.fs.list(at(p)).catch(() => null);
321 expand.toc = false;
322 if (entries) reader = { path: p, isFile: false, entries };
323 else {
324 const text = await read($, p);
325 if (text == null) return;
326 reader = { path: p, isFile: true, text };
327 }
328 $.ui.invalidate("ui.render");
329}
330
331// A link inside the open file -> the .planning path it points at, or null (not a relative .md link, or outside .planning).
332// It lives here, not in panel.mjs: a press handler runs where only this file's own functions are visible, not imported names.
333export function resolveLink(from, href) {
334 const h = String(href ?? "").split("#")[0];
335 if (!h || h.startsWith("/") || /^[a-z][a-z0-9+.-]*:/i.test(h) || !/\.md$/i.test(h)) return null;
336 const parts = String(from).split("/").slice(0, -1);
337 for (const seg of h.split("/")) { if (seg === "..") parts.pop(); else if (seg && seg !== ".") parts.push(seg); }
338 return parts[0] === ".planning" ? parts.join("/") : null;
339}
340
341// A click on a link in the open file: a relative .md link opens in the reader; anything else is left alone.
342async function linkPress($, href) {
343 const t = reader && resolveLink(reader.path, href);
344 if (t) await openReader($, t);
345}
346
347// Brings the element with this key into view (null: the top), trying again while a pane that just changed is not drawn yet.
348async function scrollTo($, key) {
349 try {
350 await $.clock.sleep(120);
351 for (let n = 0; n < 8; n++) {
352 const r = await $.ui.scroll({ in: PANE, to: key ? { key } : "start", block: "start" }).catch((err) => ({ deny: String(err) }));
353 if (!r?.deny) return;
354 await $.clock.sleep(80);
355 }
356 } catch { /* the module reloaded: the pane stays where it is */ }
357}
358
359// A path inside this project's .planning -> ".planning/..." (what the reader opens), else null.
360// ponytail: a file read through the external store's own path (.planning is usually a symlink to it) gets no link.
361function planRel(file) {
362 const f = String(file ?? "").replace(/\\/g, "/"), base = String(planRoot || root).replace(/\\/g, "/");
363 return base && f.startsWith(`${base}/.planning/`) ? f.slice(base.length + 1) : null;
364}
365
366// A click on the "open in the GSD reader" line under a tool row in the transcript: show the pane and open that file.
367async function fromTranscript($, href) {
368 const rel = planRel(decodeURIComponent(String(href).replace(/^file:\/\//, "")));
369 if (!rel) return;
370 await openPane($).catch(() => {});
371 await openReader($, rel);
372}
373
374async function readerPress($, key) {
375 const [, kind, arg] = /^reader:([^:]+):?(.*)$/s.exec(key) ?? [];
376 if (kind === "browse") return openReader($, ".planning");
377 if (kind === "open-path") return openReader($, arg);
378 if (!reader) return;
379 if (kind === "top") return void (await $.ui.scroll({ in: PANE, to: "start" }));
380 if (kind === "toc") { expand.toc = !expand.toc; $.ui.invalidate("ui.render"); return; }
381 if (kind === "goto") { expand.toc = false; $.ui.invalidate("ui.render"); return void (await scrollTo($, `sec${arg}`)); }
382 if (kind === "close") { reader = null; $.ui.invalidate("ui.render"); return; }
383 if (kind === "open") return openReader($, `${reader.path}/${arg}`);
384 if (kind === "up") {
385 if (!reader.isFile && reader.path === ".planning") { reader = null; $.ui.invalidate("ui.render"); return; }
386 return openReader($, reader.path.split("/").slice(0, -1).join("/"));
387 }
388}
389
390async function openPane($) {
391 const r = await $.ui.open({ id: PANE, title: "GSD", ...PANE_SIZE });
392 panePlaced = r.isPlaced;
393 return r;
394}
395
396export function register(on, options) {
397 openOnStart = options?.openOnStart !== false;
398 toasts = options?.toasts !== false;
399 on("session.start", async ($, e, next) => {
400 // A host without commands just goes without the report and the toggle; the band and hint do not depend on them.
401 try {
402 await $.command.register({ name: "gsd-status", description: "Where this GSD project stands: phase, progress, handoff, blockers" });
403 await $.command.register({ name: "gsd-board", description: "Show or hide the live GSD pane", argumentHint: "[close]" });
404 } catch { /* no command support here */ }
405 if (e.isInteractive) $.clock.every(1000, () => void tick($));
406 await refresh($);
407 if (isGsd && openOnStart) { try { await openPane($); } catch { /* not placed: /gsd-board opens it */ } }
408 // Once per session, and only when the handoff names a command: propose it in the empty prompt box.
409 if (rows.command && !suggested) {
410 suggested = true;
411 await $.prompt.suggest({ text: rows.command }).catch(() => {});
412 }
413 return next(e);
414 });
415 on("command.run", { command: "gsd-status" }, async ($) => ({ text: await report($) }));
416 on("command.run", { command: "gsd-board" }, async ($, e) => {
417 if ((e.args ?? "").trim() === "close") { await $.ui.close({ id: PANE }); panePlaced = false; return { text: "GSD pane closed." }; }
418 if (!isGsd) return { text: "No GSD project here: nothing to show." };
419 const r = await openPane($);
420 return { text: r.isPlaced ? "GSD pane opened. /gsd-board close hides it." : `The GSD pane is not shown: ${r.reason}` };
421 });
422
423 // The pane is driven by the session's own events.
424 on("turn.start", async ($, e, next) => {
425 const out = await next(e);
426 if (isGsd && !e.agentId) {
427 const now = await $.clock.now();
428 live.isRunning = true;
429 live.turn = { startedAt: now, edits: 0, errors: 0 };
430 live.startCommits = live.commits;
431 live.startCost = live.usage?.costUsd ?? null;
432 // A finished background agent reports back as a turn that opens with <agent-message>: the agent's own "done" line says that.
433 if (e.text && !String(e.text).trimStart().startsWith("<")) note("prompt", String(e.text).replace(/\s+/g, " ").trim(), now);
434 void soon($);
435 }
436 return out;
437 });
438 on("tool.call", async ($, e, next) => {
439 const out = await next(e);
440 if (isGsd && e.tool !== "Agent") {
441 const now = await $.clock.now();
442 const failed = out?.isError === true;
443 const isEdit = EDIT_TOOLS.has(e.tool) && !failed && out?.deny === undefined;
444 if (live.turn && !e.agentId) {
445 if (isEdit) live.turn.edits += 1;
446 if (failed) live.turn.errors += 1;
447 }
448 if (isEdit) note("write", shortPath(e.file_path ?? e.input?.file_path ?? e.notebook_path ?? ""), now);
449 else if (failed) note("error", String(e.tool), now);
450 void soon($);
451 }
452 return out;
453 });
454 on("agent.spawn", async ($, e, next) => {
455 const out = await next(e);
456 if (isGsd) {
457 if (e.fork && out?.agentId) forks.add(out.agentId);
458 note("spawn", `${e.fork ? "fork " : ""}${String(e.subagentType ?? "agent").replace(/^gsd-/, "")} ${e.description ?? ""}`.trim(), await $.clock.now());
459 void soon($);
460 }
461 return out;
462 });
463 // One response by a subagent = one step; the count is what an agent's "turns" in the history are.
464 on("turn.step", async function* ($, e, next) {
465 if (e.agentId) steps.set(e.agentId, (steps.get(e.agentId) ?? 0) + 1);
466 return yield* next(e);
467 });
468 on("turn.complete", async ($, e, next) => {
469 await refresh($);
470 if (isGsd && e.agentId) await recordRun($, e);
471 if (isGsd && !e.agentId) {
472 const now = await $.clock.now();
473 const t = live.turn;
474 const cost = live.usage?.costUsd ?? null;
475 live.receipt = {
476 durationMs: e.durationMs ?? (t ? now - t.startedAt : 0),
477 edits: t?.edits ?? 0, errors: t?.errors ?? 0,
478 commits: Math.max(0, live.commits - live.startCommits),
479 costDelta: cost != null && live.startCost != null ? cost - live.startCost : null,
480 };
481 live.isRunning = false;
482 live.turn = null;
483 $.ui.invalidate("ui.render");
484 }
485 return next(e);
486 });
487
488 // The person closing the pane with its close mark: the band comes back.
489 on("ui.close", { id: PANE }, async ($, e, next) => { const out = await next(e); panePlaced = false; return out; });
490
491 // The pane: a bordered box per topic, the way the engine's own panes are drawn.
492 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
493 const { Box, Text, Button, Markdown } = $.ui.resolve(e);
494 if (!isGsd) return Text({ dimColor: true, children: "No GSD project here." });
495 const W = Math.max(40, e.props.bodyColumns);
496 const segs = (list) => list.map(([text, color, flags]) => Text({ ...(color ? { color } : {}), ...(flags === "b" ? { bold: true } : {}), children: text }));
497 const press = (key) => () => {
498 if (key.startsWith("reader:")) return void readerPress($, key);
499 flip(key);
500 if (key === "ws") void refresh($); else $.ui.invalidate("ui.render");
501 };
502 const link = (k) => void linkPress($, k.href);
503 // A line is plain text, a row with a ▸/▾ button in front, or one whole-line button.
504 const line = (l, i) => {
505 if (l.md !== undefined) return Markdown({ key: l.key ?? `md${i}`, text: l.md, onLinkPress: link });
506 if (Array.isArray(l)) return Text({ wrap: "truncate", children: segs(l) });
507 if (l.toggle) {
508 return Box({ flexDirection: "row", children: [
509 Button({ key: l.toggle.key, label: l.toggle.open ? "▾" : "▸", plain: true, ...(l.toggle.hotkey ? { hotkey: l.toggle.hotkey } : {}), onPress: press(l.toggle.key) }),
510 Text({ children: " " }),
511 Text({ wrap: "truncate", children: segs(l.segs) }),
512 ] });
513 }
514 return Button({ key: l.button.key, label: l.button.label, plain: true, hotkey: l.button.hotkey, onPress: press(l.button.key) });
515 };
516 const m = panelModel({ ...live, history: hist, reader, expand, now: await $.clock.now() }, W);
517 return Box({
518 flexDirection: "column", width: W,
519 children: [
520 Box({ justifyContent: "center", children: [Text({ bold: true, wrap: "truncate", children: segs(m.header) })] }),
521 ...m.panels.map((p) => Box({
522 flexDirection: "column", borderStyle: "round", borderColor: p.color, paddingX: 1, width: W,
523 children: [
524 Box({ justifyContent: "space-between", children: [Text({ wrap: "truncate", children: segs(p.title) }), ...(p.right ? [Text({ children: segs(p.right) })] : [])] }),
525 ...p.lines.map(line),
526 ],
527 })),
528 ],
529 });
530 });
531
532 // A tool row that read or wrote a .planning file: the engine's own row, and a line under it that opens the file in the reader.
533 // Clicks reach a mod only in the fullscreen terminal, so elsewhere the row stays as the engine draws it.
534 on("ui.render", { component: "ToolUse" }, async ($, e, next) => {
535 const p = e.props ?? {};
536 const file = p.input?.file_path;
537 if (!isGsd || e.surface !== "terminal" || !e.viewport?.isFullscreen || !["Read", "Write", "Edit"].includes(p.tool) || typeof file !== "string" || !/\.md$/i.test(file) || p.isRunning || p.isErrored) return next(e);
538 if (!planRel(file)) return next(e);
539 const { Box, Markdown } = $.ui.resolve(e);
540 const href = "file://" + encodeURI(file).replace(/[#?()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
541 return Box({ flexDirection: "column", children: [
542 await next(e),
543 Box({ paddingLeft: 2, children: [Markdown({ key: "open", dimColor: true, text: `⎿ [open in the GSD reader ↗](${href})`, pressableLinks: [href], onLinkPress: () => void fromTranscript($, href) })] }),
544 ] });
545 });
546
547 // The band above the prompt: the GSD line, and the drift warning when STATE.md is behind. While the pane is open it
548 // shows the same, so the band stands down.
549 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
550 const below = await next(e); // whatever other mods draw in this band: keep it, add our line on top
551 const { resume, drift } = rows;
552 if (!resume || panePlaced) return below;
553 const { Box, Text } = $.ui.resolve(e);
554 const parts = [Text({ color: "cyan", children: resume })];
555 if (drift) parts.push(Text({ color: "yellow", children: " " + drift }));
556 const mine = Box({ paddingX: 1, flexDirection: "row", children: parts });
557 return below ? Box({ flexDirection: "column", children: [mine, below] }) : mine;
558 });
559
560 // The line under the prompt: add the handoff's next action to its end, dim. The engine's own line and its pills
561 // stay; another mod's tail is kept and ours follows it. Nothing while typing or while a turn runs.
562 on("ui.render", { component: "PromptHint" }, async ($, e, next) => {
563 const p = e.props ?? {};
564 if (!rows.handoff || p.isDraft || p.isWorking) return next(e);
565 const tail = p.tail ? `${p.tail} · ${rows.handoff}` : rows.handoff;
566 return next({ ...e, props: { ...p, tail } });
567 });
568}
569hooks/state-line.mjs 189 lines1// Pure: STATE.md text -> one-line summary. No $ access, so it is testable under plain Node.
2export function frontmatter(text) {
3 const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
4 if (!m) return null;
5 const out = {};
6 let nested = null;
7 for (const raw of m[1].split(/\r?\n/)) {
8 const line = raw.replace(/\s+$/, "");
9 if (!line || line.trimStart().startsWith("#")) continue;
10 const kv = /^(\s*)([A-Za-z0-9_]+):\s*(.*)$/.exec(line);
11 if (!kv) continue;
12 const [, indent, key, val] = kv;
13 const v = val.replace(/^["']|["']$/g, "");
14 if (!indent && v === "") { nested = out[key] = {}; continue; }
15 if (indent && nested) nested[key] = v; else { nested = null; out[key] = v; }
16 }
17 return out;
18}
19
20function ago(ms) {
21 const m = Math.floor(ms / 60000);
22 if (m < 1) return "just now";
23 if (m < 60) return `${m}m ago`;
24 const h = Math.floor(m / 60);
25 if (h < 48) return `${h}h ago`;
26 return `${Math.floor(h / 24)}d ago`;
27}
28
29// Free text (stopped_at, next_action) -> something that reads at a glance: the first sentence,
30// else cut at a word boundary. A "." inside a name like PLAN.md or 2026-09-09 is not a sentence end.
31export function summarize(text, max = 70) {
32 const s = String(text ?? "").replace(/\s+/g, " ").trim();
33 if (!s) return "";
34 const first = /^(.+?[.!?])(?=\s+[A-Z(]|$)/.exec(s)?.[1] ?? s;
35 const one = first.replace(/[.!?]+$/, "");
36 if (one.length <= max) return one;
37 const cut = one.slice(0, max - 1);
38 const sp = cut.lastIndexOf(" ");
39 return (sp > max * 0.5 ? cut.slice(0, sp) : cut).replace(/[\s,;:–—-]+$/, "") + "…";
40}
41
42// True for a STATE.md that GSD wrote (its frontmatter carries gsd_state_version), not any file with that name.
43export function isGsdState(text) {
44 const fm = frontmatter(text);
45 return Boolean(fm && fm.gsd_state_version);
46}
47
48// Where work stopped and how many phases are done. No age: STATE.md's last_updated is touched by tools
49// without the content changing, so "17h ago" can sit beside a stopped_at from weeks earlier.
50// Returns null for a non-GSD file or when there is nothing the GSD statusline lacks.
51export function resumeLine(text, max = 70) {
52 const fm = frontmatter(text);
53 if (!fm || !fm.gsd_state_version) return null;
54 const parts = [];
55 if (fm.stopped_at) parts.push("stopped: " + summarize(fm.stopped_at, max));
56 const p = fm.progress;
57 if (p && p.total_phases) parts.push(`${p.completed_phases ?? 0}/${p.total_phases} phases`);
58 return parts.length ? "GSD · " + parts.join(" · ") : null;
59}
60
61// The command a next_action names, if it names one: the first /gsd-<name>, plus at most one argument that looks like
62// a phase number or version (2, 4.5, v1). Free-text arguments are dropped, so the result is something that can be
63// offered as a suggestion without guessing. null when the text names no command (prose such as "Nothing is pending").
64export function nextCommand(nextAction) {
65 const m = /(?<![\w/.~-])(\/gsd-[a-z][a-z0-9-]*)(?:\s+(v?\d+(?:\.\d+)*[a-z]?)(?![\w-]))?/.exec(String(nextAction ?? ""));
66 return m ? m[1] + (m[2] ? " " + m[2] : "") : null;
67}
68
69// HANDOFF.json is the resume note GSD writes on pause. Its next_action is the first thing to read when resuming.
70// The age is the handoff's own timestamp, so unlike STATE.md's it means what it says.
71// Returns { line, command }: the text for the hint line, and the command that text names (or null).
72export function handoffInfo(jsonText, now = Date.now(), max = 90) {
73 let j;
74 try { j = JSON.parse(jsonText); } catch { return null; }
75 if (!j || typeof j.next_action !== "string" || !j.next_action.trim()) return null;
76 const shown = summarize(j.next_action, max);
77 const parts = ["next: " + shown];
78 const t = j.timestamp ? Date.parse(j.timestamp) : NaN;
79 if (!Number.isNaN(t) && now >= t) parts.push("handoff " + ago(now - t));
80 // The command comes only from the text that is shown, so the Tab suggestion always matches the hint beside it,
81 // and not when the action says to go and do it somewhere else (another session, another directory).
82 const elsewhere = /\b(open|start|launch)\b[^.]*\b(session|terminal|window)\b|~\/|\/Users\//i.test(shown);
83 return { line: parts.join(" · "), command: elsewhere ? null : nextCommand(shown) };
84}
85
86export function handoffLine(jsonText, now = Date.now(), max = 90) {
87 return handoffInfo(jsonText, now, max)?.line ?? null;
88}
89
90// Commits made after the one STATE.md was written at, counted from the git reflog text (.git/logs/HEAD),
91// so no git process is needed. null when the reflog does not contain that commit (pruned, rebased, other clone).
92export function commitsSince(reflogText, stateHead) {
93 if (!stateHead || String(stateHead).length < 7) return null; // a short prefix would match unrelated reflog rows
94 const rows = reflogText.split(/\r?\n/).filter(Boolean).map((l) => {
95 const [head, msg = ""] = l.split("\t");
96 const [, now] = head.split(" ");
97 return { now, msg };
98 });
99 let at = -1;
100 rows.forEach((r, i) => { if (r.now && r.now.startsWith(stateHead)) at = i; });
101 if (at < 0) return null;
102 return rows.slice(at + 1).filter((r) => /^(commit|merge|pull|cherry-pick|revert)/.test(r.msg)).length;
103}
104
105// "⚠ ~217 commits since STATE.md" when STATE.md is well behind the repo, else null. Approximate: the reflog only
106// holds this clone's own HEAD moves, so it undercounts (measured: 124 against 217 from git rev-list).
107export function driftNote(stateText, reflogText, threshold = 5) {
108 const fm = frontmatter(stateText);
109 if (!fm || !fm.gsd_state_version || !fm.state_head) return null;
110 const n = commitsSince(reflogText, fm.state_head);
111 return n != null && n >= threshold ? `⚠ ~${n} commits since STATE.md` : null;
112}
113
114// Free-form lists in HANDOFF.json (blockers, human_actions_pending, remaining_tasks) come as strings or as objects
115// with some text field. Show each as one line of text; anything unreadable is skipped rather than guessed at.
116export function items(v) {
117 if (v == null || v === "") return [];
118 const list = Array.isArray(v) ? v : [v];
119 return list
120 .map((x) => (typeof x === "string" ? x : x && typeof x === "object" ? (x.description ?? x.text ?? x.title ?? x.task ?? x.name ?? "") : String(x)))
121 .map((s) => summarize(s, 110))
122 .filter(Boolean);
123}
124
125function listBlock(label, v, show = 5) {
126 const all = items(v);
127 if (!all.length) return [];
128 const out = [`${label} (${all.length})`, ...all.slice(0, show).map((s) => " - " + s)];
129 if (all.length > show) out.push(` … and ${all.length - show} more`);
130 return out;
131}
132
133// The full resume report for the /gsd-status command: everything the band and hint compress, in plain lines.
134// Pure. stateText may be null (no STATE.md), handoffText may be null (no HANDOFF.json), driftText is driftNote's output.
135// Returns { text, isGsd }: isGsd is false when there is no GSD STATE.md here, and text then says so.
136export function statusReport(stateText, handoffText, now = Date.now(), drift = null) {
137 const fm = stateText == null ? null : frontmatter(stateText);
138 if (!fm || !fm.gsd_state_version) {
139 return { isGsd: false, text: "No GSD project here: .planning/STATE.md is missing or has no gsd_state_version." };
140 }
141 const out = ["GSD status"];
142 const phase = [fm.current_phase && `phase ${fm.current_phase}`, fm.current_phase_name].filter(Boolean).join(" · ");
143 if (phase) out.push("Now: " + phase + (fm.status ? ` (${fm.status})` : ""));
144 else if (fm.status) out.push("Status: " + fm.status);
145 const p = fm.progress;
146 if (p) {
147 const bits = [];
148 if (p.total_phases) bits.push(`${p.completed_phases ?? 0}/${p.total_phases} phases`);
149 if (p.total_plans) bits.push(`${p.completed_plans ?? 0}/${p.total_plans} plans`);
150 if (bits.length) out.push("Progress: " + bits.join(" · "));
151 }
152 if (fm.stopped_at) out.push("Stopped at: " + summarize(fm.stopped_at, 220));
153 if (drift) out.push(drift);
154
155 let j = null;
156 try { j = handoffText == null ? null : JSON.parse(handoffText); } catch { /* unreadable handoff: say so below */ }
157 if (handoffText != null && !j) out.push("", "HANDOFF.json is present but is not valid JSON.");
158 if (j && typeof j === "object") {
159 const t = j.timestamp ? Date.parse(j.timestamp) : NaN;
160 const age = !Number.isNaN(t) && now >= t ? ` (written ${ago(now - t)})` : "";
161 out.push("", "Handoff" + age);
162 if (typeof j.next_action === "string" && j.next_action.trim()) {
163 out.push("Next: " + summarize(j.next_action, 260));
164 const cmd = nextCommand(summarize(j.next_action, 90));
165 if (cmd) out.push("Command: " + cmd);
166 }
167 out.push(...listBlock("Blockers", j.blockers));
168 out.push(...listBlock("Needs a person", j.human_actions_pending));
169 out.push(...listBlock("Remaining tasks", j.remaining_tasks));
170 const files = items(j.uncommitted_files);
171 if (files.length) out.push(`Uncommitted at pause: ${files.length} file${files.length === 1 ? "" : "s"}`);
172 } else if (handoffText == null) {
173 out.push("", "No HANDOFF.json (nothing was paused with /gsd-pause-work).");
174 }
175 return { isGsd: true, text: out.join("\n") };
176}
177
178// Workstream mode keeps one STATE.md per workstream under .planning/workstreams/<name>/. Which one is "the" project is
179// GSD's own per-session choice, which a mod cannot see, so: the person's pick in the pane, else the name in
180// .planning/active-workstream, else the one that changed most recently.
181// cands: [{ name, mtimeMs }] (only workstreams that have a STATE.md) -> { name, index, total, names } | null
182export function pickWorkstream(cands, marker = "", choice = "") {
183 const list = [...(cands ?? [])].sort((a, b) => a.name.localeCompare(b.name));
184 if (!list.length) return null;
185 const newest = [...list].sort((a, b) => (b.mtimeMs ?? 0) - (a.mtimeMs ?? 0))[0];
186 const hit = list.find((c) => c.name === choice) ?? list.find((c) => c.name === String(marker).trim()) ?? newest;
187 return { name: hit.name, index: list.indexOf(hit) + 1, total: list.length, names: list.map((c) => c.name) };
188}
189hooks/history.mjs 102 lines1// What a finished agent leaves behind, and what the pace box and the trends view make of it. Pure: no `$`.
2// A run is [endedAtMs, type, phase, durationMs, turns, inTokens, outTokens, model, quota]. Numbers only: no description, no text.
3// `quota` is the points of the 5-hour window one agent used: the window's rise over the stretch in which agents overlapped,
4// divided by how many ran (`width`, the last number). -1: not measurable; both absent in older runs.
5export const KEEP = 300; // runs kept per project, the oldest dropped first
6
7const bare = (t) => String(t ?? "").replace(/^gsd-/, "");
8const STAGES = {
9 planning: /^(planner|phase-researcher|project-researcher|pattern-mapper|roadmapper)$/,
10 executing: /^(executor|code-fixer)$/,
11 checking: /^(verifier|plan-checker|code-reviewer|ui-checker|security-auditor)$/,
12};
13export const stageOf = (type) => Object.keys(STAGES).find((k) => STAGES[k].test(bare(type))) ?? "other";
14
15export const addRun = (runs, run) => [...(runs ?? []), run].slice(-KEEP);
16
17// The median duration of past runs of this agent type, once there are `min` of them, else null.
18export function typical(runs, type, min = 5) {
19 const d = (runs ?? []).filter((r) => bare(r[1]) === bare(type)).map((r) => r[3]).sort((a, b) => a - b);
20 return d.length >= min ? (d[(d.length - 1) >> 1] + d[d.length >> 1]) / 2 : null;
21}
22
23const pick = (sorted, q) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];
24
25// Points of the 5-hour window an agent of this type uses: the median and the middle half of the runs where it was measured
26// cleanly, alone or in a parallel wave. Null under `min` such runs. The account is shared with other sessions and with the
27// main conversation, so this is a range, never one number.
28export function quotaPerRun(runs, type = "executor", min = 5) {
29 const q = (runs ?? []).filter((r) => bare(r[1]) === bare(type) && r[8] >= 0).map((r) => r[8]).sort((a, b) => a - b);
30 return q.length >= min ? { n: q.length, med: pick(q, 0.5), lo: pick(q, 0.25), hi: pick(q, 0.75) } : null;
31}
32
33// What is left of the current phase: executor time (one typical run per open wave, the running executor's time already spent
34// taken off) and, once quota is measured, the points the open plans would add to the 5-hour window.
35// shape: planShape(...); limits: [{ kind, pct, resetsAt }]. Null when nothing is open or executors have no history.
36export function forecast(shape, runs, agents, now, limits) {
37 const typ = typical(runs, "executor", 3);
38 if (!shape?.open || !typ) return null;
39 const spent = Math.max(0, ...(agents ?? []).filter((a) => a.status === "running" && bare(a.type) === "executor" && a.since).map((a) => now - a.since));
40 const five = (limits ?? []).find((l) => /five|5h/i.test(l.kind) && Number.isFinite(l.pct));
41 const resetMs = five?.resetsAt && Number.isFinite(Date.parse(five.resetsAt)) ? Date.parse(five.resetsAt) - now : null;
42 const q = five ? quotaPerRun(runs) : null;
43 const n = (runs ?? []).filter((r) => bare(r[1]) === "executor").length;
44 const next = (shape.byWave ?? []).find(([, l]) => l.some((p) => !p.done));
45 const nextN = next ? next[1].filter((p) => !p.done).length : 0; // executors in the next wave: they run together
46 const out = { ms: Math.max(0, shape.waves * typ - spent), waves: shape.waves, open: shape.open, nextN, runs: n, pct: five?.pct ?? null, resetMs, quota: null, level: null };
47 if (q) {
48 const add = shape.open * q.med, lo = shape.open * q.lo, hi = shape.open * q.hi;
49 out.quota = { add, lo, hi, end: five.pct + add, endHi: five.pct + hi, n: q.n, next: { add: nextN * q.med, lo: nextN * q.lo, hi: nextN * q.hi, end: five.pct + nextN * q.med } };
50 out.level = out.quota.endHi >= 100 ? "warn" : out.quota.end >= 90 ? "amber" : null;
51 } else if (resetMs != null && out.ms > resetMs) out.level = "amber";
52 return out;
53}
54
55// The latest working stretch for the timeline: runs that ended and agents running now, going back from the newest until a
56// quiet gap longer than `gap`, at most `max` of them, by start time. -> [{ type, a, b, running }]
57export function burst(runs, running, now, gap = 30 * 60000, max = 12) {
58 const iv = (runs ?? []).map((r) => ({ type: r[1], a: r[0] - r[3], b: r[0], running: false }))
59 .concat((running ?? []).filter((x) => x.status === "running" && x.since).map((x) => ({ type: x.type, a: x.since, b: now, running: true })))
60 .sort((x, y) => y.a - x.a);
61 const out = [];
62 let floor = Infinity;
63 for (const x of iv) {
64 if (out.length && x.b < floor - gap) break;
65 out.push(x); floor = Math.min(floor, x.a);
66 if (out.length >= max) break;
67 }
68 return out.reverse();
69}
70
71// Time per phase and stage, the last `last` phases by when they last ran. -> [{ phase, total, planning, executing, checking, other }]
72export function phaseTotals(runs, last = 8) {
73 const by = new Map();
74 for (const r of runs ?? []) {
75 const key = r[2] || "?";
76 const p = by.get(key) ?? { phase: key, end: 0, total: 0, planning: 0, executing: 0, checking: 0, other: 0 };
77 p[stageOf(r[1])] += r[3]; p.total += r[3]; p.end = Math.max(p.end, r[0]);
78 by.set(key, p);
79 }
80 return [...by.values()].sort((a, b) => a.end - b.end).slice(-last);
81}
82
83// Agent time per local day for the last `days` days, oldest first. A run counts on the day it ended.
84export function dailyBuckets(runs, now, days = 14) {
85 const today = new Date(now); today.setHours(0, 0, 0, 0);
86 const out = Array.from({ length: days }, (_, i) => ({ start: new Date(today.getFullYear(), today.getMonth(), today.getDate() - (days - 1 - i)).getTime(), ms: 0 }));
87 for (const r of runs ?? []) { const b = [...out].reverse().find((d) => r[0] >= d.start); if (b && r[0] < b.start + 864e5) b.ms += r[3]; }
88 return out;
89}
90
91// One block per value, tall for large, "·" for zero: a sparkline.
92export function spark(values) {
93 const max = Math.max(0, ...values), ticks = "▁▂▃▄▅▆▇█";
94 return values.map((v) => (v <= 0 ? "·" : ticks[Math.min(7, Math.floor((v / max) * 7.999))])).join("");
95}
96
97export const dur = (ms) => {
98 if (ms < 60000) return `${Math.round(ms / 1000)}s`;
99 const m = Math.round(ms / 60000);
100 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}`;
101};
102hooks/panel.mjs 518 lines1// Pure: what the GSD pane draws, as data. No $ access, so it is testable under plain Node.
2// A segment is [text, color, flags]: color is a Claude Code theme token or null, flags "b" for bold, "d" for dim.
3import { typical, phaseTotals, dailyBuckets, spark, dur, stageOf, forecast, burst } from "./history.mjs";
4import { frontmatter, summarize, items } from "./state-line.mjs";
5
6// Theme tokens, so the pane follows the person's theme (light, dark, colour-blind) the way the engine's own UI does.
7export const C = { main: "claude", agent: "suggestion", ok: "success", arch: "merged", amber: "warning", warn: "error", dim: "inactive", faint: "subtle" };
8
9export const kTokens = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(Math.round(n)));
10export const fmtUsd = (x) => `$${x >= 100 ? Math.round(x) : x.toFixed(2)}`;
11export const limitLabel = (kind) => String(kind).replace(/five[_ -]?hours?/i, "5h").replace(/seven[_ -]?days?/i, "7d").replace(/[_-]+/g, " ").trim();
12export const gauge = (pct, width) => {
13 const full = Math.max(0, Math.min(width, Math.round((pct / 100) * width)));
14 return { on: "▰".repeat(full), off: "▱".repeat(width - full) };
15};
16export const clock = (ms) => `${Math.floor(ms / 60000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, "0")}`;
17export const cut = (s, w) => (s.length <= w ? s : s.slice(0, Math.max(1, w - 1)) + "…");
18const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
19const listLen = (v) => (v == null || v === "" ? 0 : Array.isArray(v) ? v.length : 1);
20
21// Commits in the reflog text (.git/logs/HEAD), and the newest ones first: { hash, msg }.
22const COMMIT = /^commit(?: \([^)]*\))?: (.*)$/;
23export function commitCount(reflogText) {
24 return String(reflogText ?? "").split(/\r?\n/).filter((l) => COMMIT.test(l.split("\t")[1] ?? "")).length;
25}
26export function commitFeed(reflogText, n = 5) {
27 const out = [];
28 const lines = String(reflogText ?? "").split(/\r?\n/).filter(Boolean);
29 for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
30 const [head, msg = ""] = lines[i].split("\t");
31 const m = COMMIT.exec(msg);
32 if (m) out.push({ hash: (head.split(" ")[1] ?? "").slice(0, 7), msg: m[1] });
33 }
34 return out;
35}
36
37// A directory of the project's work (spikes/, threads/, quick/, ...) -> how many items it holds and the newest one.
38// Sub-directories count when there are any (a spike is a folder), else files. The newest is the latest by modified
39// time when the entries have one, else the last by name with numbers in order (247 after 99).
40export function streamInfo(entries) {
41 const live = (entries ?? []).filter((e) => !e.name.startsWith("."));
42 const dirs = live.filter((e) => e.kind === "dir" || (e.isLink && e.kind !== "file"));
43 const kept = dirs.length ? dirs : live.filter((e) => e.kind === "file" && !/^[A-Z][A-Z0-9_-]*\.md$/.test(e.name));
44 if (!kept.length) return null;
45 const timed = kept.filter((e) => e.mtimeMs > 0);
46 const byNewest = timed.length
47 ? [...kept].sort((a, b) => b.mtimeMs - a.mtimeMs)
48 : [...kept].sort((a, b) => b.name.localeCompare(a.name, undefined, { numeric: true }));
49 const names = byNewest.map((e) => e.name.replace(/\.md$/, ""));
50 return { count: kept.length, newest: names[0], recent: names.slice(0, 5), ...(kept.some((e) => e.from) ? { paths: byNewest.slice(0, 5).map((e) => `${e.from}/${e.name}`) } : {}) };
51}
52
53// A file path for the log: from ".planning/" on when it is under it, else the last two parts.
54export function shortPath(p) {
55 const s = String(p ?? "");
56 const i = s.indexOf(".planning/");
57 if (i >= 0) return s.slice(i);
58 const parts = s.split("/").filter(Boolean);
59 return parts.slice(-2).join("/");
60}
61
62// A line is segments, or a segment list that is also a control: { segs, toggle: { key, open, hotkey } } draws a
63// ▸/▾ button before its segments; { button: { key, label, color, hotkey } } is one whole-line button.
64export const segsOf = (line) => (Array.isArray(line) ? line : line.segs ?? [[line.button?.label ?? "", line.button?.color ?? null]]);
65
66const hms = (ms) => new Date(ms).toTimeString().slice(0, 8);
67const shortType = (t) => String(t ?? "agent").replace(/^gsd-/, "");
68
69// The log's rows: time, a short kind word, the text.
70const KIND = { prompt: ["you", C.dim], spawn: ["spawn", C.agent], done: ["done", C.ok], fail: ["failed", C.warn], commit: ["commit", C.arch], write: ["wrote", C.main], error: ["error", C.warn], turn: ["turn", C.dim] };
71
72// Agents in tree order: each agent's children (forks, sub-agents) follow it, one level deeper. An agent whose parent is
73// not in the list (the main loop, or a parent that has gone) is a root. Roots show running first, then the latest to
74// finish; children in the order they started.
75export function agentTree(agents) {
76 const list = agents ?? [];
77 const ids = new Set(list.map((a) => a.id));
78 const kids = new Map();
79 const roots = [];
80 for (const a of list) {
81 if (a.parentId && ids.has(a.parentId) && a.parentId !== a.id) kids.set(a.parentId, [...(kids.get(a.parentId) ?? []), a]);
82 else roots.push(a);
83 }
84 const rank = (a) => (a.status === "running" ? 0 : 1);
85 roots.sort((a, b) => rank(a) - rank(b) || (b.endedAt ?? b.since ?? 0) - (a.endedAt ?? a.since ?? 0));
86 const out = [];
87 const seen = new Set();
88 const walk = (a, depth) => {
89 if (seen.has(a.id)) return;
90 seen.add(a.id);
91 out.push({ ...a, depth });
92 for (const k of [...(kids.get(a.id) ?? [])].sort((x, y) => (x.since ?? 0) - (y.since ?? 0))) walk(k, depth + 1);
93 };
94 for (const r of roots) walk(r, 0);
95 for (const a of list) walk(a, 0); // anything a parent cycle left out
96 return out;
97}
98
99// Which of the tree's agents to draw. Running and failed agents always show, with the agents that started them, so
100// the tree stays readable; finished ones are collapsed: the latest `keep` while something runs (a wave should not push
101// the live agents off the box), up to `budget` lines when nothing does. Returns { shown (tree order), hidden }.
102export function visibleAgents(tree, { budget = 8, keep = 2, cap = 12 } = {}) {
103 const byId = new Map(tree.map((a) => [a.id, a]));
104 const need = new Set();
105 for (const a of tree) {
106 if (a.status !== "running" && a.status !== "failed" && a.status !== "killed") continue;
107 for (let n = a, hops = 0; n && !need.has(n.id) && hops < 20; n = byId.get(n.parentId), hops++) need.add(n.id);
108 }
109 const anyLive = tree.some((a) => a.status === "running");
110 const spare = Math.max(0, Math.min(anyLive ? keep : budget, budget - need.size));
111 const finished = tree.filter((a) => !need.has(a.id)).sort((x, y) => (y.endedAt ?? y.since ?? 0) - (x.endedAt ?? x.since ?? 0));
112 for (const a of finished.slice(0, spare)) need.add(a.id);
113 const shown = tree.filter((a) => need.has(a.id)).slice(0, cap);
114 return { shown, hidden: tree.length - shown.length };
115}
116
117// The phase checklist of a ROADMAP.md: "- [x] **Phase 2.1: name** - blurb". Only that one line shape is read, so a
118// hand-edited roadmap yields fewer rows rather than wrong ones. -> [{ id, name, done }]
119export function roadmapPhases(text) {
120 const out = [];
121 for (const m of String(text ?? "").matchAll(/^\s*[-*]\s*\[( |x|X)\]\s*\*\*Phase\s+([\w.]+?):?\s+(.+?)\*\*/gm)) out.push({ id: m[2], name: m[3].trim(), done: m[1] !== " " });
122 return out;
123}
124
125// Which roadmap phase STATE.md says is current: by number, else by the label that leads the phase name. Never a guess.
126function currentIndex(phases, fm) {
127 const num = String(fm.current_phase ?? "").trim();
128 if (num) { const i = phases.findIndex((p) => p.id === num || p.id.replace(/^0+(?=\d)/, "") === num.replace(/^0+(?=\d)/, "")); if (i >= 0) return i; }
129 const lead = String(fm.current_phase_name ?? "").split(/\s/)[0].toLowerCase();
130 if (lead.length > 1) { const hits = phases.map((p, i) => (p.name.toLowerCase().split(/\s/)[0] === lead ? i : -1)).filter((i) => i >= 0); if (hits.length === 1) return hits[0]; }
131 return -1;
132}
133
134// --- pace: where a GSD run's time goes
135
136// Plans of the current phase -> how they are laid out to run. A wave is a set of plans that may run together, so plans
137// left to run in single-plan waves go one after another. plans: [{ id, wave, done }]
138export function planShape(plans) {
139 const byWave = new Map();
140 for (const p of plans ?? []) (byWave.get(p.wave) ?? byWave.set(p.wave, []).get(p.wave)).push(p);
141 const open = (plans ?? []).filter((p) => !p.done);
142 const openWaves = new Map();
143 for (const p of open) openWaves.set(p.wave, (openWaves.get(p.wave) ?? 0) + 1);
144 return {
145 total: (plans ?? []).length, open: open.length, waves: openWaves.size, widest: Math.max(0, ...openWaves.values()),
146 byWave: [...byWave].sort((a, b) => a[0] - b[0]),
147 };
148}
149
150// This session's agents -> share of agent time by type, and how parallel they ran (agent time / wall time, 1.0 = one
151// at a time). agents: [{ type, since, endedAt }]; a running agent counts up to `now`.
152export function agentPace(agents, now) {
153 const iv = (agents ?? []).filter((a) => a.since).map((a) => ({ type: shortType(a.type), a: a.since, b: Math.max(a.since, a.endedAt ?? now) }));
154 const spanOf = (list) => { // wall time covered by the intervals, overlaps counted once
155 let wall = 0, cur = null;
156 for (const x of [...list].sort((p, q) => p.a - q.a)) {
157 if (!cur || x.a > cur.b) { if (cur) wall += cur.b - cur.a; cur = { a: x.a, b: x.b }; } else cur.b = Math.max(cur.b, x.b);
158 }
159 return wall + (cur ? cur.b - cur.a : 0);
160 };
161 const sums = new Map();
162 for (const x of iv) sums.set(x.type, (sums.get(x.type) ?? 0) + (x.b - x.a));
163 const total = [...sums.values()].reduce((m, v) => m + v, 0);
164 const ex = iv.filter((x) => x.type === "executor");
165 const exSum = ex.reduce((m, x) => m + x.b - x.a, 0);
166 const exWall = ex.length ? spanOf(ex) : 0;
167 return {
168 total, byType: [...sums].sort((a, b) => b[1] - a[1]), count: iv.length,
169 executors: ex.length, executorParallel: exWall > 0 ? exSum / exWall : null,
170 };
171}
172
173// --- the markdown reader's pure parts
174
175// The YAML block at the top of a GSD file: the dashboard already shows STATE's fields, so the reader hides it.
176export const stripFrontmatter = (t) => String(t ?? "").replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
177
178// Pages of at most `max` characters (the Markdown element draws at most 10000), cut at a line, never inside a code fence.
179// ponytail: a single fenced block over 9500 characters is cut mid-fence, so show it raw if that ever matters.
180export function pages(text, max = 8000) {
181 const out = [];
182 let cur = "", fence = false;
183 for (const line of String(text ?? "").replace(/\r\n/g, "\n").split("\n")) {
184 if (cur.length + line.length + 1 > max && cur.trim() && (!fence || cur.length > 9500)) { out.push(cur.trimEnd()); cur = ""; }
185 cur += line + "\n";
186 if (/^\s*(```|~~~)/.test(line)) fence = !fence;
187 }
188 if (cur.trim()) out.push(cur.trimEnd());
189 return out.length ? out : [""];
190}
191
192// The file as blocks that each begin at a heading (#, ## or ###), one Markdown element apiece, so the contents list can
193// scroll to a heading by its element's key. -> [{ md, heading?: { level, title } }]
194export function sections(text, max = 8000) {
195 const out = [];
196 let cur = [], fence = false, head = null;
197 const flush = () => {
198 const t = cur.join("\n").trimEnd();
199 if (t.trim()) pages(t, max).forEach((md, j) => out.push({ md, ...(j === 0 && head ? { heading: head } : {}) }));
200 cur = [];
201 };
202 for (const line of String(text ?? "").replace(/\r\n/g, "\n").split("\n")) {
203 if (/^\s*(```|~~~)/.test(line)) fence = !fence;
204 const h = !fence && /^(#{1,3})\s+(.+?)\s*#*\s*$/.exec(line);
205 if (h) { flush(); head = { level: h[1].length, title: h[2].replace(/[*_`]/g, "") }; }
206 cur.push(line);
207 }
208 flush();
209 return out;
210}
211
212// GSD files are full of task lists; the renderer draws "[x]" literally, so give them glyphs.
213const tick = (t) => t.replace(/^(\s*[-*] )\[( |x|X)\] /gm, (_, lead, c) => `${lead}${c === " " ? "○" : "✓"} `);
214
215// One folder of .planning for the browser: sub-folders first, then the .md files, numbers in order (phase 2 before 10).
216export function browseList(entries) {
217 const isDir = (e) => e.kind === "dir" || (e.isLink && e.kind !== "file");
218 return (entries ?? [])
219 .filter((e) => !e.name.startsWith(".") && (isDir(e) || /\.md$/i.test(e.name)))
220 .map((e) => ({ name: e.name, dir: isDir(e) }))
221 .sort((a, b) => b.dir - a.dir || a.name.localeCompare(b.name, undefined, { numeric: true }));
222}
223
224// The reader view: replaces the dashboard. rd: { path, isFile, entries?, text? }. A line is a button or { md }.
225function readerModel(rd, header, inner, ex = {}) {
226 const crumb = String(rd.path).replace(/^\.planning\/?/, "") || ".planning";
227 const lines = [{ button: { key: "reader:up", label: rd.path === ".planning" ? "‹ back to the dashboard" : "‹ back", color: C.dim, hotkey: "b" } }];
228 if (rd.path !== ".planning") lines.push({ button: { key: "reader:close", label: "⌂ dashboard", color: C.dim, hotkey: "d" } });
229 let right = null;
230 if (rd.isFile) {
231 // The engine refuses the WHOLE pane if one Markdown block passes 10000 characters, so a block is cut at 9900 as a last resort.
232 const secs = sections(tick(stripFrontmatter(rd.text)));
233 const heads = secs.map((x, i) => ({ i, ...x.heading })).filter((x) => x.title).slice(0, 30);
234 if (heads.length >= 3) {
235 lines.push({ button: { key: "reader:toc", label: `${ex.toc ? "▾" : "▸"} contents (${heads.length})`, color: C.dim, hotkey: "c" } });
236 if (ex.toc) for (const x of heads) lines.push({ button: { key: `reader:goto:${x.i}`, label: cut(`${" ".repeat(x.level - 1)}${x.title}`, inner - 2), color: x.level === 1 ? C.arch : null } });
237 }
238 secs.forEach((x, i) => lines.push({ md: x.md.slice(0, 9900), key: `sec${i}` }));
239 lines.push({ button: { key: "reader:top", label: "↑ back to top", color: C.dim, hotkey: "t" } });
240 } else {
241 const list = browseList(rd.entries);
242 if (!list.length) lines.push([["no markdown here", C.dim]]);
243 for (const e of list.slice(0, 40)) lines.push({ button: { key: `reader:open:${e.name}`, label: `${e.dir ? "▸" : " "} ${e.name}${e.dir ? "/" : ""}`, color: e.dir ? C.arch : null } });
244 if (list.length > 40) lines.push([[`+${list.length - 40} more`, C.dim]]);
245 right = [[`${list.length}`, C.dim]];
246 }
247 return { header, panels: [{ id: "reader", color: C.arch, title: [[cut(crumb, inner - 8), C.arch, "b"]], right, lines }] };
248}
249
250// The trends view: replaces the dashboard. Three charts from the recorded runs, each labelled with its numbers.
251function trendsModel(inp, header, inner, now) {
252 const runs = inp.history ?? [];
253 const lines = [{ button: { key: "trends", label: "‹ back", color: C.dim, hotkey: "b" } }];
254 const ex = runs.filter((r) => shortType(r[1]) === "executor").slice(-30).map((r) => r[3]);
255 lines.push([["executor time per run", null, "b"], [` last ${ex.length}`, C.dim]]);
256 if (ex.length < 3) lines.push([["not enough executor runs yet (3 needed)", C.dim]]);
257 else {
258 const o = [...ex].sort((a, b) => a - b);
259 lines.push([[spark(ex), C.main]]);
260 lines.push([[`min ${dur(o[0])} · median ${dur(o[(o.length - 1) >> 1])} · max ${dur(o[o.length - 1])} · last ${dur(ex[ex.length - 1])}`, C.dim]]);
261 }
262 lines.push([[" ", null]]);
263 const ph = phaseTotals(runs);
264 lines.push([["time by phase and stage", null, "b"]]);
265 lines.push([["█ ", C.agent], ["planning ", C.dim], ["█ ", C.main], ["executing ", C.dim], ["█ ", C.ok], ["checking ", C.dim], ["█ ", C.faint], ["other", C.dim]]);
266 if (!ph.length) lines.push([["no runs recorded yet", C.dim]]);
267 const top = Math.max(1, ...ph.map((x) => x.total)), width = Math.max(8, inner - 14);
268 for (const x of ph) {
269 const bar = [["planning", C.agent], ["executing", C.main], ["checking", C.ok], ["other", C.faint]].filter(([k]) => x[k] > 0).map(([k, c]) => ["█".repeat(Math.max(1, Math.round((x[k] / top) * width))), c]);
270 lines.push([[`ph ${x.phase}`.padEnd(6), C.dim], ...bar, [` ${dur(x.total)}`, null]]);
271 }
272 lines.push([[" ", null]]);
273 const days = dailyBuckets(runs, now, 14);
274 const day = (ms) => new Date(ms).toLocaleDateString("en", { month: "short", day: "numeric" });
275 const peak = days.reduce((m, d) => (d.ms > m.ms ? d : m), days[0]);
276 lines.push([["agent time per day", null, "b"], [" last 14 days", C.dim]]);
277 lines.push([[spark(days.map((d) => d.ms)), C.agent]]);
278 lines.push([[`${day(days[0].start)} to today` + (peak.ms > 0 ? ` · peak ${dur(peak.ms)} on ${day(peak.start)}` : ""), C.dim]]);
279 lines.push([[" ", null]]);
280 lines.push([[cut("phase = STATE.md's current_phase when the agent started", inner), C.dim]]);
281 return { header, panels: [{ id: "trends", color: C.amber, title: [["trends", C.amber, "b"]], right: [[`${runs.length} agents recorded`, C.dim]], lines }] };
282}
283
284// The timeline view: replaces the dashboard. One bar per agent over the latest working stretch, so agents that ran
285// together sit on top of each other and agents that ran one after another form a staircase.
286function timelineModel(inp, header, inner, now) {
287 const rows = burst(inp.history, inp.agents, now);
288 const lines = [{ button: { key: "timeline", label: "‹ back", color: C.dim, hotkey: "b" } }];
289 if (!rows.length) lines.push([["no agents recorded yet", C.dim]]);
290 else {
291 const t0 = Math.min(...rows.map((x) => x.a)), t1 = Math.max(...rows.map((x) => x.b));
292 const span = Math.max(t1 - t0, 60000), label = 9, width = Math.max(8, inner - label - 7);
293 const col = (t) => Math.min(width - 1, Math.floor(((t - t0) / span) * width));
294 const hue = { planning: C.agent, executing: C.main, checking: C.ok, other: C.faint };
295 lines.push([[" ".repeat(label), null], [hms(t0), C.dim], [" ".repeat(Math.max(1, width - 16)), null], [hms(t1), C.dim]]);
296 for (const x of rows) {
297 const c0 = col(x.a), n = Math.max(1, col(x.b) - c0 + (x.b >= t1 ? 1 : 0));
298 lines.push([[shortType(x.type).replace(/-purpose$/, "").slice(0, label - 1).padEnd(label), C.dim], [" ".repeat(c0), null], [(x.running ? "▒" : "█").repeat(Math.min(n, width - c0)), hue[stageOf(x.type)]], [` ${dur(x.b - x.a)}`, C.dim]]);
299 }
300 lines.push([[" ", null]]);
301 const pace = agentPace(rows.map((x) => ({ type: x.type, since: x.a, endedAt: x.b })), t1);
302 const ex = pace.executors >= 2 && pace.executorParallel != null;
303 lines.push([[`${plural(rows.length, "agent")} · ${dur(t1 - t0)} wall`, C.dim], ...(ex ? [[` · executors ×${pace.executorParallel.toFixed(1)}`, pace.executorParallel < 1.15 ? C.amber : C.ok], [pace.executorParallel < 1.15 ? " one at a time" : " overlapped", C.dim]] : [])]);
304 lines.push([["█ ", C.agent], ["planning ", C.dim], ["█ ", C.main], ["executing ", C.dim], ["█ ", C.ok], ["checking ", C.dim], ["▒ ", C.dim], ["running", C.dim]]);
305 }
306 return { header, panels: [{ id: "timeline", color: C.agent, title: [["timeline", C.agent, "b"]], right: [["latest working stretch", C.dim]], lines }] };
307}
308
309// in: {
310// history: [[endedAt, type, phase, durMs, turns, inTok, outTok, model]], // finished agents of this project
311// plans: [{ id, wave, done }], // the current phase's plans
312// reader: { path, isFile, entries, text } | null, // the markdown reader, when open
313// roadmap: ROADMAP.md text | null,
314// state: STATE.md text | null,
315// usage: { pct, tokens, window, costUsd, limits: [{kind, pct}] } | null,
316// isRunning: boolean, // a turn is running now
317// turn: { startedAt, edits, errors } | null, // the running turn
318// receipt: { durationMs, edits, errors, commits, costDelta } | null, // the last finished turn
319// agents: [{ id, type, description, status, since, endedAt }],
320// streams: [{ name, count, newest }],
321// handoff: object | null,
322// log: [{ at, kind, text }],
323// now: ms }
324// -> { header: segments, panels: [{ id, color, title: segments, right: segments|null, lines: [segments] }] }
325export function panelModel(inp, width = 62) {
326 const ex = { agents: new Set(), finished: false, blockers: false, log: false, roadmap: false, pace: false, trends: false, timeline: false, toc: false, ...(inp.expand ?? {}) };
327 const w = Math.max(36, width);
328 const inner = w - 4; // the border and one column of padding on each side
329 const fm = frontmatter(inp.state ?? "") ?? {};
330 const panels = [];
331 const now = inp.now ?? 0;
332
333 const phase = fm.current_phase ? `phase ${fm.current_phase}${fm.current_phase_name ? " " + fm.current_phase_name : ""}` : fm.milestone ?? "";
334 const ws = inp.workstream ?? null;
335 const header = [["GSD", C.main, "b"], ...(ws ? [[" · ", C.dim], [cut(ws.name, 24), C.amber]] : []), ...(phase ? [[" · ", C.dim], [cut(phase, w - 22), null, "b"]] : []), ...(fm.status ? [[` · ${fm.status}`, C.dim]] : [])];
336
337 if (inp.reader) return readerModel(inp.reader, header, inner, ex);
338 if (ex.trends) return trendsModel(inp, header, inner, now);
339 if (ex.timeline) return timelineModel(inp, header, inner, now);
340
341 // main: the session's vitals, and anything that needs a person
342 const main = [];
343 const u = inp.usage;
344 if (u && u.pct != null) {
345 const g = gauge(u.pct, 10);
346 const hot = u.pct >= 80 ? C.warn : u.pct >= 60 ? C.amber : C.main;
347 main.push([["ctx ", C.dim], [g.on, hot], [g.off, C.faint], [` ${Math.round(u.pct)}%`, null, "b"], ...(u.tokens != null && u.window ? [[` ${kTokens(u.tokens)}/${kTokens(u.window)}`, C.dim]] : [])]);
348 }
349 if (u && (u.costUsd != null || (u.limits ?? []).length)) {
350 const row = [];
351 if (u.costUsd != null) row.push([`${fmtUsd(u.costUsd)} `, null]);
352 for (const l of (u.limits ?? []).slice(0, 2)) {
353 const g = gauge(l.pct, 5);
354 row.push([`${limitLabel(l.kind)} `, C.dim], [g.on, l.pct >= 80 ? C.warn : C.main], [g.off, C.faint], [` ${Math.round(l.pct)}% `, C.dim]);
355 }
356 main.push(row);
357 }
358 if (ws && ws.total > 1) main.push({ button: { key: "ws", label: `⇄ workstream ${cut(ws.name, inner - 26)} · ${ws.index} of ${ws.total}`, color: C.amber, hotkey: "w" } });
359 const h = inp.handoff;
360 const blockers = h ? listLen(h.blockers) : 0;
361 const people = h ? listLen(h.human_actions_pending) : 0;
362 if (blockers || people) {
363 const label = `${ex.blockers ? "▾" : "▸"} ${[blockers ? `⚠ ${plural(blockers, "blocker")}` : "", people ? `${people} need${people === 1 ? "s" : ""} a person` : ""].filter(Boolean).join(" · ")}`;
364 main.push({ button: { key: "blockers", label, color: blockers ? C.warn : C.amber, hotkey: "b" } });
365 if (ex.blockers) {
366 for (const t of items(h.blockers)) main.push([[" - ", C.warn], [cut(t, inner - 4), null]]);
367 for (const t of items(h.human_actions_pending)) main.push([[" - ", C.amber], [cut(t, inner - 4), null]]);
368 }
369 }
370 main.push({ button: { key: "reader:browse", label: "▸ read .planning", color: C.arch, hotkey: "o" } });
371 if (fm.last_activity_desc && !(u && u.pct != null)) main.push([[cut(summarize(fm.last_activity_desc, inner), inner), C.dim]]);
372 panels.push({
373 id: "main", color: C.main,
374 title: [[cut(phase || "GSD project", inner - 12), C.main, "b"]],
375 right: [inp.isRunning ? ["● working", C.main] : (inp.agents ?? []).some((a) => a.status === "running") ? ["◐ agents working", C.agent] : ["○ idle", C.dim]],
376 lines: main,
377 });
378
379 // roadmap: the phases, with the current one marked. Finished phases and far-off ones fold into one button.
380 const road = roadmapPhases(inp.roadmap);
381 if (road.length) {
382 const cur = currentIndex(road, fm);
383 const doneN = road.filter((p) => p.done).length;
384 const first = cur >= 0 ? cur : Math.max(0, road.findIndex((p) => !p.done));
385 const WINDOW = 6;
386 const from = ex.roadmap ? 0 : first;
387 const to = ex.roadmap ? road.length : Math.min(road.length, first + WINDOW);
388 const lines = [];
389 const before = ex.roadmap ? 0 : from, after = ex.roadmap ? 0 : road.length - to;
390 if (!ex.roadmap && (before || after)) lines.push({ button: { key: "roadmap", label: `▸ ${[before ? `${before} before` : "", after ? `${after} later` : ""].filter(Boolean).join(" · ")}`, color: C.dim, hotkey: "r" } });
391 road.slice(from, to).forEach((p, i) => {
392 const here = from + i === cur;
393 const glyph = p.done ? "✓" : here ? "▶" : "·";
394 const id = p.id.padEnd(4);
395 lines.push([[`${glyph} `, p.done ? C.ok : here ? C.main : C.faint], [id + " ", here ? C.main : C.dim, here ? "b" : undefined], [cut(p.name, inner - 2 - id.length - 1), p.done ? C.dim : null, here ? "b" : undefined]]);
396 });
397 if (ex.roadmap) lines.push({ button: { key: "roadmap", label: "▾ collapse", color: C.dim, hotkey: "r" } });
398 const sm = String(inp.state ?? "");
399 const pn = Number(/^\s*completed_plans:\s*(\d+)/m.exec(sm)?.[1]), pt = Number(/^\s*total_plans:\s*(\d+)/m.exec(sm)?.[1]);
400 panels.push({ id: "roadmap", color: C.ok, title: [["roadmap", C.ok, "b"]],
401 right: [[`${doneN}/${road.length} phases${pt ? ` · ${pn}/${pt} plans` : ""}`, C.dim]], lines });
402 }
403
404 // pace: how the plans are laid out, and where this session's agent time went. The hint shows only on clear evidence.
405 const shape = planShape(inp.plans);
406 const pace = agentPace(inp.agents, now);
407 const showTime = pace.total >= 60000;
408 const hist = inp.history ?? [];
409 if (shape.total || showTime || hist.length) {
410 const lines = [];
411 const serialPlans = shape.open >= 2 && shape.widest === 1;
412 const serialRun = pace.executors >= 2 && pace.executorParallel != null && pace.executorParallel < 1.15 && pace.total >= 120000;
413 if (shape.total) lines.push([["plans ", C.dim], [`${shape.open} to run of ${shape.total} · ${plural(shape.waves, "wave")} · widest ${shape.widest}`, null]]);
414 if (serialPlans) lines.push([["serial: each plan waits for the one before it", C.amber]]);
415 if (showTime) {
416 lines.push([["time ", C.dim], [pace.byType.slice(0, 3).map(([t, ms]) => `${t} ${Math.round((100 * ms) / pace.total)}%`).join(" · "), null]]);
417 if (serialRun) lines.push([["serial: executors ran one at a time", C.amber], [` ×${pace.executorParallel.toFixed(1)}`, C.dim]]);
418 }
419 const fc = forecast(shape, hist, inp.agents, now, u?.limits);
420 if (fc) {
421 const hue = fc.level === "warn" ? C.warn : fc.level === "amber" ? C.amber : null;
422 lines.push([["left ", C.dim], [`≈ ${dur(fc.ms)} of executing`, null], [` · ${plural(fc.waves, "wave")} · from ${fc.runs} runs`, C.dim]]);
423 if (fc.pct != null) lines.push([["5h ", C.dim], [`${Math.round(fc.pct)}%`, null, "b"], [fc.resetMs != null ? ` · resets in ${dur(Math.max(0, fc.resetMs))}` : "", C.dim]]);
424 if (fc.quota) lines.push([["quota ", C.dim], [`+${Math.round(fc.quota.add)}% (${Math.round(fc.quota.lo)}–${Math.round(fc.quota.hi)}) → ${Math.round(fc.quota.end)}%`, hue, hue ? "b" : undefined], [` · ${fc.quota.n} runs`, C.dim]]);
425 if (fc.quota && fc.nextN >= 2) lines.push([["next ", C.dim], [`wave of ${fc.nextN} together`, null], [` +${Math.round(fc.quota.next.add)}% (${Math.round(fc.quota.next.lo)}–${Math.round(fc.quota.next.hi)}) → ${Math.round(fc.quota.next.end)}%`, C.dim]]);
426 if (!fc.quota && fc.level) lines.push([["longer than the window has left", hue]]);
427 }
428 lines.push({ button: { key: "pace", label: `${ex.pace ? "▾ hide" : "▸"} details`, color: C.dim, hotkey: "p" } });
429 if (ex.pace) {
430 for (const [wave, list] of shape.byWave) lines.push([[` wave ${wave} `, C.dim], [list.map((p) => `${p.id} ${p.done ? "✓" : "○"}`).join(" "), null]]);
431 const wid = Math.max(...pace.byType.map(([t]) => t.length)) + 2;
432 for (const [t, ms] of pace.byType) lines.push([[` ${t.padEnd(wid)}`, C.dim], [clock(ms), null], [` ${Math.round((100 * ms) / pace.total)}%`, C.dim]]);
433 const here = phaseTotals(hist.filter((r) => String(r[2]) === String(fm.current_phase ?? "")), 1)[0];
434 if (here) lines.push([[" this phase ", C.dim], [["planning", "executing", "checking"].filter((k) => here[k] > 0).map((k) => `${k} ${dur(here[k])}`).join(" · "), null]]);
435 if (pace.executors >= 2 && pace.executorParallel != null) lines.push([[" executors in parallel ", C.dim], [`×${pace.executorParallel.toFixed(2)}`, null]]);
436 }
437 if (hist.length) lines.push({ button: { key: "trends", label: "▸ trends", color: C.dim, hotkey: "t" } });
438 if (hist.length || (inp.agents ?? []).some((a) => a.status === "running")) lines.push({ button: { key: "timeline", label: "▸ timeline", color: C.dim, hotkey: "g" } });
439 panels.push({ id: "pace", color: C.amber, title: [["pace", C.amber, "b"]], right: shape.total && serialPlans ? [["serial", C.amber]] : null, lines });
440 }
441
442 // agents: a tree, so a fork or a sub-agent sits under the agent that started it
443 const tree = agentTree(inp.agents);
444 if (tree.length) {
445 const running = tree.filter((a) => a.status === "running").length;
446 const forks = tree.filter((a) => a.isFork).length;
447 const base = visibleAgents(tree);
448 const { shown, hidden } = ex.finished ? visibleAgents(tree, { budget: 12, keep: 12 }) : base;
449 const lines = [];
450 shown.forEach((a, i) => {
451 const isRun = a.status === "running";
452 const bad = a.status === "failed" || a.status === "killed";
453 const glyph = isRun ? "◐" : bad ? "✗" : "✓";
454 const t = isRun ? now - (a.since ?? now) : (a.endedAt ?? now) - (a.since ?? now);
455 const right = a.since ? clock(Math.max(0, t)) : "";
456 const typ = isRun && a.since ? typical(inp.history, a.type) : null; // what this kind of agent usually takes here
457 const typTxt = typ ? ` · typ ${clock(typ)}` : "";
458 const indent = a.depth ? " ".repeat(Math.min(a.depth, 3) - 1) + "└ " : "";
459 const label = `${a.isFork ? "⑂ " : ""}${shortType(a.type)} `;
460 const room = Math.max(4, inner - 8 - indent.length - label.length - right.length - typTxt.length - 1);
461 const open = ex.agents.has(a.id);
462 lines.push({
463 toggle: { key: `agent:${a.id}`, open, hotkey: i < 6 ? String(i + 1) : undefined },
464 segs: [[`${glyph} `, isRun ? C.agent : bad ? C.warn : C.ok], [indent, C.faint], [label, null, "b"], [cut(String(a.description ?? "").replace(/\s+/g, " "), room).padEnd(room + 1), C.dim], [right, isRun ? C.agent : C.dim], ...(typTxt ? [[typTxt, t > 2 * typ ? C.amber : C.dim]] : [])],
465 });
466 if (open) {
467 const full = String(a.description ?? "").replace(/\s+/g, " ").trim();
468 for (const part of [full.slice(0, inner - 4), full.slice(inner - 4, 2 * (inner - 4))].filter(Boolean)) lines.push([[" ", null], [part, null]]);
469 const parent = a.parentId ? tree.find((x) => x.id === a.parentId) : null;
470 lines.push([[" ", null], [`id ${String(a.id).slice(0, 8)} · ${a.type}${a.isFork ? " · fork" : ""}${a.parentId ? ` · under ${parent ? shortType(parent.type) : String(a.parentId).slice(0, 8)}` : ""} · ${a.status}`, C.dim]]);
471 }
472 });
473 const hiddenLive = tree.filter((a) => a.status === "running" && !shown.includes(a)).length;
474 if (hiddenLive) lines.push([[`+${hiddenLive} running, ${hidden - hiddenLive} finished not shown`, C.agent]]);
475 else if (!ex.finished && hidden) lines.push({ button: { key: "finished", label: `▸ ${hidden} more finished`, color: C.ok, hotkey: "f" } });
476 else if (ex.finished && base.hidden) lines.push({ button: { key: "finished", label: "▾ collapse finished", color: C.dim, hotkey: "f" } });
477 const depth = Math.max(...tree.map((a) => a.depth));
478 panels.push({ id: "agents", color: C.agent, title: [[`agents · ${running} running`, C.agent, "b"]],
479 right: [[`${tree.length} total${forks ? ` · ${forks} fork${forks === 1 ? "" : "s"}` : ""}${depth ? ` · ${depth + 1} deep` : ""}`, C.dim]], lines });
480 }
481
482 // turn: the one running now, else the last receipt
483 if (inp.isRunning && inp.turn) {
484 panels.push({ id: "turn", color: C.main, title: [["turn", C.main, "b"]], right: [[clock(Math.max(0, now - inp.turn.startedAt)), C.main]],
485 lines: [[[`${plural(inp.turn.edits, "edit")} · ${plural(inp.turn.errors, "error")}`, C.dim]]] });
486 } else if (inp.receipt) {
487 const r = inp.receipt;
488 const bits = [`${Math.round(r.durationMs / 1000)}s`, plural(r.edits, "edit"), plural(r.commits, "commit"), plural(r.errors, "error")];
489 if (r.costDelta != null && r.costDelta > 0) bits.push(`+${fmtUsd(r.costDelta)}`);
490 panels.push({ id: "turn", color: C.faint, title: [["last turn", C.dim, "b"]], right: [["✓", C.ok]], lines: [[[bits.join(" · "), C.dim]]] });
491 }
492
493 // streams: the kinds of work this project holds, and the newest of each
494 if ((inp.streams ?? []).length) {
495 const lines = [];
496 for (const st of inp.streams) {
497 const open = ex.streams?.has?.(st.name);
498 lines.push({ toggle: { key: `stream:${st.name}`, open }, segs: [[st.name.padEnd(8), C.dim], [String(st.count).padStart(4) + " ", null, "b"], [cut(st.newest, inner - 16), C.dim]] });
499 if (open) (st.recent ?? [st.newest]).slice(0, 5).forEach((n, i) => lines.push(st.paths?.[i] ? { button: { key: `reader:open-path:${st.paths[i]}`, label: ` ${cut(n, inner - 8)}`, color: C.dim } } : [[" ", null], [cut(n, inner - 8), C.dim]]));
500 }
501 panels.push({ id: "streams", color: C.arch, title: [["work streams", C.arch, "b"]], right: null, lines });
502 }
503
504 // log: what happened, newest last; the button shows earlier entries
505 const all = inp.log ?? [];
506 const log = ex.log ? all.slice(-24) : all.slice(-8);
507 if (log.length) {
508 const lines = log.map((l) => {
509 const [word, color] = KIND[l.kind] ?? [l.kind, C.dim];
510 return [[`${hms(l.at)} `, C.dim], [word.padEnd(7), color, "b"], [cut(l.text, inner - 17), null]];
511 });
512 if (!ex.log && all.length > 8) lines.unshift({ button: { key: "log", label: `▸ ${Math.min(all.length, 24) - 8} earlier`, color: C.dim, hotkey: "l" } });
513 else if (ex.log && all.length > 8) lines.unshift({ button: { key: "log", label: "▾ show less", color: C.dim, hotkey: "l" } });
514 panels.push({ id: "log", color: C.faint, title: [["session log", C.dim, "b"]], right: null, lines });
515 }
516 return { header, panels };
517}
518