sign-off-axi 0.3.1 → 0.3.3

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.
Files changed (32) hide show
  1. package/README.md +7 -7
  2. package/dist/cli.js +209 -54
  3. package/dist/contract.js +134 -18
  4. package/dist/store.js +30 -12
  5. package/dist/vault.js +254 -21
  6. package/fixtures/contract/README.md +1 -1
  7. package/fixtures/contract/evidence.v1/invalid-about-missing.json +1 -0
  8. package/fixtures/contract/evidence.v1/invalid-commit-short.json +1 -0
  9. package/fixtures/contract/evidence.v1/invalid-detail-unknown.json +1 -0
  10. package/fixtures/contract/evidence.v1/invalid-media-not-there.json +1 -0
  11. package/fixtures/contract/evidence.v1/invalid-media-parent-path.json +1 -0
  12. package/fixtures/contract/evidence.v1/invalid-media-too-many.json +1 -0
  13. package/fixtures/contract/evidence.v1/shot.png +0 -0
  14. package/fixtures/contract/evidence.v1/valid-media.json +1 -0
  15. package/fixtures/contract/evidence.v1/valid-run.json +1 -0
  16. package/fixtures/contract/finding.v1/invalid-cause-missing.json +1 -0
  17. package/fixtures/contract/finding.v1/invalid-change_kind-unknown.json +1 -0
  18. package/fixtures/contract/finding.v1/invalid-evidence_ids-repeated.json +1 -0
  19. package/fixtures/contract/finding.v1/invalid-options-label-changed.json +1 -0
  20. package/fixtures/contract/finding.v1/invalid-proposal-too-long.json +1 -0
  21. package/fixtures/contract/finding.v1/invalid-record_text-unknown.json +1 -0
  22. package/fixtures/contract/finding.v1/valid-apply.json +1 -0
  23. package/fixtures/contract/finding.v1/valid-skip-no-records.json +1 -0
  24. package/fixtures/contract/gate.v1/invalid-default_if_silent-unknown.json +1 -0
  25. package/fixtures/contract/gate.v1/invalid-options-label-changed.json +1 -0
  26. package/fixtures/contract/gate.v1/invalid-options-too-few.json +1 -0
  27. package/fixtures/contract/gate.v1/invalid-run-missing.json +1 -0
  28. package/fixtures/contract/gate.v1/invalid-run-outcome-unknown.json +1 -0
  29. package/fixtures/contract/gate.v1/valid-three-actions.json +1 -0
  30. package/fixtures/contract/gate.v1/valid-two-actions.json +1 -0
  31. package/package.json +1 -1
  32. package/web/index.html +64 -35
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, evidence, gate and finding 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,12 +145,12 @@ 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 (words typed but not yet saved stay with their element while the page is open, so tapping another element and back keeps them; a reload drops them), 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`.
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).
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
+ - **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. `list` counts a dismissed item as neither waiting nor answered, as the board does (`list --full` says `dismissed` for an unanswered one and still its answer for an answered one), and `answers` does not count it as waiting; the agent is not told, so an ask it waits on keeps waiting, and `answers` still returns a dismissed answer. 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.
156
156
  - `export` writes one self-contained HTML file (`decisions.export.html` by default): every item, answer, note and another-round request with its local mocks and media inlined; comments and queued prompts are left out. `--out` must end in `.html`, so a wrong path never overwrites the board. It opens from disk with no server, is read-only, and makes no network request. A mock page is inlined on its own, so the css and images beside it show only on the running board.
@@ -159,10 +159,10 @@ sign-off-axi export [--out <file.html>] write a frozen read-only copy o
159
159
  - Raising a question that is already on the board is a no-op that reports its status, so an agent can re-run it safely. `--key` defaults to a slug of the title.
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
- - `--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.
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 still needs an answer. 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"?,"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
- - `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.
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 that still needs an answer and fails with `NOT_FOUND` when nothing needs one. 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.
167
167
 
168
168
  ```
package/dist/cli.js CHANGED
@@ -10,9 +10,9 @@ 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';
15
- import { sweepVault } from './vault.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
+ import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } 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';
18
18
  /** Coding agents init installs the skill for: the `npx skills add -a` name, the command that shows it is installed,
@@ -74,10 +74,13 @@ 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, evidence, gate and finding, 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.
79
+ A gate names its done-done run (run_id and commit) and offers 2 or 3 of approve (Approve), skip (Skip the judge) and fix (Fix first).
80
+ A finding gives a repeated cause, its counts, record ids (never record text) and a proposed change, and offers apply (Apply) and skip (Skip).
78
81
  Post one with \`sign-off-axi post --kind <kind> --from <message.json>\`, or --from - to read stdin.
79
82
  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.
83
+ 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
84
  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
85
  --kind each file must be this kind, so the wrong file is refused
83
86
  --expect-invalid every file must be refused: exits 0 only then, 2 when any passes (the conformance check of invalid fixtures)
@@ -88,6 +91,9 @@ examples:
88
91
  sign-off-axi validate ask.json
89
92
  sign-off-axi validate --kind ask messages/*.json
90
93
  sign-off-axi validate --kind report report.json
94
+ sign-off-axi validate --kind evidence proof/evidence.json
95
+ sign-off-axi validate --kind gate gate.json
96
+ sign-off-axi validate --kind finding finding.json
91
97
  sign-off-axi validate --expect-invalid node_modules/sign-off-axi/fixtures/contract/*/invalid-*.json
