buildline 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/features.js +16 -6
- package/src/install.js +3 -0
- package/src/tui.js +33 -17
- package/src/ui/index.html +11 -5
- package/templates/bin/hook.mjs +85 -10
package/README.md
CHANGED
|
@@ -134,10 +134,10 @@ Start at 2. Raise it as your gates and acceptance scenarios earn trust. [More on
|
|
|
134
134
|
|
|
135
135
|
<img alt="buildline watch: features on tracks through spec, design, plan, build, verify, review and ship, with an inspector and live event log" src="docs/assets/watch.png">
|
|
136
136
|
|
|
137
|
-
- a **comet** on the track means a Claude Code session is working on it right now, with its latest step; **NEEDS YOU** and **BLOCKED** mean an agent is waiting on your answer
|
|
137
|
+
- a **comet** on the track means a Claude Code session is working on it right now, with its latest step; **NEEDS YOU** and **BLOCKED** mean an agent is waiting on your answer, permission or reply; **BACKGROUND** means it's waiting on something else, such as CI or a background agent, and will carry on by itself
|
|
138
138
|
- run several sessions at once, one per feature: each gets its own track
|
|
139
139
|
- the model under each station is the one that runs it
|
|
140
|
-
- `↑↓` to inspect tasks and history, `⏎` to open the station on its model, `a` to approve, `q` to quit
|
|
140
|
+
- `↑↓` to inspect tasks and history, `⏎` to open the station on its model, `a` to approve, `e` to hide the event log, `q` to quit
|
|
141
141
|
- a **chime** when a feature starts waiting on you or ships, so you can look away; `m` mutes it, `npx buildline sound off` turns it off for good (the dashboard chimes too)
|
|
142
142
|
|
|
143
143
|
## The dashboard
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buildline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Install an AI software factory into any project. A spec-driven pipeline for Claude Code (and any AGENTS.md-compatible agent), for developers, designers and product managers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/features.js
CHANGED
|
@@ -181,8 +181,11 @@ export function readSessions(root, at = Date.now()) {
|
|
|
181
181
|
try {
|
|
182
182
|
const s = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
|
|
183
183
|
const age = at - new Date(s.lastAt);
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
// How long it has been in this state, and since the session started.
|
|
185
|
+
const timing = { for: at - new Date(s.since || s.lastAt), total: at - new Date(s.startedAt || s.lastAt) };
|
|
186
|
+
// A long command (tests, a deploy watch) keeps a session working with no new events.
|
|
187
|
+
if (s.state === 'working' && age < (s.inTool ? WAITING_FOR : WORKING_FOR)) out.push({ ...s, age, ...timing });
|
|
188
|
+
else if ((s.state === 'waiting' || s.state === 'background') && age < WAITING_FOR) out.push({ ...s, age, ...timing });
|
|
186
189
|
} catch {}
|
|
187
190
|
}
|
|
188
191
|
return Object.assign(out.sort((a, b) => a.age - b.age), { tracked: true });
|
|
@@ -205,18 +208,25 @@ export function readActivity(root, limit = 200) {
|
|
|
205
208
|
}
|
|
206
209
|
|
|
207
210
|
/**
|
|
208
|
-
* What's happening on a feature right now: live sessions on it, whether one
|
|
209
|
-
*
|
|
211
|
+
* What's happening on a feature right now: live sessions on it, and whether one
|
|
212
|
+
* waits on a person (that wins), one is working, or one finished its turn with
|
|
213
|
+
* work still running in the background (a CI watch, a background agent). Where no hooks report (agents other than
|
|
210
214
|
* Claude Code), a feature whose files changed in the last two minutes counts as worked on.
|
|
211
215
|
*/
|
|
212
216
|
export function liveState(feature, sessions, at = Date.now()) {
|
|
213
|
-
|
|
217
|
+
// A finished feature has nothing live, even if an old session is still tied to it.
|
|
218
|
+
const mine = feature.status === 'done' ? [] : sessions.filter((s) => s.feature === feature.id);
|
|
214
219
|
const working = mine.filter((s) => s.state === 'working');
|
|
215
220
|
const recent = at - new Date(feature.updated || 0) < 2 * 60e3;
|
|
221
|
+
const waiting = mine.find((s) => s.state === 'waiting');
|
|
216
222
|
return {
|
|
217
223
|
sessions: mine,
|
|
218
224
|
working: working.length > 0 || (!sessions.tracked && recent && !feature.blocked && feature.status !== 'done'),
|
|
219
|
-
waiting:
|
|
225
|
+
waiting: Boolean(waiting),
|
|
226
|
+
// why: 'reply' | 'question' | 'permission', and what the agent said or asked
|
|
227
|
+
waitingFor: waiting ? { why: waiting.waitingFor || 'reply', said: waiting.said || null, for: waiting.for } : null,
|
|
228
|
+
background: !working.length && !waiting ? mine.filter((s) => s.state === 'background').flatMap((s) => s.background || []) : [],
|
|
229
|
+
backgroundFor: Math.max(0, ...mine.filter((s) => s.state === 'background').map((s) => s.for || 0)),
|
|
220
230
|
};
|
|
221
231
|
}
|
|
222
232
|
|
package/src/install.js
CHANGED
|
@@ -182,6 +182,9 @@ export function mergeSettings(root, config) {
|
|
|
182
182
|
ensureHook('SessionStart', null, 'session-start');
|
|
183
183
|
ensureHook('PreToolUse', null, 'pre');
|
|
184
184
|
ensureHook('UserPromptSubmit', null, 'prompt');
|
|
185
|
+
ensureHook('PostToolUse', null, 'post');
|
|
186
|
+
// Claude asked for permission or input mid-turn: the session waits on a person.
|
|
187
|
+
ensureHook('Notification', 'permission_prompt|elicitation_dialog', 'notify');
|
|
185
188
|
ensureHook('Stop', null, 'stop');
|
|
186
189
|
ensureHook('SessionEnd', null, 'session-end');
|
|
187
190
|
|
package/src/tui.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// `buildline watch`: a live, full-screen view of the line in the terminal.
|
|
2
2
|
// Every feature is a track running through the stations; the dot shows where it
|
|
3
|
-
// is, a moving comet means
|
|
3
|
+
// is, a moving comet means a session is working on it, a turning clock means work
|
|
4
|
+
// runs in the background (CI, a background agent), a blinking dot means it waits
|
|
4
5
|
// on a person. Zero dependencies beyond picocolors: raw ANSI on a cell canvas.
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import os from 'node:os';
|
|
@@ -16,6 +17,8 @@ const TRACK = ['spec', 'design', 'plan', 'build', 'verify', 'review', 'ship'];
|
|
|
16
17
|
const LOGO = ['┌┐ ┬ ┬┬┬ ┌┬┐┬ ┬┌┐┌┌─┐', '├┴┐│ │││ ││││ ││││├┤ ', '└─┘└─┘┴┴─┘─┴┘┴─┘┴┘└┘└─┘'];
|
|
17
18
|
const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
|
|
18
19
|
const SPARK = ' ▁▂▃▄▅▆▇█';
|
|
20
|
+
const CLOCK = '◴◷◶◵';
|
|
21
|
+
const WAITS = { reply: 'waiting for your reply', question: 'asking you a question', permission: 'waiting for your permission' };
|
|
19
22
|
|
|
20
23
|
// ---- colour ------------------------------------------------------------------
|
|
21
24
|
|
|
@@ -123,16 +126,17 @@ const shortModel = (config, station) => {
|
|
|
123
126
|
const m = modelFor(config, station);
|
|
124
127
|
return m.cli === 'claude' && !m.model ? 'session' : describeModel(m).replace(/^claude-/, '');
|
|
125
128
|
};
|
|
126
|
-
const ago = (ms) => (ms < 60e3 ? `${Math.max(1, Math.round(ms / 1000))}s` : ms < 3600e3 ? `${Math.
|
|
129
|
+
const ago = (ms) => (ms < 60e3 ? `${Math.max(1, Math.round(ms / 1000))}s` : ms < 3600e3 ? `${Math.floor(ms / 60e3)}m` : `${Math.floor(ms / 3600e3)}h${Math.floor(ms / 60e3) % 60 ? `${Math.floor(ms / 60e3) % 60}m` : ''}`);
|
|
127
130
|
|
|
128
131
|
/** The NEXT UP cell: who or what the feature is waiting on right now. */
|
|
129
132
|
function rowStatus(f, t) {
|
|
130
133
|
if (f.blocked) return ['BLOCKED', C.red];
|
|
134
|
+
if (f.live.waiting) return ['NEEDS YOU', t % 12 < 8 ? C.red : C.faint];
|
|
131
135
|
if (f.live.working) {
|
|
132
136
|
const n = f.live.sessions.filter((x) => x.state === 'working').length;
|
|
133
137
|
return [`${SPINNER[t % SPINNER.length]} ${n > 1 ? `×${n} ` : ''}WORKING`, C.amberBold];
|
|
134
138
|
}
|
|
135
|
-
if (f.live.
|
|
139
|
+
if (f.live.background.length) return [`${CLOCK[Math.floor(t / 4) % CLOCK.length]} BACKGROUND`, C.cyan];
|
|
136
140
|
if (f.next.who === 'agent') return ['AGENT', C.amberDim];
|
|
137
141
|
const who = WHO[f.next.who];
|
|
138
142
|
return who ? [who.label, who.color] : ['', null];
|
|
@@ -166,7 +170,8 @@ function drawTrack(line, x, f, seg, t) {
|
|
|
166
170
|
const who = WHO[f.next.who];
|
|
167
171
|
const skipped = (i) => f.type === 'fix' && i < 3 && cur >= 3;
|
|
168
172
|
// The comet only runs while a session is actually working on this feature.
|
|
169
|
-
const working = f.live?.working && !f.blocked;
|
|
173
|
+
const working = f.live?.working && !f.blocked && !f.live.waiting;
|
|
174
|
+
const background = f.live?.background?.length && !f.blocked && !f.live.waiting;
|
|
170
175
|
const idleAgent = f.next.who === 'agent' && !f.blocked && !f.live?.waiting;
|
|
171
176
|
for (let i = 0; i < TRACK.length; i++) {
|
|
172
177
|
const nx = x + i * seg;
|
|
@@ -176,6 +181,7 @@ function drawTrack(line, x, f, seg, t) {
|
|
|
176
181
|
else if (i === cur && f.blocked) node = ['✖', t % 10 < 5 ? C.red : C.faint];
|
|
177
182
|
else if (i === cur && working) node = [['◉', '◎', '◉', '●'][Math.floor(t / 3) % 4], C.amberBold];
|
|
178
183
|
else if (i === cur && f.live?.waiting) node = ['◉', t % 12 < 8 ? C.red : C.faint];
|
|
184
|
+
else if (i === cur && background) node = [CLOCK[Math.floor(t / 4) % CLOCK.length], C.cyan];
|
|
179
185
|
else if (i === cur && idleAgent) node = ['◉', C.amberDim];
|
|
180
186
|
else if (i === cur) node = ['◉', t % 12 < 8 ? who?.color || C.amber : C.faint];
|
|
181
187
|
line.put(nx, node[0], node[1]);
|
|
@@ -208,8 +214,9 @@ function render(st, ui, W, H) {
|
|
|
208
214
|
const active = st.all.filter((f) => f.status !== 'done');
|
|
209
215
|
const shipped = st.all.length - active.length;
|
|
210
216
|
const tasks = active.flatMap((f) => f.tasks || []);
|
|
211
|
-
const
|
|
212
|
-
const
|
|
217
|
+
const busy = (f) => f.live.working || f.live.background.length;
|
|
218
|
+
const waiting = features.filter((f) => f.status !== 'done' && (f.live.waiting || (!busy(f) && ['pm', 'designer', 'eng', 'human'].includes(f.next.who)))).length;
|
|
219
|
+
const sessionsWorking = st.sessions.filter((x) => x.state !== 'waiting').length;
|
|
213
220
|
|
|
214
221
|
// header
|
|
215
222
|
const tall = W >= 96 && H >= 26;
|
|
@@ -300,13 +307,13 @@ function render(st, ui, W, H) {
|
|
|
300
307
|
const by = top + lineH;
|
|
301
308
|
const bh = H - 1 - by;
|
|
302
309
|
if (bh >= 5) {
|
|
303
|
-
const split = W >= 100 ? Math.floor(W * 0.52) : W;
|
|
310
|
+
const split = W >= 100 && ui.events !== false ? Math.floor(W * 0.52) : W;
|
|
304
311
|
drawInspector(screen, st, sel, 0, by, split, bh);
|
|
305
312
|
if (split < W) drawEvents(screen, st, ui, split, by, W - split, bh);
|
|
306
313
|
}
|
|
307
314
|
|
|
308
315
|
// footer
|
|
309
|
-
const keys = [['↑↓', 'select'], ['⏎', 'open'], ['a', 'approve'], ['u', 'dashboard'], ['s', ui.shipped ? 'hide shipped' : 'shipped'], ['m', ui.sound ? 'mute' : 'unmute'], ['q', 'quit']];
|
|
316
|
+
const keys = [['↑↓', 'select'], ['⏎', 'open'], ['a', 'approve'], ['u', 'dashboard'], ['s', ui.shipped ? 'hide shipped' : 'shipped'], ['e', ui.events === false ? 'events' : 'hide events'], ['m', ui.sound ? 'mute' : 'unmute'], ['q', 'quit']];
|
|
310
317
|
let fx = 1;
|
|
311
318
|
const foot = screen[H - 1];
|
|
312
319
|
if (ui.message && ui.messageUntil > Date.now()) fx = foot.put(1, ` ${ui.message} `, C.flash);
|
|
@@ -344,15 +351,23 @@ function drawInspector(screen, st, f, x, y, w, h) {
|
|
|
344
351
|
lines.slice(0, 4).forEach((l, i) => add([i ? ' ' : '✖ blocked ', C.red], [l, C.bold]));
|
|
345
352
|
}
|
|
346
353
|
for (const ses of f.live?.sessions || []) {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
)
|
|
354
|
+
// "● c41c8a97 opus-5-5 working 4m · up 1h12m", then what it's doing or waiting for.
|
|
355
|
+
const model = (ses.model ? String(ses.model) : shortModel(config, f.status)).replace(/^claude-/, '');
|
|
356
|
+
const up = ses.total - (ses.for ?? 0) > 60e3 ? [` · up ${ago(ses.total)}`, C.faint] : ['', null];
|
|
357
|
+
const head = (icon, iconColor, label, color) => add([icon + ' ', iconColor], [`${String(ses.id).slice(0, 8)} `, C.muted], [`${model} `, C.text], [`${label} ${ago(ses.for ?? ses.age)}`, color], up);
|
|
358
|
+
const sub = (text, color) => text && add([' ', null], [text, color]);
|
|
359
|
+
if (ses.state === 'working') {
|
|
360
|
+
head('●', C.amberBold, 'working', C.amberBold);
|
|
361
|
+
sub(`${ses.tool || 'thinking'} ${ses.detail || ''}`, C.cyan);
|
|
362
|
+
} else if (ses.state === 'background') {
|
|
363
|
+
head(CLOCK[Math.floor(Date.now() / 440) % CLOCK.length], C.cyan, 'background', C.cyan);
|
|
364
|
+
for (const job of (ses.background || []).slice(0, 3)) sub(job, C.text);
|
|
365
|
+
sub(ses.said, C.muted);
|
|
366
|
+
} else {
|
|
367
|
+
head('◌', C.red, 'needs you', C.red);
|
|
368
|
+
sub(WAITS[ses.waitingFor] || WAITS.reply, C.red);
|
|
369
|
+
sub(ses.said, C.bold);
|
|
370
|
+
}
|
|
356
371
|
}
|
|
357
372
|
const ts = f.tasks || [];
|
|
358
373
|
if (ts.length) {
|
|
@@ -502,6 +517,7 @@ export async function watch(root, opts = {}) {
|
|
|
502
517
|
if (k === '\x1b[A' || k === 'k') ui.sel = Math.max(0, ui.sel - 1);
|
|
503
518
|
else if (k === '\x1b[B' || k === 'j') ui.sel = Math.min(st.features.length - 1, ui.sel + 1);
|
|
504
519
|
else if (k === 's') (ui.shipped = !ui.shipped), refresh();
|
|
520
|
+
else if (k === 'e') (ui.events = ui.events === false), out.write('\x1b[2J');
|
|
505
521
|
else if (k === 'm') (ui.sound = !ui.sound), say(ui.sound ? '♪ sound on' : 'sound muted until you quit');
|
|
506
522
|
else if (k === 'u') return done({ action: 'ui' });
|
|
507
523
|
else if ((k === '\r' || k === '\n') && f) {
|
package/src/ui/index.html
CHANGED
|
@@ -78,6 +78,8 @@ main { padding: 16px 20px 40px; }
|
|
|
78
78
|
.live .dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); align-self: center; animation: pulse 1.2s ease-in-out infinite; }
|
|
79
79
|
.live.waiting .dot, .live.blocked .dot { background: var(--red); }
|
|
80
80
|
.live.blocked .dot { animation: none; }
|
|
81
|
+
.live.background b { color: var(--blue); }
|
|
82
|
+
.live.background .dot { background: var(--blue); animation-duration: 2.4s; }
|
|
81
83
|
.live code { font-size: 11px; background: none; padding: 0; }
|
|
82
84
|
.card.is-working { border-color: var(--accent); }
|
|
83
85
|
@keyframes pulse { 50% { opacity: .25; transform: scale(.7); } }
|
|
@@ -242,21 +244,25 @@ function card(f) {
|
|
|
242
244
|
</button>`;
|
|
243
245
|
}
|
|
244
246
|
const stationModel = (station) => { const m = S.config.models || {}; const v = m[station] ?? m.default; return v && v !== 'inherit' ? v : ''; };
|
|
245
|
-
const ago = (ms) => (ms < 60e3 ? `${Math.max(1, Math.round(ms / 1000))}s` : ms < 3600e3 ? `${Math.
|
|
246
|
-
|
|
247
|
+
const ago = (ms) => (ms < 60e3 ? `${Math.max(1, Math.round(ms / 1000))}s` : ms < 3600e3 ? `${Math.floor(ms / 60e3)}m` : `${Math.floor(ms / 3600e3)}h${Math.floor(ms / 60e3) % 60 ? `${Math.floor(ms / 60e3) % 60}m` : ''}`);
|
|
248
|
+
const WAITS = { reply: 'Waiting for your reply', question: 'Asking you', permission: 'Needs your permission' };
|
|
249
|
+
/** What is happening on a feature right now: a blocker, a session waiting on you, one working, or work left running in the background. */
|
|
247
250
|
function liveLine(f) {
|
|
248
251
|
if (f.blocked) return `<div class="live blocked"><i class="dot"></i><span class="wrap"><b>Blocked:</b> ${esc(f.blocked)}</span></div>`;
|
|
252
|
+
const w = f.live?.waitingFor;
|
|
253
|
+
if (w) return `<div class="live waiting" title="${esc(w.said || '')}"><i class="dot"></i><span><b>${WAITS[w.why] || WAITS.reply}</b>${w.for != null ? ` · ${ago(w.for)}` : ''}${w.said ? `<span class="wrap">${esc(w.said)}</span>` : ' in a Claude session'}</span></div>`;
|
|
249
254
|
const ses = f.live?.sessions || [];
|
|
250
255
|
const on = ses.filter((x) => x.state === 'working');
|
|
251
256
|
if (f.live?.working) {
|
|
252
257
|
const s = on[0];
|
|
253
258
|
if (!s) return `<div class="live working"><i class="dot"></i><span><b>Working</b> · files changing</span></div>`;
|
|
254
259
|
const model = String(s.model || stationModel(f.status) || '').replace(/^claude-/, '');
|
|
255
|
-
const head = [`<b>Working${on.length > 1 ? ` ×${on.length}` : ''}</b>`, ago(s.age), model && esc(model)].filter(Boolean).join(' · ');
|
|
260
|
+
const head = [`<b>Working${on.length > 1 ? ` ×${on.length}` : ''}</b>`, ago(s.for ?? s.age), model && esc(model)].filter(Boolean).join(' · ');
|
|
256
261
|
const doing = s.tool ? `${esc(s.tool)} <code>${esc(s.detail || '')}</code>` : 'thinking';
|
|
257
|
-
return `<div class="live working" title="${esc([s.tool, s.detail].filter(Boolean).join(': '))}"><i class="dot"></i><span><span class="line">${head}</span><span class="line">${doing}</span></span></div>`;
|
|
262
|
+
return `<div class="live working" title="${esc([[s.tool, s.detail].filter(Boolean).join(': '), s.total != null && `session running ${ago(s.total)}`].filter(Boolean).join('\n'))}"><i class="dot"></i><span><span class="line">${head}</span><span class="line">${doing}</span></span></div>`;
|
|
258
263
|
}
|
|
259
|
-
|
|
264
|
+
const bg = f.live?.background || [];
|
|
265
|
+
if (bg.length) return `<div class="live background" title="${esc(bg.join('\n'))}"><i class="dot"></i><span><b>In the background</b> · ${ago(f.live.backgroundFor)}${bg.length > 1 ? ` · ${bg.length} jobs` : ''}<span class="line"><code>${esc(bg[0])}</code></span></span></div>`;
|
|
260
266
|
return '';
|
|
261
267
|
}
|
|
262
268
|
function renderBoard() {
|
package/templates/bin/hook.mjs
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
// guard blocks edits to a spec's "## Acceptance" section while the
|
|
7
7
|
// feature is in build/verify/review, so agents can't move the goalposts,
|
|
8
8
|
// and hand edits to feature.json (agents use .buildline/bin/feature.mjs)
|
|
9
|
-
// prompt / stop / session-end
|
|
10
|
-
// heartbeat: which session works on which feature
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// prompt / post / notify / stop / session-end
|
|
10
|
+
// heartbeat: which session works on which feature and whether
|
|
11
|
+
// it is working, running something in the background, or
|
|
12
|
+
// waiting for a person, so `buildline watch` and the dashboard
|
|
13
|
+
// can show live work. Written to .buildline/sessions/ (git-ignored).
|
|
13
14
|
import fs from 'node:fs';
|
|
14
15
|
import path from 'node:path';
|
|
15
16
|
import { fileURLToPath } from 'node:url';
|
|
@@ -106,6 +107,23 @@ function featureRef(text) {
|
|
|
106
107
|
return m ? m[1].padStart(3, '0') : null;
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
/** A prompt in a line. Background tasks finishing arrive as <task-notification> prompts: show their summary. */
|
|
111
|
+
function promptDetail(prompt) {
|
|
112
|
+
const done = /^\s*<task-notification>[\s\S]*?<summary>([\s\S]*?)<\/summary>/.exec(prompt);
|
|
113
|
+
return done ? oneLine(done[1], 90) : oneLine(prompt, 60);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A feature.mjs command that changes a feature's state, e.g. "feature.mjs task 3 T1 pass". */
|
|
117
|
+
function actedOn(command) {
|
|
118
|
+
const m = /feature\.mjs\s+(?:move|task|log|block|unblock|approve)\s+(\d{1,4})\b/.exec(String(command || ''));
|
|
119
|
+
return m ? m[1].padStart(3, '0') : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isDone(id) {
|
|
123
|
+
const dir = fs.existsSync(featuresDir) ? fs.readdirSync(featuresDir).find((d) => d.startsWith(`${id}-`)) : null;
|
|
124
|
+
return dir ? readJson(path.join(featuresDir, dir, 'feature.json'))?.status === 'done' : false;
|
|
125
|
+
}
|
|
126
|
+
|
|
109
127
|
function detail(input) {
|
|
110
128
|
const ti = input.tool_input || {};
|
|
111
129
|
if (ti.file_path) return path.basename(ti.file_path);
|
|
@@ -119,6 +137,56 @@ function detail(input) {
|
|
|
119
137
|
return '';
|
|
120
138
|
}
|
|
121
139
|
|
|
140
|
+
// Tools that stop the turn to ask the person something.
|
|
141
|
+
const ASKS = ['AskUserQuestion', 'ExitPlanMode'];
|
|
142
|
+
|
|
143
|
+
const oneLine = (s, n) => {
|
|
144
|
+
const t = String(s || '').replace(/\s+/g, ' ').trim();
|
|
145
|
+
return t.length > n ? t.slice(0, n - 1) + '…' : t;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** What a session left running when its turn ended: background shells, subagents, scheduled checks. */
|
|
149
|
+
function stillRunning(input) {
|
|
150
|
+
const tasks = (Array.isArray(input.background_tasks) ? input.background_tasks : [])
|
|
151
|
+
.filter((t) => !t.status || t.status === 'running')
|
|
152
|
+
.map((t) => (t.type === 'shell' ? detail({ tool_input: { command: t.command || t.description } }) : `agent: ${oneLine(t.description, 40)}`));
|
|
153
|
+
const crons = (Array.isArray(input.session_crons) ? input.session_crons : []).map((c) => `scheduled: ${oneLine(c.prompt || c.cron || c.schedule || 'check', 36)}`);
|
|
154
|
+
return [...tasks, ...crons];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The end of the agent's last message (its last paragraph, or last sentence when that's long): what it's waiting for. */
|
|
158
|
+
function lastWords(message) {
|
|
159
|
+
const para = oneLine(String(message || '').trim().split(/\n\s*\n/).pop(), 1000);
|
|
160
|
+
if (para.length <= 90) return para || null;
|
|
161
|
+
return oneLine(/[^.!?]+[.!?]*\s*$/.exec(para)?.[0] || para, 140);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The last thing the agent said ends with a question: it wants an answer even if work runs on. */
|
|
165
|
+
const asks = (message) => /\?\s*$/.test(String(message || '').replace(/[`*_)\]\s]+$/, ''));
|
|
166
|
+
|
|
167
|
+
const quietParent = (prev) => prev.state === 'background' || prev.state === 'waiting';
|
|
168
|
+
|
|
169
|
+
/** What a session is doing after this event: working, background (turn over, tasks still running) or waiting on a person. */
|
|
170
|
+
function nextState(prev, input, event) {
|
|
171
|
+
const tool = input.tool_name;
|
|
172
|
+
const fromSubagent = Boolean(input.agent_id);
|
|
173
|
+
if (event === 'stop') {
|
|
174
|
+
const running = stillRunning(input);
|
|
175
|
+
const said = lastWords(input.last_assistant_message);
|
|
176
|
+
if (running.length && !asks(input.last_assistant_message)) return { state: 'background', waitingFor: null, background: running, said };
|
|
177
|
+
return { state: 'waiting', waitingFor: 'reply', background: running, said };
|
|
178
|
+
}
|
|
179
|
+
// A background subagent working on its own doesn't make the session busy again,
|
|
180
|
+
// nor does it answer a question the session is waiting on.
|
|
181
|
+
if (fromSubagent && quietParent(prev)) return {};
|
|
182
|
+
if (event === 'notify') return { state: 'waiting', waitingFor: prev.waitingFor === 'question' ? 'question' : 'permission', said: prev.waitingFor === 'question' ? prev.said : oneLine(input.message, 140) };
|
|
183
|
+
if (event === 'tool' && ASKS.includes(tool)) {
|
|
184
|
+
const q = input.tool_input?.questions?.[0]?.question || (tool === 'ExitPlanMode' ? 'Approve the plan?' : 'A question');
|
|
185
|
+
return { state: 'waiting', waitingFor: 'question', said: oneLine(q, 140), inTool: true };
|
|
186
|
+
}
|
|
187
|
+
return { state: 'working', waitingFor: null, background: [], said: null, inTool: event === 'tool' };
|
|
188
|
+
}
|
|
189
|
+
|
|
122
190
|
function heartbeat(input, event) {
|
|
123
191
|
const id = String(input.session_id || '').replace(/[^\w-]/g, '');
|
|
124
192
|
if (!id) return;
|
|
@@ -132,18 +200,23 @@ function heartbeat(input, event) {
|
|
|
132
200
|
const prompt = input.prompt || '';
|
|
133
201
|
const station = /\/buildline-([a-z]+)/.exec(prompt)?.[1];
|
|
134
202
|
const ti = input.tool_input || {};
|
|
135
|
-
// A session stays on the feature it was started for; reading another feature's files doesn't move it
|
|
136
|
-
|
|
203
|
+
// A session stays on the feature it was started for; reading another feature's files doesn't move it,
|
|
204
|
+
// recording progress on one does. It lets go once its feature is done or the conversation is cleared,
|
|
205
|
+
// so a reopened session doesn't keep flagging a finished feature.
|
|
206
|
+
const bound = prev.feature && !isDone(prev.feature) && !(event === 'start' && input.source === 'clear') ? prev.feature : null;
|
|
207
|
+
const feature = featureRef(prompt) || actedOn(ti.command) || bound || featureRef(ti.file_path) || featureRef(ti.command) || featureRef(ti.prompt) || null;
|
|
137
208
|
const next = {
|
|
138
209
|
...prev,
|
|
139
210
|
feature,
|
|
140
|
-
station: station || prev.station || null,
|
|
211
|
+
station: station || (feature === prev.feature ? prev.station : null) || null,
|
|
141
212
|
model: input.model?.id || input.model || prev.model || null,
|
|
142
|
-
|
|
213
|
+
...nextState(prev, input, event),
|
|
143
214
|
lastAt: at,
|
|
144
|
-
...(event === 'tool' ? { tool: input.tool_name, detail: detail(input) } : {}),
|
|
145
|
-
...(event === 'prompt' ? { tool: null, detail: prompt
|
|
215
|
+
...(event === 'tool' && !(input.agent_id && quietParent(prev)) ? { tool: input.tool_name, detail: detail(input) } : {}),
|
|
216
|
+
...(event === 'prompt' ? { tool: null, detail: promptDetail(prompt) } : {}),
|
|
146
217
|
};
|
|
218
|
+
// When the session entered its current state, so the views can say "working 4m", "waiting 12m".
|
|
219
|
+
next.since = next.state === prev.state && prev.since ? prev.since : at;
|
|
147
220
|
fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n');
|
|
148
221
|
if (event === 'tool' && feature) {
|
|
149
222
|
// A short live activity feed for the views; trimmed so it never grows large.
|
|
@@ -165,6 +238,8 @@ process.stdin.on('end', () => {
|
|
|
165
238
|
else if (mode === 'guard') guard(input);
|
|
166
239
|
else if (mode === 'pre') heartbeat(input, 'tool'), guard(input);
|
|
167
240
|
else if (mode === 'prompt') heartbeat(input, 'prompt');
|
|
241
|
+
else if (mode === 'post') heartbeat(input, 'post');
|
|
242
|
+
else if (mode === 'notify') heartbeat(input, 'notify');
|
|
168
243
|
else if (mode === 'stop') heartbeat(input, 'stop');
|
|
169
244
|
else if (mode === 'session-end') heartbeat(input, 'end');
|
|
170
245
|
} catch (err) {
|