sign-off-axi 0.4.7 → 0.4.8

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
@@ -172,6 +172,8 @@ the new page on a phone." \
172
172
 
173
173
  *Proof of a fix, with two captures. Pass, Fail, or Needs work.*
174
174
 
175
+ Evidence lists passed proof only: an update whose linked run passed. A plain update like this links no run, so it waits in **Waiting on you** instead, as does a failed or unchecked run, each with its honest result and its verdicts and Dismiss, so the agent still gets an answer. Passed updates are grouped by the project named in the run's proof line, `verify-<project>: ...` (trimmed, compared without case); a proof line with no such prefix goes under **Other**.
176
+
175
177
  ### Explainer video, with chapters
176
178
 
177
179
  ```sh
@@ -354,7 +356,8 @@ cannot block can be woken instead: an `on_answer:` line in the
354
356
  `config.yaml` beside the board names a command the board runs for every
355
357
  answer. A `notify:` block in that config can send an HTTP POST when a card
356
358
  enters Waiting on you, with its bearer token read from a named environment
357
- variable. The full reference, with the worked example, is in
359
+ variable, or, with `receiver: muxr`, from the muxr host's `notify-token`
360
+ file; `sign-off-axi notify test` sends a test notice. The full reference, with the worked example, is in
358
361
  [docs/commands.md](docs/commands.md).
359
362
 
360
363
  Every command takes `--help` and prints compact output with next-step hints.
package/dist/cli.js CHANGED
@@ -28,8 +28,8 @@ const AGENTS = {
28
28
  'gemini-cli': { bin: 'gemini', dir: '.agents/skills', home: join(homedir(), '.gemini', 'skills') },
29
29
  };
30
30
  const TOP_HELP = `usage: sign-off-axi [command] [board.json] [flags]
