Demo / the vault
Tools/review-surface/server.mjs
#!/usr/bin/env node
// The review surface — ADR-29.
//
// One page. It renders the disposition queue Hobbs writes beside a brief or a
// weekly review, and turns each ask into three buttons. It binds to 127.0.0.1,
// has no auth because nothing leaves the machine, no dependencies, and no build
// step. (chiefofstaff.io/guides/the-review-surface)
//
// What it may do, and the boundary is the whole point:
// - append an answer to the .answers.jsonl beside the queue (always)
// - run the mechanical effects the queue file declares for that
// answer, against a fixed whitelist of verbs (yes / no)
// - drop an intention file in Efforts/inbox/ for Hobbs to execute
// with context (anything with Rowan's words)
// - make one git commit per click, prefixed `rowan:` (always)
//
// What it may never do: decide what an answer means. Every effect was written
// by Hobbs, into the queue file, with the vault in front of him. An effect this
// file does not recognise is refused and the refusal is reported — the answer
// is still recorded, because the answer is the thing that must not be lost.
import { createServer } from 'node:http';
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync, appendFileSync, readdirSync, existsSync } from 'node:fs';
import { join, dirname, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const VAULT = join(HERE, '..', '..');
const QUEUE_DIR = join(VAULT, 'Efforts', 'dispositions');
const INBOX = join(VAULT, 'Efforts', 'inbox');
const PORT = Number(process.env.PORT || 7317);
const HANDLE = /^hobbs-[a-z0-9]{3}$/;
// --- the vault, read directly. No second copy of anything. -----------------
const queueFiles = () =>
existsSync(QUEUE_DIR)
? readdirSync(QUEUE_DIR).filter((f) => f.endsWith('.json')).sort().reverse()
: [];
const answersPath = (file) => join(QUEUE_DIR, file.replace(/\.json$/, '.answers.jsonl'));
function readAnswers(file) {
const p = answersPath(file);
if (!existsSync(p)) return {};
const latest = {};
for (const line of readFileSync(p, 'utf8').split('\n')) {
if (!line.trim()) continue;
try { const a = JSON.parse(line); latest[a.ask] = a; } catch { /* a bad line is not a reason to lose the good ones */ }
}
return latest;
}
// An answer's line cannot carry the sha of the commit that creates it, so the
// sha is read back out of the log instead of being written twice.
function commitsByAsk() {
const map = {};
try {
const log = execFileSync('git', ['log', '--format=%h\t%s', '-n', '400'], { cwd: VAULT, encoding: 'utf8' });
for (const line of log.split('\n')) {
const [sha, subject = ''] = line.split('\t');
const m = subject.match(/^rowan: (\S+) — .*\(via review-surface, (\S+)\)$/);
if (m && !map[m[2] + '/' + m[1]]) map[m[2] + '/' + m[1]] = sha;
}
} catch { /* a repo that cannot be read is not a reason to hide the answers */ }
return map;
}
function state() {
const commits = commitsByAsk();
return queueFiles().map((file) => {
const q = JSON.parse(readFileSync(join(QUEUE_DIR, file), 'utf8'));
const answered = readAnswers(file);
return {
file,
date: q.date,
kind: q.kind,
source: q.source,
title: q.title,
asks: (q.asks || []).map((a) => ({
...a,
answered: answered[a.id]
? { ...answered[a.id], commit: commits[`${file}/${a.id}`] || null }
: null,
})),
};
});
}
// --- effects: the whitelist, and nothing outside it ------------------------
// Every task-graph write the surface makes is attributed to Rowan in beads'
// own audit trail, not to the git user, for the same reason the commits are
// prefixed `rowan:` — the decision is his (ADR-29 clause 5).
const bd = (...args) => execFileSync('bd', [...args, '--actor=Rowan'], { cwd: VAULT, encoding: 'utf8' });
function assertHandle(id) {
if (!HANDLE.test(String(id))) throw new Error(`not a task handle: ${id}`);
return id;
}
const VERBS = {
close(e, ctx) {
bd('close', assertHandle(e.id), `--reason=${e.reason || ctx.reason}`);
return `closed ${e.id}`;
},
priority(e) {
const to = Number(e.to);
if (!Number.isInteger(to) || to < 0 || to > 4) throw new Error(`priority out of range: ${e.to}`);
bd('update', assertHandle(e.id), `--priority=${to}`);
return `${e.id} → P${to}`;
},
label(e) {
const args = ['update', assertHandle(e.id)];
for (const l of e.add || []) args.push(`--add-label=${l}`);
for (const l of e.remove || []) args.push(`--remove-label=${l}`);
if (args.length === 2) throw new Error('label effect with nothing to add or remove');
bd(...args);
return `${e.id} labels`;
},
// Not a write to the graph: a declared hand-off. It forces the intention
// file, which is how an answer that needs judgment reaches Hobbs.
hobbs(e, ctx) {
ctx.forHobbs.push(e.what);
return `handed to Hobbs: ${e.what}`;
},
note(e, ctx) {
const id = assertHandle(e.id);
const existing = (JSON.parse(bd('show', id, '--json'))[0] || {}).notes || '';
const line = `${ctx.date}, Rowan via the review surface — ${ctx.reason}`;
bd('update', id, `--notes=${existing ? existing.trimEnd() + '\n\n' : ''}${line}`);
return `noted on ${e.id}`;
},
};
function runEffects(effects, ctx) {
const done = [], refused = [];
ctx.forHobbs = [];
for (const e of effects || []) {
const verb = VERBS[e.verb];
if (!verb) { refused.push(`unrecognised verb "${e.verb}" — refused, and left for Hobbs`); continue; }
try { done.push(verb(e, ctx)); }
catch (err) { refused.push(`${e.verb} ${e.id || ''}: ${String(err.message).split('\n')[0]}`); }
}
return { done, refused, forHobbs: ctx.forHobbs };
}
// --- writing back ----------------------------------------------------------
function intentionFile(q, ask, answer, record) {
const name = `${q.date}-disposition-${ask.id}.md`;
const body = `---
type: intention
source: ${q.source}
queue: Efforts/dispositions/${q.file}
ask: ${ask.id}
answer: ${answer}
by: Rowan
via: review-surface
date: ${q.date}
at_machine: ${record.at_machine}
tasks: [${(ask.tasks || []).join(', ')}]
---
<!-- src: Rowan, via the review surface, ${q.date} -->
**The ask.** ${ask.ask}
**Rowan's answer:** ${answer}${record.trigger ? `\n\n**Trigger, in his words:** "${record.trigger}"` : ''}${record.note ? `\n\n**Note, in his words:** "${record.note}"` : ''}
${record.effects.forHobbs.length ? `**What Hobbs is being asked to do:**\n${record.effects.forHobbs.map((w) => `- ${w}`).join('\n')}\n` : ''}
**Effects the surface applied to the graph:** ${record.effects.done.filter((d) => !d.startsWith('handed to Hobbs')).join('; ') || 'none'}
${record.effects.refused.length ? `**Refused and left for Hobbs:** ${record.effects.refused.join('; ')}\n` : ''}
Hobbs executes this with context on the next triage: the vault edit, the
curation-log line in \`Calendar/daily/${q.date}.md\`, and the line in the next
brief under Atlas changes (ADR-03 rails 3 and 4, ADR-29 clause 5). Then this
file goes.
`;
writeFileSync(join(INBOX, name), body);
return `Efforts/inbox/${name}`;
}
function commit(message) {
try {
execFileSync('git', ['add', 'Efforts/dispositions', 'Efforts/inbox', '.beads'], { cwd: VAULT });
execFileSync('git', ['commit', '-q', '-m', message], { cwd: VAULT });
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: VAULT, encoding: 'utf8' }).trim();
} catch (err) {
return `uncommitted: ${String(err.stderr || err.message).split('\n')[0]}`;
}
}
function answer({ file, ask: askId, answer: choice, trigger, note }) {
if (!queueFiles().includes(file)) throw new Error(`no such queue: ${file}`);
const q = JSON.parse(readFileSync(join(QUEUE_DIR, file), 'utf8'));
q.file = file;
const ask = (q.asks || []).find((a) => a.id === askId);
if (!ask) throw new Error(`no such ask: ${askId}`);
if (!['yes', 'no', 'defer'].includes(choice)) throw new Error(`not an answer: ${choice}`);
if (choice === 'defer' && !String(trigger || '').trim()) throw new Error('a defer needs a named trigger');
const at_machine = new Date().toISOString();
const words = [trigger, note].filter((s) => String(s || '').trim()).join(' — ');
const reason = `${choice}${words ? ` — ${words}` : ''} (${ask.id}, ${file})`;
// Mechanical effects run for yes and no only. A defer carries his words by
// definition, so it goes to Hobbs with the words attached (ADR-29 clause 4).
const effects = choice === 'defer'
? runEffects((ask.effects || {}).defer, { date: q.date, reason })
: runEffects((ask.effects || {})[choice], { date: q.date, reason });
const record = {
ask: ask.id, answer: choice,
trigger: String(trigger || '').trim() || null,
note: String(note || '').trim() || null,
by: 'Rowan', via: 'review-surface',
date: q.date, // date of record: the queue's, which Hobbs sourced
at_machine, // the system clock, which is wrong — hobbs-bkt
source: q.source,
effects,
};
// Words become an intention, never a conclusion. Written before the answer
// line so the line records where it went, and both land in the one commit.
record.intention = (choice === 'defer' || words || effects.forHobbs.length)
? intentionFile(q, ask, choice, record)
: null;
appendFileSync(answersPath(file), JSON.stringify(record) + '\n');
const sha = commit(`rowan: ${ask.id} — ${choice}${words ? ` (${words.slice(0, 60)})` : ''} — ${ask.short || ask.section} (via review-surface, ${file})`);
return { ...record, commit: sha };
}
// --- server ----------------------------------------------------------------
const send = (res, code, type, body) => { res.writeHead(code, { 'content-type': type }); res.end(body); };
createServer((req, res) => {
try {
if (req.method === 'GET' && (req.url === '/' || req.url.startsWith('/?'))) {
return send(res, 200, 'text/html; charset=utf-8', readFileSync(join(HERE, 'index.html')));
}
if (req.method === 'GET' && req.url === '/api/state') {
return send(res, 200, 'application/json', JSON.stringify(state()));
}
if (req.method === 'POST' && req.url === '/api/answer') {
let raw = '';
req.on('data', (c) => { raw += c; });
return req.on('end', () => {
try { send(res, 200, 'application/json', JSON.stringify(answer(JSON.parse(raw)))); }
catch (err) { send(res, 400, 'application/json', JSON.stringify({ error: String(err.message) })); }
});
}
send(res, 404, 'text/plain', 'no');
} catch (err) {
send(res, 500, 'application/json', JSON.stringify({ error: String(err.message) }));
}
}).listen(PORT, '127.0.0.1', () => {
console.log(`review surface — http://127.0.0.1:${PORT} (${queueFiles().length} queue file(s))`);
});