92
98
  `,
93
99
  answers: `usage: sign-off-axi answers [board.json] [--key <key>] [--full] [--json]
@@ -104,10 +110,10 @@ examples:
104
110
  Blocks until the user answers, then prints the answer the way answers does. Use it instead of a polling loop.
105
111
  --filer only the items posted with this --filer (or SIGN_OFF_FILER): returns every answer to them this filer has not taken yet,
106
112
  even one saved before wait started, and marks it taken, so the board shows "Seen by agent" on that card;
107
- NOT_FOUND when none of them is answered or waiting. Without --filer or SIGN_OFF_FILER, wait takes no filer
113
+ NOT_FOUND when none of them still needs an answer. Without --filer or SIGN_OFF_FILER, wait takes no filer
108
114
  --key wait for that decision; an answer saved before wait starts returns at once
109
115
  and a key not raised yet is waited for too
110
- without --key, wait for the next answer to any decision still waiting; NOT_FOUND when nothing is
116
+ without --key, wait for the next answer to any decision that still needs an answer; NOT_FOUND when nothing needs one
111
117
  --timeout seconds to wait, 600 (10 minutes) by default; then exits 1 with code TIMEOUT
112
118
  --full adds the button label, close and title
113
119
  --json {"<key>":{"answer","label","close","at","note"?}} for the new answers; note only when the user wrote one
@@ -156,6 +162,10 @@ examples:
156
162
  Posts an item for the user. --type decision (the default) is a yes/no or pick-one question, exactly like raise.
157
163
  --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
164
  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.
165
+ --kind evidence copies its media, read from the message file's folder (the current folder for stdin), into evidence/<number>/ like --type evidence;
166
+ a "run" is shown as the update's proof record. With no key it posts the next update-<number>.
167
+ --kind gate holds a done-done run for the user: it waits like an ask; run done-done respond --action <the picked value>.
168
+ --kind finding asks the user to apply or skip a retro's proposed change: it waits like an ask.
159
169
  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
170
  --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
171
  SIGN_OFF_FILER sets it for every command; with neither, the Claude Code session id is saved when there is one.
@@ -180,6 +190,9 @@ examples:
180
190
  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
191
  sign-off-axi post --type decision --title "Keep the weekly digest?" --recommend yes
182
192
  sign-off-axi post --kind ask --from ask.json --filer agent-41
193
+ sign-off-axi post --kind evidence --from proof/evidence.json --filer done-done
194
+ sign-off-axi post --kind gate --from gate.json --filer done-done
195
+ sign-off-axi post --kind finding --from finding.json --filer learn-from-mistakes
183
196
  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
197
  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
198
  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 +265,15 @@ const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report'];
252
265
  /** 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
266
  * A new item of a contract kind must carry them (contract section 2); one already on the board is not checked again. */
