buildline 0.5.1 → 0.6.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 +2 -1
- package/package.json +1 -1
- package/src/cli.js +3 -1
- package/src/features.js +65 -1
- package/src/install.js +12 -4
- package/src/server.js +3 -1
- package/src/tui.js +61 -14
- package/src/ui/index.html +39 -6
- package/templates/AGENTS.block.md +4 -2
- package/templates/bin/feature.mjs +260 -0
- package/templates/bin/hook.mjs +88 -3
- package/templates/stations/autopilot.md +1 -1
- package/templates/stations/build.md +14 -5
- package/templates/stations/design.md +2 -2
- package/templates/stations/new.md +6 -8
- package/templates/stations/plan.md +9 -14
- package/templates/stations/review.md +1 -1
- package/templates/stations/ship.md +1 -1
- package/templates/stations/spec.md +4 -3
- package/templates/stations/verify.md +2 -2
package/README.md
CHANGED
|
@@ -134,7 +134,8 @@ Start at 2. Raise it as your gates and acceptance scenarios earn trust. [More on
|
|
|
134
134
|
|
|
135
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
136
|
|
|
137
|
-
- a **comet** on the track means
|
|
137
|
+
- a **comet** on the track means a Claude Code session is working on it right now, with its latest step; **NEEDS YOU** and **BLOCKED** mean an agent is waiting on your answer
|
|
138
|
+
- run several sessions at once, one per feature: each gets its own track
|
|
138
139
|
- the model under each station is the one that runs it
|
|
139
140
|
- `↑↓` to inspect tasks and history, `⏎` to open the station on its model, `a` to approve, `q` to quit
|
|
140
141
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buildline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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
|
@@ -7,7 +7,7 @@ import { detect, describeStack } from './detect.js';
|
|
|
7
7
|
import { AGENTS, AUTONOMY, VERSION, makeConfig, plan, apply, removePlan, applyRemove, defaultGates } from './install.js';
|
|
8
8
|
import { loadManifest } from './files.js';
|
|
9
9
|
import { PHASES, MODEL_STATIONS, CLIS, modelFor, parseModel, parseModelList, describeModel, discoverModelOptions, checkModel, stationPrompt, launchArgs, externalClis } from './models.js';
|
|
10
|
-
import { listFeatures, findFeature, createFeature, approve, setStatus, nextAction, STATIONS, TYPES } from './features.js';
|
|
10
|
+
import { listFeatures, findFeature, createFeature, approve, setStatus, nextAction, moveBlocker, STATIONS, TYPES } from './features.js';
|
|
11
11
|
|
|
12
12
|
const LOGO = [
|
|
13
13
|
pc.yellow(' ┌┐ ┬ ┬┬┬ ┌┬┐┬ ┬┌┐┌┌─┐'),
|
|
@@ -310,6 +310,8 @@ export function moveFeature(root, ref, station, opts) {
|
|
|
310
310
|
const f = findFeature(root, ref);
|
|
311
311
|
if (!f) throw new Error(`No feature matching "${ref}".`);
|
|
312
312
|
if (!STATIONS.includes(station)) throw new Error(`Station must be one of: ${STATIONS.join(', ')}`);
|
|
313
|
+
const blocker = opts.force ? null : moveBlocker(f, station, loadConfig(root).autonomy ?? 2);
|
|
314
|
+
if (blocker) throw new Error(blocker.replace(/node \.buildline\/bin\/feature\.mjs approve/, 'npx buildline approve') + ' Or pass --force.');
|
|
313
315
|
setStatus(f, station, opts.note);
|
|
314
316
|
console.log(`${pc.green('✔')} ${f.id} → ${station}`);
|
|
315
317
|
}
|
package/src/features.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { render, templatesDir } from './render.js';
|
|
6
|
+
import { moveBlocker } from '../templates/bin/feature.mjs';
|
|
7
|
+
|
|
8
|
+
export { moveBlocker };
|
|
6
9
|
|
|
7
10
|
export const STATIONS = ['new', 'spec', 'design', 'plan', 'build', 'verify', 'review', 'ship', 'done'];
|
|
8
11
|
export const TYPES = ['fix', 'feature', 'epic'];
|
|
@@ -126,9 +129,12 @@ export function nextAction(feature, autonomy = 2) {
|
|
|
126
129
|
if (/TODO/.test(readSpecSection(feature, 'Acceptance') || 'TODO')) return { who: 'pm', text: 'needs requirements & acceptance', cmd: `/buildline-spec ${feature.id}` };
|
|
127
130
|
return autonomy <= 3 && !a.spec ? { who: 'pm', text: 'waiting on spec approval', cmd: `buildline approve ${feature.id} spec` } : { who: 'agent', text: 'ready for design', cmd: `/buildline-design ${feature.id}` };
|
|
128
131
|
case 'design':
|
|
132
|
+
if (autonomy <= 1 && a.design) return { who: 'agent', text: 'ready for a plan', cmd: `/buildline-plan ${feature.id}` };
|
|
129
133
|
return { who: 'designer', text: 'needs UX / technical design', cmd: `/buildline-design ${feature.id}` };
|
|
130
134
|
case 'plan':
|
|
131
|
-
return { who: 'eng', text: 'needs a task plan', cmd: `/buildline-plan ${feature.id}` };
|
|
135
|
+
if (!tasks.length) return { who: 'eng', text: 'needs a task plan', cmd: `/buildline-plan ${feature.id}` };
|
|
136
|
+
if (feature.type !== 'fix' && autonomy <= 2 && !a.plan) return { who: 'eng', text: 'waiting on plan approval', cmd: `buildline approve ${feature.id} plan` };
|
|
137
|
+
return { who: 'agent', text: 'ready to build', cmd: `/buildline-build ${feature.id}` };
|
|
132
138
|
case 'build':
|
|
133
139
|
if (feature.type !== 'fix' && autonomy <= 3 && !a.spec) return { who: 'pm', text: 'waiting on spec approval', cmd: `buildline approve ${feature.id} spec` };
|
|
134
140
|
if (feature.type !== 'fix' && autonomy <= 2 && !a.plan && tasks.length) return { who: 'eng', text: 'waiting on plan approval', cmd: `buildline approve ${feature.id} plan` };
|
|
@@ -155,3 +161,61 @@ export function readSpecSection(feature, heading) {
|
|
|
155
161
|
return null;
|
|
156
162
|
}
|
|
157
163
|
}
|
|
164
|
+
|
|
165
|
+
// ---- live sessions -------------------------------------------------------------
|
|
166
|
+
// Claude Code hooks write one file per session to .buildline/sessions/ (see
|
|
167
|
+
// templates/bin/hook.mjs). A session that hasn't been heard from in a while has
|
|
168
|
+
// most likely crashed or been closed without a SessionEnd.
|
|
169
|
+
|
|
170
|
+
const WORKING_FOR = 10 * 60e3;
|
|
171
|
+
const WAITING_FOR = 2 * 3600e3;
|
|
172
|
+
|
|
173
|
+
export function readSessions(root, at = Date.now()) {
|
|
174
|
+
const dir = path.join(root, '.buildline', 'sessions');
|
|
175
|
+
const out = [];
|
|
176
|
+
// `tracked` says hooks are reporting here, so "no session" really means nobody is working.
|
|
177
|
+
out.tracked = fs.existsSync(dir);
|
|
178
|
+
if (!out.tracked) return out;
|
|
179
|
+
for (const name of fs.readdirSync(dir)) {
|
|
180
|
+
if (!name.endsWith('.json')) continue;
|
|
181
|
+
try {
|
|
182
|
+
const s = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
|
|
183
|
+
const age = at - new Date(s.lastAt);
|
|
184
|
+
if (s.state === 'working' && age < WORKING_FOR) out.push({ ...s, age });
|
|
185
|
+
else if (s.state === 'waiting' && age < WAITING_FOR) out.push({ ...s, age });
|
|
186
|
+
} catch {}
|
|
187
|
+
}
|
|
188
|
+
return Object.assign(out.sort((a, b) => a.age - b.age), { tracked: true });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Recent tool calls from live sessions, oldest first. */
|
|
192
|
+
export function readActivity(root, limit = 200) {
|
|
193
|
+
try {
|
|
194
|
+
const text = fs.readFileSync(path.join(root, '.buildline', 'sessions', 'activity.jsonl'), 'utf8');
|
|
195
|
+
return text.split('\n').filter(Boolean).slice(-limit).flatMap((l) => {
|
|
196
|
+
try {
|
|
197
|
+
return [JSON.parse(l)];
|
|
198
|
+
} catch {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
} catch {
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* What's happening on a feature right now: live sessions on it, whether one is
|
|
209
|
+
* working or waiting for a reply. Where no hooks report (agents other than
|
|
210
|
+
* Claude Code), a feature whose files changed in the last two minutes counts as worked on.
|
|
211
|
+
*/
|
|
212
|
+
export function liveState(feature, sessions, at = Date.now()) {
|
|
213
|
+
const mine = sessions.filter((s) => s.feature === feature.id);
|
|
214
|
+
const working = mine.filter((s) => s.state === 'working');
|
|
215
|
+
const recent = at - new Date(feature.updated || 0) < 2 * 60e3;
|
|
216
|
+
return {
|
|
217
|
+
sessions: mine,
|
|
218
|
+
working: working.length > 0 || (!sessions.tracked && recent && !feature.blocked && feature.status !== 'done'),
|
|
219
|
+
waiting: !working.length && mine.some((s) => s.state === 'waiting'),
|
|
220
|
+
};
|
|
221
|
+
}
|
package/src/install.js
CHANGED
|
@@ -140,6 +140,7 @@ export function buildEntries(root, config) {
|
|
|
140
140
|
for (const f of ['spec.md', 'progress.md']) add(`.buildline/templates/${f}`, read(`feature/${f}`));
|
|
141
141
|
add('.buildline/bin/gates.mjs', read('bin/gates.mjs'), { executable: true });
|
|
142
142
|
add('.buildline/bin/hook.mjs', read('bin/hook.mjs'), { executable: true });
|
|
143
|
+
add('.buildline/bin/feature.mjs', read('bin/feature.mjs'), { executable: true });
|
|
143
144
|
add('.buildline/features/.gitkeep', '');
|
|
144
145
|
if (config.ci) add('.github/workflows/buildline.yml', render(read('ci/buildline.yml'), vars));
|
|
145
146
|
return entries;
|
|
@@ -151,7 +152,7 @@ const HOOK_MARK = '.buildline/bin/hook.mjs';
|
|
|
151
152
|
const hookCmd = (mode) => `node "$CLAUDE_PROJECT_DIR/.buildline/bin/hook.mjs" ${mode}`;
|
|
152
153
|
|
|
153
154
|
export function settingsAllow(config) {
|
|
154
|
-
const allow = ['Bash(node .buildline/bin/gates.mjs)', 'Bash(node .buildline/bin/gates.mjs *)', 'Bash(npx buildline *)'];
|
|
155
|
+
const allow = ['Bash(node .buildline/bin/gates.mjs)', 'Bash(node .buildline/bin/gates.mjs *)', 'Bash(node .buildline/bin/feature.mjs *)', 'Bash(npx buildline *)'];
|
|
155
156
|
for (const g of config.gates) allow.push(`Bash(${g.command})`, `Bash(${g.command} *)`);
|
|
156
157
|
return [...new Set(allow)];
|
|
157
158
|
}
|
|
@@ -169,13 +170,20 @@ export function mergeSettings(root, config) {
|
|
|
169
170
|
}
|
|
170
171
|
const before = JSON.stringify(settings);
|
|
171
172
|
settings.hooks ??= {};
|
|
173
|
+
// One Buildline hook per event; older installs get their entry upgraded in place.
|
|
172
174
|
const ensureHook = (event, matcher, mode) => {
|
|
173
175
|
const groups = (settings.hooks[event] ??= []);
|
|
174
|
-
const
|
|
175
|
-
if (!
|
|
176
|
+
const ours = groups.find((g) => (g.hooks || []).some((h) => h.command?.includes(HOOK_MARK)));
|
|
177
|
+
if (!ours) return groups.push({ ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command: hookCmd(mode) }] });
|
|
178
|
+
if (matcher) ours.matcher = matcher;
|
|
179
|
+
else delete ours.matcher;
|
|
180
|
+
for (const h of ours.hooks) if (h.command?.includes(HOOK_MARK)) h.command = hookCmd(mode);
|
|
176
181
|
};
|
|
177
182
|
ensureHook('SessionStart', null, 'session-start');
|
|
178
|
-
ensureHook('PreToolUse',
|
|
183
|
+
ensureHook('PreToolUse', null, 'pre');
|
|
184
|
+
ensureHook('UserPromptSubmit', null, 'prompt');
|
|
185
|
+
ensureHook('Stop', null, 'stop');
|
|
186
|
+
ensureHook('SessionEnd', null, 'session-end');
|
|
179
187
|
|
|
180
188
|
settings.permissions ??= {};
|
|
181
189
|
settings.permissions.allow ??= [];
|
package/src/server.js
CHANGED
|
@@ -8,7 +8,7 @@ import crypto from 'node:crypto';
|
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
import pc from 'picocolors';
|
|
11
|
-
import { listFeatures, findFeature, createFeature, approve, setStatus, nextAction } from './features.js';
|
|
11
|
+
import { listFeatures, findFeature, createFeature, approve, setStatus, nextAction, readSessions, liveState } from './features.js';
|
|
12
12
|
import { AUTONOMY, VERSION } from './install.js';
|
|
13
13
|
import { goalFor } from './cli.js';
|
|
14
14
|
|
|
@@ -35,9 +35,11 @@ function openBrowser(url) {
|
|
|
35
35
|
|
|
36
36
|
function state(root) {
|
|
37
37
|
const config = JSON.parse(fs.readFileSync(path.join(root, '.buildline', 'buildline.json'), 'utf8'));
|
|
38
|
+
const sessions = readSessions(root);
|
|
38
39
|
const features = listFeatures(root).map(({ path: _p, ...f }) => ({
|
|
39
40
|
...f,
|
|
40
41
|
next: nextAction({ ...f, path: _p }, config.autonomy),
|
|
42
|
+
live: liveState(f, sessions),
|
|
41
43
|
goal: goalFor(f),
|
|
42
44
|
}));
|
|
43
45
|
const contextDir = path.join(root, '.buildline', 'context');
|
package/src/tui.js
CHANGED
|
@@ -7,7 +7,7 @@ import os from 'node:os';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { spawnSync } from 'node:child_process';
|
|
9
9
|
import pc from 'picocolors';
|
|
10
|
-
import { listFeatures, nextAction, approve } from './features.js';
|
|
10
|
+
import { listFeatures, nextAction, approve, readSessions, readActivity, liveState } from './features.js';
|
|
11
11
|
import { modelFor, describeModel } from './models.js';
|
|
12
12
|
import { AUTONOMY, VERSION } from './install.js';
|
|
13
13
|
|
|
@@ -41,6 +41,7 @@ const C = {
|
|
|
41
41
|
amberDim: fg('#8a6a1a'),
|
|
42
42
|
green: fg('#4ec27f'),
|
|
43
43
|
red: fg('#f7768e'),
|
|
44
|
+
cyan: fg('#59c2e6'),
|
|
44
45
|
flash: pc.isColorSupported ? (s) => `\x1b[48;5;94m\x1b[38;5;230m${s}\x1b[39m\x1b[49m` : null,
|
|
45
46
|
};
|
|
46
47
|
const WHO = {
|
|
@@ -102,11 +103,17 @@ function box(screen, x, y, w, h, title, { accent = false } = {}) {
|
|
|
102
103
|
function loadState(root, showShipped) {
|
|
103
104
|
const config = JSON.parse(fs.readFileSync(path.join(root, '.buildline', 'buildline.json'), 'utf8'));
|
|
104
105
|
const all = listFeatures(root);
|
|
105
|
-
const
|
|
106
|
+
const sessions = readSessions(root);
|
|
107
|
+
const features = all
|
|
108
|
+
.filter((f) => showShipped || f.status !== 'done')
|
|
109
|
+
.map((f) => ({ ...f, next: nextAction(f, config.autonomy), live: liveState(f, sessions) }));
|
|
110
|
+
// Station history plus the live tool feed from running sessions, in time order.
|
|
111
|
+
const tools = readActivity(root).map((a) => ({ at: a.at, id: a.feature, station: a.tool, note: a.detail, tool: true, key: `t|${a.at}|${a.session}|${a.detail}` }));
|
|
106
112
|
const events = all
|
|
107
113
|
.flatMap((f) => (f.history || []).map((h) => ({ ...h, id: f.id, key: `${f.id}|${h.at}|${h.note}` })))
|
|
114
|
+
.concat(tools)
|
|
108
115
|
.sort((a, b) => String(a.at).localeCompare(String(b.at)));
|
|
109
|
-
return { config, all, features, events };
|
|
116
|
+
return { config, all, features, events, sessions };
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
const stationIndex = (status) => (status === 'new' ? 0 : status === 'done' ? TRACK.length : TRACK.indexOf(status));
|
|
@@ -114,6 +121,21 @@ const shortModel = (config, station) => {
|
|
|
114
121
|
const m = modelFor(config, station);
|
|
115
122
|
return m.cli === 'claude' && !m.model ? 'session' : describeModel(m).replace(/^claude-/, '');
|
|
116
123
|
};
|
|
124
|
+
const ago = (ms) => (ms < 60e3 ? `${Math.max(1, Math.round(ms / 1000))}s` : ms < 3600e3 ? `${Math.round(ms / 60e3)}m` : `${Math.round(ms / 3600e3)}h`);
|
|
125
|
+
|
|
126
|
+
/** The NEXT UP cell: who or what the feature is waiting on right now. */
|
|
127
|
+
function rowStatus(f, t) {
|
|
128
|
+
if (f.blocked) return ['BLOCKED', C.red];
|
|
129
|
+
if (f.live.working) {
|
|
130
|
+
const n = f.live.sessions.filter((x) => x.state === 'working').length;
|
|
131
|
+
return [`${SPINNER[t % SPINNER.length]} ${n > 1 ? `×${n} ` : ''}WORKING`, C.amberBold];
|
|
132
|
+
}
|
|
133
|
+
if (f.live.waiting) return ['NEEDS YOU', t % 12 < 8 ? C.red : C.faint];
|
|
134
|
+
if (f.next.who === 'agent') return ['AGENT', C.amberDim];
|
|
135
|
+
const who = WHO[f.next.who];
|
|
136
|
+
return who ? [who.label, who.color] : ['', null];
|
|
137
|
+
}
|
|
138
|
+
|
|
117
139
|
const hash = (s) => [...String(s)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7);
|
|
118
140
|
|
|
119
141
|
function timeLabel(iso) {
|
|
@@ -141,7 +163,9 @@ function drawTrack(line, x, f, seg, t) {
|
|
|
141
163
|
const cur = stationIndex(f.status);
|
|
142
164
|
const who = WHO[f.next.who];
|
|
143
165
|
const skipped = (i) => f.type === 'fix' && i < 3 && cur >= 3;
|
|
144
|
-
|
|
166
|
+
// The comet only runs while a session is actually working on this feature.
|
|
167
|
+
const working = f.live?.working && !f.blocked;
|
|
168
|
+
const idleAgent = f.next.who === 'agent' && !f.blocked && !f.live?.waiting;
|
|
145
169
|
for (let i = 0; i < TRACK.length; i++) {
|
|
146
170
|
const nx = x + i * seg;
|
|
147
171
|
let node = ['○', C.faint];
|
|
@@ -149,6 +173,8 @@ function drawTrack(line, x, f, seg, t) {
|
|
|
149
173
|
else if (i < cur) node = ['●', C.green];
|
|
150
174
|
else if (i === cur && f.blocked) node = ['✖', t % 10 < 5 ? C.red : C.faint];
|
|
151
175
|
else if (i === cur && working) node = [['◉', '◎', '◉', '●'][Math.floor(t / 3) % 4], C.amberBold];
|
|
176
|
+
else if (i === cur && f.live?.waiting) node = ['◉', t % 12 < 8 ? C.red : C.faint];
|
|
177
|
+
else if (i === cur && idleAgent) node = ['◉', C.amberDim];
|
|
152
178
|
else if (i === cur) node = ['◉', t % 12 < 8 ? who?.color || C.amber : C.faint];
|
|
153
179
|
line.put(nx, node[0], node[1]);
|
|
154
180
|
if (i === TRACK.length - 1) break;
|
|
@@ -180,7 +206,8 @@ function render(st, ui, W, H) {
|
|
|
180
206
|
const active = st.all.filter((f) => f.status !== 'done');
|
|
181
207
|
const shipped = st.all.length - active.length;
|
|
182
208
|
const tasks = active.flatMap((f) => f.tasks || []);
|
|
183
|
-
const waiting =
|
|
209
|
+
const waiting = features.filter((f) => f.status !== 'done' && !f.live.working && (f.live.waiting || ['pm', 'designer', 'eng', 'human'].includes(f.next.who))).length;
|
|
210
|
+
const sessionsWorking = st.sessions.filter((x) => x.state === 'working').length;
|
|
184
211
|
|
|
185
212
|
// header
|
|
186
213
|
const tall = W >= 96 && H >= 26;
|
|
@@ -196,6 +223,7 @@ function render(st, ui, W, H) {
|
|
|
196
223
|
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
224
|
right(1, [
|
|
198
225
|
['ACTIVE ', C.muted], [String(active.length), C.bold], [' SHIPPED ', C.muted], [String(shipped), C.green],
|
|
226
|
+
[' SESSIONS ', C.muted], [String(sessionsWorking), sessionsWorking ? C.amberBold : C.bold],
|
|
199
227
|
[' TASKS ', C.muted], [`${tasks.filter((x) => x.passes).length}/${tasks.length}`, C.bold],
|
|
200
228
|
[' WAITING ON PEOPLE ', C.muted], [String(waiting), waiting ? WHO.pm.color : C.bold],
|
|
201
229
|
]);
|
|
@@ -230,13 +258,13 @@ function render(st, ui, W, H) {
|
|
|
230
258
|
if (seg >= 7) screen[top + 2].put(nx - Math.floor(m.length / 2), m, C.faint);
|
|
231
259
|
});
|
|
232
260
|
screen[top + 1].put(rx, 'FEATURE', C.muted);
|
|
233
|
-
screen[top + 1].put(W - 3 -
|
|
261
|
+
screen[top + 1].put(W - 3 - 3, 'NOW', C.muted);
|
|
234
262
|
const rowsAvail = lineH - 5;
|
|
235
263
|
if (ui.sel < ui.scroll) ui.scroll = ui.sel;
|
|
236
264
|
if (ui.sel >= ui.scroll + rowsAvail) ui.scroll = ui.sel - rowsAvail + 1;
|
|
237
265
|
if (!features.length) {
|
|
238
266
|
const line = screen[top + 4];
|
|
239
|
-
drawTrack(line, tx, { id: 'idle', status: 'spec', type: 'feature', next: { who: 'agent' } }, seg, t);
|
|
267
|
+
drawTrack(line, tx, { id: 'idle', status: 'spec', type: 'feature', next: { who: 'agent' }, live: { working: true, sessions: [] } }, seg, t);
|
|
240
268
|
line.put(rx, 'the line is quiet · ', C.muted);
|
|
241
269
|
line.put(rx + 20, 'npx buildline new "…"', C.amber);
|
|
242
270
|
}
|
|
@@ -248,8 +276,8 @@ function render(st, ui, W, H) {
|
|
|
248
276
|
line.put(2, selected ? '▸' : ' ', C.amberBold);
|
|
249
277
|
line.put(4, f.id, flashing ? C.flash : selected ? C.amberBold : C.muted);
|
|
250
278
|
drawTrack(line, tx, f, seg, t);
|
|
251
|
-
const
|
|
252
|
-
const whoW =
|
|
279
|
+
const [label, labelColor] = rowStatus(f, t);
|
|
280
|
+
const whoW = 12;
|
|
253
281
|
const ts = f.tasks || [];
|
|
254
282
|
const roomy = W - 3 - whoW - rx >= 30;
|
|
255
283
|
const barW = ts.length ? (roomy ? 11 : 5) : 0;
|
|
@@ -262,7 +290,7 @@ function render(st, ui, W, H) {
|
|
|
262
290
|
if (roomy) bx = line.put(line.put(bx, '▰'.repeat(filled), C.green), '▱'.repeat(5 - filled), C.faint) + 1;
|
|
263
291
|
line.put(bx, `${pass}/${ts.length}`, pass === ts.length ? C.green : C.muted);
|
|
264
292
|
}
|
|
265
|
-
if (
|
|
293
|
+
if (label) line.put(W - 3 - [...label].length, label, labelColor);
|
|
266
294
|
});
|
|
267
295
|
if (features.length > rowsAvail) screen[top + lineH - 1].put(W - 16, ` ${ui.sel + 1}/${features.length} `, C.muted);
|
|
268
296
|
|
|
@@ -306,6 +334,24 @@ function drawInspector(screen, st, f, x, y, w, h) {
|
|
|
306
334
|
return a ? [[`${s} ✔ ${a.by} `, C.green]] : [[`${s} · `, C.faint]];
|
|
307
335
|
});
|
|
308
336
|
add(['approvals ', C.muted], ...appr.flat());
|
|
337
|
+
if (f.blocked) {
|
|
338
|
+
const width = Math.max(10, w - 15);
|
|
339
|
+
const words = String(f.blocked).split(/\s+/);
|
|
340
|
+
const lines = [''];
|
|
341
|
+
for (const word of words) (lines.at(-1) + ' ' + word).trim().length > width ? lines.push(word) : (lines[lines.length - 1] = (lines.at(-1) + ' ' + word).trim());
|
|
342
|
+
lines.slice(0, 4).forEach((l, i) => add([i ? ' ' : '✖ blocked ', C.red], [l, C.bold]));
|
|
343
|
+
}
|
|
344
|
+
for (const ses of f.live?.sessions || []) {
|
|
345
|
+
const on = ses.state === 'working';
|
|
346
|
+
add(
|
|
347
|
+
[on ? '● ' : '◌ ', on ? C.amberBold : C.red],
|
|
348
|
+
[`session ${String(ses.id).slice(0, 8)} `, C.muted],
|
|
349
|
+
[`${(ses.model ? String(ses.model) : shortModel(config, f.status)).replace(/^claude-/, '')} `, C.text],
|
|
350
|
+
[on ? `${ses.tool || 'thinking'} ` : 'waiting for your reply ', on ? C.cyan : C.red],
|
|
351
|
+
[on ? `${ses.detail || ''} ` : '', C.text],
|
|
352
|
+
[`· ${ago(ses.age)} ago`, C.faint],
|
|
353
|
+
);
|
|
354
|
+
}
|
|
309
355
|
const ts = f.tasks || [];
|
|
310
356
|
if (ts.length) {
|
|
311
357
|
add();
|
|
@@ -324,7 +370,8 @@ function drawInspector(screen, st, f, x, y, w, h) {
|
|
|
324
370
|
}
|
|
325
371
|
add();
|
|
326
372
|
const who = WHO[f.next.who];
|
|
327
|
-
add(['next ', C.muted], [
|
|
373
|
+
if (f.blocked) add(['next ', C.muted], ['answer the question in the session that asked it', C.text]);
|
|
374
|
+
else add(['next ', C.muted], [who ? who.label + ' ' : '', who?.color || C.text], [f.next.text, C.text]);
|
|
328
375
|
if (f.next.cmd) add(['❯ ', C.amberBold], [f.next.cmd, C.amber]);
|
|
329
376
|
const room = h - 2 - rows.length - 2;
|
|
330
377
|
const journey = (f.history || []).slice(-room);
|
|
@@ -346,9 +393,9 @@ function drawEvents(screen, st, ui, x, y, w, h) {
|
|
|
346
393
|
const line = screen[y + 1 + r];
|
|
347
394
|
const fresh = (ui.freshEvents.get(e.key) || 0) > Date.now();
|
|
348
395
|
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,
|
|
351
|
-
line.put(cx + 1, e.note || '', fresh ? C.bold : C.text, x + w - 2);
|
|
396
|
+
cx = line.put(cx + 1, e.id || '···', fresh ? C.flash : C.amber);
|
|
397
|
+
cx = line.put(cx + 1, clip(e.station || '', 7).padEnd(7), e.tool ? C.cyan : C.muted);
|
|
398
|
+
line.put(cx + 1, e.note || '', e.tool ? C.muted : fresh ? C.bold : C.text, x + w - 2);
|
|
352
399
|
});
|
|
353
400
|
if (!rows.length) screen[y + 1].put(x + 2, 'nothing yet', C.faint);
|
|
354
401
|
}
|
package/src/ui/index.html
CHANGED
|
@@ -55,7 +55,7 @@ main { padding: 16px 20px 40px; }
|
|
|
55
55
|
.col .owner { display: block; font-size: 11px; font-weight: 500; color: var(--ink-3); text-transform: none; letter-spacing: 0; margin-top: 1px; }
|
|
56
56
|
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; margin-bottom: 8px; box-shadow: var(--shadow); cursor: pointer; display: block; width: 100%; text-align: left; }
|
|
57
57
|
.card:hover { border-color: var(--accent); }
|
|
58
|
-
.card .top { display: flex; gap: 6px; align-items: center; margin-bottom: 4px; }
|
|
58
|
+
.card .top { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 4px; }
|
|
59
59
|
.card .id { font: 12px var(--mono); color: var(--ink-3); }
|
|
60
60
|
.card .title { font-weight: 600; line-height: 1.3; }
|
|
61
61
|
.card .next { font-size: 12px; line-height: 1.35; color: var(--ink-2); margin-top: 6px; display: flex; gap: 6px; align-items: baseline; }
|
|
@@ -68,6 +68,20 @@ main { padding: 16px 20px 40px; }
|
|
|
68
68
|
.who-designer { background: var(--blue-soft); color: var(--blue); }
|
|
69
69
|
.who-eng, .who-agent { background: var(--green-soft); color: var(--green); }
|
|
70
70
|
.who-human { background: var(--red-soft); color: var(--red); }
|
|
71
|
+
.live { font-size: 12px; line-height: 1.4; margin-top: 6px; display: flex; gap: 6px; align-items: baseline; color: var(--ink-2); min-width: 0; }
|
|
72
|
+
.live > span { min-width: 0; flex: 1; }
|
|
73
|
+
.live .line { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
74
|
+
.live .wrap { display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
|
|
75
|
+
.live b { font-weight: 600; }
|
|
76
|
+
.live.working b { color: var(--ink); }
|
|
77
|
+
.live.waiting b, .live.blocked b { color: var(--red); }
|
|
78
|
+
.live .dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); align-self: center; animation: pulse 1.2s ease-in-out infinite; }
|
|
79
|
+
.live.waiting .dot, .live.blocked .dot { background: var(--red); }
|
|
80
|
+
.live.blocked .dot { animation: none; }
|
|
81
|
+
.live code { font-size: 11px; background: none; padding: 0; }
|
|
82
|
+
.card.is-working { border-color: var(--accent); }
|
|
83
|
+
@keyframes pulse { 50% { opacity: .25; transform: scale(.7); } }
|
|
84
|
+
@media (prefers-reduced-motion: reduce) { .live .dot { animation: none; } }
|
|
71
85
|
.progress { height: 4px; background: var(--line); border-radius: 2px; overflow: hidden; margin-top: 8px; }
|
|
72
86
|
.progress > i { display: block; height: 100%; background: var(--green); }
|
|
73
87
|
.approvals { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; }
|
|
@@ -217,14 +231,33 @@ function md(src) {
|
|
|
217
231
|
function card(f) {
|
|
218
232
|
const tasks = f.tasks || [], done = tasks.filter((t) => t.passes).length;
|
|
219
233
|
const ap = Object.keys(f.approvals || {});
|
|
220
|
-
return `<button class="card" data-id="${esc(f.id)}">
|
|
221
|
-
<div class="top"><span class="id">${esc(f.id)}</span><span class="chip type-${esc(f.type)}">${esc(f.type)}</span
|
|
234
|
+
return `<button class="card${f.live?.working ? ' is-working' : ''}" data-id="${esc(f.id)}">
|
|
235
|
+
<div class="top"><span class="id">${esc(f.id)}</span><span class="chip type-${esc(f.type)}">${esc(f.type)}</span></div>
|
|
222
236
|
<div class="title">${esc(f.title)}</div>
|
|
223
237
|
${tasks.length ? `<div class="progress" title="${done}/${tasks.length} tasks passing"><i style="width:${(done / tasks.length) * 100}%"></i></div>` : ''}
|
|
224
|
-
${
|
|
238
|
+
${liveLine(f)}
|
|
239
|
+
${f.next?.text && f.status !== 'done' && !f.blocked ? `<div class="next">${f.next.who ? `<span class="who who-${esc(f.next.who)}">${esc(f.next.who)}</span>` : ''}${esc(f.next.text)}</div>` : ''}
|
|
225
240
|
${ap.length ? `<div class="approvals">${ap.map((a) => `<span class="chip" title="by ${esc(f.approvals[a].by)}">✓ ${esc(a)}</span>`).join('')}</div>` : ''}
|
|
226
241
|
</button>`;
|
|
227
242
|
}
|
|
243
|
+
const stationModel = (station) => { const m = S.config.models || {}; const v = m[station] ?? m.default; return v && v !== 'inherit' ? v : ''; };
|
|
244
|
+
const ago = (ms) => (ms < 60e3 ? `${Math.max(1, Math.round(ms / 1000))}s` : ms < 3600e3 ? `${Math.round(ms / 60e3)}m` : `${Math.round(ms / 3600e3)}h`);
|
|
245
|
+
/** What is happening on a feature right now: a blocker, a working session, or one waiting for a reply. */
|
|
246
|
+
function liveLine(f) {
|
|
247
|
+
if (f.blocked) return `<div class="live blocked"><i class="dot"></i><span class="wrap"><b>Blocked:</b> ${esc(f.blocked)}</span></div>`;
|
|
248
|
+
const ses = f.live?.sessions || [];
|
|
249
|
+
const on = ses.filter((x) => x.state === 'working');
|
|
250
|
+
if (f.live?.working) {
|
|
251
|
+
const s = on[0];
|
|
252
|
+
if (!s) return `<div class="live working"><i class="dot"></i><span><b>Working</b> · files changing</span></div>`;
|
|
253
|
+
const model = String(s.model || stationModel(f.status) || '').replace(/^claude-/, '');
|
|
254
|
+
const head = [`<b>Working${on.length > 1 ? ` ×${on.length}` : ''}</b>`, ago(s.age), model && esc(model)].filter(Boolean).join(' · ');
|
|
255
|
+
const doing = s.tool ? `${esc(s.tool)} <code>${esc(s.detail || '')}</code>` : 'thinking';
|
|
256
|
+
return `<div class="live working" title="${esc([s.tool, s.detail].filter(Boolean).join(': '))}"><i class="dot"></i><span><span class="line">${head}</span><span class="line">${doing}</span></span></div>`;
|
|
257
|
+
}
|
|
258
|
+
if (f.live?.waiting) return `<div class="live waiting"><i class="dot"></i><span><b>Waiting for your reply</b> in a Claude session</span></div>`;
|
|
259
|
+
return '';
|
|
260
|
+
}
|
|
228
261
|
function renderBoard() {
|
|
229
262
|
if (!S.features.length) {
|
|
230
263
|
return `<div class="hero"><h1>No features yet</h1><p>Every change in this project flows through the factory: spec, design, plan, build, verify, review, ship.</p>
|
|
@@ -234,7 +267,7 @@ function renderBoard() {
|
|
|
234
267
|
return `<div class="board">${COLUMNS.map(([key, label, owner]) => {
|
|
235
268
|
const items = S.features.filter((f) => f.status === key);
|
|
236
269
|
const model = (S.config.models || {})[key] ?? S.config.models?.default;
|
|
237
|
-
const modelTag = model && model !== 'inherit' ? ` · <span class="model" title="Model for this station (buildline models)">${esc(model)}</span>` : '';
|
|
270
|
+
const modelTag = key !== 'done' && model && model !== 'inherit' ? ` · <span class="model" title="Model for this station (buildline models)">${esc(model)}</span>` : '';
|
|
238
271
|
return `<section class="col" aria-label="${label}"><h2><span>${label} <span class="owner">${owner}${modelTag}</span></span><small>${items.length}</small></h2>${items.map(card).join('') || '<div class="empty">—</div>'}</section>`;
|
|
239
272
|
}).join('')}</div>`;
|
|
240
273
|
}
|
|
@@ -327,7 +360,7 @@ async function refresh() {
|
|
|
327
360
|
} catch (e) { toast('Dashboard lost connection: is `buildline ui` still running?'); }
|
|
328
361
|
}
|
|
329
362
|
refresh();
|
|
330
|
-
setInterval(() => { if (!document.hidden && !editing && !ctxEditing) refresh(); },
|
|
363
|
+
setInterval(() => { if (!document.hidden && !editing && !ctxEditing) refresh(); }, 2000);
|
|
331
364
|
</script>
|
|
332
365
|
</body>
|
|
333
366
|
</html>
|
|
@@ -23,7 +23,7 @@ Each station has a playbook in `.buildline/stations/<station>.md`. Read the play
|
|
|
23
23
|
|
|
24
24
|
### Where things live
|
|
25
25
|
|
|
26
|
-
- `.buildline/features/<id>-<slug>/feature.json` — state: status, approvals, tasks (each with `passes`), history
|
|
26
|
+
- `.buildline/features/<id>-<slug>/feature.json` — state: status, approvals, tasks (each with `passes`), history. Change it only with `node .buildline/bin/feature.mjs` (run it with no arguments for usage), never by hand.
|
|
27
27
|
- `.buildline/features/<id>-<slug>/spec.md` — the single source of truth for that change
|
|
28
28
|
- `.buildline/features/<id>-<slug>/progress.md` — append-only work log so any session can resume
|
|
29
29
|
- `.buildline/context/` — living product, architecture and design knowledge (update it when you ship)
|
|
@@ -45,4 +45,6 @@ A task only gets `"passes": true` after the gates pass **and** its own verificat
|
|
|
45
45
|
5. **Respect the autonomy level** (`autonomy` in `buildline.json`): pause for human approval where the station playbook says to.
|
|
46
46
|
6. **Small, reversible steps.** Prefer many small commits over one large one. Match the existing code style.
|
|
47
47
|
7. **Ask, don't guess,** when the spec is ambiguous about user-facing behaviour.
|
|
48
|
-
8. **
|
|
48
|
+
8. **Record state with the feature script.** `node .buildline/bin/feature.mjs move|task|log|block|approve …` stamps real times, keeps the history the team watches live, and refuses moves that skip a required approval. Never edit `feature.json` by hand.
|
|
49
|
+
9. **Mark it blocked before you ask.** When you need an answer from a person, run `node .buildline/bin/feature.mjs block <id> "<the question>"`, then ask. When they answer, run `node .buildline/bin/feature.mjs unblock <id>` and carry on.
|
|
50
|
+
10. **Respect model routing.** `models` in `buildline.json` can send a station to another tool (e.g. `"review": "codex"`). If a station is routed to a different CLI than the one you are, hand it off with `npx buildline step <station> <id> --headless` and summarise the result instead of doing it yourself.
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Feature state for the factory. Managed by buildline. Zero dependencies.
|
|
3
|
+
//
|
|
4
|
+
// Agents record progress through this script instead of editing feature.json by
|
|
5
|
+
// hand: it stamps real times, keeps history, and refuses moves that skip a human
|
|
6
|
+
// approval the autonomy level requires.
|
|
7
|
+
//
|
|
8
|
+
// node .buildline/bin/feature.mjs new "<title>" [--type fix|feature|epic] [--parent <id>]
|
|
9
|
+
// node .buildline/bin/feature.mjs show <id>
|
|
10
|
+
// node .buildline/bin/feature.mjs move <id> <station> [--note "…"]
|
|
11
|
+
// node .buildline/bin/feature.mjs task <id> add "<title>" [--verify "…"] [--covers R1,A2] [--parallel]
|
|
12
|
+
// node .buildline/bin/feature.mjs task <id> <T1> pass|fail [--note "what you ran and saw"]
|
|
13
|
+
// node .buildline/bin/feature.mjs log <id> "<note>"
|
|
14
|
+
// node .buildline/bin/feature.mjs block <id> "<question or reason>"
|
|
15
|
+
// node .buildline/bin/feature.mjs unblock <id> [--note "…"]
|
|
16
|
+
// node .buildline/bin/feature.mjs approve <id> <spec|design|plan> --by "<name>" (only when that person said yes)
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
20
|
+
|
|
21
|
+
export const STATIONS = ['new', 'spec', 'design', 'plan', 'build', 'verify', 'review', 'ship', 'done'];
|
|
22
|
+
const TYPES = ['fix', 'feature', 'epic'];
|
|
23
|
+
const STAGES = ['spec', 'design', 'plan'];
|
|
24
|
+
const now = () => new Date().toISOString();
|
|
25
|
+
|
|
26
|
+
/** Approvals a feature needs before it may leave a station, by autonomy level. Fixes need none. */
|
|
27
|
+
export function requiredApproval(station, autonomy, type) {
|
|
28
|
+
if (type === 'fix') return null;
|
|
29
|
+
if (station === 'spec' && autonomy <= 3) return 'spec';
|
|
30
|
+
if (station === 'design' && autonomy <= 1) return 'design';
|
|
31
|
+
if (station === 'plan' && autonomy <= 2) return 'plan';
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Why moving `feature` to `to` isn't allowed yet, or null. Moving back is always allowed. */
|
|
36
|
+
export function moveBlocker(feature, to, autonomy) {
|
|
37
|
+
const from = STATIONS.indexOf(feature.status);
|
|
38
|
+
const target = STATIONS.indexOf(to);
|
|
39
|
+
if (target < 0) return `Unknown station "${to}". Use one of: ${STATIONS.join(', ')}.`;
|
|
40
|
+
for (let i = Math.max(from, 0); i < target; i++) {
|
|
41
|
+
const stage = requiredApproval(STATIONS[i], autonomy, feature.type);
|
|
42
|
+
if (stage && !feature.approvals?.[stage]) {
|
|
43
|
+
return (
|
|
44
|
+
`${feature.id} needs ${stage} approval before it can leave "${STATIONS[i]}" (autonomy L${autonomy}). ` +
|
|
45
|
+
`Ask the person: "Approve the ${stage}?". If they say yes in this conversation, record it with ` +
|
|
46
|
+
`node .buildline/bin/feature.mjs approve ${feature.id} ${stage} --by "<their name>". ` +
|
|
47
|
+
`They can also approve in npx buildline ui or npx buildline watch. Leave the status where it is until then.`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function featuresDir(root) {
|
|
55
|
+
return path.join(root, '.buildline', 'features');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function findFeatureDir(root, ref) {
|
|
59
|
+
const dir = featuresDir(root);
|
|
60
|
+
if (!fs.existsSync(dir)) return null;
|
|
61
|
+
const r = String(ref ?? '').toLowerCase();
|
|
62
|
+
const dirs = fs.readdirSync(dir).filter((d) => /^\d{3,}-/.test(d) && fs.existsSync(path.join(dir, d, 'feature.json')));
|
|
63
|
+
const hit = dirs.find((d) => d.split('-')[0] === r.padStart(3, '0')) || dirs.find((d) => d === r) || dirs.find((d) => d.includes(r));
|
|
64
|
+
return hit ? path.join(dir, hit) : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function load(root, ref) {
|
|
68
|
+
const dir = findFeatureDir(root, ref);
|
|
69
|
+
if (!dir) throw new Error(`No feature "${ref}" in .buildline/features.`);
|
|
70
|
+
return { dir, data: JSON.parse(fs.readFileSync(path.join(dir, 'feature.json'), 'utf8')) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function save({ dir, data }) {
|
|
74
|
+
data.updated = now();
|
|
75
|
+
fs.writeFileSync(path.join(dir, 'feature.json'), JSON.stringify(data, null, 2) + '\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const autonomyOf = (root) => {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(fs.readFileSync(path.join(root, '.buildline', 'buildline.json'), 'utf8')).autonomy ?? 2;
|
|
81
|
+
} catch {
|
|
82
|
+
return 2;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const record = (data, station, note) => (data.history = [...(data.history || []), { at: now(), station, note }]);
|
|
86
|
+
|
|
87
|
+
export function move(root, ref, to, { note, force = false } = {}) {
|
|
88
|
+
const f = load(root, ref);
|
|
89
|
+
const blocker = force ? null : moveBlocker(f.data, to, autonomyOf(root));
|
|
90
|
+
if (blocker) throw new Error(blocker);
|
|
91
|
+
const from = f.data.status;
|
|
92
|
+
if (['verify', 'review'].includes(from) && to === 'build') f.data.metrics = { ...f.data.metrics, reworks: (f.data.metrics?.reworks || 0) + 1 };
|
|
93
|
+
if (to === 'done') f.data.metrics = { ...f.data.metrics, shippedAt: now() };
|
|
94
|
+
f.data.status = to;
|
|
95
|
+
f.data.blocked = null;
|
|
96
|
+
record(f.data, to, note || `Moved from ${from} to ${to}`);
|
|
97
|
+
save(f);
|
|
98
|
+
return f.data;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function approve(root, ref, stage, by) {
|
|
102
|
+
if (!STAGES.includes(stage)) throw new Error(`Approval stage must be one of: ${STAGES.join(', ')}`);
|
|
103
|
+
if (!by) throw new Error('Say who approved: --by "<name>". Only record an approval the person gave you.');
|
|
104
|
+
const f = load(root, ref);
|
|
105
|
+
f.data.approvals = { ...f.data.approvals, [stage]: { by, at: now() } };
|
|
106
|
+
record(f.data, stage, `${stage} approved by ${by}`);
|
|
107
|
+
save(f);
|
|
108
|
+
return f.data;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function addTask(root, ref, title, { verify, covers, parallel } = {}) {
|
|
112
|
+
if (!title) throw new Error('A task needs a title.');
|
|
113
|
+
const f = load(root, ref);
|
|
114
|
+
const tasks = f.data.tasks || [];
|
|
115
|
+
const n = tasks.reduce((m, t) => Math.max(m, parseInt(String(t.id).replace(/\D/g, ''), 10) || 0), 0) + 1;
|
|
116
|
+
const task = { id: `T${n}`, title, ...(covers ? { covers: String(covers).split(',').map((s) => s.trim()).filter(Boolean) } : {}), ...(verify ? { verify } : {}), ...(parallel ? { parallel: true } : {}), passes: false, notes: '' };
|
|
117
|
+
f.data.tasks = [...tasks, task];
|
|
118
|
+
save(f);
|
|
119
|
+
return task;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function setTask(root, ref, id, passes, note) {
|
|
123
|
+
const f = load(root, ref);
|
|
124
|
+
const task = (f.data.tasks || []).find((t) => String(t.id).toLowerCase() === String(id).toLowerCase());
|
|
125
|
+
if (!task) throw new Error(`${f.data.id} has no task ${id}. Tasks: ${(f.data.tasks || []).map((t) => t.id).join(', ') || 'none yet'}.`);
|
|
126
|
+
task.passes = passes;
|
|
127
|
+
if (note) task.notes = note;
|
|
128
|
+
record(f.data, f.data.status, `${task.id} ${passes ? 'passes' : 'fails'}${note ? `: ${note}` : ''}`);
|
|
129
|
+
save(f);
|
|
130
|
+
return task;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function log(root, ref, note) {
|
|
134
|
+
if (!note) throw new Error('Nothing to log.');
|
|
135
|
+
const f = load(root, ref);
|
|
136
|
+
record(f.data, f.data.status, note);
|
|
137
|
+
save(f);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function block(root, ref, reason) {
|
|
141
|
+
if (!reason) throw new Error('Say what you need: block <id> "<question or reason>".');
|
|
142
|
+
const f = load(root, ref);
|
|
143
|
+
f.data.blocked = reason;
|
|
144
|
+
record(f.data, f.data.status, `Blocked: ${reason}`);
|
|
145
|
+
save(f);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function unblock(root, ref, note) {
|
|
149
|
+
const f = load(root, ref);
|
|
150
|
+
f.data.blocked = null;
|
|
151
|
+
record(f.data, f.data.status, note ? `Unblocked: ${note}` : 'Unblocked');
|
|
152
|
+
save(f);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const slugify = (title) =>
|
|
156
|
+
title.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, ' ').trim().split(/\s+/).slice(0, 5).join('-') || 'feature';
|
|
157
|
+
|
|
158
|
+
export function create(root, title, { type = 'feature', parent = null, owner = null, templates } = {}) {
|
|
159
|
+
if (!title?.trim()) throw new Error('A feature needs a title.');
|
|
160
|
+
if (!TYPES.includes(type)) throw new Error(`Type must be one of: ${TYPES.join(', ')}`);
|
|
161
|
+
const base = featuresDir(root);
|
|
162
|
+
fs.mkdirSync(base, { recursive: true });
|
|
163
|
+
const next = fs.readdirSync(base).reduce((m, d) => Math.max(m, /^\d{3,}-/.test(d) ? parseInt(d, 10) : 0), 0) + 1;
|
|
164
|
+
const id = String(next).padStart(3, '0');
|
|
165
|
+
const slug = slugify(title);
|
|
166
|
+
const dir = path.join(base, `${id}-${slug}`);
|
|
167
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
168
|
+
const at = now();
|
|
169
|
+
const data = {
|
|
170
|
+
id, slug, title: title.trim(), type, status: type === 'fix' ? 'build' : 'spec', owner, parent, blocked: null,
|
|
171
|
+
approvals: {}, tasks: [], metrics: { reworks: 0, createdAt: at, shippedAt: null },
|
|
172
|
+
history: [{ at, station: 'new', note: `Opened as ${type}` }], created: at, updated: at,
|
|
173
|
+
};
|
|
174
|
+
fs.writeFileSync(path.join(dir, 'feature.json'), JSON.stringify(data, null, 2) + '\n');
|
|
175
|
+
const vars = { id, title: data.title, type, date: at.slice(0, 10) };
|
|
176
|
+
for (const name of ['spec.md', 'progress.md']) {
|
|
177
|
+
const local = path.join(root, '.buildline', 'templates', name);
|
|
178
|
+
const source = fs.existsSync(local) ? local : templates && path.join(templates, name);
|
|
179
|
+
const text = source && fs.existsSync(source) ? fs.readFileSync(source, 'utf8') : `# ${data.title}\n`;
|
|
180
|
+
fs.writeFileSync(path.join(dir, name), text.replace(/\{\{(\w+)\}\}/g, (m, k) => (k in vars ? vars[k] : m)));
|
|
181
|
+
}
|
|
182
|
+
return { ...data, dir };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---- command line --------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
function parse(argv) {
|
|
188
|
+
const pos = [];
|
|
189
|
+
const opts = {};
|
|
190
|
+
for (let i = 0; i < argv.length; i++) {
|
|
191
|
+
const a = argv[i];
|
|
192
|
+
if (a.startsWith('--')) {
|
|
193
|
+
const key = a.slice(2);
|
|
194
|
+
const val = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
|
|
195
|
+
opts[key] = val;
|
|
196
|
+
} else pos.push(a);
|
|
197
|
+
}
|
|
198
|
+
return { pos, opts };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function summary(d) {
|
|
202
|
+
const tasks = d.tasks || [];
|
|
203
|
+
return `${d.id} ${d.title} · ${d.status}${d.blocked ? ` · BLOCKED: ${d.blocked}` : ''} · ${tasks.filter((t) => t.passes).length}/${tasks.length} tasks`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function main(argv) {
|
|
207
|
+
const root = process.env.BUILDLINE_ROOT || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
208
|
+
const { pos, opts } = parse(argv);
|
|
209
|
+
const [cmd, ref, ...rest] = pos;
|
|
210
|
+
switch (cmd) {
|
|
211
|
+
case 'new': {
|
|
212
|
+
const f = create(root, [ref, ...rest].join(' '), { type: opts.type, parent: opts.parent, owner: opts.owner });
|
|
213
|
+
return console.log(`✔ created ${path.relative(root, f.dir)} · status ${f.status}`);
|
|
214
|
+
}
|
|
215
|
+
case 'show':
|
|
216
|
+
return console.log(JSON.stringify(load(root, ref).data, null, 2));
|
|
217
|
+
case 'move':
|
|
218
|
+
return console.log('✔ ' + summary(move(root, ref, rest[0], { note: opts.note, force: !!opts.force })));
|
|
219
|
+
case 'approve':
|
|
220
|
+
return console.log('✔ ' + summary(approve(root, ref, rest[0], opts.by)));
|
|
221
|
+
case 'task': {
|
|
222
|
+
if (rest[0] === 'add') {
|
|
223
|
+
const t = addTask(root, ref, rest.slice(1).join(' '), opts);
|
|
224
|
+
return console.log(`✔ ${t.id} added: ${t.title}`);
|
|
225
|
+
}
|
|
226
|
+
const [id, verdict] = rest;
|
|
227
|
+
if (!['pass', 'fail'].includes(verdict)) throw new Error('Usage: task <id> <T1> pass|fail [--note "…"]');
|
|
228
|
+
const t = setTask(root, ref, id, verdict === 'pass', opts.note);
|
|
229
|
+
return console.log(`✔ ${t.id} ${t.passes ? 'passes' : 'fails'} · ${summary(load(root, ref).data)}`);
|
|
230
|
+
}
|
|
231
|
+
case 'log':
|
|
232
|
+
log(root, ref, rest.join(' '));
|
|
233
|
+
return console.log('✔ logged');
|
|
234
|
+
case 'block':
|
|
235
|
+
block(root, ref, rest.join(' '));
|
|
236
|
+
return console.log(`✔ ${ref} marked blocked. Tell the person what you need; run unblock when they answer.`);
|
|
237
|
+
case 'unblock':
|
|
238
|
+
unblock(root, ref, opts.note);
|
|
239
|
+
return console.log(`✔ ${ref} unblocked`);
|
|
240
|
+
default:
|
|
241
|
+
console.log(fs.readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n').slice(1, 17).map((l) => l.replace(/^\/\/ ?/, '')).join('\n'));
|
|
242
|
+
if (cmd) process.exitCode = 1;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const invoked = () => {
|
|
247
|
+
try {
|
|
248
|
+
return import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href;
|
|
249
|
+
} catch {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
if (process.argv[1] && invoked()) {
|
|
254
|
+
try {
|
|
255
|
+
main(process.argv.slice(2));
|
|
256
|
+
} catch (err) {
|
|
257
|
+
console.error(`✘ ${err.message}`);
|
|
258
|
+
process.exitCode = 1;
|
|
259
|
+
}
|
|
260
|
+
}
|
package/templates/bin/hook.mjs
CHANGED
|
@@ -2,8 +2,14 @@
|
|
|
2
2
|
// Claude Code hooks for the factory. Managed by buildline.
|
|
3
3
|
//
|
|
4
4
|
// session-start prints a short factory briefing into the new session's context
|
|
5
|
+
// pre before each tool call: the guards below, plus a session heartbeat
|
|
5
6
|
// guard blocks edits to a spec's "## Acceptance" section while the
|
|
6
|
-
// feature is in build/verify/review, so agents can't move the goalposts
|
|
7
|
+
// feature is in build/verify/review, so agents can't move the goalposts,
|
|
8
|
+
// and hand edits to feature.json (agents use .buildline/bin/feature.mjs)
|
|
9
|
+
// prompt / stop / session-end
|
|
10
|
+
// heartbeat: which session works on which feature, so
|
|
11
|
+
// `buildline watch` and the dashboard can show live work.
|
|
12
|
+
// Written to .buildline/sessions/ (git-ignored).
|
|
7
13
|
import fs from 'node:fs';
|
|
8
14
|
import path from 'node:path';
|
|
9
15
|
import { fileURLToPath } from 'node:url';
|
|
@@ -11,6 +17,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
11
17
|
const root = process.env.CLAUDE_PROJECT_DIR || path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
12
18
|
const featuresDir = path.join(root, '.buildline', 'features');
|
|
13
19
|
const mode = process.argv[2];
|
|
20
|
+
const sessionsDir = path.join(root, '.buildline', 'sessions');
|
|
14
21
|
|
|
15
22
|
function readJson(file) {
|
|
16
23
|
try {
|
|
@@ -48,10 +55,22 @@ const acceptance = (text) => {
|
|
|
48
55
|
return m ? m[1].trim() : null;
|
|
49
56
|
};
|
|
50
57
|
|
|
58
|
+
const deny = (msg) => {
|
|
59
|
+
process.stderr.write(msg + '\n');
|
|
60
|
+
process.exit(2);
|
|
61
|
+
};
|
|
62
|
+
|
|
51
63
|
function guard(input) {
|
|
52
64
|
const tool = input.tool_name;
|
|
53
65
|
const ti = input.tool_input || {};
|
|
54
66
|
const file = ti.file_path ? path.resolve(root, ti.file_path) : null;
|
|
67
|
+
const viaScript = 'Use node .buildline/bin/feature.mjs instead (move, task, log, block, approve); run it with no arguments for usage. It records real times and checks approvals.';
|
|
68
|
+
if (file && file.startsWith(featuresDir + path.sep) && path.basename(file) === 'feature.json' && fs.existsSync(file)) {
|
|
69
|
+
deny(`buildline guard: don't edit ${path.relative(root, file)} by hand. ${viaScript}`);
|
|
70
|
+
}
|
|
71
|
+
if (tool === 'Bash' && /feature\.json/.test(ti.command || '') && !/feature\.mjs/.test(ti.command || '') && /(>|sed\s+-i|writeFile|json\.dump|\btee\b|perl\s+-[a-z]*i)/.test(ti.command || '')) {
|
|
72
|
+
deny(`buildline guard: don't write feature.json from the shell. ${viaScript}`);
|
|
73
|
+
}
|
|
55
74
|
if (!file || !file.startsWith(featuresDir + path.sep) || path.basename(file) !== 'spec.md') return;
|
|
56
75
|
|
|
57
76
|
const feature = readJson(path.join(path.dirname(file), 'feature.json'));
|
|
@@ -63,7 +82,9 @@ function guard(input) {
|
|
|
63
82
|
else if (tool === 'Edit') after = before.replace(ti.old_string ?? '', ti.new_string ?? '');
|
|
64
83
|
else if (Array.isArray(ti.edits)) for (const e of ti.edits) after = after.replace(e.old_string ?? '', e.new_string ?? '');
|
|
65
84
|
|
|
66
|
-
|
|
85
|
+
// An acceptance section still holding the template's TODO may be filled in (fixes start at build).
|
|
86
|
+
const unfilled = /\bTODO\b/.test(acceptance(before) ?? 'TODO');
|
|
87
|
+
if (!unfilled && acceptance(before) !== acceptance(after)) {
|
|
67
88
|
process.stderr.write(
|
|
68
89
|
`buildline guard: "## Acceptance" in ${path.relative(root, file)} is locked while the feature is in "${feature.status}". ` +
|
|
69
90
|
'If a scenario is wrong, stop and ask the spec owner; they can change it at the spec station.\n',
|
|
@@ -72,6 +93,66 @@ function guard(input) {
|
|
|
72
93
|
}
|
|
73
94
|
}
|
|
74
95
|
|
|
96
|
+
// ---- heartbeat -------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/** The feature a prompt or tool call is about, e.g. "/buildline-build 3" or a path under features/003-…. */
|
|
99
|
+
function featureRef(text) {
|
|
100
|
+
const s = String(text || '');
|
|
101
|
+
const m =
|
|
102
|
+
/\.buildline\/features\/(\d{3,})-/.exec(s) ||
|
|
103
|
+
/feature\.mjs\s+(?:move|task|log|block|unblock|approve|show)\s+(\d{1,4})\b/.exec(s) ||
|
|
104
|
+
/\/buildline(?:-[a-z]+)?\s+(\d{1,4})\b/.exec(s) ||
|
|
105
|
+
/buildline\s+(?:step\s+[a-z]+|run|approve|move)\s+(\d{1,4})\b/.exec(s);
|
|
106
|
+
return m ? m[1].padStart(3, '0') : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function detail(input) {
|
|
110
|
+
const ti = input.tool_input || {};
|
|
111
|
+
if (ti.file_path) return path.basename(ti.file_path);
|
|
112
|
+
if (ti.command) {
|
|
113
|
+
// Short and readable: drop heredoc bodies and the long feature folder paths.
|
|
114
|
+
const cmd = String(ti.command).split(/<<|\n/)[0].replace(/\.buildline\/features\/[^/\s]+\//g, '').replace(/\s+/g, ' ').trim();
|
|
115
|
+
return cmd.length > 48 ? cmd.slice(0, 47) + '…' : cmd;
|
|
116
|
+
}
|
|
117
|
+
if (ti.pattern) return String(ti.pattern).slice(0, 40);
|
|
118
|
+
if (ti.description) return String(ti.description).slice(0, 60);
|
|
119
|
+
return '';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function heartbeat(input, event) {
|
|
123
|
+
const id = String(input.session_id || '').replace(/[^\w-]/g, '');
|
|
124
|
+
if (!id) return;
|
|
125
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
126
|
+
const ignore = path.join(sessionsDir, '.gitignore');
|
|
127
|
+
if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, '*\n');
|
|
128
|
+
const file = path.join(sessionsDir, `${id}.json`);
|
|
129
|
+
if (event === 'end') return fs.rmSync(file, { force: true });
|
|
130
|
+
const at = new Date().toISOString();
|
|
131
|
+
const prev = readJson(file) || { id, startedAt: at };
|
|
132
|
+
const prompt = input.prompt || '';
|
|
133
|
+
const station = /\/buildline-([a-z]+)/.exec(prompt)?.[1];
|
|
134
|
+
const ti = input.tool_input || {};
|
|
135
|
+
// A session stays on the feature it was started for; reading another feature's files doesn't move it.
|
|
136
|
+
const feature = featureRef(prompt) || prev.feature || featureRef(ti.file_path) || featureRef(ti.command) || featureRef(ti.prompt) || null;
|
|
137
|
+
const next = {
|
|
138
|
+
...prev,
|
|
139
|
+
feature,
|
|
140
|
+
station: station || prev.station || null,
|
|
141
|
+
model: input.model?.id || input.model || prev.model || null,
|
|
142
|
+
state: event === 'stop' ? 'waiting' : 'working',
|
|
143
|
+
lastAt: at,
|
|
144
|
+
...(event === 'tool' ? { tool: input.tool_name, detail: detail(input) } : {}),
|
|
145
|
+
...(event === 'prompt' ? { tool: null, detail: prompt.replace(/\s+/g, ' ').slice(0, 60) } : {}),
|
|
146
|
+
};
|
|
147
|
+
fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n');
|
|
148
|
+
if (event === 'tool' && feature) {
|
|
149
|
+
// A short live activity feed for the views; trimmed so it never grows large.
|
|
150
|
+
const feed = path.join(sessionsDir, 'activity.jsonl');
|
|
151
|
+
fs.appendFileSync(feed, JSON.stringify({ at, session: id.slice(0, 8), feature, tool: input.tool_name, detail: next.detail }) + '\n');
|
|
152
|
+
if (fs.statSync(feed).size > 200_000) fs.writeFileSync(feed, fs.readFileSync(feed, 'utf8').split('\n').slice(-500).join('\n'));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
75
156
|
let raw = '';
|
|
76
157
|
process.stdin.on('data', (c) => (raw += c));
|
|
77
158
|
process.stdin.on('end', () => {
|
|
@@ -80,8 +161,12 @@ process.stdin.on('end', () => {
|
|
|
80
161
|
input = raw ? JSON.parse(raw) : {};
|
|
81
162
|
} catch {}
|
|
82
163
|
try {
|
|
83
|
-
if (mode === 'session-start') sessionStart();
|
|
164
|
+
if (mode === 'session-start') sessionStart(), heartbeat(input, 'start');
|
|
84
165
|
else if (mode === 'guard') guard(input);
|
|
166
|
+
else if (mode === 'pre') heartbeat(input, 'tool'), guard(input);
|
|
167
|
+
else if (mode === 'prompt') heartbeat(input, 'prompt');
|
|
168
|
+
else if (mode === 'stop') heartbeat(input, 'stop');
|
|
169
|
+
else if (mode === 'session-end') heartbeat(input, 'end');
|
|
85
170
|
} catch (err) {
|
|
86
171
|
// A broken hook must never break the session.
|
|
87
172
|
process.stderr.write(`buildline hook error: ${err.message}\n`);
|
|
@@ -33,7 +33,7 @@ Stop and summarise instead of continuing if:
|
|
|
33
33
|
- the change needs a secret, a paid service, a production system or a destructive action,
|
|
34
34
|
- `metrics.reworks` reaches 5 (the spec or plan probably needs a human).
|
|
35
35
|
|
|
36
|
-
Write the blocker to `progress.md` and
|
|
36
|
+
Write the blocker to `progress.md` and run `node .buildline/bin/feature.mjs block <id> "<one-line reason>"` so the board shows it waiting on a person.
|
|
37
37
|
|
|
38
38
|
## Running it with `/goal` (Claude Code)
|
|
39
39
|
|
|
@@ -16,7 +16,10 @@ Resolve the feature from `$ARGUMENTS`, or use the most recent feature with statu
|
|
|
16
16
|
3. Read `spec.md`: Requirements, Acceptance, Design, Plan.
|
|
17
17
|
4. Run `node .buildline/bin/gates.mjs`. If gates fail **before** you change anything, record it in `progress.md` and fix or report it first. Don't stack new work on a red baseline.
|
|
18
18
|
|
|
19
|
-
For `fix` features
|
|
19
|
+
For `fix` features:
|
|
20
|
+
|
|
21
|
+
- If `## Brief` or `## Acceptance` in `spec.md` still say TODO, fill them in yourself from the title and the code: a one-line brief and one scenario that reproduces the bug. Fixes don't wait for a PM. Only if you can't tell what correct looks like (for example, which currency to show), block with the question (see "Asking for help").
|
|
22
|
+
- With no tasks yet, add two: `node .buildline/bin/feature.mjs task <id> add "Reproduce with a failing test, then fix"` and `node .buildline/bin/feature.mjs task <id> add "Gates pass and A1 verified"`.
|
|
20
23
|
|
|
21
24
|
## 2. Work the next task
|
|
22
25
|
|
|
@@ -26,14 +29,20 @@ Pick the first task with `"passes": false`.
|
|
|
26
29
|
2. Implement the smallest change that makes it pass. Follow the conventions in `.buildline/context/architecture.md` and reuse existing components and utilities before making new ones.
|
|
27
30
|
3. Run the task's `verify` step **and** the gates. Read the output; don't assume.
|
|
28
31
|
4. For UI work, run the app and look at it (a screenshot via the browser tools if available). Check the states listed in the design.
|
|
29
|
-
5. Only when everything above is green,
|
|
32
|
+
5. Only when everything above is green, record it: `node .buildline/bin/feature.mjs task <id> T2 pass --note "<what you ran and what you saw>"`.
|
|
30
33
|
6. Commit with a clear message, e.g. `feat(saved-carts): add model and migration [004/T1]`.
|
|
31
34
|
|
|
32
|
-
|
|
35
|
+
## Asking for help
|
|
36
|
+
|
|
37
|
+
If you get stuck on the same task after two genuinely different attempts, or you need a decision only a person can make, stop. Don't thrash or guess:
|
|
38
|
+
|
|
39
|
+
1. Write what you tried in `progress.md`.
|
|
40
|
+
2. `node .buildline/bin/feature.mjs block <id> "<the question, in one line>"` so the board shows the feature waiting on a person.
|
|
41
|
+
3. Ask the question. When it's answered, `node .buildline/bin/feature.mjs unblock <id> --note "<the answer>"` and carry on.
|
|
33
42
|
|
|
34
43
|
## 3. Hard rules
|
|
35
44
|
|
|
36
|
-
- Never
|
|
45
|
+
- Never change a filled-in `## Acceptance` in `spec.md` (a fix may fill in an empty one), never delete or weaken a test to make it pass, never disable a gate. If you believe the spec or a test is wrong, stop and explain.
|
|
37
46
|
- Never mark a task as passing without running its verification in this session.
|
|
38
47
|
- Stay inside the feature's scope. Note unrelated problems you find in `progress.md` under "Noticed" instead of fixing them.
|
|
39
48
|
- Don't commit secrets, generated artefacts or debugging leftovers.
|
|
@@ -49,4 +58,4 @@ Append to `progress.md`:
|
|
|
49
58
|
- Next: T3 (UI list); watch out for the cart total rounding in utils/money.ts
|
|
50
59
|
```
|
|
51
60
|
|
|
52
|
-
When every task passes:
|
|
61
|
+
When every task passes: `node .buildline/bin/feature.mjs move <id> verify --note "<n>/<n> tasks pass"` and continue with `/buildline-verify <id>` (or tell the person to).
|
|
@@ -34,6 +34,6 @@ Follow the conventions in `architecture.md`. If you're breaking one, say so expl
|
|
|
34
34
|
|
|
35
35
|
## Handoff
|
|
36
36
|
|
|
37
|
-
-
|
|
38
|
-
-
|
|
37
|
+
- **Approval:** at autonomy L1, ask for explicit design approval and record it only when given: `node .buildline/bin/feature.mjs approve <id> design --by "<their name>"`.
|
|
38
|
+
- Then `node .buildline/bin/feature.mjs move <id> plan --note "<one line>"`. At other levels, the designer can approve UX in the dashboard; flag the feature as waiting for design approval if UX changed and nobody has approved it yet.
|
|
39
39
|
- Next: `/buildline-plan <id>`.
|
|
@@ -24,16 +24,14 @@ State the type and the reason in one line. The person can override.
|
|
|
24
24
|
|
|
25
25
|
## 3. Create the feature
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Run `node .buildline/bin/feature.mjs new "<title>" --type <type>` (add `--owner "<name>"` if the person told you who owns it). It creates `.buildline/features/<id>-<slug>/` with `feature.json`, `spec.md` and `progress.md`, and sets the starting status: `build` for a fix, `spec` otherwise.
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
Then fill in `spec.md → ## Brief` (problem, who, outcome, open questions) and set `owner` in `feature.json` if the person told you who owns it.
|
|
29
|
+
Then fill in `spec.md → ## Brief` (problem, who, outcome, open questions).
|
|
32
30
|
|
|
33
31
|
## 4. Route
|
|
34
32
|
|
|
35
|
-
- `fix` →
|
|
36
|
-
- `feature` →
|
|
37
|
-
- `epic` →
|
|
33
|
+
- `fix` → it starts at `build`. In `spec.md`, also fill `## Acceptance` with a single scenario that reproduces the bug (it must fail before the fix). Next: `/buildline-build <id>`.
|
|
34
|
+
- `feature` → it starts at `spec`. Next: `/buildline-spec <id>`.
|
|
35
|
+
- `epic` → it starts at `spec`. Next: `/buildline-spec <id>`, which will end by proposing the split into child features.
|
|
38
36
|
|
|
39
|
-
|
|
37
|
+
Record why: `node .buildline/bin/feature.mjs log <id> "<type and one-line reason>"`.
|
|
@@ -6,19 +6,14 @@ Resolve the feature from `$ARGUMENTS`, or use the most recent feature with statu
|
|
|
6
6
|
|
|
7
7
|
## Write the tasks
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
"id": "T1",
|
|
14
|
-
"title": "Add `saved_carts` table and model",
|
|
15
|
-
"covers": ["R1", "A2"],
|
|
16
|
-
"verify": "`npm test -- saved-cart.model` passes; migration runs up and down",
|
|
17
|
-
"passes": false,
|
|
18
|
-
"notes": ""
|
|
19
|
-
}
|
|
9
|
+
Add them in order, one command per task (ids T1, T2, … are assigned for you):
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
node .buildline/bin/feature.mjs task <id> add "Add saved_carts table and model" --covers R1,A2 --verify "npm test -- saved-cart.model passes; migration runs up and down"
|
|
20
13
|
```
|
|
21
14
|
|
|
15
|
+
Add `--parallel` to tasks that could run alongside the previous one.
|
|
16
|
+
|
|
22
17
|
Rules:
|
|
23
18
|
|
|
24
19
|
- **Small**: each task fits in one focused session and one commit, usually under ~200 changed lines.
|
|
@@ -26,7 +21,7 @@ Rules:
|
|
|
26
21
|
- **Verifiable**: `verify` says exactly what to run or observe. "Works" is not a verification.
|
|
27
22
|
- **Traceable**: `covers` lists the requirement and acceptance ids it moves forward. Every `A*` scenario must be covered by at least one task.
|
|
28
23
|
- **Test-first where it's cheap**: when a task introduces logic, its first step is a failing test for the acceptance scenario.
|
|
29
|
-
- **Ordered**: dependencies come first. Mark tasks that could run in parallel with
|
|
24
|
+
- **Ordered**: dependencies come first. Mark tasks that could run in parallel with `--parallel`.
|
|
30
25
|
- Include the boring tasks: migrations, feature flags, docs, analytics events, cleanup of temporary code.
|
|
31
26
|
|
|
32
27
|
Then write a one-paragraph `## Plan` summary in `spec.md` (approach, order, risks) so humans don't have to read JSON.
|
|
@@ -39,6 +34,6 @@ Then write a one-paragraph `## Plan` summary in `spec.md` (approach, order, risk
|
|
|
39
34
|
|
|
40
35
|
## Handoff
|
|
41
36
|
|
|
42
|
-
-
|
|
43
|
-
-
|
|
37
|
+
- **Approval:** at autonomy ≤ 2, ask the person to approve the plan and record it only on an explicit yes: `node .buildline/bin/feature.mjs approve <id> plan --by "<their name>"`. Until then the feature stays at `plan`. At L3+, continue without waiting.
|
|
38
|
+
- Then `node .buildline/bin/feature.mjs move <id> build --note "<n> tasks"`.
|
|
44
39
|
- Next: `/buildline-build <id>`, or `/buildline-autopilot <id>` to run build → verify → review unattended.
|
|
@@ -34,4 +34,4 @@ Write `## Review` in `spec.md`: a summary of what changed and why, risk areas to
|
|
|
34
34
|
|
|
35
35
|
## 5. Route
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
Blockers and should-fix findings become tasks (`node .buildline/bin/feature.mjs task <id> add "…"`) and the feature goes back with `node .buildline/bin/feature.mjs move <id> build --note "<n> findings"`. When there are none: `node .buildline/bin/feature.mjs move <id> ship --note "review clean"`, next `/buildline-ship <id>`. A human still approves the merge at every autonomy level.
|
|
@@ -27,6 +27,6 @@ Specs are written for one change; the context files carry what lasts. Update the
|
|
|
27
27
|
|
|
28
28
|
## 4. Close
|
|
29
29
|
|
|
30
|
-
-
|
|
30
|
+
- `node .buildline/bin/feature.mjs move <id> done --note "<PR link>"` (this records the ship date).
|
|
31
31
|
- Append a final `progress.md` entry.
|
|
32
32
|
- Suggest the next feature in the queue (`/buildline`).
|
|
@@ -41,6 +41,7 @@ If `type` is `epic`, end by proposing 2–6 child features, each a thin, shippab
|
|
|
41
41
|
|
|
42
42
|
## 6. Approval and handoff
|
|
43
43
|
|
|
44
|
-
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
44
|
+
- **Approval:** the spec needs human approval when autonomy ≤ 3. Ask: "Approve this spec?" Only when the person explicitly approves, record it: `node .buildline/bin/feature.mjs approve <id> spec --by "<their name>"`. Never record an approval on someone's behalf. They can also approve in `npx buildline ui` or `npx buildline watch`. Until then the feature stays at `spec` and the board shows it waiting on them.
|
|
45
|
+
- Once approved (or at L4), move on: `node .buildline/bin/feature.mjs move <id> design --note "<one line>"`, or `plan` if the change has no UX or architecture decisions (say why). The script refuses the move while a required approval is missing.
|
|
46
|
+
- Record what happened: `node .buildline/bin/feature.mjs log <id> "spec drafted, waiting for approval"` (or what changed).
|
|
47
|
+
- Suggest the next command.
|
|
@@ -40,5 +40,5 @@ Save screenshots and logs to `.buildline/features/<id>-<slug>/evidence/`.
|
|
|
40
40
|
|
|
41
41
|
## 4. Route
|
|
42
42
|
|
|
43
|
-
- All pass →
|
|
44
|
-
- Any fail →
|
|
43
|
+
- All pass → `node .buildline/bin/feature.mjs move <id> review --note "A1–A<n> pass"`. Next: `/buildline-review <id>`.
|
|
44
|
+
- Any fail → for each failure, `node .buildline/bin/feature.mjs task <id> add "Fix A3: empty state copy" --covers A3`, then `node .buildline/bin/feature.mjs move <id> build --note "<n> scenarios fail"` (this counts a rework). Next: `/buildline-build <id>`.
|