sign-off-axi 0.4.7 → 0.4.9

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 every update, never in **Waiting on you**: a passed run shows a tick, and a plain update that links no run, a failed run or an unchecked run says its honest result, each with its verdicts and Dismiss on the update's own screen. **Proven today** counts every update filed today, each with its own result. 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
@@ -281,6 +283,8 @@ sign-off-axi signin done \
281
283
  --key npm-login
282
284
  ```
283
285
 
286
+ To hide a card as the Dismiss button does, or bring it back: `sign-off-axi dismiss <key>` and `sign-off-axi restore <key>`.
287
+
284
288
  <picture>
285
289
  <source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-signin-dark.webp">
286
290
  <img src="docs/media/kind-signin.webp" alt="A sign-in card on top with an Open button, what it unlocks, who waits and the time left">
@@ -354,7 +358,8 @@ cannot block can be woken instead: an `on_answer:` line in the
354
358
  `config.yaml` beside the board names a command the board runs for every
355
359
  answer. A `notify:` block in that config can send an HTTP POST when a card
356
360
  enters Waiting on you, with its bearer token read from a named environment
357
- variable. The full reference, with the worked example, is in
361
+ variable, or, with `receiver: muxr`, from the muxr host's `notify-token`
362
+ file; `sign-off-axi notify test` sends a test notice. The full reference, with the worked example, is in
358
363
  [docs/commands.md](docs/commands.md).
359
364
 
360
365
  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[20]:
32
+ (none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, report, signin, dismiss, restore, 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:
@@ -43,6 +43,7 @@ examples:
43
43
  sign-off-axi post .sign-off/decisions.json --type report --title "Sign-in now takes 2 steps" --about "Why the code step moved first." --view mocks/sign-in-flow/index.html --preview mocks/sign-in-flow/preview.svg
44
44
  sign-off-axi post --type signin --key npm-login --service npm --link https://example.com/login --unlocks "Publish the release" --waits "Main" --expires 15m
45
45
  sign-off-axi signin done --key npm-login
46
+ sign-off-axi dismiss ship-the-new-sign-in-screen-this-week
46
47
  sign-off-axi init
47
48
  sign-off-axi doctor
48
49
  sign-off-axi validate ask.json
@@ -62,7 +63,7 @@ examples:
62
63
  sign-off-axi
63
64
  sign-off-axi list ./decisions.json --full
64
65
  `,
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>]
66
+ 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
67
  sign-off-axi raise [board.json] --from <decisions.json> [--filer <id>]
67
68
  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
69
  raise is the alias for post --type decision: same flags, same output.