31
- commands[17]:
32
- (none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, report, signin, doctor, validate, help
31
+ commands[18]:
32
+ (none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, report, signin, doctor, notify, validate, help
33
33
  board: the nearest .sign-off/ board (the walk's bounds and SIGN_OFF_HOME: README, "Test runs never touch your board"), else ~/.sign-off/decisions.json; a board file given always wins
34
34
  flags: --full and --json on list, answers and wait, --events on wait, --json on status, --filer on post, raise and wait, --help on every command, -v/--version
35
35
  examples:
@@ -62,7 +62,7 @@ examples:
62
62
  sign-off-axi
63
63
  sign-off-axi list ./decisions.json --full
64
64
  `,
65
- raise: `usage: sign-off-axi raise [board.json] --title "<question>" [--key <key>] [--about <text>] [--detail <text>] --why <text> [--option <value>=<label>]... --outcome <value>=<text>... --recommend <value> --recommend-reason <text> --default <value> [--silent-by <ISO time>] [--merge <key>]... [--close done|release] [--filer <id>]
65
+ raise: `usage: sign-off-axi raise [board.json] --title "<question>" [--key <key>] [--about <text>] [--detail <text>] --why <text> [--option <value>=<label>]... --outcome <value>=<text>... --recommend <value> --recommend-reason <text> --default <value> [--silent-by <ISO time>] [--merge <key>]... [--proof <key>] [--close done|release] [--filer <id>]
66
66
  sign-off-axi raise [board.json] --from <decisions.json> [--filer <id>]
67
67
  Adds questions to the board: yes/no, or 2 to 6 choices with one --option each. --key defaults to a slug of the title.
68
68
  raise is the alias for post --type decision: same flags, same output.
@@ -76,6 +76,7 @@ Every question meets the question standard (docs/contract.md, "Asking a good que
76
76
  yes and no are labelled Yes and No; a question never takes Done, OK or Not now buttons.
77
77
  One decision is one card: look at the open cards first. --merge <key> (up to 10) names other cards on the board that ask the same thing and offer every option of this one:
78
78
  they are hidden while this one waits, and its answer closes them too, with the same value and a note naming this key. A merged card merges nothing itself, a card is named by one ask until that ask is answered, and an answered or dismissed card cannot be merged.
79
+ --proof <key> names an evidence update already on the board that this ask rests on: Decide shows its capture and result at the top.
79
80
  The user answers with one tap and can undo it for a few seconds.
80
81
  --from adds every decision in a decisions file: ${FORMAT}
81
82
  Raising the same question again is a no-op that reports its status; a different question under a used key is refused.
@@ -286,6 +287,19 @@ It opens from disk with no server and makes no network request. --out defaults t
286
287
  examples:
287
288
  sign-off-axi export
288
289
  sign-off-axi export ./decisions.json --out ~/board-12-oct.html
290
+ `,
291
+ notify: `usage: sign-off-axi notify [check|test] [dir]
292
+ Device notifications are off until the config.yaml beside the board has a notify: block. For the muxr receiver it is:
293
+ notify:
294
+ receiver: muxr
295
+ link_base: https://my-machine.my-tailnet.ts.net:4410/ (https or a tailnet address, so the link opens on the iPad)
296
+ url: http://127.0.0.1:8793/v1/notify (optional: this is the default)
297
+ The token is read at send time from the file <MUXR_HOME>/notify-token (default ~/.muxr/notify-token), never kept in the config.
298
+ check (the default): says whether notifications are off, misconfigured, waiting on the token file, or ready. It sends nothing.
299
+ test: sends one real "sign-off test" notice now and says plainly what happened (sent, no token file, token refused, receiver not running).
300
+ examples:
301
+ sign-off-axi notify
302
+ sign-off-axi notify test
289
303
  `,
290
304
  doctor: `usage: sign-off-axi doctor [dir]
291
305
  Checks the repo setup init wrote: node version, config, board file, answer files, a lock a crashed command left behind, gitignore lines, folders, whether the dashboard port is free,
@@ -323,7 +337,7 @@ function fits(title, key) {
323
337
  throw fail(`${key ? `key ${key}: ` : ''}title is ${n} characters, over ${TITLE_MAX}`, 'VALIDATION_ERROR', `Shorten the title to ${TITLE_MAX} characters and put the rest in about`);
324
338
  }
325
339
  /** The question standard's flags: a decision's why, each option's outcome, the reason for the recommendation, when silence decides, the cards it merges. */
326
- const ASK_FLAGS = ['why', 'outcome', 'recommend-reason', 'silent-by', 'merge'];
340
+ const ASK_FLAGS = ['why', 'outcome', 'recommend-reason', 'silent-by', 'merge', 'proof'];
327
341
  const MANY = ['option', 'outcome', 'merge'];
328
342
  /** Board types that are contract kinds: a decision (the default type) is an ask. */
329
343
  const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report', 'plan', 'design'];
@@ -1304,7 +1318,7 @@ function buildIncoming(values, extra, filer, file) {
1304
1318
  try {
1305
1319
  const { title, key = slug(title), about, detail, why, recommend: recommend_value, default: default_if_silent, close } = values;
1306
1320
  const ask = !Object.hasOwn(extra, 'type'), options = ask ? outcomesOf(values) : optionsOf(values);
1307
- const told = ask && { why, recommend_reason: values['recommend-reason'], silent_by: values['silent-by'], merges: values.merge };
1321
+ const told = ask && { why, recommend_reason: values['recommend-reason'], silent_by: values['silent-by'], merges: values.merge, proof: values.proof };
1308
1322
  writeFileSync(source, JSON.stringify({ schema: 2, items: [{ key, title, about, detail, ...told, options, recommend_value, default_if_silent, close, ...extra, filer }] }));
1309
1323
  const incoming = loadDecisions(source, board), raw = readItems(source).items;
1310
1324
  if (!ask)
@@ -1330,6 +1344,9 @@ const canon = (v) => JSON.stringify(v, (_, x) => (x && typeof x === 'object' &&
1330
1344
  /** A merge names cards on the board that take this card's answer: each is on the board, offers every option of this card, and merges nothing itself.
1331
1345
  * A card is named by one ask until that ask is answered, dismissed or not. A merge new to the board also names a card still open. */
1332
1346
  function checkMerges(incoming, resulting, current) {
1347
+ for (const d of incoming)
1348
+ if (d.proof && resulting.find((x) => x.key === d.proof)?.type !== 'evidence')
1349
+ throw fail(`key ${d.key} rests on ${d.proof}, which is not an evidence update on the board`, 'VALIDATION_ERROR', `Post the evidence update first, or leave out --proof`);
1333
1350
  const byKey = new Map(resulting.map((d) => [d.key, d])), before = new Map(current.map((c) => [c.key, c]));
1334
1351
  const closed = (key) => !!(before.get(key)?.answer || before.get(key)?.dismissed);
1335
1352
  const holder = (key, self) => resulting.find((x) => x.key !== self && !before.get(x.key)?.answer && x.merges?.includes(key));
@@ -1617,11 +1634,11 @@ function readSetup(so) {
1617
1634
  readFileSync(join(so, 'config.yaml'), 'utf8').split(/\r?\n/).forEach((l, n) => {
1618
1635
  // The notify block's indented lines keep their # , and } characters: only a # after a space starts a comment.
1619
1636
  if (inNotify && (!l.trim() || /^\s/.test(l))) {
1620
- const text = l.replace(/(^|\s+)#.*$/, '').trim(), field = text.match(/^(url|token_env|link_base)\s*:\s*(.+)$/);
1637
+ const text = l.replace(/(^|\s+)#.*$/, '').trim(), field = text.match(/^(url|token_env|link_base|receiver)\s*:\s*(.+)$/);
1621
1638
  if (field)
1622
1639
  notify[field[1]] = field[2].replace(/^(['"])(.*)\1$/, '$2');
1623
1640
  else if (text)
1624
- problems.push(`line ${n + 1} is not one "url", "token_env" or "link_base" line under notify`);
1641
+ problems.push(`line ${n + 1} is not one "receiver", "url", "token_env" or "link_base" line under notify`);
1625
1642
  return;
1626
1643
  }
1627
1644
  inNotify = false;
@@ -1661,15 +1678,36 @@ function readSetup(so) {
1661
1678
  catch {
1662
1679
  return undefined;
1663
1680
  } };
1681
+ const muxr = notify.receiver?.toLowerCase() === 'muxr';
1682
+ if (notifyBlock && notify.receiver && !muxr)
1683
+ problems.push('notify receiver must be muxr, or leave the receiver line out');
1684
+ if (muxr)
1685
+ notify.url ??= MUXR_URL;
1664
1686
  const sendTo = parsed(notify.url), base = parsed(notify.link_base);
1665
1687
  const usable = !!sendTo && !!base && (sendTo.protocol === 'https:' || (sendTo.protocol === 'http:' && (loopback(sendTo.hostname.replace(/^\[|\]$/g, '')) || sendTo.hostname === 'localhost')))
1666
1688
  && ['http:', 'https:'].includes(base.protocol) && (notify.token_env === undefined || /^[A-Za-z_]\w*$/.test(notify.token_env));
1667
1689
  if (notifyBlock && !usable)
1668
1690
  problems.push('notify needs url (https://, or http:// on this machine) and link_base (http:// or https://) lines, and token_env must name an environment variable');
1691
+ // A phone or iPad opens the link, so a muxr notice's link is https or a tailnet address: 127.0.0.1 or a LAN name would be dead there.
1692
+ const reachable = !!base && (base.protocol === 'https:' || /\.ts\.net$/i.test(base.hostname) || (isIP(base.hostname) === 4 && /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(base.hostname)));
1693
+ if (notifyBlock && usable && muxr && !reachable)
1694
+ problems.push('notify link_base must be https:// or a tailnet address (a .ts.net name or 100.x address) so the link opens on the iPad');
1669
1695
  return { board: join(so, goodBoard ? config.board : 'decisions.json'), port: goodPort ? Number(port) : 4410, bind, hosts, onAnswer, goodBoard, problems,
1670
- notify: notifyBlock && usable ? { url: notify.url, token_env: notify.token_env, link_base: notify.link_base } : undefined };
1696
+ notify: notifyBlock && usable && (!muxr || reachable) ? { url: notify.url, token_env: notify.token_env, link_base: notify.link_base, muxr } : undefined };
1671
1697
  }
1672
- const NOTIFY_SENDER = `void (async()=>{const delays=[2000,10000,60000];for(let i=0;i<4;i++){try{if((await fetch(process.env.SIGN_OFF_NOTIFY_URL,{method:'POST',headers:{'content-type':'application/json',...(process.env.SIGN_OFF_NOTIFY_TOKEN&&{authorization:'Bearer '+process.env.SIGN_OFF_NOTIFY_TOKEN})},body:process.env.SIGN_OFF_NOTIFY_BODY,signal:AbortSignal.timeout(10000)})).ok)return}catch{}if(i<3)await new Promise(r=>setTimeout(r,delays[i]))}require('node:fs').appendFileSync(process.env.SIGN_OFF_NOTIFY_LOG,new Date().toISOString()+' notification send failed for card '+process.env.SIGN_OFF_NOTIFY_ID+'\\n')})();`;
1698
+ const MUXR_URL = 'http://127.0.0.1:8793/v1/notify';
1699
+ /** The muxr receiver's token file, read at send time and never stored: <MUXR_HOME>/notify-token, MUXR_HOME defaulting to ~/.muxr. */
1700
+ const muxrTokenFile = () => join(process.env.MUXR_HOME || join(homedir(), '.muxr'), 'notify-token');
1701
+ /** Why a notice cannot be sent yet, in plain words; undefined when its token is there. */
1702
+ const tokenMissing = (n) => n.muxr
1703
+ ? (() => { try {
1704
+ return readFileSync(muxrTokenFile(), 'utf8').trim() ? undefined : `${tilde(muxrTokenFile())} is empty`;
1705
+ }
1706
+ catch {
1707
+ return `${tilde(muxrTokenFile())} is not there yet (the muxr host writes it when it updates)`;
1708
+ } })()
1709
+ : n.token_env && !process.env[n.token_env] ? `${n.token_env} is not set` : undefined;
1710
+ const NOTIFY_SENDER = `void (async()=>{const delays=[2000,10000,60000],f=process.env.SIGN_OFF_NOTIFY_TOKEN_FILE;for(let i=0;i<4;i++){try{const t=f?require('node:fs').readFileSync(f,'utf8').trim():process.env.SIGN_OFF_NOTIFY_TOKEN;if((await fetch(process.env.SIGN_OFF_NOTIFY_URL,{method:'POST',headers:{'content-type':'application/json',...(t&&{authorization:'Bearer '+t})},body:process.env.SIGN_OFF_NOTIFY_BODY,signal:AbortSignal.timeout(10000)})).ok)return}catch{}if(i<3)await new Promise(r=>setTimeout(r,delays[i]))}require('node:fs').appendFileSync(process.env.SIGN_OFF_NOTIFY_LOG,new Date().toISOString()+' notification send failed for card '+process.env.SIGN_OFF_NOTIFY_ID+'\\n')})();`;
1673
1711
  /** The cards waiting on the user before a write: a card waiting after the write but not before it has entered Waiting on you. */
1674
1712
  function waitingKeys(file) {
1675
1713
  try {
@@ -1685,7 +1723,7 @@ function waitingKeys(file) {
1685
1723
  function notifyEntered(file, named, before) {
1686
1724
  try {
1687
1725
  const so = dirname(named), notify = before ? readSetup(so).notify : undefined;
1688
- if (!before || !notify || (notify.token_env && !process.env[notify.token_env]))
1726
+ if (!before || !notify || tokenMissing(notify))
1689
1727
  return;
1690
1728
  const { items } = readItems(file), fresh = board(file, () => { }).filter((d) => waits(d) && !before.has(d.key) && !items.some((i) => i?.key === d.key && i.announced));
1691
1729
  if (!fresh.length)
@@ -1700,13 +1738,18 @@ function notifyEntered(file, named, before) {
1700
1738
  }
1701
1739
  catch { }
1702
1740
  }
1741
+ /** The JSON one card sends: sign-off's own shape, or muxr's {title (1-80 chars), body, url, id}. */
1742
+ function noticeBody(notify, d) {
1743
+ const kind = d.kind ?? { decision: 'ask', explainer: d.options.length ? 'ask' : 'report' }[d.type] ?? d.type;
1744
+ const text = [...(d.about ?? d.detail ?? d.why ?? d.title).replace(/[\r\n]+/g, ' ')].slice(0, 200).join(''), url = `${notify.link_base.replace(/\/$/, '')}#waiting/${encodeURIComponent(d.key)}`;
1745
+ if (notify.muxr)
1746
+ return { title: [...d.title].slice(0, 80).join('') || d.key, body: text, url, id: d.key };
1747
+ return { v: 1, source: 'sign-off', id: d.key, kind: ['ask', 'plan', 'design', 'evidence', 'report'].includes(kind) ? kind : 'ask',
1748
+ title: [...d.title].slice(0, 120).join(''), text, url, at: d.filed ?? new Date().toISOString() };
1749
+ }
1703
1750
  function sendNotice(so, notify, d) {
1704
1751
  const token = notify.token_env ? process.env[notify.token_env] : undefined;
1705
- const kind = d.kind ?? { decision: 'ask', explainer: d.options.length ? 'ask' : 'report' }[d.type] ?? d.type;
1706
- const body = JSON.stringify({ v: 1, source: 'sign-off', id: d.key, kind: ['ask', 'plan', 'design', 'evidence', 'report'].includes(kind) ? kind : 'ask',
1707
- title: [...d.title].slice(0, 120).join(''), text: [...(d.about ?? d.detail ?? d.why ?? d.title).replace(/[\r\n]+/g, ' ')].slice(0, 200).join(''),
1708
- url: `${notify.link_base.replace(/\/$/, '')}#waiting/${encodeURIComponent(d.key)}`, at: d.filed ?? new Date().toISOString() });
1709
- const child = spawn(process.execPath, ['-e', NOTIFY_SENDER], { detached: true, stdio: 'ignore', env: { ...process.env, SIGN_OFF_NOTIFY_URL: notify.url, SIGN_OFF_NOTIFY_BODY: body, SIGN_OFF_NOTIFY_TOKEN: token ?? '', SIGN_OFF_NOTIFY_ID: d.key, SIGN_OFF_NOTIFY_LOG: join(so, 'notify.log') } });
1752
+ const child = spawn(process.execPath, ['-e', NOTIFY_SENDER], { detached: true, stdio: 'ignore', env: { ...process.env, SIGN_OFF_NOTIFY_URL: notify.url, SIGN_OFF_NOTIFY_BODY: JSON.stringify(noticeBody(notify, d)), SIGN_OFF_NOTIFY_TOKEN: notify.muxr ? '' : token ?? '', SIGN_OFF_NOTIFY_TOKEN_FILE: notify.muxr ? muxrTokenFile() : '', SIGN_OFF_NOTIFY_ID: d.key, SIGN_OFF_NOTIFY_LOG: join(so, 'notify.log') } });
1710
1753
  child.on('error', () => warn(`notification sender could not start for card ${d.key}`));
1711
1754
  child.unref();
1712
1755
  }
@@ -1722,7 +1765,7 @@ function init(args) {
1722
1765
  }
1723
1766
  catch { }
1724
1767
  const files = [
1725
- [join(so, 'config.yaml'), `# sign-off repo setup. board: path under .sign-off/, port: dashboard port, agent: auto or the coding agents to install the skill for.\n# bind: 127.0.0.1 keeps the dashboard on this machine; a private address such as its VPN address lets a phone open it.\n# hosts: (optional) private DNS names that lead to that address, such as its mesh VPN name, commas between them.\n# on_answer: (optional) a command run for each answer, with the answer as one JSON line on its stdin; no shell, so | > && do nothing: on_answer: node hook.mjs\nboard: decisions.json\nport: 4410\nbind: 127.0.0.1\nagent: ${agent}\n`],
1768
+ [join(so, 'config.yaml'), `# sign-off repo setup. board: path under .sign-off/, port: dashboard port, agent: auto or the coding agents to install the skill for.\n# bind: 127.0.0.1 keeps the dashboard on this machine; a private address such as its VPN address lets a phone open it.\n# hosts: (optional) private DNS names that lead to that address, such as its mesh VPN name, commas between them.\n# on_answer: (optional) a command run for each answer, with the answer as one JSON line on its stdin; no shell, so | > && do nothing: on_answer: node hook.mjs\n# name: (optional) your name, shown on the board. leash: how much agents may do without asking: ask, matters (the default) or act; the board's Leash control sets it.\nboard: decisions.json\nport: 4410\nbind: 127.0.0.1\nagent: ${agent}\n`],
1726
1769
  [board, `{"schema": 2, "items": []}\n`],
1727
1770
  [join(so, '.gitignore'), GITIGNORE],
1728
1771
  ];
@@ -1794,6 +1837,43 @@ function report(args) {
1794
1837
  : `Run \`sign-off-axi post <board.json> --type report --title "<the answer>" --about "<why it matters>" --view <path>/index.html --preview <path>/preview.svg\`, both paths from the board file's folder, which must hold this folder`,
1795
1838
  ]);
1796
1839
  }
