buildline 0.3.1 → 0.5.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 +20 -8
- package/bin/buildline.js +12 -4
- package/package.json +1 -1
- package/src/cli.js +111 -31
- package/src/models.js +96 -11
- package/src/tui.js +474 -0
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ independent verification, and a clear place for developers, designers and produc
|
|
|
20
20
|
npx buildline
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
**[Website & docs](https://julianoczkowski.github.io/buildline/)** · [Quick start](#quick-start) · [How it works](#how-it-works) · [Models per station](#models-per-station) · [Dashboard](#the-dashboard)
|
|
23
|
+
**[Website & docs](https://julianoczkowski.github.io/buildline/)** · [Quick start](#quick-start) · [How it works](#how-it-works) · [Models per station](#models-per-station) · [Watch](#watch-the-line) · [Dashboard](#the-dashboard)
|
|
24
24
|
|
|
25
25
|
</div>
|
|
26
26
|
|
|
@@ -92,19 +92,20 @@ Role guides: [product managers](docs/for-product-managers.md) · [designers](doc
|
|
|
92
92
|
|
|
93
93
|
## Models per station
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
Pick the model for each job: Fable to plan, Opus to build, Codex to review, or anything else your tools support. Buildline doesn't ship a model list. The picker reads what's available from your installed Claude Code and the Anthropic API, and you can always type a model in, so new models work on day one.
|
|
96
96
|
|
|
97
97
|
```bash
|
|
98
|
-
npx buildline models
|
|
99
|
-
npx buildline models set
|
|
100
|
-
npx buildline models
|
|
98
|
+
npx buildline models # pick by phase or per station
|
|
99
|
+
npx buildline models set planning fable # spec, design, plan
|
|
100
|
+
npx buildline models set building claude-opus-5-5 # build
|
|
101
|
+
npx buildline models check # what each choice resolves to today
|
|
101
102
|
```
|
|
102
103
|
|
|
103
104
|
```json
|
|
104
|
-
"models": { "
|
|
105
|
+
"models": { "plan": "fable", "build": "claude-opus-5-5", "review": "codex", "default": "sonnet" }
|
|
105
106
|
```
|
|
106
107
|
|
|
107
|
-
Claude Code skills and subagents switch model per station. A station
|
|
108
|
+
Claude Code skills and subagents switch model per station. A station set to Codex or Gemini is handed off automatically, and `buildline step <station> <id>` opens it in the right tool. [More on models →](docs/models.md)
|
|
108
109
|
|
|
109
110
|
## Guardrails that hold
|
|
110
111
|
|
|
@@ -127,6 +128,16 @@ Instructions in prose are advice. These are enforced:
|
|
|
127
128
|
|
|
128
129
|
Start at 2. Raise it as your gates and acceptance scenarios earn trust. [More on autonomy →](docs/autonomy.md)
|
|
129
130
|
|
|
131
|
+
## Watch the line
|
|
132
|
+
|
|
133
|
+
`npx buildline watch` is the same picture for the terminal: every feature on its own track through the stations, updating live as agents and people move work along.
|
|
134
|
+
|
|
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
|
+
|
|
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
|
|
138
|
+
- the model under each station is the one that runs it
|
|
139
|
+
- `↑↓` to inspect tasks and history, `⏎` to open the station on its model, `a` to approve, `q` to quit
|
|
140
|
+
|
|
130
141
|
## The dashboard
|
|
131
142
|
|
|
132
143
|
`npx buildline ui` opens a local board so people who don't live in a terminal can take part:
|
|
@@ -180,8 +191,9 @@ npx buildline status the board in your terminal --json
|
|
|
180
191
|
npx buildline new "<title>" open a feature --type fix|feature|epic
|
|
181
192
|
npx buildline approve <id> <stage> record an approval (spec | design | plan)
|
|
182
193
|
npx buildline ui dashboard
|
|
194
|
+
npx buildline watch live view of the line in the terminal
|
|
183
195
|
npx buildline step <station> [id] open a station on its model --headless --model
|
|
184
|
-
npx buildline models models
|
|
196
|
+
npx buildline models choose models set <station|phase> <model> · check
|
|
185
197
|
npx buildline run <id> autopilot with Claude Code /goal --headless
|
|
186
198
|
npx buildline gates run quality gates
|
|
187
199
|
npx buildline doctor check the setup
|
package/bin/buildline.js
CHANGED
|
@@ -18,9 +18,10 @@ ${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')}
|
|
21
22
|
${pc.cyan('ui')} open the dashboard in your browser ${pc.dim('--port <n> --no-open')}
|
|
22
23
|
${pc.cyan('step')} <station> [id] open a station in the CLI and model set for it ${pc.dim('--model <m> --headless --print')}
|
|
23
|
-
${pc.cyan('models')}
|
|
24
|
+
${pc.cyan('models')} choose models per station ${pc.dim('set <station|phase> <model> · check')}
|
|
24
25
|
${pc.cyan('run')} [id] autopilot: build → verify → review with Claude Code /goal ${pc.dim('--headless --print')}
|
|
25
26
|
${pc.cyan('gates')} [names...] run the quality gates
|
|
26
27
|
${pc.cyan('doctor')} check the setup
|
|
@@ -32,7 +33,7 @@ ${pc.bold('Init options')}
|
|
|
32
33
|
--agents <list> claude,codex,cursor,gemini,copilot
|
|
33
34
|
--autonomy <1-4> 1 assist · 2 guided · 3 autopilot · 4 dark factory
|
|
34
35
|
--team <list> developer,designer,pm
|
|
35
|
-
--models <
|
|
36
|
+
--models <list> e.g. planning=fable,building=claude-opus-5-5,checking=codex
|
|
36
37
|
--ci / --no-ci add the GitHub Actions workflow
|
|
37
38
|
--dry-run show what would change without writing
|
|
38
39
|
--force overwrite managed files even if you edited them
|
|
@@ -43,7 +44,7 @@ ${pc.bold('Examples')}
|
|
|
43
44
|
npx buildline init -y --autonomy 3 ${pc.dim('# non-interactive install')}
|
|
44
45
|
npx buildline new "Saved carts" ${pc.dim('# open a feature')}
|
|
45
46
|
npx buildline run 004 ${pc.dim('# unattended build loop')}
|
|
46
|
-
npx buildline models set
|
|
47
|
+
npx buildline models set planning fable ${pc.dim('# Fable specs, designs and plans')}
|
|
47
48
|
npx buildline step review 004 ${pc.dim('# run one station on its model')}
|
|
48
49
|
`;
|
|
49
50
|
|
|
@@ -71,13 +72,16 @@ const { values: opts, positionals } = parseArgs({
|
|
|
71
72
|
headless: { type: 'boolean' },
|
|
72
73
|
print: { type: 'boolean' },
|
|
73
74
|
json: { type: 'boolean' },
|
|
75
|
+
once: { type: 'boolean' },
|
|
76
|
+
shipped: { type: 'boolean' },
|
|
77
|
+
'no-intro': { type: 'boolean' },
|
|
74
78
|
help: { type: 'boolean', short: 'h' },
|
|
75
79
|
version: { type: 'boolean', short: 'v' },
|
|
76
80
|
},
|
|
77
81
|
});
|
|
78
82
|
|
|
79
83
|
const root = path.resolve(opts.cwd || process.cwd());
|
|
80
|
-
const o = { ...opts, dryRun: opts['dry-run'], ci: opts['no-ci'] ? false : opts.ci, open: !opts['no-open'] };
|
|
84
|
+
const o = { ...opts, dryRun: opts['dry-run'], ci: opts['no-ci'] ? false : opts.ci, open: !opts['no-open'], intro: !opts['no-intro'] };
|
|
81
85
|
const [cmd, ...rest] = positionals;
|
|
82
86
|
|
|
83
87
|
async function main() {
|
|
@@ -99,6 +103,10 @@ async function main() {
|
|
|
99
103
|
case 'move':
|
|
100
104
|
if (rest.length < 2) throw new Error('Usage: buildline move <id> <station>');
|
|
101
105
|
return cli.moveFeature(root, rest[0], rest[1], o);
|
|
106
|
+
case 'watch':
|
|
107
|
+
case 'top':
|
|
108
|
+
case 'tui':
|
|
109
|
+
return cli.watch(root, o);
|
|
102
110
|
case 'ui':
|
|
103
111
|
case 'dashboard':
|
|
104
112
|
return (await import('../src/server.js')).serve(root, o);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buildline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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
|
@@ -6,7 +6,7 @@ import pc from 'picocolors';
|
|
|
6
6
|
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
|
-
import {
|
|
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, STATIONS, TYPES } from './features.js';
|
|
11
11
|
|
|
12
12
|
const LOGO = [
|
|
@@ -95,7 +95,7 @@ export async function init(root, opts) {
|
|
|
95
95
|
team: parseList(opts.team) || ['developer', 'designer', 'pm'],
|
|
96
96
|
ci: opts.ci ?? (stack.git && fs.existsSync(path.join(root, '.github'))),
|
|
97
97
|
gates: defaultGates(stack),
|
|
98
|
-
models:
|
|
98
|
+
models: parseModelList(opts.models),
|
|
99
99
|
};
|
|
100
100
|
|
|
101
101
|
if (interactive) {
|
|
@@ -127,14 +127,7 @@ export async function init(root, opts) {
|
|
|
127
127
|
}),
|
|
128
128
|
);
|
|
129
129
|
|
|
130
|
-
|
|
131
|
-
await p.select({
|
|
132
|
-
message: 'Which models should each station use?',
|
|
133
|
-
options: Object.entries(PRESETS).map(([value, x]) => ({ value, label: x.label, hint: x.hint + (externalClis({ models: x.models }).some((c) => !hasCommand(c)) ? ' (needs ' + externalClis({ models: x.models }).join(', ') + ')' : '') })),
|
|
134
|
-
initialValue: 'session',
|
|
135
|
-
}),
|
|
136
|
-
);
|
|
137
|
-
answers.models = PRESETS[preset].models;
|
|
130
|
+
answers.models = await pickModels({ models: answers.models });
|
|
138
131
|
|
|
139
132
|
const gateChoice = bail(
|
|
140
133
|
await p.select({
|
|
@@ -268,6 +261,15 @@ export function status(root, opts = {}) {
|
|
|
268
261
|
console.log();
|
|
269
262
|
}
|
|
270
263
|
|
|
264
|
+
// ---- watch -------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
export async function watch(root, opts = {}) {
|
|
267
|
+
loadConfig(root);
|
|
268
|
+
const next = await (await import('./tui.js')).watch(root, opts);
|
|
269
|
+
if (next?.action === 'step') return step(root, next.station, next.id);
|
|
270
|
+
if (next?.action === 'ui') return (await import('./server.js')).serve(root, {});
|
|
271
|
+
}
|
|
272
|
+
|
|
271
273
|
// ---- new / approve / move ----------------------------------------------------
|
|
272
274
|
|
|
273
275
|
export async function newFeature(root, title, opts) {
|
|
@@ -354,35 +356,109 @@ export async function step(root, station, ref, opts = {}) {
|
|
|
354
356
|
return launch(root, cmd, args);
|
|
355
357
|
}
|
|
356
358
|
|
|
357
|
-
/**
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
const
|
|
361
|
-
if (
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
359
|
+
/** One model, chosen from what's available right now or typed in. */
|
|
360
|
+
async function pickOne(message, current, options) {
|
|
361
|
+
const initial = options.some((o) => o.value === (current || 'inherit')) ? current || 'inherit' : '__other';
|
|
362
|
+
const choice = bail(await p.select({ message, options, initialValue: initial, maxItems: 10 }));
|
|
363
|
+
if (choice !== '__other') return choice;
|
|
364
|
+
const typed = bail(
|
|
365
|
+
await p.text({
|
|
366
|
+
message: `${message}: model name or id`,
|
|
367
|
+
placeholder: 'e.g. claude-opus-5-5, fable, codex:gpt-5-codex, gemini:gemini-2.5-pro',
|
|
368
|
+
initialValue: initial === '__other' ? current || '' : '',
|
|
369
|
+
validate: (v) => (v?.trim() ? undefined : 'Type a model, or press Esc to go back'),
|
|
370
|
+
}),
|
|
371
|
+
);
|
|
372
|
+
return typed.trim();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Interactive model routing: one model everywhere, per phase, or per station. Returns the new models map. */
|
|
376
|
+
export async function pickModels(config) {
|
|
377
|
+
const current = config.models || {};
|
|
378
|
+
const spin = p.spinner();
|
|
379
|
+
spin.start('Looking up available models');
|
|
380
|
+
const options = await discoverModelOptions(config);
|
|
381
|
+
spin.stop(`Models available: ${options.filter((o) => !['inherit', '__other'].includes(o.value)).map((o) => o.label).join(', ') || 'none found, type one in'}`);
|
|
382
|
+
const scope = bail(
|
|
383
|
+
await p.select({
|
|
384
|
+
message: 'Which models should the stations use?',
|
|
385
|
+
options: [
|
|
386
|
+
{ value: 'session', label: 'The session model everywhere', hint: 'whatever you start Claude Code (or another agent) with' },
|
|
387
|
+
{ value: 'phase', label: 'Choose for planning, building and checking', hint: 'e.g. Fable plans, Opus builds' },
|
|
388
|
+
{ value: 'station', label: 'Choose for every station' },
|
|
389
|
+
Object.keys(current).length && { value: 'keep', label: 'Keep the current setup' },
|
|
390
|
+
].filter(Boolean),
|
|
391
|
+
initialValue: Object.keys(current).length ? 'keep' : 'session',
|
|
392
|
+
}),
|
|
393
|
+
);
|
|
394
|
+
if (scope === 'keep') return current;
|
|
395
|
+
if (scope === 'session') return {};
|
|
396
|
+
const models = {};
|
|
397
|
+
const set = (stations, value) => stations.forEach((st) => value !== 'inherit' && (models[st] = value));
|
|
398
|
+
if (scope === 'phase') {
|
|
399
|
+
for (const [key, phase] of Object.entries(PHASES)) {
|
|
400
|
+
const now = key === 'default' ? current.default : current[phase.stations[0]];
|
|
401
|
+
set(key === 'default' ? ['default'] : phase.stations, await pickOne(`${phase.label} ${pc.dim('(' + phase.hint + ')')}`, now, options));
|
|
371
402
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
console.log(`${pc.green('✔')} models updated; skills and subagents refreshed.`);
|
|
375
|
-
} else if (action) {
|
|
376
|
-
throw new Error('Usage: buildline models [set <station> <model> | preset <name>]');
|
|
403
|
+
} else {
|
|
404
|
+
for (const st of MODEL_STATIONS) set([st], await pickOne(st, current[st] ?? current.default, options));
|
|
377
405
|
}
|
|
378
|
-
|
|
406
|
+
const missing = externalClis({ models }).filter((c) => !hasCommand(c));
|
|
407
|
+
if (missing.length) p.log.warn(`Not installed: ${missing.map((c) => `${CLIS[c].label} (${CLIS[c].install})`).join(', ')}`);
|
|
408
|
+
return models;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function saveModels(root, config, models) {
|
|
412
|
+
config.models = models;
|
|
413
|
+
fs.writeFileSync(configPath(root), JSON.stringify(config, null, 2) + '\n');
|
|
414
|
+
apply(root, plan(root, config));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function printModels(config) {
|
|
379
418
|
console.log(`\n${pc.bold('Models per station')} ${pc.dim('(.buildline/buildline.json › models)')}\n`);
|
|
380
419
|
for (const s of MODEL_STATIONS) {
|
|
381
420
|
const m = modelFor(config, s);
|
|
382
421
|
const missing = m.cli !== 'claude' && !hasCommand(m.cli) ? pc.red(` ${m.cli} not installed`) : '';
|
|
383
422
|
console.log(` ${s.padEnd(9)} ${m.model || m.cli !== 'claude' ? pc.cyan(describeModel(m)) : pc.dim(describeModel(m))}${missing}`);
|
|
384
423
|
}
|
|
385
|
-
console.log(
|
|
424
|
+
console.log();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Show, pick, set or check per-station models. */
|
|
428
|
+
export async function models(root, args, opts) {
|
|
429
|
+
const config = loadConfig(root);
|
|
430
|
+
const [action, target, value] = args;
|
|
431
|
+
if (action === 'set') {
|
|
432
|
+
if (!target || !value) throw new Error(`Usage: buildline models set <station|phase> <model> stations: default, ${MODEL_STATIONS.join(', ')}; phases: planning, building, checking`);
|
|
433
|
+
const next = { ...(config.models || {}) };
|
|
434
|
+
const changes = parseModelList(`${target}=${value === 'inherit' ? 'x' : value}`);
|
|
435
|
+
for (const st of Object.keys(changes)) value === 'inherit' ? delete next[st] : (next[st] = value);
|
|
436
|
+
saveModels(root, config, next);
|
|
437
|
+
console.log(`${pc.green('✔')} ${Object.keys(changes).join(', ')} → ${value}. Skills and subagents refreshed.`);
|
|
438
|
+
} else if (action === 'check') {
|
|
439
|
+
const values = [...new Set(MODEL_STATIONS.map((s) => modelFor(config, s)).filter((m) => m.model || m.cli !== 'claude').map(describeModel))];
|
|
440
|
+
if (!values.length) return console.log('Every station uses the session model; nothing to check.');
|
|
441
|
+
console.log(`\nAsking each tool what these resolve to today ${pc.dim('(one tiny request per Claude model)')}\n`);
|
|
442
|
+
let bad = 0;
|
|
443
|
+
for (const v of values) {
|
|
444
|
+
const r = checkModel(v, { cwd: root });
|
|
445
|
+
if (!r.ok) bad++;
|
|
446
|
+
console.log(` ${r.ok ? pc.green('✔') : pc.red('✘')} ${v.padEnd(24)} ${r.ok ? pc.dim('→ ' + r.resolved + (r.note ? ` (${r.note})` : '')) : pc.red(r.error)}`);
|
|
447
|
+
}
|
|
448
|
+
console.log();
|
|
449
|
+
if (bad) process.exitCode = 1;
|
|
450
|
+
return;
|
|
451
|
+
} else if (action) {
|
|
452
|
+
throw new Error('Usage: buildline models [set <station|phase> <model> | check]');
|
|
453
|
+
} else if (!opts.json && process.stdout.isTTY && process.stdin.isTTY) {
|
|
454
|
+
printModels(config);
|
|
455
|
+
const next = await pickModels(config);
|
|
456
|
+
saveModels(root, config, next);
|
|
457
|
+
p.log.success('Saved. Skills and subagents refreshed.');
|
|
458
|
+
}
|
|
459
|
+
if (opts.json) return console.log(JSON.stringify(Object.fromEntries(MODEL_STATIONS.map((s) => [s, modelFor(config, s)])), null, 2));
|
|
460
|
+
printModels(config);
|
|
461
|
+
console.log(` ${pc.dim('Change:')} buildline models ${pc.dim('or')} buildline models set planning fable ${pc.dim('Verify:')} buildline models check\n`);
|
|
386
462
|
}
|
|
387
463
|
|
|
388
464
|
export async function run(root, ref, opts) {
|
|
@@ -483,8 +559,10 @@ export async function home(root) {
|
|
|
483
559
|
options: [
|
|
484
560
|
{ value: 'new', label: 'Start a new feature or fix' },
|
|
485
561
|
features.length && { value: 'open', label: 'Work on a feature', hint: 'approve, move, autopilot, open in Claude' },
|
|
562
|
+
features.length && { value: 'watch', label: 'Watch the line', hint: 'live view in the terminal' },
|
|
486
563
|
{ value: 'ui', label: 'Open the dashboard', hint: 'browser UI for PMs and designers' },
|
|
487
564
|
hasCommand('claude') && { value: 'claude', label: 'Open Claude Code at the front door', hint: '/buildline' },
|
|
565
|
+
{ value: 'models', label: 'Choose models per station', hint: 'which model plans, builds and checks' },
|
|
488
566
|
{ value: 'gates', label: 'Run quality gates' },
|
|
489
567
|
{ value: 'doctor', label: 'Check setup (doctor)' },
|
|
490
568
|
{ value: 'update', label: 'Update Buildline files' },
|
|
@@ -494,8 +572,10 @@ export async function home(root) {
|
|
|
494
572
|
);
|
|
495
573
|
if (action === 'exit') return p.outro('Happy building.');
|
|
496
574
|
if (action === 'new') await newFeature(root, null, {});
|
|
575
|
+
if (action === 'watch') return watch(root, {});
|
|
497
576
|
if (action === 'ui') return (await import('./server.js')).serve(root, {});
|
|
498
577
|
if (action === 'claude') return launchClaude(root, ['/buildline']);
|
|
578
|
+
if (action === 'models') saveModels(root, loadConfig(root), await pickModels(loadConfig(root)));
|
|
499
579
|
if (action === 'gates') gates(root, []);
|
|
500
580
|
if (action === 'doctor') doctor(root);
|
|
501
581
|
if (action === 'update') await update(root, {});
|
package/src/models.js
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
// Per-station model routing. `models` in buildline.json maps a station (or
|
|
2
|
-
// "default") to a Claude Code
|
|
3
|
-
// another CLI: "codex", "codex:<model>", "gemini", "gemini:<model>".
|
|
2
|
+
// "default") to whatever the user picked: a Claude Code alias (opus, fable, …)
|
|
3
|
+
// or full model id, or another CLI: "codex", "codex:<model>", "gemini", "gemini:<model>".
|
|
4
|
+
// Buildline never validates names against a list of its own: new models work the
|
|
5
|
+
// day the underlying CLI accepts them. Picker choices are discovered at runtime.
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
4
7
|
|
|
5
8
|
export const MODEL_STATIONS = ['onboard', 'new', 'spec', 'design', 'plan', 'build', 'verify', 'review', 'ship'];
|
|
6
9
|
|
|
10
|
+
// How the wizard groups stations when choosing by phase.
|
|
11
|
+
export const PHASES = {
|
|
12
|
+
planning: { label: 'Planning', hint: 'spec, design, plan', stations: ['spec', 'design', 'plan'] },
|
|
13
|
+
building: { label: 'Building', hint: 'build: writing the code', stations: ['build'] },
|
|
14
|
+
checking: { label: 'Checking', hint: 'verify, review', stations: ['verify', 'review'] },
|
|
15
|
+
default: { label: 'Everything else', hint: 'onboard, intake, ship, and anything unset', stations: [] },
|
|
16
|
+
};
|
|
17
|
+
|
|
7
18
|
export const CLIS = {
|
|
8
19
|
claude: { label: 'Claude Code', install: 'https://code.claude.com' },
|
|
9
20
|
codex: { label: 'Codex CLI', install: 'npm i -g @openai/codex' },
|
|
@@ -19,15 +30,6 @@ export const STATION_SUBAGENTS = {
|
|
|
19
30
|
review: 'buildline-reviewer',
|
|
20
31
|
};
|
|
21
32
|
|
|
22
|
-
const BALANCED = { spec: 'opus', design: 'opus', plan: 'opus', build: 'sonnet', verify: 'sonnet', review: 'opus' };
|
|
23
|
-
|
|
24
|
-
export const PRESETS = {
|
|
25
|
-
session: { label: 'Same model everywhere', hint: 'every station uses the model you start the agent with', models: {} },
|
|
26
|
-
balanced: { label: 'Balanced', hint: 'Opus for spec, design, plan and review; Sonnet builds and verifies', models: BALANCED },
|
|
27
|
-
budget: { label: 'Budget', hint: 'Sonnet everywhere, Haiku for intake', models: { default: 'sonnet', new: 'haiku' } },
|
|
28
|
-
second: { label: 'Second opinion', hint: 'Balanced, and Codex reviews every diff', models: { ...BALANCED, review: 'codex' } },
|
|
29
|
-
};
|
|
30
|
-
|
|
31
33
|
/** "opus" → { cli: 'claude', model: 'opus' }; "codex:gpt-5" → { cli: 'codex', model: 'gpt-5' }. */
|
|
32
34
|
export function parseModel(value) {
|
|
33
35
|
const v = String(value ?? '').trim();
|
|
@@ -44,6 +46,89 @@ export function modelFor(config, station) {
|
|
|
44
46
|
|
|
45
47
|
export const describeModel = ({ cli, model }) => (cli === 'claude' ? model || 'session model' : `${cli}${model ? ':' + model : ''}`);
|
|
46
48
|
|
|
49
|
+
/** "planning=fable,build=claude-opus-5-5" → { spec, design, plan: 'fable', build: '…' }. Phases expand to their stations. */
|
|
50
|
+
export function parseModelList(text) {
|
|
51
|
+
const models = {};
|
|
52
|
+
for (const pair of String(text || '').split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
53
|
+
const [key, ...rest] = pair.split('=');
|
|
54
|
+
const value = rest.join('=').trim();
|
|
55
|
+
if (!value) throw new Error(`"${pair}": use station=model, e.g. plan=fable`);
|
|
56
|
+
const targets = PHASES[key] && key !== 'default' ? PHASES[key].stations : [key];
|
|
57
|
+
for (const t of targets) {
|
|
58
|
+
if (t !== 'default' && !MODEL_STATIONS.includes(t)) throw new Error(`Unknown station or phase "${key}". Use ${[...Object.keys(PHASES), ...MODEL_STATIONS].join(', ')}.`);
|
|
59
|
+
if (value !== 'inherit') models[t] = value;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return models;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const has = (cmd) => spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { stdio: 'ignore' }).status === 0;
|
|
66
|
+
|
|
67
|
+
/** Model aliases the installed Claude Code advertises in `claude --help` (each tracks the newest model in its family). */
|
|
68
|
+
export function claudeAliases() {
|
|
69
|
+
const res = spawnSync('claude', ['--help'], { encoding: 'utf8', timeout: 10000, shell: process.platform === 'win32' });
|
|
70
|
+
const help = (res.stdout || '').replace(/\s+/g, ' ');
|
|
71
|
+
const desc = help.match(/--model <model>(.*?)(?= --[a-z])/)?.[1] || '';
|
|
72
|
+
return [...new Set([...desc.matchAll(/'([a-z][\w.-]*)'/g)].map((m) => m[1]))];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Current Claude models from the Anthropic API, when an API key is available. */
|
|
76
|
+
export async function anthropicModels() {
|
|
77
|
+
const key = process.env.ANTHROPIC_API_KEY;
|
|
78
|
+
if (!key) return [];
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch('https://api.anthropic.com/v1/models?limit=100', {
|
|
81
|
+
headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' },
|
|
82
|
+
signal: AbortSignal.timeout(4000),
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok) return [];
|
|
85
|
+
return ((await res.json()).data || []).map((m) => ({ id: m.id, name: m.display_name || m.id }));
|
|
86
|
+
} catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Everything the picker can offer right now: aliases from the installed Claude
|
|
93
|
+
* Code, models from the API, other installed CLIs, and values already in the
|
|
94
|
+
* config. Each option is { value, label, hint }; "__other" means type one in.
|
|
95
|
+
*/
|
|
96
|
+
export async function discoverModelOptions(config = {}) {
|
|
97
|
+
const options = [{ value: 'inherit', label: 'Session model', hint: 'whatever model the agent was started with' }];
|
|
98
|
+
const seen = new Set(['inherit']);
|
|
99
|
+
const push = (o) => {
|
|
100
|
+
if (!seen.has(o.value)) seen.add(o.value), options.push(o);
|
|
101
|
+
};
|
|
102
|
+
const aliases = has('claude') ? claudeAliases() : [];
|
|
103
|
+
// If this Claude Code doesn't list aliases in --help, fall back to the long-standing ones.
|
|
104
|
+
for (const a of aliases.length ? aliases : ['opus', 'sonnet', 'haiku']) push({ value: a, label: a, hint: 'newest model in this family, follows Claude Code updates' });
|
|
105
|
+
for (const m of await anthropicModels()) push({ value: m.id, label: m.id, hint: m.name });
|
|
106
|
+
for (const v of Object.values(config.models || {})) push({ value: v, label: v, hint: 'already in your config' });
|
|
107
|
+
for (const cli of ['codex', 'gemini']) if (has(cli)) push({ value: cli, label: cli, hint: `${CLIS[cli].label}, its default model (or type ${cli}:<model>)` });
|
|
108
|
+
options.push({ value: '__other', label: 'Other…', hint: 'type any model name or id, e.g. claude-opus-5-5 or codex:gpt-5-codex' });
|
|
109
|
+
return options;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Ask the CLI which model a name resolves to today. Claude: one tiny headless
|
|
114
|
+
* request, reporting the model that actually answered. Others: installed or not.
|
|
115
|
+
*/
|
|
116
|
+
export function checkModel(value, { cwd } = {}) {
|
|
117
|
+
const m = parseModel(value);
|
|
118
|
+
if (m.cli !== 'claude') return has(m.cli) ? { ok: true, resolved: describeModel(m), note: 'installed, not called' } : { ok: false, error: `${CLIS[m.cli].label} not installed: ${CLIS[m.cli].install}` };
|
|
119
|
+
if (!has('claude')) return { ok: false, error: 'Claude Code not installed' };
|
|
120
|
+
const args = ['-p', 'Reply with OK.', '--output-format', 'json', '--max-turns', '1', ...(m.model ? ['--model', m.model] : [])];
|
|
121
|
+
const res = spawnSync('claude', args, { cwd, encoding: 'utf8', timeout: 120000, shell: process.platform === 'win32' });
|
|
122
|
+
try {
|
|
123
|
+
const out = JSON.parse(res.stdout);
|
|
124
|
+
const used = Object.keys(out.modelUsage || {});
|
|
125
|
+
if (out.is_error || !used.length) return { ok: false, error: String(out.result || 'no response').slice(0, 160) };
|
|
126
|
+
return { ok: true, resolved: used.join(', ') };
|
|
127
|
+
} catch {
|
|
128
|
+
return { ok: false, error: (res.stderr || res.stdout || 'no output').trim().split('\n').pop().slice(0, 160) };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
47
132
|
/** Prompt that starts a station in the given CLI. */
|
|
48
133
|
export function stationPrompt(cli, station, id) {
|
|
49
134
|
const arg = id ? ` ${id}` : '';
|
package/src/tui.js
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
// `buildline watch`: a live, full-screen view of the line in the terminal.
|
|
2
|
+
// Every feature is a track running through the stations; the dot shows where it
|
|
3
|
+
// is, a moving comet means an agent can work on it, a blinking dot means it waits
|
|
4
|
+
// on a person. Zero dependencies beyond picocolors: raw ANSI on a cell canvas.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import pc from 'picocolors';
|
|
10
|
+
import { listFeatures, nextAction, approve } from './features.js';
|
|
11
|
+
import { modelFor, describeModel } from './models.js';
|
|
12
|
+
import { AUTONOMY, VERSION } from './install.js';
|
|
13
|
+
|
|
14
|
+
const TRACK = ['spec', 'design', 'plan', 'build', 'verify', 'review', 'ship'];
|
|
15
|
+
const LOGO = ['┌┐ ┬ ┬┬┬ ┌┬┐┬ ┬┌┐┌┌─┐', '├┴┐│ │││ ││││ ││││├┤ ', '└─┘└─┘┴┴─┘─┴┘┴─┘┴┘└┘└─┘'];
|
|
16
|
+
const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
|
|
17
|
+
const SPARK = ' ▁▂▃▄▅▆▇█';
|
|
18
|
+
|
|
19
|
+
// ---- colour ------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const TRUECOLOR = /truecolor|24bit/i.test(process.env.COLORTERM || '') || /iTerm|WezTerm|vscode|ghostty/i.test(process.env.TERM_PROGRAM || '');
|
|
22
|
+
const to256 = (r, g, b) => 16 + 36 * Math.round((r / 255) * 5) + 6 * Math.round((g / 255) * 5) + Math.round((b / 255) * 5);
|
|
23
|
+
function fg(hex, { bold = false } = {}) {
|
|
24
|
+
if (!pc.isColorSupported) return bold ? (s) => s : null;
|
|
25
|
+
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
|
26
|
+
const open = (bold ? '\x1b[1m' : '') + (TRUECOLOR ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${to256(r, g, b)}m`);
|
|
27
|
+
const close = '\x1b[39m' + (bold ? '\x1b[22m' : '');
|
|
28
|
+
return (s) => open + s + close;
|
|
29
|
+
}
|
|
30
|
+
const mix = (a, b, t) => '#' + [1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16) * (1 - t) + parseInt(b.slice(i, i + 2), 16) * t).toString(16).padStart(2, '0')).join('');
|
|
31
|
+
|
|
32
|
+
const C = {
|
|
33
|
+
text: fg('#d6d3cd'),
|
|
34
|
+
bold: fg('#f2efe9', { bold: true }),
|
|
35
|
+
muted: fg('#8a867e'),
|
|
36
|
+
faint: fg('#4a4741'),
|
|
37
|
+
border: fg('#5d5a54'),
|
|
38
|
+
amber: fg('#e5a400'),
|
|
39
|
+
amberBold: fg('#ffc233', { bold: true }),
|
|
40
|
+
amberHot: fg('#ffd76a'),
|
|
41
|
+
amberDim: fg('#8a6a1a'),
|
|
42
|
+
green: fg('#4ec27f'),
|
|
43
|
+
red: fg('#f7768e'),
|
|
44
|
+
flash: pc.isColorSupported ? (s) => `\x1b[48;5;94m\x1b[38;5;230m${s}\x1b[39m\x1b[49m` : null,
|
|
45
|
+
};
|
|
46
|
+
const WHO = {
|
|
47
|
+
pm: { label: 'PM', color: fg('#d58bf0') },
|
|
48
|
+
designer: { label: 'DESIGN', color: fg('#7aa2f7') },
|
|
49
|
+
eng: { label: 'ENG', color: fg('#59c2e6') },
|
|
50
|
+
agent: { label: 'AGENT', color: C.amber },
|
|
51
|
+
human: { label: 'BLOCKED', color: C.red },
|
|
52
|
+
};
|
|
53
|
+
const LOGO_COLORS = Array.from({ length: LOGO[0].length }, (_, i) => fg(mix('#ffd76a', '#e07a00', i / (LOGO[0].length - 1)), { bold: true }));
|
|
54
|
+
|
|
55
|
+
// ---- canvas ------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
class Line {
|
|
58
|
+
constructor(width) {
|
|
59
|
+
this.w = width;
|
|
60
|
+
this.ch = Array(width).fill(' ');
|
|
61
|
+
this.st = Array(width).fill(null);
|
|
62
|
+
}
|
|
63
|
+
/** Write text from column x (clipped to `max`), return the column after it. */
|
|
64
|
+
put(x, text, style = null, max = this.w) {
|
|
65
|
+
for (const c of String(text)) {
|
|
66
|
+
if (x >= Math.min(max, this.w)) break;
|
|
67
|
+
if (x >= 0) (this.ch[x] = c), (this.st[x] = style);
|
|
68
|
+
x++;
|
|
69
|
+
}
|
|
70
|
+
return x;
|
|
71
|
+
}
|
|
72
|
+
toString() {
|
|
73
|
+
let out = '';
|
|
74
|
+
let run = '';
|
|
75
|
+
let cur = null;
|
|
76
|
+
for (let i = 0; i < this.w; i++) {
|
|
77
|
+
if (this.st[i] !== cur) (out += cur ? cur(run) : run), (run = ''), (cur = this.st[i]);
|
|
78
|
+
run += this.ch[i];
|
|
79
|
+
}
|
|
80
|
+
return out + (cur ? cur(run) : run);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const clip = (s, n) => {
|
|
85
|
+
const chars = [...String(s ?? '')];
|
|
86
|
+
return chars.length > n ? chars.slice(0, Math.max(0, n - 1)).join('') + '…' : chars.join('');
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
function box(screen, x, y, w, h, title, { accent = false } = {}) {
|
|
90
|
+
if (w < 4 || h < 2) return;
|
|
91
|
+
const b = accent ? C.amberDim : C.border;
|
|
92
|
+
screen[y].put(x, '╭' + '─'.repeat(w - 2) + '╮', b);
|
|
93
|
+
for (let r = y + 1; r < y + h - 1; r++) screen[r].put(x, '│', b), screen[r].put(x + w - 1, '│', b);
|
|
94
|
+
screen[y + h - 1].put(x, '╰' + '─'.repeat(w - 2) + '╯', b);
|
|
95
|
+
if (title) {
|
|
96
|
+
screen[y].put(x + 2, ' ' + title + ' ', C.amberBold, x + w - 2);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- data --------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
function loadState(root, showShipped) {
|
|
103
|
+
const config = JSON.parse(fs.readFileSync(path.join(root, '.buildline', 'buildline.json'), 'utf8'));
|
|
104
|
+
const all = listFeatures(root);
|
|
105
|
+
const features = all.filter((f) => showShipped || f.status !== 'done').map((f) => ({ ...f, next: nextAction(f, config.autonomy) }));
|
|
106
|
+
const events = all
|
|
107
|
+
.flatMap((f) => (f.history || []).map((h) => ({ ...h, id: f.id, key: `${f.id}|${h.at}|${h.note}` })))
|
|
108
|
+
.sort((a, b) => String(a.at).localeCompare(String(b.at)));
|
|
109
|
+
return { config, all, features, events };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const stationIndex = (status) => (status === 'new' ? 0 : status === 'done' ? TRACK.length : TRACK.indexOf(status));
|
|
113
|
+
const shortModel = (config, station) => {
|
|
114
|
+
const m = modelFor(config, station);
|
|
115
|
+
return m.cli === 'claude' && !m.model ? 'session' : describeModel(m).replace(/^claude-/, '');
|
|
116
|
+
};
|
|
117
|
+
const hash = (s) => [...String(s)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7);
|
|
118
|
+
|
|
119
|
+
function timeLabel(iso) {
|
|
120
|
+
const d = new Date(iso);
|
|
121
|
+
if (Number.isNaN(+d)) return '--:--:--';
|
|
122
|
+
const sameDay = d.toDateString() === new Date().toDateString();
|
|
123
|
+
return sameDay ? d.toTimeString().slice(0, 8) : d.toLocaleDateString('en', { month: 'short', day: '2-digit' }).padEnd(8);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sparkline(events, bins = 12, hours = 24) {
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
const span = (hours * 3600e3) / bins;
|
|
129
|
+
const counts = Array(bins).fill(0);
|
|
130
|
+
for (const e of events) {
|
|
131
|
+
const age = now - new Date(e.at);
|
|
132
|
+
if (age >= 0 && age < hours * 3600e3) counts[bins - 1 - Math.floor(age / span)]++;
|
|
133
|
+
}
|
|
134
|
+
const max = Math.max(1, ...counts);
|
|
135
|
+
return counts.map((c) => SPARK[c ? Math.max(1, Math.round((c / max) * (SPARK.length - 1))) : 0]).join('');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- drawing -----------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
function drawTrack(line, x, f, seg, t) {
|
|
141
|
+
const cur = stationIndex(f.status);
|
|
142
|
+
const who = WHO[f.next.who];
|
|
143
|
+
const skipped = (i) => f.type === 'fix' && i < 3 && cur >= 3;
|
|
144
|
+
const working = f.next.who === 'agent' && !f.blocked;
|
|
145
|
+
for (let i = 0; i < TRACK.length; i++) {
|
|
146
|
+
const nx = x + i * seg;
|
|
147
|
+
let node = ['○', C.faint];
|
|
148
|
+
if (skipped(i)) node = ['·', C.faint];
|
|
149
|
+
else if (i < cur) node = ['●', C.green];
|
|
150
|
+
else if (i === cur && f.blocked) node = ['✖', t % 10 < 5 ? C.red : C.faint];
|
|
151
|
+
else if (i === cur && working) node = [['◉', '◎', '◉', '●'][Math.floor(t / 3) % 4], C.amberBold];
|
|
152
|
+
else if (i === cur) node = ['◉', t % 12 < 8 ? who?.color || C.amber : C.faint];
|
|
153
|
+
line.put(nx, node[0], node[1]);
|
|
154
|
+
if (i === TRACK.length - 1) break;
|
|
155
|
+
const len = seg - 1;
|
|
156
|
+
const head = ((t + hash(f.id)) % (len + 5)) - 1;
|
|
157
|
+
for (let k = 0; k < len; k++) {
|
|
158
|
+
let cell = ['─', C.faint];
|
|
159
|
+
if (skipped(i)) cell = ['┄', C.faint];
|
|
160
|
+
else if (i < cur) cell = ['━', C.green];
|
|
161
|
+
else if (i === cur && working) {
|
|
162
|
+
const d = head - k;
|
|
163
|
+
cell = d === 0 ? ['━', C.amberHot] : d === 1 ? ['━', C.amber] : d === 2 ? ['━', C.amberDim] : ['─', C.faint];
|
|
164
|
+
} else if (i === cur) cell = ['┈', C.border];
|
|
165
|
+
line.put(nx + 1 + k, cell[0], cell[1]);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (cur === TRACK.length) line.put(x + (TRACK.length - 1) * seg + 2, '✔', C.green);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function render(st, ui, W, H) {
|
|
172
|
+
const { config, features, events } = st;
|
|
173
|
+
const t = ui.tick;
|
|
174
|
+
const screen = Array.from({ length: H }, () => new Line(W));
|
|
175
|
+
if (W < 60 || H < 14) {
|
|
176
|
+
const msg = `make the terminal at least 60×14 (now ${W}×${H})`;
|
|
177
|
+
screen[Math.floor(H / 2)].put(Math.max(0, Math.floor((W - msg.length) / 2)), msg, C.amber);
|
|
178
|
+
return screen;
|
|
179
|
+
}
|
|
180
|
+
const active = st.all.filter((f) => f.status !== 'done');
|
|
181
|
+
const shipped = st.all.length - active.length;
|
|
182
|
+
const tasks = active.flatMap((f) => f.tasks || []);
|
|
183
|
+
const waiting = active.filter((f) => ['pm', 'designer', 'eng', 'human'].includes(nextAction(f, config.autonomy).who)).length;
|
|
184
|
+
|
|
185
|
+
// header
|
|
186
|
+
const tall = W >= 96 && H >= 26;
|
|
187
|
+
const live = `${SPINNER[t % SPINNER.length]} LIVE ${new Date().toTimeString().slice(0, 8)}`;
|
|
188
|
+
if (tall) {
|
|
189
|
+
LOGO.forEach((row, r) => [...row].forEach((c, i) => screen[r].put(2 + i, c, LOGO_COLORS[i])));
|
|
190
|
+
screen[2].put(2 + LOGO[0].length + 1, `v${VERSION}`, C.muted);
|
|
191
|
+
const right = (r, parts) => {
|
|
192
|
+
const width = parts.reduce((n, [s]) => n + [...s].length, 0);
|
|
193
|
+
let x = W - 2 - width;
|
|
194
|
+
for (const [s, style] of parts) x = screen[r].put(x, s, style);
|
|
195
|
+
};
|
|
196
|
+
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
|
+
right(1, [
|
|
198
|
+
['ACTIVE ', C.muted], [String(active.length), C.bold], [' SHIPPED ', C.muted], [String(shipped), C.green],
|
|
199
|
+
[' TASKS ', C.muted], [`${tasks.filter((x) => x.passes).length}/${tasks.length}`, C.bold],
|
|
200
|
+
[' WAITING ON PEOPLE ', C.muted], [String(waiting), waiting ? WHO.pm.color : C.bold],
|
|
201
|
+
]);
|
|
202
|
+
right(2, [['24h activity ', C.muted], [sparkline(events), C.amber]]);
|
|
203
|
+
} else {
|
|
204
|
+
let x = screen[0].put(1, ' BUILDLINE ', pc.isColorSupported ? (s) => `\x1b[43m\x1b[30m${s}\x1b[39m\x1b[49m` : null);
|
|
205
|
+
x = screen[0].put(x + 1, config.project || '', C.bold);
|
|
206
|
+
const liveX = W - 1 - [...live].length;
|
|
207
|
+
screen[0].put(x, ` ${active.length} active · ${shipped} shipped · ${waiting} waiting`, C.muted, liveX - 1);
|
|
208
|
+
screen[0].put(liveX, live, C.green);
|
|
209
|
+
}
|
|
210
|
+
const top = tall ? 4 : 2;
|
|
211
|
+
|
|
212
|
+
// the line
|
|
213
|
+
const minBottom = 9;
|
|
214
|
+
const lineRows = Math.max(1, features.length);
|
|
215
|
+
const lineH = Math.max(6, Math.min(lineRows + 5, H - top - 1 - minBottom));
|
|
216
|
+
const inner = W - 4;
|
|
217
|
+
const right = Math.min(48, Math.max(30, Math.floor(inner * 0.4)));
|
|
218
|
+
const seg = Math.max(4, Math.min(12, Math.floor((inner - 8 - right) / (TRACK.length - 1))));
|
|
219
|
+
const tx = 2 + 8;
|
|
220
|
+
const rx = tx + seg * (TRACK.length - 1) + 4;
|
|
221
|
+
const SHORT = { spec: 'SPC', design: 'DSN', plan: 'PLN', build: 'BLD', verify: 'VFY', review: 'REV', ship: 'SHP' };
|
|
222
|
+
box(screen, 0, top, W, lineH, 'THE LINE', { accent: true });
|
|
223
|
+
const sel = features[ui.sel];
|
|
224
|
+
const selIdx = sel ? stationIndex(sel.status) : -1;
|
|
225
|
+
TRACK.forEach((s, i) => {
|
|
226
|
+
const nx = tx + i * seg;
|
|
227
|
+
const label = seg >= 8 ? s.toUpperCase() : SHORT[s];
|
|
228
|
+
screen[top + 1].put(nx - Math.floor(label.length / 2), label, i === selIdx ? C.amberBold : C.muted);
|
|
229
|
+
const m = clip(shortModel(config, s), seg - 1);
|
|
230
|
+
if (seg >= 7) screen[top + 2].put(nx - Math.floor(m.length / 2), m, C.faint);
|
|
231
|
+
});
|
|
232
|
+
screen[top + 1].put(rx, 'FEATURE', C.muted);
|
|
233
|
+
screen[top + 1].put(W - 3 - 7, 'NEXT UP', C.muted);
|
|
234
|
+
const rowsAvail = lineH - 5;
|
|
235
|
+
if (ui.sel < ui.scroll) ui.scroll = ui.sel;
|
|
236
|
+
if (ui.sel >= ui.scroll + rowsAvail) ui.scroll = ui.sel - rowsAvail + 1;
|
|
237
|
+
if (!features.length) {
|
|
238
|
+
const line = screen[top + 4];
|
|
239
|
+
drawTrack(line, tx, { id: 'idle', status: 'spec', type: 'feature', next: { who: 'agent' } }, seg, t);
|
|
240
|
+
line.put(rx, 'the line is quiet · ', C.muted);
|
|
241
|
+
line.put(rx + 20, 'npx buildline new "…"', C.amber);
|
|
242
|
+
}
|
|
243
|
+
features.slice(ui.scroll, ui.scroll + rowsAvail).forEach((f, r) => {
|
|
244
|
+
const i = ui.scroll + r;
|
|
245
|
+
const line = screen[top + 4 + r];
|
|
246
|
+
const selected = i === ui.sel;
|
|
247
|
+
const flashing = (ui.flash.get(f.id) || 0) > Date.now();
|
|
248
|
+
line.put(2, selected ? '▸' : ' ', C.amberBold);
|
|
249
|
+
line.put(4, f.id, flashing ? C.flash : selected ? C.amberBold : C.muted);
|
|
250
|
+
drawTrack(line, tx, f, seg, t);
|
|
251
|
+
const who = WHO[f.next.who];
|
|
252
|
+
const whoW = 8;
|
|
253
|
+
const ts = f.tasks || [];
|
|
254
|
+
const roomy = W - 3 - whoW - rx >= 30;
|
|
255
|
+
const barW = ts.length ? (roomy ? 11 : 5) : 0;
|
|
256
|
+
const titleEnd = W - 3 - whoW - barW - 1;
|
|
257
|
+
line.put(rx, clip(f.title, titleEnd - rx), selected ? C.bold : C.text);
|
|
258
|
+
if (ts.length) {
|
|
259
|
+
const pass = ts.filter((x) => x.passes).length;
|
|
260
|
+
const filled = Math.round((pass / ts.length) * 5);
|
|
261
|
+
let bx = titleEnd + 1;
|
|
262
|
+
if (roomy) bx = line.put(line.put(bx, '▰'.repeat(filled), C.green), '▱'.repeat(5 - filled), C.faint) + 1;
|
|
263
|
+
line.put(bx, `${pass}/${ts.length}`, pass === ts.length ? C.green : C.muted);
|
|
264
|
+
}
|
|
265
|
+
if (who) line.put(W - 3 - who.label.length, who.label, who.color);
|
|
266
|
+
});
|
|
267
|
+
if (features.length > rowsAvail) screen[top + lineH - 1].put(W - 16, ` ${ui.sel + 1}/${features.length} `, C.muted);
|
|
268
|
+
|
|
269
|
+
// inspector + events
|
|
270
|
+
const by = top + lineH;
|
|
271
|
+
const bh = H - 1 - by;
|
|
272
|
+
if (bh >= 5) {
|
|
273
|
+
const split = W >= 100 ? Math.floor(W * 0.52) : W;
|
|
274
|
+
drawInspector(screen, st, sel, 0, by, split, bh);
|
|
275
|
+
if (split < W) drawEvents(screen, st, ui, split, by, W - split, bh);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// footer
|
|
279
|
+
const keys = [['↑↓', 'select'], ['⏎', 'open'], ['a', 'approve'], ['u', 'dashboard'], ['s', ui.shipped ? 'hide shipped' : 'shipped'], ['q', 'quit']];
|
|
280
|
+
let fx = 1;
|
|
281
|
+
const foot = screen[H - 1];
|
|
282
|
+
if (ui.message && ui.messageUntil > Date.now()) fx = foot.put(1, ` ${ui.message} `, C.flash);
|
|
283
|
+
else
|
|
284
|
+
for (const [k, label] of keys) {
|
|
285
|
+
if (fx + k.length + label.length + 2 > W - 1) break;
|
|
286
|
+
fx = foot.put(foot.put(fx, k, C.amber) + 1, label, C.muted) + 3;
|
|
287
|
+
}
|
|
288
|
+
const tag = `buildline v${VERSION}`;
|
|
289
|
+
if (fx + tag.length < W - 2) foot.put(W - 2 - tag.length, tag, C.faint);
|
|
290
|
+
return screen;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function drawInspector(screen, st, f, x, y, w, h) {
|
|
294
|
+
box(screen, x, y, w, h, f ? `INSPECT ${f.id}` : 'INSPECT');
|
|
295
|
+
if (!f) return;
|
|
296
|
+
const { config } = st;
|
|
297
|
+
const rows = [];
|
|
298
|
+
const add = (...parts) => rows.push(parts);
|
|
299
|
+
const cur = stationIndex(f.status);
|
|
300
|
+
const model = TRACK.includes(f.status) ? describeModel(modelFor(config, f.status)) : '—';
|
|
301
|
+
add([clip(f.title, w - 6), C.bold]);
|
|
302
|
+
add(['type ', C.muted], [f.type.padEnd(9), C.text], ['station ', C.muted], [`${f.status} ${cur >= 0 && cur < TRACK.length ? `(${cur + 1}/${TRACK.length})` : ''}`, C.amber]);
|
|
303
|
+
add(['model ', C.muted], [model, C.text]);
|
|
304
|
+
const appr = ['spec', 'design', 'plan'].map((s) => {
|
|
305
|
+
const a = f.approvals?.[s];
|
|
306
|
+
return a ? [[`${s} ✔ ${a.by} `, C.green]] : [[`${s} · `, C.faint]];
|
|
307
|
+
});
|
|
308
|
+
add(['approvals ', C.muted], ...appr.flat());
|
|
309
|
+
const ts = f.tasks || [];
|
|
310
|
+
if (ts.length) {
|
|
311
|
+
add();
|
|
312
|
+
add(['tasks', C.muted]);
|
|
313
|
+
const firstOpen = ts.findIndex((x) => !x.passes);
|
|
314
|
+
const room = Math.max(1, h - 2 - rows.length - 3);
|
|
315
|
+
const shown = ts.slice(0, room);
|
|
316
|
+
shown.forEach((task, i) =>
|
|
317
|
+
add(
|
|
318
|
+
[task.passes ? ' ✔ ' : i === firstOpen ? ' ▶ ' : ' · ', task.passes ? C.green : i === firstOpen ? C.amberBold : C.faint],
|
|
319
|
+
[`${task.id || i + 1} `, C.muted],
|
|
320
|
+
[task.title || '', task.passes ? C.text : i === firstOpen ? C.bold : C.muted],
|
|
321
|
+
),
|
|
322
|
+
);
|
|
323
|
+
if (ts.length > shown.length) add([` +${ts.length - shown.length} more`, C.faint]);
|
|
324
|
+
}
|
|
325
|
+
add();
|
|
326
|
+
const who = WHO[f.next.who];
|
|
327
|
+
add(['next ', C.muted], [who ? who.label + ' ' : '', who?.color || C.text], [f.next.text, C.text]);
|
|
328
|
+
if (f.next.cmd) add(['❯ ', C.amberBold], [f.next.cmd, C.amber]);
|
|
329
|
+
const room = h - 2 - rows.length - 2;
|
|
330
|
+
const journey = (f.history || []).slice(-room);
|
|
331
|
+
if (room > 0 && journey.length) {
|
|
332
|
+
add();
|
|
333
|
+
add(['journey', C.muted]);
|
|
334
|
+
for (const e of journey) add([' ' + timeLabel(e.at) + ' ', C.faint], [String(e.station || '').padEnd(7), C.muted], [' ' + (e.note || ''), C.text]);
|
|
335
|
+
}
|
|
336
|
+
rows.slice(0, h - 2).forEach((parts, r) => {
|
|
337
|
+
let cx = x + 2;
|
|
338
|
+
for (const [s, style] of parts) cx = screen[y + 1 + r].put(cx, s, style, x + w - 2);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function drawEvents(screen, st, ui, x, y, w, h) {
|
|
343
|
+
box(screen, x, y, w, h, 'EVENTS · tail -f');
|
|
344
|
+
const rows = st.events.slice(-(h - 2));
|
|
345
|
+
rows.forEach((e, r) => {
|
|
346
|
+
const line = screen[y + 1 + r];
|
|
347
|
+
const fresh = (ui.freshEvents.get(e.key) || 0) > Date.now();
|
|
348
|
+
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);
|
|
352
|
+
});
|
|
353
|
+
if (!rows.length) screen[y + 1].put(x + 2, 'nothing yet', C.faint);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---- runtime -----------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
function whoami(root) {
|
|
359
|
+
const r = spawnSync('git', ['config', 'user.name'], { cwd: root, encoding: 'utf8' });
|
|
360
|
+
return (r.stdout || '').trim() || os.userInfo().username;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** One frame as plain text, for non-interactive output and tests. */
|
|
364
|
+
export function snapshot(root, { width = 110, shipped = false } = {}) {
|
|
365
|
+
const st = loadState(root, shipped);
|
|
366
|
+
const height = Math.max(24, 4 + st.features.length + 5 + 16);
|
|
367
|
+
const ui = { root, tick: 0, sel: 0, scroll: 0, flash: new Map(), freshEvents: new Map(), shipped };
|
|
368
|
+
return render(st, ui, width, height).map((l) => l.toString().replace(/\s+$/, '')).join('\n');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function boot(out, st, root) {
|
|
372
|
+
const ok = pc.isColorSupported ? `[ ${pc.green('OK')} ]` : '[ OK ]';
|
|
373
|
+
const active = st.all.filter((f) => f.status !== 'done').length;
|
|
374
|
+
const routed = TRACK.filter((s) => st.config.models?.[s]).map((s) => `${s} → ${st.config.models[s]}`);
|
|
375
|
+
const lines = [
|
|
376
|
+
`Mounted ${path.join(path.basename(root), '.buildline')}`,
|
|
377
|
+
`Loaded ${st.all.length} feature${st.all.length === 1 ? '' : 's'} (${active} active, ${st.all.length - active} shipped)`,
|
|
378
|
+
routed.length ? `Routed models: ${routed.slice(0, 4).join(', ')}${routed.length > 4 ? ', …' : ''}` : 'Routed models: every station on the session model',
|
|
379
|
+
`Armed gates: ${(st.config.gates || []).map((g) => g.name).join(', ') || 'none'}`,
|
|
380
|
+
'Started the line.',
|
|
381
|
+
];
|
|
382
|
+
out.write('\x1b[2J\x1b[H\n');
|
|
383
|
+
for (const l of lines) {
|
|
384
|
+
out.write(` ${ok} ${l}\r\n`);
|
|
385
|
+
await new Promise((r) => setTimeout(r, 90));
|
|
386
|
+
}
|
|
387
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Run the live view. Resolves with what the user asked to do on the way out:
|
|
392
|
+
* null, { action: 'step', station, id } or { action: 'ui' }.
|
|
393
|
+
*/
|
|
394
|
+
export async function watch(root, opts = {}) {
|
|
395
|
+
const out = process.stdout;
|
|
396
|
+
if (!out.isTTY || !process.stdin.isTTY || opts.once) {
|
|
397
|
+
console.log(snapshot(root, { width: out.columns || 110, shipped: opts.shipped }));
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
const ui = { root, tick: 0, sel: 0, scroll: 0, flash: new Map(), freshEvents: new Map(), shipped: false, message: '', messageUntil: 0 };
|
|
401
|
+
let st = loadState(root, ui.shipped);
|
|
402
|
+
let lastStatus = new Map(st.all.map((f) => [f.id, f.status]));
|
|
403
|
+
let seen = new Set(st.events.map((e) => e.key));
|
|
404
|
+
|
|
405
|
+
out.write('\x1b[?1049h\x1b[?25l');
|
|
406
|
+
if (opts.intro !== false) await boot(out, st, root);
|
|
407
|
+
|
|
408
|
+
const refresh = () => {
|
|
409
|
+
try {
|
|
410
|
+
st = loadState(root, ui.shipped);
|
|
411
|
+
} catch {
|
|
412
|
+
return; // a file mid-write; try again next tick
|
|
413
|
+
}
|
|
414
|
+
for (const f of st.all) {
|
|
415
|
+
if (lastStatus.has(f.id) && lastStatus.get(f.id) !== f.status) ui.flash.set(f.id, Date.now() + 2500);
|
|
416
|
+
}
|
|
417
|
+
for (const e of st.events) if (!seen.has(e.key)) ui.freshEvents.set(e.key, Date.now() + 4000);
|
|
418
|
+
lastStatus = new Map(st.all.map((f) => [f.id, f.status]));
|
|
419
|
+
seen = new Set(st.events.map((e) => e.key));
|
|
420
|
+
ui.sel = Math.max(0, Math.min(ui.sel, st.features.length - 1));
|
|
421
|
+
};
|
|
422
|
+
const draw = () => {
|
|
423
|
+
const W = out.columns || 100;
|
|
424
|
+
const H = out.rows || 30;
|
|
425
|
+
const screen = render(st, ui, W, H);
|
|
426
|
+
out.write(screen.map((l, y) => `\x1b[${y + 1};1H${l}`).join(''));
|
|
427
|
+
ui.tick++;
|
|
428
|
+
};
|
|
429
|
+
const say = (message) => ((ui.message = message), (ui.messageUntil = Date.now() + 3000));
|
|
430
|
+
|
|
431
|
+
return new Promise((resolve) => {
|
|
432
|
+
const anim = setInterval(draw, 110);
|
|
433
|
+
const poll = setInterval(refresh, 1000);
|
|
434
|
+
const onResize = () => (out.write('\x1b[2J'), draw());
|
|
435
|
+
const done = (result) => {
|
|
436
|
+
clearInterval(anim);
|
|
437
|
+
clearInterval(poll);
|
|
438
|
+
out.off('resize', onResize);
|
|
439
|
+
process.stdin.off('data', onKey);
|
|
440
|
+
process.stdin.setRawMode(false);
|
|
441
|
+
process.stdin.pause();
|
|
442
|
+
out.write('\x1b[?25h\x1b[?1049l');
|
|
443
|
+
resolve(result);
|
|
444
|
+
};
|
|
445
|
+
const onKey = (buf) => {
|
|
446
|
+
const k = buf.toString();
|
|
447
|
+
const f = st.features[ui.sel];
|
|
448
|
+
if (k === 'q' || k === '\x03' || k === '\x1b') return done(null);
|
|
449
|
+
if (k === '\x1b[A' || k === 'k') ui.sel = Math.max(0, ui.sel - 1);
|
|
450
|
+
else if (k === '\x1b[B' || k === 'j') ui.sel = Math.min(st.features.length - 1, ui.sel + 1);
|
|
451
|
+
else if (k === 's') (ui.shipped = !ui.shipped), refresh();
|
|
452
|
+
else if (k === 'u') return done({ action: 'ui' });
|
|
453
|
+
else if ((k === '\r' || k === '\n') && f) {
|
|
454
|
+
if (f.status === 'done') say(`${f.id} has shipped`);
|
|
455
|
+
else return done({ action: 'step', station: TRACK.includes(f.status) ? f.status : 'buildline', id: f.id });
|
|
456
|
+
} else if (k === 'a' && f) {
|
|
457
|
+
const stage = f.next.cmd?.match(/^buildline approve \S+ (\w+)$/)?.[1];
|
|
458
|
+
if (!stage) say(`${f.id} isn't waiting on an approval`);
|
|
459
|
+
else {
|
|
460
|
+
approve(st.all.find((x) => x.id === f.id), stage, whoami(root));
|
|
461
|
+
say(`✔ ${stage} approved for ${f.id}`);
|
|
462
|
+
refresh();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
draw();
|
|
466
|
+
};
|
|
467
|
+
process.stdin.setRawMode(true);
|
|
468
|
+
process.stdin.resume();
|
|
469
|
+
process.stdin.on('data', onKey);
|
|
470
|
+
out.on('resize', onResize);
|
|
471
|
+
out.write('\x1b[2J');
|
|
472
|
+
draw();
|
|
473
|
+
});
|
|
474
|
+
}
|