buildline 0.5.1 → 0.7.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/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; }
@@ -138,6 +152,7 @@ dialog h2 { margin: 0 0 12px; font-size: 17px; }
138
152
  <button role="tab" aria-selected="false" data-view="context">Context</button>
139
153
  </nav>
140
154
  <span class="spacer"></span>
155
+ <button class="btn" id="soundBtn" aria-pressed="true"></button>
141
156
  <button class="btn" id="whoami" title="Name used for approvals"></button>
142
157
  <button class="btn primary" id="newBtn">+ New</button>
143
158
  </header>
@@ -217,14 +232,33 @@ function md(src) {
217
232
  function card(f) {
218
233
  const tasks = f.tasks || [], done = tasks.filter((t) => t.passes).length;
219
234
  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>
235
+ return `<button class="card${f.live?.working ? ' is-working' : ''}" data-id="${esc(f.id)}">
236
+ <div class="top"><span class="id">${esc(f.id)}</span><span class="chip type-${esc(f.type)}">${esc(f.type)}</span></div>
222
237
  <div class="title">${esc(f.title)}</div>
223
238
  ${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>` : ''}
239
+ ${liveLine(f)}
240
+ ${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
241
  ${ap.length ? `<div class="approvals">${ap.map((a) => `<span class="chip" title="by ${esc(f.approvals[a].by)}">✓ ${esc(a)}</span>`).join('')}</div>` : ''}
226
242
  </button>`;
227
243
  }
244
+ const stationModel = (station) => { const m = S.config.models || {}; const v = m[station] ?? m.default; return v && v !== 'inherit' ? v : ''; };
245
+ 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`);
246
+ /** What is happening on a feature right now: a blocker, a working session, or one waiting for a reply. */
247
+ function liveLine(f) {
248
+ if (f.blocked) return `<div class="live blocked"><i class="dot"></i><span class="wrap"><b>Blocked:</b> ${esc(f.blocked)}</span></div>`;
249
+ const ses = f.live?.sessions || [];
250
+ const on = ses.filter((x) => x.state === 'working');
251
+ if (f.live?.working) {
252
+ const s = on[0];
253
+ if (!s) return `<div class="live working"><i class="dot"></i><span><b>Working</b> · files changing</span></div>`;
254
+ const model = String(s.model || stationModel(f.status) || '').replace(/^claude-/, '');
255
+ const head = [`<b>Working${on.length > 1 ? ` ×${on.length}` : ''}</b>`, ago(s.age), model && esc(model)].filter(Boolean).join(' · ');
256
+ const doing = s.tool ? `${esc(s.tool)} <code>${esc(s.detail || '')}</code>` : 'thinking';
257
+ 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>`;
258
+ }
259
+ 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>`;
260
+ return '';
261
+ }
228
262
  function renderBoard() {
229
263
  if (!S.features.length) {
230
264
  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 +268,7 @@ function renderBoard() {
234
268
  return `<div class="board">${COLUMNS.map(([key, label, owner]) => {
235
269
  const items = S.features.filter((f) => f.status === key);
236
270
  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>` : '';
271
+ const modelTag = key !== 'done' && model && model !== 'inherit' ? ` · <span class="model" title="Model for this station (buildline models)">${esc(model)}</span>` : '';
238
272
  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
273
  }).join('')}</div>`;
240
274
  }
@@ -254,6 +288,10 @@ async function render() {
254
288
  $('#project').textContent = S.config.project;
255
289
  $('#autonomy').textContent = S.autonomyLabel || `L${S.config.autonomy}`;
256
290
  $('#whoami').textContent = me() ? `👤 ${me()}` : 'Set your name';
291
+ const on = soundOn();
292
+ $('#soundBtn').textContent = on ? '♪ Sound on' : '♪ Sound off';
293
+ $('#soundBtn').setAttribute('aria-pressed', on);
294
+ $('#soundBtn').title = on ? 'Chimes when a feature needs you or ships. Click to mute.' : 'Click to chime when a feature needs you or ships.';
257
295
  document.querySelectorAll('nav.tabs button').forEach((b) => b.setAttribute('aria-selected', b.dataset.view === view));
258
296
  if (!(view === 'context' && ctxEditing && $('#ctxEditor'))) $('#main').innerHTML = view === 'board' ? renderBoard() : await renderContext();
259
297
  if (openId) await renderDrawer();
@@ -310,6 +348,7 @@ $('#newDialog').addEventListener('close', async () => {
310
348
  });
311
349
  $('#newBtn').onclick = newFeature;
312
350
  $('#whoami').onclick = askName;
351
+ $('#soundBtn').onclick = () => { try { localStorage.setItem('buildline.sound', soundOn() ? 'off' : 'on'); } catch {} render(); if (soundOn()) chime(true); };
313
352
  $('#backdrop').onclick = closeDrawer;
314
353
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && openId && !editing) closeDrawer(); });
315
354
  document.addEventListener('click', (e) => {
@@ -319,15 +358,42 @@ document.addEventListener('click', (e) => {
319
358
  const x = e.target.closest('[data-ctx]'); if (x) { ctxPath = x.dataset.ctx; ctxEditing = false; render(); }
320
359
  });
321
360
 
361
+ // ---- sound: one chime when a feature needs a person or ships ----
362
+ let audio = null, needs = null, lastChime = 0;
363
+ const soundOn = () => { let v = null; try { v = localStorage.getItem('buildline.sound'); } catch {} return v ? v === 'on' : S?.config.sound !== false; };
364
+ const attention = (f) => (f.status === 'done' ? 'done' : f.blocked || f.live?.waiting ? 'waiting' : null);
365
+ // Browsers only allow sound after the page has been touched, so the audio starts on the first click or key.
366
+ const unlock = () => { audio ||= new (window.AudioContext || window.webkitAudioContext)(); audio.resume(); };
367
+ ['pointerdown', 'keydown'].forEach((e) => document.addEventListener(e, unlock, { once: true }));
368
+ function chime(force = false) {
369
+ if (!audio || (!force && Date.now() - lastChime < 3000)) return;
370
+ lastChime = Date.now();
371
+ const t = audio.currentTime, out = audio.createGain();
372
+ out.connect(audio.destination);
373
+ out.gain.setValueAtTime(0.0001, t);
374
+ out.gain.exponentialRampToValueAtTime(0.25, t + 0.01);
375
+ out.gain.exponentialRampToValueAtTime(0.0001, t + 1.2);
376
+ for (const [hz, level] of [[880, 1], [1320, 0.35], [1760, 0.15]]) {
377
+ const o = audio.createOscillator(), g = audio.createGain();
378
+ o.frequency.value = hz; g.gain.value = level;
379
+ o.connect(g).connect(out); o.start(t); o.stop(t + 1.25);
380
+ }
381
+ }
382
+ function listen() {
383
+ const now = new Map(S.features.map((f) => [f.id, attention(f)]));
384
+ if (needs && soundOn() && [...now].some(([id, a]) => a && needs.get(id) !== a)) chime();
385
+ needs = now;
386
+ }
387
+
322
388
  async function refresh() {
323
389
  try {
324
390
  const next = await api('/api/state');
325
391
  const changed = JSON.stringify(next) !== JSON.stringify(S);
326
- S = next; if (changed) await render();
392
+ S = next; listen(); if (changed) await render();
327
393
  } catch (e) { toast('Dashboard lost connection: is `buildline ui` still running?'); }
328
394
  }
329
395
  refresh();
330
- setInterval(() => { if (!document.hidden && !editing && !ctxEditing) refresh(); }, 3000);
396
+ setInterval(() => { if (!editing && !ctxEditing) refresh(); }, 2000);
331
397
  </script>
332
398
  </body>
333
399
  </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>`.