buildline 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +214 -2
- package/bin/buildline.js +131 -0
- package/package.json +44 -4
- package/src/cli.js +537 -0
- package/src/detect.js +228 -0
- package/src/features.js +157 -0
- package/src/files.js +107 -0
- package/src/install.js +280 -0
- package/src/models.js +66 -0
- package/src/render.js +9 -0
- package/src/server.js +143 -0
- package/src/ui/index.html +333 -0
- package/templates/AGENTS.block.md +48 -0
- package/templates/CLAUDE.block.md +20 -0
- package/templates/GEMINI.block.md +3 -0
- package/templates/agents/buildline-design-reviewer.md +21 -0
- package/templates/agents/buildline-mapper.md +23 -0
- package/templates/agents/buildline-reviewer.md +24 -0
- package/templates/agents/buildline-spec-critic.md +19 -0
- package/templates/agents/buildline-verifier.md +30 -0
- package/templates/bin/gates.mjs +65 -0
- package/templates/bin/hook.mjs +90 -0
- package/templates/buildline-readme.md +55 -0
- package/templates/ci/buildline.yml +71 -0
- package/templates/context/architecture.md +21 -0
- package/templates/context/design.md +22 -0
- package/templates/context/product.md +22 -0
- package/templates/cursor.mdc +11 -0
- package/templates/feature/progress.md +6 -0
- package/templates/feature/spec.md +34 -0
- package/templates/principles.md +23 -0
- package/templates/stations/autopilot.md +52 -0
- package/templates/stations/build.md +52 -0
- package/templates/stations/buildline.md +48 -0
- package/templates/stations/design.md +39 -0
- package/templates/stations/new.md +39 -0
- package/templates/stations/onboard.md +57 -0
- package/templates/stations/plan.md +44 -0
- package/templates/stations/review.md +37 -0
- package/templates/stations/ship.md +32 -0
- package/templates/stations/spec.md +46 -0
- package/templates/stations/verify.md +44 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import * as p from '@clack/prompts';
|
|
5
|
+
import pc from 'picocolors';
|
|
6
|
+
import { detect, describeStack } from './detect.js';
|
|
7
|
+
import { AGENTS, AUTONOMY, VERSION, makeConfig, plan, apply, removePlan, applyRemove, defaultGates } from './install.js';
|
|
8
|
+
import { loadManifest } from './files.js';
|
|
9
|
+
import { PRESETS, MODEL_STATIONS, CLIS, modelFor, parseModel, describeModel, stationPrompt, launchArgs, externalClis } from './models.js';
|
|
10
|
+
import { listFeatures, findFeature, createFeature, approve, setStatus, nextAction, STATIONS, TYPES } from './features.js';
|
|
11
|
+
|
|
12
|
+
const LOGO = [
|
|
13
|
+
pc.yellow(' ┌┐ ┬ ┬┬┬ ┌┬┐┬ ┬┌┐┌┌─┐'),
|
|
14
|
+
pc.yellow(' ├┴┐│ │││ ││││ ││││├┤ '),
|
|
15
|
+
pc.yellow(' └─┘└─┘┴┴─┘─┴┘┴─┘┴┘└┘└─┘') + pc.dim(` v${VERSION}`),
|
|
16
|
+
].join('\n');
|
|
17
|
+
|
|
18
|
+
const configPath = (root) => path.join(root, '.buildline', 'buildline.json');
|
|
19
|
+
export const isInstalled = (root) => fs.existsSync(configPath(root));
|
|
20
|
+
|
|
21
|
+
function loadConfig(root) {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(fs.readFileSync(configPath(root), 'utf8'));
|
|
24
|
+
} catch (err) {
|
|
25
|
+
throw new Error(`Buildline not installed here (or .buildline/buildline.json is invalid). Run ${pc.cyan('npx buildline init')}. ${err.code === 'ENOENT' ? '' : err.message}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const hasCommand = (cmd) => spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { stdio: 'ignore' }).status === 0;
|
|
30
|
+
|
|
31
|
+
function bail(value) {
|
|
32
|
+
if (p.isCancel(value)) {
|
|
33
|
+
p.cancel('Cancelled. Nothing was written.');
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function bar(done, total, width = 10) {
|
|
40
|
+
if (!total) return pc.dim('·'.repeat(width));
|
|
41
|
+
const n = Math.round((done / total) * width);
|
|
42
|
+
return pc.green('█'.repeat(n)) + pc.dim('░'.repeat(width - n));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ACTION_LABEL = {
|
|
46
|
+
create: pc.green('create'),
|
|
47
|
+
update: pc.cyan('update'),
|
|
48
|
+
merge: pc.cyan('merge '),
|
|
49
|
+
'skip-modified': pc.yellow('kept '),
|
|
50
|
+
unchanged: pc.dim('same '),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function summarisePlan(plans) {
|
|
54
|
+
const counts = {};
|
|
55
|
+
for (const x of plans) counts[x.action] = (counts[x.action] || 0) + 1;
|
|
56
|
+
const lines = plans
|
|
57
|
+
.filter((x) => x.action !== 'unchanged')
|
|
58
|
+
.map((x) => `${ACTION_LABEL[x.action] || x.action} ${x.rel}${x.action === 'skip-modified' ? pc.dim(' (you edited it; use --force to overwrite)') : ''}`);
|
|
59
|
+
return { lines, counts };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- init --------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
export async function init(root, opts) {
|
|
65
|
+
const interactive = !opts.yes && process.stdin.isTTY;
|
|
66
|
+
console.log('\n' + LOGO + '\n');
|
|
67
|
+
p.intro(pc.bgYellow(pc.black(' buildline init ')));
|
|
68
|
+
|
|
69
|
+
const stack = detect(root);
|
|
70
|
+
const existingConfig = isInstalled(root) ? loadConfig(root) : null;
|
|
71
|
+
const facts = [
|
|
72
|
+
`${pc.dim('project ')} ${stack.name}`,
|
|
73
|
+
`${pc.dim('stack ')} ${describeStack(stack)}`,
|
|
74
|
+
stack.ui.length ? `${pc.dim('ui ')} ${stack.ui.join(', ')}` : null,
|
|
75
|
+
stack.testing.length ? `${pc.dim('testing ')} ${stack.testing.join(', ')}` : null,
|
|
76
|
+
`${pc.dim('gates ')} ${defaultGates(stack).map((g) => g.name).join(', ') || pc.yellow('none detected yet')}`,
|
|
77
|
+
stack.existingAgentConfig.length ? `${pc.dim('found ')} ${stack.existingAgentConfig.join(', ')} ${pc.dim('(kept, factory adds alongside)')}` : null,
|
|
78
|
+
].filter(Boolean);
|
|
79
|
+
p.note(facts.join('\n'), stack.empty ? 'New project detected' : 'Existing project detected');
|
|
80
|
+
|
|
81
|
+
if (existingConfig && interactive) {
|
|
82
|
+
const go = bail(await p.confirm({ message: 'A factory is already installed here. Update it to v' + VERSION + ' with your current settings?' }));
|
|
83
|
+
if (!go) return p.outro('Nothing changed.');
|
|
84
|
+
return update(root, { ...opts, quietIntro: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const detectedAgents = ['claude', 'codex', 'cursor'];
|
|
88
|
+
if (stack.existingAgentConfig.includes('Gemini CLI')) detectedAgents.push('gemini');
|
|
89
|
+
if (stack.existingAgentConfig.includes('GitHub Copilot')) detectedAgents.push('copilot');
|
|
90
|
+
|
|
91
|
+
const parseList = (v) => (v ? String(v).split(',').map((s) => s.trim()).filter(Boolean) : null);
|
|
92
|
+
let answers = {
|
|
93
|
+
agents: parseList(opts.agents) || detectedAgents,
|
|
94
|
+
autonomy: opts.autonomy ? Number(opts.autonomy) : 2,
|
|
95
|
+
team: parseList(opts.team) || ['developer', 'designer', 'pm'],
|
|
96
|
+
ci: opts.ci ?? (stack.git && fs.existsSync(path.join(root, '.github'))),
|
|
97
|
+
gates: defaultGates(stack),
|
|
98
|
+
models: PRESETS[opts.models]?.models || {},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
if (interactive) {
|
|
102
|
+
answers.agents = bail(
|
|
103
|
+
await p.multiselect({
|
|
104
|
+
message: 'Which AI tools does your team use?',
|
|
105
|
+
options: Object.entries(AGENTS).map(([value, a]) => ({ value, label: a.label, hint: a.hint })),
|
|
106
|
+
initialValues: answers.agents,
|
|
107
|
+
required: true,
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
answers.team = bail(
|
|
111
|
+
await p.multiselect({
|
|
112
|
+
message: 'Who will use the factory?',
|
|
113
|
+
options: [
|
|
114
|
+
{ value: 'developer', label: 'Developers' },
|
|
115
|
+
{ value: 'designer', label: 'Designers' },
|
|
116
|
+
{ value: 'pm', label: 'Product managers' },
|
|
117
|
+
],
|
|
118
|
+
initialValues: answers.team,
|
|
119
|
+
required: true,
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
answers.autonomy = bail(
|
|
123
|
+
await p.select({
|
|
124
|
+
message: 'How much should agents do on their own?',
|
|
125
|
+
options: Object.entries(AUTONOMY).map(([value, a]) => ({ value: Number(value), label: a.label, hint: a.hint })),
|
|
126
|
+
initialValue: 2,
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
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;
|
|
138
|
+
|
|
139
|
+
const gateChoice = bail(
|
|
140
|
+
await p.select({
|
|
141
|
+
message: answers.gates.length ? `Quality gates: ${answers.gates.map((g) => g.command).join(' · ')}` : 'No test/lint/build commands found yet.',
|
|
142
|
+
options: answers.gates.length
|
|
143
|
+
? [
|
|
144
|
+
{ value: 'keep', label: 'Use these' },
|
|
145
|
+
{ value: 'edit', label: 'Edit them' },
|
|
146
|
+
{ value: 'later', label: 'Decide later', hint: 'onboarding will set them up' },
|
|
147
|
+
]
|
|
148
|
+
: [
|
|
149
|
+
{ value: 'later', label: 'Set up during onboarding', hint: 'recommended for new projects' },
|
|
150
|
+
{ value: 'edit', label: 'Enter commands now' },
|
|
151
|
+
],
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
if (gateChoice === 'later') answers.gates = stack.empty ? [] : answers.gates;
|
|
155
|
+
if (gateChoice === 'edit') {
|
|
156
|
+
const gates = [];
|
|
157
|
+
for (const name of ['lint', 'typecheck', 'test', 'build']) {
|
|
158
|
+
const current = answers.gates.find((g) => g.name === name)?.command || '';
|
|
159
|
+
const cmd = bail(await p.text({ message: `${name} command ${pc.dim('(empty to skip)')}`, initialValue: current, placeholder: current || 'e.g. npm test' }));
|
|
160
|
+
if (cmd?.trim()) gates.push({ name, command: cmd.trim(), required: true });
|
|
161
|
+
}
|
|
162
|
+
answers.gates = gates;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (stack.git || fs.existsSync(path.join(root, '.github'))) {
|
|
166
|
+
answers.ci = bail(
|
|
167
|
+
await p.confirm({
|
|
168
|
+
message: 'Add a GitHub Actions workflow? (gates on every PR + Claude review against the spec)',
|
|
169
|
+
initialValue: answers.ci,
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const config = makeConfig(stack, answers);
|
|
176
|
+
const result = plan(root, config, { force: opts.force });
|
|
177
|
+
const { lines } = summarisePlan(result.plans);
|
|
178
|
+
if (result.settings?.error) p.log.warn(result.settings.error);
|
|
179
|
+
p.note(lines.join('\n') || 'Nothing to change.', 'Files');
|
|
180
|
+
|
|
181
|
+
if (opts.dryRun) return p.outro('Dry run: nothing was written.');
|
|
182
|
+
if (interactive) {
|
|
183
|
+
const ok = bail(await p.confirm({ message: 'Install the factory?' }));
|
|
184
|
+
if (!ok) return p.outro('Nothing was written.');
|
|
185
|
+
}
|
|
186
|
+
apply(root, result);
|
|
187
|
+
p.log.success(`Buildline installed: ${result.plans.filter((x) => x.content).length} files written.`);
|
|
188
|
+
|
|
189
|
+
const steps = [];
|
|
190
|
+
if (config.agents.includes('claude')) {
|
|
191
|
+
steps.push(`${pc.cyan('claude')} then type ${pc.cyan('/buildline-onboard')} ${pc.dim(stack.empty ? 'interview + scaffold your project' : 'map this codebase into context')}`);
|
|
192
|
+
steps.push(`${pc.cyan('/buildline-new <idea>')} ${pc.dim('start your first feature')}`);
|
|
193
|
+
} else {
|
|
194
|
+
steps.push(`Ask your agent: ${pc.cyan('"run the Buildline onboard station"')}`);
|
|
195
|
+
}
|
|
196
|
+
steps.push(`${pc.cyan('npx buildline ui')} ${pc.dim('dashboard for PMs and designers')}`);
|
|
197
|
+
steps.push(`${pc.cyan('npx buildline')} ${pc.dim('status and menu any time')}`);
|
|
198
|
+
p.note(steps.join('\n'), 'Next steps');
|
|
199
|
+
|
|
200
|
+
if (interactive && config.agents.includes('claude') && hasCommand('claude')) {
|
|
201
|
+
const launch = bail(await p.confirm({ message: 'Start onboarding in Claude Code now?', initialValue: true }));
|
|
202
|
+
if (launch) {
|
|
203
|
+
p.outro('Opening Claude Code…');
|
|
204
|
+
return launchClaude(root, ['/buildline-onboard']);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
p.outro(`Read ${pc.cyan('.buildline/README.md')} for the role-by-role guide.`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---- update / remove ---------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
export async function update(root, opts) {
|
|
213
|
+
if (!opts.quietIntro) p.intro(pc.bgYellow(pc.black(' buildline update ')));
|
|
214
|
+
const config = loadConfig(root);
|
|
215
|
+
const manifest = loadManifest(root);
|
|
216
|
+
const result = plan(root, config, { force: opts.force });
|
|
217
|
+
const { lines } = summarisePlan(result.plans);
|
|
218
|
+
if (result.settings?.error) p.log.warn(result.settings.error);
|
|
219
|
+
p.note(lines.join('\n') || 'Everything is up to date.', `v${manifest.version || '?'} → v${VERSION}`);
|
|
220
|
+
if (opts.dryRun) return p.outro('Dry run: nothing was written.');
|
|
221
|
+
apply(root, result);
|
|
222
|
+
p.outro('Buildline updated.');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function remove(root, opts) {
|
|
226
|
+
p.intro(pc.bgRed(pc.white(' buildline remove ')));
|
|
227
|
+
const config = loadConfig(root);
|
|
228
|
+
const result = removePlan(root, config);
|
|
229
|
+
const label = { delete: pc.red('delete'), 'strip-block': pc.cyan('strip '), 'keep-modified': pc.yellow('keep '), 'keep-user': pc.dim('keep ') };
|
|
230
|
+
p.note(result.plans.map((x) => `${label[x.action]} ${x.rel}`).join('\n'), 'Removal plan');
|
|
231
|
+
p.log.info('Your features, specs, context files, principles and buildline.json are kept. Delete .buildline/ yourself if you want them gone.');
|
|
232
|
+
if (opts.dryRun) return p.outro('Dry run: nothing was removed.');
|
|
233
|
+
if (!opts.yes) {
|
|
234
|
+
const ok = bail(await p.confirm({ message: 'Remove the factory from this project?', initialValue: false }));
|
|
235
|
+
if (!ok) return p.outro('Nothing removed.');
|
|
236
|
+
}
|
|
237
|
+
applyRemove(root, result);
|
|
238
|
+
p.outro('Buildline removed.');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---- status ------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
export function status(root, opts = {}) {
|
|
244
|
+
const config = loadConfig(root);
|
|
245
|
+
const features = listFeatures(root);
|
|
246
|
+
if (opts.json) {
|
|
247
|
+
console.log(JSON.stringify({ config, features: features.map(({ path: _p, ...f }) => ({ ...f, next: nextAction(f, config.autonomy) })) }, null, 2));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const active = features.filter((f) => f.status !== 'done');
|
|
251
|
+
const done = features.length - active.length;
|
|
252
|
+
console.log(`\n${pc.bold(config.project)} ${pc.dim('·')} ${AUTONOMY[config.autonomy]?.label || 'L' + config.autonomy} ${pc.dim('·')} ${active.length} active, ${done} shipped\n`);
|
|
253
|
+
if (!active.length) {
|
|
254
|
+
console.log(pc.dim(' No active features. Start one with ') + pc.cyan('npx buildline new "<title>"') + pc.dim(' or /buildline-new in Claude Code.\n'));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const who = { pm: pc.magenta('PM '), designer: pc.blue('design'), eng: pc.cyan('eng '), agent: pc.green('agent '), human: pc.red('human ') };
|
|
258
|
+
for (const f of active) {
|
|
259
|
+
const tasks = f.tasks || [];
|
|
260
|
+
const passing = tasks.filter((t) => t.passes).length;
|
|
261
|
+
const next = nextAction(f, config.autonomy);
|
|
262
|
+
const station = f.status.padEnd(7);
|
|
263
|
+
console.log(
|
|
264
|
+
` ${pc.dim(f.id)} ${(f.title || '').slice(0, 34).padEnd(34)} ${pc.yellow(station)} ${bar(passing, tasks.length)} ${who[next.who] || ' '} ${next.text}`,
|
|
265
|
+
);
|
|
266
|
+
if (next.cmd) console.log(` ${' '.repeat(45)}${pc.dim('▸ ' + next.cmd)}`);
|
|
267
|
+
}
|
|
268
|
+
console.log();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---- new / approve / move ----------------------------------------------------
|
|
272
|
+
|
|
273
|
+
export async function newFeature(root, title, opts) {
|
|
274
|
+
loadConfig(root);
|
|
275
|
+
let type = opts.type;
|
|
276
|
+
if (!title && process.stdin.isTTY) {
|
|
277
|
+
title = bail(await p.text({ message: 'What do you want to build or fix?', placeholder: 'e.g. Let users save carts for later' }));
|
|
278
|
+
}
|
|
279
|
+
if (!type && process.stdin.isTTY) {
|
|
280
|
+
type = bail(
|
|
281
|
+
await p.select({
|
|
282
|
+
message: 'How big is it?',
|
|
283
|
+
options: [
|
|
284
|
+
{ value: 'fix', label: 'Fix', hint: 'bug or small change: skips spec/design/plan' },
|
|
285
|
+
{ value: 'feature', label: 'Feature', hint: 'new behaviour or UI: full pipeline' },
|
|
286
|
+
{ value: 'epic', label: 'Epic', hint: 'several PRs: will be split into features' },
|
|
287
|
+
],
|
|
288
|
+
initialValue: 'feature',
|
|
289
|
+
}),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
const f = createFeature(root, { title, type: type || 'feature', owner: opts.owner || null, parent: opts.parent || null });
|
|
293
|
+
const next = f.type === 'fix' ? `/buildline-build ${f.id}` : `/buildline-spec ${f.id}`;
|
|
294
|
+
console.log(`${pc.green('✔')} Created ${pc.bold(f.id)} ${f.title} ${pc.dim(`(${f.type}) → .buildline/features/${f.dir}/`)}`);
|
|
295
|
+
console.log(` Next: ${pc.cyan(next)} in Claude Code, or edit ${pc.dim(`.buildline/features/${f.dir}/spec.md`)}`);
|
|
296
|
+
return f;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function approveFeature(root, ref, stage, opts) {
|
|
300
|
+
const f = findFeature(root, ref);
|
|
301
|
+
if (!f) throw new Error(`No feature matching "${ref}".`);
|
|
302
|
+
const by = opts.by || process.env.GIT_AUTHOR_NAME || spawnSync('git', ['config', 'user.name'], { cwd: root, encoding: 'utf8' }).stdout?.trim() || process.env.USER;
|
|
303
|
+
approve(f, stage, by);
|
|
304
|
+
console.log(`${pc.green('✔')} ${f.id} ${stage} approved by ${by}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function moveFeature(root, ref, station, opts) {
|
|
308
|
+
const f = findFeature(root, ref);
|
|
309
|
+
if (!f) throw new Error(`No feature matching "${ref}".`);
|
|
310
|
+
if (!STATIONS.includes(station)) throw new Error(`Station must be one of: ${STATIONS.join(', ')}`);
|
|
311
|
+
setStatus(f, station, opts.note);
|
|
312
|
+
console.log(`${pc.green('✔')} ${f.id} → ${station}`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---- gates / run / doctor ------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
export function gates(root, names) {
|
|
318
|
+
const script = path.join(root, '.buildline', 'bin', 'gates.mjs');
|
|
319
|
+
if (!fs.existsSync(script)) throw new Error('Gates script missing. Run npx buildline update.');
|
|
320
|
+
const res = spawnSync(process.execPath, [script, ...names], { cwd: root, stdio: 'inherit' });
|
|
321
|
+
process.exitCode = res.status ?? 1;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function goalFor(f) {
|
|
325
|
+
return (
|
|
326
|
+
`/goal Feature ${f.id} (${f.title}) reaches status "ship": following .buildline/stations/autopilot.md, ` +
|
|
327
|
+
`every task in .buildline/features/${f.dir}/feature.json has "passes": true, the last \`node .buildline/bin/gates.mjs\` run printed "ALL GATES PASSED", ` +
|
|
328
|
+
`and the buildline-verifier subagent reported every acceptance scenario as PASS. Stop early if blocked as defined in the playbook, or after 60 turns.`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function launch(root, cmd, args) {
|
|
333
|
+
if (!hasCommand(cmd)) {
|
|
334
|
+
const cli = CLIS[cmd] || { label: cmd, install: '' };
|
|
335
|
+
throw new Error(`${cli.label} is not installed${cli.install ? ` (${cli.install})` : ''}. Install it, then run this again.`);
|
|
336
|
+
}
|
|
337
|
+
// npm installs CLIs as .cmd shims on Windows, which need a shell (and quoted args).
|
|
338
|
+
const win = process.platform === 'win32';
|
|
339
|
+
const child = spawn(cmd, win ? args.map((x) => `"${x.replace(/"/g, '\\"')}"`) : args, { cwd: root, stdio: 'inherit', shell: win });
|
|
340
|
+
return new Promise((resolve) => child.on('exit', (code) => resolve((process.exitCode = code ?? 0))));
|
|
341
|
+
}
|
|
342
|
+
const launchClaude = (root, args) => launch(root, 'claude', args);
|
|
343
|
+
|
|
344
|
+
/** Open a station for a feature in whichever CLI and model buildline.json routes it to. */
|
|
345
|
+
export async function step(root, station, ref, opts = {}) {
|
|
346
|
+
const config = loadConfig(root);
|
|
347
|
+
if (station !== 'buildline' && !MODEL_STATIONS.includes(station) && station !== 'autopilot') throw new Error(`Unknown station "${station}". Use one of: ${MODEL_STATIONS.join(', ')}.`);
|
|
348
|
+
const f = ref ? findFeature(root, ref) : null;
|
|
349
|
+
if (ref && !f) throw new Error(`No feature matching "${ref}".`);
|
|
350
|
+
const m = opts.model ? parseModel(opts.model) : modelFor(config, station);
|
|
351
|
+
const [cmd, args] = launchArgs(m, stationPrompt(m.cli, station, f?.id || ''), { headless: opts.headless });
|
|
352
|
+
if (opts.print) return console.log([cmd, ...args.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a))].join(' '));
|
|
353
|
+
console.log(`${pc.yellow('▶')} ${station}${f ? ` ${pc.bold(f.id)} ${f.title}` : ''} ${pc.dim('on ' + describeModel(m))}`);
|
|
354
|
+
return launch(root, cmd, args);
|
|
355
|
+
}
|
|
356
|
+
|
|
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;
|
|
371
|
+
}
|
|
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>]');
|
|
377
|
+
}
|
|
378
|
+
if (opts.json) return console.log(JSON.stringify(Object.fromEntries(MODEL_STATIONS.map((s) => [s, modelFor(config, s)])), null, 2));
|
|
379
|
+
console.log(`\n${pc.bold('Models per station')} ${pc.dim('(.buildline/buildline.json › models)')}\n`);
|
|
380
|
+
for (const s of MODEL_STATIONS) {
|
|
381
|
+
const m = modelFor(config, s);
|
|
382
|
+
const missing = m.cli !== 'claude' && !hasCommand(m.cli) ? pc.red(` ${m.cli} not installed`) : '';
|
|
383
|
+
console.log(` ${s.padEnd(9)} ${m.model || m.cli !== 'claude' ? pc.cyan(describeModel(m)) : pc.dim(describeModel(m))}${missing}`);
|
|
384
|
+
}
|
|
385
|
+
console.log(`\n ${pc.dim('Change one:')} buildline models set review codex ${pc.dim('Presets:')} ${Object.keys(PRESETS).join(', ')}\n`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export async function run(root, ref, opts) {
|
|
389
|
+
const config = loadConfig(root);
|
|
390
|
+
const f = ref ? findFeature(root, ref) : listFeatures(root).find((x) => ['plan', 'build', 'verify', 'review'].includes(x.status));
|
|
391
|
+
if (!f) throw new Error(ref ? `No feature matching "${ref}".` : 'No feature ready to run. Pass an id: buildline run 004');
|
|
392
|
+
if (config.autonomy < 3 && !opts.force) {
|
|
393
|
+
throw new Error(`Autopilot needs autonomy L3+ (this project is L${config.autonomy}). Use /buildline-build ${f.id}, raise "autonomy" in .buildline/buildline.json, or pass --force.`);
|
|
394
|
+
}
|
|
395
|
+
const goal = goalFor(f);
|
|
396
|
+
if (opts.print) return console.log(goal);
|
|
397
|
+
const build = modelFor(config, 'build');
|
|
398
|
+
if (build.cli !== 'claude') console.log(pc.yellow(`! Autopilot runs in Claude Code (/goal); build is set to ${build.cli}, so the session model is used for building.`));
|
|
399
|
+
const [, args] = launchArgs(build.cli === 'claude' ? build : { cli: 'claude', model: null }, goal, { headless: opts.headless });
|
|
400
|
+
console.log(`${pc.yellow('▶')} Autopilot for ${pc.bold(f.id)} ${f.title}${opts.headless ? pc.dim(' (headless)') : ''}`);
|
|
401
|
+
return launchClaude(root, args);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function doctor(root) {
|
|
405
|
+
const checks = [];
|
|
406
|
+
const ok = (label, detail) => checks.push([pc.green('✔'), label, detail]);
|
|
407
|
+
const warn = (label, detail) => checks.push([pc.yellow('!'), label, detail]);
|
|
408
|
+
const fail = (label, detail) => checks.push([pc.red('✘'), label, detail]);
|
|
409
|
+
|
|
410
|
+
const [major] = process.versions.node.split('.').map(Number);
|
|
411
|
+
major >= 20 ? ok('Node.js', process.versions.node) : fail('Node.js', `${process.versions.node}: need 20.12+`);
|
|
412
|
+
hasCommand('git') ? ok('git', 'installed') : warn('git', 'not found: progress tracking and review rely on git');
|
|
413
|
+
if (hasCommand('claude')) {
|
|
414
|
+
const v = spawnSync('claude', ['--version'], { encoding: 'utf8' }).stdout?.trim();
|
|
415
|
+
ok('Claude Code', v || 'installed');
|
|
416
|
+
} else warn('Claude Code', 'not found: install from https://code.claude.com (other agents still work via AGENTS.md)');
|
|
417
|
+
|
|
418
|
+
if (!isInstalled(root)) {
|
|
419
|
+
fail('Buildline', 'not installed here: run npx buildline init');
|
|
420
|
+
} else {
|
|
421
|
+
let config;
|
|
422
|
+
try {
|
|
423
|
+
config = loadConfig(root);
|
|
424
|
+
ok('buildline.json', `autonomy L${config.autonomy}, agents: ${config.agents.join(', ')}`);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
fail('buildline.json', err.message);
|
|
427
|
+
}
|
|
428
|
+
const manifest = loadManifest(root);
|
|
429
|
+
manifest.version === VERSION ? ok('Version', VERSION) : warn('Version', `installed ${manifest.version || '?'}, CLI is ${VERSION}: run npx buildline update`);
|
|
430
|
+
if (config) {
|
|
431
|
+
const routed = MODEL_STATIONS.map((s) => [s, modelFor(config, s)]).filter(([, m]) => m.model || m.cli !== 'claude');
|
|
432
|
+
ok('Models', routed.length ? routed.map(([s, m]) => `${s}: ${describeModel(m)}`).join(', ') : 'session model everywhere');
|
|
433
|
+
for (const cli of externalClis(config)) hasCommand(cli) ? ok(CLIS[cli].label, 'installed') : fail(CLIS[cli].label, `routed to in models but not installed: ${CLIS[cli].install}`);
|
|
434
|
+
config.gates?.length ? ok('Gates', config.gates.map((g) => g.name).join(', ')) : warn('Gates', 'none configured: agents cannot verify their work. Run /buildline-onboard or edit buildline.json');
|
|
435
|
+
const r = plan(root, config);
|
|
436
|
+
const modified = r.plans.filter((x) => x.action === 'skip-modified').map((x) => x.rel);
|
|
437
|
+
const missing = r.plans.filter((x) => x.action === 'create').map((x) => x.rel);
|
|
438
|
+
missing.length ? warn('Managed files', `${missing.length} missing: run npx buildline update`) : ok('Managed files', 'present');
|
|
439
|
+
if (modified.length) warn('Customised', modified.join(', '));
|
|
440
|
+
if (config.agents.includes('claude')) {
|
|
441
|
+
try {
|
|
442
|
+
const s = JSON.parse(fs.readFileSync(path.join(root, '.claude', 'settings.json'), 'utf8'));
|
|
443
|
+
JSON.stringify(s.hooks || {}).includes('.buildline/bin/hook.mjs') ? ok('Claude hooks', 'session briefing + acceptance guard') : warn('Claude hooks', 'missing: run npx buildline update');
|
|
444
|
+
} catch {
|
|
445
|
+
warn('Claude hooks', '.claude/settings.json missing or invalid');
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const todo = ['product.md', 'architecture.md', 'design.md'].filter((f) => {
|
|
450
|
+
try {
|
|
451
|
+
return fs.readFileSync(path.join(root, '.buildline', 'context', f), 'utf8').includes('TODO');
|
|
452
|
+
} catch {
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
todo.length ? warn('Context', `${todo.join(', ')} still have TODOs: run /buildline-onboard`) : ok('Context', 'filled in');
|
|
457
|
+
const bad = listFeatures(root).filter((f) => f.error);
|
|
458
|
+
bad.length ? fail('Features', `invalid feature.json in ${bad.map((f) => f.dir).join(', ')}`) : ok('Features', `${listFeatures(root).length} found`);
|
|
459
|
+
}
|
|
460
|
+
console.log();
|
|
461
|
+
for (const [mark, label, detail] of checks) console.log(` ${mark} ${label.padEnd(15)} ${pc.dim(detail)}`);
|
|
462
|
+
console.log();
|
|
463
|
+
if (checks.some((c) => c[0] === pc.red('✘'))) process.exitCode = 1;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ---- interactive home ----------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
export async function home(root) {
|
|
469
|
+
console.log('\n' + LOGO + '\n');
|
|
470
|
+
if (!isInstalled(root)) {
|
|
471
|
+
p.intro(pc.bgYellow(pc.black(' buildline ')));
|
|
472
|
+
const go = bail(await p.confirm({ message: `No factory in ${pc.cyan(path.basename(root))} yet. Set one up?`, initialValue: true }));
|
|
473
|
+
if (!go) return p.outro('See you later.');
|
|
474
|
+
return init(root, {});
|
|
475
|
+
}
|
|
476
|
+
// eslint-disable-next-line no-constant-condition
|
|
477
|
+
while (true) {
|
|
478
|
+
status(root);
|
|
479
|
+
const features = listFeatures(root).filter((f) => f.status !== 'done');
|
|
480
|
+
const action = bail(
|
|
481
|
+
await p.select({
|
|
482
|
+
message: 'What would you like to do?',
|
|
483
|
+
options: [
|
|
484
|
+
{ value: 'new', label: 'Start a new feature or fix' },
|
|
485
|
+
features.length && { value: 'open', label: 'Work on a feature', hint: 'approve, move, autopilot, open in Claude' },
|
|
486
|
+
{ value: 'ui', label: 'Open the dashboard', hint: 'browser UI for PMs and designers' },
|
|
487
|
+
hasCommand('claude') && { value: 'claude', label: 'Open Claude Code at the front door', hint: '/buildline' },
|
|
488
|
+
{ value: 'gates', label: 'Run quality gates' },
|
|
489
|
+
{ value: 'doctor', label: 'Check setup (doctor)' },
|
|
490
|
+
{ value: 'update', label: 'Update Buildline files' },
|
|
491
|
+
{ value: 'exit', label: 'Exit' },
|
|
492
|
+
].filter(Boolean),
|
|
493
|
+
}),
|
|
494
|
+
);
|
|
495
|
+
if (action === 'exit') return p.outro('Happy building.');
|
|
496
|
+
if (action === 'new') await newFeature(root, null, {});
|
|
497
|
+
if (action === 'ui') return (await import('./server.js')).serve(root, {});
|
|
498
|
+
if (action === 'claude') return launchClaude(root, ['/buildline']);
|
|
499
|
+
if (action === 'gates') gates(root, []);
|
|
500
|
+
if (action === 'doctor') doctor(root);
|
|
501
|
+
if (action === 'update') await update(root, {});
|
|
502
|
+
if (action === 'open') {
|
|
503
|
+
const config = loadConfig(root);
|
|
504
|
+
const id = bail(
|
|
505
|
+
await p.select({
|
|
506
|
+
message: 'Which feature?',
|
|
507
|
+
options: features.map((f) => ({ value: f.id, label: `${f.id} ${f.title}`, hint: `${f.status} · ${nextAction(f, config.autonomy).text}` })),
|
|
508
|
+
}),
|
|
509
|
+
);
|
|
510
|
+
const f = findFeature(root, id);
|
|
511
|
+
const what = bail(
|
|
512
|
+
await p.select({
|
|
513
|
+
message: `${f.id} ${f.title}`,
|
|
514
|
+
options: [
|
|
515
|
+
{ value: 'station', label: 'Continue at this station', hint: `${f.status} · ${describeModel(modelFor(config, f.status))}` },
|
|
516
|
+
config.autonomy >= 3 && ['plan', 'build', 'verify', 'review'].includes(f.status) && { value: 'run', label: 'Run autopilot (/goal)' },
|
|
517
|
+
{ value: 'approve', label: 'Record an approval' },
|
|
518
|
+
{ value: 'move', label: 'Move to another station' },
|
|
519
|
+
{ value: 'back', label: 'Back' },
|
|
520
|
+
].filter(Boolean),
|
|
521
|
+
}),
|
|
522
|
+
);
|
|
523
|
+
if (what === 'station') return step(root, MODEL_STATIONS.includes(f.status) && f.status !== 'new' ? f.status : 'buildline', f.id);
|
|
524
|
+
if (what === 'run') return run(root, f.id, {});
|
|
525
|
+
if (what === 'approve') {
|
|
526
|
+
const stage = bail(await p.select({ message: 'Approve which stage?', options: ['spec', 'design', 'plan'].map((s) => ({ value: s, label: s, hint: f.approvals?.[s] ? `already approved by ${f.approvals[s].by}` : '' })) }));
|
|
527
|
+
approveFeature(root, f.id, stage, {});
|
|
528
|
+
}
|
|
529
|
+
if (what === 'move') {
|
|
530
|
+
const station = bail(await p.select({ message: 'Move to', options: STATIONS.map((s) => ({ value: s, label: s })), initialValue: f.status }));
|
|
531
|
+
moveFeature(root, f.id, station, {});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export { TYPES };
|