buildline 0.6.0 → 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
@@ -138,6 +138,7 @@ Start at 2. Raise it as your gates and acceptance scenarios earn trust. [More on
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
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)
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.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
@@ -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
@@ -219,3 +219,15 @@ export function liveState(feature, sessions, at = Date.now()) {
219
219
  waiting: !working.length && mine.some((s) => s.state === 'waiting'),
220
220
  };
221
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/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
@@ -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, readSessions, readActivity, liveState } 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
 
@@ -113,7 +114,8 @@ function loadState(root, showShipped) {
113
114
  .flatMap((f) => (f.history || []).map((h) => ({ ...h, id: f.id, key: `${f.id}|${h.at}|${h.note}` })))
114
115
  .concat(tools)
115
116
  .sort((a, b) => String(a.at).localeCompare(String(b.at)));
116
- return { config, all, features, events, sessions };
117
+ const needs = new Map(all.map((f) => [f.id, attention(f, liveState(f, sessions))]));
118
+ return { config, all, features, events, sessions, needs };
117
119
  }
118
120
 
119
121
  const stationIndex = (status) => (status === 'new' ? 0 : status === 'done' ? TRACK.length : TRACK.indexOf(status));
@@ -304,7 +306,7 @@ function render(st, ui, W, H) {
304
306
  }
305
307
 
306
308
  // footer
307
- 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']];
308
310
  let fx = 1;
309
311
  const foot = screen[H - 1];
310
312
  if (ui.message && ui.messageUntil > Date.now()) fx = foot.put(1, ` ${ui.message} `, C.flash);
@@ -446,8 +448,10 @@ export async function watch(root, opts = {}) {
446
448
  }
447
449
  const ui = { root, tick: 0, sel: 0, scroll: 0, flash: new Map(), freshEvents: new Map(), shipped: false, message: '', messageUntil: 0 };
448
450
  let st = loadState(root, ui.shipped);
451
+ ui.sound = opts.sound !== false && soundOn(st.config);
449
452
  let lastStatus = new Map(st.all.map((f) => [f.id, f.status]));
450
453
  let seen = new Set(st.events.map((e) => e.key));
454
+ let needs = st.needs;
451
455
 
452
456
  out.write('\x1b[?1049h\x1b[?25l');
453
457
  if (opts.intro !== false) await boot(out, st, root);
@@ -462,6 +466,8 @@ export async function watch(root, opts = {}) {
462
466
  if (lastStatus.has(f.id) && lastStatus.get(f.id) !== f.status) ui.flash.set(f.id, Date.now() + 2500);
463
467
  }
464
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;
465
471
  lastStatus = new Map(st.all.map((f) => [f.id, f.status]));
466
472
  seen = new Set(st.events.map((e) => e.key));
467
473
  ui.sel = Math.max(0, Math.min(ui.sel, st.features.length - 1));
