sign-off-axi 0.5.0 → 0.5.1

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
@@ -75,7 +75,7 @@ A board that fills up with noise stops being read, so the rules sit at the door.
75
75
  - A question must say why it is asked, what each answer does, which one the agent recommends and what happens if you stay quiet; one that does not is refused with the fix ([the question standard](docs/contract.md#ask-schema-1)).
76
76
  - One decision is one card: raising the same question again changes nothing, and `--merge <key>` folds an older card into the better-asked one.
77
77
  - Proof posted as a Pass/Fail question is refused, and proof that is mostly text, a placeholder or a test card is refused too: proof goes to Evidence, as a capture of the real product; a report must be a page to tap, not a wall of words.
78
- - **Dismiss** hides a card from the board, **Undo** brings it back (`sign-off-axi dismiss <key>` and `restore <key>` do the same from a shell).
78
+ - **Dismiss** hides a card from the board, **Undo** brings it back (`sign-off-axi dismiss <key>` and `restore <key>` do the same from a shell). `sign-off-axi dismiss --filer <id>` closes a finished task: every card that id still waits on leaves the board. Evidence and reports leave the main view a day after they were filed and wait under **Archived**; nothing is deleted, and Restore brings one back. A new ask that repeats an open ask on the same decision is refused, and answering one closes its twins.
79
79
 
80
80
  ## What you can ask
81
81
 
@@ -86,6 +86,7 @@ One line on what the agent runs per kind; the full reference is [docs/question-k
86
86
  | Yes or no, pick one | `raise --title ... --recommend ...` | buttons, the recommended one first |
87
87
  | 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 |
88
88
  | Proof update | `post --type evidence --media shot.png` | the capture, its result, Pass / Fail / Needs work |
89
+ | 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) |
89
90
  | Explainer video | `post --type explainer --media clip.mp4 --chapter 0:00=Start` | a player with chapter buttons, then Yes or No |
90
91
  | Report page | `report init <folder>`, then `post --type report --view ...` | a page to tap through |
91
92
  | Diagram, interactive page | `post --type diagram --view flow.svg` | the picture on the card; interactive pages run sandboxed |
package/dist/cli.js CHANGED
@@ -3,16 +3,17 @@ import { createServer } from 'node:http';
3
3
  import { isIP } from 'node:net';
4
4
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
5
5
  import { spawn, spawnSync } from 'node:child_process';
6
- import { accessSync, closeSync, constants, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
6
+ import { accessSync, chmodSync, closeSync, constants, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, readSync, realpathSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
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 { pipeline } from 'node:stream';
10
11
  import { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
11
12
  import { parseArgs } from 'node:util';
12
13
  import { encode } from '@toon-format/toon';
13
14
  import { AxiError, runAxiCli } from 'axi-sdk-js';
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';
15
- import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, mediaFiles, parseMessage, reportPage } from './contract.js';
15
+ import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, asks, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, appendRead, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, sameDecision, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
16
+ import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, guided, mediaFiles, parseMessage, reportPage } from './contract.js';
16
17
  import { faults, isStill } from './evidence-text.js';
17
18
  import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
18
19
  const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
@@ -28,8 +29,8 @@ const AGENTS = {
28
29
  'gemini-cli': { bin: 'gemini', dir: '.agents/skills', home: join(homedir(), '.gemini', 'skills') },
29
30
  };
30
31
  const TOP_HELP = `usage: sign-off-axi [command] [board.json] [flags]
31
- commands[21]:
32
- (none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, report, signin, dismiss, restore, refile, doctor, notify, validate, help
32
+ commands[22]:
33
+ (none)=list, raise, post, answers, wait, poll, deeper, open, status, stop, serve, export, init, report, signin, dismiss, restore, refile, doctor, notify, validate, help
33
34
  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
35
  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
36
  examples:
@@ -40,6 +41,7 @@ examples:
40
41
  sign-off-axi post .sign-off/decisions.json --type diagram --title "Is this the sign-in flow?" --view mocks/sign-in/flow.svg
41
42
  sign-off-axi post --type explainer --title "Is the new sign-in clear?" --about "The new sign-in has 2 steps." --media explainer.mp4 --chapter 0:00=Start
42
43
  sign-off-axi report init .sign-off/mocks/sign-in-flow
44
+ sign-off-axi deeper
43
45
  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
46
  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
47
  sign-off-axi signin done --key npm-login
@@ -77,6 +79,7 @@ Every question meets the question standard (docs/contract.md, "Asking a good que
77
79
  yes and no are labelled Yes and No; a question never takes Done, OK or Not now buttons.
78
80
  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:
79
81
  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.
82
+ A new ask that repeats an open ask on the same decision (the same answers, titles that share at least 80% of their words), from anyone, is not posted: the refusal names the card to wait on. When one is answered, every open ask on the same decision takes the answer and leaves Decide.
80
83
  --proof <key> names an evidence update already on the board that this ask rests on: Decide shows its capture and result at the top.
81
84
  The user answers with one tap and can undo it for a few seconds.
82
85
  --from adds every decision in a decisions file: ${FORMAT}
@@ -90,7 +93,8 @@ examples:
90
93
  `,
91
94
  validate: `usage: sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid]
92
95
  Checks message files against the communication contract without posting them: ${CONTRACT}
93
- A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, evidence, gate, finding, plan and design at schema 1, and report at schema 2.
96
+ A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, evidence, gate, finding and design at schema 1, report at schema 3 (schema 2 still passes) and plan at schema 2 (schema 1 still passes).
97
+ A report 3 and a plan 2 are guided pages: fields the board draws as one scroll, no page of your own.
94
98
  An evidence message's media paths read from the message file's folder: each file must be there, inside it, and 50 MB or less.
95
99
  A report is a page to tap through: its "view" (.html) and "preview" (its picture, beside it) are checked for their shape here; the files themselves are checked at post, from the board file's folder, filled with no FILL: lines left (see report init).
96
100
  A plan's "view" (.html) is checked for its shape here; the file itself is checked at post, from the board file's folder.
@@ -177,7 +181,7 @@ examples:
177
181
  sign-off-axi serve --port 0
178
182
  `,
179
183
  post: `usage: sign-off-axi post [board.json] [--type decision|design|diagram|interactive] --title "<question>" [--key <key>] [--about <text>] [--option <value>=<label>]... [--mock <value>=<path>]... [--link <value>=<url>]... [--view <path>] [--tradeoff <text>] [--recommend <value>] [--default <value>] [--close done|release] [--filer <id>]
180
- sign-off-axi post [board.json] --type evidence --title "<what this shows>" --about "<short note>" --media <file>... [--link <commit or URL>] [--detail <text>] [--filer <id>]
184
+ sign-off-axi post [board.json] --type evidence --title "<what this shows>" --about "<short note>" --media <file>... [--attach <file>]... [--link <commit or URL>] [--detail <text>] [--filer <id>]
181
185
  sign-off-axi post [board.json] --from <board file> --replace [--filer <id>]
182
186
  sign-off-axi post [board.json] --kind <kind> --from <message.json|-> [--filer <id>]
183
187
  sign-off-axi post [board.json] --type explainer --title "<question>" --media <video> [--about <summary>] [--chapter <m:ss>=<title>]... [--key <key>] [--option <value>=<label>]... [--recommend <value>]
@@ -212,6 +216,7 @@ An empty items list removes that filer's items.
212
216
  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.
213
217
  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.
214
218
  --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.
219
+ --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.
215
220
  The user gives a verdict, pass, fail or needs-work, with an optional note; read it with answers or wait --key update-<number>.
216
221
  --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
217
222
  and played on the card. --about is its short summary; up to ${CHAPTERS_MAX} --chapter m:ss=<title> (in order) are buttons that jump the video there. Answers work as for a decision.
@@ -241,6 +246,7 @@ examples:
241
246
  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.
242
247
  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
243
248
  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
249
+ 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
244
250
  sign-off-axi post --type explainer --title "Is the new sign-in clear?" --about "The new sign-in has 2 steps." --media explainer.mp4 --chapter 0:00=Start --chapter 0:12="Step 2"
245
251
  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
246
252
  sign-off-axi post --type signin --key npm-login --service npm --link https://example.com/login --unlocks "Publish the release" --waits "Main" --expires 15m
@@ -253,10 +259,14 @@ examples:
253
259
  sign-off-axi signin done --key npm-login
254
260
  `,
255
261
  dismiss: `usage: sign-off-axi dismiss <key> [board.json]
262
+ sign-off-axi dismiss --filer <id> [board.json]
256
263
  Hides a card from the board, like its Dismiss button. The agent is not told, and an ask it waits on keeps waiting.
264
+ --filer <id> closes a finished task: every card that id still has waiting leaves the board. Run it when your work ends, so nothing of yours is left on Decide.
265
+ Evidence and reports leave the main view by themselves a day after they were filed; they stay under Archived on their screen, and restore brings one back for good.
257
266
  restore <key> [board.json] brings it back. Find the key with \`sign-off-axi list --full\`.
258
267
  examples:
259
268
  sign-off-axi dismiss ship-the-new-sign-in-screen-this-week
269
+ sign-off-axi dismiss --filer agent-41
260
270
  sign-off-axi restore ship-the-new-sign-in-screen-this-week
261
271
  `,
262
272
  refile: `usage: sign-off-axi refile <key> [board.json] --media <file>... [--link <commit or URL>]
@@ -288,6 +298,17 @@ examples:
288
298
  sign-off-axi init
289
299
  sign-off-axi init ./my-project --agent opencode
290
300
  sign-off-axi init --agent codex,gemini-cli
301
+ `,
302
+ deeper: `usage: sign-off-axi deeper [board.json] [--filer <id>] [--all] [--json]
303
+ sign-off-axi deeper [board.json] --id <id> --text "<answer>" [--more]
304
+ Go deeper is the button on every part of a plan 2 or report 3. The person taps it; wait --filer wakes with the ask, or this command lists the ones nobody finished.
305
+ Answer in plain words about the part they tapped, a few lines at a time: each --more piece streams into that spot on the board while they watch, and a last call without --more ends it.
306
+ --id the ask to answer --text the answer, or - to read stdin (2000 characters or fewer per piece)
307
+ --more more pieces follow --all also the finished asks
308
+ examples:
309
+ sign-off-axi deeper
310
+ sign-off-axi deeper --id 3fa9c1 --text "I timed all three paths on 12 Oct." --more
311
+ sign-off-axi deeper --id 3fa9c1 --text "The After bars are goals, not measurements."
291
312
  `,
292
313
  poll: `usage: sign-off-axi poll [board.json] [--timeout 600] [--json]
293
314
  Blocks until the user comments on an item or queues a prompt for the agent on the page, then prints every new one once and marks it read.
@@ -403,7 +424,7 @@ function contractItems(items, from, file, replace = false) {
403
424
  }
404
425
  try {
405
426
  checkMessage(check);
406
- if (check.kind === 'report' || check.kind === 'plan')
427
+ if ((check.kind === 'report' || check.kind === 'plan') && !guided(check))
407
428
  reportPage(check, dirname(file));
408
429
  if (check.kind === 'design')
409
430
  designFiles(check, dirname(file));
@@ -520,14 +541,16 @@ function filerOf(values, session = false) {
520
541
  const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
521
542
  : `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
522
543
  /** Still waiting on the user: not answered, not dismissed, and something to answer (an item with no options and no reply is only shown). */
523
- 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');
544
+ const waits = (d) => asks(d) && !d.dismissed && awaitsVerdict(d.type, d.answer);
545
+ /** The items that archive after a day: all of them, except on a board another program feeds, which owns what it shows. */
546
+ const archivable = (file, decisions) => ((x) => (x.readonly === true || x.answers_only === true ? [] : decisions))(readItems(file).extra);
524
547
  /** The board's decisions with their latest answer; a missing board file is an empty board.
525
548
  * A dismissal hides an item from the board and is not waiting here either, checked against the same dismissed log the board reads. */
526
549
  function board(file, say = warn) {
527
550
  if (!existsSync(file))
528
551
  return [];
529
552
  const decisions = loadDecisions(file), answers = readAnswers(file, say);
530
- const hidden = new Set(readDismissed(file, answers, readRounds(file), say));
553
+ const hidden = new Set(readDismissed(file, answers, readRounds(file), say, archivable(file, decisions)));
531
554
  for (const key of Object.keys(answers))
532
555
  if (!decisions.some((d) => d.key === key))
533
556
  say(`left out the answer for ${key}: that question is no longer in ${basename(file)}`);
@@ -623,6 +646,45 @@ function eventLines(file, rows, answers, rounds) {
623
646
  ...rounds.map(({ key, note, at, comments }) => ({ at, line: { ...head('round', key), note, at, ...tail(key, comments) } }))]
624
647
  .sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)).map(({ line }) => JSON.stringify(line)).join('\n');
625
648
  }
649
+ /** Go deeper asks as the agent reads them: what part, what it is about, the question if the person typed one, and how to answer. */
650
+ function deeperOut(file, asks, on, events) {
651
+ if (events) {
652
+ const kind = kindOf(file), titles = new Map(board(file).map((d) => [d.key, d.title]));
653
+ return asks.map(({ id, key, part, about, q, at }) => JSON.stringify({ event: 'deeper', ...kind(key), key, title: titles.get(key), id, part, about, ...(q && { q }), at })).join('\n');
654
+ }
655
+ return toon({ board: tilde(file), deeper: asks.map(({ id, key, part, about, q = '', at }) => ({ id, key, part, about, q, at })) }, ['The person tapped Go deeper: answer in plain words, a few lines at a time, as the part they tapped',
656
+ `Run \`sign-off-axi deeper${on} --id <id> --text "<what you found>" --more\` for each piece, then once more without --more to finish`]);
657
+ }
658
+ /** `deeper`: the Go deeper asks nobody has finished, or with --id and --text one piece of the answer the board streams under the part the person tapped. */
659
+ async function deeper(args) {
660
+ const { file, values, on } = parse('deeper', args, { filer: { type: 'string' }, id: { type: 'string' }, text: { type: 'string' }, more: { type: 'boolean' }, all: { type: 'boolean' }, json: { type: 'boolean' } });
661
+ ready(file, on);
662
+ const filer = filerOf(values);
663
+ if (values.id === undefined) {
664
+ if (values.text !== undefined || values.more)
665
+ throw fail('--text and --more answer one ask, so they need --id', 'VALIDATION_ERROR', 'Run `sign-off-axi deeper` to see the ids');
666
+ const mine = new Set(board(file).filter((d) => filer === undefined || d.filer === filer).map((d) => d.key));
667
+ const open = readDeeper(file).filter((x) => mine.has(x.key) && (values.all || !x.done));
668
+ if (values.json)
669
+ return JSON.stringify({ deeper: open });
670
+ if (!open.length)
671
+ return toon({ board: tilde(file), deeper: '0 open' }, [`Run \`sign-off-axi wait${on}\` to be woken when the person taps Go deeper`]);
672
+ markDeeperRead(file, open.filter((x) => !x.read).map((x) => x.id));
673
+ return toon({ board: tilde(file), deeper: open.map(({ id, key, part, about, q = '', at, done }) => ({ id, key, part, about, q, at, done: !!done })) }, [`Run \`sign-off-axi deeper${on} --id <id> --text "<answer>"\` to answer one`]);
674
+ }
675
+ const ask = readDeeper(file).find((x) => x.id === values.id);
676
+ if (!ask)
677
+ throw fail(`no Go deeper ask with id ${values.id}`, 'NOT_FOUND', `Run \`sign-off-axi deeper${on}\` to see the open ones`);
678
+ if (ask.done)
679
+ throw fail(`ask ${values.id} is already answered`, 'CONFLICT', 'The person can tap Go deeper again for a follow-up');
680
+ let text = values.text;
681
+ if (text === '-')
682
+ text = readFileSync(0, 'utf8');
683
+ if (typeof text !== 'string' || !text.trim() || [...text.trim()].length > 2000)
684
+ throw fail('--text must be the answer, 2000 characters or fewer', 'VALIDATION_ERROR', 'Run `sign-off-axi deeper --help`');
685
+ answerDeeper(file, ask.id, text.trim(), !!values.more);
686
+ return toon({ id: ask.id, key: ask.key, part: ask.part, answered: values.more ? 'piece sent, more to come' : 'done' }, values.more ? [`Run \`sign-off-axi deeper${on} --id ${ask.id} --text "<next piece>" [--more]\``] : [`Run \`sign-off-axi wait${on}\` for the next answer or ask`]);
687
+ }
626
688
  async function wait(args) {
627
689
  const { file, values, on: onBoard } = parse('wait', args, { ...TIERS, events: { type: 'boolean' }, filer: { type: 'string' }, key: { type: 'string' }, timeout: { type: 'string', default: '600' }, into: { type: 'string' } });
628
690
  if (!/^\d{1,7}$/.test(values.timeout))
@@ -649,10 +711,10 @@ async function wait(args) {
649
711
  seenRounds.set(r.key, (seenRounds.get(r.key) ?? 0) + 1);
650
712
  // A save wakes the loop at once; the 1 second timer is the fallback where file events are unavailable.
651
713
  let wake = () => { }, watcher, vaultWarned = false;
652
- const name = basename(answersPath(file));
714
+ const name = basename(answersPath(file)), asking = basename(deeperPath(file));
653
715
  try {
654
716
  try {
655
- watcher = watch(dirname(file), (_, changed) => changed === name && wake()).on('error', () => { });
717
+ watcher = watch(dirname(file), (_, changed) => (changed === name || changed === asking) && wake()).on('error', () => { });
656
718
  }
657
719
  catch { }
658
720
  for (let first = true;; first = false) {
@@ -687,6 +749,16 @@ async function wait(args) {
687
749
  if (found.length) {
688
750
  return printAnswers(file, found, waiting, values, [waiting ? `Run \`sign-off-axi wait${on}\` for the next of the ${waiting} waiting` : `Run \`sign-off-axi stop${onBoard}\` to close the board`]);
689
751
  }
752
+ // A Go deeper ask on this filer's guided page wakes the wait once, and is marked read when it is handed over.
753
+ if (values.key === undefined) {
754
+ const asks = await locked(`${deeperPath(file)}.lock`, () => {
755
+ const got = readDeeper(file).filter((x) => !x.read && rows.some((d) => d.key === x.key));
756
+ markDeeperRead(file, got.map((x) => x.id));
757
+ return got;
758
+ }, 'another wait is still taking Go deeper asks');
759
+ if (asks.length)
760
+ return deeperOut(file, asks, onBoard + (filer ? ` --filer ${filer}` : ''), values.events);
761
+ }
690
762
  // Private vault expiries and refusals reach a filer wait only once its ordinary answers are out; each is taken once.
691
763
  let live = 0, unknown = false;
692
764
  if (filer !== undefined && values.key === undefined) {
@@ -815,11 +887,11 @@ const mockFile = (path) => path.split('?')[0];
815
887
  /** Posts an item. `raise` is the alias for `post --type decision`: same flags as before, same output. */
816
888
  function post(args, cmd = 'post') {
817
889
  const typed = cmd === 'post' ? { replace: { type: 'boolean' }, kind: { type: 'string' }, type: { type: 'string' }, tradeoff: { type: 'string' }, mock: { type: 'string', multiple: true },
818
- media: { type: 'string', multiple: true }, link: { type: 'string', multiple: true }, view: { type: 'string' }, preview: { type: 'string' }, chapter: { type: 'string', multiple: true },
890
+ media: { type: 'string', multiple: true }, attach: { type: 'string', multiple: true }, link: { type: 'string', multiple: true }, view: { type: 'string' }, preview: { type: 'string' }, chapter: { type: 'string', multiple: true },
819
891
  ...Object.fromEntries(SIGNIN_FIELDS.map((f) => [f, { type: 'string' }])) } : {};
820
892
  const { file, named, values, on } = parse(cmd, args, { from: { type: 'string' }, filer: { type: 'string' }, ...typed,
821
893
  ...Object.fromEntries(RAISE_FIELDS.map((f) => [f, { type: 'string', multiple: MANY.includes(f) }])) });
822
- const given = [...RAISE_FIELDS, 'type', 'tradeoff', 'mock', 'media', 'link', 'view', 'preview', 'chapter', ...SIGNIN_FIELDS].filter((f) => values[f] !== undefined);
894
+ const given = [...RAISE_FIELDS, 'type', 'tradeoff', 'mock', 'media', 'attach', 'link', 'view', 'preview', 'chapter', ...SIGNIN_FIELDS].filter((f) => values[f] !== undefined);
823
895
  // Only a design takes --link more than once (one per option); any other type keeps its one item link, the last given.
824
896
  const optionLinks = values.link ?? [];
825
897
  if (values.type !== 'design' && optionLinks.length)
@@ -855,8 +927,8 @@ function post(args, cmd = 'post') {
855
927
  throw fail(`--type must be decision, design, evidence, diagram, interactive, explainer, report or signin`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
856
928
  if (type === 'evidence' || type === 'explainer')
857
929
  return postMedia(file, named, on, values, type, filer);
858
- if (values.media !== undefined || (type !== 'design' && values.link !== undefined) || values.chapter !== undefined)
859
- throw fail('--media, --link and --chapter need --type evidence or explainer (--link also goes on --type design, one per option)', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
930
+ if (values.media !== undefined || values.attach !== undefined || (type !== 'design' && values.link !== undefined) || values.chapter !== undefined)
931
+ throw fail('--media, --attach, --link and --chapter need --type evidence or explainer (--link also goes on --type design, one per option)', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
860
932
  if (type !== 'design' && (values.tradeoff !== undefined || values.mock !== undefined))
861
933
  throw fail('--tradeoff and --mock need --type design', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
862
934
  const view = type === 'diagram' || type === 'interactive';
@@ -1002,11 +1074,21 @@ function signin(args) {
1002
1074
  function dismiss(restoring) {
1003
1075
  const cmd = restoring ? 'restore' : 'dismiss';
1004
1076
  return (args) => {
1005
- const at = args.findIndex((a) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json')), key = args[at];
1006
- if (at < 0)
1077
+ // dismiss --filer <id> closes a finished task: every card that filer still has waiting leaves the board.
1078
+ const f = restoring ? -1 : args.findIndex((a) => a === '--filer'), filer = f < 0 ? undefined : args[f + 1], rest = f < 0 ? args : args.filter((_, i) => i !== f && i !== f + 1);
1079
+ if (f >= 0 && !isKey(filer))
1080
+ throw fail('--filer needs an id of 1-128 of A-Z a-z 0-9 . _ -', 'VALIDATION_ERROR', 'Pass the id you posted with');
1081
+ const at = rest.findIndex((a) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json')), key = rest[at];
1082
+ if (at < 0 && f < 0)
1007
1083
  throw fail(`${cmd} needs a card key`, 'VALIDATION_ERROR', `sign-off-axi ${cmd} <key> [board.json]`);
1008
- const { file, on } = parse(cmd, args.filter((_, i) => i !== at), {});
1084
+ const { file, on } = parse(cmd, rest.filter((_, i) => i !== at), {});
1009
1085
  return locked(`${file}.lock`, () => {
1086
+ if (filer) {
1087
+ const keys = board(file).filter((d) => d.filer === filer && waits(d)).map((d) => d.key);
1088
+ if (keys.length)
1089
+ appendDismissed(file, keys, true);
1090
+ return toon({ board: tilde(file), dismissed: keys.length ? keys : `0 cards of ${filer} were waiting` }, keys.length ? [`Run \`sign-off-axi restore${on} <key>\` to bring one back`] : []);
1091
+ }
1010
1092
  if (!isKey(key) || !board(file).some((x) => x.key === key))
1011
1093
  throw fail(`no card with key ${key} on ${tilde(file)}`, 'NOT_FOUND', `Run \`sign-off-axi list${on} --full\` to see the keys`);
1012
1094
  appendDismissed(file, [key], !restoring);
@@ -1103,10 +1185,47 @@ const UNFIT = {
1103
1185
  'colour bars': (path) => `--media ${path} is a colour-bar test card, not evidence`,
1104
1186
  placeholder: (path) => `--media ${path} is a placeholder card, not evidence`,
1105
1187
  };
1188
+ /** The attachment size limit in bytes: SIGN_OFF_ATTACH_MAX_MB, else 1 GB. */
1189
+ function attachLimit() {
1190
+ const raw = process.env.SIGN_OFF_ATTACH_MAX_MB, mb = raw === undefined || raw === '' ? ATTACH_DEFAULT_MB : Number(raw);
1191
+ if (!(mb > 0))
1192
+ throw fail(`SIGN_OFF_ATTACH_MAX_MB=${raw} must be a number of megabytes above 0`, 'VALIDATION_ERROR', 'Unset it, or set it to a size such as 1024');
1193
+ return mb * 1024 * 1024;
1194
+ }
1195
+ /** One --attach file: it must be a real file within the limit. Its download name is made safe here (letters, digits and . _ ( ) + - only), and never names a place on disk. */
1196
+ function attachment(given) {
1197
+ const path = resolve(given);
1198
+ let info;
1199
+ try {
1200
+ info = statSync(path, { throwIfNoEntry: false });
1201
+ }
1202
+ catch (e) {
1203
+ throw fail(`--attach ${given} ${reasonOf(e.code)}`, 'NOT_FOUND', 'Name a file that is there');
1204
+ }
1205
+ if (!info?.isFile())
1206
+ throw fail(`--attach ${given} ${info || existsSync(path) ? 'is not a file' : 'is not there'}`, 'NOT_FOUND', 'Name a file that is there');
1207
+ const limit = attachLimit();
1208
+ if (info.size > limit)
1209
+ throw fail(`--attach ${tilde(path)} is ${Math.ceil(info.size / 1048576)} MB, over the ${Math.floor(limit / 1048576)} MB limit`, 'VALIDATION_ERROR', 'Raise the limit with SIGN_OFF_ATTACH_MAX_MB=<megabytes>, or attach a smaller file');
1210
+ const ext = extname(path).toLowerCase(), name = basename(path).replace(/[^\w.()+ -]/g, '_').replace(/^\.+/, '_').slice(-120);
1211
+ return { src: path, ext: /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : '.bin', name: ATTACH_NAME.test(name) ? name : 'file' };
1212
+ }
1213
+ /** The SHA-256 of a file, read in 1 MB chunks. */
1214
+ function hashFile(path) {
1215
+ const hash = createHash('sha256'), buf = Buffer.allocUnsafe(1 << 20), fd = openSync(path, 'r');
1216
+ try {
1217
+ for (let n; (n = readSync(fd, buf, 0, buf.length, null)) > 0;)
1218
+ hash.update(buf.subarray(0, n));
1219
+ }
1220
+ finally {
1221
+ closeSync(fd);
1222
+ }
1223
+ return hash.digest('hex');
1224
+ }
1106
1225
  function postMedia(file, named, on, values, type, filer, message) {
1107
1226
  fits(values.title);
1108
1227
  const explainer = type === 'explainer';
1109
- const extra = (explainer ? ['mock', 'tradeoff', 'view', 'link'] : ['key', 'option', 'recommend', 'default', 'mock', 'tradeoff', 'view', 'chapter']).find((f) => values[f] !== undefined);
1228
+ const extra = (explainer ? ['mock', 'tradeoff', 'view', 'link', 'attach'] : ['key', 'option', 'recommend', 'default', 'mock', 'tradeoff', 'view', 'chapter']).find((f) => values[f] !== undefined);
1110
1229
  if (extra)
1111
1230
  throw fail(`--${extra} does not apply to --type ${type}`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
1112
1231
  const example = explainer ? 'sign-off-axi post --type explainer --title "<question>" --about "<short summary>" --media explainer.mp4'
@@ -1115,8 +1234,10 @@ function postMedia(file, named, on, values, type, filer, message) {
1115
1234
  throw fail('--about is required for --type evidence: the short note', 'VALIDATION_ERROR', example);
1116
1235
  if (explainer && values.media?.length !== 1)
1117
1236
  throw fail('--type explainer needs one --media video (.mp4 or .webm)', 'VALIDATION_ERROR', example);
1118
- if ((!message && !values.media?.length) || values.media.length > MEDIA_MAX)
1119
- throw fail(`--type evidence needs 1 to ${MEDIA_MAX} --media files`, 'VALIDATION_ERROR', example);
1237
+ if ((!message && !values.media?.length && !values.attach?.length) || (values.media?.length ?? 0) > MEDIA_MAX)
1238
+ throw fail(`--type evidence needs 1 to ${MEDIA_MAX} --media files, or an --attach file`, 'VALIDATION_ERROR', example);
1239
+ if ((values.attach?.length ?? 0) > ATTACH_MAX)
1240
+ throw fail(`--attach takes up to ${ATTACH_MAX} files`, 'VALIDATION_ERROR', 'Zip the files together, or post a second update');
1120
1241
  const kinds = explainer ? ['.mp4', '.webm'] : Object.keys(mediaTypes), options = optionsOf(values);
1121
1242
  recommended([{ options, recommend_value: values.recommend }], true);
1122
1243
  const chapters = values.chapter?.map((c) => {
@@ -1125,7 +1246,7 @@ function postMedia(file, named, on, values, type, filer, message) {
1125
1246
  throw fail(`--chapter ${c} must be <m:ss>=<title>`, 'VALIDATION_ERROR', 'sign-off-axi post --type explainer ... --chapter 0:00=Start --chapter 0:12="Step 2"');
1126
1247
  return { at: Number(m[1]) * 60 + Number(m[2]), title: m[3] };
1127
1248
  });
1128
- const sources = values.media.map((m) => {
1249
+ const sources = (values.media ?? []).map((m) => {
1129
1250
  const path = resolve(m);
1130
1251
  let info;
1131
1252
  try {
@@ -1142,6 +1263,7 @@ function postMedia(file, named, on, values, type, filer, message) {
1142
1263
  throw fail(`--media ${tilde(path)} is over 50 MB`, 'VALIDATION_ERROR', 'Trim or compress the recording');
1143
1264
  return path;
1144
1265
  });
1266
+ const attached = (values.attach ?? []).map((a) => attachment(a));
1145
1267
  // A still that is not a picture of the product (text, colour bars or a placeholder) is measured before anything is copied.
1146
1268
  const [unfit] = explainer ? [] : sources.flatMap((src) => isStill(src) ? faults(src).map((f) => ({ src, f })) : []);
1147
1269
  if (unfit)
@@ -1178,13 +1300,20 @@ function postMedia(file, named, on, values, type, filer, message) {
1178
1300
  }
1179
1301
  }
1180
1302
  const key = own || `update-${number}`, dir = join(store, String(number));
1181
- let media;
1303
+ let media, attachments = [];
1182
1304
  try {
1183
1305
  media = sources.map((src, i) => {
1184
1306
  const bytes = readFileSync(src), path = `evidence/${number}/${i + 1}${extname(src).toLowerCase()}`;
1185
1307
  writeFileSync(join(dir, `${i + 1}${extname(src).toLowerCase()}`), bytes, { mode: 0o600 });
1186
1308
  return { path, sha256: sha256(bytes), bytes: bytes.length };
1187
1309
  });
1310
+ // An attachment is copied by the kernel and hashed in chunks: the file is never held in memory.
1311
+ attachments = attached.map(({ src, ext, name }, i) => {
1312
+ const stored = `attach-${i + 1}${ext}`, dest = join(dir, stored);
1313
+ copyFileSync(src, dest, constants.COPYFILE_EXCL);
1314
+ chmodSync(dest, 0o600);
1315
+ return { path: `evidence/${number}/${stored}`, name, sha256: hashFile(dest), bytes: statSync(dest).size };
1316
+ });
1188
1317
  }
1189
1318
  catch (e) {
1190
1319
  // A file that cannot be read after all leaves no copies behind.
@@ -1195,7 +1324,7 @@ function postMedia(file, named, on, values, type, filer, message) {
1195
1324
  const { run, media: _paths, ...fields } = message ?? {};
1196
1325
  items.push(explainer ? { key, type, title, about, detail, options, recommend_value, default_if_silent, close, chapters, media, filer, filed }
1197
1326
  : message ? { ...fields, key, type, number, ...(media.length ? { media } : {}), ...(run && { evidence: run }), filed }
1198
- : { key, type, number, title, about, detail, close, link, media, filer, filed });
1327
+ : { key, type, number, title, about, detail, close, link, ...(media.length && { media }), ...(attachments.length && { attachments }), filer, filed });
1199
1328
  // The whole board is checked before it is written, so a bad post leaves the board and evidence/ as they were.
1200
1329
  const check = `${file}.${process.pid}.check`;
1201
1330
  writeFileSync(check, JSON.stringify({ ...extra, schema: 2, items }), { mode: 0o600 });
@@ -1214,7 +1343,7 @@ function postMedia(file, named, on, values, type, filer, message) {
1214
1343
  notifyEntered(file, named, before);
1215
1344
  if (explainer)
1216
1345
  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)]);
1217
- return toon({ board: tilde(file), posted: [{ key, number, media: media.length, title, ...(message && { status: 'posted' }) }] });
1346
+ return toon({ board: tilde(file), posted: [{ key, number, media: media.length, ...(attachments.length && { attached: attachments.length }), title, ...(message && { status: 'posted' }) }] });
1218
1347
  });
1219
1348
  }
1220
1349
  /** 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. */
@@ -1259,7 +1388,7 @@ async function postKind(file, named, on, values, built) {
1259
1388
  if (m.kind === 'evidence')
1260
1389
  sources = mediaFiles(m, from === '-' ? process.cwd() : dirname(resolve(from)));
1261
1390
  // A report's or plan's page is served from the board file's folder, as --view is.
1262
- if (m.kind === 'report' || m.kind === 'plan')
1391
+ if ((m.kind === 'report' || m.kind === 'plan') && !guided(m))
1263
1392
  reportPage(m, dirname(file));
1264
1393
  if (m.kind === 'design')
1265
1394
  designFiles(m, dirname(file));
@@ -1440,6 +1569,12 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
1440
1569
  const proof = incoming.find((d) => d.type === 'decision' && !d.kind && !current.some((c) => c.key === d.key) && ['pass', 'fail'].every((v) => d.options.some((o) => o.value === v)));
1441
1570
  if (proof)
1442
1571
  throw fail(`key ${proof.key} offers Pass and Fail: a proof is not an ask and would wait on you`, 'VALIDATION_ERROR', 'Post it with sign-off-axi post --type evidence --title "<what this shows>" --about "<short note>" --media <file>');
1572
+ // One decision is one card: a new ask that repeats an open one, whoever filed it, is not posted. Merging it into that card is the way to ask it better.
1573
+ for (const d of incoming) {
1574
+ const open = current.some((c) => c.key === d.key) ? undefined : current.find((c) => waits(c) && !d.merges?.includes(c.key) && sameDecision(c, d));
1575
+ if (open)
1576
+ throw fail(`key ${d.key} asks what ${open.key} already asks, and ${open.key} still waits`, 'CONFLICT', `Wait on it: \`sign-off-axi wait${on} --key ${open.key}\`; or post this ask with --merge ${open.key} to ask it better`);
1577
+ }
1443
1578
  const raised = incoming.map((d, i) => {
1444
1579
  const had = current.find((c) => c.key === d.key);
1445
1580
  // A contract message refused for its key exits 2 like any other refusal (contract section 4); raise --title keeps exit 1.
@@ -1686,7 +1821,7 @@ const skillInstalled = (dir, agent) => [join(dir, AGENTS[agent].dir), AGENTS[age
1686
1821
  .some((skills) => existsSync(join(skills, 'sign-off-axi', 'SKILL.md')));
1687
1822
  const SKILL_ADD = ['-y', 'skills', 'add', 'umeranjum17/sign-off', '--skill', 'sign-off-axi', '-y', '-a'];
1688
1823
  const skillAdd = (agents) => `npx ${[...SKILL_ADD, ...agents].join(' ')}`;
1689
- const GITIGNORE = 'mocks/\nevidence/\n*.answers.jsonl\n*.rounds.jsonl\n*.messages.jsonl\n*.annotations.jsonl\n*.seen.jsonl\n*.dismissed.jsonl\n*.board.pid\n*.board.log\nnotify.log\n*.token\n*.export.html\n';
1824
+ 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*.board.pid\n*.board.log\nnotify.log\n*.token\n*.export.html\n';
1690
1825
  /** 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
1691
1826
  * (decisions.json, 4410) and `problems` says what is wrong; a file that cannot be read throws. */
1692
1827
  function readSetup(so) {
@@ -2364,10 +2499,13 @@ const state = () => {
2364
2499
  ...profile(),
2365
2500
  answers,
2366
2501
  seen: Object.values(answers).filter(taken).map((a) => a.key),
2502
+ seen_at: Object.fromEntries(Object.values(answers).flatMap((a) => { const t = seenAt(file)(a); return t ? [[a.key, t]] : []; })),
2503
+ reads: readReads(file),
2504
+ deeper: readDeeper(file),
2367
2505
  rounds,
2368
2506
  messages: readMessages(file, warn),
2369
2507
  annotations: readOnly() || answersOnly() ? [] : ((sent) => readAnnotations(file, warn).map((a) => (sent.has(a.id) ? { ...a, sent: true } : a)))(sentIds(file)),
2370
- dismissed: readDismissed(file, answers, rounds, warn),
2508
+ dismissed: readDismissed(file, answers, rounds, warn, archivable(file, loadDecisions(file))),
2371
2509
  };
2372
2510
  };
2373
2511
  /** A mock file's sha256 now, or null when it is gone: a comment shows only on the exact file it was made on.
@@ -2391,6 +2529,8 @@ const answersOnly = () => readItems(file).extra.answers_only === true;
2391
2529
  const mediaTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.mp4': 'video/mp4', '.webm': 'video/webm' };
2392
2530
  const mockTypes = { '.html': 'text/html', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' };
2393
2531
  /** What a mock's folder may serve beside it: pages, styles, scripts, images, fonts and clips; never the board, answers, token or config. */
2532
+ /** Download types for --attach files; any other extension downloads as application/octet-stream. */
2533
+ const attachTypes = { '.apk': 'application/vnd.android.package-archive', '.zip': 'application/zip', '.pdf': 'application/pdf', '.txt': 'text/plain; charset=utf-8', '.json': 'application/json', '.csv': 'text/csv', '.gz': 'application/gzip' };
2394
2534
  const assetTypes = { ...mediaTypes, ...mockTypes, '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript', '.gif': 'image/gif', '.woff2': 'font/woff2' };
2395
2535
  const MOCK_BYTES = 5_000_000;
2396
2536
  /** A sandboxed mock frame sends no cookie, so beyond loopback its URLs carry this pass in the path: made from the token, it opens mock folders only. */
@@ -2543,6 +2683,22 @@ async function route(req) {
2543
2683
  return [404, { error: 'Evidence is unavailable' }];
2544
2684
  }
2545
2685
  }
2686
+ if (req.method === 'GET' && req.url?.startsWith('/api/attachment?')) {
2687
+ const q = new URL(req.url, 'http://localhost').searchParams;
2688
+ const a = loadDecisions(file).find((d) => d.key === q.get('key'))?.attachments?.[Number(q.get('i') || NaN)];
2689
+ if (!a)
2690
+ return [404, { error: 'Attachment is unavailable' }];
2691
+ try {
2692
+ // Only its size is checked per request: hashing a file of hundreds of MB on every download would block the board.
2693
+ const actual = under(dirname(file), a.path);
2694
+ if (statSync(actual).size !== a.bytes)
2695
+ return [404, { error: 'Attachment changed since it was posted' }];
2696
+ return [200, { attachment: { path: actual, name: a.name, bytes: a.bytes, type: attachTypes[extname(a.path)] ?? 'application/octet-stream' } }];
2697
+ }
2698
+ catch {
2699
+ return [404, { error: 'Attachment is unavailable' }];
2700
+ }
2701
+ }
2546
2702
  if (req.method === 'GET' && req.url === '/api/board')
2547
2703
  return [200, { file, pid: process.pid }];
2548
2704
  if (req.method === 'GET' && req.url === '/api/decisions') {
@@ -2551,12 +2707,12 @@ async function route(req) {
2551
2707
  clearTimeout(timer);
2552
2708
  return [200, state()];
2553
2709
  }
2554
- if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin', '/api/leash'].includes(req.url ?? ''))
2710
+ if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin', '/api/leash', '/api/deeper', '/api/reads'].includes(req.url ?? ''))
2555
2711
  return [404, { error: 'Not found' }];
2556
2712
  if (readOnly())
2557
2713
  return [403, { error: 'This board is read-only' }];
2558
2714
  // Dismiss only hides an item on this page and sends nothing to the feeding program, so an answers-only board takes it too.
2559
- if (!['/api/answers', '/api/dismiss', '/api/signin'].includes(req.url) && answersOnly())
2715
+ if (!['/api/answers', '/api/dismiss', '/api/signin', '/api/reads'].includes(req.url) && answersOnly())
2560
2716
  return [403, { error: 'This board takes answers only' }];
2561
2717
  if (!req.headers['content-type']?.startsWith('application/json'))
2562
2718
  return [415, { error: 'Send JSON' }];
@@ -2644,6 +2800,21 @@ async function route(req) {
2644
2800
  appendDismissed(file, [...new Set(keys)], dismissed);
2645
2801
  return [200, state()];
2646
2802
  }
2803
+ if (req.url === '/api/reads' || req.url === '/api/deeper') {
2804
+ const { key, part, q } = JSON.parse(body) ?? {}, d = loadDecisions(file).find((x) => x.key === key), about = typeof part === 'string' && d ? guidePart(d, part) : undefined;
2805
+ if (!about)
2806
+ return [404, { error: 'This part is no longer on the board' }];
2807
+ if (req.url === '/api/reads') {
2808
+ appendRead(file, key, part);
2809
+ return [200, state()];
2810
+ }
2811
+ if (q !== undefined && (typeof q !== 'string' || [...q.trim()].length > NOTE_MAX))
2812
+ return [400, { error: `The question must be text of ${NOTE_MAX} characters or fewer` }];
2813
+ // One ask at a time per part: a second tap while the agent is answering keeps the first.
2814
+ if (!readDeeper(file).some((x) => x.key === key && x.part === part && !x.done))
2815
+ askDeeper(file, key, part, about.slice(0, 160), q?.trim() || undefined);
2816
+ return [200, state()];
2817
+ }
2647
2818
  if (req.url === '/api/rounds') {
2648
2819
  const { key, note = '' } = JSON.parse(body) ?? {};
2649
2820
  if (typeof note !== 'string' || [...note.trim()].length > NOTE_MAX)
@@ -2844,6 +3015,18 @@ const server = createServer(async (req, res) => {
2844
3015
  status = e instanceof InputError ? (req.method === 'GET' ? 422 : 409) : e instanceof SyntaxError ? 400 : 500;
2845
3016
  body = { error: e.message };
2846
3017
  }
3018
+ if (body && typeof body === 'object' && 'attachment' in body) {
3019
+ // Streamed from disk in the range asked for, so a file of hundreds of MB never sits in memory.
3020
+ const { path, name, bytes, type } = body.attachment;
3021
+ const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? ''), ranged = !!range && !!(range[1] || range[2]);
3022
+ const start = !ranged ? 0 : range[1] ? Number(range[1]) : Math.max(0, bytes - Number(range[2]));
3023
+ const end = ranged && range[1] && range[2] ? Math.min(Number(range[2]), bytes - 1) : bytes - 1;
3024
+ if (ranged && start > end)
3025
+ return res.writeHead(416, { 'content-range': `bytes */${bytes}` }).end();
3026
+ res.writeHead(ranged ? 206 : 200, { 'content-type': type, 'content-disposition': `attachment; filename="${name}"`, 'x-content-type-options': 'nosniff', 'cache-control': 'private, no-store', 'accept-ranges': 'bytes',
3027
+ 'content-length': Math.max(0, end - start + 1), ...(ranged && { 'content-range': `bytes ${start}-${end}/${bytes}` }) });
3028
+ return bytes ? pipeline(createReadStream(path, { start, end }), res, () => { }) : res.end();
3029
+ }
2847
3030
  if (body && typeof body === 'object' && 'media' in body) {
2848
3031
  const { media: bytes, type, point } = body, media = point ? Buffer.concat([bytes, pointer]) : bytes;
2849
3032
  const headers = { 'content-type': type, 'x-content-type-options': 'nosniff', 'content-security-policy': FRAME_CSP, 'accept-ranges': 'bytes', 'content-length': media.length };
@@ -2870,7 +3053,7 @@ await runAxiCli({
2870
3053
  topLevelHelp: TOP_HELP,
2871
3054
  getCommandHelp: (cmd) => HELP[cmd],
2872
3055
  home: axi('list', list),
2873
- 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)), refile: axi('refile', refile), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
3056
+ 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), 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]) => {
2874
3057
  if (rest.length || (name && !HELP[name]))
2875
3058
  throw fail(`unknown command or argument: ${[name, ...rest].join(' ')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi --help` to see the commands');
2876
3059
  return name ? HELP[name] : TOP_HELP;