254
267
  function contractItems(items, from, file) {
255
- const had = new Set(existsSync(file) ? readItems(file).items.map((i) => i?.key) : []);
268
+ const on = new Map((existsSync(file) ? readItems(file).items : []).map((i) => [i?.key, i]));
256
269
  items.forEach((m, i) => {
257
- if (had.has(m?.key))
270
+ // 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.
271
+ // A stored board-format evidence item passes through a feed unchanged; any other evidence under a key is refused.
272
+ const kept = on.get(m?.key)?.type === 'evidence';
273
+ if ((m?.kind === 'evidence' && m?.type === undefined) || (!kept && (m?.kind ?? m?.type) === 'evidence')) {
274
+ 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>');
275
+ }
276
+ if (on.has(m?.key))
258
277
  return;
259
278
  if (m?.contract === undefined && m?.kind === undefined) {
260
279
  if (!CONTRACT_TYPES.includes(m?.type ?? 'decision'))
@@ -351,17 +370,19 @@ function filerOf(values, session = false) {
351
370
  }
352
371
  const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
353
372
  : `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
354
- /** Still waiting on the user: not answered, and something to answer (an item with no options and no reply is only shown). */
355
- const waits = (d) => !d.answer && (d.options.length > 0 || !!d.reply);
356
- /** The board's decisions with their latest answer; a missing board file is an empty board. */
373
+ /** Still waiting on the user: not answered, not dismissed, and something to answer (an item with no options and no reply is only shown). */
374
+ const waits = (d) => !d.dismissed && !d.answer && (d.options.length > 0 || !!d.reply);
375
+ /** The board's decisions with their latest answer; a missing board file is an empty board.
376
+ * A dismissal hides an item from the board and is not waiting here either, checked against the same dismissed log the board reads. */
357
377
  function board(file, say = warn) {
358
378
  if (!existsSync(file))
359
379
  return [];
360
380
  const decisions = loadDecisions(file), answers = readAnswers(file, say);
381
+ const hidden = new Set(readDismissed(file, answers, readRounds(file), say));
361
382
  for (const key of Object.keys(answers))
362
383
  if (!decisions.some((d) => d.key === key))
363
384
  say(`left out the answer for ${key}: that question is no longer in ${basename(file)}`);
364
- return decisions.map((d) => ({ ...d, answer: Object.hasOwn(answers, d.key) ? answers[d.key] : undefined }));
385
+ return decisions.map((d) => ({ ...d, answer: Object.hasOwn(answers, d.key) ? answers[d.key] : undefined, dismissed: hidden.has(d.key) }));
365
386
  }
366
387
  function ready(file, on, root) {
367
388
  if (!existsSync(file))
@@ -372,9 +393,9 @@ function ready(file, on, root) {
372
393
  }
373
394
  function list(args) {
374
395
  const { file, values, on } = parse('list', args, TIERS);
375
- const rows = board(file), waiting = rows.filter(waits), answered = rows.filter((d) => d.answer).length;
396
+ const rows = board(file), waiting = rows.filter(waits), answered = rows.filter((d) => d.answer && !d.dismissed).length;
376
397
  if (values.json)
377
- return JSON.stringify({ board: file, decisions: rows });
398
+ return JSON.stringify({ board: file, decisions: rows.map(({ dismissed, ...d }) => (dismissed ? { ...d, dismissed } : d)) });
378
399
  const help = [
379
400
  ...(waiting.length ? [`Run \`sign-off-axi open${on}\` to show the board to the user`] : []),
380
401
  ...(answered ? [`Run \`sign-off-axi answers${on}\` to read the ${answered} saved answers`] : []),
@@ -382,7 +403,7 @@ function list(args) {
382
403
  ];
383
404
  const where = tilde(file) + (existsSync(file) ? '' : ' (no file yet; raise creates it)');
384
405
  if (values.full) {
385
- return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: d.answer?.answer ?? 'waiting', title: d.title, about: d.about ?? '', detail: d.detail ?? '',
406
+ return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: d.answer?.answer ?? (d.dismissed ? 'dismissed' : 'waiting'), title: d.title, about: d.about ?? '', detail: d.detail ?? '',
386
407
  recommend: d.recommend_value ?? '', default_if_silent: d.default_if_silent ?? '', close: d.close })) }, help);
387
408
  }
388
409
  return toon({
@@ -396,20 +417,20 @@ function printAnswers(file, rows, waiting, values, help) {
396
417
  const done = rows.flatMap((d) => (d.answer ? [{ ...d.answer, title: d.title }] : []));
397
418
  const rounds = readRounds(file).filter((r) => rows.some((d) => d.key === r.key));
398
419
  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
- }));
420
+ const asked = (key) => rounds.filter((r) => r.key === key).map(({ note, at, comments }) => ({ note, at, comments }));
421
+ 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
422
  for (const r of rounds)
404
423
  if (!out[r.key])
405
- out[r.key] = { rounds: rounds.filter((q) => q.key === r.key).map(({ note, at }) => ({ note, at })) };
424
+ out[r.key] = { rounds: asked(r.key) };
406
425
  return JSON.stringify(out);
407
426
  }
408
- const notes = done.some((a) => a.note);
427
+ 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
428
  return toon({
410
429
  board: tilde(file),
411
430
  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
431
  ...(rounds.length && { rounds: rounds.map(({ key, note, at }) => ({ key, note, at })) }),
432
+ // Comments on mock elements that went with an answer or a round, each with the element it points at.
433
+ ...(sent.length && { comments: sent }),
413
434
  waiting,
414
435
  }, help);
415
436
  }
@@ -422,10 +443,13 @@ function answers(args) {
422
443
  return printAnswers(file, rows, waiting, values, waiting ? [`Run \`sign-off-axi open${on}\` so the user can answer the ${waiting} waiting`, `Run \`sign-off-axi wait${on}${key}\` to block until they answer`] : []);
423
444
  }