@@ -496,6 +502,7 @@ export async function watch(root, opts = {}) {
496
502
  if (k === '\x1b[A' || k === 'k') ui.sel = Math.max(0, ui.sel - 1);
497
503
  else if (k === '\x1b[B' || k === 'j') ui.sel = Math.min(st.features.length - 1, ui.sel + 1);
498
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');
499
506
  else if (k === 'u') return done({ action: 'ui' });
500
507
  else if ((k === '\r' || k === '\n') && f) {
501
508
  if (f.status === 'done') say(`${f.id} has shipped`);
package/src/ui/index.html CHANGED
@@ -152,6 +152,7 @@ dialog h2 { margin: 0 0 12px; font-size: 17px; }
152
152
  <button role="tab" aria-selected="false" data-view="context">Context</button>
153
153
  </nav>
154
154
  <span class="spacer"></span>
155
+ <button class="btn" id="soundBtn" aria-pressed="true"></button>
155
156
  <button class="btn" id="whoami" title="Name used for approvals"></button>
156
157
  <button class="btn primary" id="newBtn">+ New</button>
157
158
  </header>
@@ -287,6 +288,10 @@ async function render() {
287
288
  $('#project').textContent = S.config.project;
288
289
  $('#autonomy').textContent = S.autonomyLabel || `L${S.config.autonomy}`;
289
290
  $('#whoami').textContent = me() ? `👤 ${me()}` : 'Set your name';
291
+ const on = soundOn();
292
+ $('#soundBtn').textContent = on ? '♪ Sound on' : '♪ Sound off';
293
+ $('#soundBtn').setAttribute('aria-pressed', on);
294
+ $('#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
295
  document.querySelectorAll('nav.tabs button').forEach((b) => b.setAttribute('aria-selected', b.dataset.view === view));
291
296
  if (!(view === 'context' && ctxEditing && $('#ctxEditor'))) $('#main').innerHTML = view === 'board' ? renderBoard() : await renderContext();
292
297
  if (openId) await renderDrawer();
@@ -343,6 +348,7 @@ $('#newDialog').addEventListener('close', async () => {
343
348
  });
344
349
  $('#newBtn').onclick = newFeature;
345
350
  $('#whoami').onclick = askName;
351
+ $('#soundBtn').onclick = () => { try { localStorage.setItem('buildline.sound', soundOn() ? 'off' : 'on'); } catch {} render(); if (soundOn()) chime(true); };
346
352
  $('#backdrop').onclick = closeDrawer;
347
353
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && openId && !editing) closeDrawer(); });
348
354
  document.addEventListener('click', (e) => {
@@ -352,15 +358,42 @@ document.addEventListener('click', (e) => {
352
358
  const x = e.target.closest('[data-ctx]'); if (x) { ctxPath = x.dataset.ctx; ctxEditing = false; render(); }
353
359
  });
354
360
 
361
+ // ---- sound: one chime when a feature needs a person or ships ----
362
+ let audio = null, needs = null, lastChime = 0;
363
+ const soundOn = () => { let v = null; try { v = localStorage.getItem('buildline.sound'); } catch {} return v ? v === 'on' : S?.config.sound !== false; };
364
+ const attention = (f) => (f.status === 'done' ? 'done' : f.blocked || f.live?.waiting ? 'waiting' : null);
365
+ // Browsers only allow sound after the page has been touched, so the audio starts on the first click or key.
366
+ const unlock = () => { audio ||= new (window.AudioContext || window.webkitAudioContext)(); audio.resume(); };
367
+ ['pointerdown', 'keydown'].forEach((e) => document.addEventListener(e, unlock, { once: true }));
368
+ function chime(force = false) {
369
+ if (!audio || (!force && Date.now() - lastChime < 3000)) return;
370
+ lastChime = Date.now();
371
+ const t = audio.currentTime, out = audio.createGain();
372
+ out.connect(audio.destination);
373
+ out.gain.setValueAtTime(0.0001, t);
374
+ out.gain.exponentialRampToValueAtTime(0.25, t + 0.01);
375
+ out.gain.exponentialRampToValueAtTime(0.0001, t + 1.2);
376
+ for (const [hz, level] of [[880, 1], [1320, 0.35], [1760, 0.15]]) {
377
+ const o = audio.createOscillator(), g = audio.createGain();
378
+ o.frequency.value = hz; g.gain.value = level;
379
+ o.connect(g).connect(out); o.start(t); o.stop(t + 1.25);
380
+ }
381
+ }
382
+ function listen() {
383
+ const now = new Map(S.features.map((f) => [f.id, attention(f)]));
384
+ if (needs && soundOn() && [...now].some(([id, a]) => a && needs.get(id) !== a)) chime();
385
+ needs = now;
386
+ }
387
+
355
388
  async function refresh() {
356
389
  try {
357
390
  const next = await api('/api/state');
358
391
  const changed = JSON.stringify(next) !== JSON.stringify(S);
359
- S = next; if (changed) await render();
392
+ S = next; listen(); if (changed) await render();
360
393
  } catch (e) { toast('Dashboard lost connection: is `buildline ui` still running?'); }
361
394
  }
362
395
  refresh();
363
- setInterval(() => { if (!document.hidden && !editing && !ctxEditing) refresh(); }, 2000);
396
+ setInterval(() => { if (!editing && !ctxEditing) refresh(); }, 2000);
364
397
  </script>
365
398
  </body>
366
399
  </html>