@@ -76,6 +77,7 @@ Every question meets the question standard (docs/contract.md, "Asking a good que
76
77
  yes and no are labelled Yes and No; a question never takes Done, OK or Not now buttons.
77
78
  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
79
  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.
80
+ --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
81
  The user answers with one tap and can undo it for a few seconds.
80
82
  --from adds every decision in a decisions file: ${FORMAT}
81
83
  Raising the same question again is a no-op that reports its status; a different question under a used key is refused.
@@ -247,6 +249,16 @@ Closes a sign-in card that post --type signin made: the sign-in is done, so the
247
249
  Run it when you see the sign-in worked. A card posted with --start also closes by itself when its command ends with exit 0. wait --key <key> also returns once it is done.
248
250
  examples:
249
251
  sign-off-axi signin done --key npm-login
252
+ `,
253
+ dismiss: `usage: sign-off-axi dismiss <key> [board.json]
254
+ Hides a card from the board, like its Dismiss button. The agent is not told, and an ask it waits on keeps waiting.
255
+ restore <key> [board.json] brings it back. Find the key with \`sign-off-axi list --full\`.
256
+ examples:
257
+ sign-off-axi dismiss ship-the-new-sign-in-screen-this-week
258
+ sign-off-axi restore ship-the-new-sign-in-screen-this-week
259
+ `,
260
+ restore: `usage: sign-off-axi restore <key> [board.json]
261
+ Brings back a card that dismiss hid. See \`sign-off-axi dismiss --help\`.
250
262
  `,
251
263
  report: `usage: sign-off-axi report init <folder>
252
264
  Writes a report page to fill: <folder>/index.html and its card picture <folder>/preview.svg. A file already there is kept.
@@ -286,6 +298,19 @@ It opens from disk with no server and makes no network request. --out defaults t
286
298
  examples:
287
299
  sign-off-axi export
288
300
  sign-off-axi export ./decisions.json --out ~/board-12-oct.html
301
+ `,
302
+ notify: `usage: sign-off-axi notify [check|test] [dir]
303
+ Device notifications are off until the config.yaml beside the board has a notify: block. For the muxr receiver it is:
304
+ notify:
305
+ receiver: muxr
306
+ link_base: https://my-machine.my-tailnet.ts.net:4410/ (https or a tailnet address, so the link opens on the iPad)
307
+ url: http://127.0.0.1:8793/v1/notify (optional: this is the default)
308
+ The token is read at send time from the file <MUXR_HOME>/notify-token (default ~/.muxr/notify-token), never kept in the config.
309
+ check (the default): says whether notifications are off, misconfigured, waiting on the token file, or ready. It sends nothing.
310
+ test: sends one real "sign-off test" notice now and says plainly what happened (sent, no token file, token refused, receiver not running).
311
+ examples:
312
+ sign-off-axi notify
313
+ sign-off-axi notify test
289
314
  `,
290
315
  doctor: `usage: sign-off-axi doctor [dir]
291
316
  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 +348,7 @@ function fits(title, key) {
323
348
  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
349
  }
325
350
  /** 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'];
351
+ const ASK_FLAGS = ['why', 'outcome', 'recommend-reason', 'silent-by', 'merge', 'proof'];
327
352
  const MANY = ['option', 'outcome', 'merge'];
328
353
  /** Board types that are contract kinds: a decision (the default type) is an ask. */
329
354
  const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report', 'plan', 'design'];
@@ -488,7 +513,7 @@ function filerOf(values, session = false) {
488
513
  const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
489
514
  : `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
490
515
  /** Still waiting on the user: not answered, not dismissed, and something to answer (an item with no options and no reply is only shown). */
491
- const waits = (d) => !d.dismissed && awaitsVerdict(d.type, d.answer) && (d.options.length > 0 || !!d.reply || d.type === 'signin' || d.type === 'plan' || d.type === 'design');
516
+ const waits = (d) => d.type !== 'evidence' && !d.dismissed && awaitsVerdict(d.type, d.answer) && (d.options.length > 0 || !!d.reply || d.type === 'signin' || d.type === 'plan' || d.type === 'design');
492
517
  /** The board's decisions with their latest answer; a missing board file is an empty board.
493
518
  * A dismissal hides an item from the board and is not waiting here either, checked against the same dismissed log the board reads. */
494
519
  function board(file, say = warn) {
@@ -529,7 +554,7 @@ function list(args) {
529
554
  ];
530
555
  const where = tilde(file) + (existsSync(file) ? '' : ' (no file yet; raise creates it)');
531
556
  if (values.full) {
532
- return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: awaitsVerdict(d.type, d.answer) ? (d.dismissed ? 'dismissed' : 'waiting') : d.answer.answer, title: d.title, about: d.about ?? '', detail: d.detail ?? '',
557
+ return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: awaitsVerdict(d.type, d.answer) ? (d.dismissed ? 'dismissed' : d.type === 'evidence' ? 'shown' : 'waiting') : d.answer.answer, title: d.title, about: d.about ?? '', detail: d.detail ?? '',
533
558
  recommend: d.recommend_value ?? '', default_if_silent: d.default_if_silent ?? '', close: d.close })) }, help);
534
559
  }
535
560
  return toon({
@@ -958,6 +983,22 @@ function signin(args) {
958
983
  return toon({ board: tilde(file), closed: [{ key: d.key, status: saved ? 'done' : 'already done', service: d.title }] }, [`Run \`sign-off-axi list${on}\` to see what still waits on the user`]);
959
984
  });
960
985
  }
