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 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
- Use the strongest model where judgement matters and a fast one where the work is well specified. You can also have a second vendor review the diff.
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 preset balanced # Opus specs, designs, plans, reviews · Sonnet builds and verifies
99
- npx buildline models set review codex # Codex reviews every diff as a second opinion
100
- npx buildline models # see what each station uses
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": { "default": "sonnet", "plan": "opus", "review": "codex:gpt-5-codex", "verify": "gemini" }
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 routed 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
+ 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 per station set <station> <model> · preset <name>
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')} show models per station ${pc.dim('set <station> <model> · preset <name>')}
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 <preset> session · balanced · budget · second
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 review codex ${pc.dim('# Codex reviews every diff')}
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.1",
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 { PRESETS, MODEL_STATIONS, CLIS, modelFor, parseModel, describeModel, stationPrompt, launchArgs, externalClis } from './models.js';
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: PRESETS[opts.models]?.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
- const preset = bail(
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
- /** Show or change per-station models. */
358
- export async function models(root, args, opts) {
359
- const config = loadConfig(root);
360
- const [action, station, value] = args;
361
- if (action === 'set' || action === 'preset') {
362
- if (action === 'preset') {
363
- if (!PRESETS[station]) throw new Error(`Unknown preset "${station}". Use one of: ${Object.keys(PRESETS).join(', ')}.`);
364
- config.models = { ...PRESETS[station].models };
365
- } else {
366
- if (!station || !['default', ...MODEL_STATIONS].includes(station)) throw new Error(`Usage: buildline models set <default|${MODEL_STATIONS.join('|')}> <model|inherit>`);
367
- if (!value) throw new Error('Give a model: opus, sonnet, haiku, a model id, codex[:model], gemini[:model], or inherit.');
368
- config.models = { ...(config.models || {}) };
369
- if (value === 'inherit') delete config.models[station];
370
- else config.models[station] = value;
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
- fs.writeFileSync(configPath(root), JSON.stringify(config, null, 2) + '\n');
373
- apply(root, plan(root, config));
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
- if (opts.json) return console.log(JSON.stringify(Object.fromEntries(MODEL_STATIONS.map((s) => [s, modelFor(config, s)])), null, 2));
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(`\n ${pc.dim('Change one:')} buildline models set review codex ${pc.dim('Presets:')} ${Object.keys(PRESETS).join(', ')}\n`);
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 model (opus, sonnet, haiku, a full model id) or to
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
+ }