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 +4 -1
- package/bin/buildline.js +7 -3
- package/package.json +1 -1
- package/src/cli.js +17 -1
- package/src/features.js +77 -1
- package/src/install.js +12 -4
- package/src/server.js +6 -3
- package/src/sound.js +29 -0
- package/src/tui.js +69 -15
- package/src/ui/index.html +73 -7
- package/templates/AGENTS.block.md +4 -2
- package/templates/bin/feature.mjs +260 -0
- package/templates/bin/hook.mjs +88 -3
- package/templates/stations/autopilot.md +1 -1
- package/templates/stations/build.md +14 -5
- package/templates/stations/design.md +2 -2
- package/templates/stations/new.md +6 -8
- package/templates/stations/plan.md +9 -14
- package/templates/stations/review.md +1 -1
- package/templates/stations/ship.md +1 -1
- package/templates/stations/spec.md +4 -3
- package/templates/stations/verify.md +2 -2
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
|
|
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.
|
|
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
|
|
175
|
-
if (!
|
|
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',
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 -
|
|
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
|
|
252
|
-
const whoW =
|
|
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 (
|
|
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], [
|
|
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,
|
|
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`);
|