sign-off-axi 0.3.0 → 0.3.2

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
@@ -131,7 +131,7 @@ sign-off-axi post --type explainer --title "<question>" --media <video> [--about
131
131
  sign-off-axi init [dir] [--agent auto|<name>] write the repo setup under <dir>/.sign-off/ (config, board, mocks/ and evidence/ folders)
132
132
  and install the skill for each chosen coding agent (auto: each one on PATH)
133
133
  sign-off-axi doctor [dir] check that setup and each agent's skill; every failure names its fix
134
- sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid] check ask and report messages against the contract (docs/contract.md) without posting
134
+ sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid] check ask, report and evidence messages against the contract (docs/contract.md) without posting
135
135
  sign-off-axi open [--port 4410] [--bind <address>] [--evidence-root <dir>] start the board in the background; prints its URL and pid
136
136
  sign-off-axi status is the board open started for this file running? its URL and pid
137
137
  sign-off-axi stop stop that board
@@ -145,11 +145,11 @@ sign-off-axi export [--out <file.html>] write a frozen read-only copy o
145
145
 
146
146
  - The board is the one you name right after the command (`sign-off-axi answers ./decisions.json`). With none, it is the nearest `.sign-off/` board that `init` set up, found by the walk described in [Test runs never touch your board](#test-runs-never-touch-your-board); otherwise the default board there (`~/.sign-off/decisions.json`, or under `SIGN_OFF_HOME`). Answers are saved next to it.
147
147
  - Next-step commands in the output name paths under the working folder relative to it, and say `npx -y sign-off-axi` when sign-off-axi runs through `npx`.
148
- - `post --type design` asks for a pick between up to 6 mock options, each with an optional `--mock <value>=<path>` file (`.html`, `.svg`, `.png`, `.jpg`, `.jpeg`, `.webp`) shown as a tall phone frame above its button (the recommended one first; on a phone the frames swipe sideways, the next one peeking in, and a tap opens one full size), plus a `--tradeoff` line and a `--recommend` badge. Mock paths are relative to the board file's folder and never escape it. One `.html` page can hold every variant: `--mock a=mocks/<key>/index.html?variant=a` loads the page with `?variant=a`. An `.html` mock or view loads the css, scripts, images and fonts beside it by relative name (`style.css`, `img/dot.png`), from its own folder only, never anything above it. The board adds a small pointing script to each `.html` mock: once the board turns it on for that frame, a tap on a part of the mock is kept from the mock and named back to the board as a path to that element, its tag and its words; it passes only text and gives the mock no reach into the board. With **Comment on the mocks** on, a tap opens a comment on that element: one per element, listed under the mocks with Edit and Delete, kept on the board and shown only on the exact mock file it was made on. An option with no mock, or whose file is gone, says "No mock for this option". `post --type decision` (the default) is exactly `raise`, and `raise` stays as its alias.
148
+ - `post --type design` asks for a pick between up to 6 mock options, each with an optional `--mock <value>=<path>` file (`.html`, `.svg`, `.png`, `.jpg`, `.jpeg`, `.webp`) shown as a tall phone frame above its button (the recommended one first; on a phone the frames swipe sideways, the next one peeking in, and a tap opens one full size), plus a `--tradeoff` line and a `--recommend` badge. Mock paths are relative to the board file's folder and never escape it. One `.html` page can hold every variant: `--mock a=mocks/<key>/index.html?variant=a` loads the page with `?variant=a`. An `.html` mock or view loads the css, scripts, images and fonts beside it by relative name (`style.css`, `img/dot.png`), from its own folder only, never anything above it. The board adds a small pointing script to each `.html` mock: once the board turns it on for that frame, a tap on a part of the mock is kept from the mock and named back to the board as a path to that element, its tag and its words; it passes only text and gives the mock no reach into the board. With **Comment on the mocks** on, a tap opens a comment on that element: one per element, listed under the mocks with Edit and Delete, kept on the board and shown, until it is sent, only on the exact mock file it was made on. The comments go to the agent with the next answer on that card (a pick, Other answer or **Another round**, which may then have no note), each with its element, in `wait` and `answers` (`comments`); once sent they are listed as sent and can no longer change. An option with no mock, or whose file is gone, says "No mock for this option". `post --type decision` (the default) is exactly `raise`, and `raise` stays as its alias.
149
149
  - `post --type evidence` posts the next numbered update (`update-1`, `update-2`, ...) with 1 to 12 screenshots or recordings (`.png`, `.jpg`, `.jpeg`, `.webp`, `.mp4`, `.webm`, up to 50 MB each), a short note and an optional commit or PR link. The files are copied into `evidence/<number>/` next to the board, so the board is the store: no hosted service. The page shows the updates newest first; each file is served only while its size and SHA-256 still match what was posted, and recordings play inline on a phone. The person gives a verdict, **Pass**, **Fail** or **Needs work**, with an optional note, read back with `answers` or `wait --key update-<number>`.
150
150
  - `post --type diagram` and `post --type interactive` show a question with a picture: `--view <path>` names one board-relative file, an image (`.svg`, `.png`, `.jpg`, `.jpeg`, `.webp`) drawn at card width for a diagram, or one self-contained `.html` page that runs in a sandboxed frame for interactive. Every mock and view is served under a Content-Security-Policy with no `fetch`, no remote files and no access to the dashboard or its tab. They answer like a decision.
151
151
  - `post --type explainer` shows a question with a narrated video the agent made itself (stills plus speech from a free local engine such as piper or espeak-ng, joined with ffmpeg; the skill has the recipe). `--media` names one `.mp4` or `.webm`, copied into `evidence/<number>/` and served hash-checked like evidence media; it plays on the card with its own controls, at phone width too. `--about` is a short summary and each `--chapter m:ss=<title>` is a button that plays the video from there. sign-off itself never makes the video: it calls no speech engine, no ffmpeg and no network service.
152
- - The person can also tap **Another round** inside **Other answer** (with the typed note), which wakes a waiting agent the same way an answer does; the agent reads it back in `answers --json` under `rounds`.
152
+ - The person can also tap **Another round** inside **Other answer** (with the typed note), which wakes a waiting agent the same way an answer does; the agent reads it back in `answers --json` under `rounds`, with any comments on mock elements it carried.
153
153
  - **Dismiss** (the × on an item in a list, **Dismiss** under Needs you first and beside **Skip for now** in Triage, or **Clear all done** on Answered) hides items from the board, Today included; **Undo** in the toast brings them back, and so does **Restore** under **Dismissed (N)** at the end of Needs you first and Triage. The agent is not told, so an ask it waits on keeps waiting. The storage is in [docs/file-formats.md](docs/file-formats.md).
154
154
  - The person can comment on any item (**Send as a comment**, also inside **Other answer**: each card has that one place to type) and queue a free-text prompt for the agent (**Ask the agent**). `poll` blocks until one arrives and prints every new one once (`--json` prints `{"messages":[{"id","kind","key"?,"text","at"}]}`); the page then shows it as read by the agent. Typed notes that are not sent yet stay in the browser across reloads; everything sent is saved next to the board and survives a restart.
155
155
  - The board runs on your own machine only: no hosted service, no recurring cost. It listens on `127.0.0.1` unless `--bind <address>` (or `bind:` in the `config.yaml` that `init` writes) names one private address of this machine, such as its VPN address, so a phone or tablet on your own network can open it. A `hosts:` line in that config names private DNS names that lead to the bind address, such as its mesh VPN name (commas between them), so the board also opens by name; `doctor` checks each one resolves to the bind address. All interfaces and public addresses are refused. Beyond `127.0.0.1` the board makes an access token once (`decisions.token`, mode 600, next to the board), `open` prints it in the link, and every request without it is refused; the link sets a cookie, so the page and its media work after the first load. Delete the token file to shut old links out at once, then `stop` and `open` for a new one.
@@ -160,7 +160,7 @@ sign-off-axi export [--out <file.html>] write a frozen read-only copy o
160
160
  - Each raised or posted item records when it was filed (`filed`), so Triage shows `Waiting since 09:15` and the Waiting on you tile `Oldest 09:15`; `post --replace` keeps an item's first time.
161
161
  - `open` is a no-op when the same board is already open on the same address, and fails with `CONFLICT` when it runs on another one. `--evidence-root` is the folder holding `.done-done/`, for decisions that link run evidence.
162
162
  - `--filer <id>` on `post` and `raise` saves who filed the item (an agent session id or any 1-128 of `A-Z a-z 0-9 . _ -`; `SIGN_OFF_FILER` sets it for every command, and a post with neither saves `CLAUDE_CODE_SESSION_ID` when it is set). `wait --filer <id>` then returns only answers to that filer's items it has not taken yet, marks each taken (`decisions.seen.jsonl`) and the card shows **Seen by agent**; it fails with `NOT_FOUND` when none of that filer's items is answered or waiting. The page never shows the filer id.
163
- - `on_answer: <command> [args]` in the `config.yaml` beside the board runs that command for every saved answer, read when the board starts. It runs with no shell, in the board's folder, and gets the answer as one JSON line on stdin (`{"key","answer","label","close","at","note"?,"title"?,"filer"?}`); the answer is never put into its arguments, so a typed note cannot inject a command. A failure is logged and never blocks the answer. With no shell, `|`, `>` and `&&` are passed as plain arguments, so put a pipeline in a script and name the script; a relative path such as `./hook.sh` is found from the board's folder. `doctor` checks that the command can run.
163
+ - `on_answer: <command> [args]` in the `config.yaml` beside the board runs that command for every saved answer, read when the board starts. It runs with no shell, in the board's folder, and gets the answer as one JSON line on stdin (`{"key","answer","label","close","at","note"?,"title"?,"filer"?,"comments"?}`, where `comments`, present when the answer carried comments on mock elements, lists each as `id`, `mock`, `hash`, `ref`, `tag`, `words` and `text`); the answer is never put into its arguments, so a typed note cannot inject a command. A failure is logged and never blocks the answer. With no shell, `|`, `>` and `&&` are passed as plain arguments, so put a pipeline in a script and name the script; a relative path such as `./hook.sh` is found from the board's folder. `doctor` checks that the command can run.
164
164
  - `post --from <file> --replace --filer <id>` lets several programs feed one board: it replaces only that filer's items (and items with no filer that the file names by key), keeps every other item, the board's other top-level fields and all answers, and an empty items list removes that filer's items.
165
165
  - `wait` replaces a polling loop. With `--key` it returns at once if that decision is already answered, and waits for a key that is not raised yet; without it, it waits for the next answer to any decision still waiting and fails with `NOT_FOUND` when nothing is. It wakes on each save, gives up after `--timeout` seconds (600, ten minutes, by default) with code `TIMEOUT` and exit 1, and takes `--full` and `--json` like `answers`. Ctrl-C stops it without changing anything.
166
166
  - `open` records the board's pid, port and address next to the board file (`decisions.board.pid`). `status` and `stop` read it and only act on a pid that really serves that board, so no one has to guess a pid to kill. `open` on a board already served by `serve` records that server, so `stop` can close it too.
@@ -202,6 +202,8 @@ npm run build:skill # regenerates skills/sign-off-axi/SKILL.md from sr
202
202
  npm pack # builds the publishable sign-off-axi tarball
203
203
  ```
204
204
 
205
+ A release is a merged version-bump PR: CI publishes the new version to npm.
206
+
205
207
  ### Test runs never touch your board
206
208
 
207
209
  With no board named, a bare command uses the nearest `.sign-off/` that `init` set up, looking from the working folder up to its repo root (the folder holding `.git`) or up to HOME, but never HOME's own `.sign-off/`. The working folder's own `.sign-off/` is always used. A folder above it is searched only when that climb reaches a repo root or HOME, so a `.sign-off/` above a folder with neither (outside HOME, no `.git`) is never used, and the account's own home is never used. Otherwise the default board is `~/.sign-off/decisions.json`, under `SIGN_OFF_HOME` when it is set (it takes HOME's place for the default board and for where the climb stops), else under HOME.
package/dist/cli.js CHANGED
@@ -10,8 +10,8 @@ import { basename, delimiter, dirname, extname, isAbsolute, join, relative, reso
10
10
  import { parseArgs } from 'node:util';
11
11
  import { encode } from '@toon-format/toon';
12
12
  import { AxiError, runAxiCli } from 'axi-sdk-js';
13
- import { CHAPTERS_MAX, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendMessage, appendRound, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, writeItems } from './store.js';
14
- import { ContractError, KINDS, checkMessage, parseMessage } from './contract.js';
13
+ import { CHAPTERS_MAX, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendMessage, appendRound, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, writeItems } from './store.js';
14
+ import { ContractError, KINDS, MEDIA_BYTES, checkMessage, mediaFiles, parseMessage } from './contract.js';
15
15
  import { sweepVault } from './vault.js';
16
16
  const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
17
17
  const CONTRACT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/contract.md';
@@ -74,10 +74,11 @@ examples:
74
74
  `,
75
75
  validate: `usage: sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid]
76
76
  Checks message files against the communication contract without posting them: ${CONTRACT}
77
- A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask and report, schema 1 each.
77
+ A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, report and evidence, schema 1 each.
78
+ 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.
78
79
  Post one with \`sign-off-axi post --kind <kind> --from <message.json>\`, or --from - to read stdin.
79
80
  A board file given to raise --from or post --from may hold such messages as items: each is checked the same way before it is stored, and a report shows under Reports with no answer.
80
- In such a file a new item of a contract kind (a decision, the default, an ask, evidence or a report) must carry contract, kind and schema.
81
+ In such a file a new item of a contract kind (a decision, the default, an ask or a report) must carry contract, kind and schema; new evidence is posted with post --kind evidence, one at a time.
81
82
  Each file gets a row with ok, or the error and one help line that names the fix. A field written twice, an unknown field or a broken limit refuses the file.
82
83
  --kind each file must be this kind, so the wrong file is refused
83
84
  --expect-invalid every file must be refused: exits 0 only then, 2 when any passes (the conformance check of invalid fixtures)
@@ -88,6 +89,7 @@ examples:
88
89
  sign-off-axi validate ask.json
89
90
  sign-off-axi validate --kind ask messages/*.json
90
91
  sign-off-axi validate --kind report report.json
92
+ sign-off-axi validate --kind evidence proof/evidence.json
91
93
  sign-off-axi validate --expect-invalid node_modules/sign-off-axi/fixtures/contract/*/invalid-*.json
92
94
  `,
93
95
  answers: `usage: sign-off-axi answers [board.json] [--key <key>] [--full] [--json]
@@ -156,6 +158,8 @@ examples:
156
158
  Posts an item for the user. --type decision (the default) is a yes/no or pick-one question, exactly like raise.
157
159
  --kind posts one contract message (${CONTRACT}), read from a file or from stdin with --from -: it must be that kind and pass every rule,
158
160
  else it is refused with one fix line and nothing is written. A key already on the board takes only the same message again; anything else exits 2.
161
+ --kind evidence copies its media, read from the message file's folder (the current folder for stdin), into evidence/<number>/ like --type evidence;
162
+ a "run" is shown as the update's proof record. With no key it posts the next update-<number>.
159
163
  Listing --option needs --recommend <value>, on every type: the option you would pick, shown first and marked. --from --replace feeds are not held to it.
160
164
  --filer names who posts (your agent session or any id of 1-128 of A-Z a-z 0-9 . _ -), saved on the item; then \`wait --filer <id>\` gets the answer.
161
165
  SIGN_OFF_FILER sets it for every command; with neither, the Claude Code session id is saved when there is one.
@@ -180,6 +184,7 @@ examples:
180
184
  sign-off-axi post .sign-off/decisions.json --type design --key sign-in --title "Pick the sign-in layout" --option a=A --option b=B --option c=C --mock a=mocks/sign-in/index.html?variant=a --mock b=mocks/sign-in/index.html?variant=b --mock c=mocks/sign-in/index.html?variant=c --tradeoff "..." --recommend b
181
185
  sign-off-axi post --type decision --title "Keep the weekly digest?" --recommend yes
182
186
  sign-off-axi post --kind ask --from ask.json --filer agent-41
187
+ sign-off-axi post --kind evidence --from proof/evidence.json --filer done-done
183
188
  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
184
189
  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
185
190
  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"
@@ -252,9 +257,15 @@ const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report'];
252
257
  /** 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.
253
258
  * A new item of a contract kind must carry them (contract section 2); one already on the board is not checked again. */
254
259
  function contractItems(items, from, file) {
255
- const had = new Set(existsSync(file) ? readItems(file).items.map((i) => i?.key) : []);
260
+ const on = new Map((existsSync(file) ? readItems(file).items : []).map((i) => [i?.key, i]));
256
261
  items.forEach((m, i) => {
257
- if (had.has(m?.key))
262
+ // Evidence in message form (no type) never comes in a feed, even under a key on the board: its media must be copied and numbered.
263
+ // A stored board-format evidence item passes through a feed unchanged; any other evidence under a key is refused.
264
+ const kept = on.get(m?.key)?.type === 'evidence';
265
+ if ((m?.kind === 'evidence' && m?.type === undefined) || (!kept && (m?.kind ?? m?.type) === 'evidence')) {
266
+ throw fail(`${from}: items[${i}]${isKey(m?.key) ? ` key ${m.key}` : ''}: evidence is posted one at a time, not in a --from file`, 'VALIDATION_ERROR', 'Post it with sign-off-axi post --kind evidence --from <message.json>');
267
+ }
268
+ if (on.has(m?.key))
258
269
  return;
259
270
  if (m?.contract === undefined && m?.kind === undefined) {
260
271
  if (!CONTRACT_TYPES.includes(m?.type ?? 'decision'))
@@ -396,20 +407,20 @@ function printAnswers(file, rows, waiting, values, help) {
396
407
  const done = rows.flatMap((d) => (d.answer ? [{ ...d.answer, title: d.title }] : []));
397
408
  const rounds = readRounds(file).filter((r) => rows.some((d) => d.key === r.key));
398
409
  if (values.json) {
399
- const out = Object.fromEntries(done.map(({ key, answer, label, close, at, note }) => {
400
- const asked = rounds.filter((r) => r.key === key).map(({ note, at }) => ({ note, at }));
401
- return [key, { answer, label, close, at, note, ...(asked.length && { rounds: asked }) }];
402
- }));
410
+ const asked = (key) => rounds.filter((r) => r.key === key).map(({ note, at, comments }) => ({ note, at, comments }));
411
+ const out = Object.fromEntries(done.map(({ key, answer, label, close, at, note, comments }) => [key, { answer, label, close, at, note, comments, ...(asked(key).length && { rounds: asked(key) }) }]));
403
412
  for (const r of rounds)
404
413
  if (!out[r.key])
405
- out[r.key] = { rounds: rounds.filter((q) => q.key === r.key).map(({ note, at }) => ({ note, at })) };
414
+ out[r.key] = { rounds: asked(r.key) };
406
415
  return JSON.stringify(out);
407
416
  }
408
- const notes = done.some((a) => a.note);
417
+ const notes = done.some((a) => a.note), sent = [...done, ...rounds].flatMap(({ key, comments = [] }) => comments.map(({ mock, ref, tag, words, text }) => ({ key, mock, ref, tag, words, text })));
409
418
  return toon({
410
419
  board: tilde(file),
411
420
  answers: done.length ? done.map(({ key, answer, at, label, close, title, note = '' }) => ({ key, answer, at, ...(values.full && { label, close, title }), ...(notes && { note }) })) : '0 saved yet',
412
421
  ...(rounds.length && { rounds: rounds.map(({ key, note, at }) => ({ key, note, at })) }),
422
+ // Comments on mock elements that went with an answer or a round, each with the element it points at.
423
+ ...(sent.length && { comments: sent }),
413
424
  waiting,
414
425
  }, help);
415
426
  }
@@ -565,7 +576,7 @@ function post(args, cmd = 'post') {
565
576
  const size = statSync(actual);
566
577
  if (!size.isFile() || size.size > MOCK_BYTES)
567
578
  throw fail(`--view ${values.view} must be a file of 5 MB or less`, 'VALIDATION_ERROR');
568
- return storeIncoming(file, on, buildIncoming(values, { type, view: values.view }, filer), filer);
579
+ return storeIncoming(file, on, buildIncoming(values, { type, view: values.view }, filer, file), filer);
569
580
  }
570
581
  const mocks = values.mock?.map((m) => {
571
582
  const at = m.indexOf('=');
@@ -578,7 +589,7 @@ function post(args, cmd = 'post') {
578
589
  throw fail(`--mock ${path} escapes the board folder`, 'VALIDATION_ERROR', 'Keep mock files next to the board file');
579
590
  return { value: m.slice(0, at), path };
580
591
  });
581
- return storeIncoming(file, on, buildIncoming(values, { type, tradeoff: values.tradeoff, mocks }, filer), filer);
592
+ return storeIncoming(file, on, buildIncoming(values, { type, tradeoff: values.tradeoff, mocks }, filer, file), filer);
582
593
  }
583
594
  /** For a program that feeds the board: the --from file, checked like any post, becomes the whole board, items and top-level fields. Answers stay.
584
595
  * With a filer it replaces only that filer's items (and items with no filer it names by key); every other item and top-level field stays. */
@@ -633,9 +644,9 @@ function replace(file, on, from, filer) {
633
644
  return toon({ board: tilde(file), items: items.length, changed }, [`Run \`sign-off-axi answers${on}\` to read the answers`]);
