buildline 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -134,10 +134,11 @@ 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
+ - 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)
141
142
 
142
143
  ## The dashboard
143
144
 
@@ -196,6 +197,7 @@ npx buildline watch live view of the line in the terminal
196
197
  npx buildline step <station> [id] open a station on its model --headless --model
197
198
  npx buildline models choose models set <station|phase> <model> · check
198
199
  npx buildline run <id> autopilot with Claude Code /goal --headless
200
+ npx buildline sound [on|off] chime when a feature needs you or ships
199
201
  npx buildline gates run quality gates
200
202
  npx buildline doctor check the setup
201
203
  npx buildline update refresh managed files, keep your edits
package/bin/buildline.js CHANGED
@@ -18,10 +18,11 @@ ${pc.bold('Commands')}
18
18
  ${pc.cyan('new')} "<title>" open a feature ${pc.dim('--type fix|feature|epic --owner <name> --parent <id>')}
19
19
  ${pc.cyan('approve')} <id> <stage> record a human approval (spec | design | plan) ${pc.dim('--by <name>')}
20
20
  ${pc.cyan('move')} <id> <station> move a feature to a station ${pc.dim('--note "<why>"')}
21
- ${pc.cyan('watch')} live view of the line in your terminal ${pc.dim('--shipped --once --no-intro')}
22
- ${pc.cyan('ui')} open the dashboard in your browser ${pc.dim('--port <n> --no-open')}
21
+ ${pc.cyan('watch')} live view of the line in your terminal ${pc.dim('--shipped --once --no-intro --quiet')}
22
+ ${pc.cyan('ui')} open the dashboard in your browser ${pc.dim('--port <n> --no-open --quiet')}
23
23
  ${pc.cyan('step')} <station> [id] open a station in the CLI and model set for it ${pc.dim('--model <m> --headless --print')}
24
24
  ${pc.cyan('models')} choose models per station ${pc.dim('set <station|phase> <model> · check')}
25
+ ${pc.cyan('sound')} [on|off] chime when a feature needs you or ships (watch and dashboard)
25
26
  ${pc.cyan('run')} [id] autopilot: build → verify → review with Claude Code /goal ${pc.dim('--headless --print')}
26
27
  ${pc.cyan('gates')} [names...] run the quality gates
27
28
  ${pc.cyan('doctor')} check the setup
@@ -75,13 +76,14 @@ const { values: opts, positionals } = parseArgs({
75
76
  once: { type: 'boolean' },
76
77
  shipped: { type: 'boolean' },
77
78
  'no-intro': { type: 'boolean' },
79
+ quiet: { type: 'boolean', short: 'q' },
78
80
  help: { type: 'boolean', short: 'h' },
79
81
  version: { type: 'boolean', short: 'v' },
80
82
  },
81
83
  });
82
84
 
83
85
  const root = path.resolve(opts.cwd || process.cwd());
84
- const o = { ...opts, dryRun: opts['dry-run'], ci: opts['no-ci'] ? false : opts.ci, open: !opts['no-open'], intro: !opts['no-intro'] };
86
+ const o = { ...opts, dryRun: opts['dry-run'], ci: opts['no-ci'] ? false : opts.ci, open: !opts['no-open'], intro: !opts['no-intro'], sound: !opts.quiet };
85
87
  const [cmd, ...rest] = positionals;
86
88
 
