The vault 146 files

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))`);
});