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/install.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Builds the list of files the factory installs for a given config, and applies
|
|
2
|
+
// it safely (see files.js). Also merges hooks/permissions into .claude/settings.json.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { render, templatesDir } from './render.js';
|
|
6
|
+
import { loadManifest, saveManifest, planWrite, applyPlan, planRemove, hash } from './files.js';
|
|
7
|
+
import { modelFor, STATION_SUBAGENTS, CLIS } from './models.js';
|
|
8
|
+
|
|
9
|
+
export const VERSION = JSON.parse(fs.readFileSync(path.join(templatesDir, '..', 'package.json'), 'utf8')).version;
|
|
10
|
+
|
|
11
|
+
export const AGENTS = {
|
|
12
|
+
claude: { label: 'Claude Code', hint: 'skills, subagents, hooks' },
|
|
13
|
+
codex: { label: 'Codex CLI', hint: 'reads AGENTS.md' },
|
|
14
|
+
cursor: { label: 'Cursor', hint: '.cursor/rules' },
|
|
15
|
+
gemini: { label: 'Gemini CLI', hint: 'GEMINI.md' },
|
|
16
|
+
copilot: { label: 'GitHub Copilot', hint: 'reads AGENTS.md' },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const AUTONOMY = {
|
|
20
|
+
1: { label: 'L1 · Assist', hint: 'human approves every station; agent never moves on alone' },
|
|
21
|
+
2: { label: 'L2 · Guided', hint: 'human approves spec + plan; agent builds, verifies, reviews' },
|
|
22
|
+
3: { label: 'L3 · Autopilot', hint: 'human approves spec; agent plans and runs build→review unattended' },
|
|
23
|
+
4: { label: 'L4 · Dark factory', hint: 'spec → ready-to-merge PR with no checkpoints; human merges' },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const STATION_SKILLS = [
|
|
27
|
+
['buildline', 'buildline', 'Software factory front door: shows feature status, what is next, and routes to the right station (onboard, new, spec, design, plan, build, verify, review, ship). Use when the user says "buildline" or "factory", asks what to do next, or wants to start any piece of work.', '[what you want to do]'],
|
|
28
|
+
['buildline-onboard', 'onboard', 'Onboard the software factory: interview for a new project and scaffold it, or map an existing codebase into .buildline/context files and real quality gates. Use when setting up the factory or when context files are stale.', ''],
|
|
29
|
+
['buildline-new', 'new', 'Buildline intake: turn an idea, request or bug report into a sized feature (fix / feature / epic) with a brief. Use when someone proposes a feature, reports a bug, or says "new feature".', '<idea or bug description>'],
|
|
30
|
+
['buildline-spec', 'spec', 'Buildline spec station: interview the PM and write testable requirements and Given/When/Then acceptance scenarios for a feature. Use when writing or refining a feature spec.', '<feature id>'],
|
|
31
|
+
['buildline-design', 'design', 'Buildline design station: UX flows, screen states, components, copy and accessibility, plus technical design, for a feature. Use for design or technical approach work on a factory feature.', '<feature id>'],
|
|
32
|
+
['buildline-plan', 'plan', 'Buildline plan station: break a feature spec into small, verifiable, ordered tasks in feature.json. Use when a spec is ready for planning.', '<feature id>'],
|
|
33
|
+
['buildline-build', 'build', 'Buildline build station: implement a feature task by task behind the quality gates, test-first, with evidence and a commit per task. Use to implement or continue implementing a factory feature.', '<feature id>'],
|
|
34
|
+
['buildline-verify', 'verify', 'Buildline verify station: independently check a feature against its acceptance scenarios with gates, the running app and browser checks. Use when all build tasks pass.', '<feature id>'],
|
|
35
|
+
['buildline-review', 'review', 'Buildline review station: review a feature diff against its spec and principles, then prepare the PR description. Use when verification passes.', '<feature id>'],
|
|
36
|
+
['buildline-ship', 'ship', 'Buildline ship station: open the PR, update the changelog and fold learnings back into the context files.', '<feature id>', true],
|
|
37
|
+
['buildline-autopilot', 'autopilot', 'Buildline autopilot: run build, verify and review in a loop until the feature is ready to ship or blocked. Pairs with /goal for unattended runs.', '<feature id>', true],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const read = (rel) => fs.readFileSync(path.join(templatesDir, rel), 'utf8');
|
|
41
|
+
const bullets = (items, fallback = 'TODO') => (items?.length ? items.map((i) => `- ${i}`).join('\n') : `- ${fallback}`);
|
|
42
|
+
|
|
43
|
+
export function defaultGates(stack) {
|
|
44
|
+
const order = ['lint', 'typecheck', 'test', 'build'];
|
|
45
|
+
return order.filter((k) => stack.commands?.[k]).map((name) => ({ name, command: stack.commands[name], required: true }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function makeConfig(stack, answers) {
|
|
49
|
+
return {
|
|
50
|
+
version: 1,
|
|
51
|
+
project: answers.project || stack.name,
|
|
52
|
+
autonomy: answers.autonomy ?? 2,
|
|
53
|
+
team: answers.team || ['developer', 'designer', 'pm'],
|
|
54
|
+
agents: answers.agents || ['claude', 'codex', 'cursor'],
|
|
55
|
+
ci: answers.ci ?? false,
|
|
56
|
+
stack: {
|
|
57
|
+
greenfield: stack.empty,
|
|
58
|
+
languages: stack.languages,
|
|
59
|
+
frameworks: stack.frameworks,
|
|
60
|
+
ui: stack.ui,
|
|
61
|
+
testing: stack.testing,
|
|
62
|
+
packageManager: stack.packageManager,
|
|
63
|
+
monorepo: stack.monorepo,
|
|
64
|
+
},
|
|
65
|
+
commands: { dev: stack.commands?.dev || null },
|
|
66
|
+
gates: answers.gates || defaultGates(stack),
|
|
67
|
+
models: answers.models || {},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function ciSetup(config) {
|
|
72
|
+
const pm = config.stack.packageManager;
|
|
73
|
+
const steps = [];
|
|
74
|
+
if (pm === 'pnpm') steps.push(' - uses: pnpm/action-setup@v4', ' - run: pnpm install --frozen-lockfile');
|
|
75
|
+
else if (pm === 'yarn') steps.push(' - run: yarn install --frozen-lockfile');
|
|
76
|
+
else if (pm === 'bun') steps.push(' - uses: oven-sh/setup-bun@v2', ' - run: bun install');
|
|
77
|
+
else if (pm === 'npm') steps.push(' - run: npm ci');
|
|
78
|
+
else if (pm === 'uv') steps.push(' - uses: astral-sh/setup-uv@v5', ' - run: uv sync');
|
|
79
|
+
else if (pm === 'poetry' || pm === 'pip') steps.push(' - uses: actions/setup-python@v5', " with: { python-version: '3.12' }", pm === 'poetry' ? ' - run: pipx install poetry && poetry install' : ' - run: pip install -r requirements.txt');
|
|
80
|
+
return steps.join('\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every file the factory manages, for this config. */
|
|
84
|
+
export function buildEntries(root, config) {
|
|
85
|
+
const has = (a) => config.agents.includes(a);
|
|
86
|
+
const gatesList = config.gates.length
|
|
87
|
+
? config.gates.map((g) => `- **${g.name}**: \`${g.command}\`${g.required === false ? ' (optional)' : ''}`).join('\n')
|
|
88
|
+
: '- _None configured yet. Run `/buildline-onboard` or add commands to `gates` in `.buildline/buildline.json`._';
|
|
89
|
+
const vars = {
|
|
90
|
+
gatesList,
|
|
91
|
+
stackBullets: bullets([...config.stack.frameworks, ...config.stack.languages, config.stack.packageManager && `Package manager: ${config.stack.packageManager}`].filter(Boolean)),
|
|
92
|
+
uiBullets: config.stack.ui.length ? `Detected: ${config.stack.ui.join(', ')}.` : '',
|
|
93
|
+
ciSetup: ciSetup(config),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const entries = [];
|
|
97
|
+
const add = (p, content, extra = {}) => entries.push({ path: p, content, mode: 'file', ...extra });
|
|
98
|
+
|
|
99
|
+
entries.push({ path: 'AGENTS.md', mode: 'block', header: '# AGENTS.md', content: render(read('AGENTS.block.md'), vars) });
|
|
100
|
+
|
|
101
|
+
if (has('claude')) {
|
|
102
|
+
const claudePath = path.join(root, 'CLAUDE.md');
|
|
103
|
+
const existing = fs.existsSync(claudePath) ? fs.readFileSync(claudePath, 'utf8').replace(/<!-- buildline:start[\s\S]*?buildline:end -->/, '') : '';
|
|
104
|
+
const agentsImport = /@\.?\/?AGENTS\.md/.test(existing) ? '' : '@AGENTS.md\n';
|
|
105
|
+
entries.push({ path: 'CLAUDE.md', mode: 'block', header: '# CLAUDE.md', position: 'top', content: render(read('CLAUDE.block.md'), { agentsImport }) });
|
|
106
|
+
|
|
107
|
+
for (const [name, station, description, hint, manualOnly] of STATION_SKILLS) {
|
|
108
|
+
const m = modelFor(config, station);
|
|
109
|
+
const front = [
|
|
110
|
+
'---',
|
|
111
|
+
`name: ${name}`,
|
|
112
|
+
`description: ${JSON.stringify(description)}`,
|
|
113
|
+
hint ? `argument-hint: ${JSON.stringify(hint)}` : null,
|
|
114
|
+
manualOnly ? 'disable-model-invocation: true' : null,
|
|
115
|
+
m.cli === 'claude' && m.model ? `model: ${m.model}` : null,
|
|
116
|
+
'---',
|
|
117
|
+
].filter(Boolean);
|
|
118
|
+
const handoff =
|
|
119
|
+
m.cli === 'claude'
|
|
120
|
+
? ''
|
|
121
|
+
: `\nThis project runs the ${station} station on ${CLIS[m.cli].label} (\`models.${station}\` in \`.buildline/buildline.json\`). Hand it off: run \`npx buildline step ${station} $ARGUMENTS --headless\` with Bash, wait for it to finish, then summarise what it did and what is next. If the command fails because ${m.cli} isn't installed, say so and carry on with the playbook below yourself.\n`;
|
|
122
|
+
const body = `${handoff}\nRead \`.buildline/stations/${station}.md\` now and follow it step by step. It is the single source of truth for this station, shared with every other agent in the project.\n\nArguments: $ARGUMENTS\n`;
|
|
123
|
+
add(`.claude/skills/${name}/SKILL.md`, front.join('\n') + '\n' + body);
|
|
124
|
+
}
|
|
125
|
+
const subagentStation = Object.fromEntries(Object.entries(STATION_SUBAGENTS).map(([st, agent]) => [`${agent}.md`, st]));
|
|
126
|
+
for (const f of fs.readdirSync(path.join(templatesDir, 'agents'))) {
|
|
127
|
+
const m = subagentStation[f] ? modelFor(config, subagentStation[f]) : null;
|
|
128
|
+
const content = read(`agents/${f}`);
|
|
129
|
+
add(`.claude/agents/${f}`, m?.cli === 'claude' && m.model ? content.replace(/^model: .*$/m, `model: ${m.model}`) : content);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (has('cursor')) add('.cursor/rules/buildline.mdc', read('cursor.mdc'));
|
|
133
|
+
if (has('gemini')) entries.push({ path: 'GEMINI.md', mode: 'block', header: '# GEMINI.md', position: 'top', content: read('GEMINI.block.md') });
|
|
134
|
+
|
|
135
|
+
add('.buildline/buildline.json', JSON.stringify(config, null, 2) + '\n', { userOwned: true });
|
|
136
|
+
add('.buildline/README.md', render(read('buildline-readme.md'), { project: config.project }));
|
|
137
|
+
add('.buildline/principles.md', read('principles.md'), { userOwned: true });
|
|
138
|
+
for (const f of ['product.md', 'architecture.md', 'design.md']) add(`.buildline/context/${f}`, render(read(`context/${f}`), vars), { userOwned: true });
|
|
139
|
+
for (const f of fs.readdirSync(path.join(templatesDir, 'stations'))) add(`.buildline/stations/${f}`, read(`stations/${f}`));
|
|
140
|
+
for (const f of ['spec.md', 'progress.md']) add(`.buildline/templates/${f}`, read(`feature/${f}`));
|
|
141
|
+
add('.buildline/bin/gates.mjs', read('bin/gates.mjs'), { executable: true });
|
|
142
|
+
add('.buildline/bin/hook.mjs', read('bin/hook.mjs'), { executable: true });
|
|
143
|
+
add('.buildline/features/.gitkeep', '');
|
|
144
|
+
if (config.ci) add('.github/workflows/buildline.yml', render(read('ci/buildline.yml'), vars));
|
|
145
|
+
return entries;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---- .claude/settings.json ---------------------------------------------------
|
|
149
|
+
|
|
150
|
+
const HOOK_MARK = '.buildline/bin/hook.mjs';
|
|
151
|
+
const hookCmd = (mode) => `node "$CLAUDE_PROJECT_DIR/.buildline/bin/hook.mjs" ${mode}`;
|
|
152
|
+
|
|
153
|
+
export function settingsAllow(config) {
|
|
154
|
+
const allow = ['Bash(node .buildline/bin/gates.mjs)', 'Bash(node .buildline/bin/gates.mjs *)', 'Bash(npx buildline *)'];
|
|
155
|
+
for (const g of config.gates) allow.push(`Bash(${g.command})`, `Bash(${g.command} *)`);
|
|
156
|
+
return [...new Set(allow)];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Returns { content, changed, added } or { error }. Never drops user settings. */
|
|
160
|
+
export function mergeSettings(root, config) {
|
|
161
|
+
const file = path.join(root, '.claude', 'settings.json');
|
|
162
|
+
let settings = {};
|
|
163
|
+
if (fs.existsSync(file)) {
|
|
164
|
+
try {
|
|
165
|
+
settings = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
166
|
+
} catch (err) {
|
|
167
|
+
return { error: `.claude/settings.json is not valid JSON (${err.message}); left untouched.` };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const before = JSON.stringify(settings);
|
|
171
|
+
settings.hooks ??= {};
|
|
172
|
+
const ensureHook = (event, matcher, mode) => {
|
|
173
|
+
const groups = (settings.hooks[event] ??= []);
|
|
174
|
+
const present = groups.some((g) => (g.hooks || []).some((h) => h.command?.includes(HOOK_MARK)));
|
|
175
|
+
if (!present) groups.push({ ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command: hookCmd(mode) }] });
|
|
176
|
+
};
|
|
177
|
+
ensureHook('SessionStart', null, 'session-start');
|
|
178
|
+
ensureHook('PreToolUse', 'Edit|Write|MultiEdit', 'guard');
|
|
179
|
+
|
|
180
|
+
settings.permissions ??= {};
|
|
181
|
+
settings.permissions.allow ??= [];
|
|
182
|
+
const added = settingsAllow(config).filter((r) => !settings.permissions.allow.includes(r));
|
|
183
|
+
settings.permissions.allow.push(...added);
|
|
184
|
+
|
|
185
|
+
const content = JSON.stringify(settings, null, 2) + '\n';
|
|
186
|
+
return { content, changed: JSON.stringify(settings) !== before, added };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function unmergeSettings(root, manifest) {
|
|
190
|
+
const file = path.join(root, '.claude', 'settings.json');
|
|
191
|
+
if (!fs.existsSync(file)) return null;
|
|
192
|
+
let settings;
|
|
193
|
+
try {
|
|
194
|
+
settings = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
for (const event of Object.keys(settings.hooks || {})) {
|
|
199
|
+
settings.hooks[event] = settings.hooks[event]
|
|
200
|
+
.map((g) => ({ ...g, hooks: (g.hooks || []).filter((h) => !h.command?.includes(HOOK_MARK)) }))
|
|
201
|
+
.filter((g) => g.hooks.length);
|
|
202
|
+
if (!settings.hooks[event].length) delete settings.hooks[event];
|
|
203
|
+
}
|
|
204
|
+
if (settings.hooks && !Object.keys(settings.hooks).length) delete settings.hooks;
|
|
205
|
+
const ours = new Set(manifest.settingsAllow || []);
|
|
206
|
+
if (settings.permissions?.allow) {
|
|
207
|
+
settings.permissions.allow = settings.permissions.allow.filter((r) => !ours.has(r));
|
|
208
|
+
if (!settings.permissions.allow.length) delete settings.permissions.allow;
|
|
209
|
+
if (!Object.keys(settings.permissions).length) delete settings.permissions;
|
|
210
|
+
}
|
|
211
|
+
return Object.keys(settings).length ? JSON.stringify(settings, null, 2) + '\n' : '';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---- top level ---------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
/** Plan an install/update. Nothing is written. */
|
|
217
|
+
export function plan(root, config, { force = false } = {}) {
|
|
218
|
+
const manifest = loadManifest(root);
|
|
219
|
+
const entries = buildEntries(root, config);
|
|
220
|
+
const plans = entries.map((e) => ({ ...planWrite(root, manifest, e, { force }), mode: e.mode, executable: e.executable }));
|
|
221
|
+
let settings = null;
|
|
222
|
+
if (config.agents.includes('claude')) {
|
|
223
|
+
settings = mergeSettings(root, config);
|
|
224
|
+
if (!settings.error && settings.changed) {
|
|
225
|
+
plans.push({ rel: '.claude/settings.json', action: fs.existsSync(path.join(root, '.claude/settings.json')) ? 'merge' : 'create', content: settings.content, mode: 'settings' });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { manifest, plans, settings };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function apply(root, { manifest, plans, settings }) {
|
|
232
|
+
applyPlan(root, manifest, plans.filter((p) => p.mode !== 'settings'));
|
|
233
|
+
const s = plans.find((p) => p.mode === 'settings');
|
|
234
|
+
if (s) {
|
|
235
|
+
fs.mkdirSync(path.join(root, '.claude'), { recursive: true });
|
|
236
|
+
fs.writeFileSync(path.join(root, s.rel), s.content);
|
|
237
|
+
manifest.settingsAllow = [...new Set([...(manifest.settingsAllow || []), ...(settings?.added || [])])];
|
|
238
|
+
}
|
|
239
|
+
manifest.version = VERSION;
|
|
240
|
+
manifest.installedAt ??= new Date().toISOString();
|
|
241
|
+
manifest.updatedAt = new Date().toISOString();
|
|
242
|
+
saveManifest(root, manifest);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function removePlan(root, config) {
|
|
246
|
+
const manifest = loadManifest(root);
|
|
247
|
+
const entries = buildEntries(root, config);
|
|
248
|
+
const plans = planRemove(root, manifest, entries);
|
|
249
|
+
const settings = unmergeSettings(root, manifest);
|
|
250
|
+
return { manifest, plans, settings };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function applyRemove(root, { plans, settings }) {
|
|
254
|
+
for (const p of plans) {
|
|
255
|
+
const abs = path.join(root, p.rel);
|
|
256
|
+
if (p.action === 'delete') fs.rmSync(abs, { force: true });
|
|
257
|
+
else if (p.action === 'strip-block') fs.writeFileSync(abs, p.content);
|
|
258
|
+
}
|
|
259
|
+
if (settings !== null && settings !== undefined) {
|
|
260
|
+
const file = path.join(root, '.claude', 'settings.json');
|
|
261
|
+
if (settings === '') fs.rmSync(file, { force: true });
|
|
262
|
+
else fs.writeFileSync(file, settings);
|
|
263
|
+
}
|
|
264
|
+
fs.rmSync(path.join(root, '.buildline', 'manifest.json'), { force: true });
|
|
265
|
+
// Remove now-empty directories we created, deepest first. Feature data is never deleted.
|
|
266
|
+
for (const d of ['.buildline/bin', '.buildline/stations', '.buildline/templates', '.cursor/rules', '.cursor', '.claude/agents', '.claude/skills', '.claude', '.github/workflows', '.github']) {
|
|
267
|
+
const abs = path.join(root, d);
|
|
268
|
+
try {
|
|
269
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
|
270
|
+
for (const sub of fs.readdirSync(abs)) {
|
|
271
|
+
const s = path.join(abs, sub);
|
|
272
|
+
if (fs.statSync(s).isDirectory() && !fs.readdirSync(s).length) fs.rmdirSync(s);
|
|
273
|
+
}
|
|
274
|
+
if (!fs.readdirSync(abs).length) fs.rmdirSync(abs);
|
|
275
|
+
}
|
|
276
|
+
} catch {}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export { hash };
|
package/src/models.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
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>".
|
|
4
|
+
|
|
5
|
+
export const MODEL_STATIONS = ['onboard', 'new', 'spec', 'design', 'plan', 'build', 'verify', 'review', 'ship'];
|
|
6
|
+
|
|
7
|
+
export const CLIS = {
|
|
8
|
+
claude: { label: 'Claude Code', install: 'https://code.claude.com' },
|
|
9
|
+
codex: { label: 'Codex CLI', install: 'npm i -g @openai/codex' },
|
|
10
|
+
gemini: { label: 'Gemini CLI', install: 'npm i -g @google/gemini-cli' },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// The subagent each station leans on, so its model follows the station's.
|
|
14
|
+
export const STATION_SUBAGENTS = {
|
|
15
|
+
onboard: 'buildline-mapper',
|
|
16
|
+
spec: 'buildline-spec-critic',
|
|
17
|
+
design: 'buildline-design-reviewer',
|
|
18
|
+
verify: 'buildline-verifier',
|
|
19
|
+
review: 'buildline-reviewer',
|
|
20
|
+
};
|
|
21
|
+
|
|
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
|
+
/** "opus" → { cli: 'claude', model: 'opus' }; "codex:gpt-5" → { cli: 'codex', model: 'gpt-5' }. */
|
|
32
|
+
export function parseModel(value) {
|
|
33
|
+
const v = String(value ?? '').trim();
|
|
34
|
+
if (!v || v === 'inherit') return { cli: 'claude', model: null };
|
|
35
|
+
const [head, ...rest] = v.split(':');
|
|
36
|
+
if (CLIS[head]) return { cli: head, model: rest.join(':') || null };
|
|
37
|
+
return { cli: 'claude', model: v };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function modelFor(config, station) {
|
|
41
|
+
const models = config.models || {};
|
|
42
|
+
return parseModel(models[station] ?? models.default);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const describeModel = ({ cli, model }) => (cli === 'claude' ? model || 'session model' : `${cli}${model ? ':' + model : ''}`);
|
|
46
|
+
|
|
47
|
+
/** Prompt that starts a station in the given CLI. */
|
|
48
|
+
export function stationPrompt(cli, station, id) {
|
|
49
|
+
const arg = id ? ` ${id}` : '';
|
|
50
|
+
if (cli === 'claude') return station === 'buildline' ? `/buildline${arg}` : `/buildline-${station}${arg}`;
|
|
51
|
+
const target = id ? ` for feature ${id}` : '';
|
|
52
|
+
return `Follow .buildline/stations/${station}.md step by step${target}. Project rules are in AGENTS.md.`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** [command, args] that launches a CLI with a prompt, interactively or headless. */
|
|
56
|
+
export function launchArgs({ cli, model }, prompt, { headless = false } = {}) {
|
|
57
|
+
// Headless runs may edit files in the workspace (like Claude's acceptEdits), nothing more.
|
|
58
|
+
if (cli === 'codex') return ['codex', [...(headless ? ['exec', '--sandbox', 'workspace-write'] : []), ...(model ? ['-m', model] : []), prompt]];
|
|
59
|
+
if (cli === 'gemini') return ['gemini', [...(model ? ['-m', model] : []), ...(headless ? ['--approval-mode', 'auto_edit', '-p'] : ['-i']), prompt]];
|
|
60
|
+
return ['claude', [...(model ? ['--model', model] : []), ...(headless ? ['-p', prompt, '--permission-mode', 'acceptEdits'] : [prompt])]];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** CLIs a config needs installed, besides Claude Code. */
|
|
64
|
+
export function externalClis(config) {
|
|
65
|
+
return [...new Set(Object.values(config.models || {}).map((v) => parseModel(v).cli).filter((c) => c !== 'claude'))];
|
|
66
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
export const templatesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'templates');
|
|
5
|
+
|
|
6
|
+
/** Replaces {{name}} placeholders. GitHub's ${{ expr }} (with spaces) is left alone. */
|
|
7
|
+
export function render(template, vars) {
|
|
8
|
+
return template.replace(/\{\{(\w+)\}\}/g, (m, key) => (key in vars ? String(vars[key] ?? '') : m));
|
|
9
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Local dashboard for the factory. Binds to 127.0.0.1 only; write requests need
|
|
2
|
+
// a per-session token embedded in the page, and only factory markdown files can
|
|
3
|
+
// be read or edited.
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import pc from 'picocolors';
|
|
11
|
+
import { listFeatures, findFeature, createFeature, approve, setStatus, nextAction } from './features.js';
|
|
12
|
+
import { AUTONOMY, VERSION } from './install.js';
|
|
13
|
+
import { goalFor } from './cli.js';
|
|
14
|
+
|
|
15
|
+
const uiFile = path.join(path.dirname(fileURLToPath(import.meta.url)), 'ui', 'index.html');
|
|
16
|
+
|
|
17
|
+
const READABLE = [/^\.buildline\/features\/[^/]+\/(spec|progress)\.md$/, /^\.buildline\/context\/[\w-]+\.md$/, /^\.buildline\/principles\.md$/, /^\.buildline\/stations\/[\w-]+\.md$/, /^\.buildline\/README\.md$/];
|
|
18
|
+
const WRITABLE = [/^\.buildline\/features\/[^/]+\/spec\.md$/, /^\.buildline\/context\/[\w-]+\.md$/, /^\.buildline\/principles\.md$/];
|
|
19
|
+
|
|
20
|
+
function safeRel(root, rel, rules) {
|
|
21
|
+
if (typeof rel !== 'string' || rel.includes('..') || rel.includes('\0')) return null;
|
|
22
|
+
const norm = rel.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
23
|
+
if (!rules.some((r) => r.test(norm))) return null;
|
|
24
|
+
const abs = path.resolve(root, norm);
|
|
25
|
+
return abs.startsWith(path.resolve(root) + path.sep) ? abs : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function openBrowser(url) {
|
|
29
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
30
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
31
|
+
try {
|
|
32
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function state(root) {
|
|
37
|
+
const config = JSON.parse(fs.readFileSync(path.join(root, '.buildline', 'buildline.json'), 'utf8'));
|
|
38
|
+
const features = listFeatures(root).map(({ path: _p, ...f }) => ({
|
|
39
|
+
...f,
|
|
40
|
+
next: nextAction({ ...f, path: _p }, config.autonomy),
|
|
41
|
+
goal: goalFor(f),
|
|
42
|
+
}));
|
|
43
|
+
const contextDir = path.join(root, '.buildline', 'context');
|
|
44
|
+
const context = fs.existsSync(contextDir)
|
|
45
|
+
? fs.readdirSync(contextDir).filter((f) => f.endsWith('.md')).map((f) => {
|
|
46
|
+
const rel = `.buildline/context/${f}`;
|
|
47
|
+
return { name: f.replace('.md', ''), path: rel, todo: fs.readFileSync(path.join(root, rel), 'utf8').includes('TODO') };
|
|
48
|
+
})
|
|
49
|
+
: [];
|
|
50
|
+
context.push({ name: 'principles', path: '.buildline/principles.md', todo: false });
|
|
51
|
+
return { version: VERSION, config, autonomyLabel: AUTONOMY[config.autonomy]?.label, features, context };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const readBody = (req) =>
|
|
55
|
+
new Promise((resolve, reject) => {
|
|
56
|
+
let data = '';
|
|
57
|
+
req.on('data', (c) => {
|
|
58
|
+
data += c;
|
|
59
|
+
if (data.length > 2_000_000) reject(new Error('Body too large'));
|
|
60
|
+
});
|
|
61
|
+
req.on('end', () => resolve(data));
|
|
62
|
+
req.on('error', reject);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export function serve(root, opts = {}) {
|
|
66
|
+
if (!fs.existsSync(path.join(root, '.buildline', 'buildline.json'))) {
|
|
67
|
+
throw new Error('No factory here. Run npx buildline init first.');
|
|
68
|
+
}
|
|
69
|
+
const token = crypto.randomBytes(16).toString('hex');
|
|
70
|
+
const port = Number(opts.port) || 4545;
|
|
71
|
+
|
|
72
|
+
const server = http.createServer(async (req, res) => {
|
|
73
|
+
const send = (code, body, type = 'application/json') => {
|
|
74
|
+
res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
|
|
75
|
+
res.end(type === 'application/json' ? JSON.stringify(body) : body);
|
|
76
|
+
};
|
|
77
|
+
try {
|
|
78
|
+
// Guard against DNS rebinding: only answer requests addressed to localhost.
|
|
79
|
+
const host = (req.headers.host || '').split(':')[0];
|
|
80
|
+
if (!['localhost', '127.0.0.1'].includes(host)) return send(403, { error: 'Forbidden host' });
|
|
81
|
+
|
|
82
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
83
|
+
if (req.method === 'GET' && url.pathname === '/') {
|
|
84
|
+
const html = fs.readFileSync(uiFile, 'utf8').replace('__BUILDLINE_TOKEN__', token);
|
|
85
|
+
return send(200, html, 'text/html; charset=utf-8');
|
|
86
|
+
}
|
|
87
|
+
if (!url.pathname.startsWith('/api/')) return send(404, { error: 'Not found' });
|
|
88
|
+
if (req.method !== 'GET' && req.headers['x-buildline-token'] !== token) return send(403, { error: 'Bad token' });
|
|
89
|
+
|
|
90
|
+
if (req.method === 'GET' && url.pathname === '/api/state') return send(200, state(root));
|
|
91
|
+
|
|
92
|
+
if (url.pathname === '/api/file') {
|
|
93
|
+
if (req.method === 'GET') {
|
|
94
|
+
const abs = safeRel(root, url.searchParams.get('path'), READABLE);
|
|
95
|
+
if (!abs || !fs.existsSync(abs)) return send(404, { error: 'Not readable' });
|
|
96
|
+
return send(200, { content: fs.readFileSync(abs, 'utf8') });
|
|
97
|
+
}
|
|
98
|
+
if (req.method === 'PUT') {
|
|
99
|
+
const { path: rel, content } = JSON.parse(await readBody(req));
|
|
100
|
+
const abs = safeRel(root, rel, WRITABLE);
|
|
101
|
+
if (!abs || typeof content !== 'string') return send(400, { error: 'Not writable' });
|
|
102
|
+
fs.writeFileSync(abs, content);
|
|
103
|
+
return send(200, { ok: true });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (req.method === 'POST' && url.pathname === '/api/features') {
|
|
108
|
+
const { title, type, owner } = JSON.parse(await readBody(req));
|
|
109
|
+
const f = createFeature(root, { title, type, owner: owner || null });
|
|
110
|
+
return send(200, { id: f.id, dir: f.dir });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const m = /^\/api\/features\/([^/]+)\/(approve|move)$/.exec(url.pathname);
|
|
114
|
+
if (req.method === 'POST' && m) {
|
|
115
|
+
const f = findFeature(root, decodeURIComponent(m[1]));
|
|
116
|
+
if (!f) return send(404, { error: 'No such feature' });
|
|
117
|
+
const body = JSON.parse(await readBody(req));
|
|
118
|
+
if (m[2] === 'approve') approve(f, body.stage, body.by);
|
|
119
|
+
else setStatus(f, body.status, body.note);
|
|
120
|
+
return send(200, { ok: true });
|
|
121
|
+
}
|
|
122
|
+
send(404, { error: 'Not found' });
|
|
123
|
+
} catch (err) {
|
|
124
|
+
send(400, { error: err.message });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
server.on('error', (err) => {
|
|
129
|
+
if (err.code === 'EADDRINUSE' && !opts.port) {
|
|
130
|
+
opts.port = port + 1;
|
|
131
|
+
return serve(root, opts);
|
|
132
|
+
}
|
|
133
|
+
console.error(`${pc.red('✘')} ${err.message}`);
|
|
134
|
+
process.exitCode = 1;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
server.listen(port, '127.0.0.1', () => {
|
|
138
|
+
const url = `http://127.0.0.1:${port}`;
|
|
139
|
+
console.log(`\n ${pc.yellow('▲ buildline dashboard')} ${pc.cyan(url)}\n ${pc.dim('Ctrl+C to stop. Only reachable from this machine.')}\n`);
|
|
140
|
+
if (opts.open !== false) openBrowser(url);
|
|
141
|
+
});
|
|
142
|
+
return server;
|
|
143
|
+
}
|