87
89
  async function main() {
@@ -117,6 +119,8 @@ async function main() {
117
119
  return cli.models(root, rest, o);
118
120
  case 'run':
119
121
  return cli.run(root, rest[0], o);
122
+ case 'sound':
123
+ return cli.sound(root, rest[0]);
120
124
  case 'gates':
121
125
  return cli.gates(root, rest);
122
126
  case 'doctor':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buildline",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
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/cli.js CHANGED
@@ -8,6 +8,7 @@ import { AGENTS, AUTONOMY, VERSION, makeConfig, plan, apply, removePlan, applyRe
8
8
  import { loadManifest } from './files.js';
9
9
  import { PHASES, MODEL_STATIONS, CLIS, modelFor, parseModel, parseModelList, describeModel, discoverModelOptions, checkModel, stationPrompt, launchArgs, externalClis } from './models.js';
10
10
  import { listFeatures, findFeature, createFeature, approve, setStatus, nextAction, moveBlocker, STATIONS, TYPES } from './features.js';
11
+ import { chime } from './sound.js';
11
12
 
12
13
  const LOGO = [
13
14
  pc.yellow(' ┌┐ ┬ ┬┬┬ ┌┬┐┬ ┬┌┐┌┌─┐'),
@@ -270,6 +271,19 @@ export async function watch(root, opts = {}) {
270
271
  if (next?.action === 'ui') return (await import('./server.js')).serve(root, {});
271
272
  }
272
273
 
274
+ export function sound(root, value) {
275
+ const config = loadConfig(root);
276
+ if (value) {
277
+ if (!['on', 'off'].includes(value)) throw new Error('Usage: buildline sound [on|off]');
278
+ if (value === 'on') delete config.sound;
279
+ else config.sound = false;
280
+ fs.writeFileSync(configPath(root), JSON.stringify(config, null, 2) + '\n');
281
+ }
282
+ const on = config.sound !== false;
283
+ console.log(`${on ? pc.green('♪') : pc.dim('♪')} Sound is ${on ? 'on' : 'off'}: ${on ? '`watch` and the dashboard chime when a feature needs you or ships.' : 'no chime.'} ${pc.dim(on ? 'Turn off: buildline sound off' : 'Turn on: buildline sound on')}`);
284
+ if (on && value === 'on') chime();
285
+ }
286
+
273
287
  // ---- new / approve / move ----------------------------------------------------
274
288
 
275
289
  export async function newFeature(root, title, opts) {
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
- if (s.state === 'working' && age < WORKING_FOR) out.push({ ...s, age });
185
- else if (s.state === 'waiting' && age < WAITING_FOR) out.push({ ...s, age });
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,17 +208,35 @@ 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 is
209
- * working or waiting for a reply. Where no hooks report (agents other than
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
  const mine = sessions.filter((s) => s.feature === feature.id);
214
218
  const working = mine.filter((s) => s.state === 'working');
215
219
  const recent = at - new Date(feature.updated || 0) < 2 * 60e3;
220
+ const waiting = mine.find((s) => s.state === 'waiting');
216
221
  return {
217
222
  sessions: mine,
218
223
  working: working.length > 0 || (!sessions.tracked && recent && !feature.blocked && feature.status !== 'done'),
219
- waiting: !working.length && mine.some((s) => s.state === 'waiting'),
224
+ waiting: Boolean(waiting),
225
+ // why: 'reply' | 'question' | 'permission', and what the agent said or asked
226
+ waitingFor: waiting ? { why: waiting.waitingFor || 'reply', said: waiting.said || null, for: waiting.for } : null,
227
+ background: !working.length && !waiting ? mine.filter((s) => s.state === 'background').flatMap((s) => s.background || []) : [],
228
+ backgroundFor: Math.max(0, ...mine.filter((s) => s.state === 'background').map((s) => s.for || 0)),
220
229
  };
221
230
  }
231
+
232
+ /** What the chime is for: 'waiting' when a feature needs a person, 'done' once it ships, else null. */
233
+ export function attention(feature, live) {
234
+ if (feature.status === 'done') return 'done';
235
+ return feature.blocked || live?.waiting ? 'waiting' : null;
236
+ }
237
+
238
+ /** Ids whose attention changed to waiting or done since `before` (a Map from the last look). */
239
+ export function newAttention(before, now) {
240
+ if (!before) return [];
241
+ return [...now].filter(([id, a]) => a && before.get(id) !== a).map(([id]) => id);
242
+ }
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/server.js CHANGED
@@ -33,8 +33,9 @@ function openBrowser(url) {
33
33
  } catch {}
34
34
  }
35
35
 
36
- function state(root) {
36
+ function state(root, opts = {}) {
37
37
  const config = JSON.parse(fs.readFileSync(path.join(root, '.buildline', 'buildline.json'), 'utf8'));
38
+ if (opts.sound === false) config.sound = false;
38
39
  const sessions = readSessions(root);
39
40
  const features = listFeatures(root).map(({ path: _p, ...f }) => ({
40
41
  ...f,
@@ -89,7 +90,7 @@ export function serve(root, opts = {}) {
89
90
  if (!url.pathname.startsWith('/api/')) return send(404, { error: 'Not found' });
90
91
  if (req.method !== 'GET' && req.headers['x-buildline-token'] !== token) return send(403, { error: 'Bad token' });
91
92
 
92
- if (req.method === 'GET' && url.pathname === '/api/state') return send(200, state(root));
93
+ if (req.method === 'GET' && url.pathname === '/api/state') return send(200, state(root, opts));
93
94
 
94
95
  if (url.pathname === '/api/file') {
95
96
  if (req.method === 'GET') {
package/src/sound.js ADDED
@@ -0,0 +1,29 @@
1
+ // The one sound Buildline makes: a short chime when a feature needs a person or
2
+ // ships. Plays the system's own notification sound where there is one, and falls
3
+ // back to the terminal bell. Off with `"sound": false` in buildline.json.
4
+ import fs from 'node:fs';
5
+ import { spawn } from 'node:child_process';
6
+
7
+ const PLAYERS = {
8
+ darwin: ['afplay', '/System/Library/Sounds/Glass.aiff'],
9
+ linux: ['paplay', '/usr/share/sounds/freedesktop/stereo/complete.oga'],
10
+ };
11
+
12
+ let last = 0;
13
+
14
+ export function chime(out = process.stdout) {
15
+ if (Date.now() - last < 3000) return; // several features at once still make one sound
16
+ last = Date.now();
17
+ const bell = () => out.write('\x07');
18
+ const player = PLAYERS[process.platform];
19
+ if (!player || !fs.existsSync(player[1])) return bell();
20
+ try {
21
+ const child = spawn(player[0], [player[1]], { stdio: 'ignore', detached: true });
22
+ child.on('error', bell);
23
+ child.unref();
24
+ } catch {
25
+ bell();
26
+ }
27
+ }
28
+
29
+ export const soundOn = (config) => config?.sound !== false;
package/src/tui.js CHANGED
@@ -1,13 +1,15 @@
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 an agent can work on it, a blinking dot means it waits
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';
7
8
  import path from 'node:path';
8
9
  import { spawnSync } from 'node:child_process';
9
10
  import pc from 'picocolors';
10
- import { listFeatures, nextAction, approve, readSessions, readActivity, liveState } from './features.js';
11
+ import { listFeatures, nextAction, approve, readSessions, readActivity, liveState, attention, newAttention } from './features.js';
12
+ import { chime, soundOn } from './sound.js';
11
13
  import { modelFor, describeModel } from './models.js';
12
14
  import { AUTONOMY, VERSION } from './install.js';
13
15
 
@@ -15,6 +17,8 @@ const TRACK = ['spec', 'design', 'plan', 'build', 'verify', 'review', 'ship'];
15
17
  const LOGO = ['┌┐ ┬ ┬┬┬ ┌┬┐┬ ┬┌┐┌┌─┐', '├┴┐│ │││ ││││ ││││├┤ ', '└─┘└─┘┴┴─┘─┴┘┴─┘┴┘└┘└─┘'];
16
18
  const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
17
19
  const SPARK = ' ▁▂▃▄▅▆▇█';
20
+ const CLOCK = '◴◷◶◵';
21
+ const WAITS = { reply: 'waiting for your reply', question: 'asking you a question', permission: 'waiting for your permission' };
18
22
 
19
23
  // ---- colour ------------------------------------------------------------------
20
24
 
@@ -113,7 +117,8 @@ function loadState(root, showShipped) {
113
117
  .flatMap((f) => (f.history || []).map((h) => ({ ...h, id: f.id, key: `${f.id}|${h.at}|${h.note}` })))
114
118
  .concat(tools)
115
119
  .sort((a, b) => String(a.at).localeCompare(String(b.at)));
116
- return { config, all, features, events, sessions };
120
+ const needs = new Map(all.map((f) => [f.id, attention(f, liveState(f, sessions))]));
121
+ return { config, all, features, events, sessions, needs };
117
122
  }
118
123
 
119
124
  const stationIndex = (status) => (status === 'new' ? 0 : status === 'done' ? TRACK.length : TRACK.indexOf(status));
@@ -121,16 +126,17 @@ const shortModel = (config, station) => {
121
126
  const m = modelFor(config, station);
122
127
  return m.cli === 'claude' && !m.model ? 'session' : describeModel(m).replace(/^claude-/, '');
123
128
  };
124
- const ago = (ms) => (ms < 60e3 ? `${Math.max(1, Math.round(ms / 1000))}s` : ms < 3600e3 ? `${Math.round(ms / 60e3)}m` : `${Math.round(ms / 3600e3)}h`);
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` : ''}`);
125
130
 
126
131
  /** The NEXT UP cell: who or what the feature is waiting on right now. */
127
132
  function rowStatus(f, t) {
128
133
  if (f.blocked) return ['BLOCKED', C.red];
134
+ if (f.live.waiting) return ['NEEDS YOU', t % 12 < 8 ? C.red : C.faint];
129
135
  if (f.live.working) {
130
136
  const n = f.live.sessions.filter((x) => x.state === 'working').length;
131
137
  return [`${SPINNER[t % SPINNER.length]} ${n > 1 ? `×${n} ` : ''}WORKING`, C.amberBold];
132
138
  }
133
- if (f.live.waiting) return ['NEEDS YOU', t % 12 < 8 ? C.red : C.faint];
139
+ if (f.live.background.length) return [`${CLOCK[Math.floor(t / 4) % CLOCK.length]} BACKGROUND`, C.cyan];
134
140
  if (f.next.who === 'agent') return ['AGENT', C.amberDim];
135
141
  const who = WHO[f.next.who];
136
142
  return who ? [who.label, who.color] : ['', null];
@@ -164,7 +170,8 @@ function drawTrack(line, x, f, seg, t) {
164
170
  const who = WHO[f.next.who];
165
171
  const skipped = (i) => f.type === 'fix' && i < 3 && cur >= 3;
166
172
  // The comet only runs while a session is actually working on this feature.
167
- 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;
168
175
  const idleAgent = f.next.who === 'agent' && !f.blocked && !f.live?.waiting;
169
176
  for (let i = 0; i < TRACK.length; i++) {
170
177
  const nx = x + i * seg;
@@ -174,6 +181,7 @@ function drawTrack(line, x, f, seg, t) {
174
181
  else if (i === cur && f.blocked) node = ['✖', t % 10 < 5 ? C.red : C.faint];
175
182
  else if (i === cur && working) node = [['◉', '◎', '◉', '●'][Math.floor(t / 3) % 4], C.amberBold];
176
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];
177
185
  else if (i === cur && idleAgent) node = ['◉', C.amberDim];
178
186
  else if (i === cur) node = ['◉', t % 12 < 8 ? who?.color || C.amber : C.faint];
179
187
  line.put(nx, node[0], node[1]);
@@ -206,8 +214,9 @@ function render(st, ui, W, H) {
206
214
  const active = st.all.filter((f) => f.status !== 'done');
207
215
  const shipped = st.all.length - active.length;
208
216
  const tasks = active.flatMap((f) => f.tasks || []);
209
- const waiting = features.filter((f) => f.status !== 'done' && !f.live.working && (f.live.waiting || ['pm', 'designer', 'eng', 'human'].includes(f.next.who))).length;
210
- const sessionsWorking = st.sessions.filter((x) => x.state === 'working').length;
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;
211
220
 
212
221
  // header
213
222
  const tall = W >= 96 && H >= 26;
@@ -298,13 +307,13 @@ function render(st, ui, W, H) {
298
307
  const by = top + lineH;
299
308
  const bh = H - 1 - by;
300
309
  if (bh >= 5) {
301
- const split = W >= 100 ? Math.floor(W * 0.52) : W;
310
+ const split = W >= 100 && ui.events !== false ? Math.floor(W * 0.52) : W;
302
311
  drawInspector(screen, st, sel, 0, by, split, bh);
303
312
  if (split < W) drawEvents(screen, st, ui, split, by, W - split, bh);
304
313
  }
305
314
 
306
315
  // footer
307
- const keys = [['↑↓', 'select'], ['⏎', 'open'], ['a', 'approve'], ['u', 'dashboard'], ['s', ui.shipped ? 'hide shipped' : 'shipped'], ['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']];
308
317
  let fx = 1;
309
318
  const foot = screen[H - 1];
310
319
  if (ui.message && ui.messageUntil > Date.now()) fx = foot.put(1, ` ${ui.message} `, C.flash);
@@ -342,15 +351,23 @@ function drawInspector(screen, st, f, x, y, w, h) {
342
351
  lines.slice(0, 4).forEach((l, i) => add([i ? ' ' : '✖ blocked ', C.red], [l, C.bold]));
343
352
  }
344
353
  for (const ses of f.live?.sessions || []) {
345
- const on = ses.state === 'working';
346
- add(
347
- [on ? '● ' : '◌ ', on ? C.amberBold : C.red],
348
- [`session ${String(ses.id).slice(0, 8)} `, C.muted],
349
- [`${(ses.model ? String(ses.model) : shortModel(config, f.status)).replace(/^claude-/, '')} `, C.text],
350
- [on ? `${ses.tool || 'thinking'} ` : 'waiting for your reply ', on ? C.cyan : C.red],
351
- [on ? `${ses.detail || ''} ` : '', C.text],
352
- [`· ${ago(ses.age)} ago`, C.faint],
353
- );
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
+ }
354
371
  }
355
372
  const ts = f.tasks || [];
356
373
  if (ts.length) {
@@ -446,8 +463,10 @@ export async function watch(root, opts = {}) {
446
463
  }
447
464
  const ui = { root, tick: 0, sel: 0, scroll: 0, flash: new Map(), freshEvents: new Map(), shipped: false, message: '', messageUntil: 0 };
448
465
  let st = loadState(root, ui.shipped);
466
+ ui.sound = opts.sound !== false && soundOn(st.config);
449
467
  let lastStatus = new Map(st.all.map((f) => [f.id, f.status]));
450
468
  let seen = new Set(st.events.map((e) => e.key));
469
+ let needs = st.needs;
451
470
 
452
471
  out.write('\x1b[?1049h\x1b[?25l');
453
472
  if (opts.intro !== false) await boot(out, st, root);
@@ -462,6 +481,8 @@ export async function watch(root, opts = {}) {
462
481
  if (lastStatus.has(f.id) && lastStatus.get(f.id) !== f.status) ui.flash.set(f.id, Date.now() + 2500);
463
482
  }
464
483
  for (const e of st.events) if (!seen.has(e.key)) ui.freshEvents.set(e.key, Date.now() + 4000);
484
+ if (newAttention(needs, st.needs).length && ui.sound) chime(out);
485
+ needs = st.needs;
465
486
  lastStatus = new Map(st.all.map((f) => [f.id, f.status]));
466
487
  seen = new Set(st.events.map((e) => e.key));
467
488
  ui.sel = Math.max(0, Math.min(ui.sel, st.features.length - 1));
@@ -496,6 +517,8 @@ export async function watch(root, opts = {}) {
496
517
  if (k === '\x1b[A' || k === 'k') ui.sel = Math.max(0, ui.sel - 1);
497
518
  else if (k === '\x1b[B' || k === 'j') ui.sel = Math.min(st.features.length - 1, ui.sel + 1);
498
519
  else if (k === 's') (ui.shipped = !ui.shipped), refresh();
520
+ else if (k === 'e') (ui.events = ui.events === false), out.write('\x1b[2J');
521
+ else if (k === 'm') (ui.sound = !ui.sound), say(ui.sound ? '♪ sound on' : 'sound muted until you quit');
499
522
  else if (k === 'u') return done({ action: 'ui' });
500
523
  else if ((k === '\r' || k === '\n') && f) {
501
524
  if (f.status === 'done') say(`${f.id} has shipped`);
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); } }
@@ -152,6 +154,7 @@ dialog h2 { margin: 0 0 12px; font-size: 17px; }
152
154
  <button role="tab" aria-selected="false" data-view="context">Context</button>
153
155
  </nav>
154
156
  <span class="spacer"></span>
157
+ <button class="btn" id="soundBtn" aria-pressed="true"></button>
155
158
  <button class="btn" id="whoami" title="Name used for approvals"></button>
156
159
  <button class="btn primary" id="newBtn">+ New</button>
157
160
  </header>
@@ -241,21 +244,25 @@ function card(f) {
241
244
  </button>`;
242
245
  }
243
246
  const stationModel = (station) => { const m = S.config.models || {}; const v = m[station] ?? m.default; return v && v !== 'inherit' ? v : ''; };
244
- const ago = (ms) => (ms < 60e3 ? `${Math.max(1, Math.round(ms / 1000))}s` : ms < 3600e3 ? `${Math.round(ms / 60e3)}m` : `${Math.round(ms / 3600e3)}h`);
245
- /** What is happening on a feature right now: a blocker, a working session, or one waiting for a reply. */
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. */
246
250
  function liveLine(f) {
247
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>`;
248
254
  const ses = f.live?.sessions || [];
249
255
  const on = ses.filter((x) => x.state === 'working');
250
256
  if (f.live?.working) {
251
257
  const s = on[0];
252
258
  if (!s) return `<div class="live working"><i class="dot"></i><span><b>Working</b> · files changing</span></div>`;
253
259
  const model = String(s.model || stationModel(f.status) || '').replace(/^claude-/, '');
254
- 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(' · ');
255
261
  const doing = s.tool ? `${esc(s.tool)} <code>${esc(s.detail || '')}</code>` : 'thinking';
256
- 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>`;
257
263
  }
258
- if (f.live?.waiting) return `<div class="live waiting"><i class="dot"></i><span><b>Waiting for your reply</b> in a Claude session</span></div>`;
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>`;
259
266
  return '';
260
267
  }
261
268
  function renderBoard() {
@@ -287,6 +294,10 @@ async function render() {
287
294
  $('#project').textContent = S.config.project;
288
295
  $('#autonomy').textContent = S.autonomyLabel || `L${S.config.autonomy}`;
289
296
  $('#whoami').textContent = me() ? `👤 ${me()}` : 'Set your name';
297
+ const on = soundOn();
298
+ $('#soundBtn').textContent = on ? '♪ Sound on' : '♪ Sound off';
299
+ $('#soundBtn').setAttribute('aria-pressed', on);
300
+ $('#soundBtn').title = on ? 'Chimes when a feature needs you or ships. Click to mute.' : 'Click to chime when a feature needs you or ships.';
290
301
  document.querySelectorAll('nav.tabs button').forEach((b) => b.setAttribute('aria-selected', b.dataset.view === view));
291
302
  if (!(view === 'context' && ctxEditing && $('#ctxEditor'))) $('#main').innerHTML = view === 'board' ? renderBoard() : await renderContext();
292
303
  if (openId) await renderDrawer();
@@ -343,6 +354,7 @@ $('#newDialog').addEventListener('close', async () => {
343
354
  });
344
355
  $('#newBtn').onclick = newFeature;
345
356
  $('#whoami').onclick = askName;
357
+ $('#soundBtn').onclick = () => { try { localStorage.setItem('buildline.sound', soundOn() ? 'off' : 'on'); } catch {} render(); if (soundOn()) chime(true); };
346
358
  $('#backdrop').onclick = closeDrawer;
347
359
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && openId && !editing) closeDrawer(); });
348
360
  document.addEventListener('click', (e) => {
@@ -352,15 +364,42 @@ document.addEventListener('click', (e) => {
352
364
  const x = e.target.closest('[data-ctx]'); if (x) { ctxPath = x.dataset.ctx; ctxEditing = false; render(); }
353
365
  });
354
366
 
367
+ // ---- sound: one chime when a feature needs a person or ships ----
368
+ let audio = null, needs = null, lastChime = 0;
369
+ const soundOn = () => { let v = null; try { v = localStorage.getItem('buildline.sound'); } catch {} return v ? v === 'on' : S?.config.sound !== false; };
370
+ const attention = (f) => (f.status === 'done' ? 'done' : f.blocked || f.live?.waiting ? 'waiting' : null);
371
+ // Browsers only allow sound after the page has been touched, so the audio starts on the first click or key.
372
+ const unlock = () => { audio ||= new (window.AudioContext || window.webkitAudioContext)(); audio.resume(); };
373
+ ['pointerdown', 'keydown'].forEach((e) => document.addEventListener(e, unlock, { once: true }));
374
+ function chime(force = false) {
375
+ if (!audio || (!force && Date.now() - lastChime < 3000)) return;
376
+ lastChime = Date.now();
377
+ const t = audio.currentTime, out = audio.createGain();
378
+ out.connect(audio.destination);
379
+ out.gain.setValueAtTime(0.0001, t);
380
+ out.gain.exponentialRampToValueAtTime(0.25, t + 0.01);
381
+ out.gain.exponentialRampToValueAtTime(0.0001, t + 1.2);
382
+ for (const [hz, level] of [[880, 1], [1320, 0.35], [1760, 0.15]]) {
383
+ const o = audio.createOscillator(), g = audio.createGain();
384
+ o.frequency.value = hz; g.gain.value = level;
385
+ o.connect(g).connect(out); o.start(t); o.stop(t + 1.25);
386
+ }
387
+ }
388
+ function listen() {
389
+ const now = new Map(S.features.map((f) => [f.id, attention(f)]));
390
+ if (needs && soundOn() && [...now].some(([id, a]) => a && needs.get(id) !== a)) chime();
391
+ needs = now;
392
+ }
393
+
355
394
  async function refresh() {
356
395
  try {
357
396
  const next = await api('/api/state');
358
397
  const changed = JSON.stringify(next) !== JSON.stringify(S);
359
- S = next; if (changed) await render();
398
+ S = next; listen(); if (changed) await render();
360
399
  } catch (e) { toast('Dashboard lost connection: is `buildline ui` still running?'); }
361
400
  }
362
401
  refresh();
363
- setInterval(() => { if (!document.hidden && !editing && !ctxEditing) refresh(); }, 2000);
402
+ setInterval(() => { if (!editing && !ctxEditing) refresh(); }, 2000);
364
403
  </script>
365
404
  </body>
366
405
  </html>
@@ -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, so
11
- // `buildline watch` and the dashboard can show live work.
12
- // Written to .buildline/sessions/ (git-ignored).
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,12 @@ 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
+
109
116
  function detail(input) {
110
117
  const ti = input.tool_input || {};
111
118
  if (ti.file_path) return path.basename(ti.file_path);
@@ -119,6 +126,56 @@ function detail(input) {
119
126
  return '';
120
127
  }
121
128
 
129
+ // Tools that stop the turn to ask the person something.
130
+ const ASKS = ['AskUserQuestion', 'ExitPlanMode'];
131
+
132
+ const oneLine = (s, n) => {
133
+ const t = String(s || '').replace(/\s+/g, ' ').trim();
134
+ return t.length > n ? t.slice(0, n - 1) + '…' : t;
135
+ };
136
+
137
+ /** What a session left running when its turn ended: background shells, subagents, scheduled checks. */
138
+ function stillRunning(input) {
139
+ const tasks = (Array.isArray(input.background_tasks) ? input.background_tasks : [])
140
+ .filter((t) => !t.status || t.status === 'running')
141
+ .map((t) => (t.type === 'shell' ? detail({ tool_input: { command: t.command || t.description } }) : `agent: ${oneLine(t.description, 40)}`));
142
+ const crons = (Array.isArray(input.session_crons) ? input.session_crons : []).map((c) => `scheduled: ${oneLine(c.prompt || c.cron || c.schedule || 'check', 36)}`);
143
+ return [...tasks, ...crons];
144
+ }
145
+
146
+ /** The end of the agent's last message (its last paragraph, or last sentence when that's long): what it's waiting for. */
147
+ function lastWords(message) {
148
+ const para = oneLine(String(message || '').trim().split(/\n\s*\n/).pop(), 1000);
149
+ if (para.length <= 90) return para || null;
150
+ return oneLine(/[^.!?]+[.!?]*\s*$/.exec(para)?.[0] || para, 140);
151
+ }
152
+
153
+ /** The last thing the agent said ends with a question: it wants an answer even if work runs on. */
154
+ const asks = (message) => /\?\s*$/.test(String(message || '').replace(/[`*_)\]\s]+$/, ''));
155
+
156
+ const quietParent = (prev) => prev.state === 'background' || prev.state === 'waiting';
157
+
158
+ /** What a session is doing after this event: working, background (turn over, tasks still running) or waiting on a person. */
159
+ function nextState(prev, input, event) {
160
+ const tool = input.tool_name;
161
+ const fromSubagent = Boolean(input.agent_id);
162
+ if (event === 'stop') {
163
+ const running = stillRunning(input);
164
+ const said = lastWords(input.last_assistant_message);
165
+ if (running.length && !asks(input.last_assistant_message)) return { state: 'background', waitingFor: null, background: running, said };
166
+ return { state: 'waiting', waitingFor: 'reply', background: running, said };
167
+ }
168
+ // A background subagent working on its own doesn't make the session busy again,
169
+ // nor does it answer a question the session is waiting on.
170
+ if (fromSubagent && quietParent(prev)) return {};
171
+ if (event === 'notify') return { state: 'waiting', waitingFor: prev.waitingFor === 'question' ? 'question' : 'permission', said: prev.waitingFor === 'question' ? prev.said : oneLine(input.message, 140) };
172
+ if (event === 'tool' && ASKS.includes(tool)) {
173
+ const q = input.tool_input?.questions?.[0]?.question || (tool === 'ExitPlanMode' ? 'Approve the plan?' : 'A question');
174
+ return { state: 'waiting', waitingFor: 'question', said: oneLine(q, 140), inTool: true };
175
+ }
176
+ return { state: 'working', waitingFor: null, background: [], said: null, inTool: event === 'tool' };
177
+ }
178
+
122
179
  function heartbeat(input, event) {
123
180
  const id = String(input.session_id || '').replace(/[^\w-]/g, '');
124
181
  if (!id) return;
@@ -139,11 +196,13 @@ function heartbeat(input, event) {
139
196
  feature,
140
197
  station: station || prev.station || null,
141
198
  model: input.model?.id || input.model || prev.model || null,
142
- state: event === 'stop' ? 'waiting' : 'working',
199
+ ...nextState(prev, input, event),
143
200
  lastAt: at,
144
- ...(event === 'tool' ? { tool: input.tool_name, detail: detail(input) } : {}),
145
- ...(event === 'prompt' ? { tool: null, detail: prompt.replace(/\s+/g, ' ').slice(0, 60) } : {}),
201
+ ...(event === 'tool' && !(input.agent_id && quietParent(prev)) ? { tool: input.tool_name, detail: detail(input) } : {}),
202
+ ...(event === 'prompt' ? { tool: null, detail: promptDetail(prompt) } : {}),
146
203
  };
204
+ // When the session entered its current state, so the views can say "working 4m", "waiting 12m".
205
+ next.since = next.state === prev.state && prev.since ? prev.since : at;
147
206
  fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n');
148
207
  if (event === 'tool' && feature) {
149
208
  // A short live activity feed for the views; trimmed so it never grows large.
@@ -165,6 +224,8 @@ process.stdin.on('end', () => {
165
224
  else if (mode === 'guard') guard(input);
166
225
  else if (mode === 'pre') heartbeat(input, 'tool'), guard(input);
167
226
  else if (mode === 'prompt') heartbeat(input, 'prompt');
227
+ else if (mode === 'post') heartbeat(input, 'post');
228
+ else if (mode === 'notify') heartbeat(input, 'notify');
168
229
  else if (mode === 'stop') heartbeat(input, 'stop');
169
230
  else if (mode === 'session-end') heartbeat(input, 'end');
170
231
  } catch (err) {