buildline 0.5.1 → 0.7.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,9 +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 an agent can work on it now; a **blinking dot** means it waits on a PM, designer or engineer
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
138
+ - run several sessions at once, one per feature: each gets its own track
138
139
  - the model under each station is the one that runs it
139
140
  - `↑↓` to inspect tasks and history, `⏎` to open the station on its model, `a` to approve, `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)
140
142
 
141
143
  ## The dashboard
142
144
 
@@ -195,6 +197,7 @@ npx buildline watch live view of the line in the terminal
195
197
  npx buildline step <station> [id] open a station on its model --headless --model
196
198
  npx buildline models choose models set <station|phase> <model> · check
197
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
198
201
  npx buildline gates run quality gates
199
202
  npx buildline doctor check the setup
200
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.5.1",
3
+ "version": "0.7.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
@@ -7,7 +7,8 @@ import { detect, describeStack } from './detect.js';
7
7
  import { AGENTS, AUTONOMY, VERSION, makeConfig, plan, apply, removePlan, applyRemove, defaultGates } from './install.js';
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
- import { listFeatures, findFeature, createFeature, approve, setStatus, nextAction, STATIONS, TYPES } from './features.js';
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) {
@@ -310,6 +324,8 @@ export function moveFeature(root, ref, station, opts) {
310
324
  const f = findFeature(root, ref);
311
325
  if (!f) throw new Error(`No feature matching "${ref}".`);
312
326
  if (!STATIONS.includes(station)) throw new Error(`Station must be one of: ${STATIONS.join(', ')}`);
327
+ const blocker = opts.force ? null : moveBlocker(f, station, loadConfig(root).autonomy ?? 2);
328
+ if (blocker) throw new Error(blocker.replace(/node \.buildline\/bin\/feature\.mjs approve/, 'npx buildline approve') + ' Or pass --force.');
313
329
  setStatus(f, station, opts.note);
314
330
  console.log(`${pc.green('✔')} ${f.id} → ${station}`);
315
331
  }
package/src/features.js CHANGED
@@ -3,6 +3,9 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { render, templatesDir } from './render.js';
6
+ import { moveBlocker } from '../templates/bin/feature.mjs';
7
+
8
+ export { moveBlocker };
6
9
 
7
10
  export const STATIONS = ['new', 'spec', 'design', 'plan', 'build', 'verify', 'review', 'ship', 'done'];
8
11
  export const TYPES = ['fix', 'feature', 'epic'];
@@ -126,9 +129,12 @@ export function nextAction(feature, autonomy = 2) {
126
129
  if (/TODO/.test(readSpecSection(feature, 'Acceptance') || 'TODO')) return { who: 'pm', text: 'needs requirements & acceptance', cmd: `/buildline-spec ${feature.id}` };
127
130
  return autonomy <= 3 && !a.spec ? { who: 'pm', text: 'waiting on spec approval', cmd: `buildline approve ${feature.id} spec` } : { who: 'agent', text: 'ready for design', cmd: `/buildline-design ${feature.id}` };
128
131
  case 'design':
132
+ if (autonomy <= 1 && a.design) return { who: 'agent', text: 'ready for a plan', cmd: `/buildline-plan ${feature.id}` };
129
133
  return { who: 'designer', text: 'needs UX / technical design', cmd: `/buildline-design ${feature.id}` };
130
134
  case 'plan':
131
- return { who: 'eng', text: 'needs a task plan', cmd: `/buildline-plan ${feature.id}` };
135
+ if (!tasks.length) return { who: 'eng', text: 'needs a task plan', cmd: `/buildline-plan ${feature.id}` };
136
+ if (feature.type !== 'fix' && autonomy <= 2 && !a.plan) return { who: 'eng', text: 'waiting on plan approval', cmd: `buildline approve ${feature.id} plan` };
137
+ return { who: 'agent', text: 'ready to build', cmd: `/buildline-build ${feature.id}` };
132
138
  case 'build':
133
139
  if (feature.type !== 'fix' && autonomy <= 3 && !a.spec) return { who: 'pm', text: 'waiting on spec approval', cmd: `buildline approve ${feature.id} spec` };
134
140
  if (feature.type !== 'fix' && autonomy <= 2 && !a.plan && tasks.length) return { who: 'eng', text: 'waiting on plan approval', cmd: `buildline approve ${feature.id} plan` };
@@ -155,3 +161,73 @@ export function readSpecSection(feature, heading) {
155
161
  return null;
156
162
  }
157
163
  }
164
+
165
+ // ---- live sessions -------------------------------------------------------------
166
+ // Claude Code hooks write one file per session to .buildline/sessions/ (see
167
+ // templates/bin/hook.mjs). A session that hasn't been heard from in a while has
168
+ // most likely crashed or been closed without a SessionEnd.
169
+
170
+ const WORKING_FOR = 10 * 60e3;
171
+ const WAITING_FOR = 2 * 3600e3;
172
+
173
+ export function readSessions(root, at = Date.now()) {
174
+ const dir = path.join(root, '.buildline', 'sessions');
175
+ const out = [];
176
+ // `tracked` says hooks are reporting here, so "no session" really means nobody is working.
177
+ out.tracked = fs.existsSync(dir);
178
+ if (!out.tracked) return out;
179
+ for (const name of fs.readdirSync(dir)) {
180
+ if (!name.endsWith('.json')) continue;
181
+ try {
182
+ const s = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
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 });
186
+ } catch {}
187
+ }
188
+ return Object.assign(out.sort((a, b) => a.age - b.age), { tracked: true });
189
+ }
190
+
191
+ /** Recent tool calls from live sessions, oldest first. */
192
+ export function readActivity(root, limit = 200) {
193
+ try {
194
+ const text = fs.readFileSync(path.join(root, '.buildline', 'sessions', 'activity.jsonl'), 'utf8');
195
+ return text.split('\n').filter(Boolean).slice(-limit).flatMap((l) => {
196
+ try {
197
+ return [JSON.parse(l)];
198
+ } catch {
199
+ return [];
200
+ }
201
+ });
202
+ } catch {
203
+ return [];
204
+ }
205
+ }
206
+
207
+ /**
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
210
+ * Claude Code), a feature whose files changed in the last two minutes counts as worked on.
211
+ */
212
+ export function liveState(feature, sessions, at = Date.now()) {
213
+ const mine = sessions.filter((s) => s.feature === feature.id);
214
+ const working = mine.filter((s) => s.state === 'working');
215
+ const recent = at - new Date(feature.updated || 0) < 2 * 60e3;
216
+ return {
217
+ sessions: mine,
218
+ working: working.length > 0 || (!sessions.tracked && recent && !feature.blocked && feature.status !== 'done'),
219
+ waiting: !working.length && mine.some((s) => s.state === 'waiting'),
220
+ };
221
+ }
222
+
223
+ /** What the chime is for: 'waiting' when a feature needs a person, 'done' once it ships, else null. */
224
+ export function attention(feature, live) {
225
+ if (feature.status === 'done') return 'done';
226
+ return feature.blocked || live?.waiting ? 'waiting' : null;
227
+ }
228
+
229
+ /** Ids whose attention changed to waiting or done since `before` (a Map from the last look). */
230
+ export function newAttention(before, now) {
231
+ if (!before) return [];
232
+ return [...now].filter(([id, a]) => a && before.get(id) !== a).map(([id]) => id);
233
+ }
package/src/install.js CHANGED
@@ -140,6 +140,7 @@ export function buildEntries(root, config) {
140
140
  for (const f of ['spec.md', 'progress.md']) add(`.buildline/templates/${f}`, read(`feature/${f}`));
141
141
  add('.buildline/bin/gates.mjs', read('bin/gates.mjs'), { executable: true });
142
142
  add('.buildline/bin/hook.mjs', read('bin/hook.mjs'), { executable: true });
143
+ add('.buildline/bin/feature.mjs', read('bin/feature.mjs'), { executable: true });
143
144
  add('.buildline/features/.gitkeep', '');
144
145
  if (config.ci) add('.github/workflows/buildline.yml', render(read('ci/buildline.yml'), vars));
145
146
  return entries;
@@ -151,7 +152,7 @@ const HOOK_MARK = '.buildline/bin/hook.mjs';
151
152
  const hookCmd = (mode) => `node "$CLAUDE_PROJECT_DIR/.buildline/bin/hook.mjs" ${mode}`;
152
153
 
153
154
  export function settingsAllow(config) {
154
- const allow = ['Bash(node .buildline/bin/gates.mjs)', 'Bash(node .buildline/bin/gates.mjs *)', 'Bash(npx buildline *)'];
155
+ const allow = ['Bash(node .buildline/bin/gates.mjs)', 'Bash(node .buildline/bin/gates.mjs *)', 'Bash(node .buildline/bin/feature.mjs *)', 'Bash(npx buildline *)'];
155
156
  for (const g of config.gates) allow.push(`Bash(${g.command})`, `Bash(${g.command} *)`);
156
157
  return [...new Set(allow)];
157
158
  }
@@ -169,13 +170,20 @@ export function mergeSettings(root, config) {
169
170
  }
170
171
  const before = JSON.stringify(settings);
171
172
  settings.hooks ??= {};
173
+ // One Buildline hook per event; older installs get their entry upgraded in place.
172
174
  const ensureHook = (event, matcher, mode) => {
173
175
  const groups = (settings.hooks[event] ??= []);
174
- const present = groups.some((g) => (g.hooks || []).some((h) => h.command?.includes(HOOK_MARK)));
175
- if (!present) groups.push({ ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command: hookCmd(mode) }] });
176
+ const ours = groups.find((g) => (g.hooks || []).some((h) => h.command?.includes(HOOK_MARK)));
177
+ if (!ours) return groups.push({ ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command: hookCmd(mode) }] });
178
+ if (matcher) ours.matcher = matcher;
179
+ else delete ours.matcher;
180
+ for (const h of ours.hooks) if (h.command?.includes(HOOK_MARK)) h.command = hookCmd(mode);
176
181
  };
177
182
  ensureHook('SessionStart', null, 'session-start');
178
- ensureHook('PreToolUse', 'Edit|Write|MultiEdit', 'guard');
183
+ ensureHook('PreToolUse', null, 'pre');
184
+ ensureHook('UserPromptSubmit', null, 'prompt');
185
+ ensureHook('Stop', null, 'stop');
186
+ ensureHook('SessionEnd', null, 'session-end');
179
187
 
180
188
  settings.permissions ??= {};
181
189
  settings.permissions.allow ??= [];
package/src/server.js CHANGED
@@ -8,7 +8,7 @@ import crypto from 'node:crypto';
8
8
  import { spawn } from 'node:child_process';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import pc from 'picocolors';
11
- import { listFeatures, findFeature, createFeature, approve, setStatus, nextAction } from './features.js';
11
+ import { listFeatures, findFeature, createFeature, approve, setStatus, nextAction, readSessions, liveState } from './features.js';
12
12
  import { AUTONOMY, VERSION } from './install.js';
13
13
  import { goalFor } from './cli.js';
14
14
 
@@ -33,11 +33,14 @@ 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;
39
+ const sessions = readSessions(root);
38
40
  const features = listFeatures(root).map(({ path: _p, ...f }) => ({
39
41
  ...f,
40
42
  next: nextAction({ ...f, path: _p }, config.autonomy),
43
+ live: liveState(f, sessions),
41
44
  goal: goalFor(f),
42
45
  }));
43
46
  const contextDir = path.join(root, '.buildline', 'context');
@@ -87,7 +90,7 @@ export function serve(root, opts = {}) {
87
90
  if (!url.pathname.startsWith('/api/')) return send(404, { error: 'Not found' });
88
91
  if (req.method !== 'GET' && req.headers['x-buildline-token'] !== token) return send(403, { error: 'Bad token' });
89
92
 
90
- 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));
91
94
 
92
95
  if (url.pathname === '/api/file') {
93
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
@@ -7,7 +7,8 @@ import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { spawnSync } from 'node:child_process';
9
9
  import pc from 'picocolors';
10
- import { listFeatures, nextAction, approve } from './features.js';
10
+ import { listFeatures, nextAction, approve, readSessions, readActivity, liveState, attention, newAttention } from './features.js';
11
+ import { chime, soundOn } from './sound.js';
11
12
  import { modelFor, describeModel } from './models.js';
12
13
  import { AUTONOMY, VERSION } from './install.js';
13
14
 
@@ -41,6 +42,7 @@ const C = {
41
42
  amberDim: fg('#8a6a1a'),
42
43
  green: fg('#4ec27f'),
43
44
  red: fg('#f7768e'),
45
+ cyan: fg('#59c2e6'),
44
46
  flash: pc.isColorSupported ? (s) => `\x1b[48;5;94m\x1b[38;5;230m${s}\x1b[39m\x1b[49m` : null,
45
47
  };
46
48
  const WHO = {
@@ -102,11 +104,18 @@ function box(screen, x, y, w, h, title, { accent = false } = {}) {
102
104
  function loadState(root, showShipped) {
103
105
  const config = JSON.parse(fs.readFileSync(path.join(root, '.buildline', 'buildline.json'), 'utf8'));
104
106
  const all = listFeatures(root);
105
- const features = all.filter((f) => showShipped || f.status !== 'done').map((f) => ({ ...f, next: nextAction(f, config.autonomy) }));
107
+ const sessions = readSessions(root);
108
+ const features = all
109
+ .filter((f) => showShipped || f.status !== 'done')
110
+ .map((f) => ({ ...f, next: nextAction(f, config.autonomy), live: liveState(f, sessions) }));
111
+ // Station history plus the live tool feed from running sessions, in time order.
112
+ const tools = readActivity(root).map((a) => ({ at: a.at, id: a.feature, station: a.tool, note: a.detail, tool: true, key: `t|${a.at}|${a.session}|${a.detail}` }));
106
113
  const events = all
107
114
  .flatMap((f) => (f.history || []).map((h) => ({ ...h, id: f.id, key: `${f.id}|${h.at}|${h.note}` })))
115
+ .concat(tools)
108
116
  .sort((a, b) => String(a.at).localeCompare(String(b.at)));
109
- return { config, all, features, events };
117
+ const needs = new Map(all.map((f) => [f.id, attention(f, liveState(f, sessions))]));
118
+ return { config, all, features, events, sessions, needs };
110
119
  }
111
120
 
112
121
  const stationIndex = (status) => (status === 'new' ? 0 : status === 'done' ? TRACK.length : TRACK.indexOf(status));
@@ -114,6 +123,21 @@ const shortModel = (config, station) => {
114
123
  const m = modelFor(config, station);
115
124
  return m.cli === 'claude' && !m.model ? 'session' : describeModel(m).replace(/^claude-/, '');
116
125
  };
126
+ 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`);
127
+
128
+ /** The NEXT UP cell: who or what the feature is waiting on right now. */
129
+ function rowStatus(f, t) {
130
+ if (f.blocked) return ['BLOCKED', C.red];
131
+ if (f.live.working) {
132
+ const n = f.live.sessions.filter((x) => x.state === 'working').length;
133
+ return [`${SPINNER[t % SPINNER.length]} ${n > 1 ? `×${n} ` : ''}WORKING`, C.amberBold];
134
+ }
135
+ if (f.live.waiting) return ['NEEDS YOU', t % 12 < 8 ? C.red : C.faint];
136
+ if (f.next.who === 'agent') return ['AGENT', C.amberDim];
137
+ const who = WHO[f.next.who];
138
+ return who ? [who.label, who.color] : ['', null];
139
+ }
140
+
117
141
  const hash = (s) => [...String(s)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7);
118
142
 
119
143
  function timeLabel(iso) {
@@ -141,7 +165,9 @@ function drawTrack(line, x, f, seg, t) {
141
165
  const cur = stationIndex(f.status);
142
166
  const who = WHO[f.next.who];
143
167
  const skipped = (i) => f.type === 'fix' && i < 3 && cur >= 3;
144
- const working = f.next.who === 'agent' && !f.blocked;
168
+ // The comet only runs while a session is actually working on this feature.
169
+ const working = f.live?.working && !f.blocked;
170
+ const idleAgent = f.next.who === 'agent' && !f.blocked && !f.live?.waiting;
145
171
  for (let i = 0; i < TRACK.length; i++) {
146
172
  const nx = x + i * seg;
147
173
  let node = ['○', C.faint];
@@ -149,6 +175,8 @@ function drawTrack(line, x, f, seg, t) {
149
175
  else if (i < cur) node = ['●', C.green];
150
176
  else if (i === cur && f.blocked) node = ['✖', t % 10 < 5 ? C.red : C.faint];
151
177
  else if (i === cur && working) node = [['◉', '◎', '◉', '●'][Math.floor(t / 3) % 4], C.amberBold];
178
+ else if (i === cur && f.live?.waiting) node = ['◉', t % 12 < 8 ? C.red : C.faint];
179
+ else if (i === cur && idleAgent) node = ['◉', C.amberDim];
152
180
  else if (i === cur) node = ['◉', t % 12 < 8 ? who?.color || C.amber : C.faint];
153
181
  line.put(nx, node[0], node[1]);
154
182
  if (i === TRACK.length - 1) break;
@@ -180,7 +208,8 @@ function render(st, ui, W, H) {
180
208
  const active = st.all.filter((f) => f.status !== 'done');
181
209
  const shipped = st.all.length - active.length;
182
210
  const tasks = active.flatMap((f) => f.tasks || []);
183
- const waiting = active.filter((f) => ['pm', 'designer', 'eng', 'human'].includes(nextAction(f, config.autonomy).who)).length;
211
+ const waiting = features.filter((f) => f.status !== 'done' && !f.live.working && (f.live.waiting || ['pm', 'designer', 'eng', 'human'].includes(f.next.who))).length;
212
+ const sessionsWorking = st.sessions.filter((x) => x.state === 'working').length;
184
213
 
185
214
  // header
186
215
  const tall = W >= 96 && H >= 26;
@@ -196,6 +225,7 @@ function render(st, ui, W, H) {
196
225
  right(0, [[config.project || path.basename(ui.root), C.bold], [' · ', C.faint], [AUTONOMY[config.autonomy]?.label || `L${config.autonomy}`, C.amber], [' · ', C.faint], [live, C.green]]);
197
226
  right(1, [
198
227
  ['ACTIVE ', C.muted], [String(active.length), C.bold], [' SHIPPED ', C.muted], [String(shipped), C.green],
228
+ [' SESSIONS ', C.muted], [String(sessionsWorking), sessionsWorking ? C.amberBold : C.bold],
199
229
  [' TASKS ', C.muted], [`${tasks.filter((x) => x.passes).length}/${tasks.length}`, C.bold],
200
230
  [' WAITING ON PEOPLE ', C.muted], [String(waiting), waiting ? WHO.pm.color : C.bold],
201
231
  ]);
@@ -230,13 +260,13 @@ function render(st, ui, W, H) {
230
260
  if (seg >= 7) screen[top + 2].put(nx - Math.floor(m.length / 2), m, C.faint);
231
261
  });
232
262
  screen[top + 1].put(rx, 'FEATURE', C.muted);
233
- screen[top + 1].put(W - 3 - 7, 'NEXT UP', C.muted);
263
+ screen[top + 1].put(W - 3 - 3, 'NOW', C.muted);
234
264
  const rowsAvail = lineH - 5;
235
265
  if (ui.sel < ui.scroll) ui.scroll = ui.sel;
236
266
  if (ui.sel >= ui.scroll + rowsAvail) ui.scroll = ui.sel - rowsAvail + 1;
237
267
  if (!features.length) {
238
268
  const line = screen[top + 4];
239
- drawTrack(line, tx, { id: 'idle', status: 'spec', type: 'feature', next: { who: 'agent' } }, seg, t);
269
+ drawTrack(line, tx, { id: 'idle', status: 'spec', type: 'feature', next: { who: 'agent' }, live: { working: true, sessions: [] } }, seg, t);
240
270
  line.put(rx, 'the line is quiet · ', C.muted);
241
271
  line.put(rx + 20, 'npx buildline new "…"', C.amber);
242
272
  }
@@ -248,8 +278,8 @@ function render(st, ui, W, H) {
248
278
  line.put(2, selected ? '▸' : ' ', C.amberBold);
249
279
  line.put(4, f.id, flashing ? C.flash : selected ? C.amberBold : C.muted);
250
280
  drawTrack(line, tx, f, seg, t);
251
- const who = WHO[f.next.who];
252
- const whoW = 8;
281
+ const [label, labelColor] = rowStatus(f, t);
282
+ const whoW = 12;
253
283
  const ts = f.tasks || [];
254
284
  const roomy = W - 3 - whoW - rx >= 30;
255
285
  const barW = ts.length ? (roomy ? 11 : 5) : 0;
@@ -262,7 +292,7 @@ function render(st, ui, W, H) {
262
292
  if (roomy) bx = line.put(line.put(bx, '▰'.repeat(filled), C.green), '▱'.repeat(5 - filled), C.faint) + 1;
263
293
  line.put(bx, `${pass}/${ts.length}`, pass === ts.length ? C.green : C.muted);
264
294
  }
265
- if (who) line.put(W - 3 - who.label.length, who.label, who.color);
295
+ if (label) line.put(W - 3 - [...label].length, label, labelColor);
266
296
  });
267
297
  if (features.length > rowsAvail) screen[top + lineH - 1].put(W - 16, ` ${ui.sel + 1}/${features.length} `, C.muted);
268
298
 
@@ -276,7 +306,7 @@ function render(st, ui, W, H) {
276
306
  }
277
307
 
278
308
  // footer
279
- const keys = [['↑↓', 'select'], ['⏎', 'open'], ['a', 'approve'], ['u', 'dashboard'], ['s', ui.shipped ? 'hide shipped' : 'shipped'], ['q', 'quit']];
309
+ const keys = [['↑↓', 'select'], ['⏎', 'open'], ['a', 'approve'], ['u', 'dashboard'], ['s', ui.shipped ? 'hide shipped' : 'shipped'], ['m', ui.sound ? 'mute' : 'unmute'], ['q', 'quit']];
280
310
  let fx = 1;
281
311
  const foot = screen[H - 1];
282
312
  if (ui.message && ui.messageUntil > Date.now()) fx = foot.put(1, ` ${ui.message} `, C.flash);
@@ -306,6 +336,24 @@ function drawInspector(screen, st, f, x, y, w, h) {
306
336
  return a ? [[`${s} ✔ ${a.by} `, C.green]] : [[`${s} · `, C.faint]];
307
337
  });
308
338
  add(['approvals ', C.muted], ...appr.flat());
339
+ if (f.blocked) {
340
+ const width = Math.max(10, w - 15);
341
+ const words = String(f.blocked).split(/\s+/);
342
+ const lines = [''];
343
+ for (const word of words) (lines.at(-1) + ' ' + word).trim().length > width ? lines.push(word) : (lines[lines.length - 1] = (lines.at(-1) + ' ' + word).trim());
344
+ lines.slice(0, 4).forEach((l, i) => add([i ? ' ' : '✖ blocked ', C.red], [l, C.bold]));
345
+ }
346
+ for (const ses of f.live?.sessions || []) {
347
+ const on = ses.state === 'working';
348
+ add(
349
+ [on ? '● ' : '◌ ', on ? C.amberBold : C.red],
350
+ [`session ${String(ses.id).slice(0, 8)} `, C.muted],
351
+ [`${(ses.model ? String(ses.model) : shortModel(config, f.status)).replace(/^claude-/, '')} `, C.text],
352
+ [on ? `${ses.tool || 'thinking'} ` : 'waiting for your reply ', on ? C.cyan : C.red],
353
+ [on ? `${ses.detail || ''} ` : '', C.text],
354
+ [`· ${ago(ses.age)} ago`, C.faint],
355
+ );
356
+ }
309
357
  const ts = f.tasks || [];
310
358
  if (ts.length) {
311
359
  add();
@@ -324,7 +372,8 @@ function drawInspector(screen, st, f, x, y, w, h) {
324
372
  }
325
373
  add();
326
374
  const who = WHO[f.next.who];
327
- add(['next ', C.muted], [who ? who.label + ' ' : '', who?.color || C.text], [f.next.text, C.text]);
375
+ if (f.blocked) add(['next ', C.muted], ['answer the question in the session that asked it', C.text]);
376
+ else add(['next ', C.muted], [who ? who.label + ' ' : '', who?.color || C.text], [f.next.text, C.text]);
328
377
  if (f.next.cmd) add(['❯ ', C.amberBold], [f.next.cmd, C.amber]);
329
378
  const room = h - 2 - rows.length - 2;
330
379
  const journey = (f.history || []).slice(-room);
@@ -346,9 +395,9 @@ function drawEvents(screen, st, ui, x, y, w, h) {
346
395
  const line = screen[y + 1 + r];
347
396
  const fresh = (ui.freshEvents.get(e.key) || 0) > Date.now();
348
397
  let cx = line.put(x + 2, timeLabel(e.at), C.faint);
349
- cx = line.put(cx + 1, e.id, fresh ? C.flash : C.amber);
350
- cx = line.put(cx + 1, String(e.station || '').padEnd(7), C.muted);
351
- line.put(cx + 1, e.note || '', fresh ? C.bold : C.text, x + w - 2);
398
+ cx = line.put(cx + 1, e.id || '···', fresh ? C.flash : C.amber);
399
+ cx = line.put(cx + 1, clip(e.station || '', 7).padEnd(7), e.tool ? C.cyan : C.muted);
400
+ line.put(cx + 1, e.note || '', e.tool ? C.muted : fresh ? C.bold : C.text, x + w - 2);
352
401
  });
353
402
  if (!rows.length) screen[y + 1].put(x + 2, 'nothing yet', C.faint);
354
403
  }
@@ -399,8 +448,10 @@ export async function watch(root, opts = {}) {
399
448
  }
400
449
  const ui = { root, tick: 0, sel: 0, scroll: 0, flash: new Map(), freshEvents: new Map(), shipped: false, message: '', messageUntil: 0 };
401
450
  let st = loadState(root, ui.shipped);
451
+ ui.sound = opts.sound !== false && soundOn(st.config);
402
452
  let lastStatus = new Map(st.all.map((f) => [f.id, f.status]));
403
453
  let seen = new Set(st.events.map((e) => e.key));
454
+ let needs = st.needs;
404
455
 
405
456
  out.write('\x1b[?1049h\x1b[?25l');
406
457
  if (opts.intro !== false) await boot(out, st, root);
@@ -415,6 +466,8 @@ export async function watch(root, opts = {}) {
415
466
  if (lastStatus.has(f.id) && lastStatus.get(f.id) !== f.status) ui.flash.set(f.id, Date.now() + 2500);
416
467
  }
417
468
  for (const e of st.events) if (!seen.has(e.key)) ui.freshEvents.set(e.key, Date.now() + 4000);
469
+ if (newAttention(needs, st.needs).length && ui.sound) chime(out);
470
+ needs = st.needs;
418
471
  lastStatus = new Map(st.all.map((f) => [f.id, f.status]));
419
472
  seen = new Set(st.events.map((e) => e.key));
420
473
  ui.sel = Math.max(0, Math.min(ui.sel, st.features.length - 1));
@@ -449,6 +502,7 @@ export async function watch(root, opts = {}) {
449
502
  if (k === '\x1b[A' || k === 'k') ui.sel = Math.max(0, ui.sel - 1);
450
503
  else if (k === '\x1b[B' || k === 'j') ui.sel = Math.min(st.features.length - 1, ui.sel + 1);
451
504
  else if (k === 's') (ui.shipped = !ui.shipped), refresh();
505
+ else if (k === 'm') (ui.sound = !ui.sound), say(ui.sound ? '♪ sound on' : 'sound muted until you quit');
452
506
  else if (k === 'u') return done({ action: 'ui' });
453
507
  else if ((k === '\r' || k === '\n') && f) {
454
508
  if (f.status === 'done') say(`${f.id} has shipped`);