634
645
  });
635
646
  }
636
- const MEDIA_BYTES = 50_000_000;
637
- /** Copies the media into evidence/<n>/ next to the board and adds the item, numbered under the board lock: update-<n>, or an explainer under its own key. */
638
- function postMedia(file, on, values, type, filer) {
647
+ /** Copies the media into evidence/<n>/ next to the board and adds the item, numbered under the board lock: update-<n>, or an explainer under its own key.
648
+ * `message` is a checked contract evidence message (post --kind evidence): its key, if any, takes only the same message again, and its run is stored as `evidence`. */
649
+ function postMedia(file, on, values, type, filer, message) {
639
650
  fits(values.title);
640
651
  const explainer = type === 'explainer';
641
652
  const extra = (explainer ? ['mock', 'tradeoff', 'view', 'link'] : ['key', 'option', 'recommend', 'default', 'mock', 'tradeoff', 'view', 'chapter']).find((f) => values[f] !== undefined);
@@ -647,7 +658,7 @@ function postMedia(file, on, values, type, filer) {
647
658
  throw fail('--about is required for --type evidence: the short note', 'VALIDATION_ERROR', example);
648
659
  if (explainer && values.media?.length !== 1)
649
660
  throw fail('--type explainer needs one --media video (.mp4 or .webm)', 'VALIDATION_ERROR', example);
650
- if (!values.media?.length || values.media.length > MEDIA_MAX)
661
+ if ((!message && !values.media?.length) || values.media.length > MEDIA_MAX)
651
662
  throw fail(`--type evidence needs 1 to ${MEDIA_MAX} --media files`, 'VALIDATION_ERROR', example);
652
663
  const kinds = explainer ? ['.mp4', '.webm'] : Object.keys(mediaTypes), options = optionsOf(values);
653
664
  recommended([{ options, recommend_value: values.recommend }], true);
@@ -669,10 +680,18 @@ function postMedia(file, on, values, type, filer) {
669
680
  });
670
681
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
671
682
  return locked(`${file}.lock`, () => {
672
- const items = existsSync(file) ? readItems(file).items : [];
683
+ const { items, extra } = existsSync(file) ? readItems(file) : { items: [], extra: {} };
673
684
  // Boards in one folder share evidence/, so a number is taken only by creating its folder; one in use is never written into.
674
685
  const current = board(file), store = join(dirname(file), 'evidence'), taken = current.map((d) => Number(/^update-(\d+)$/.exec(d.key)?.[1] ?? 0));
675
- const own = explainer && (values.key ?? slug(values.title));
686
+ const own = message ? message.key : explainer && (values.key ?? slug(values.title)), had = message && own && items.find((x) => x?.key === own);
687
+ if (had) {
688
+ // The stored item, read back as the message it came from: its run, and each media file by hash.
689
+ const asMessage = ({ type: _t, number: _n, filed: _f, evidence: run, media: kept, ...rest }) => canon({ ...rest, run, media: kept?.map((x) => x.sha256) });
690
+ if (asMessage(had) !== canon({ ...message, media: sources.length ? sources.map((src) => sha256(readFileSync(src))) : undefined })) {
691
+ throw fail(`key ${own} already holds a different message on ${tilde(file)}`, 'CONFLICT', 'Post it under a new key');
692
+ }
693
+ return toon({ board: tilde(file), posted: [{ key: own, number: had.number, media: sources.length, title: had.title, status: 'already posted' }] }, [`Run \`sign-off-axi open${on}\` so the user can give a verdict`]);
694
+ }
676
695
  if (own && current.some((d) => d.key === own))
677
696
  throw fail(`key ${own} is already used on ${tilde(file)}`, 'CONFLICT', 'Post it under a new --key');
678
697
  mkdirSync(store, { recursive: true, mode: 0o700 });
@@ -688,17 +707,27 @@ function postMedia(file, on, values, type, filer) {
688
707
  }
689
708
  }
690
709
  const key = own || `update-${number}`, dir = join(store, String(number));
691
- const media = sources.map((src, i) => {
692
- const bytes = readFileSync(src), path = `evidence/${number}/${i + 1}${extname(src).toLowerCase()}`;
693
- writeFileSync(join(dir, `${i + 1}${extname(src).toLowerCase()}`), bytes, { mode: 0o600 });
694
- return { path, sha256: sha256(bytes), bytes: bytes.length };
695
- });
710
+ let media;
711
+ try {
712
+ media = sources.map((src, i) => {
713
+ const bytes = readFileSync(src), path = `evidence/${number}/${i + 1}${extname(src).toLowerCase()}`;
714
+ writeFileSync(join(dir, `${i + 1}${extname(src).toLowerCase()}`), bytes, { mode: 0o600 });
715
+ return { path, sha256: sha256(bytes), bytes: bytes.length };
716
+ });
717
+ }
718
+ catch (e) {
719
+ // A file that cannot be read after all leaves no copies behind.
720
+ rmSync(dir, { recursive: true, force: true });
721
+ throw e;
722
+ }
696
723
  const { title, about, detail, close, link, recommend: recommend_value, default: default_if_silent } = values, filed = new Date().toISOString();
724
+ const { run, media: _paths, ...fields } = message ?? {};
697
725
  items.push(explainer ? { key, type, title, about, detail, options, recommend_value, default_if_silent, close, chapters, media, filer, filed }
698
- : { key, type, number, title, about, detail, close, link, media, filer, filed });
726
+ : message ? { ...fields, key, type, number, ...(media.length ? { media } : {}), ...(run && { evidence: run }), filed }
727
+ : { key, type, number, title, about, detail, close, link, media, filer, filed });
699
728
  // The whole board is checked before it is written, so a bad post leaves the board and evidence/ as they were.
700
729
  const check = `${file}.${process.pid}.check`;
701
- writeFileSync(check, JSON.stringify({ schema: 2, items }), { mode: 0o600 });
730
+ writeFileSync(check, JSON.stringify({ ...extra, schema: 2, items }), { mode: 0o600 });
702
731
  try {
703
732
  loadDecisions(check);
704
733
  }
@@ -712,7 +741,7 @@ function postMedia(file, on, values, type, filer) {
712
741
  writeItems(file, items);
713
742
  if (explainer)
714
743
  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)]);
