sign-off-axi 0.4.6 → 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
@@ -5,14 +5,15 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
5
5
  import { spawn, spawnSync } from 'node:child_process';
6
6
  import { accessSync, closeSync, constants, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
7
7
  import { lookup } from 'node:dns/promises';
8
- import { homedir, tmpdir, userInfo } from 'node:os';
8
+ import { availableParallelism, homedir, tmpdir, userInfo } from 'node:os';
9
+ import { Worker } from 'node:worker_threads';
9
10
  import { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
10
11
  import { parseArgs } from 'node:util';
11
12
  import { encode } from '@toon-format/toon';
12
13
  import { AxiError, runAxiCli } from 'axi-sdk-js';
13
14
  import { CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
14
15
  import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, mediaFiles, parseMessage, reportPage } from './contract.js';
15
- import { faults } from './evidence-text.js';
16
+ import { faults, isStill } from './evidence-text.js';
16
17
  import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
17
18
  const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
18
19
  const CONTRACT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/contract.md';
@@ -27,8 +28,8 @@ const AGENTS = {
27
28
  'gemini-cli': { bin: 'gemini', dir: '.agents/skills', home: join(homedir(), '.gemini', 'skills') },
28
29
  };
29
30
  const TOP_HELP = `usage: sign-off-axi [command] [board.json] [flags]
30
- commands[17]:
31
- (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
32
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
33
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
34
35
  examples:
@@ -61,7 +62,7 @@ examples:
61
62
  sign-off-axi
62
63
  sign-off-axi list ./decisions.json --full
63
64
  `,
64
- 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>]
65
66
  sign-off-axi raise [board.json] --from <decisions.json> [--filer <id>]
66
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.
67
68
  raise is the alias for post --type decision: same flags, same output.
@@ -75,6 +76,7 @@ Every question meets the question standard (docs/contract.md, "Asking a good que
75
76
  yes and no are labelled Yes and No; a question never takes Done, OK or Not now buttons.
76
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:
77
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.
78
80
  The user answers with one tap and can undo it for a few seconds.
79
81
  --from adds every decision in a decisions file: ${FORMAT}
80
82
  Raising the same question again is a no-op that reports its status; a different question under a used key is refused.
@@ -285,6 +287,19 @@ It opens from disk with no server and makes no network request. --out defaults t
285
287
  examples:
286
288
  sign-off-axi export
287
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
288
303
  `,
289
304
  doctor: `usage: sign-off-axi doctor [dir]
290
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,
@@ -322,12 +337,14 @@ function fits(title, key) {
322
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`);
323
338
  }
324
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. */
325
- const ASK_FLAGS = ['why', 'outcome', 'recommend-reason', 'silent-by', 'merge'];
340
+ const ASK_FLAGS = ['why', 'outcome', 'recommend-reason', 'silent-by', 'merge', 'proof'];
326
341
  const MANY = ['option', 'outcome', 'merge'];
327
342
  /** Board types that are contract kinds: a decision (the default type) is an ask. */
328
343
  const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report', 'plan', 'design'];
329
- /** The stored item as the message it came from: the board's own `filed` stamp is not part of a message that names none. */
330
- const asSent = ({ filed: _filed, ...rest }) => rest;
344
+ /** The stored item as the message it came from: the board's own `filed` stamp is not part of a message that names none, and the notice record (`announced`) is the board's too. */
345
+ const asSent = ({ filed: _filed, announced: _announced, ...rest }) => rest;
346
+ /** A report names its own `filed` time, so only the notice record goes. */
347
+ const asReport = ({ announced: _announced, ...rest }) => rest;
331
348
  /** Items of a --from file that carry contract or kind are contract messages: each is checked against its kind's rules before anything is stored.
332
349
  * A new item of a contract kind must carry them (contract section 2). A --replace feed replaces the stored item, so an item under an existing key
333
350
  * that changed is checked again; an unchanged item, a board-format one and every item outside a --replace feed are not checked again. */
@@ -349,7 +366,7 @@ function contractItems(items, from, file, replace = false) {
349
366
  // item, a board-format one and every item outside a --replace feed are not checked again.
350
367
  const asMessage = (x) => {
351
368
  if (m?.kind !== 'evidence')
352
- return m?.kind === 'report' ? x : asSent(x);
369
+ return m?.kind === 'report' ? asReport(x) : asSent(x);
353
370
  const { type: _type, number: _number, media, evidence: run, ...message } = asSent(x);
354
371
  return { ...message, ...(media !== undefined && { media: Array.isArray(media) ? media.map((r) => r?.path) : media }), ...(run && { run }) };
355
372
  };
@@ -919,7 +936,7 @@ function postSignin(file, named, on, values, filer) {
919
936
  let status = same ? (done ? 'already done' : 'already waiting') : had ? 'new link' : 'posted';
920
937
  if (!same) {
921
938
  fits(d.title, d.key);
922
- const next = { ...item, filed: new Date().toISOString().replace(/\.\d+Z$/, 'Z') };
939
+ const next = { ...item, ...(had?.announced && { announced: had.announced }), filed: new Date().toISOString().replace(/\.\d+Z$/, 'Z') };
923
940
  if (had)
924
941
  items[at] = next;
925
942
  else
@@ -990,7 +1007,9 @@ function replace(file, named, on, from, filer) {
990
1007
  }
991
1008
  }
992
1009
  const old = existsSync(file) ? readItems(file) : undefined;
993
- let items = feed, extra = fed.extra, resulting = incoming;
1010
+ // The notice record belongs to the card key, so a feed that rewrites a card keeps it: the receiver was told once.
1011
+ const told = new Map((old?.items ?? []).filter((i) => i?.key && i.announced).map((i) => [i.key, i.announced]));
1012
+ let items = feed.map((i) => (told.has(i?.key) ? { ...i, announced: told.get(i.key) } : i)), extra = fed.extra, resulting = incoming;
994
1013
  if (filer) {
995
1014
  // The feed's items first: the board shows the file's last item first, so the other filers' items keep showing on top.
996
1015
  const fedKeys = new Set(feed.map((i) => i.key));
@@ -1067,7 +1086,7 @@ function postMedia(file, named, on, values, type, filer, message) {
1067
1086
  return path;
1068
1087
  });
1069
1088
  // A still that is not a picture of the product (text, colour bars or a placeholder) is measured before anything is copied.
1070
- const [unfit] = explainer ? [] : sources.flatMap((src) => /\.(png|jpe?g|webp)$/i.test(src) ? faults(src).map((f) => ({ src, f })) : []);
1089
+ const [unfit] = explainer ? [] : sources.flatMap((src) => isStill(src) ? faults(src).map((f) => ({ src, f })) : []);
1071
1090
  if (unfit)
1072
1091
  throw fail(UNFIT[unfit.f](tilde(unfit.src)), 'VALIDATION_ERROR', 'Post a real capture of the running product instead: a screenshot or recording of the screen the user sees');
1073
1092
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
@@ -1077,8 +1096,8 @@ function postMedia(file, named, on, values, type, filer, message) {
1077
1096
  const current = board(file), store = join(dirname(file), 'evidence'), taken = current.map((d) => Number(/^update-(\d+)$/.exec(d.key)?.[1] ?? 0));
1078
1097
  const own = message ? message.key : explainer && (values.key ?? slug(values.title)), had = message && own && items.find((x) => x?.key === own);
1079
1098
  if (had) {
1080
- // The stored item, read back as the message it came from: its run, and each media file by hash.
1081
- const asMessage = ({ type: _t, number: _n, filed: _f, evidence: run, media: kept, ...rest }) => canon({ ...rest, run, media: kept?.map((x) => x.sha256) });
1099
+ // The stored item, read back as the message it came from: its run, and each media file by hash. The notice record is the board's, not the message's.
1100
+ const asMessage = ({ type: _t, number: _n, filed: _f, announced: _a, evidence: run, media: kept, ...rest }) => canon({ ...rest, run, media: kept?.map((x) => x.sha256) });
1082
1101
  if (asMessage(had) !== canon({ ...message, media: sources.length ? sources.map((src) => sha256(readFileSync(src))) : undefined })) {
1083
1102
  throw fail(`key ${own} already holds a different message on ${tilde(file)}`, 'CONFLICT', 'Post it under a new key');
1084
1103
  }
@@ -1299,7 +1318,7 @@ function buildIncoming(values, extra, filer, file) {
1299
1318
  try {
1300
1319
  const { title, key = slug(title), about, detail, why, recommend: recommend_value, default: default_if_silent, close } = values;
1301
1320
  const ask = !Object.hasOwn(extra, 'type'), options = ask ? outcomesOf(values) : optionsOf(values);
1302
- 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 };
1303
1322
  writeFileSync(source, JSON.stringify({ schema: 2, items: [{ key, title, about, detail, ...told, options, recommend_value, default_if_silent, close, ...extra, filer }] }));
1304
1323
  const incoming = loadDecisions(source, board), raw = readItems(source).items;
1305
1324
  if (!ask)
@@ -1325,6 +1344,9 @@ const canon = (v) => JSON.stringify(v, (_, x) => (x && typeof x === 'object' &&
1325
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.
1326
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. */
1327
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`);
1328
1350
  const byKey = new Map(resulting.map((d) => [d.key, d])), before = new Map(current.map((c) => [c.key, c]));
1329
1351
  const closed = (key) => !!(before.get(key)?.answer || before.get(key)?.dismissed);
1330
1352
  const holder = (key, self) => resulting.find((x) => x.key !== self && !before.get(x.key)?.answer && x.merges?.includes(key));
@@ -1372,7 +1394,7 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
1372
1394
  // The same question from another filer, or at another time, is the same question: it keeps its first filer and time.
1373
1395
  const same = (x) => JSON.stringify({ ...x, answer: undefined, dismissed: undefined, filer: undefined, filed: undefined, options: [...x.options].sort((a, b) => a.value.localeCompare(b.value)) });
1374
1396
  // A message that names no filed time matches the one the board stamped.
1375
- const differs = () => exact ? canon({ ...items.find((x) => x?.key === d.key), ...(raw[i].filed === undefined && { filed: undefined }) }) !== canon(raw[i]) : same(had) !== same(d);
1397
+ const differs = () => exact ? canon({ ...items.find((x) => x?.key === d.key), ...(raw[i].filed === undefined && { filed: undefined }), announced: undefined }) !== canon(raw[i]) : same(had) !== same(d);
1376
1398
  if (had && differs())
1377
1399
  throw refuse(`already holds a different ${contract ? 'message' : 'question'} on ${tilde(file)}`);
1378
1400
  // Each new item records when it was raised, unless it names its own time.
@@ -1612,11 +1634,11 @@ function readSetup(so) {
1612
1634
  readFileSync(join(so, 'config.yaml'), 'utf8').split(/\r?\n/).forEach((l, n) => {
1613
1635
  // The notify block's indented lines keep their # , and } characters: only a # after a space starts a comment.
1614
1636
  if (inNotify && (!l.trim() || /^\s/.test(l))) {
1615
- 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*(.+)$/);
1616
1638
  if (field)
1617
1639
  notify[field[1]] = field[2].replace(/^(['"])(.*)\1$/, '$2');
1618
1640
  else if (text)
1619
- 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`);
1620
1642
  return;
1621
1643
  }
1622
1644
  inNotify = false;
@@ -1656,15 +1678,36 @@ function readSetup(so) {
1656
1678
  catch {
1657
1679
  return undefined;
1658
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;
1659
1686
  const sendTo = parsed(notify.url), base = parsed(notify.link_base);
1660
1687
  const usable = !!sendTo && !!base && (sendTo.protocol === 'https:' || (sendTo.protocol === 'http:' && (loopback(sendTo.hostname.replace(/^\[|\]$/g, '')) || sendTo.hostname === 'localhost')))
1661
1688
  && ['http:', 'https:'].includes(base.protocol) && (notify.token_env === undefined || /^[A-Za-z_]\w*$/.test(notify.token_env));
1662
1689
  if (notifyBlock && !usable)
1663
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');
1664
1695
  return { board: join(so, goodBoard ? config.board : 'decisions.json'), port: goodPort ? Number(port) : 4410, bind, hosts, onAnswer, goodBoard, problems,
1665
- 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 };
1666
1697
  }
1667
- 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')})();`;
1668
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. */
1669
1712
  function waitingKeys(file) {
1670
1713
  try {
@@ -1674,24 +1717,39 @@ function waitingKeys(file) {
1674
1717
  return undefined;
1675
1718
  }
1676
1719
  }
1677
- /** Announces each card that entered Waiting on you with a write. The card is already stored, so nothing here fails the command. */
1720
+ /** Announces each card that entered Waiting on you with a write. The card is already stored, so nothing here fails the command.
1721
+ * A card is announced once: the card keeps the record, so one that leaves Waiting and comes back (a new sign-in link) stays quiet,
1722
+ * the same silent return an Undo gets. */
1678
1723
  function notifyEntered(file, named, before) {
1679
1724
  try {
1680
1725
  const so = dirname(named), notify = before ? readSetup(so).notify : undefined;
1681
- if (!before || !notify || (notify.token_env && !process.env[notify.token_env]))
1726
+ if (!before || !notify || tokenMissing(notify))
1727
+ return;
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));
1729
+ if (!fresh.length)
1682
1730
  return;
1683
- for (const d of board(file, () => { }).filter((d) => waits(d) && !before.has(d.key)))
1731
+ const at = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
1732
+ for (const i of items)
1733
+ if (fresh.some((d) => d.key === i?.key))
1734
+ i.announced = at;
1735
+ writeItems(file, items);
1736
+ for (const d of fresh)
1684
1737
  sendNotice(so, notify, d);
1685
1738
  }
1686
1739
  catch { }
1687
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
+ }
1688
1750
  function sendNotice(so, notify, d) {
1689
1751
  const token = notify.token_env ? process.env[notify.token_env] : undefined;
1690
- const kind = d.kind ?? { decision: 'ask', explainer: d.options.length ? 'ask' : 'report' }[d.type] ?? d.type;
1691
- const body = JSON.stringify({ v: 1, source: 'sign-off', id: d.key, kind: ['ask', 'plan', 'design', 'evidence', 'report'].includes(kind) ? kind : 'ask',
1692
- title: [...d.title].slice(0, 120).join(''), text: [...(d.about ?? d.detail ?? d.why ?? d.title).replace(/[\r\n]+/g, ' ')].slice(0, 200).join(''),
1693
- url: `${notify.link_base.replace(/\/$/, '')}#waiting/${encodeURIComponent(d.key)}`, at: d.filed ?? new Date().toISOString() });
1694
- 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') } });
1695
1753
  child.on('error', () => warn(`notification sender could not start for card ${d.key}`));
1696
1754
  child.unref();
1697
1755
  }
@@ -1707,7 +1765,7 @@ function init(args) {
1707
1765
  }
1708
1766
  catch { }
1709
1767
  const files = [
1710
- [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`],
1711
1769
  [board, `{"schema": 2, "items": []}\n`],
1712
1770
  [join(so, '.gitignore'), GITIGNORE],
1713
1771
  ];
@@ -1779,6 +1837,43 @@ function report(args) {
1779
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`,
1780
1838
  ]);
1781
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
+ }
1782
1877
  async function doctor(args) {
1783
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)}`;
1784
1879
  const checks = [];
@@ -1846,8 +1941,9 @@ async function doctor(args) {
1846
1941
  check('config', `board ${basename(board)}, port ${port}`);
1847
1942
  else
1848
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}\``}`);
1849
- if (setup.notify?.token_env && !process.env[setup.notify.token_env])
1850
- 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`);
1851
1947
  // A hosts: name is only of use when it leads to the bind address.
1852
1948
  const astray = [];
1853
1949
  for (const h of setup.hosts) {
@@ -1972,11 +2068,12 @@ async function doctor(args) {
1972
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}`));
1973
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`]);
1974
2070
  }
1975
- let file = '', evidenceRoot = '', bind = '127.0.0.1', hosts = [], onAnswer = [];
2071
+ let file = '', config = '', evidenceRoot = '', bind = '127.0.0.1', hosts = [], onAnswer = [];
1976
2072
  async function serve(args) {
1977
2073
  const parsed = parse('serve', args, BOARD_FLAGS), port = boardPort(parsed.named, parsed.values.port);
1978
2074
  ready(parsed.file, parsed.on, parsed.values['evidence-root']);
1979
2075
  bind = bindOf(parsed.named, parsed.values.bind);
2076
+ config = join(dirname(parsed.named), 'config.yaml');
1980
2077
  ({ hosts = [], onAnswer = [] } = setupOf(parsed.named) ?? {});
1981
2078
  let warned = false;
1982
2079
  const sweep = async (phase) => {
@@ -1994,6 +2091,7 @@ async function serve(args) {
1994
2091
  tokenOf(parsed.file, true);
1995
2092
  await sweep('startup');
1996
2093
  [file, evidenceRoot] = [parsed.file, parsed.values['evidence-root'] ? resolve(parsed.values['evidence-root']) : ''];
2094
+ guardReady = warmGuard();
1997
2095
  await new Promise((ok, no) => server.once('error', no).listen(port, bind, ok)).catch((e) => {
1998
2096
  throw fail(e.code === 'EADDRINUSE' ? `port ${port} is in use; pass --port` : e.code === 'EADDRNOTAVAIL' ? `${bind} is not an address of this machine; pass another --bind` : e.message, 'SERVE_FAILED', 'Run `sign-off-axi --help` for the flags, then start it again');
1999
2097
  });
@@ -2048,6 +2146,9 @@ async function exportBoard(args) {
2048
2146
  const parsed = parse('export', args, { out: { type: 'string' }, 'evidence-root': { type: 'string' } });
2049
2147
  ready(parsed.file, parsed.on, parsed.values['evidence-root']);
2050
2148
  [file, evidenceRoot] = [parsed.file, parsed.values['evidence-root'] ? resolve(parsed.values['evidence-root']) : ''];
2149
+ config = join(dirname(parsed.named), 'config.yaml');
2150
+ guardReady = warmGuard();
2151
+ await guardReady;
2051
2152
  const { file: _, messages: _messages, annotations: _annotations, ...frozen } = state(), files = {}, q = encodeURIComponent;
2052
2153
  let missing = 0;
2053
2154
  // The same URLs the page asks the server for, read through the same hash and path checks.
@@ -2094,19 +2195,92 @@ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
2094
2195
  /** A card whose picture is a colour-bar test card or a placeholder is not shown: a card posted before the guard can hold one. Read by the
2095
2196
  * stored hash, so each picture is measured once. */
2096
2197
  const unfitMedia = new Map();
2097
- const fakeEvidence = (d) => d.type === 'evidence' && (d.media ?? []).some((m) => {
2098
- if (!unfitMedia.has(m.sha256)) {
2099
- let found;
2198
+ const fakeEvidence = (d) => d.type === 'evidence' && (d.media ?? []).some((m) => isStill(m.path) && unfitMedia.get(m.sha256) === true);
2199
+ /** Every still on the board, once, so a request never walks the media: the board's files are hash-checked and immutable. */
2200
+ const stillJobs = () => {
2201
+ const jobs = [], seen = new Set();
2202
+ for (const d of loadDecisions(file)) {
2203
+ if (d.type !== 'evidence')
2204
+ continue;
2205
+ for (const m of d.media ?? []) {
2206
+ if (!isStill(m.path) || seen.has(m.sha256) || unfitMedia.has(m.sha256))
2207
+ continue;
2208
+ seen.add(m.sha256);
2209
+ try {
2210
+ jobs.push({ path: under(dirname(file), m.path), sha256: m.sha256 });
2211
+ }
2212
+ catch { /* an absent file has no faults */ }
2213
+ }
2214
+ }
2215
+ return jobs;
2216
+ };
2217
+ /** Measures the board's stills in parallel and off the request path. A fresh board is therefore ready before it is asked for cards: the
2218
+ * page and media answer at once, and the guard's pass never blocks them. One follow-up pass takes whatever a lost worker never reported. */
2219
+ let guardReady = Promise.resolve();
2220
+ /** A board read waits this long for the warm pass, so a still that is slow or stuck never holds the cards. */
2221
+ const GUARD_WAIT = 500;
2222
+ /** A worker still measuring after this is stuck in a convert that outlived its own timeout: it is stopped and its stills stay unmeasured. */
2223
+ const WORKER_WAIT = 35_000;
2224
+ const warmGuard = () => new Promise((done) => {
2225
+ const pass = (retry) => {
2226
+ let jobs;
2100
2227
  try {
2101
- found = faults(under(dirname(file), m.path)).some((f) => f !== 'text');
2228
+ jobs = stillJobs();
2102
2229
  }
2103
2230
  catch {
2104
- return false;
2231
+ return done();
2232
+ }
2233
+ if (!jobs.length)
2234
+ return done();
2235
+ const workers = Math.max(1, Math.min(8, availableParallelism(), jobs.length));
2236
+ let left = workers;
2237
+ const ended = () => {
2238
+ if (--left)
2239
+ return;
2240
+ if (retry)
2241
+ pass(false);
2242
+ else
2243
+ done();
2244
+ };
2245
+ for (let i = 0; i < workers; i++) {
2246
+ let worker;
2247
+ try {
2248
+ worker = new Worker(new URL('./stills-worker.js', import.meta.url), { workerData: jobs.filter((_, j) => j % workers === i) });
2249
+ }
2250
+ catch {
2251
+ ended();
2252
+ continue;
2253
+ }
2254
+ let stop;
2255
+ const arm = () => {
2256
+ clearTimeout(stop);
2257
+ stop = setTimeout(() => { void worker.terminate(); }, WORKER_WAIT);
2258
+ stop.unref();
2259
+ };
2260
+ arm();
2261
+ worker.on('message', ([sha, unfit]) => { unfitMedia.set(sha, unfit); arm(); });
2262
+ worker.once('error', () => clearTimeout(stop));
2263
+ worker.once('exit', () => { clearTimeout(stop); ended(); });
2105
2264
  }
2106
- unfitMedia.set(m.sha256, found);
2107
- }
2108
- return unfitMedia.get(m.sha256);
2265
+ };
2266
+ pass(true);
2109
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
+ }
2110
2284
  const state = () => {
2111
2285
  reap();
2112
2286
  // The page marks sent comments in annotations, so answers and rounds go without theirs: a read-only board or an export carries none.
@@ -2121,6 +2295,7 @@ const state = () => {
2121
2295
  ...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
2122
2296
  readonly: readOnly(),
2123
2297
  answers_only: answersOnly(),
2298
+ ...profile(),
2124
2299
  answers,
2125
2300
  seen: Object.values(answers).filter(taken).map((a) => a.key),
2126
2301
  rounds,
@@ -2292,9 +2467,12 @@ async function route(req) {
2292
2467
  if (req.method === 'GET' && req.url === '/api/board')
2293
2468
  return [200, { file, pid: process.pid }];
2294
2469
  if (req.method === 'GET' && req.url === '/api/decisions') {
2470
+ let timer;
2471
+ await Promise.race([guardReady, new Promise((ok) => { timer = setTimeout(ok, GUARD_WAIT); })]);
2472
+ clearTimeout(timer);
2295
2473
  return [200, state()];
2296
2474
  }
2297
- 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 ?? ''))
2298
2476
  return [404, { error: 'Not found' }];
2299
2477
  if (readOnly())
2300
2478
  return [403, { error: 'This board is read-only' }];
@@ -2362,6 +2540,21 @@ async function route(req) {
2362
2540
  return board(file).find((x) => x.key === key)?.answer ? [200, { state: state() }] : [502, { error: e.message, state: state() }];
2363
2541
  }
2364
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
+ }
2365
2558
  if (req.url === '/api/dismiss') {
2366
2559
  const { keys, dismissed } = JSON.parse(body) ?? {};
2367
2560
  if (!Array.isArray(keys) || !keys.length || keys.length > 200 || typeof dismissed !== 'boolean')
@@ -2369,8 +2562,6 @@ async function route(req) {
2369
2562
  const have = new Map(board(file).map((x) => [x.key, x]));
2370
2563
  if (!keys.every((k) => isKey(k) && have.has(k)))
2371
2564
  return [404, { error: 'This item is no longer in the file' }];
2372
- if (dismissed && keys.some((k) => have.get(k)?.type === 'plan' && !have.get(k)?.answer))
2373
- return [400, { error: 'A plan waits until it is sent back, so it cannot be dismissed' }];
2374
2565
  appendDismissed(file, [...new Set(keys)], dismissed);
2375
2566
  return [200, state()];
2376
2567
  }
@@ -2597,7 +2788,7 @@ await runAxiCli({
2597
2788
  topLevelHelp: TOP_HELP,
2598
2789
  getCommandHelp: (cmd) => HELP[cmd],
2599
2790
  home: axi('list', list),
2600
- 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]) => {
2601
2792
  if (rest.length || (name && !HELP[name]))
2602
2793
  throw fail(`unknown command or argument: ${[name, ...rest].join(' ')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi --help` to see the commands');
2603
2794
  return name ? HELP[name] : TOP_HELP;