sign-off-axi 0.5.7 → 0.5.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
@@ -87,6 +87,7 @@ One line on what the agent runs per kind; the full reference is [docs/question-k
87
87
  |---|---|---|
88
88
  | Yes or no, pick one | `raise --ask-ref <captain-message-id-or-hold-id> --title ... --recommend ...` | buttons, the recommended one first |
89
89
  | Design pick (mocks A/B/C) | `post --type design --mock a=... --mock b=... --link a=<url>` | mocks side by side, an Open live link under each option that has a `--link`, a note on any part |
90
+ | Design review (screens of a product) | `post --kind design --from design.json` | nothing in Decide: it waits under **Designs** for Main to review; once Main passes every screen, the lead posts one card per product that opens them all, phone and desktop, and Main's `promote <key>` puts that card in Decide |
90
91
  | Proof update | `post --type evidence --media shot.png` | the capture, its result, Pass / Fail / Needs work |
91
92
  | Proof with a file to install | `post --type evidence --media shot.png --attach app.apk` | the capture, and the file with its size and a Download button (any type, up to 1 GB, `SIGN_OFF_ATTACH_MAX_MB` to change) |
92
93
  | Explainer video | `post --type explainer --media clip.mp4 --chapter 0:00=Start` | a player with chapter buttons, then Yes or No |
@@ -118,6 +119,7 @@ sign-off stands alone: the CLI and the skill need no other tool and no superviso
118
119
  - [Question kinds](docs/question-kinds.md): the reference for each kind.
119
120
  - [Contract](docs/contract.md): the message rules agents post by.
120
121
  - [File formats](docs/file-formats.md): board and message files.
122
+ - [Vision](VISION.md): who sign-off is for and where it is going.
121
123
 
122
124
  ## Development
123
125
 
package/dist/cli.js CHANGED
@@ -7,15 +7,17 @@ import { accessSync, chmodSync, closeSync, constants, copyFileSync, createReadSt
7
7
  import { lookup } from 'node:dns/promises';
8
8
  import { availableParallelism, homedir, tmpdir, userInfo } from 'node:os';
9
9
  import { Worker } from 'node:worker_threads';
10
+ import { brotliCompressSync, constants as zlib, gzipSync } from 'node:zlib';
10
11
  import { pipeline } from 'node:stream';
11
12
  import { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
12
13
  import { parseArgs } from 'node:util';
13
14
  import { encode } from '@toon-format/toon';
14
15
  import { acknowledgeOwner, checkOwner, deliveries, dispatchOwners, registerOwner } from './owner.js';
15
16
  import { AxiError, runAxiCli } from 'axi-sdk-js';
16
- import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendPartComment, annotationsPath, markCommentsRead, readPartComments, replyComment, asks, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, appendOpened, readOpened, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, reading, readDismissed, readItems, sameDecision, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
17
+ import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendPartComment, annotationsPath, markCommentsRead, readPartComments, replyComment, asks, appendAnswer, appendDismissed, appendPromoted, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, appendOpened, readOpened, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, reading, readDismissed, readItems, readPromoted, sameDecision, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
17
18
  import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, diagramPages, diagramViews, guided, mediaFiles, parseMessage, reportPage } from './contract.js';
18
19
  import { faults, isStill } from './evidence-text.js';
20
+ import { copiesOf } from './images.js';
19
21
  import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
20
22
  const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
21
23
  const CONTRACT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/contract.md';
@@ -30,8 +32,8 @@ const AGENTS = {
30
32
  'gemini-cli': { bin: 'gemini', dir: '.agents/skills', home: join(homedir(), '.gemini', 'skills') },
31
33
  };
32
34
  const TOP_HELP = `usage: sign-off-axi [command] [board.json] [flags]
33
- commands[23]:
34
- (none)=list, raise, post, answers, wait, poll, deeper, owner, open, status, stop, serve, export, init, report, signin, dismiss, restore, refile, doctor, notify, validate, help
35
+ commands[24]:
36
+ (none)=list, raise, post, answers, wait, poll, deeper, owner, open, status, stop, serve, export, init, report, signin, dismiss, restore, promote, refile, doctor, notify, validate, help
35
37
  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
36
38
  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
37
39
  examples:
@@ -225,7 +227,7 @@ An empty items list removes that filer's items.
225
227
  --type diagram and --type interactive are a decision shown with a picture: --view names one board-relative file,
226
228
  an image (.svg, .png, .jpg, .jpeg, .webp, 5 MB or less) shown at card width for a diagram, or one self-contained .html page for interactive.
227
229
  The page runs in a sandboxed frame: no fetch, no remote files, no access to the dashboard or its tab. Answers work as for a decision.
228
- --type evidence posts the next numbered update (key update-<number>): 1 to ${MEDIA_MAX} screenshots or recordings (.png, .jpg, .jpeg, .webp, .mp4, .webm, 50 MB each), copied into evidence/<number>/ next to the board, with a short note and an optional commit or PR link. A still that is not a picture of the product is refused: mostly text (a log, receipt or summary), a colour-bar test card or a placeholder card. Post a real capture of the running product; a colour-bar or placeholder card already posted is not shown on the board.
230
+ --type evidence posts the next numbered update (key update-<number>): 1 to ${MEDIA_MAX} screenshots or recordings (.png, .jpg, .jpeg, .webp, .mp4, .webm, 50 MB each), copied into evidence/<number>/ next to the board (each still also gets a lighter copy and a thumbnail there, which the board shows; the original stays and opens full size), with a short note and an optional commit or PR link. A still that is not a picture of the product is refused: mostly text (a log, receipt or summary), a colour-bar test card or a placeholder card. Post a real capture of the running product; a colour-bar or placeholder card already posted is not shown on the board.
229
231
  --attach <file> (up to ${ATTACH_MAX}, any file type, such as an Android .apk) adds a file the user downloads from the card, over the board link, on a phone or tablet. It is copied (never read into memory) into evidence/<number>/ and streamed on download. A file over ${ATTACH_DEFAULT_MB} MB is refused; raise the limit with SIGN_OFF_ATTACH_MAX_MB=<megabytes>. An update may have only --attach files and no --media.
230
232
  The user gives a verdict, pass, fail or needs-work, with an optional note; read it with answers or wait --key update-<number>.
231
233
  --type explainer is a decision shown with a narrated video you made: one --media .mp4 or .webm (50 MB or less), copied into evidence/<number>/ like evidence
@@ -254,6 +256,7 @@ examples:
254
256
  sign-off-axi post --kind plan --from plan.json --filer agent-41
255
257
  sign-off-axi post --kind design --from design.json --filer agent-41
256
258
  A design message lists project journeys, per-screen image or HTML view variants, and a card preview; post checks every asset stays under the board folder.
259
+ A design shows under Designs for Main to review, never in the user's Decide or any count of asks, until Main runs promote (see promote --help).
257
260
  sign-off-axi post .sign-off/decisions.json --type interactive --title "Does the sign-in flow feel right?" --view mocks/sign-in/demo.html --option yes=Yes --option no="Needs work" --recommend yes
258
261
  sign-off-axi post --type evidence --title "Sign-in screen on a phone" --about "The new screen loads in 1 second." --media phone.png --media flow.mp4 --link https://github.com/acme/app/pull/12
259
262
  sign-off-axi post --type evidence --title "Debug build for the phone" --about "The app installs and signs in." --media phone.png --attach app-debug.apk
@@ -286,6 +289,15 @@ examples:
286
289
  `,
287
290
  restore: `usage: sign-off-axi restore <key> [board.json]
288
291
  Brings back a card that dismiss hid. See \`sign-off-axi dismiss --help\`.
292
+ `,
293
+ promote: `usage: sign-off-axi promote <key> [board.json] [--undo]
294
+ For Main only, never a lead. A design review a lead posts (post --kind design) shows under Designs, where Main reviews it; it never waits in Decide or counts as the user's ask.
295
+ Once Main has passed every screen, the lead posts one design per product that opens all its screens, phone and desktop, and Main promotes that one card:
296
+ it then waits in Decide, counts there and notifies like any ask until the user answers it. --undo puts it back under Designs only.
297
+ It writes one line to <board>.promoted.jsonl beside the board; no post, feed or board request writes that file.
298
+ examples:
299
+ sign-off-axi promote fleet-dashboard-prototype
300
+ sign-off-axi promote fleet-dashboard-prototype --undo
289
301
  `,
290
302
  report: `usage: sign-off-axi report init <folder> [--plan] [--page]
291
303
  Writes the guided template to fill: <folder>/report.json, or <folder>/plan.json with --plan. A file already there is kept.
@@ -572,6 +584,8 @@ const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --fil
572
584
  : `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
573
585
  /** Still waiting on the user: not answered, not dismissed, not an expired sign-in, and something to answer (an item with no options and no reply is only shown). */
574
586
  const waits = (d) => asks(d) && !lapsed(d) && !d.dismissed && awaitsVerdict(d.type, d.answer);
587
+ /** Waiting on the user, as Decide counts it: a design review waits on Main instead until Main promotes it (web/index.html area() says the same); its poster's wait still waits for the verdict. */
588
+ const asksUser = (d) => waits(d) && (d.kind !== 'design' || !!d.promoted);
575
589
  /** The items that archive after a day: all of them, except on a board another program feeds, which owns what it shows. */
576
590
  const archivable = (file, decisions) => ((x) => (x.readonly === true || x.answers_only === true ? [] : decisions))(readItems(file).extra);
577
591
  /** The board's decisions with their latest answer; a missing board file is an empty board.
@@ -584,7 +598,8 @@ function board(file, say = warn) {
584
598
  for (const key of Object.keys(answers))
585
599
  if (!decisions.some((d) => d.key === key))
586
600
  say(`left out the answer for ${key}: that question is no longer in ${basename(file)}`);
587
- return decisions.map((d) => ({ ...d, answer: Object.hasOwn(answers, d.key) ? answers[d.key] : undefined, dismissed: hidden.has(d.key) }));
601
+ const promoted = new Set(readPromoted(file, say));
602
+ return decisions.map((d) => ({ ...d, answer: Object.hasOwn(answers, d.key) ? answers[d.key] : undefined, dismissed: hidden.has(d.key), ...(promoted.has(d.key) && { promoted: true }) }));
588
603
  }
589
604
  function ready(file, on, root) {
590
605
  if (!existsSync(file))
@@ -604,7 +619,7 @@ function ready(file, on, root) {
604
619
  }
605
620
  function list(args) {
606
621
  const { file, values, on } = parse('list', args, TIERS);
607
- const rows = board(file), waiting = rows.filter(waits), answered = rows.filter((d) => d.answer && !awaitsVerdict(d.type, d.answer) && !d.dismissed).length;
622
+ const rows = board(file), waiting = rows.filter(asksUser), answered = rows.filter((d) => d.answer && !awaitsVerdict(d.type, d.answer) && !d.dismissed).length;
608
623
  if (values.json)
609
624
  return JSON.stringify({ board: file, decisions: rows.map(({ dismissed, ...d }) => ({ ...d, ...(d.type === 'signin' && { link: undefined }), ...(dismissed && { dismissed }) })) });
610
625
  const help = [
@@ -614,7 +629,7 @@ function list(args) {
614
629
  ];
615
630
  const where = tilde(file) + (existsSync(file) ? '' : ' (no file yet; raise creates it)');
616
631
  if (values.full) {
617
- 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 ?? '',
632
+ return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: awaitsVerdict(d.type, d.answer) ? (d.dismissed ? 'dismissed' : d.type === 'evidence' ? 'shown' : d.kind === 'design' && !d.promoted ? 'in review' : 'waiting') : d.answer.answer, title: d.title, about: d.about ?? '', detail: d.detail ?? '',
618
633
  recommend: d.recommend_value ?? '', default_if_silent: d.default_if_silent ?? '', close: d.close })) }, help);
619
634
  }
620
635
  return toon({
@@ -1200,6 +1215,24 @@ function dismiss(restoring) {
1200
1215
  });
1201
1216
  };
1202
1217
  }
1218
+ /** promote <key> [board.json] [--undo]: Main puts a design review it passed in front of the user, in Decide; --undo takes it back to Designs. */
1219
+ function promote(args) {
1220
+ const at = args.findIndex((a) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json')), key = args[at];
1221
+ if (at < 0 || !isKey(key))
1222
+ throw fail('promote needs a design card key', 'VALIDATION_ERROR', 'sign-off-axi promote <key> [board.json]');
1223
+ const { file, named, values, on } = parse('promote', args.filter((_, i) => i !== at), { undo: { type: 'boolean' } });
1224
+ return locked(`${file}.lock`, () => {
1225
+ const d = board(file).find((x) => x.key === key);
1226
+ if (!d)
1227
+ throw fail(`no card with key ${key} on ${tilde(file)}`, 'NOT_FOUND', `Run \`sign-off-axi list${on} --full\` to see the keys`);
1228
+ if (d.kind !== 'design')
1229
+ throw fail(`${key} is not a design review`, 'VALIDATION_ERROR', 'Only a design posted with post --kind design is promoted; every other ask already waits in Decide');
1230
+ const before = waitingKeys(file);
1231
+ appendPromoted(file, key, !values.undo);
1232
+ notifyEntered(file, named, before);
1233
+ return toon({ board: tilde(file), [values.undo ? 'unpromoted' : 'promoted']: [key] }, [values.undo ? `It shows under Designs only. Run \`sign-off-axi promote${on} ${key}\` to put it in Decide again` : `It waits in Decide. Run \`sign-off-axi promote${on} ${key} --undo\` to take it back`]);
1234
+ });
1235
+ }
1203
1236
  /** refile <key> [board.json] --media <file>...: an old proof posted as a Pass/Fail ask becomes an evidence update (title and note kept), and the ask is dismissed. */
1204
1237
  async function refile(args) {
1205
1238
  const at = args.findIndex((a) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json')), key = args[at];
@@ -1417,11 +1450,23 @@ function postMedia(file, named, on, values, type, filer, message) {
1417
1450
  }
1418
1451
  const key = own || `update-${number}`, dir = join(store, String(number));
1419
1452
  let media, attachments = [];
1453
+ const full = [];
1420
1454
  try {
1421
1455
  media = sources.map((src, i) => {
1422
1456
  const bytes = readFileSync(src), path = `evidence/${number}/${i + 1}${extname(src).toLowerCase()}`;
1423
1457
  writeFileSync(join(dir, `${i + 1}${extname(src).toLowerCase()}`), bytes, { mode: 0o600 });
1424
- return { path, sha256: sha256(bytes), bytes: bytes.length };
1458
+ // The original stays as it was posted; the board shows a lighter copy of a still where one weighs less.
1459
+ const made = isStill(src) ? copiesOf(bytes, extname(src)) : {}, refs = {};
1460
+ for (const size of ['light', 'thumb']) {
1461
+ const c = made[size];
1462
+ if (!c)
1463
+ continue;
1464
+ writeFileSync(join(dir, `${i + 1}-${size}${c.ext}`), c.bytes, { mode: 0o600 });
1465
+ refs[size] = { path: `evidence/${number}/${i + 1}-${size}${c.ext}`, sha256: sha256(c.bytes), bytes: c.bytes.length, width: c.width, height: c.height };
1466
+ }
1467
+ if (made.reason)
1468
+ full.push(`Capture ${i + 1} shows at full size: ${made.reason}`);
1469
+ return { path, sha256: sha256(bytes), bytes: bytes.length, ...refs };
1425
1470
  });
1426
1471
  // An attachment is copied by the kernel and hashed in chunks: the file is never held in memory.
1427
1472
  attachments = attached.map(({ src, ext, name }, i) => {
@@ -1459,7 +1504,7 @@ function postMedia(file, named, on, values, type, filer, message) {
1459
1504
  notifyEntered(file, named, before);
1460
1505
  if (explainer)
1461
1506
  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)]);
1462
- return toon({ board: tilde(file), posted: [{ key, number, media: media.length, ...(attachments.length && { attached: attachments.length }), title, ...(message && { status: 'posted' }) }] });
1507
+ return toon({ board: tilde(file), posted: [{ key, number, media: media.length, ...(attachments.length && { attached: attachments.length }), title, ...(message && { status: 'posted' }) }] }, full);
1463
1508
  });
1464
1509
  }
1465
1510
  /** 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. */
@@ -1957,7 +2002,7 @@ const skillInstalled = (dir, agent) => [join(dir, AGENTS[agent].dir), AGENTS[age
1957
2002
  .some((skills) => existsSync(join(skills, 'sign-off-axi', 'SKILL.md')));
1958
2003
  const SKILL_ADD = ['-y', 'skills', 'add', 'umeranjum17/sign-off', '--skill', 'sign-off-axi', '-y', '-a'];
1959
2004
  const skillAdd = (agents) => `npx ${[...SKILL_ADD, ...agents].join(' ')}`;
1960
- const GITIGNORE = 'mocks/\nevidence/\n*.answers.jsonl\n*.rounds.jsonl\n*.messages.jsonl\n*.annotations.jsonl\n*.seen.jsonl\n*.dismissed.jsonl\n*.reads.jsonl\n*.deeper.jsonl\n*.owners.json\n*.delivery.jsonl\n*.board.pid\n*.board.log\nnotify.log\n*.token\n*.export.html\n';
2005
+ const GITIGNORE = 'mocks/\nevidence/\n*.answers.jsonl\n*.rounds.jsonl\n*.messages.jsonl\n*.annotations.jsonl\n*.seen.jsonl\n*.dismissed.jsonl\n*.promoted.jsonl\n*.reads.jsonl\n*.deeper.jsonl\n*.owners.json\n*.delivery.jsonl\n*.board.pid\n*.board.log\nnotify.log\n*.token\n*.export.html\n';
1961
2006
  /** The board, port, bind and hosts in <so>/config.yaml (the `key: value` lines init writes). A bad line falls back on its own default
1962
2007
  * (decisions.json, 4410) and `problems` says what is wrong; a file that cannot be read throws. */
1963
2008
  function readSetup(so) {
@@ -2041,7 +2086,7 @@ const tokenMissing = (n) => n.muxr
2041
2086
  : n.token_env && !process.env[n.token_env] ? `${n.token_env} is not set` : undefined;
2042
2087
  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')})();`;
2043
2088
  /** Cards worth a notice: those waiting on the user, and a new evidence update (shown, never waiting). */
2044
- const notifies = (d) => waits(d) || (d.type === 'evidence' && !d.dismissed);
2089
+ const notifies = (d) => asksUser(d) || (d.type === 'evidence' && !d.dismissed);
2045
2090
  /** The cards that would notify before a write: one that notifies after the write but not before it is new to the user. */
2046
2091
  function waitingKeys(file) {
2047
2092
  try {
@@ -2502,7 +2547,7 @@ async function exportBoard(args) {
2502
2547
  let missing = 0;
2503
2548
  // The same URLs the page asks the server for, read through the same hash and path checks.
2504
2549
  const inline = async (url, query, document = false) => {
2505
- const [code, body] = await route({ method: 'GET', url }).catch(() => [404, null]);
2550
+ const [code, body] = await route({ method: 'GET', url, headers: {} }).catch(() => [404, null]);
2506
2551
  if (code !== 200) {
2507
2552
  missing++;
2508
2553
  return;
@@ -2524,8 +2569,9 @@ async function exportBoard(args) {
2524
2569
  await inline(at(d.key, '', d.view), undefined, true);
2525
2570
  if (d.preview)
2526
2571
  await inline(at(d.key, '', d.preview));
2527
- for (const i of (d.media ?? []).keys())
2528
- await inline(`/api/media?key=${q(d.key)}&i=${i}`);
2572
+ for (const [i, m] of (d.media ?? []).entries())
2573
+ for (const v of ['', ...['light', 'thumb'].filter((v) => m[v])])
2574
+ await inline(`/api/media?key=${q(d.key)}&i=${i}${v && `&v=${v}`}`);
2529
2575
  for (const m of d.evidenceCard?.media ?? [])
2530
2576
  await inline(m.url);
2531
2577
  }
@@ -2539,6 +2585,26 @@ async function exportBoard(args) {
2539
2585
  return toon({ board: tilde(parsed.file), exported: tilde(out), items: frozen.decisions.length, inlined: Object.keys(files).length, ...(missing && { missing }) }, [`Open ${tilde(out)} in a browser: one read-only file, no server needed`]);
2540
2586
  }
2541
2587
  const page = readFileSync(new URL('../web/index.html', import.meta.url));
2588
+ /** Text a browser fetches is sent compressed when it takes that: the page and the board's state are mostly text and shrink 4-8x, which on a
2589
+ * phone's network is most of the wait. Brotli at a fast level for each reply; the page itself is squeezed once, on its first request. */
2590
+ const COMPRESSIBLE = /^(text\/|application\/(json|javascript|manifest\+json)|image\/svg)/;
2591
+ const squeeze = (req, bytes) => {
2592
+ const takes = String(req.headers['accept-encoding'] ?? '');
2593
+ if (bytes.length < 1024)
2594
+ return { bytes };
2595
+ if (/\bbr\b/.test(takes))
2596
+ return { bytes: brotliCompressSync(bytes, { params: { [zlib.BROTLI_PARAM_QUALITY]: 5, [zlib.BROTLI_PARAM_SIZE_HINT]: bytes.length } }), encoding: 'br' };
2597
+ if (/\bgzip\b/.test(takes))
2598
+ return { bytes: gzipSync(bytes, { level: 6 }), encoding: 'gzip' };
2599
+ return { bytes };
2600
+ };
2601
+ const pageTag = `"${createHash('sha256').update(page).digest('hex').slice(0, 32)}"`;
2602
+ // Level 9 takes about 50 ms and level 11 over half a second for 7% less, so the page is squeezed at 9.
2603
+ const squeezed = {};
2604
+ const pageAs = (encoding) => (squeezed[encoding] ??= encoding === 'br'
2605
+ ? brotliCompressSync(page, { params: { [zlib.BROTLI_PARAM_QUALITY]: 9, [zlib.BROTLI_PARAM_SIZE_HINT]: page.length } }) : gzipSync(page, { level: 9 }));
2606
+ /** The browser keeps a reply and asks again with its tag, so an unchanged page, state or file costs a 304 and no body. */
2607
+ const unchanged = (req, tag) => req.headers['if-none-match']?.split(/\s*,\s*/).includes(tag) ?? false;
2542
2608
  /** The board's design tokens, favicon, home-screen icon and install manifest, served beside the page; see web/tokens.css and web/icon.svg. */
2543
2609
  const ICONS = Object.fromEntries([['tokens.css', 'text/css; charset=utf-8'], ['icon.svg', 'image/svg+xml'], ['apple-touch-icon.png', 'image/png'], ['icon-192.png', 'image/png'], ['icon-512.png', 'image/png'], ['icon-maskable-512.png', 'image/png'], ['manifest.webmanifest', 'application/manifest+json']]
2544
2610
  .map(([name, type]) => [`/${name}`, { type, bytes: readFileSync(new URL(`../web/${name}`, import.meta.url)) }]));
@@ -2641,15 +2707,17 @@ const state = () => {
2641
2707
  // The page marks sent comments in annotations, so answers and rounds go without theirs: a read-only board or an export carries none.
2642
2708
  const answers = Object.fromEntries(Object.entries(readAnswers(file, warn)).map(([k, { comments: _, ...a }]) => [k, a])), taken = seenBy(file);
2643
2709
  const rounds = readRounds(file, warn).map(({ comments: _, ...r }) => r);
2710
+ // The board file is read once for the whole state: it can hold thousands of items.
2711
+ const { extra } = readItems(file), fed = extra.readonly === true || extra.answers_only === true, items = loadDecisions(file);
2644
2712
  return {
2645
2713
  file,
2646
2714
  mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
2647
2715
  // Who filed an item stays in the file: the page only learns that its filer took the answer.
2648
2716
  // A sign-in's start command stays on this host too: the page only learns that a tap makes a new link, and how the last run went.
2649
- decisions: loadDecisions(file).filter((d) => !fakeEvidence(d)).map(({ filer: _, start, ...d }) => ({ ...d, ...(d.type === 'design' && answers[d.key]?.answer === 'approve' && { status: 'approved' }), ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
2717
+ decisions: items.filter((d) => !fakeEvidence(d)).map(({ filer: _, start, ...d }) => ({ ...d, ...(d.type === 'design' && answers[d.key]?.answer === 'approve' && { status: 'approved' }), ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
2650
2718
  ...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
2651
- readonly: readOnly(),
2652
- answers_only: answersOnly(),
2719
+ readonly: extra.readonly === true,
2720
+ answers_only: extra.answers_only === true,
2653
2721
  ...profile(),
2654
2722
  answers,
2655
2723
  seen: Object.values(answers).filter(taken).map((a) => a.key),
@@ -2660,19 +2728,28 @@ const state = () => {
2660
2728
  delivery: deliveries(file),
2661
2729
  rounds,
2662
2730
  messages: readMessages(file, warn),
2663
- annotations: readOnly() || answersOnly() ? [] : ((sent) => readAnnotations(file, warn).map((a) => (sent.has(a.id) ? { ...a, sent: true } : a)))(sentIds(file)),
2664
- comments: readOnly() || answersOnly() ? [] : readPartComments(file),
2665
- dismissed: readDismissed(file, answers, rounds, warn, archivable(file, loadDecisions(file))),
2731
+ annotations: fed ? [] : ((sent) => readAnnotations(file, warn).map((a) => (sent.has(a.id) ? { ...a, sent: true } : a)))(sentIds(file)),
2732
+ comments: fed ? [] : readPartComments(file),
2733
+ dismissed: readDismissed(file, answers, rounds, warn, fed ? [] : items),
2734
+ promoted: readPromoted(file, warn),
2666
2735
  };
2667
2736
  };
2668
2737
  /** A mock file's sha256 now, or null when it is gone: a comment shows only on the exact file it was made on.
2669
- * ponytail: hashes every mock on each state read; cache by mtime if boards carry many big mocks. */
2670
- const mockHash = (path) => { try {
2671
- return sha256(readFileSync(under(dirname(file), mockFile(path))));
2672
- }
2673
- catch {
2674
- return null;
2675
- } };
2738
+ * Kept per file while its size, change time and inode stay the same, so a state read does not rehash every mock. */
2739
+ const mockHashes = new Map();
2740
+ const mockHash = (path) => {
2741
+ try {
2742
+ const actual = under(dirname(file), mockFile(path)), st = statSync(actual), at = `${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`, kept = mockHashes.get(actual);
2743
+ if (kept?.at === at)
2744
+ return kept.hash;
2745
+ const hash = sha256(readFileSync(actual));
2746
+ mockHashes.set(actual, { at, hash });
2747
+ return hash;
2748
+ }
2749
+ catch {
2750
+ return null;
2751
+ }
2752
+ };
2676
2753
  /** The comments that go with a card's next answer or round: not sent yet, and made on the mock file the card shows now. */
2677
2754
  const unsent = (d) => {
2678
2755
  const sent = sentIds(file), now = new Map((d.mocks ?? []).map((m) => [m.value, mockHash(m.path)]));
@@ -2830,18 +2907,22 @@ async function route(req) {
2830
2907
  }
2831
2908
  if (req.method === 'GET' && req.url?.startsWith('/api/media?')) {
2832
2909
  const q = new URL(req.url, 'http://localhost').searchParams;
2833
- const m = loadDecisions(file).find((d) => d.key === q.get('key'))?.media?.[Number(q.get('i') || NaN)];
2834
- const type = m && mediaTypes[extname(m.path).toLowerCase()];
2910
+ // v=light or v=thumb asks for the copy post made of a still; with no v, the file as it was posted.
2911
+ const v = q.get('v'), posted = loadDecisions(file).find((d) => d.key === q.get('key'))?.media?.[Number(q.get('i') || NaN)];
2912
+ const m = v === null ? posted : v === 'light' || v === 'thumb' ? posted?.[v] : undefined, type = m && mediaTypes[extname(m.path).toLowerCase()];
2835
2913
  if (!m || !type)
2836
2914
  return [404, { error: 'Evidence is unavailable' }];
2837
2915
  try {
2838
- const actual = under(dirname(file), m.path);
2916
+ const actual = under(dirname(file), m.path), forever = q.get('h') === m.sha256.slice(0, 16);
2839
2917
  if (statSync(actual).size !== m.bytes)
2840
2918
  return [404, { error: 'Evidence file changed since it was posted' }];
2919
+ // The browser already holds this exact capture: no read and no hash.
2920
+ if (unchanged(req, `"${m.sha256.slice(0, 32)}"`))
2921
+ return [200, { media: Buffer.alloc(0), type, tag: m.sha256.slice(0, 32), forever }];
2841
2922
  const bytes = readFileSync(actual);
2842
2923
  if (sha256(bytes) !== m.sha256)
2843
2924
  return [404, { error: 'Evidence file changed since it was posted' }];
2844
- return [200, { media: bytes, type }];
2925
+ return [200, { media: bytes, type, tag: m.sha256.slice(0, 32), forever }];
2845
2926
  }
2846
2927
  catch {
2847
2928
  return [404, { error: 'Evidence is unavailable' }];
@@ -3200,7 +3281,11 @@ const server = createServer(async (req, res) => {
3200
3281
  if (req.method === 'GET' && req.url === '/favicon.ico')
3201
3282
  return res.writeHead(204).end();
3202
3283
  if (req.method === 'GET' && req.url === '/') {
3203
- return res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'self' 'unsafe-inline'" }).end(page);
3284
+ const headers = { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'self' 'unsafe-inline'", etag: pageTag, 'cache-control': 'no-cache', vary: 'accept-encoding' };
3285
+ if (unchanged(req, pageTag))
3286
+ return res.writeHead(304, headers).end();
3287
+ const takes = String(req.headers['accept-encoding'] ?? ''), encoding = /\bbr\b/.test(takes) ? 'br' : /\bgzip\b/.test(takes) ? 'gzip' : undefined, bytes = encoding ? pageAs(encoding) : page;
3288
+ return res.writeHead(200, { ...headers, ...(encoding && { 'content-encoding': encoding }), 'content-length': bytes.length }).end(bytes);
3204
3289
  }
3205
3290
  let status, body;
3206
3291
  try {
@@ -3223,19 +3308,32 @@ const server = createServer(async (req, res) => {
3223
3308
  return bytes ? pipeline(createReadStream(path, { start, end }), res, () => { }) : res.end();
3224
3309
  }
3225
3310
  if (body && typeof body === 'object' && 'media' in body) {
3226
- const { media: bytes, type, point, page } = body, media = point ? Buffer.concat([bytes, pointer, ...(page ? [page] : [])]) : bytes;
3227
- const headers = { 'content-type': type, 'x-content-type-options': 'nosniff', 'content-security-policy': FRAME_CSP, 'accept-ranges': 'bytes', 'content-length': media.length };
3311
+ const { media: bytes, type, point, page, tag: stored, forever } = body;
3312
+ const media = point ? Buffer.concat([bytes, pointer, ...(page ? [page] : [])]) : bytes, tag = `"${stored ?? sha256(media).slice(0, 32)}"`;
3313
+ // A capture named by its hash never changes, so the browser keeps it; any other file is asked again with its tag.
3314
+ const kept = { etag: tag, 'cache-control': forever ? 'private, max-age=31536000, immutable' : 'private, no-cache' };
3315
+ if (status === 200 && unchanged(req, tag))
3316
+ return res.writeHead(304, kept).end();
3317
+ const headers = { 'content-type': type, 'x-content-type-options': 'nosniff', 'content-security-policy': FRAME_CSP, 'accept-ranges': 'bytes', 'content-length': media.length, ...(status === 200 && kept) };
3228
3318
  // One byte range, so phone browsers can play and seek a recording.
3229
3319
  const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? '');
3230
- if (!range || (!range[1] && !range[2]))
3231
- return res.writeHead(status, headers).end(media);
3320
+ if (!range || (!range[1] && !range[2])) {
3321
+ const sent = COMPRESSIBLE.test(type) ? squeeze(req, media) : { bytes: media };
3322
+ return res.writeHead(status, { ...headers, 'content-length': sent.bytes.length, ...(sent.encoding && { 'content-encoding': sent.encoding, vary: 'accept-encoding' }) }).end(sent.bytes);
3323
+ }
3232
3324
  const start = range[1] ? Number(range[1]) : Math.max(0, media.length - Number(range[2]));
3233
3325
  const end = range[1] && range[2] ? Math.min(Number(range[2]), media.length - 1) : media.length - 1;
3234
3326
  if (start > end)
3235
3327
  return res.writeHead(416, { 'content-range': `bytes */${media.length}` }).end();
3236
3328
  return res.writeHead(206, { ...headers, 'content-length': end - start + 1, 'content-range': `bytes ${start}-${end}/${media.length}` }).end(media.subarray(start, end + 1));
3237
3329
  }
3238
- res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body));
3330
+ const json = Buffer.from(JSON.stringify(body));
3331
+ // The page reads the state again every 20 seconds: unchanged, that read is a 304 with no body.
3332
+ const tag = req.method === 'GET' && status === 200 ? `"${sha256(json).slice(0, 32)}"` : undefined;
3333
+ if (tag && unchanged(req, tag))
3334
+ return res.writeHead(304, { etag: tag, 'cache-control': 'no-cache' }).end();
3335
+ const sent = squeeze(req, json);
3336
+ res.writeHead(status, { 'content-type': 'application/json', 'content-length': sent.bytes.length, ...(sent.encoding && { 'content-encoding': sent.encoding }), vary: 'accept-encoding', ...(tag && { etag: tag, 'cache-control': 'no-cache' }) }).end(sent.bytes);
3239
3337
  });
3240
3338
  const argv = process.argv.slice(2);
3241
3339
  // Flags with no command belong to the default list view (sign-off-axi --json).
@@ -3248,7 +3346,7 @@ await runAxiCli({
3248
3346
  topLevelHelp: TOP_HELP,
3249
3347
  getCommandHelp: (cmd) => HELP[cmd],
3250
3348
  home: axi('list', list),
3251
- 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), owner: axi('owner', owner), deeper: axi('deeper', deeper), signin: axi('signin', signin), dismiss: axi('dismiss', dismiss(false)), restore: axi('restore', dismiss(true)), refile: axi('refile', refile), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
3349
+ 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), owner: axi('owner', owner), deeper: axi('deeper', deeper), signin: axi('signin', signin), dismiss: axi('dismiss', dismiss(false)), restore: axi('restore', dismiss(true)), promote: axi('promote', promote), refile: axi('refile', refile), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
3252
3350
  if (rest.length || (name && !HELP[name]))
3253
3351
  throw fail(`unknown command or argument: ${[name, ...rest].join(' ')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi --help` to see the commands');
3254
3352
  return name ? HELP[name] : TOP_HELP;
@@ -157,9 +157,11 @@ function convert(file) {
157
157
  });
158
158
  }
159
159
  /** Packed 8-bit RGB of a PNG, every colour type and bit depth, interlaced or not. undefined for a PNG it does not read. */
160
- function pngRgb(buf) {
160
+ const pngRgb = (buf) => pngPixels(buf, 3)?.data;
161
+ /** A PNG's pixels as packed 8-bit RGB (3 channels) or RGBA (4, with its alpha channel or palette transparency), and its size. */
162
+ export function pngPixels(buf, out) {
161
163
  try {
162
- let width = 0, height = 0, type = 0, depth = 0, interlace = 0, palette;
164
+ let width = 0, height = 0, type = 0, depth = 0, interlace = 0, palette, trns;
163
165
  const idat = [];
164
166
  for (let at = 8; at + 8 <= buf.length;) {
165
167
  const len = buf.readUInt32BE(at), name = buf.toString('latin1', at + 4, at + 8), data = buf.subarray(at + 8, at + 8 + len);
@@ -167,6 +169,8 @@ function pngRgb(buf) {
167
169
  [width, height, depth, type, interlace] = [data.readUInt32BE(0), data.readUInt32BE(4), data[8], data[9], data[12]];
168
170
  else if (name === 'PLTE')
169
171
  palette = data;
172
+ else if (name === 'tRNS')
173
+ trns = data;
170
174
  else if (name === 'IDAT')
171
175
  idat.push(data);
172
176
  else if (name === 'IEND')
@@ -177,7 +181,7 @@ function pngRgb(buf) {
177
181
  if (!channels || ![1, 2, 4, 8, 16].includes(depth) || (type === 3 && (!palette || depth > 8)))
178
182
  return undefined;
179
183
  const bits = channels * depth, bpp = Math.max(1, bits >> 3), raw = inflateSync(Buffer.concat(idat));
180
- const rgb = Buffer.alloc(width * height * 3);
184
+ const rgb = Buffer.alloc(width * height * out, 255);
181
185
  const to8 = (v) => (depth < 8 ? Math.round((v * 255) / ((1 << depth) - 1)) : v);
182
186
  let at = 0;
183
187
  for (const [x0, y0, dx, dy] of interlace ? ADAM7 : WHOLE) {
@@ -215,25 +219,31 @@ function pngRgb(buf) {
215
219
  };
216
220
  for (let y = 0; y < ph; y++) {
217
221
  for (let x = 0; x < pw; x++) {
218
- const n = x * channels, o = ((y0 + y * dy) * width + x0 + x * dx) * 3;
222
+ const n = x * channels, o = ((y0 + y * dy) * width + x0 + x * dx) * out;
219
223
  if (type === 3) {
220
- const k = sample(y, n) * 3;
224
+ const v = sample(y, n), k = v * 3;
221
225
  rgb[o] = palette[k] ?? 0;
222
226
  rgb[o + 1] = palette[k + 1] ?? 0;
223
227
  rgb[o + 2] = palette[k + 2] ?? 0;
228
+ if (out === 4)
229
+ rgb[o + 3] = trns?.[v] ?? 255;
224
230
  }
225
231
  else if (channels <= 2) {
226
232
  rgb[o] = rgb[o + 1] = rgb[o + 2] = to8(sample(y, n));
233
+ if (out === 4 && channels === 2)
234
+ rgb[o + 3] = to8(sample(y, n + 1));
227
235
  }
228
236
  else {
229
237
  rgb[o] = to8(sample(y, n));
230
238
  rgb[o + 1] = to8(sample(y, n + 1));
231
239
  rgb[o + 2] = to8(sample(y, n + 2));
240
+ if (out === 4 && channels === 4)
241
+ rgb[o + 3] = to8(sample(y, n + 3));
232
242
  }
233
243
  }
234
244
  }
235
245
  }
236
- return rgb;
246
+ return { width, height, data: rgb };
237
247
  }
238
248
  catch {
239
249
  return undefined;