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 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 an agent can work on it now; a **blinking dot** means it waits on a PM, designer or engineer
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.5.1",
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 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
+ 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', 'Edit|Write|MultiEdit', 'guard');
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 features = all.filter((f) => showShipped || f.status !== 'done').map((f) => ({ ...f, next: nextAction(f, config.autonomy) }));
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
- const working = f.next.who === 'agent' && !f.blocked;
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 = active.filter((f) => ['pm', 'designer', 'eng', 'human'].includes(nextAction(f, config.autonomy).who)).length;
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 - 7, 'NEXT UP', C.muted);
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 who = WHO[f.next.who];
252
- const whoW = 8;
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 (who) line.put(W - 3 - who.label.length, who.label, who.color);
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], [who ? who.label + ' ' : '', who?.color || C.text], [f.next.text, C.text]);
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, String(e.station || '').padEnd(7), C.muted);
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>${f.blocked ? '<span class="chip type-fix">blocked</span>' : ''}</div>
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
- ${f.next?.text && f.status !== 'done' ? `<div class="next">${f.next.who ? `<span class="who who-${esc(f.next.who)}">${esc(f.next.who)}</span>` : ''}${esc(f.next.text)}</div>` : ''}
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(); }, 3000);
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. **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.
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
+ }
@@ -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
- if (acceptance(before) !== acceptance(after)) {
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 set `feature.json → blocked` to a one-line reason.
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 with no tasks: create a single task `T1` ("Reproduce with a failing test, then fix") plus `T2` ("Gates pass and A1 verified").
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, set `"passes": true` and write in `notes` what you ran and what you observed (one or two lines).
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
- If you get stuck on the same task after two genuinely different attempts, stop: write what you tried in `progress.md`, set `notes` on the task, and ask for help. Don't thrash.
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 edit `## Acceptance` in `spec.md`, 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.
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: set status to `verify`, append a `history` entry, and continue with `/buildline-verify <id>` (or tell the person to).
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
- - Set status to `plan`. Append a `history` entry.
38
- - **Approval:** at autonomy L1, ask for explicit design approval and record `approvals.design` only when given. 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.
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
- Preferred: run `npx buildline new "<title>" --type <type>`. It creates `.buildline/features/<id>-<slug>/` with `feature.json`, `spec.md` and `progress.md`.
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
- If the CLI isn't available, do it by hand: next id = highest existing id + 1, zero-padded to 3 digits; slug = kebab-case title (max 5 words); copy the files from `.buildline/templates/`.
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` → set status to `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>`.
36
- - `feature` → status `spec`. Next: `/buildline-spec <id>`.
37
- - `epic` → status `spec`. Next: `/buildline-spec <id>`, which will end by proposing the split into child features.
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
- Append a `history` entry to `feature.json`: `{ "at": "<ISO date>", "station": "new", "note": "<type and one-line reason>" }`.
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
- Write them into `feature.json → tasks` (replace the placeholder):
10
-
11
- ```json
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 `"parallel": true`.
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
- - Set status to `build`. Append a `history` entry.
43
- - **Approval:** at autonomy ≤ 2, ask the person to approve the plan and record `approvals.plan` only on an explicit yes. At L3+, continue without waiting.
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
- When there are no open blockers or should-fix findings: status `ship`, history entry, next `/buildline-ship <id>`. A human still approves the merge at every autonomy level.
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
- - Set status to `done`, fill `metrics.shippedAt`, append a `history` entry with the PR link.
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
- - Set status to `design` (or `plan` if the change has no UX or architecture decisions; say why).
45
- - **Approval:** the spec needs human approval when autonomy ≤ 3. Ask: "Approve this spec?" Only when the person explicitly approves, record `approvals.spec = { "by": "<name or role>", "at": "<ISO date>" }`. Never record an approval on someone's behalf. They can also approve from `npx buildline ui` or with `npx buildline approve <id> spec`.
46
- - Append a `history` entry and suggest the next command.
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 → status `review`, history entry, next `/buildline-review <id>`.
44
- - Any fail → status back to `build`. For each failure, add a new task to `feature.json` (`"title": "Fix A3: empty state copy"`, `"covers": ["A3"]`, `"passes": false`) and increment `metrics.reworks` in `feature.json`. Next: `/buildline-build <id>`.
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>`.