buildline 0.4.0 → 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 +12 -1
- package/bin/buildline.js +9 -1
- package/package.json +1 -1
- package/src/cli.js +11 -0
- 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
|
|
|
@@ -128,6 +128,16 @@ Instructions in prose are advice. These are enforced:
|
|
|
128
128
|
|
|
129
129
|
Start at 2. Raise it as your gates and acceptance scenarios earn trust. [More on autonomy →](docs/autonomy.md)
|
|
130
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
|
+
|
|
131
141
|
## The dashboard
|
|
132
142
|
|
|
133
143
|
`npx buildline ui` opens a local board so people who don't live in a terminal can take part:
|
|
@@ -181,6 +191,7 @@ npx buildline status the board in your terminal --json
|
|
|
181
191
|
npx buildline new "<title>" open a feature --type fix|feature|epic
|
|
182
192
|
npx buildline approve <id> <stage> record an approval (spec | design | plan)
|
|
183
193
|
npx buildline ui dashboard
|
|
194
|
+
npx buildline watch live view of the line in the terminal
|
|
184
195
|
npx buildline step <station> [id] open a station on its model --headless --model
|
|
185
196
|
npx buildline models choose models set <station|phase> <model> · check
|
|
186
197
|
npx buildline run <id> autopilot with Claude Code /goal --headless
|
package/bin/buildline.js
CHANGED
|
@@ -18,6 +18,7 @@ ${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
24
|
${pc.cyan('models')} choose models per station ${pc.dim('set <station|phase> <model> · check')}
|
|
@@ -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
|
@@ -261,6 +261,15 @@ export function status(root, opts = {}) {
|
|
|
261
261
|
console.log();
|
|
262
262
|
}
|
|
263
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
|
+
|
|
264
273
|
// ---- new / approve / move ----------------------------------------------------
|
|
265
274
|
|
|
266
275
|
export async function newFeature(root, title, opts) {
|
|
@@ -550,6 +559,7 @@ export async function home(root) {
|
|
|
550
559
|
options: [
|
|
551
560
|
{ value: 'new', label: 'Start a new feature or fix' },
|
|
552
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' },
|
|
553
563
|
{ value: 'ui', label: 'Open the dashboard', hint: 'browser UI for PMs and designers' },
|
|
554
564
|
hasCommand('claude') && { value: 'claude', label: 'Open Claude Code at the front door', hint: '/buildline' },
|
|
555
565
|
{ value: 'models', label: 'Choose models per station', hint: 'which model plans, builds and checks' },
|
|
@@ -562,6 +572,7 @@ export async function home(root) {
|
|
|
562
572
|
);
|
|
563
573
|
if (action === 'exit') return p.outro('Happy building.');
|
|
564
574
|
if (action === 'new') await newFeature(root, null, {});
|
|
575
|
+
if (action === 'watch') return watch(root, {});
|
|
565
576
|
if (action === 'ui') return (await import('./server.js')).serve(root, {});
|
|
566
577
|
if (action === 'claude') return launchClaude(root, ['/buildline']);
|
|
567
578
|
if (action === 'models') saveModels(root, loadConfig(root), await pickModels(loadConfig(root)));
|
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
|
+
}
|