986
+ /** dismiss|restore <key> [board.json]: the board's Dismiss and Undo, for an agent. The same dismissed log the board writes, for a key that is on the board. */
987
+ function dismiss(restoring) {
988
+ const cmd = restoring ? 'restore' : 'dismiss';
989
+ return (args) => {
990
+ const at = args.findIndex((a) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json')), key = args[at];
991
+ if (at < 0)
992
+ throw fail(`${cmd} needs a card key`, 'VALIDATION_ERROR', `sign-off-axi ${cmd} <key> [board.json]`);
993
+ const { file, on } = parse(cmd, args.filter((_, i) => i !== at), {});
994
+ return locked(`${file}.lock`, () => {
995
+ if (!isKey(key) || !board(file).some((x) => x.key === key))
996
+ throw fail(`no card with key ${key} on ${tilde(file)}`, 'NOT_FOUND', `Run \`sign-off-axi list${on} --full\` to see the keys`);
997
+ appendDismissed(file, [key], !restoring);
998
+ return toon({ board: tilde(file), [restoring ? 'restored' : 'dismissed']: [key] }, [`Run \`sign-off-axi ${restoring ? 'dismiss' : 'restore'}${on} ${key}\` to undo`]);
999
+ });
1000
+ };
1001
+ }
961
1002
  /** For a program that feeds the board: the --from file, checked like any post, becomes the whole board, items and top-level fields. Answers stay.
962
1003
  * With a filer it replaces only that filer's items (and items with no filer it names by key); every other item and top-level field stays. */
963
1004
  function replace(file, named, on, from, filer) {
@@ -1087,7 +1128,7 @@ function postMedia(file, named, on, values, type, filer, message) {
1087
1128
  if (asMessage(had) !== canon({ ...message, media: sources.length ? sources.map((src) => sha256(readFileSync(src))) : undefined })) {
1088
1129
  throw fail(`key ${own} already holds a different message on ${tilde(file)}`, 'CONFLICT', 'Post it under a new key');
1089
1130
  }
1090
- return toon({ board: tilde(file), posted: [{ key: own, number: had.number, media: sources.length, title: had.title, status: 'already posted' }] }, [`Run \`sign-off-axi open${on}\` so the user can give a verdict`]);
1131
+ return toon({ board: tilde(file), posted: [{ key: own, number: had.number, media: sources.length, title: had.title, status: 'already posted' }] }, explainer ? [`Run \`sign-off-axi open${on}\` so the user can give a verdict`] : []);
1091
1132
  }
1092
1133
  const answers = readAnswers(file);
1093
1134
  if (message && own && Object.hasOwn(answers, own) && !awaitsVerdict(message.kind, answers[own]))
@@ -1143,7 +1184,7 @@ function postMedia(file, named, on, values, type, filer, message) {
1143
1184
  notifyEntered(file, named, before);
1144
1185
  if (explainer)
1145
1186
  return toon({ board: tilde(file), raised: [{ key, status: 'raised', title }] }, [`Run \`sign-off-axi open${on}\` so the user can watch and answer`, waitFor(on, filer, key)]);
1146
- return toon({ board: tilde(file), posted: [{ key, number, media: media.length, title, ...(message && { status: 'posted' }) }] }, [`Run \`sign-off-axi open${on}\` so the user can give a verdict`, waitFor(on, filer, key)]);
1187
+ return toon({ board: tilde(file), posted: [{ key, number, media: media.length, title, ...(message && { status: 'posted' }) }] });
1147
1188
  });
1148
1189
  }
1149
1190
  /** post --type report: the flags as one report message, posted as post --kind report posts it. A report with no page is refused before anything is read. */