424
445
  async function wait(args) {
425
- const { file, values, on: onBoard } = parse('wait', args, { ...TIERS, filer: { type: 'string' }, key: { type: 'string' }, timeout: { type: 'string', default: '600' } });
446
+ const { file, values, on: onBoard } = parse('wait', args, { ...TIERS, filer: { type: 'string' }, key: { type: 'string' }, timeout: { type: 'string', default: '600' }, into: { type: 'string' } });
426
447
  if (!/^\d{1,7}$/.test(values.timeout))
427
448
  throw fail('--timeout must be whole seconds, 0 or more', 'VALIDATION_ERROR', `Run \`sign-off-axi wait --help\``);
428
449
  const filer = filerOf(values), on = onBoard + (filer ? ` --filer ${filer}` : '');
450
+ // Vault Give delivery: its own loop, before any board read, so no ordinary wait changes. Undocumented while the vault is OFF.
451
+ if (values.into !== undefined)
452
+ return waitInto(file, values, onBoard);
429
453
  if (values.key === undefined)
430
454
  ready(file, onBoard);
431
455
  const seconds = Number(values.timeout), deadline = Date.now() + seconds * 1000, key = values.key === undefined ? '' : ` --key ${values.key}`;
@@ -437,7 +461,7 @@ async function wait(args) {
437
461
  for (const r of readRounds(file))
438
462
  seenRounds.set(r.key, (seenRounds.get(r.key) ?? 0) + 1);
439
463
  // A save wakes the loop at once; the 1 second timer is the fallback where file events are unavailable.
440
- let wake = () => { }, watcher;
464
+ let wake = () => { }, watcher, vaultWarned = false;
441
465
  const name = basename(answersPath(file));
442
466
  try {
443
467
  try {
@@ -466,7 +490,36 @@ async function wait(args) {
466
490
  if (found.length) {
467
491
  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`]);
468
492
  }
469
- if (values.key === undefined && !waiting) {
493
+ // Private vault expiries and refusals reach a filer wait only once its ordinary answers are out; each is taken once.
494
+ let live = 0, unknown = false;
495
+ if (filer !== undefined && values.key === undefined) {
496
+ const vault = await takeVaultExpiries(file, filer).catch(() => undefined);
497
+ unknown = !vault;
498
+ if ((!vault || !vault.clean) && !vaultWarned) {
499
+ vaultWarned = true;
500
+ warn('Vault expiry status unavailable; ordinary answers still arrive.');
501
+ }
502
+ if (vault?.events.length)
503
+ return vaultEvent(file, vault.events, values);
504
+ live = vault?.live ?? 0;
505
+ }
506
+ // A keyed filer wait reads its own item's expiry or refusal, unless one of its board rows already holds the key.
507
+ if (filer !== undefined && values.key !== undefined && isKey(values.key) && !rows.some((d) => d.key === values.key)) {
508
+ let event;
509
+ try {
510
+ event = await readVaultExpiry(file, values.key, filer, true);
511
+ }
512
+ catch (e) {
513
+ if (e?.code !== 'READER_MISMATCH' && !vaultWarned) {
514
+ vaultWarned = true;
515
+ warn('Vault expiry status unavailable; ordinary answers still arrive.');
516
+ }
517
+ }
518
+ if (event)
519
+ return vaultEvent(file, [{ key: values.key, at: event.at, status: event.status }], values);
520
+ }
521
+ // A dismissed ask is hidden from the board, not withdrawn: it is not counted as waiting, but the agent still waits on it.
522
+ if (values.key === undefined && !rows.some((d) => waits({ ...d, dismissed: false })) && !live && !unknown) {
470
523
  throw fail(`nothing${filer ? ` posted by ${filer}` : ''} is waiting on ${tilde(file)}`, 'NOT_FOUND', `Run \`sign-off-axi answers${onBoard}\` to read the saved answers`, `Run \`sign-off-axi raise${on} --title "<question>"\` to ask a new one`);
471
524
  }
472
525
  const left = deadline - Date.now();
@@ -485,6 +538,55 @@ async function wait(args) {
485
538
  watcher?.close();
486
539
  }
487
540
  }
541
+ /** Metadata only: a fixed sentence, never a value, URL, path or filer. Failures exit 1; a delivery exits 0 with a label.
542
+ * One poll takes one kind, so all events share a status. */
543
+ const VAULT_EVENTS = {
544
+ expired: { sentence: errors.EXPIRED, code: 'EXPIRED' },
545
+ refused: { sentence: errors.REFUSED, code: 'REFUSED' },
546
+ 'delivery-uncertain': { sentence: errors.DELIVERY_UNCERTAIN, code: 'DELIVERY_UNCERTAIN' },
547
+ delivered: { sentence: 'Saved to the approved destination file.' },
548
+ };
549
+ function vaultEvent(file, events, values) {
550
+ const status = events[0].status, { sentence, code } = VAULT_EVENTS[status], at = (ms) => new Date(ms).toISOString().slice(0, 19) + 'Z';
551
+ if (code)
552
+ process.exitCode = 1;
553
+ if (values.json)
554
+ return JSON.stringify(Object.fromEntries(events.map((e) => [e.key, { answer: status, label: sentence, close: 'done', at: at(e.at) }])));
555
+ return toon({ board: tilde(file), [status]: events.map((e) => ({ key: e.key, at: at(e.at) })), ...(code ? { error: sentence, code } : { label: sentence }) }, code ? ['Ask for a fresh item under a new key'] : []);
556
+ }
557
+ // Only these leave the item deliverable later; every other vault code is final for this wait.
558
+ const INTO_RETRY = ['BUSY', 'NOT_FOUND', 'VAULT_KEY_UNAVAILABLE'];
559
+ const INTO_HELP = { DESTINATION_DENIED: 'Fix the destination folder locally, then run the same wait again', DESTINATION_EXISTS: 'Fix the destination folder locally, then run the same wait again',
560
+ READER_MISMATCH: 'Check --key, --filer and --into against the item', VALIDATION_ERROR: 'Check --key, --filer and --into against the item' };
561
+ /** `wait --into`: delivers one Give once into its bound file, polling while it is pending. The destination is never printed. */
562
+ async function waitInto(file, values, onBoard) {
563
+ const { key, filer, into } = values, seconds = Number(values.timeout), deadline = Date.now() + seconds * 1000;
564
+ // An explicit --filer: an inherited SIGN_OFF_FILER must not route a secret write.
565
+ if (key === undefined || filer === undefined)
566
+ throw fail('wait --into needs --key and --filer', 'VALIDATION_ERROR', 'Pass the Give item\'s --key and your own --filer');
567
+ let warned = false;
568
+ for (;;) {
569
+ let done;
570
+ try {
571
+ done = await deliverVault(file, key, filer, into);
572
+ }
573
+ catch (e) {
574
+ const code = e instanceof VaultError ? e.code : 'BUSY';
575
+ if (!INTO_RETRY.includes(code))
576
+ throw fail(errors[code], code, INTO_HELP[code] ?? 'Ask for a fresh item under a new key');
577
+ if (code !== 'NOT_FOUND' && !warned) {
578
+ warned = true;
579
+ warn('Vault delivery status unavailable; still waiting.');
580
+ }
581
+ }
582
+ if (done)
583
+ return vaultEvent(file, [done], values);
584
+ const left = deadline - Date.now();
585
+ if (left <= 0)
586
+ throw fail(`no vault item with key ${key} was ready to deliver within ${seconds}s`, 'TIMEOUT', `Run \`sign-off-axi wait${onBoard} --key ${key} --filer ${filer} --into <file> --timeout ${seconds}\` to keep waiting`);
587
+ await new Promise((ok) => setTimeout(ok, Math.min(1000, left)));
588
+ }
589
+ }
488
590
  const slug = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 64).replace(/^-+|-+$/g, '') || 'decision';
489
591
  /** Runs fn while holding an exclusive lock file that names its pid, so two agents raising at once never drop a decision,
490
592
  * and a lock a crash left behind can be told apart from one in use. */
@@ -597,7 +699,13 @@ function replace(file, on, from, filer) {
597
699
  fits(i?.title, i?.key);
598
700
  const feed = fed.items.map((i) => ({ filed: first.get(i?.key)?.filed ?? now, ...i, ...(filer && { filer }) }));
599
701
  // An answer belongs to its question: an answered key may not come back asking something else.
600
- const question = (d) => JSON.stringify([d.title, [...d.options].sort((a, b) => a.value.localeCompare(b.value)), !!d.reply]);
702
+ const question = (d) => JSON.stringify([d.title, [...d.options].sort((a, b) => a.value.localeCompare(b.value)), !!d.reply, d.run, d.finding]);
703
+ // A gate's answer is the action for its run, and a finding's the verdict on its proposal, so a dropped answered one cannot come back.
704
+ const saved = readAnswers(file);
705
+ for (const i of fed.items)
706
+ if ((i?.kind === 'gate' || i?.kind === 'finding') && !first.has(i?.key) && Object.hasOwn(saved, i?.key)) {
707
+ throw fail(`key ${i.key} was answered on ${tilde(file)} and is no longer there`, 'CONFLICT', 'Post it under a new key');
708
+ }
601
709
  for (const had of board(file)) {
602
710
  const d = incoming.find((x) => x.key === had.key);
603
711
  if (filer && d && had.filer && had.filer !== filer)
@@ -633,9 +741,9 @@ function replace(file, on, from, filer) {
633
741
  return toon({ board: tilde(file), items: items.length, changed }, [`Run \`sign-off-axi answers${on}\` to read the answers`]);
634
742
  });
635
743
  }
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) {
744
+ /** 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.
745
+ * `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`. */
746
+ function postMedia(file, on, values, type, filer, message) {
639
747
  fits(values.title);
640
748
  const explainer = type === 'explainer';
641
749
  const extra = (explainer ? ['mock', 'tradeoff', 'view', 'link'] : ['key', 'option', 'recommend', 'default', 'mock', 'tradeoff', 'view', 'chapter']).find((f) => values[f] !== undefined);
@@ -647,7 +755,7 @@ function postMedia(file, on, values, type, filer) {
647
755
  throw fail('--about is required for --type evidence: the short note', 'VALIDATION_ERROR', example);
648
756
  if (explainer && values.media?.length !== 1)
649
757
  throw fail('--type explainer needs one --media video (.mp4 or .webm)', 'VALIDATION_ERROR', example);
650
- if (!values.media?.length || values.media.length > MEDIA_MAX)
758
+ if ((!message && !values.media?.length) || values.media.length > MEDIA_MAX)
651
759
  throw fail(`--type evidence needs 1 to ${MEDIA_MAX} --media files`, 'VALIDATION_ERROR', example);
652
760
  const kinds = explainer ? ['.mp4', '.webm'] : Object.keys(mediaTypes), options = optionsOf(values);
653
761
  recommended([{ options, recommend_value: values.recommend }], true);
@@ -672,7 +780,15 @@ function postMedia(file, on, values, type, filer) {
672
780
  const { items, extra } = existsSync(file) ? readItems(file) : { items: [], extra: {} };
673
781
  // Boards in one folder share evidence/, so a number is taken only by creating its folder; one in use is never written into.
674
782
  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));
783
+ const own = message ? message.key : explainer && (values.key ?? slug(values.title)), had = message && own && items.find((x) => x?.key === own);
784
+ if (had) {
785
+ // The stored item, read back as the message it came from: its run, and each media file by hash.
786
+ const asMessage = ({ type: _t, number: _n, filed: _f, evidence: run, media: kept, ...rest }) => canon({ ...rest, run, media: kept?.map((x) => x.sha256) });
787
+ if (asMessage(had) !== canon({ ...message, media: sources.length ? sources.map((src) => sha256(readFileSync(src))) : undefined })) {
788
+ throw fail(`key ${own} already holds a different message on ${tilde(file)}`, 'CONFLICT', 'Post it under a new key');
789
+ }
790
+ 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`]);
791
+ }
676
792
  if (own && current.some((d) => d.key === own))
677
793
  throw fail(`key ${own} is already used on ${tilde(file)}`, 'CONFLICT', 'Post it under a new --key');
678
794
  mkdirSync(store, { recursive: true, mode: 0o700 });
@@ -688,14 +804,24 @@ function postMedia(file, on, values, type, filer) {
688
804
  }
689
805
  }
690
806
  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
- });
807
+ let media;
808
+ try {
809
+ media = sources.map((src, i) => {
810
+ const bytes = readFileSync(src), path = `evidence/${number}/${i + 1}${extname(src).toLowerCase()}`;
811
+ writeFileSync(join(dir, `${i + 1}${extname(src).toLowerCase()}`), bytes, { mode: 0o600 });
812
+ return { path, sha256: sha256(bytes), bytes: bytes.length };
813
+ });
814
+ }
815
+ catch (e) {
816
+ // A file that cannot be read after all leaves no copies behind.
817
+ rmSync(dir, { recursive: true, force: true });
818
+ throw e;
819
+ }
696
820
  const { title, about, detail, close, link, recommend: recommend_value, default: default_if_silent } = values, filed = new Date().toISOString();
821
+ const { run, media: _paths, ...fields } = message ?? {};
697
822
  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 });
823
+ : message ? { ...fields, key, type, number, ...(media.length ? { media } : {}), ...(run && { evidence: run }), filed }
824
+ : { key, type, number, title, about, detail, close, link, media, filer, filed });
699
825
  // The whole board is checked before it is written, so a bad post leaves the board and evidence/ as they were.
700
826
  const check = `${file}.${process.pid}.check`;
701
827
  writeFileSync(check, JSON.stringify({ ...extra, schema: 2, items }), { mode: 0o600 });
@@ -712,7 +838,7 @@ function postMedia(file, on, values, type, filer) {
712
838
  writeItems(file, items);
713
839
  if (explainer)
714
840
  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)]);
841
+ 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
842
  });
717
843
  }
718
844
  /** 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 +856,13 @@ async function postKind(file, on, values) {
730
856
  catch (e) {
731
857
  throw fail(`${name}: cannot read: ${e.code}`, 'NOT_FOUND', 'Check the path names a readable file');
732
858
  }
859
+ let sources = [];
733
860
  try {
734
861
  m = parseMessage(bytes);
735
862
  checkMessage(m, kind);
863
+ // Media paths read from the message file's folder, or from the current folder for stdin.
864
+ if (m.kind === 'evidence')
865
+ sources = mediaFiles(m, from === '-' ? process.cwd() : dirname(resolve(from)));
736
866
  }
737
867
  catch (e) {
738
868
  throw e instanceof ContractError ? fail(`${name}: ${e.message}`, 'VALIDATION_ERROR', e.help) : e;
@@ -742,6 +872,8 @@ async function postKind(file, on, values) {
742
872
  }
743
873
  const filer = m.filer ?? filerOf(values, true), scratch = mkdtempSync(join(tmpdir(), 'sign-off-')), source = join(scratch, basename(name));
744
874
  try {
875
+ if (m.kind === 'evidence')
876
+ return await postMedia(file, on, { title: m.title, about: m.about, media: sources }, 'evidence', filer, { ...m, ...(filer && { filer }) });
745
877
  writeFileSync(source, JSON.stringify({ schema: 2, items: [{ ...m, ...(filer && { filer }) }] }));
746
878
  return await storeIncoming(file, on, buildIncoming({ from: source }, {}, filer, file), filer, true);
747
879
  }
@@ -782,7 +914,10 @@ function validate(args) {
782
914
  }
783
915
  try {
784
916
  m = parseMessage(raw);
785
- return { file, ...checkMessage(m, want), ok: true, error: '', help: '' };
917
+ const kind = checkMessage(m, want);
918
+ if (m.kind === 'evidence')
919
+ mediaFiles(m, dirname(resolve(file)));
920
+ return { file, ...kind, ok: true, error: '', help: '' };
786
921
  }
787
922
  catch (e) {
788
923
  if (!(e instanceof ContractError))
@@ -843,22 +978,28 @@ function storeIncoming(file, on, { incoming, raw }, filer, exact = false) {
843
978
  const current = board(file), items = existsSync(file) ? readItems(file).items : [], saved = readAnswers(file), now = new Date().toISOString();
844
979
  const raised = incoming.map((d, i) => {
845
980
  const had = current.find((c) => c.key === d.key);
981
+ // A contract message refused for its key exits 2 like any other refusal (contract section 4); raise --title keeps exit 1.
982
+ const contract = exact || raw[i]?.contract !== undefined || raw[i]?.kind !== undefined;
983
+ const refuse = (why) => {
984
+ if (contract)
985
+ process.once('exit', () => { process.exitCode = 2; });
986
+ return fail(`key ${d.key} ${why}`, 'CONFLICT', contract ? 'Post it under a new key' : 'Raise it under a new --key');
987
+ };
846
988
  // An answer outlives its item: a new question under that key would inherit it.
847
989
  if (!had && Object.hasOwn(saved, d.key))
848
- throw fail(`key ${d.key} already has a saved answer on ${tilde(file)} from an item no longer there`, 'CONFLICT', 'Raise it under a new --key');
990
+ throw refuse(`already has a saved answer on ${tilde(file)} from an item no longer there`);
849
991
  // The same question from another filer, or at another time, is the same question: it keeps its first filer and time.
850
- const same = (x) => JSON.stringify({ ...x, answer: undefined, filer: undefined, filed: undefined, options: [...x.options].sort((a, b) => a.value.localeCompare(b.value)) });
992
+ const same = (x) => JSON.stringify({ ...x, answer: undefined, dismissed: undefined, filer: undefined, filed: undefined, options: [...x.options].sort((a, b) => a.value.localeCompare(b.value)) });
851
993
  // A message that names no filed time matches the one the board stamped.
852
994
  const differs = () => exact ? canon({ ...items.find((x) => x?.key === d.key), ...(raw[i].filed === undefined && { filed: undefined }) }) !== canon(raw[i]) : same(had) !== same(d);
853
- if (had && differs()) {
854
- throw fail(`key ${d.key} already holds a different ${exact ? 'message' : 'question'} on ${tilde(file)}`, 'CONFLICT', 'Raise it under a new --key');
855
- }
995
+ if (had && differs())
996
+ throw refuse(`already holds a different ${contract ? 'message' : 'question'} on ${tilde(file)}`);
856
997
  // Each new item records when it was raised, unless it names its own time.
857
998
  if (!had) {
858
999
  fits(d.title, d.key);
859
1000
  items.push({ filed: now, ...raw[i] });
860
1001
  }
861
- return { key: d.key, status: !had ? 'raised' : had.answer ? `already answered ${had.answer.answer}` : waits(had) ? 'already waiting' : 'already posted', title: d.title };
1002
+ return { key: d.key, status: !had ? 'raised' : had.answer ? `already answered ${had.answer.answer}` : waits({ ...had, dismissed: false }) ? 'already waiting' : 'already posted', title: d.title };
862
1003
  });
863
1004
  if (raised.some((r) => r.status === 'raised'))
864
1005
  writeItems(file, items);
@@ -1474,7 +1615,9 @@ const page = readFileSync(new URL('../web/index.html', import.meta.url));
1474
1615
  const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
1475
1616
  const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
1476
1617
  const state = () => {
1477
- const answers = readAnswers(file, warn), taken = seenBy(file), rounds = readRounds(file, warn);
1618
+ // The page marks sent comments in annotations, so answers and rounds go without theirs: a read-only board or an export carries none.
1619
+ const answers = Object.fromEntries(Object.entries(readAnswers(file, warn)).map(([k, { comments: _, ...a }]) => [k, a])), taken = seenBy(file);
1620
+ const rounds = readRounds(file, warn).map(({ comments: _, ...r }) => r);
1478
1621
  return {
1479
1622
  file,
1480
1623
  mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
@@ -1487,7 +1630,7 @@ const state = () => {
1487
1630
  seen: Object.values(answers).filter(taken).map((a) => a.key),
1488
1631
  rounds,
1489
1632
  messages: readMessages(file, warn),
1490
- annotations: readOnly() || answersOnly() ? [] : readAnnotations(file, warn),
1633
+ annotations: readOnly() || answersOnly() ? [] : ((sent) => readAnnotations(file, warn).map((a) => (sent.has(a.id) ? { ...a, sent: true } : a)))(sentIds(file)),
1491
1634
  dismissed: readDismissed(file, answers, rounds, warn),
1492
1635
  };
1493
1636
  };
@@ -1499,6 +1642,12 @@ const mockHash = (path) => { try {
1499
1642
  catch {
1500
1643
  return null;
1501
1644
  } };
1645
+ /** 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. */
1646
+ const unsent = (d) => {
1647
+ const sent = sentIds(file), now = new Map((d.mocks ?? []).map((m) => [m.value, mockHash(m.path)]));
1648
+ return answersOnly() ? [] : readAnnotations(file).filter((a) => a.key === d.key && !sent.has(a.id) && now.get(a.mock) === a.hash)
1649
+ .map(({ id, mock, hash, ref, tag, words, text }) => ({ id, mock, hash, ref, tag, words, text }));
1650
+ };
1502
1651
  /** A board whose file says `"readonly": true` shows its items and takes no answers, rounds or comments: another program feeds it. */
1503
1652
  const readOnly = () => readItems(file).extra.readonly === true;
1504
1653
  /** A board whose file says `"answers_only": true` takes answers and replies but no rounds, comments or prompts: another program feeds it. */
@@ -1657,7 +1806,10 @@ async function route(req) {
1657
1806
  return [200, state()];
1658
1807
  }
1659
1808
  if (req.url === '/api/annotations') {
1660
- const { id, deleted, key, mock, ref, tag, words, text, hash: shown } = JSON.parse(body) ?? {}, live = readAnnotations(file);
1809
+ const { id, deleted, key, mock, ref, tag, words, text, hash: shown } = JSON.parse(body) ?? {}, live = readAnnotations(file), sent = sentIds(file);
1810
+ // A comment that went with an answer or a round is the agent's now: it stays as it was sent.
1811
+ if (id !== undefined && sent.has(id))
1812
+ return [409, { error: 'This comment was sent' }];
1661
1813
  if (deleted === true) {
1662
1814
  if (!live.some((a) => a.id === id))
1663
1815
  return [404, { error: 'This comment is gone' }];
@@ -1679,7 +1831,7 @@ async function route(req) {
1679
1831
  if (id !== undefined && !live.some((a) => a.id === id))
1680
1832
  return [404, { error: 'This comment is gone' }];
1681
1833
  // One comment per element: another on the same element of the same file edits the first.
1682
- const same = live.find((a) => a.key === key && a.mock === mock && a.hash === hash && a.ref === ref);
1834
+ const same = live.find((a) => a.key === key && a.mock === mock && a.hash === hash && a.ref === ref && !sent.has(a.id));
1683
1835
  appendAnnotation(file, { id: same?.id, key, mock, hash, ref, tag, words, text: text.trim() });
1684
1836
  return [200, state()];
1685
1837
  }
@@ -1697,11 +1849,14 @@ async function route(req) {
1697
1849
  const { key, note = '' } = JSON.parse(body) ?? {};
1698
1850
  if (typeof note !== 'string' || [...note.trim()].length > NOTE_MAX)
1699
1851
  return [400, { error: `The note must be text of ${NOTE_MAX} characters or fewer` }];
1700
- if (!note.trim())
1701
- return [400, { error: 'Write a note first: another round needs your comment' }];
1702
- if (!loadDecisions(file).some((x) => x.key === key))
1852
+ const d = loadDecisions(file).find((x) => x.key === key);
1853
+ if (!d)
1703
1854
  return [404, { error: 'This question is no longer in the file' }];
1704
- appendRound(file, key, note.trim());
1855
+ // Comments on the mocks are enough for a round; with none, the round needs a note.
1856
+ const comments = unsent(d);
1857
+ if (!note.trim() && !comments.length)
1858
+ return [400, { error: 'Write a note first: another round needs your comment' }];
1859
+ appendRound(file, key, note.trim(), comments);
1705
1860
  return [200, state()];
1706
1861
  }
1707
1862
  const { key, answer, note = '' } = JSON.parse(body) ?? {};
@@ -1728,7 +1883,7 @@ async function route(req) {
1728
1883
  const other = d.reply || d.options.length ? [REPLY] : [], option = d.options.find((o) => o.value === answer) ?? other.find((o) => o.value === answer);
1729
1884
  if (!option)
1730
1885
  return [400, { error: `The answer must be one of: ${[...d.options, ...other].map((o) => o.label).join(', ') || 'none, this item is only shown'}` }];
1731
- answered(appendAnswer(file, d, option, note.trim()), d);
1886
+ answered(appendAnswer(file, d, option, note.trim(), unsent(d)), d);
1732
1887
  return [200, state()];
1733
1888
  }
1734
1889
  /** 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. */