#!/usr/bin/env node /** * Move a grain-lab note between its three states, from the terminal. * * The lab's notes live in `.lab/state.json` and have two writers: the page, * and whoever is acting on them. This is the second writer's side of it — * marking a note `working` when it is picked up and `done` when the change * lands. The dev server watches the file and pushes every outside change to * the open page, so both show up live in the panel rather than the next time * something happens to reload. * * Marking `working` is not bookkeeping: it is the only signal the person who * wrote the note gets that anything is happening at all. * * node scripts/lab.mjs list * node scripts/lab.mjs working "redesign this button" # id or text match * node scripts/lab.mjs done mu7k6bf1-tu5e * node scripts/lab.mjs open mu7k6bf1-tu5e # put it back * * Writes are atomic (temp file then rename), matching the dev server — the * page saves on every slider drag, and a half-written file read back at the * next boot would silently empty the session. * * They are also SERIALISED, by a lockfile taken for the life of the process. * Atomic only means no half-written file; it says nothing about two agents * reading the same state and both writing it back. See the lock below. That * is what makes several agents on several threads safe rather than merely * likely to work. */ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' const FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '.lab', 'state.json') const [cmd = 'list', ...rest] = process.argv.slice(2) const query = rest.join(' ').trim().toLowerCase() const read = () => { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')) } catch { return { entries: [], notes: [] } } } /* ── one writer at a time ────────────────────────────────────────────────── `state` is read once, mutated, and written back WHOLE. That is a read-modify-write, and two agents running this at the same moment both read, both write, and the first one's change is gone. Nothing errors and nothing logs: a claim or a step simply vanishes. On a tool whose entire job is to say what is happening, that is the worst bug available — and it is the one thing standing between this and several agents working different threads at once, which the document already allows (one `agent` per thread). `mkdir` is the lock: atomic on every filesystem this runs on, since it either creates the directory or fails because someone else holds it. Held for the life of the process, because every command here is read, change, write, exit. STALE LOCKS are broken after two seconds. A process killed mid-command would otherwise wedge every agent on the machine, and nothing here takes anywhere near that long. If the lock cannot be taken it says so and proceeds anyway. Refusing would be safer for the file and worse for the person: an agent that cannot claim a thread goes quiet, and quiet is exactly what this tool exists to prevent. A warning on stderr is something a human or an agent can act on. */ const LOCK = `${FILE}.lock` const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) const held = (() => { const until = Date.now() + 4000 for (;;) { try { fs.mkdirSync(path.dirname(LOCK), { recursive: true }) fs.mkdirSync(LOCK) return true } catch { try { if (Date.now() - fs.statSync(LOCK).mtimeMs > 2000) { fs.rmSync(LOCK, { recursive: true, force: true }) continue } } catch { /* it was released under us — go round and take it */ } if (Date.now() > until) return false sleep(12) } } })() if (!held) { console.error( 'lab: could not take .lab/state.json.lock after 4s — writing anyway.\n' + ' another agent may be mid-command; if a claim or a step goes missing, this is why.', ) } process.on('exit', () => { if (!held) return try { fs.rmSync(LOCK, { recursive: true, force: true }) } catch { /* already gone: a stale-lock sweep beat us to it, which is fine */ } }) const state = read() /* Atomic, the same way the dev server writes: the browser saves on every slider drag, and a half-written file read back at the next boot would silently empty the session. Declared here because `const` is not hoisted and the commands below are all writers. */ const save = () => { fs.mkdirSync(path.dirname(FILE), { recursive: true }) const tmp = `${FILE}.tmp` fs.writeFileSync(tmp, JSON.stringify(state, null, 2)) fs.renameSync(tmp, FILE) } /* `chosen` is a LIST now — one option is rarely the whole answer — but sets written before that are a bare string, so both shapes are read. */ const keys = (v) => v.chosen === null ? null : Array.isArray(v.chosen) ? v.chosen : v.chosen ? [v.chosen] : [] const vlabel = (v) => { const k = keys(v) const said = (v.messages ?? []).length return ( `${k === null ? '?' : '='} ${v.id} ${v.selector}` + `${v.label ? ` "${v.label}"` : ''} (${v.route}): ${v.ask}` + ` [${v.options.map((o) => o.key).join('/')}]` + `${said ? ` ${said} said` : ''}` + `${k === null ? '' : ` → ${k.length ? k.join(' + ') : 'original'}`}` ) } const label = (n) => `${n.done ? 'x' : n.working ? '~' : ' '} ${n.id} ${n.selector}` + `${n.label ? ` "${n.label}"` : ''} (${n.route}): ${n.text}` if (cmd === 'list') { console.log(state.notes.map(label).join('\n') || 'no notes') const ts = state.threads ?? [] if (ts.length) console.log('\n' + ts.map(tlabel).join('\n')) const vs = state.variants ?? [] if (vs.length) console.log('\n' + vs.map(vlabel).join('\n')) process.exit(0) } /* Offer a set of alternatives for one element. node scripts/lab.mjs variants The file is one `VariantSet` without `id`/`at`/`chosen` — those are filled in here, because a set the agent could name is a set the agent could collide with. `css` on each option is an ordinary ruleset; it is injected unlayered at runtime, so it beats anything the app declares in `@layer`. */ if (cmd === 'variants') { if (!query) { console.error('variants needs a path to a json file') process.exit(1) } let spec try { spec = JSON.parse(fs.readFileSync(rest.join(' ').trim(), 'utf8')) } catch (e) { console.error(`could not read ${rest.join(' ')}: ${e.message}`) process.exit(1) } /* `"thread": ""` in the spec puts the chips in that thread's bubble instead of the panel's variants tab. The thread already knows the selector, the label and the route — which element it is on is the whole reason it exists — so those are inherited rather than retyped, and a spec that names a thread needs nothing but `ask` and `options`. */ const on = spec.thread ? tfind(spec.thread) : null if (spec.thread && !on) { console.error(`no thread ${spec.thread}`) process.exit(1) } const selector = spec.selector ?? on?.selector if (!selector || !Array.isArray(spec.options) || !spec.options.length) { console.error('needs at least { selector, ask, options: [{key,label,css}] }') process.exit(1) } const set = { id: `v${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, selector, label: spec.label ?? on?.label ?? '', route: spec.route ?? on?.route ?? '/', ask: spec.ask ?? 'variants', ...(on ? { thread: on.id } : {}), options: spec.options, chosen: null, at: new Date().toISOString(), } state.variants = [...(state.variants ?? []), set] save() console.log(vlabel(set)) process.exit(0) } /* Reply into a set's thread. node scripts/lab.mjs say "keeping the banner, losing the ember" The panel shows it under the chips, which is the point: the answer to "can I have the second one's layout with the first one's colour" belongs next to the things being talked about, not in a terminal the person asking cannot see. */ if (cmd === 'say') { const [id, ...words] = rest const text = words.join(' ').trim() if (!id || !text) { console.error('say needs a variant set id and something to say') process.exit(1) } if (text.length > SAY_MAX) { console.error( `too long: ${text.length} chars, max ${SAY_MAX}. The bubble sits ON the ` + `component — say what changed and the one thing they cannot see for ` + `themselves, and put the reasoning in a code comment.`, ) process.exit(1) } const v = (state.variants ?? []).find((x) => x.id === id) if (!v) { console.error(`no variant set ${id}`) process.exit(1) } v.messages = [ ...(v.messages ?? []), { id: `m${Date.now().toString(36)}`, from: 'claude', text, at: new Date().toISOString(), }, ] save() console.log(`said on ${id}: ${text}`) process.exit(0) } /* ── threads ────────────────────────────────────────────────────────────── A thread is one unit of work, taken by one agent. These four commands are that agent's whole side of it. node scripts/lab.mjs threads # what is open, and who has what node scripts/lab.mjs take # claim it — refuses if taken node scripts/lab.mjs reply "..." # answer, on the page node scripts/lab.mjs finish # mark it done `take` refusing a claimed thread is the whole point: several agents may be watching the same file, and the file is the only thing they share. */ /* Function DECLARATIONS, not consts: `list` above prints threads too, and a `const` arrow declared down here is in its temporal dead zone by then — the same trap the note on `save` describes. */ function tfind(id) { return (state.threads ?? []).find((t) => t.id === id) } function tlabel(t) { const mark = t.status === 'done' ? 'x' : t.status === 'working' ? '~' : ' ' const last = t.messages[t.messages.length - 1]?.text.slice(0, 90) ?? '' return ( `${mark} ${t.id} ${t.selector}` + `${t.label ? ` "${t.label.slice(0, 40)}"` : ''} (${t.route})` + `${t.agent ? ` → ${t.agent}` : ''} ${t.messages.length} msg` + `\n ${last}` ) } if (cmd === 'threads') { const ts = state.threads ?? [] console.log(ts.length ? ts.map(tlabel).join('\n') : 'no threads') process.exit(0) } if (cmd === 'take') { const [id, ...who] = rest const agent = who.join(' ').trim() const t = tfind(id) if (!t) { console.error(`no thread ${id}`) process.exit(1) } if (t.status !== 'open') { /* Not an error you should paper over: it means another agent is already on this component, and two agents editing one component is the failure this whole claim exists to prevent. */ console.error(`${id} is already ${t.status}${t.agent ? ` with ${t.agent}` : ''}`) process.exit(1) } t.status = 'working' if (agent) t.agent = agent save() console.log(tlabel(t)) process.exit(0) } /* What the agent is doing, as it does it — the thread's process viewer. node scripts/lab.mjs step "reading Hero.tsx" A claim says a thread is taken. It does not say anything is HAPPENING, and on work that takes minutes those are different questions. This is the second one, answered by the only party that knows. */ /* Move a set that already exists onto a thread, so its chips move from the panel into that thread's bubble. `variants` takes `"thread"` in the spec, which covers a set offered on a thread from the start. This covers the other case and it is the common one while threads are new: a set was offered before the thread existed, or before sets could belong to one, and the person is looking at the bubble wondering where the answer went. */ /** * How long a message on a thread may be. * * The bubble is 19rem wide and it sits ON the component it is about. A reply * that runs past this covers the thing the person is trying to look at, which * is the one thing this tool exists to keep visible — reported, with a * screenshot of a bubble filled top to bottom by a single reply, as "don't * write long text like this, only short sentences". * * It refuses rather than truncates: a cut-off sentence is worse than being * made to write a shorter one, and the detail almost always belongs in a code * comment next to the change instead. */ const SAY_MAX = 400 /** * What is still on the page, and what it is waiting for. * * Threads and sets do not expire — deleting somebody's note because it got * old is the one thing this tool must never do. The cost of that is they * accumulate: an answered thread nobody closed, a set of chips nobody picked * from, and the page keeps drawing an outline for each. Reported as "there * are stale pickers", looking at a card marked for work finished an hour * earlier. * * So: no sweeper, a REPORT. It prints what is left and the exact command to * clear each one, and the decision stays with whoever is looking at the page. */ if (cmd === 'stale') { const open = (state.threads ?? []).filter((t) => t.status !== 'done') const sets = state.variants ?? [] let n = 0 for (const t of open) { const mine = sets.filter((v) => v.thread === t.id) const waiting = mine.filter((v) => v.chosen === null) const last = t.messages[t.messages.length - 1] const why = waiting.length ? `waiting on a pick from ${waiting.length} set(s)` : last && last.from !== 'you' ? 'answered, never closed' : t.status === 'working' ? `${t.agent ?? 'an agent'} is on it` : 'nobody has claimed it' console.log(`${tlabel(t)}\n ${why}`) console.log(` close: node scripts/lab.mjs finish ${t.id}`) for (const v of waiting) console.log(` drop: node scripts/lab.mjs drop ${v.id}`) n++ } const loose = sets.filter((v) => !v.thread && v.chosen === null) for (const v of loose) { console.log(`? ${v.id} ${v.selector} (in the panel, undecided)`) console.log(` drop: node scripts/lab.mjs drop ${v.id}`) n++ } if (!n) console.log('nothing left open') process.exit(0) } if (cmd === 'attach') { const [sid, tid] = rest if (!sid || !tid) { console.error('attach needs a set id and a thread id') process.exit(1) } const v = (state.variants ?? []).find((x) => x.id === sid || x.id.startsWith(sid)) const t = tfind(tid) if (!v) { console.error(`no variant set ${sid}`) process.exit(1) } if (!t) { console.error(`no thread ${tid}`) process.exit(1) } v.thread = t.id save() console.log(`${v.id} → ${t.id} ${t.selector}`) process.exit(0) } if (cmd === 'step') { const [id, ...words] = rest const t = tfind(id) if (!t) { console.error(`no thread ${id}`) process.exit(1) } const text = words.join(' ').trim() if (!text) { console.error('step needs something to report') process.exit(1) } t.steps = [ ...(t.steps ?? []), { id: `s${Date.now().toString(36)}`, at: new Date().toISOString(), text }, ] save() console.log(`${id} · ${text}`) process.exit(0) } if (cmd === 'reply' || cmd === 'finish') { const [id, ...words] = rest const t = tfind(id) if (!t) { console.error(`no thread ${id}`) process.exit(1) } const text = words.join(' ').trim() if (cmd === 'reply' && !text) { console.error('reply needs something to say') process.exit(1) } if (text.length > SAY_MAX) { console.error( `too long: ${text.length} chars, max ${SAY_MAX}. The bubble sits ON the ` + `component — say what changed and the one thing they cannot see for ` + `themselves, and put the reasoning in a code comment.`, ) process.exit(1) } if (text) { t.messages = [ ...t.messages, { id: `m${Date.now().toString(36)}`, from: 'claude', text, at: new Date().toISOString(), ...(t.agent ? { agent: t.agent } : {}), }, ] } if (cmd === 'finish') t.status = 'done' save() console.log(tlabel(t)) process.exit(0) } if (cmd === 'drop') { const before = (state.variants ?? []).length state.variants = (state.variants ?? []).filter((v) => v.id !== query) if (state.variants.length === before) { console.error(`no variant set ${query}`) process.exit(1) } save() console.log(`dropped ${query}`) process.exit(0) } const flags = { working: { working: true, done: false }, done: { working: false, done: true }, open: { working: false, done: false } } if (!flags[cmd]) { console.error( `unknown command "${cmd}" — try list, working, done, open, variants, say, drop, ` + `threads, stale, take, step, attach, reply, finish`, ) process.exit(1) } if (!query) { console.error(`${cmd} needs a note id or a piece of its text`) process.exit(1) } /* Id, then the note's exact text, then a substring — narrowest match that hits anything wins, and an ambiguous substring refuses rather than moving the wrong note. "redesign this button" matching both "redesign this button" and "redesign this button completely" is the whole reason for the ladder. */ const byId = state.notes.filter((n) => n.id === query) const exact = state.notes.filter((n) => n.text.toLowerCase().trim() === query) const loose = state.notes.filter((n) => n.text.toLowerCase().includes(query)) const hits = byId.length ? byId : exact.length ? exact : loose if (!hits.length) { console.error(`no note matching "${query}"`) process.exit(1) } if (hits.length > 1) { console.error(`"${query}" matches ${hits.length} notes — use an id:`) console.error(hits.map(label).join('\n')) process.exit(1) } for (const n of hits) Object.assign(n, flags[cmd]) save() console.log(hits.map(label).join('\n'))