1840
+ /** `notify check|test`: whether device notifications can work from this setup, and one real test notice. Never prints the token. */
1841
+ async function notify(args) {
1842
+ const mode = ['check', 'test'].includes(args[0]) ? args.shift() : 'check';
1843
+ const { dir: folder, given } = repoDir('notify', args), dir = given ? folder : setupFolderHere() ?? folder, so = join(dir, '.sign-off');
1844
+ let setup;
1845
+ try {
1846
+ setup = readSetup(so);
1847
+ }
1848
+ catch {
1849
+ throw fail(`no readable config.yaml in ${tilde(so)}`, 'NOT_FOUND', `Run \`sign-off-axi init ${shellPath(dir)}\``);
1850
+ }
1851
+ const config = shellPath(join(so, 'config.yaml'));
1852
+ if (setup.problems.some((p) => p.startsWith('notify')))
1853
+ throw fail(`the notify: block in ${tilde(join(so, 'config.yaml'))} cannot be used: ${setup.problems.filter((p) => p.startsWith('notify')).join('; ')}`, 'VALIDATION_ERROR', `Edit ${config}, then run \`sign-off-axi notify\` again`);
1854
+ const n = setup.notify;
1855
+ if (!n)
1856
+ return toon({ notifications: 'off' }, [`Ask the user once whether they want device notifications; if yes, add \`notify:\`, \` receiver: muxr\` and \` link_base: <the board's https or tailnet address>\` lines to ${config}`]);
1857
+ const why = tokenMissing(n);
1858
+ if (why)
1859
+ throw fail(`notifications are on but cannot send: ${why}`, 'NOT_FOUND', n.muxr ? 'Update the muxr host so it writes its notify-token file, then run `sign-off-axi notify test`' : `Export ${n.token_env} with the receiver's token`);
1860
+ if (mode === 'check')
1861
+ return toon({ notifications: 'on', receiver: n.muxr ? 'muxr' : 'webhook', url: n.url, link_base: n.link_base, token: n.muxr ? tilde(muxrTokenFile()) : `$${n.token_env ?? '(none)'}` }, ['Run `sign-off-axi notify test` to send one real test notice']);
1862
+ const probe = { key: 'sign-off-test', title: 'sign-off test', about: 'Device notifications from sign-off work.', type: 'decision', options: [] };
1863
+ let status;
1864
+ try {
1865
+ const token = n.muxr ? readFileSync(muxrTokenFile(), 'utf8').trim() : n.token_env ? process.env[n.token_env] : undefined;
1866
+ status = (await fetch(n.url, { method: 'POST', headers: { 'content-type': 'application/json', ...(token && { authorization: `Bearer ${token}` }) }, body: JSON.stringify(noticeBody(n, probe)), signal: AbortSignal.timeout(10000) })).status;
1867
+ }
1868
+ catch {
1869
+ throw fail(`nothing answered at ${n.url}`, 'NOT_FOUND', n.muxr ? 'Start the muxr host on this machine, then run `sign-off-axi notify test` again' : 'Check the url: line, then run `sign-off-axi notify test` again');
1870
+ }
1871
+ if ([401, 403].includes(status))
1872
+ throw fail(`the receiver refused the token (HTTP ${status})`, 'VALIDATION_ERROR', n.muxr ? `Check that ${tilde(muxrTokenFile())} holds the token the muxr host expects` : `Check ${n.token_env}`);
1873
+ if (status >= 300)
1874
+ throw fail(`the receiver answered HTTP ${status}, so the test notice was not accepted`, 'VALIDATION_ERROR', 'Check the receiver is the notification endpoint');
1875
+ return toon({ notifications: 'on', test: 'sent', http: status }, ['Check the device for the "sign-off test" notice']);
1876
+ }
1797
1877
  async function doctor(args) {
1798
1878
  const { dir: folder, given } = repoDir('doctor', args), dir = given ? folder : setupFolderHere() ?? folder, so = join(dir, '.sign-off'), rerun = `sign-off-axi init ${shellPath(dir)}`;
1799
1879
  const checks = [];
@@ -1861,8 +1941,9 @@ async function doctor(args) {
1861
1941
  check('config', `board ${basename(board)}, port ${port}`);
1862
1942
  else
1863
1943
  check('config', `${basename(config)}: ${setup.problems.join('; ')}`, `Edit ${shellPath(config)} to hold "board: <name>.json", "port: <0-65535>" and "bind: <127.0.0.1 or a private address>" lines, such as board: decisions.json, port: 4410 and bind: 127.0.0.1, and hosts: only DNS names${setup.goodBoard ? '' : `, or delete it and run \`${rerun}\``}`);
1864
- if (setup.notify?.token_env && !process.env[setup.notify.token_env])
1865
- check('notify', `${setup.notify.token_env} is not set, so no notice is sent`, `Export ${setup.notify.token_env} with the receiver's token`);
1944
+ const why = setup.notify && tokenMissing(setup.notify);
1945
+ if (setup.notify && why)
1946
+ check('notify', `${why}, so no notice is sent`, setup.notify.muxr ? 'Update the muxr host so it writes its notify-token file, then run `sign-off-axi notify test`' : `Export ${setup.notify.token_env} with the receiver's token`);
1866
1947
  // A hosts: name is only of use when it leads to the bind address.
1867
1948
  const astray = [];
1868
1949
  for (const h of setup.hosts) {
@@ -1987,11 +2068,12 @@ async function doctor(args) {
1987
2068
  throw fail(`${bad.length} of ${checks.length} checks need a fix in ${tilde(dir)}`, 'NOT_FOUND', ...bad.map((c) => `${c.name}: ${c.ok}. ${c.fix}`));
1988
2069
  return toon({ dir: tilde(dir), checks: checks.map(({ name, ok }) => ({ name, ok })) }, [`Run \`sign-off-axi open ${shellPath(board)}\` to show the board to the user`]);
1989
2070
  }
1990
- let file = '', evidenceRoot = '', bind = '127.0.0.1', hosts = [], onAnswer = [];
2071
+ let file = '', config = '', evidenceRoot = '', bind = '127.0.0.1', hosts = [], onAnswer = [];
1991
2072
  async function serve(args) {
1992
2073
  const parsed = parse('serve', args, BOARD_FLAGS), port = boardPort(parsed.named, parsed.values.port);
1993
2074
  ready(parsed.file, parsed.on, parsed.values['evidence-root']);
1994
2075
  bind = bindOf(parsed.named, parsed.values.bind);
2076
+ config = join(dirname(parsed.named), 'config.yaml');
1995
2077
  ({ hosts = [], onAnswer = [] } = setupOf(parsed.named) ?? {});
1996
2078
  let warned = false;
1997
2079
  const sweep = async (phase) => {
@@ -2064,6 +2146,7 @@ async function exportBoard(args) {
2064
2146
  const parsed = parse('export', args, { out: { type: 'string' }, 'evidence-root': { type: 'string' } });
2065
2147
  ready(parsed.file, parsed.on, parsed.values['evidence-root']);
2066
2148
  [file, evidenceRoot] = [parsed.file, parsed.values['evidence-root'] ? resolve(parsed.values['evidence-root']) : ''];
2149
+ config = join(dirname(parsed.named), 'config.yaml');
2067
2150
  guardReady = warmGuard();
2068
2151
  await guardReady;
2069
2152
  const { file: _, messages: _messages, annotations: _annotations, ...frozen } = state(), files = {}, q = encodeURIComponent;
@@ -2182,6 +2265,22 @@ const warmGuard = () => new Promise((done) => {
2182
2265
  };
2183
2266
  pass(true);
2184
2267
  });
2268
+ /** How much agents may do without asking, from the leash: line of config.yaml: ask (wait on every ask), matters (take the default at the
2269
+ * silent-by time, the default) or act (take the recommended pick now and report it). With the name: line it is the page's only setting. */
2270
+ const LEASHES = ['ask', 'matters', 'act'];
2271
+ const configLine = (text, key) => new RegExp(`^[ \\t]*${key}[ \\t]*:([^#\\n]*)`, 'm').exec(text)?.[1].trim() || undefined;
2272
+ /** The user's name and leash; none for a board with no config.yaml beside it. */
2273
+ function profile() {
2274
+ let text;
2275
+ try {
2276
+ text = readFileSync(config, 'utf8');
2277
+ }
2278
+ catch {
2279
+ return {};
2280
+ }
2281
+ const leash = configLine(text, 'leash') ?? '';
2282
+ return { name: configLine(text, 'name')?.slice(0, 40), leash: LEASHES.includes(leash) ? leash : 'matters' };
2283
+ }
2185
2284
  const state = () => {
2186
2285
  reap();
2187
2286
  // The page marks sent comments in annotations, so answers and rounds go without theirs: a read-only board or an export carries none.
@@ -2196,6 +2295,7 @@ const state = () => {
2196
2295
  ...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
2197
2296
  readonly: readOnly(),
2198
2297
  answers_only: answersOnly(),
2298
+ ...profile(),
2199
2299
  answers,
2200
2300
  seen: Object.values(answers).filter(taken).map((a) => a.key),
2201
2301
  rounds,
@@ -2372,7 +2472,7 @@ async function route(req) {
2372
2472
  clearTimeout(timer);
2373
2473
  return [200, state()];
2374
2474
  }
2375
- if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin'].includes(req.url ?? ''))
2475
+ if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin', '/api/leash'].includes(req.url ?? ''))
2376
2476
  return [404, { error: 'Not found' }];
2377
2477
  if (readOnly())
2378
2478
  return [403, { error: 'This board is read-only' }];
@@ -2440,6 +2540,21 @@ async function route(req) {
2440
2540
  return board(file).find((x) => x.key === key)?.answer ? [200, { state: state() }] : [502, { error: e.message, state: state() }];
2441
2541
  }
2442
2542
  }
2543
+ if (req.url === '/api/leash') {
2544
+ const { leash } = JSON.parse(body) ?? {};
2545
+ if (!LEASHES.includes(leash))
2546
+ return [400, { error: `Send a leash of ${LEASHES.join(', ')}` }];
2547
+ let text;
2548
+ try {
2549
+ text = readFileSync(config, 'utf8');
2550
+ }
2551
+ catch {
2552
+ return [409, { error: 'This board has no config.yaml to keep the leash in; run sign-off-axi init' }];
2553
+ }
2554
+ const line = /^[ \t]*leash[ \t]*:.*$/m;
2555
+ writeFileSync(config, line.test(text) ? text.replace(line, `leash: ${leash}`) : `${text.replace(/\n?$/, '\n')}leash: ${leash}\n`);
2556
+ return [200, state()];
2557
+ }
2443
2558
  if (req.url === '/api/dismiss') {
2444
2559
  const { keys, dismissed } = JSON.parse(body) ?? {};
2445
2560
  if (!Array.isArray(keys) || !keys.length || keys.length > 200 || typeof dismissed !== 'boolean')
@@ -2447,8 +2562,6 @@ async function route(req) {
2447
2562
  const have = new Map(board(file).map((x) => [x.key, x]));
2448
2563
  if (!keys.every((k) => isKey(k) && have.has(k)))
2449
2564
  return [404, { error: 'This item is no longer in the file' }];
2450
- if (dismissed && keys.some((k) => have.get(k)?.type === 'plan' && !have.get(k)?.answer))
2451
- return [400, { error: 'A plan waits until it is sent back, so it cannot be dismissed' }];
2452
2565
  appendDismissed(file, [...new Set(keys)], dismissed);
2453
2566
  return [200, state()];
2454
2567
  }
@@ -2675,7 +2788,7 @@ await runAxiCli({
2675
2788
  topLevelHelp: TOP_HELP,
2676
2789
  getCommandHelp: (cmd) => HELP[cmd],
2677
2790
  home: axi('list', list),
2678
- commands: { list: axi('list', list), raise: axi('raise', (args) => post(args, 'raise')), post: axi('post', post), answers: axi('answers', answers), wait: axi('wait', wait), poll: axi('poll', poll), open: axi('open', open), status: axi('status', status), stop: axi('stop', stop), serve: axi('serve', serve), export: axi('export', exportBoard), init: axi('init', init), report: axi('report', report), signin: axi('signin', signin), doctor: axi('doctor', doctor), validate, help: ([name, ...rest]) => {
2791
+ commands: { list: axi('list', list), raise: axi('raise', (args) => post(args, 'raise')), post: axi('post', post), answers: axi('answers', answers), wait: axi('wait', wait), poll: axi('poll', poll), open: axi('open', open), status: axi('status', status), stop: axi('stop', stop), serve: axi('serve', serve), export: axi('export', exportBoard), init: axi('init', init), report: axi('report', report), signin: axi('signin', signin), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
2679
2792
  if (rest.length || (name && !HELP[name]))
2680
2793
  throw fail(`unknown command or argument: ${[name, ...rest].join(' ')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi --help` to see the commands');
2681
2794
  return name ? HELP[name] : TOP_HELP;
package/dist/contract.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The v1 communication contract (docs/contract.md): one strict, versioned shape per kind of message.
2
2
  import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
3
3
  import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
4
- import { isKey, MEDIA_MAX } from './store.js';
4
+ import { isKey, MEDIA_MAX, REPORT_FORMS } from './store.js';
5
5
  /** A message that does not match: what is wrong, and the one line that says how to fix it. */
6
6
  export class ContractError extends Error {
7
7
  help;
@@ -15,12 +15,12 @@ export const SCHEMAS = { ask: 1, report: 2, evidence: 1, gate: 1, finding: 1, pl
15
15
  export const KINDS = Object.keys(SCHEMAS);
16
16
  const COMMON = ['contract', 'kind', 'schema', 'key', 'title', 'filer', 'link', 'about'];
17
17
  const FIELDS = {
18
- ask: [...COMMON, 'detail', 'why', 'options', 'recommend_value', 'recommend_reason', 'default_if_silent', 'silent_by', 'close', 'merges'],
19
- report: [...COMMON, 'filed', 'group', 'decided', 'view', 'preview'],
18
+ ask: [...COMMON, 'detail', 'why', 'options', 'recommend_value', 'recommend_reason', 'default_if_silent', 'silent_by', 'close', 'merges', 'proof'],
19
+ report: [...COMMON, 'filed', 'group', 'decided', 'form', 'video', 'asked_by', 'view', 'preview'],
20
20
  evidence: [...COMMON, 'media', 'run'],
21
21
  gate: [...COMMON, 'run', 'options', 'recommend_value', 'close'],
22
22
  finding: [...COMMON, 'cause', 'incidents', 'days', 'evidence_ids', 'change_kind', 'proposal', 'options', 'recommend_value'],
23
- plan: [...COMMON, 'sections', 'decisions', 'steps', 'view'],
23
+ plan: [...COMMON, 'project', 'sections', 'decisions', 'steps', 'view'],
24
24
  design: [...COMMON, 'group', 'filed', 'status', 'preview', 'journeys', 'options', 'recommend'],
25
25
  };
26
26
  /** A done-done run: its id, full commit and the four Checks lines. */
@@ -82,7 +82,7 @@ const show = (v) => JSON.stringify(v);
82
82
  /** A field name as it reads inside a sentence: escaped, so a newline or control character shows as \n or \u001b. */
83
83
  const escaped = (v) => show(v).slice(1, -1);
84
84
  /** The raise flag each question field fills: a refusal of a flag post names that flag, not the field. */
85
- const FLAG_OF = { why: 'why', about: 'about', detail: 'detail', title: 'title', default_if_silent: 'default', recommend_reason: 'recommend-reason', recommend_value: 'recommend', silent_by: 'silent-by', merges: 'merge', options: 'option', label: 'option', value: 'option', outcome: 'outcome' };
85
+ const FLAG_OF = { why: 'why', about: 'about', detail: 'detail', title: 'title', default_if_silent: 'default', recommend_reason: 'recommend-reason', recommend_value: 'recommend', silent_by: 'silent-by', merges: 'merge', proof: 'proof', options: 'option', label: 'option', value: 'option', outcome: 'outcome' };
86
86
  /** The flag a field fills: options[1].outcome is --outcome, merges[0] is --merge. */
87
87
  const flagOf = (field) => { const f = field.replace(/^options\[\d+\]\./, '').replace(/\[\d+\]$/, ''); return `--${FLAG_OF[f] ?? f}`; };
88
88
  /** A field as a refusal names it: the JSON field, or the raise flag that fills it. */
@@ -249,6 +249,12 @@ export function checkMessage(m, want, flags = false) {
249
249
  text('group', m.group, 40, 'the section it belongs to, in 40 characters or fewer, or leave it out');
250
250
  if (Object.hasOwn(m, 'decided'))
251
251
  text('decided', m.decided, 200, 'what was decided, in 200 characters or fewer, or leave it out');
252
+ if (Object.hasOwn(m, 'form') && !REPORT_FORMS.includes(m.form))
253
+ throw bad('form', `${show(m.form)} is not one of ${REPORT_FORMS.join(', ')}`, `Set "form" to ${REPORT_FORMS.join(', ')}, or leave it out`);
254
+ if (Object.hasOwn(m, 'video'))
255
+ text('video', m.video, 10, 'the video length such as "0:28", in 10 characters or fewer, or leave it out');
256
+ if (Object.hasOwn(m, 'asked_by'))
257
+ text('asked_by', m.asked_by, 40, 'who asked for it, such as "You asked" or "Docs agent", in 40 characters or fewer, or leave it out');
252
258
  // A report is a page to tap through and a picture of it for the card: text alone is a status note, not a report.
253
259
  const need = 'a report is a page to tap through, not text';
254
260
  page('view', /\.html$/i, '.html', '"mocks/<key>/index.html"', need);
@@ -309,6 +315,8 @@ export function checkMessage(m, want, flags = false) {
309
315
  }
310
316
  // A plan is sections to approve or change, decisions with a recommended pick, steps to tap through, and where it goes as a diagram.
311
317
  if (m.kind === 'plan') {
318
+ if (Object.hasOwn(m, 'project'))
319
+ text('project', m.project, 40, 'the project name, in 40 characters or fewer, or leave it out');
312
320
  const ids = list('sections', 1, 8, ['id', 'heading', 'body'], '{"id":"goals","heading":"Goals and non-goals","body":"<what it does and does not do>"}').map((o, n) => {
313
321
  if (!(typeof o.id === 'string' && /^[a-z0-9-]{1,40}$/.test(o.id)))
314
322
  throw bad(`sections[${n}].id`, 'must be 1-40 of a-z 0-9 -', 'Set "id" to a short slug such as "goals"');
@@ -319,7 +327,7 @@ export function checkMessage(m, want, flags = false) {
319
327
  const twice = ids.findIndex((id, n) => ids.indexOf(id) < n);
320
328
  if (twice >= 0)
321
329
  throw bad(`sections[${twice}].id`, `repeats ${ids[twice]}`, 'Give every section its own id');
322
- list('decisions', 0, 5, ['key', 'question', 'why', 'options', 'recommend'], '{"key":"store","question":"Keep plans in the board file?","why":"...","options":[...],"recommend":"yes"}').forEach((o, n, all) => {
330
+ list('decisions', 0, 5, ['key', 'question', 'why', 'reason', 'after', 'options', 'recommend'], '{"key":"store","question":"Keep plans in the board file?","why":"...","options":[...],"recommend":"yes"}').forEach((o, n, all) => {
323
331
  const at = `decisions[${n}]`;
324
332
  if (!isKey(o.key))
325
333
  throw bad(`${at}.key`, 'must be 1-128 of A-Z a-z 0-9 . _ -', 'Set "key" to a short id such as "store"');
@@ -333,13 +341,21 @@ export function checkMessage(m, want, flags = false) {
333
341
  if (!/[\p{L}\p{N}]/u.test(q) || q.split(/\s+/).length > 12)
334
342
  throw bad(`${at}.question`, 'must be one question of 12 words or fewer, ending in a question mark', 'Ask it in 12 words or fewer, ending in a question mark; put the rest in "why"');
335
343
  text(`${at}.why`, o.why, 500, 'why it matters, in 500 characters or fewer');
344
+ if (Object.hasOwn(o, 'reason'))
345
+ text(`${at}.reason`, o.reason, 300, 'why the recommended option, in 300 characters or fewer, or leave it out');
346
+ if (Object.hasOwn(o, 'after') && !ids.includes(o.after))
347
+ throw bad(`${at}.after`, `must be the id of one of the plan's sections`, `Set "after" to one of: ${ids.join(', ')}`);
336
348
  if (!Array.isArray(o.options) || o.options.length < 2 || o.options.length > 6)
337
349
  throw bad(`${at}.options`, Object.hasOwn(o, 'options') ? 'must list 2 to 6 options' : 'is required', 'Set "options" to 2-6 objects such as {"value":"yes","label":"Yes","outcome":"Plans live in the board file"}');
338
350
  checkOptions(`${at}.options`, o.options, 'plan');
339
351
  recommend(`${at}.recommend`, o, o.options);
340
352
  });
341
353
  if (Object.hasOwn(m, 'steps'))
342
- list('steps', 2, 12, ['title'], '{"title":"The agent posts the plan"}').forEach((o, n) => text(`steps[${n}].title`, o.title, 80, 'the step, in 80 characters or fewer'));
354
+ list('steps', 2, 12, ['title', 'text'], '{"title":"The agent posts the plan"}').forEach((o, n) => {
355
+ text(`steps[${n}].title`, o.title, 80, 'the step, in 80 characters or fewer');
356
+ if (Object.hasOwn(o, 'text'))
357
+ text(`steps[${n}].text`, o.text, 300, 'what happens in this step, in 300 characters or fewer, or leave it out');
358
+ });
343
359
  page('view', /\.html$/i, '.html', '"mocks/<key>/index.html"', 'a plan shows where it goes as a diagram on a page', 'Set "view" to "mocks/<key>/index.html", a path from the board file\'s folder');
344
360
  return { kind: m.kind, schema: m.schema };
345
361
  }
@@ -393,6 +409,12 @@ export function checkMessage(m, want, flags = false) {
393
409
  throw bad(`merges[${n}]`, `repeats ${k}`, 'List each key once');
394
410
  });
395
411
  }
412
+ if (Object.hasOwn(m, 'proof')) {
413
+ if (!isKey(m.proof))
414
+ throw bad('proof', 'must be 1-128 of A-Z a-z 0-9 . _ -', `Set ${named('proof', flags)} to the key of an evidence update on the board that this ask rests on, such as ${flags ? '--proof update-3' : '"update-3"'}`);
415
+ if (m.proof === m.key)
416
+ throw bad('proof', 'is this ask\'s own key', 'Name the evidence update the ask rests on, not the ask');
417
+ }
396
418
  if (ask && flags)
397
419
  question(m, bad, flags);
398
420
  // A gate's run is its own rule (section 5), so it comes after the field rules of section 4.
@@ -554,9 +576,10 @@ export function reportPage(m, base) {
554
576
  throw bad(`${page} has no dark mode`, 'Add a @media (prefers-color-scheme: dark) block, or start again from report init');
555
577
  if (!/<(svg|img|canvas|video)\b/i.test(html))
556
578
  throw bad(`${page} has no picture or diagram`, 'Draw the idea: add an <svg> or <img> under the answer and a diagram under it');
557
- if (!/<(button|input|select)\b/i.test(html))
579
+ // A plan's page is its diagram: the steps and the details are on the board, so it needs nothing to tap and no fold.
580
+ if (m.kind !== 'plan' && !/<(button|input|select)\b/i.test(html))
558
581
  throw bad(`${page} has nothing to tap or slide, so it is text, not a report`, 'Add a demo the reader can tap through, such as the scaffold\'s steps with Back and Next');
559
- if (!/<details\b/i.test(html))
582
+ if (m.kind !== 'plan' && !/<details\b/i.test(html))
560
583
  throw bad(`${page} has no folded detail`, 'Fold the detail into <details><summary>Details</summary>...</details> at the end');
561
584
  if (/<(script|img|iframe|link|video|audio|source)\b[^>]*\b(src|href)\s*=\s*["']?\s*(https?:)?\/\//i.test(html))
562
585
  throw bad(`${page} loads something from the web, which the board blocks`, 'Put scripts, styles and pictures in the page or beside it');
package/dist/store.js CHANGED
@@ -29,6 +29,8 @@ const isText = (v) => typeof v === 'string';
29
29
  export const isKey = (v) => isText(v) && /^[A-Za-z0-9._-]{1,128}$/.test(v);
30
30
  const ONE_OF = { close: ['done', 'release'], area: ['waiting', 'evidence', 'reports', 'answered'] };
31
31
  /** What a step uses or can stop on: an AI model, a store, a gate, a hard fail. */
32
+ /** What a report explains with: the board draws one line icon per form. */
33
+ export const REPORT_FORMS = ['diagram', 'chart', 'before-after'];
32
34
  const MARKS = ['ai', 'store', 'gate', 'fail'];
33
35
  export const NOTE_MAX = 500;
34
36
  const TRADEOFF_MAX = 1000;
@@ -87,6 +89,8 @@ export function loadDecisions(path, board) {
87
89
  throw bad(f, 'must be text');
88
90
  if (d.merges !== undefined && !(Array.isArray(d.merges) && d.merges.every(isKey)))
89
91
  throw bad('merges', 'must list keys of 1-128 of A-Z a-z 0-9 . _ -');
92
+ if (d.proof !== undefined && !isKey(d.proof))
93
+ throw bad('proof', 'must be a key of 1-128 of A-Z a-z 0-9 . _ -');
90
94
  if (d.evidence !== undefined && (!d.evidence || !/^[A-Za-z0-9._-]{1,128}$/.test(d.evidence.run_id ?? '')
91
95
  || !/^[a-f0-9]{40}$/.test(d.evidence.commit ?? '')
92
96
  || ['outcome', 'repro', 'proof', 'evidence'].some((f) => !isText(d.evidence[f]) || !d.evidence[f].trim() || [...d.evidence[f]].length > 1000))) {
@@ -120,6 +124,12 @@ export function loadDecisions(path, board) {
120
124
  throw bad('group', 'must be text of 40 characters or fewer');
121
125
  if (d.filed !== undefined && !(isText(d.filed) && /^\d{4}-\d{2}-\d{2}T/.test(d.filed) && !Number.isNaN(Date.parse(d.filed))))
122
126
  throw bad('filed', 'must be an ISO 8601 date-time such as 2026-10-13T07:19:00Z');
127
+ if (d.form !== undefined && !REPORT_FORMS.includes(d.form))
128
+ throw bad('form', `must be one of ${REPORT_FORMS.join(', ')}`);
129
+ if (d.video !== undefined && !(isText(d.video) && d.video.trim() && [...d.video].length <= 10))
130
+ throw bad('video', 'must be text of 10 characters or fewer');
131
+ if (d.asked_by !== undefined && !(isText(d.asked_by) && d.asked_by.trim() && [...d.asked_by].length <= 40))
132
+ throw bad('asked_by', 'must be text of 40 characters or fewer');
123
133
  if (d.decided !== undefined && !(isText(d.decided) && d.decided.trim() && [...d.decided].length <= 200))
124
134
  throw bad('decided', 'must be text of 200 characters or fewer');
125
135
  if (d.type === 'evidence' && !(Number.isInteger(d.number) && d.number >= 1))
@@ -204,20 +214,21 @@ export function loadDecisions(path, board) {
204
214
  }
205
215
  return {
206
216
  key: d.key, title: d.title, about: d.about, detail: d.detail, options: options.map((o) => ({ value: o.value, label: o.label, ...(o.outcome && { outcome: o.outcome }) })),
207
- ...(d.why && { why: d.why }), ...(d.recommend_reason && { recommend_reason: d.recommend_reason }), ...(d.silent_by && { silent_by: d.silent_by }), ...(d.merges && { merges: [...d.merges] }),
217
+ ...(d.why && { why: d.why }), ...(d.recommend_reason && { recommend_reason: d.recommend_reason }), ...(d.silent_by && { silent_by: d.silent_by }), ...(d.merges && { merges: [...d.merges] }), ...(d.proof && { proof: d.proof }),
208
218
  recommend_value: d.recommend_value, default_if_silent: d.default_if_silent, close: d.close ?? 'done', evidence: d.evidence,
209
219
  type: plan ? 'plan' : d.type ?? 'decision', tradeoff: d.tradeoff, link: d.link, mocks: d.mocks?.map((m) => ({ value: m.value, path: m.path })),
210
220
  ...(views && { view: d.view }), ...(report && views && { preview: d.preview }), ...(d.reply && { reply: true }), ...(d.filer && { filer: d.filer }), ...(d.area && { area: d.area }),
211
221
  ...(signin && { unlocks: d.unlocks, waits: d.waits, ...(d.expires && { expires: d.expires }), ...(d.start && { start: [...d.start] }) }),
212
222
  ...(d.group && { group: d.group }), ...(d.filed && { filed: d.filed }), ...(d.decided && { decided: d.decided }),
213
- ...(d.steps && { steps: d.steps.map(({ title, marks }) => ({ title, ...(marks?.length && { marks }) })) }),
223
+ ...(d.form && { form: d.form }), ...(d.video && { video: d.video }), ...(d.asked_by && { asked_by: d.asked_by }),
224
+ ...(d.steps && { steps: d.steps.map(({ title, marks, text }) => ({ title, ...(text && { text }), ...(marks?.length && { marks }) })) }),
214
225
  ...(stored && d.media && { media: d.media.map(({ path, sha256, bytes }) => ({ path, sha256, bytes })) }),
215
226
  ...(d.type === 'evidence' && { number: d.number }),
216
227
  // A gate's run is part of its question: the same key with another run is a different question.
217
228
  ...(d.kind === 'gate' && d.run && { run: { run_id: d.run.run_id, commit: d.run.commit } }),
218
229
  // So is a finding's proposal, with the counts and records behind it.
219
230
  ...(d.kind === 'finding' && { finding: { cause: d.cause, incidents: d.incidents, days: d.days, evidence_ids: d.evidence_ids, change_kind: d.change_kind, proposal: d.proposal } }),
220
- ...(plan && { sections: d.sections, decisions: d.decisions }),
231
+ ...(plan && { sections: d.sections, decisions: d.decisions, ...(d.project && { project: d.project }) }),
221
232
  ...(design && { kind: 'design', type: 'design', group: d.group, filed: d.filed, status: d.status, preview: d.preview, journeys: d.journeys, ...(d.options && { options: d.options, recommend_value: d.recommend }) }),
222
233
  ...(d.type === 'explainer' && d.chapters && { chapters: d.chapters.map(({ at, title }) => ({ at, title })) }),
223
234
  };
@@ -5,17 +5,14 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Plan: show plans on the board</title>
7
7
  <style>
8
- body { font: 16px/1.5 system-ui, sans-serif; margin: 1rem; color: #111; background: #fff; }
9
- @media (prefers-color-scheme: dark) { body { color: #eee; background: #111; } }
10
- svg { display: block; width: 100%; max-width: 34rem; height: auto; margin: 1rem 0; }
8
+ :root { color-scheme: light dark; }
9
+ body { font: 16px/1.5 system-ui, sans-serif; margin: 0; color: #111; background: transparent; }
10
+ @media (prefers-color-scheme: dark) { body { color: #eee; } }
11
+ svg { display: block; width: 100%; height: auto; }
11
12
  svg text { font: 13px system-ui, sans-serif; fill: currentColor; }
12
- .step { min-height: 3rem; }
13
- button { font: inherit; min-height: 2.75rem; padding: 0 1rem; }
14
13
  </style>
15
14
  </head>
16
15
  <body>
17
- <h1>Show plans on the board</h1>
18
- <p>Umer reviews an agent's plan on a phone: approve or change each section, answer each decision, then send it all back at once.</p>
19
16
  <svg viewBox="0 0 340 110" role="img" aria-label="The agent posts a plan, the board shows it, Umer answers, and one send-back returns to the agent">
20
17
  <rect x="2" y="25" width="70" height="40" rx="6" fill="none" stroke="currentColor"/><text x="37" y="50" text-anchor="middle">Agent</text>
21
18
  <rect x="134" y="25" width="70" height="40" rx="6" fill="none" stroke="currentColor"/><text x="169" y="50" text-anchor="middle">Board</text>
@@ -23,20 +20,5 @@ button { font: inherit; min-height: 2.75rem; padding: 0 1rem; }
23
20
  <path d="M74 40 H130 M206 40 H262" stroke="currentColor" stroke-width="2"/>
24
21
  <path d="M301 67 V100 H37 V67" fill="none" stroke="currentColor" stroke-dasharray="4 3"/><text x="169" y="90" text-anchor="middle">one send-back</text>
25
22
  </svg>
26
- <h2>How it works</h2>
27
- <p class="step" id="step" aria-live="polite"></p>
28
- <p><button type="button" id="back">Back</button> <button type="button" id="next">Next</button></p>
29
- <details><summary>Details</summary>
30
- <p>Sections: goals and non-goals, architecture, slices and order, coding standards, risks. Each takes Approve or Change with a note.</p>
31
- <p>Decisions: send every answer back as one event, and show plans under Waiting on you.</p>
32
- </details>
33
- <script>
34
- const steps = ['1 of 4. The agent posts the plan.', '2 of 4. The board stores it under Waiting on you.', '3 of 4. Umer answers each section and decision.', '4 of 4. Send back returns every answer to the agent.'];
35
- let i = 0;
36
- const show = () => { document.getElementById('step').textContent = steps[i]; };
37
- document.getElementById('back').onclick = () => { i = Math.max(0, i - 1); show(); };
38
- document.getElementById('next').onclick = () => { i = Math.min(steps.length - 1, i + 1); show(); };
39
- show();
40
- </script>
41
23
  </body>
42
24
  </html>