@@ -1304,7 +1345,7 @@ function buildIncoming(values, extra, filer, file) {
1304
1345
  try {
1305
1346
  const { title, key = slug(title), about, detail, why, recommend: recommend_value, default: default_if_silent, close } = values;
1306
1347
  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 };
1348
+ const told = ask && { why, recommend_reason: values['recommend-reason'], silent_by: values['silent-by'], merges: values.merge, proof: values.proof };
1308
1349
  writeFileSync(source, JSON.stringify({ schema: 2, items: [{ key, title, about, detail, ...told, options, recommend_value, default_if_silent, close, ...extra, filer }] }));
1309
1350
  const incoming = loadDecisions(source, board), raw = readItems(source).items;
1310
1351
  if (!ask)
@@ -1330,6 +1371,9 @@ const canon = (v) => JSON.stringify(v, (_, x) => (x && typeof x === 'object' &&
1330
1371
  /** 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
1372
  * 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
1373
  function checkMerges(incoming, resulting, current) {
1374
+ for (const d of incoming)
1375
+ if (d.proof && resulting.find((x) => x.key === d.proof)?.type !== 'evidence')
1376
+ 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
1377
  const byKey = new Map(resulting.map((d) => [d.key, d])), before = new Map(current.map((c) => [c.key, c]));
1334
1378
  const closed = (key) => !!(before.get(key)?.answer || before.get(key)?.dismissed);
1335
1379
  const holder = (key, self) => resulting.find((x) => x.key !== self && !before.get(x.key)?.answer && x.merges?.includes(key));
@@ -1617,11 +1661,11 @@ function readSetup(so) {
1617
1661
  readFileSync(join(so, 'config.yaml'), 'utf8').split(/\r?\n/).forEach((l, n) => {
1618
1662
  // The notify block's indented lines keep their # , and } characters: only a # after a space starts a comment.
1619
1663
  if (inNotify && (!l.trim() || /^\s/.test(l))) {
1620
- const text = l.replace(/(^|\s+)#.*$/, '').trim(), field = text.match(/^(url|token_env|link_base)\s*:\s*(.+)$/);
1664
+ const text = l.replace(/(^|\s+)#.*$/, '').trim(), field = text.match(/^(url|token_env|link_base|receiver)\s*:\s*(.+)$/);
1621
1665
  if (field)
1622
1666
  notify[field[1]] = field[2].replace(/^(['"])(.*)\1$/, '$2');
1623
1667
  else if (text)
1624
- problems.push(`line ${n + 1} is not one "url", "token_env" or "link_base" line under notify`);
1668
+ problems.push(`line ${n + 1} is not one "receiver", "url", "token_env" or "link_base" line under notify`);
1625
1669
  return;
1626
1670
  }
1627
1671
  inNotify = false;
@@ -1661,19 +1705,42 @@ function readSetup(so) {
1661
1705
  catch {
1662
1706
  return undefined;
1663
1707
  } };
1708
+ const muxr = notify.receiver?.toLowerCase() === 'muxr';
1709
+ if (notifyBlock && notify.receiver && !muxr)
1710
+ problems.push('notify receiver must be muxr, or leave the receiver line out');
1711
+ if (muxr)
1712
+ notify.url ??= MUXR_URL;
1664
1713
  const sendTo = parsed(notify.url), base = parsed(notify.link_base);
1665
1714
  const usable = !!sendTo && !!base && (sendTo.protocol === 'https:' || (sendTo.protocol === 'http:' && (loopback(sendTo.hostname.replace(/^\[|\]$/g, '')) || sendTo.hostname === 'localhost')))
1666
1715
  && ['http:', 'https:'].includes(base.protocol) && (notify.token_env === undefined || /^[A-Za-z_]\w*$/.test(notify.token_env));
1667
1716
  if (notifyBlock && !usable)
1668
1717
  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');
1718
+ // 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.
1719
+ 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)));
1720
+ if (notifyBlock && usable && muxr && !reachable)
1721
+ 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
1722
  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 };
1723
+ notify: notifyBlock && usable && (!muxr || reachable) ? { url: notify.url, token_env: notify.token_env, link_base: notify.link_base, muxr } : undefined };
1671
1724
  }
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')})();`;
1673
- /** The cards waiting on the user before a write: a card waiting after the write but not before it has entered Waiting on you. */
1725
+ const MUXR_URL = 'http://127.0.0.1:8793/v1/notify';
1726
+ /** The muxr receiver's token file, read at send time and never stored: <MUXR_HOME>/notify-token, MUXR_HOME defaulting to ~/.muxr. */
1727
+ const muxrTokenFile = () => join(process.env.MUXR_HOME || join(homedir(), '.muxr'), 'notify-token');
1728
+ /** Why a notice cannot be sent yet, in plain words; undefined when its token is there. */
1729
+ const tokenMissing = (n) => n.muxr
1730
+ ? (() => { try {
1731
+ return readFileSync(muxrTokenFile(), 'utf8').trim() ? undefined : `${tilde(muxrTokenFile())} is empty`;
1732
+ }
1733
+ catch {
1734
+ return `${tilde(muxrTokenFile())} is not there yet (the muxr host writes it when it updates)`;
1735
+ } })()
1736
+ : n.token_env && !process.env[n.token_env] ? `${n.token_env} is not set` : undefined;
1737
+ 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')})();`;
1738
+ /** Cards worth a notice: those waiting on the user, and a new evidence update (shown, never waiting). */
1739
+ const notifies = (d) => waits(d) || (d.type === 'evidence' && !d.dismissed);
1740
+ /** The cards that would notify before a write: one that notifies after the write but not before it is new to the user. */
1674
1741
  function waitingKeys(file) {
1675
1742
  try {
1676
- return new Set(board(file, () => { }).filter(waits).map((d) => d.key));
1743
+ return new Set(board(file, () => { }).filter(notifies).map((d) => d.key));
1677
1744
  }
1678
1745
  catch {
1679
1746
  return undefined;
@@ -1685,9 +1752,9 @@ function waitingKeys(file) {
1685
1752
  function notifyEntered(file, named, before) {
1686
1753
  try {
1687
1754
  const so = dirname(named), notify = before ? readSetup(so).notify : undefined;
1688
- if (!before || !notify || (notify.token_env && !process.env[notify.token_env]))
1755
+ if (!before || !notify || tokenMissing(notify))
1689
1756
  return;
1690
- const { items } = readItems(file), fresh = board(file, () => { }).filter((d) => waits(d) && !before.has(d.key) && !items.some((i) => i?.key === d.key && i.announced));
1757
+ const { items } = readItems(file), fresh = board(file, () => { }).filter((d) => notifies(d) && !before.has(d.key) && !items.some((i) => i?.key === d.key && i.announced));
1691
1758
  if (!fresh.length)
1692
1759
  return;
1693
1760
  const at = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
@@ -1700,13 +1767,18 @@ function notifyEntered(file, named, before) {
1700
1767
  }
1701
1768
  catch { }
1702
1769
  }
1770
+ /** The JSON one card sends: sign-off's own shape, or muxr's {title (1-80 chars), body, url, id}. */
1771
+ function noticeBody(notify, d) {
1772
+ const kind = d.kind ?? { decision: 'ask', explainer: d.options.length ? 'ask' : 'report' }[d.type] ?? d.type;
1773
+ 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)}`;
1774
+ if (notify.muxr)
1775
+ return { title: [...d.title].slice(0, 80).join('') || d.key, body: text, url, id: d.key };
1776
+ return { v: 1, source: 'sign-off', id: d.key, kind: ['ask', 'plan', 'design', 'evidence', 'report'].includes(kind) ? kind : 'ask',
1777
+ title: [...d.title].slice(0, 120).join(''), text, url, at: d.filed ?? new Date().toISOString() };
1778
+ }
1703
1779
  function sendNotice(so, notify, d) {
1704
1780
  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') } });
1781
+ 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
1782
  child.on('error', () => warn(`notification sender could not start for card ${d.key}`));
1711
1783
  child.unref();
1712
1784
  }
@@ -1722,7 +1794,7 @@ function init(args) {
1722
1794
  }
1723
1795
  catch { }
1724
1796
  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`],
1797
+ [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
1798
  [board, `{"schema": 2, "items": []}\n`],
1727
1799
  [join(so, '.gitignore'), GITIGNORE],
1728
1800
  ];
@@ -1794,6 +1866,43 @@ function report(args) {
1794
1866
  : `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
1867
  ]);
1796
1868
  }
1869
+ /** `notify check|test`: whether device notifications can work from this setup, and one real test notice. Never prints the token. */
1870
+ async function notify(args) {
1871
+ const mode = ['check', 'test'].includes(args[0]) ? args.shift() : 'check';
1872
+ const { dir: folder, given } = repoDir('notify', args), dir = given ? folder : setupFolderHere() ?? folder, so = join(dir, '.sign-off');
1873
+ let setup;
1874
+ try {
1875
+ setup = readSetup(so);
1876
+ }
1877
+ catch {
1878
+ throw fail(`no readable config.yaml in ${tilde(so)}`, 'NOT_FOUND', `Run \`sign-off-axi init ${shellPath(dir)}\``);
1879
+ }
1880
+ const config = shellPath(join(so, 'config.yaml'));
1881
+ if (setup.problems.some((p) => p.startsWith('notify')))
1882
+ 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`);
1883
+ const n = setup.notify;
1884
+ if (!n)
1885
+ 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}`]);
1886
+ const why = tokenMissing(n);
1887
+ if (why)
1888
+ 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`);
1889
+ if (mode === 'check')
1890
+ 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']);
1891
+ const probe = { key: 'sign-off-test', title: 'sign-off test', about: 'Device notifications from sign-off work.', type: 'decision', options: [] };
1892
+ let status;
1893
+ try {
1894
+ const token = n.muxr ? readFileSync(muxrTokenFile(), 'utf8').trim() : n.token_env ? process.env[n.token_env] : undefined;
1895
+ 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;
1896
+ }
1897
+ catch {
1898
+ 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');
1899
+ }
1900
+ if ([401, 403].includes(status))
1901
+ 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}`);
1902
+ if (status >= 300)
1903
+ throw fail(`the receiver answered HTTP ${status}, so the test notice was not accepted`, 'VALIDATION_ERROR', 'Check the receiver is the notification endpoint');
1904
+ return toon({ notifications: 'on', test: 'sent', http: status }, ['Check the device for the "sign-off test" notice']);
1905
+ }
1797
1906
  async function doctor(args) {
1798
1907
  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
1908
  const checks = [];
@@ -1861,8 +1970,9 @@ async function doctor(args) {
1861
1970
  check('config', `board ${basename(board)}, port ${port}`);
1862
1971
  else
1863
1972
  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`);
1973
+ const why = setup.notify && tokenMissing(setup.notify);
1974
+ if (setup.notify && why)
1975
+ 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
1976
  // A hosts: name is only of use when it leads to the bind address.
1867
1977
  const astray = [];
1868
1978
  for (const h of setup.hosts) {
@@ -1987,11 +2097,12 @@ async function doctor(args) {
1987
2097
  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
2098
  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
2099
  }
1990
- let file = '', evidenceRoot = '', bind = '127.0.0.1', hosts = [], onAnswer = [];
2100
+ let file = '', config = '', evidenceRoot = '', bind = '127.0.0.1', hosts = [], onAnswer = [];
1991
2101
  async function serve(args) {
1992
2102
  const parsed = parse('serve', args, BOARD_FLAGS), port = boardPort(parsed.named, parsed.values.port);
1993
2103
  ready(parsed.file, parsed.on, parsed.values['evidence-root']);
1994
2104
  bind = bindOf(parsed.named, parsed.values.bind);
2105
+ config = join(dirname(parsed.named), 'config.yaml');
1995
2106
  ({ hosts = [], onAnswer = [] } = setupOf(parsed.named) ?? {});
1996
2107
  let warned = false;
1997
2108
  const sweep = async (phase) => {
@@ -2064,6 +2175,7 @@ async function exportBoard(args) {
2064
2175
  const parsed = parse('export', args, { out: { type: 'string' }, 'evidence-root': { type: 'string' } });
2065
2176
  ready(parsed.file, parsed.on, parsed.values['evidence-root']);
2066
2177
  [file, evidenceRoot] = [parsed.file, parsed.values['evidence-root'] ? resolve(parsed.values['evidence-root']) : ''];
2178
+ config = join(dirname(parsed.named), 'config.yaml');
2067
2179
  guardReady = warmGuard();
2068
2180
  await guardReady;
2069
2181
  const { file: _, messages: _messages, annotations: _annotations, ...frozen } = state(), files = {}, q = encodeURIComponent;
@@ -2182,6 +2294,22 @@ const warmGuard = () => new Promise((done) => {
2182
2294
  };
2183
2295
  pass(true);
2184
2296
  });
2297
+ /** 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
2298
+ * 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. */
2299
+ const LEASHES = ['ask', 'matters', 'act'];
2300
+ const configLine = (text, key) => new RegExp(`^[ \\t]*${key}[ \\t]*:([^#\\n]*)`, 'm').exec(text)?.[1].trim() || undefined;
2301
+ /** The user's name and leash; none for a board with no config.yaml beside it. */
2302
+ function profile() {
2303
+ let text;
2304
+ try {
2305
+ text = readFileSync(config, 'utf8');
2306
+ }
2307
+ catch {
2308
+ return {};
2309
+ }
2310
+ const leash = configLine(text, 'leash') ?? '';
2311
+ return { name: configLine(text, 'name')?.slice(0, 40), leash: LEASHES.includes(leash) ? leash : 'matters' };
2312
+ }
2185
2313
  const state = () => {
2186
2314
  reap();
2187
2315
  // 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 +2324,7 @@ const state = () => {
2196
2324
  ...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
2197
2325
  readonly: readOnly(),
2198
2326
  answers_only: answersOnly(),
2327
+ ...profile(),
2199
2328
  answers,
2200
2329
  seen: Object.values(answers).filter(taken).map((a) => a.key),
2201
2330
  rounds,
@@ -2372,7 +2501,7 @@ async function route(req) {
2372
2501
  clearTimeout(timer);
2373
2502
  return [200, state()];
2374
2503
  }
2375
- if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin'].includes(req.url ?? ''))
2504
+ if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin', '/api/leash'].includes(req.url ?? ''))
2376
2505
  return [404, { error: 'Not found' }];
2377
2506
  if (readOnly())
2378
2507
  return [403, { error: 'This board is read-only' }];
@@ -2440,6 +2569,21 @@ async function route(req) {
2440
2569
  return board(file).find((x) => x.key === key)?.answer ? [200, { state: state() }] : [502, { error: e.message, state: state() }];
2441
2570
  }
2442
2571
  }
2572
+ if (req.url === '/api/leash') {
2573
+ const { leash } = JSON.parse(body) ?? {};
2574
+ if (!LEASHES.includes(leash))
2575
+ return [400, { error: `Send a leash of ${LEASHES.join(', ')}` }];
2576
+ let text;
2577
+ try {
2578
+ text = readFileSync(config, 'utf8');
2579
+ }
2580
+ catch {
2581
+ return [409, { error: 'This board has no config.yaml to keep the leash in; run sign-off-axi init' }];
2582
+ }
2583
+ const line = /^[ \t]*leash[ \t]*:.*$/m;
2584
+ writeFileSync(config, line.test(text) ? text.replace(line, `leash: ${leash}`) : `${text.replace(/\n?$/, '\n')}leash: ${leash}\n`);
2585
+ return [200, state()];
2586
+ }
2443
2587
  if (req.url === '/api/dismiss') {
2444
2588
  const { keys, dismissed } = JSON.parse(body) ?? {};
2445
2589
  if (!Array.isArray(keys) || !keys.length || keys.length > 200 || typeof dismissed !== 'boolean')
@@ -2447,8 +2591,6 @@ async function route(req) {
2447
2591
  const have = new Map(board(file).map((x) => [x.key, x]));
2448
2592
  if (!keys.every((k) => isKey(k) && have.has(k)))
2449
2593
  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
2594
  appendDismissed(file, [...new Set(keys)], dismissed);
2453
2595
  return [200, state()];
2454
2596
  }
@@ -2675,7 +2817,7 @@ await runAxiCli({
2675
2817
  topLevelHelp: TOP_HELP,
2676
2818
  getCommandHelp: (cmd) => HELP[cmd],
2677
2819
  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]) => {
2820
+ 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), dismiss: axi('dismiss', dismiss(false)), restore: axi('restore', dismiss(true)), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
2679
2821
  if (rest.length || (name && !HELP[name]))
2680
2822
  throw fail(`unknown command or argument: ${[name, ...rest].join(' ')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi --help` to see the commands');
2681
2823
  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');