715
- return toon({ board: tilde(file), posted: [{ key, number, media: media.length, title }] }, [`Run \`sign-off-axi open${on}\` so the user can give a verdict`, waitFor(on, filer, key)]);
744
+ return toon({ board: tilde(file), posted: [{ key, number, media: media.length, title, ...(message && { status: 'posted' }) }] }, [`Run \`sign-off-axi open${on}\` so the user can give a verdict`, waitFor(on, filer, key)]);
716
745
  });
717
746
  }
718
747
  /** Posts one contract message (docs/contract.md), from a file or from stdin with --from -: checked against --kind, then stored like a raised item. */
@@ -730,9 +759,13 @@ async function postKind(file, on, values) {
730
759
  catch (e) {
731
760
  throw fail(`${name}: cannot read: ${e.code}`, 'NOT_FOUND', 'Check the path names a readable file');
732
761
  }
762
+ let sources = [];
733
763
  try {
734
764
  m = parseMessage(bytes);
735
765
  checkMessage(m, kind);
766
+ // Media paths read from the message file's folder, or from the current folder for stdin.
767
+ if (m.kind === 'evidence')
768
+ sources = mediaFiles(m, from === '-' ? process.cwd() : dirname(resolve(from)));
736
769
  }
737
770
  catch (e) {
738
771
  throw e instanceof ContractError ? fail(`${name}: ${e.message}`, 'VALIDATION_ERROR', e.help) : e;
@@ -742,6 +775,8 @@ async function postKind(file, on, values) {
742
775
  }
743
776
  const filer = m.filer ?? filerOf(values, true), scratch = mkdtempSync(join(tmpdir(), 'sign-off-')), source = join(scratch, basename(name));
744
777
  try {
778
+ if (m.kind === 'evidence')
779
+ return await postMedia(file, on, { title: m.title, about: m.about, media: sources }, 'evidence', filer, { ...m, ...(filer && { filer }) });
745
780
  writeFileSync(source, JSON.stringify({ schema: 2, items: [{ ...m, ...(filer && { filer }) }] }));
746
781
  return await storeIncoming(file, on, buildIncoming({ from: source }, {}, filer, file), filer, true);
747
782
  }
@@ -782,7 +817,10 @@ function validate(args) {
782
817
  }
783
818
  try {
784
819
  m = parseMessage(raw);
785
- return { file, ...checkMessage(m, want), ok: true, error: '', help: '' };
820
+ const kind = checkMessage(m, want);
821
+ if (m.kind === 'evidence')
822
+ mediaFiles(m, dirname(resolve(file)));
823
+ return { file, ...kind, ok: true, error: '', help: '' };
786
824
  }
787
825
  catch (e) {
788
826
  if (!(e instanceof ContractError))
@@ -810,13 +848,14 @@ function recommended(items, flags) {
810
848
  if (d)
811
849
  throw fail(`items[${items.indexOf(d)}] (key ${d.key}) lists options but no recommend_value`, 'VALIDATION_ERROR', `Add "recommend_value": "<value>", the option you would pick: one of ${values}`);
812
850
  }
813
- /** Flags are validated by the same loader as a file: write them as a one-decision file first. */
814
- function buildIncoming(values, extra = {}, filer, file = '') {
851
+ /** Flags are validated by the same loader as a file: write them as a one-decision file first. Items are checked against the board they go to, so its answers_only or readonly holds. */
852
+ function buildIncoming(values, extra, filer, file) {
853
+ const board = existsSync(file) ? readItems(file).extra : {};
815
854
  if (values.from !== undefined) {
816
855
  // An item that names its own filer keeps it.
817
856
  const source = resolve(values.from), raw = readItems(source).items;
818
857
  contractItems(raw, values.from, file);
819
- const incoming = loadDecisions(source);
858
+ const incoming = loadDecisions(source, board);
820
859
  recommended(raw, false);
821
860
  return { incoming: incoming.map((d) => ({ filer, ...d })), raw: raw.map((i) => ({ filer, ...i })) };
822
861
  }
@@ -825,7 +864,7 @@ function buildIncoming(values, extra = {}, filer, file = '') {
825
864
  try {
826
865
  const { title, key = slug(title), about, detail, recommend: recommend_value, default: default_if_silent, close } = values;
827
866
  writeFileSync(source, JSON.stringify({ schema: 2, items: [{ key, title, about, detail, options: optionsOf(values), recommend_value, default_if_silent, close, ...extra, filer }] }));
828
- const incoming = loadDecisions(source), raw = readItems(source).items;
867
+ const incoming = loadDecisions(source, board), raw = readItems(source).items;
829
868
  recommended(raw, true);
830
869
  return { incoming, raw };
831
870
  }
@@ -850,7 +889,11 @@ function storeIncoming(file, on, { incoming, raw }, filer, exact = false) {
850
889
  // A message that names no filed time matches the one the board stamped.
851
890
  const differs = () => exact ? canon({ ...items.find((x) => x?.key === d.key), ...(raw[i].filed === undefined && { filed: undefined }) }) !== canon(raw[i]) : same(had) !== same(d);
852
891
  if (had && differs()) {
853
- throw fail(`key ${d.key} already holds a different ${exact ? 'message' : 'question'} on ${tilde(file)}`, 'CONFLICT', 'Raise it under a new --key');
892
+ // A contract message refused for its key exits 2 like any other refusal (contract section 4); raise --title keeps exit 1.
893
+ const contract = exact || raw[i]?.contract !== undefined || raw[i]?.kind !== undefined;
894
+ if (contract)
895
+ process.once('exit', () => { process.exitCode = 2; });
896
+ throw fail(`key ${d.key} already holds a different ${contract ? 'message' : 'question'} on ${tilde(file)}`, 'CONFLICT', contract ? 'Post it under a new key' : 'Raise it under a new --key');
854
897
  }
855
898
  // Each new item records when it was raised, unless it names its own time.
856
899
  if (!had) {
@@ -1473,7 +1516,9 @@ const page = readFileSync(new URL('../web/index.html', import.meta.url));
1473
1516
  const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
1474
1517
  const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
1475
1518
  const state = () => {
1476
- const answers = readAnswers(file, warn), taken = seenBy(file), rounds = readRounds(file, warn);
1519
+ // The page marks sent comments in annotations, so answers and rounds go without theirs: a read-only board or an export carries none.
1520
+ const answers = Object.fromEntries(Object.entries(readAnswers(file, warn)).map(([k, { comments: _, ...a }]) => [k, a])), taken = seenBy(file);
1521
+ const rounds = readRounds(file, warn).map(({ comments: _, ...r }) => r);
1477
1522
  return {
1478
1523
  file,
1479
1524
  mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
@@ -1486,7 +1531,7 @@ const state = () => {
1486
1531
  seen: Object.values(answers).filter(taken).map((a) => a.key),
1487
1532
  rounds,
1488
1533
  messages: readMessages(file, warn),
1489
- annotations: readOnly() || answersOnly() ? [] : readAnnotations(file, warn),
1534
+ annotations: readOnly() || answersOnly() ? [] : ((sent) => readAnnotations(file, warn).map((a) => (sent.has(a.id) ? { ...a, sent: true } : a)))(sentIds(file)),
1490
1535
  dismissed: readDismissed(file, answers, rounds, warn),
1491
1536
  };
1492
1537
  };
@@ -1498,6 +1543,12 @@ const mockHash = (path) => { try {
1498
1543
  catch {
1499
1544
  return null;
1500
1545
  } };
1546
+ /** 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. */
1547
+ const unsent = (d) => {
1548
+ const sent = sentIds(file), now = new Map((d.mocks ?? []).map((m) => [m.value, mockHash(m.path)]));
1549
+ return answersOnly() ? [] : readAnnotations(file).filter((a) => a.key === d.key && !sent.has(a.id) && now.get(a.mock) === a.hash)
1550
+ .map(({ id, mock, hash, ref, tag, words, text }) => ({ id, mock, hash, ref, tag, words, text }));
1551
+ };
1501
1552
  /** A board whose file says `"readonly": true` shows its items and takes no answers, rounds or comments: another program feeds it. */
1502
1553
  const readOnly = () => readItems(file).extra.readonly === true;
1503
1554
  /** A board whose file says `"answers_only": true` takes answers and replies but no rounds, comments or prompts: another program feeds it. */
@@ -1656,7 +1707,10 @@ async function route(req) {
1656
1707
  return [200, state()];
1657
1708
  }
1658
1709
  if (req.url === '/api/annotations') {
1659
- const { id, deleted, key, mock, ref, tag, words, text, hash: shown } = JSON.parse(body) ?? {}, live = readAnnotations(file);
1710
+ const { id, deleted, key, mock, ref, tag, words, text, hash: shown } = JSON.parse(body) ?? {}, live = readAnnotations(file), sent = sentIds(file);
1711
+ // A comment that went with an answer or a round is the agent's now: it stays as it was sent.
1712
+ if (id !== undefined && sent.has(id))
1713
+ return [409, { error: 'This comment was sent' }];
1660
1714
  if (deleted === true) {
1661
1715
  if (!live.some((a) => a.id === id))
1662
1716
  return [404, { error: 'This comment is gone' }];
@@ -1678,7 +1732,7 @@ async function route(req) {
1678
1732
  if (id !== undefined && !live.some((a) => a.id === id))
1679
1733
  return [404, { error: 'This comment is gone' }];
1680
1734
  // One comment per element: another on the same element of the same file edits the first.
1681
- const same = live.find((a) => a.key === key && a.mock === mock && a.hash === hash && a.ref === ref);
1735
+ const same = live.find((a) => a.key === key && a.mock === mock && a.hash === hash && a.ref === ref && !sent.has(a.id));
1682
1736
  appendAnnotation(file, { id: same?.id, key, mock, hash, ref, tag, words, text: text.trim() });
1683
1737
  return [200, state()];
1684
1738
  }
@@ -1696,11 +1750,14 @@ async function route(req) {
1696
1750
  const { key, note = '' } = JSON.parse(body) ?? {};
1697
1751
  if (typeof note !== 'string' || [...note.trim()].length > NOTE_MAX)
1698
1752
  return [400, { error: `The note must be text of ${NOTE_MAX} characters or fewer` }];
1699
- if (!note.trim())
1700
- return [400, { error: 'Write a note first: another round needs your comment' }];
1701
- if (!loadDecisions(file).some((x) => x.key === key))
1753
+ const d = loadDecisions(file).find((x) => x.key === key);
1754
+ if (!d)
1702
1755
  return [404, { error: 'This question is no longer in the file' }];
1703
- appendRound(file, key, note.trim());
1756
+ // Comments on the mocks are enough for a round; with none, the round needs a note.
1757
+ const comments = unsent(d);
1758
+ if (!note.trim() && !comments.length)
1759
+ return [400, { error: 'Write a note first: another round needs your comment' }];
1760
+ appendRound(file, key, note.trim(), comments);
1704
1761
  return [200, state()];
1705
1762
  }
1706
1763
  const { key, answer, note = '' } = JSON.parse(body) ?? {};
@@ -1727,7 +1784,7 @@ async function route(req) {
1727
1784
  const other = d.reply || d.options.length ? [REPLY] : [], option = d.options.find((o) => o.value === answer) ?? other.find((o) => o.value === answer);
1728
1785
  if (!option)
1729
1786
  return [400, { error: `The answer must be one of: ${[...d.options, ...other].map((o) => o.label).join(', ') || 'none, this item is only shown'}` }];
1730
- answered(appendAnswer(file, d, option, note.trim()), d);
1787
+ answered(appendAnswer(file, d, option, note.trim(), unsent(d)), d);
1731
1788
  return [200, state()];
1732
1789
  }
1733
1790
  /** Runs the config's on_answer command for a saved answer, with no shell: the answer goes in as one JSON line on its stdin, never in its arguments. */
package/dist/contract.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // The v1 communication contract (docs/contract.md): one strict, versioned shape per kind of message.
2
- import { isKey } from './store.js';
2
+ import { accessSync, constants, realpathSync, statSync } from 'node:fs';
3
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
4
+ import { isKey, MEDIA_MAX } from './store.js';
3
5
  /** A message that does not match: what is wrong, and the one line that says how to fix it. */
4
6
  export class ContractError extends Error {
5
7
  help;
@@ -10,12 +12,16 @@ export class ContractError extends Error {
10
12
  }
11
13
  export const KINDS = ['ask', 'report', 'evidence', 'gate', 'finding'];
12
14
  /** The kinds this build checks, with the schema number of each. */
13
- export const SCHEMAS = { ask: 1, report: 1 };
15
+ export const SCHEMAS = { ask: 1, report: 1, evidence: 1 };
14
16
  const COMMON = ['contract', 'kind', 'schema', 'key', 'title', 'filer', 'link', 'about'];
15
17
  const FIELDS = {
16
18
  ask: [...COMMON, 'detail', 'options', 'recommend_value', 'default_if_silent', 'close'],
17
19
  report: [...COMMON, 'filed', 'group', 'decided'],
20
+ evidence: [...COMMON, 'media', 'run'],
18
21
  };
22
+ /** A done-done run: its id, full commit and the four Checks lines. */
23
+ const RUN = ['run_id', 'commit', 'outcome', 'repro', 'proof', 'evidence'];
24
+ export const MEDIA_BYTES = 50_000_000;
19
25
  /** Parses one message; a field written twice in any object refuses it, since plain JSON.parse keeps the last one quietly. */
20
26
  export function parseMessage(bytes) {
21
27
  let text, doc;
@@ -104,9 +110,9 @@ export function checkMessage(m, want) {
104
110
  const fields = FIELDS[m.kind], extra = Object.keys(m).find((f) => !fields.includes(f));
105
111
  if (extra === 'options' && m.kind === 'report')
106
112
  throw bad('options', 'is not a field of report: a report takes no answer', 'Remove "options", or post an ask to get an answer');
107
- if (extra)
108
- throw bad(escaped(extra), `is not a field of ${m.kind} schema ${m.schema}`, `Remove ${show(extra)}; ${m.kind} takes ${fields.slice(3).join(', ')}`);
109
- if (!isKey(m.key))
113
+ if (extra !== undefined)
114
+ throw bad(escaped(extra) || '""', `is not a field of ${m.kind} schema ${m.schema}`, `Remove ${show(extra)}; ${m.kind} takes ${fields.slice(3).join(', ')}`);
115
+ if (!(m.kind === 'evidence' && !Object.hasOwn(m, 'key')) && !isKey(m.key))
110
116
  throw bad('key', Object.hasOwn(m, 'key') ? `${show(m.key)} must be 1-128 of A-Z a-z 0-9 . _ -` : 'is required', 'Set "key" to a short id such as "ship-signin-2"');
111
117
  if (!Object.hasOwn(m, 'title'))
112
118
  throw bad('title', 'is required', 'Add "title": the question, in 160 characters or fewer');
@@ -116,10 +122,46 @@ export function checkMessage(m, want) {
116
122
  if (Object.hasOwn(m, 'link') && !(typeof m.link === 'string' && /^(https?:\/\/[^\s\p{Cc}\p{Cs}]{1,500}|[a-f0-9]{7,40})$/u.test(m.link))) {
117
123
  throw bad('link', 'must be an http(s) link with no spaces or control characters, or a 7-40 character commit hash', 'Set "link" to a URL such as "https://example.com/pr/4" or a commit hash, or leave it out');
118
124
  }
119
- if (m.kind === 'report') {
125
+ if (m.kind !== 'ask') {
126
+ const what = m.kind === 'report' ? 'what happened' : 'what this shows';
120
127
  if (!Object.hasOwn(m, 'about'))
121
- throw bad('about', 'is required', 'Add "about": what happened, in 500 characters or fewer');
122
- text('about', m.about, 500, 'what happened, in 500 characters or fewer');
128
+ throw bad('about', 'is required', `Add "about": ${what}, in 500 characters or fewer`);
129
+ text('about', m.about, 500, `${what}, in 500 characters or fewer`);
130
+ }
131
+ if (m.kind === 'evidence') {
132
+ if (!Object.hasOwn(m, 'media') && !Object.hasOwn(m, 'run'))
133
+ throw bad('media', 'or run is required', 'Add "media": ["shot.png"], files next to the message, or "run": the done-done run it proves');
134
+ if (Object.hasOwn(m, 'media')) {
135
+ if (!Array.isArray(m.media) || !m.media.length || m.media.length > MEDIA_MAX)
136
+ throw bad('media', `must list 1 to ${MEDIA_MAX} files`, 'Set "media" to file paths next to the message, such as ["shot.png"]');
137
+ m.media.forEach((p, n) => {
138
+ text(`media[${n}]`, p, 1000, 'a file path from the message file\'s folder, such as "shot.png"');
139
+ if (/^([\\/]|[A-Za-z]:)/.test(p) || p.split(/[\\/]/).includes('..'))
140
+ throw bad(`media[${n}]`, `${show(p)} must be a relative path with no ..`, 'Give the path from the message file\'s folder, such as "evidence-out/after.png"');
141
+ if (!/\.(png|jpe?g|webp|mp4|webm)$/i.test(p))
142
+ throw bad(`media[${n}]`, `${show(p)} must end in .png, .jpg, .jpeg, .webp, .mp4 or .webm`, 'Post a screenshot or recording in one of those formats');
143
+ });
144
+ }
145
+ if (Object.hasOwn(m, 'run')) {
146
+ const r = m.run;
147
+ if (!r || typeof r !== 'object' || Array.isArray(r))
148
+ throw bad('run', 'must be an object', `Set "run" to {${RUN.map(show).join(', ')}}`);
149
+ const odd = Object.keys(r).find((f) => !RUN.includes(f));
150
+ if (odd !== undefined)
151
+ throw bad(`run.${escaped(odd) || '""'}`, 'is not a field of run', `Remove ${show(odd)}; run has only ${RUN.join(', ')}`);
152
+ const missing = RUN.find((f) => !Object.hasOwn(r, f));
153
+ if (missing)
154
+ throw bad(`run.${missing}`, 'is required', `Add "${missing}" to "run"`);
155
+ if (!isKey(r.run_id))
156
+ throw bad('run.run_id', 'must be 1-128 of A-Z a-z 0-9 . _ -', 'Set "run_id" to the done-done run id');
157
+ if (!(typeof r.commit === 'string' && /^[a-f0-9]{40}$/.test(r.commit)))
158
+ throw bad('run.commit', 'must be a full 40-character lowercase commit', 'Set "commit" to the full hash, such as the output of git rev-parse HEAD');
159
+ for (const f of RUN.slice(2))
160
+ text(`run.${f}`, r[f], 1000, `the run's ${f} Checks line, in 1000 characters or fewer`);
161
+ }
162
+ return { kind: m.kind, schema: m.schema };
163
+ }
164
+ if (m.kind === 'report') {
123
165
  // An exact UTC second that exists on the calendar: Date.parse would roll 2026-02-30 over to March.
124
166
  const at = typeof m.filed === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(m.filed) ? new Date(m.filed) : undefined;
125
167
  if (!at || Number.isNaN(at.getTime()) || at.toISOString().replace('.000', '') !== m.filed) {
@@ -144,8 +186,8 @@ export function checkMessage(m, want) {
144
186
  if (!o || typeof o !== 'object' || Array.isArray(o))
145
187
  throw bad(field, 'must be an object', 'Write each option as {"value":"<id>","label":"<text>"}');
146
188
  const odd = Object.keys(o).find((f) => f !== 'value' && f !== 'label');
147
- if (odd)
148
- throw bad(`${field}.${escaped(odd)}`, 'is not a field of an option', `Remove ${show(odd)}; an option has only value and label`);
189
+ if (odd !== undefined)
190
+ throw bad(`${field}.${escaped(odd) || '""'}`, 'is not a field of an option', `Remove ${show(odd)}; an option has only value and label`);
149
191
  if (!isKey(o.value))
150
192
  throw bad(`${field}.value`, 'must be 1-128 of A-Z a-z 0-9 . _ -', 'Set "value" to a short id such as "yes"');
151
193
  if (options.slice(0, n).some((p) => p.value === o.value))
@@ -164,3 +206,35 @@ export function checkMessage(m, want) {
164
206
  throw bad('close', 'must be done or release', 'Set "close" to "done" or "release", or leave it out');
165
207
  return { kind: m.kind, schema: m.schema };
166
208
  }
209
+ /** An evidence message's media files, from `base`, the message file's folder: each a file of 50,000,000 bytes or fewer whose real path,
210
+ * symlinks followed, stays inside that folder. Returns the real paths; validate checks them and copies nothing. */
211
+ export function mediaFiles(m, base) {
212
+ const root = realpathSync(base), at = isKey(m.key) ? `key ${m.key}: ` : '';
213
+ return (m.media ?? []).map((p, n) => {
214
+ const bad = (rule, help) => new ContractError(`${at}media[${n}] ${show(p)} ${rule}`, help);
215
+ let real;
216
+ try {
217
+ real = realpathSync(resolve(root, p));
218
+ }
219
+ catch {
220
+ throw bad('is not there', `Put ${p} in the message file's folder, or fix the path`);
221
+ }
222
+ const rel = relative(root, real);
223
+ if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel))
224
+ throw bad('leads outside the message file\'s folder', 'Copy the file into the message file\'s folder instead of linking to it');
225
+ const info = statSync(real);
226
+ if (!info.isFile())
227
+ throw bad('is not a file', 'Name a screenshot or recording file');
228
+ if (!/\.(png|jpe?g|webp|mp4|webm)$/i.test(real))
229
+ throw bad('links to a file that does not end in .png, .jpg, .jpeg, .webp, .mp4 or .webm', 'Name the screenshot or recording itself');
230
+ try {
231
+ accessSync(real, constants.R_OK);
232
+ }
233
+ catch {
234
+ throw bad('cannot be read', 'Make the file readable, then post again');
235
+ }
236
+ if (info.size > MEDIA_BYTES)
237
+ throw bad(`is over ${MEDIA_BYTES} bytes`, 'Trim or compress the recording');
238
+ return real;
239
+ });
240
+ }
package/dist/store.js CHANGED
@@ -56,8 +56,9 @@ export function writeItems(path, items, extra) {
56
56
  writeFileSync(tmp, `${JSON.stringify({ ...(extra ?? old?.extra), schema: 2, items }, null, 2)}\n`, { mode: 0o600 });
57
57
  renameSync(tmp, path);
58
58
  }
59
- export function loadDecisions(path) {
60
- const name = basename(path), { schema, items, extra } = readItems(path), list = schema === 1 ? 'decisions' : 'items';
59
+ /** `board`: the top-level fields of the board the items are posted to, which decide in place of the file's own. */
60
+ export function loadDecisions(path, board) {
61
+ const name = basename(path), { schema, items, extra: own } = readItems(path), list = schema === 1 ? 'decisions' : 'items', extra = board ?? own;
61
62
  const fed = extra.readonly === true || extra.answers_only === true;
62
63
  const seen = new Map();
63
64
  return items.map((d, i) => {
@@ -76,7 +77,7 @@ export function loadDecisions(path) {
76
77
  throw bad(f, 'must be text');
77
78
  if (d.evidence !== undefined && (!d.evidence || !/^[A-Za-z0-9._-]{1,128}$/.test(d.evidence.run_id ?? '')
78
79
  || !/^[a-f0-9]{40}$/.test(d.evidence.commit ?? '')
79
- || ['outcome', 'repro', 'proof', 'evidence'].some((f) => !isText(d.evidence[f]) || !d.evidence[f].trim() || d.evidence[f].length > 1000))) {
80
+ || ['outcome', 'repro', 'proof', 'evidence'].some((f) => !isText(d.evidence[f]) || !d.evidence[f].trim() || [...d.evidence[f]].length > 1000))) {
80
81
  throw bad('evidence', 'must name a done-done run, full commit, and four Checks lines');
81
82
  }
82
83
  // Any lowercase type word loads; the page shows a plain card for a type it has no renderer for.
@@ -86,7 +87,8 @@ export function loadDecisions(path) {
86
87
  if (d[f] !== undefined && !allowed.includes(d[f]))
87
88
  throw bad(f, `must be ${allowed.join(' or ')}`);
88
89
  const stored = Object.hasOwn(STORED, d.type ?? '') ? STORED[d.type] : undefined;
89
- if (stored) {
90
+ // An evidence update that links a done-done run may have no files of its own (contract evidence with run only).
91
+ if (stored && !(d.type === 'evidence' && d.media === undefined && d.evidence)) {
90
92
  const [max, kinds, ext] = stored;
91
93
  if (!Array.isArray(d.media) || !d.media.length || d.media.length > max || !d.media.every((m) => isText(m?.path)
92
94
  && /^evidence\/\d+\/\w[\w-]*\.\w+$/.test(m.path) && ext.test(m.path) && /^[a-f0-9]{64}$/.test(m.sha256 ?? '') && Number.isInteger(m.bytes) && m.bytes >= 0)) {
@@ -161,7 +163,7 @@ export function loadDecisions(path) {
161
163
  ...(views && { view: d.view }), ...(d.reply && { reply: true }), ...(d.filer && { filer: d.filer }), ...(d.area && { area: d.area }),
162
164
  ...(d.group && { group: d.group }), ...(d.filed && { filed: d.filed }), ...(d.decided && { decided: d.decided }),
163
165
  ...(d.steps && { steps: d.steps.map(({ title, marks }) => ({ title, ...(marks?.length && { marks }) })) }),
164
- ...(stored && { media: d.media.map(({ path, sha256, bytes }) => ({ path, sha256, bytes })) }),
166
+ ...(stored && d.media && { media: d.media.map(({ path, sha256, bytes }) => ({ path, sha256, bytes })) }),
165
167
  ...(d.type === 'evidence' && { number: d.number }),
166
168
  ...(d.type === 'explainer' && d.chapters && { chapters: d.chapters.map(({ at, title }) => ({ at, title })) }),
167
169
  };
@@ -172,29 +174,32 @@ export const roundsPath = (decisionsPath) => decisionsPath.replace(/\.json$/, ''
172
174
  const readRaw = (file) => (existsSync(file) ? readFileSync(file, 'utf8') : '');
173
175
  /** Latest answer per key; a line with an empty answer (an Undo with nothing to go back to) leaves the key unanswered. A torn line (a crash mid-write) is skipped and reported through warn. */
174
176
  export function readAnswers(decisionsPath, warn = () => { }) {
175
- const latest = new Map();
177
+ const latest = new Map(), sent = new Map();
176
178
  readRaw(answersPath(decisionsPath)).split('\n').forEach((line, n) => {
177
179
  try {
178
180
  const a = line && JSON.parse(line);
179
181
  if (a?.answer === '')
180
182
  latest.delete(a.key);
181
- else if (a)
183
+ else if (a) {
182
184
  latest.set(a.key, a);
185
+ if (a.comments)
186
+ sent.set(a.key, [...(sent.get(a.key) ?? []), ...a.comments]);
187
+ }
183
188
  }
184
189
  catch {
185
190
  warn(`skipped unreadable line ${n + 1} in ${answersPath(decisionsPath)}`);
186
191
  }
187
192
  });
188
- return Object.fromEntries(latest);
193
+ return Object.fromEntries([...latest].map(([key, a]) => [key, sent.has(key) ? { ...a, comments: sent.get(key) } : a]));
189
194
  }
190
195
  /** The option an Undo saves when the item had no answer before: an empty answer, so the item waits again. */
191
196
  export const CLEARED = { value: '', label: '' };
192
197
  /** Appends one answer line unless it equals the current answer and note, and returns the saved answer. Starts a new line after a torn fragment. */
193
- export function appendAnswer(decisionsPath, d, option, note = '') {
198
+ export function appendAnswer(decisionsPath, d, option, note = '', comments = []) {
194
199
  const current = readAnswers(decisionsPath)[d.key];
195
- if (option === CLEARED ? !current : current?.answer === option.value && (current.note ?? '') === note)
200
+ if (option === CLEARED ? !current : current?.answer === option.value && (current.note ?? '') === note && !comments.length)
196
201
  return;
197
- const answer = { key: d.key, answer: option.value, label: option.label, close: d.close, at: now(), ...(note && { note }) };
202
+ const answer = { key: d.key, answer: option.value, label: option.label, close: d.close, at: now(), ...(note && { note }), ...(comments.length && { comments }) };
198
203
  appendLine(answersPath(decisionsPath), answer);
199
204
  return answer;
200
205
  }
@@ -235,10 +240,20 @@ export function readRounds(decisionsPath, warn = () => { }) {
235
240
  });
236
241
  return rounds;
237
242
  }
238
- /** Asks the agent for another round on a question, carrying the person's note. */
239
- export function appendRound(decisionsPath, key, note) {
240
- appendLine(roundsPath(decisionsPath), { key, note, at: now() });
243
+ /** Asks the agent for another round on a question, carrying the person's note and the comments on its mocks. */
244
+ export function appendRound(decisionsPath, key, note, comments = []) {
245
+ appendLine(roundsPath(decisionsPath), { key, note, at: now(), ...(comments.length && { comments }) });
241
246
  }
247
+ /** Ids of the comments that went with an answer or a round: sent, so locked. */
248
+ export const sentIds = (decisionsPath) => new Set([answersPath, roundsPath].flatMap((path) => readRaw(path(decisionsPath)).split('\n').flatMap((line) => {
249
+ try {
250
+ const c = line && JSON.parse(line)?.comments;
251
+ return Array.isArray(c) ? c.map((x) => x?.id) : [];
252
+ }
253
+ catch {
254
+ return [];
255
+ }
256
+ })));
242
257
  /** Items hidden from the board, one line per key and change; the agent is not told, and an ask it waits on keeps waiting. */
243
258
  export const dismissedPath = (decisionsPath) => decisionsPath.replace(/\.json$/, '') + '.dismissed.jsonl';
244
259
  export function appendDismissed(decisionsPath, keys, dismissed) {
@@ -9,7 +9,7 @@ fixtures/contract/<kind>.v<schema>/valid-<name>.json
9
9
  fixtures/contract/<kind>.v<schema>/invalid-<field>-<rule>.json
10
10
  ```
11
11
 
12
- - `<kind>` is `ask`, `report`, `evidence`, `gate` or `finding`, and `<schema>` its schema number, such as `ask.v1/`. A version ships a folder for every kind it validates. This version ships `ask.v1/` and `report.v1/`.
12
+ - `<kind>` is `ask`, `report`, `evidence`, `gate` or `finding`, and `<schema>` its schema number, such as `ask.v1/`. A version ships a folder for every kind it validates. This version ships `ask.v1/`, `report.v1/` and `evidence.v1/`.
13
13
  - Each file holds one message. An evidence fixture that names `media` ships those files next to it.
14
14
  - Each kind has at least 2 valid files and 3 invalid ones: one with a required field missing, one over a limit, and one with an unknown field.
15
15
  - An invalid file breaks exactly one rule. Its name gives the field and the rule, such as `invalid-title-too-long.json`, and sign-off's own tests check that the refusal names that field.
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"evidence","schema":1,"title":"Sign-in screen on a phone","filer":"done-done","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456789abcdef0123456789abcdef01234567","outcome":"Umer can see the updated sign-in screen.","repro":"repro: n/a: no bug","proof":"verify-sign-off: real browser journey","evidence":"after.png"}}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"evidence","schema":1,"title":"Sign-in screen on a phone","filer":"done-done","about":"Umer signs in on a phone. The screen loads in 1 second.","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456","outcome":"Umer can see the updated sign-in screen.","repro":"repro: n/a: no bug","proof":"verify-sign-off: real browser journey","evidence":"after.png"}}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"evidence","schema":1,"title":"Sign-in screen on a phone","filer":"done-done","about":"Umer signs in on a phone. The screen loads in 1 second.","detail":"More words.","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456789abcdef0123456789abcdef01234567","outcome":"Umer can see the updated sign-in screen.","repro":"repro: n/a: no bug","proof":"verify-sign-off: real browser journey","evidence":"after.png"}}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"evidence","schema":1,"title":"Sign-in screen on a phone","filer":"done-done","about":"Umer signs in on a phone. The screen loads in 1 second.","media":["gone.png"]}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"evidence","schema":1,"title":"Sign-in screen on a phone","filer":"done-done","about":"Umer signs in on a phone. The screen loads in 1 second.","media":["../shot.png"]}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"evidence","schema":1,"key":"too-many","title":"Sign-in screen, second pass","about":"Umer checks the new layout.","media":["shot.png","shot.png","shot.png","shot.png","shot.png","shot.png","shot.png","shot.png","shot.png","shot.png","shot.png","shot.png","shot.png"],"link":"https://example.com/pr/4"}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"evidence","schema":1,"key":"signin-phone-2","title":"Sign-in screen, second pass","about":"Umer checks the new layout.","media":["shot.png"],"link":"https://example.com/pr/4"}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"evidence","schema":1,"title":"Sign-in screen on a phone","filer":"done-done","about":"Umer signs in on a phone. The screen loads in 1 second.","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456789abcdef0123456789abcdef01234567","outcome":"Umer can see the updated sign-in screen.","repro":"repro: n/a: no bug","proof":"verify-sign-off: real browser journey","evidence":"after.png"}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "One review board for evidence, quick yes/no decisions and retro changes.",
5
5
  "type": "module",
6
6
  "bin": { "sign-off-axi": "dist/cli.js" },
package/web/index.html CHANGED
@@ -133,10 +133,11 @@
133
133
  .sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
134
134
  .sec-head h2 { margin: 0; } .sec-head:has(+ .in-evidence) { margin: 1rem 0 0.375rem; }
135
135
  .rows { list-style: none; margin: 0; padding: 0 1rem; }
136
- .rows li + li { border-top: 1px solid var(--line); }
136
+ .rows > li + li { border-top: 1px solid var(--line); }
137
137
  .compact { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 0; color: var(--text); text-decoration: none; }
138
138
  .compact .t { display: grid; flex: 1; min-width: 0; }
139
- .ans { display: grid; justify-items: end; text-align: right; } .ans b { color: var(--c-answered); } .ans time { color: var(--muted); font-size: 0.875rem; }
139
+ /* The answer sits under the title, never beside it: a long answer beside the title squeezed it to a letter a line. */
140
+ .ans { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 0.5rem; margin-top: 0.125rem; } .ans b { color: var(--c-answered); } .ans time { color: var(--muted); font-size: 0.875rem; }
140
141
  .compact strong { font-size: 1.0625rem; font-weight: 600; line-height: 1.3; }
141
142
  .compact .meta { margin: 0.125rem 0 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
142
143
  .thumb, .compact .icon { width: 3.5rem; height: 3.5rem; border-radius: 0.625rem; }
@@ -238,7 +239,7 @@
238
239
  background: none; color: var(--muted); font-size: 1.5rem; line-height: 1.25; }
239
240
  .x:hover { background: var(--sunk); color: var(--text); }
240
241
  .head .st + .x { margin-left: 0; }
241
- .rows li { display: flex; align-items: center; }
242
+ .rows > li { display: flex; align-items: center; }
242
243
  .rows li .compact { flex: 1; min-width: 0; }
243
244
  .rows li .x { margin: 0; }
244
245
  .clear { min-height: 2.75rem; padding: 0; border: 0; background: none; color: var(--accent); font-weight: 600; }
@@ -340,8 +341,8 @@
340
341
  .reports { grid-template-columns: 1fr 1fr; align-items: start; }
341
342
  /* Answered sets each row as its own card, two across; an opened row takes the whole width. */
342
343
  .rows.i-answered { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; align-items: start; padding: 0; border: 0; background: none; box-shadow: none; }
343
- .rows.i-answered li { padding: 0 0.5rem 0 1rem; border: 1px solid var(--line); border-radius: 1rem; background: var(--surface); box-shadow: var(--shadow); }
344
- .rows.i-answered li:has(.done[open]) { grid-column: 1 / -1; }
344
+ .rows.i-answered > li { padding: 0 0.5rem 0 1rem; border: 1px solid var(--line); border-radius: 1rem; background: var(--surface); box-shadow: var(--shadow); }
345
+ .rows.i-answered > li:has(.done[open]) { grid-column: 1 / -1; }
345
346
  .upd.portrait > .media { width: 8rem; }
346
347
  /* Evidence sets its updates two across; a page that is not an update keeps the whole width. */
347
348
  .list.in-evidence { grid-template-columns: 1fr 1fr; align-items: start; } .in-evidence > .row:not(.update) { grid-column: 1 / -1; }
@@ -393,7 +394,7 @@
393
394
  const drop = clearIfSaved(d.key, drafts.get(d.key));
394
395
  call('/api/answers', {
395
396
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, answer: o.value, note }),
396
- }, typed ? undefined : drop).then(() => offerUndo(d, before)).then(() => typed && call('/api/messages', {
397
+ }, () => { editing.delete(d.key); if (!typed) drop(); }).then(() => offerUndo(d, before)).then(() => typed && call('/api/messages', {
397
398
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'comment', key: d.key, text: typed }),
398
399
  }, drop)).catch((e) => {
399
400
  shown(err).textContent = `Couldn't save: ${e.message}`;
@@ -562,9 +563,10 @@
562
563
  roundBtn.onclick = () => {
563
564
  roundBtn.disabled = true;
564
565
  const note = (drafts.get(d.key) ?? noteOf(d)).trim();
566
+ const drop = clearIfSaved(d.key, drafts.get(d.key));
565
567
  call('/api/rounds', {
566
568
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, note }),
567
- }, clearIfSaved(d.key, drafts.get(d.key))).catch((e) => {
569
+ }, () => { editing.delete(d.key); drop(); }).catch((e) => {
568
570
  shown(err).textContent = `Couldn't ask: ${e.message}`;
569
571
  roundBtn.disabled = false;
570
572
  });
@@ -580,7 +582,7 @@
580
582
  const hint = !d.options.length ? 'Reply in a few words' : words ? `Your own answer in a few words. It also goes, as a note, ${also}.` : `A note, sent ${also}.`;
581
583
  const fold = el('details', { class: 'other', ...((unfolded.has(d.key) || drafts.get(d.key)) && { open: '' }) },
582
584
  el('summary', { 'aria-label': `${label}, ${d.title}` }, `${label}${status && ` · ${status}`}`),
583
- asked.length ? el('p', { class: 'meta saved-note', dir: 'auto' }, `You asked for another round: ${asked[asked.length - 1].note}`) : '',
585
+ asked.length ? el('p', { class: 'meta saved-note', dir: 'auto' }, `You asked for another round: ${asked[asked.length - 1].note || 'with your comments on the mocks'}`) : '',
584
586
  ...comments.map((m) => el('p', { class: 'meta saved-note', dir: 'auto' }, `Your comment, ${m.delivered ? 'read by the agent' : 'not read yet'}: ${m.text}`)),
585
587
  el('label', { for: box.id }, hint), box,
586
588
  el('div', { class: 'actions' }, words ? sendBtn : '', round ? roundBtn : '', talk && !FROZEN ? commentBtn : ''));
@@ -668,16 +670,16 @@
668
670
  }));
669
671
  }
670
672
 
671
- /** Comments on mock elements, not sent yet: the open editor per item, then the list, each comment shown only on the mock file it was made on. */
673
+ /** Comments on mock elements: the open editor per item, then the unsent list, then the sent ones. An unsent comment shows only on the mock file it was made on. */
672
674
  const editing = new Map(), talks = () => !state.readonly && !state.answers_only && !FROZEN;
673
675
  const redraw = (d) => document.querySelectorAll('.ann').forEach((n) => n.dataset.ann === d.key && n.replaceWith(annotations(d)));
674
676
  function annotations(d) {
675
677
  const hash = new Map((d.mocks ?? []).map((m) => [m.value, m.hash])), open = editing.get(d.key);
676
- // A comment shows only on the mock file it was made on; the others are counted per mock.
678
+ // An unsent comment shows only on the mock file it was made on; the others are counted per mock. A sent one is always listed.
677
679
  const mine = [], old = new Map(), name = (m) => d.options.find((o) => o.value === m)?.label ?? m;
678
680
  for (const a of state.annotations ?? []) {
679
681
  if (a.key !== d.key) continue;
680
- if (hash.get(a.mock) === a.hash) mine.push(a);
682
+ if (a.sent || hash.get(a.mock) === a.hash) mine.push(a);
681
683
  else old.set(a.mock, (old.get(a.mock) ?? 0) + 1);
682
684
  }
683
685
  const what = (a) => `${name(a.mock)} · ${a.tag}${a.words ? ` “${a.words.length > 40 ? `${a.words.slice(0, 40)}…` : a.words}”` : ''}`;
@@ -702,20 +704,24 @@
702
704
  let form = '';
703
705
  if (open && pages.length) {
704
706
  const box = el('textarea', { id: `ann-text-${d.key}`, rows: 2, maxlength: 500, dir: 'auto' }), save = el('button', {}, 'Save comment'), cancel = el('button', { class: 'quick' }, 'Cancel');
705
- box.value = open.text ?? mine.find((a) => a.mock === open.mock && a.ref === open.ref)?.text ?? '';
707
+ box.value = open.text ?? mine.find((a) => !a.sent && a.mock === open.mock && a.ref === open.ref)?.text ?? '';
706
708
  box.oninput = () => (open.text = box.value);
707
709
  save.onclick = () => post(save, { id: open.id, key: d.key, mock: open.mock, ref: open.ref, tag: open.tag, words: open.words, text: box.value, hash: open.hash }, () => editing.delete(d.key));
708
710
  cancel.onclick = () => { editing.delete(d.key); redraw(d); };
709
711
  form = el('div', {}, el('label', { for: box.id }, `Comment on ${what(open)}`), box, el('div', { class: 'actions' }, save, cancel));
710
712
  }
711
- const rows = mine.map((a) => {
713
+ const row = (a, ...buttons) => el('li', {}, el('div', {}, el('p', { class: 'meta' }, what(a)), el('p', { class: 'saved-note', dir: 'auto' }, a.text)), ...buttons);
714
+ // A comment goes with the card's next answer or round; once sent it is the agent's and stays as it was.
715
+ const sent = mine.filter((a) => a.sent).map((a) => row(a));
716
+ const rows = mine.filter((a) => !a.sent).map((a) => {
712
717
  const edit = el('button', { class: 'quick', 'aria-label': `Edit comment, ${what(a)}` }, 'Edit'), del = el('button', { class: 'quick', 'aria-label': `Delete comment, ${what(a)}` }, 'Delete');
713
718
  edit.onclick = () => { editing.set(d.key, { ...a }); redraw(d); };
714
719
  del.onclick = () => post(del, { id: a.id, deleted: true }, () => { if (editing.get(d.key)?.mock === a.mock && editing.get(d.key)?.ref === a.ref) editing.delete(d.key); });
715
- return el('li', {}, el('div', {}, el('p', { class: 'meta' }, what(a)), el('p', { class: 'saved-note', dir: 'auto' }, a.text)), el('span', { class: 'actions' }, edit, del));
720
+ return row(a, el('span', { class: 'actions' }, edit, del));
716
721
  });
717
722
  return el('div', { class: 'ann', 'data-ann': d.key }, pages.length ? el('div', { class: 'actions' }, toggle) : '',
718
- on ? el('p', { class: 'meta' }, 'Tap an element in a mock to comment on it. Turn this off to open a mock full size.') : '', form, rows.length ? el('p', { class: 'meta' }, 'Your comments on the mocks, not sent yet') : '', rows.length ? el('ul', {}, ...rows) : '',
723
+ on ? el('p', { class: 'meta' }, 'Tap an element in a mock to comment on it. Turn this off to open a mock full size.') : '', form, ...(rows.length ? [el('p', { class: 'meta' }, 'Your comments on the mocks, not sent yet'), el('ul', {}, ...rows), el('p', { class: 'meta' }, 'They go with your pick, your Other answer or Another round.')] : []),
724
+ ...(sent.length ? [el('p', { class: 'meta' }, 'Sent to the agent'), el('ul', {}, ...sent)] : []),
719
725
  ...[...old].map(([m, n]) => el('p', { class: 'meta' }, `${n} comment${n > 1 ? 's' : ''} on an earlier version of “${name(m)}” ${n > 1 ? 'are' : 'is'} hidden`)), err);
720
726
  }
721
727
 
@@ -771,12 +777,12 @@
771
777
  done.length > SHOWN ? el('p', { class: 'meta' }, `and ${done.length - SHOWN} earlier`) : '');
772
778
  }
773
779
 
774
- /** A short row: a picture (or the area's mark) and the title; an answered item has its answer and time on the right. */
780
+ /** A short row: a picture (or the area's mark) and the title; an answered item has its answer and time under the title. */
775
781
  const compact = (d, a) => {
776
782
  const s = state.answers[d.key];
777
- return [thumb(a, pic(d)), el('span', { class: 't' }, el('strong', { dir: 'auto' }, title(d)), s ? '' : el('span', { class: 'meta', dir: 'auto' }, d.about ?? NAMES[d.type] ?? d.type)),
778
- s ? el('span', { class: 'ans' }, el('b', { dir: 'auto' }, s.answer === 'text' ? 'Your reply' : s.label),
779
- el('time', {}, isToday(s.at) ? hhmm(s.at) : day(s.at))) : ''];
783
+ return [thumb(a, pic(d)), el('span', { class: 't' }, el('strong', { dir: 'auto' }, title(d)), s
784
+ ? el('span', { class: 'ans' }, el('b', { dir: 'auto' }, s.answer === 'text' ? 'Your reply' : s.label), el('time', {}, isToday(s.at) ? hhmm(s.at) : day(s.at)))
785
+ : el('span', { class: 'meta', dir: 'auto' }, d.about ?? NAMES[d.type] ?? d.type))];
780
786
  };
781
787
  let jump = null; // The item a Summary row was tapped for, scrolled to on its area's screen.
782
788
  /** A short list of an area on the Summary: a picture and the title; a tap opens the area at that item. */