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 +2 -0
- package/bin/buildline.js +7 -3
- package/package.json +1 -1
- package/src/cli.js +14 -0
- package/src/features.js +12 -0
- package/src/server.js +3 -2
- package/src/sound.js +29 -0
- package/src/tui.js +10 -3
- package/src/ui/index.html +35 -2
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.
|
|
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
|
-
|
|
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 (!
|
|
396
|
+
setInterval(() => { if (!editing && !ctxEditing) refresh(); }, 2000);
|
|
364
397
|
</script>
|
|
365
398
|
</body>
|
|
366
399
|
</html>
|