sign-off-axi 0.3.2 → 0.3.4

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,26 +131,26 @@ 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, report and evidence 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
138
138
  sign-off-axi serve [--port 4410] [--bind <address>] [--evidence-root <dir>] run the board in the foreground (--port 0 picks a free port)
139
139
  sign-off-axi answers [--key <key>] the latest answer per decision, and how many still wait
140
- sign-off-axi wait [--filer <id>] [--key <key>] [--timeout 600] block until the user answers, then print it like answers
141
- (--filer: only that filer's items; without --key each answer once)
140
+ sign-off-axi wait [--filer <id>] [--key <key>] [--timeout 600] [--events (needs --filer)] block until the user answers, then print it like answers; --events prints one JSON event line per answer and round (docs/contract.md section 7)
141
+ (--filer: only that filer's items, each marked taken; without --key each answer once)
142
142
  sign-off-axi poll [--timeout 600] block until the user comments or queues a prompt, then print each new one once
143
143
  sign-off-axi export [--out <file.html>] write a frozen read-only copy of the board as one HTML file
144
144
  ```
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, 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.
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
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. 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).
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"?,"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.
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 (with `--key`, that key's answer even if taken), 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 (`{"event":"answer","contract":1,"kind","schema","key","answer","label","close","at","note"?,"title"?,"filer"?,"comments"?}`, where `kind` and `schema` are the item's contract kind, or `ask` and 1 for a board-format item, and `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
@@ -12,7 +12,7 @@ import { encode } from '@toon-format/toon';
12
12
  import { AxiError, runAxiCli } from 'axi-sdk-js';
13
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
14
  import { ContractError, KINDS, MEDIA_BYTES, checkMessage, mediaFiles, parseMessage } from './contract.js';
15
- import { sweepVault } from './vault.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,
@@ -29,7 +29,7 @@ const TOP_HELP = `usage: sign-off-axi [command] [board.json] [flags]
29
29
  commands[15]:
30
30
  (none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, doctor, validate, help
31
31
  board: the nearest .sign-off/ board (the walk's bounds and SIGN_OFF_HOME: README, "Test runs never touch your board"), else ~/.sign-off/decisions.json; a board file given always wins
32
- flags: --full and --json on list, answers and wait, --json on status, --filer on post, raise and wait, --help on every command, -v/--version
32
+ flags: --full and --json on list, answers and wait, --events on wait, --json on status, --filer on post, raise and wait, --help on every command, -v/--version
33
33
  examples:
34
34
  sign-off-axi
35
35
  sign-off-axi raise --title "Ship the new sign-in screen this week?" --recommend yes
@@ -74,8 +74,10 @@ 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, report and evidence, 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
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).
79
81
  Post one with \`sign-off-axi post --kind <kind> --from <message.json>\`, or --from - to read stdin.
80
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.
81
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.
@@ -90,6 +92,8 @@ examples:
90
92
  sign-off-axi validate --kind ask messages/*.json
91
93
  sign-off-axi validate --kind report report.json
92
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
93
97
  sign-off-axi validate --expect-invalid node_modules/sign-off-axi/fixtures/contract/*/invalid-*.json
94
98
  `,
95
99
  answers: `usage: sign-off-axi answers [board.json] [--key <key>] [--full] [--json]
@@ -102,22 +106,26 @@ examples:
102
106
  sign-off-axi answers --key keep-the-weekly-digest
103
107
  sign-off-axi answers ./decisions.json --json
104
108
  `,
105
- wait: `usage: sign-off-axi wait [board.json] [--filer <id>] [--key <key>] [--timeout 600] [--full] [--json]
109
+ wait: `usage: sign-off-axi wait [board.json] [--filer <id>] [--key <key>] [--timeout 600] [--full] [--json | --events]
106
110
  Blocks until the user answers, then prints the answer the way answers does. Use it instead of a polling loop.
107
111
  --filer only the items posted with this --filer (or SIGN_OFF_FILER): returns every answer to them this filer has not taken yet,
108
112
  even one saved before wait started, and marks it taken, so the board shows "Seen by agent" on that card;
109
- 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
110
114
  --key wait for that decision; an answer saved before wait starts returns at once
111
115
  and a key not raised yet is waited for too
112
- 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
113
117
  --timeout seconds to wait, 600 (10 minutes) by default; then exits 1 with code TIMEOUT
114
118
  --full adds the button label, close and title
115
119
  --json {"<key>":{"answer","label","close","at","note"?}} for the new answers; note only when the user wrote one
120
+ --events needs --filer (or SIGN_OFF_FILER); one JSON line per new answer and per another-round ask, in the order they were saved (${CONTRACT}#7-answers):
121
+ {"event":"answer","contract":1,"kind","schema","key","title","answer","label","close","at","note"?,"filer"?,"comments"?}
122
+ {"event":"round","contract":1,"kind","schema","key","title","note","at","filer"?,"comments"?}; a board-format item reports as ask schema 1
116
123
  A note column appears when any shown answer has a note.
117
124
  Ctrl-C stops waiting; nothing is changed.
118
125
  examples:
119
126
  sign-off-axi wait --key keep-the-weekly-digest
120
127
  sign-off-axi wait ./decisions.json --timeout 60 --json
128
+ sign-off-axi wait --filer agent-41 --events
121
129
  `,
122
130
  open: `usage: sign-off-axi open [board.json] [--port 4410] [--bind <address>] [--evidence-root <dir>]
123
131
  Starts the board in the background on 127.0.0.1 and prints its URL and pid; a no-op when this board is already open.
@@ -160,6 +168,8 @@ Posts an item for the user. --type decision (the default) is a yes/no or pick-on
160
168
  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
169
  --kind evidence copies its media, read from the message file's folder (the current folder for stdin), into evidence/<number>/ like --type evidence;
162
170
  a "run" is shown as the update's proof record. With no key it posts the next update-<number>.
171
+ --kind gate holds a done-done run for the user: it waits like an ask; run done-done respond --action <the picked value>.
172
+ --kind finding asks the user to apply or skip a retro's proposed change: it waits like an ask.
163
173
  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.
164
174
  --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.
165
175
  SIGN_OFF_FILER sets it for every command; with neither, the Claude Code session id is saved when there is one.
@@ -185,6 +195,8 @@ examples:
185
195
  sign-off-axi post --type decision --title "Keep the weekly digest?" --recommend yes
186
196
  sign-off-axi post --kind ask --from ask.json --filer agent-41
187
197
  sign-off-axi post --kind evidence --from proof/evidence.json --filer done-done
198
+ sign-off-axi post --kind gate --from gate.json --filer done-done
199
+ sign-off-axi post --kind finding --from finding.json --filer learn-from-mistakes
188
200
  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
189
201
  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
190
202
  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"
@@ -362,17 +374,19 @@ function filerOf(values, session = false) {
362
374
  }
363
375
  const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
364
376
  : `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
365
- /** Still waiting on the user: not answered, and something to answer (an item with no options and no reply is only shown). */
366
- const waits = (d) => !d.answer && (d.options.length > 0 || !!d.reply);
367
- /** The board's decisions with their latest answer; a missing board file is an empty board. */
377
+ /** Still waiting on the user: not answered, not dismissed, and something to answer (an item with no options and no reply is only shown). */
378
+ const waits = (d) => !d.dismissed && !d.answer && (d.options.length > 0 || !!d.reply);
379
+ /** The board's decisions with their latest answer; a missing board file is an empty board.
380
+ * A dismissal hides an item from the board and is not waiting here either, checked against the same dismissed log the board reads. */
368
381
  function board(file, say = warn) {
369
382
  if (!existsSync(file))
370
383
  return [];
371
384
  const decisions = loadDecisions(file), answers = readAnswers(file, say);
385
+ const hidden = new Set(readDismissed(file, answers, readRounds(file), say));
372
386
  for (const key of Object.keys(answers))
373
387
  if (!decisions.some((d) => d.key === key))
374
388
  say(`left out the answer for ${key}: that question is no longer in ${basename(file)}`);
375
- return decisions.map((d) => ({ ...d, answer: Object.hasOwn(answers, d.key) ? answers[d.key] : undefined }));
389
+ return decisions.map((d) => ({ ...d, answer: Object.hasOwn(answers, d.key) ? answers[d.key] : undefined, dismissed: hidden.has(d.key) }));
376
390
  }
377
391
  function ready(file, on, root) {
378
392
  if (!existsSync(file))
@@ -383,9 +397,9 @@ function ready(file, on, root) {
383
397
  }
384
398
  function list(args) {
385
399
  const { file, values, on } = parse('list', args, TIERS);
386
- const rows = board(file), waiting = rows.filter(waits), answered = rows.filter((d) => d.answer).length;
400
+ const rows = board(file), waiting = rows.filter(waits), answered = rows.filter((d) => d.answer && !d.dismissed).length;
387
401
  if (values.json)
388
- return JSON.stringify({ board: file, decisions: rows });
402
+ return JSON.stringify({ board: file, decisions: rows.map(({ dismissed, ...d }) => (dismissed ? { ...d, dismissed } : d)) });
389
403
  const help = [
390
404
  ...(waiting.length ? [`Run \`sign-off-axi open${on}\` to show the board to the user`] : []),
391
405
  ...(answered ? [`Run \`sign-off-axi answers${on}\` to read the ${answered} saved answers`] : []),
@@ -393,7 +407,7 @@ function list(args) {
393
407
  ];
394
408
  const where = tilde(file) + (existsSync(file) ? '' : ' (no file yet; raise creates it)');
395
409
  if (values.full) {
396
- 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 ?? '',
410
+ 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 ?? '',
397
411
  recommend: d.recommend_value ?? '', default_if_silent: d.default_if_silent ?? '', close: d.close })) }, help);
398
412
  }
399
413
  return toon({
@@ -432,11 +446,35 @@ function answers(args) {
432
446
  const waiting = rows.filter(waits).length, key = values.key === undefined ? '' : ` --key ${values.key}`;
433
447
  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`] : []);
434
448
  }
449
+ /** The contract fields an answer or round reports with: the item's kind and schema, or ask schema 1 for a board-format or dropped item (contract section 2). */
450
+ function kindOf(file) {
451
+ const items = new Map((existsSync(file) ? readItems(file).items : []).map((i) => [i?.key, i]));
452
+ return (key) => {
453
+ const i = items.get(key), contract = typeof i?.kind === 'string';
454
+ return { contract: 1, kind: contract ? i.kind : 'ask', schema: contract ? i.schema : 1 };
455
+ };
456
+ }
457
+ /** One JSON line per answer and per round (contract section 7), oldest first. */
458
+ function eventLines(file, rows, answers, rounds) {
459
+ const kind = kindOf(file), item = new Map(rows.map((d) => [d.key, d]));
460
+ const head = (event, key) => ({ event, ...kind(key), key, title: item.get(key)?.title });
461
+ const tail = (key, comments) => ({ ...(item.get(key)?.filer && { filer: item.get(key).filer }), ...(comments?.length && { comments }) });
462
+ return [...answers.map(({ key, answer, label, close, at, note, comments }) => ({ at, line: { ...head('answer', key), answer, label, close, at, ...(note && { note }), ...tail(key, comments) } })),
463
+ ...rounds.map(({ key, note, at, comments }) => ({ at, line: { ...head('round', key), note, at, ...tail(key, comments) } }))]
464
+ .sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)).map(({ line }) => JSON.stringify(line)).join('\n');
465
+ }
435
466
  async function wait(args) {
436
- const { file, values, on: onBoard } = parse('wait', args, { ...TIERS, filer: { type: 'string' }, key: { type: 'string' }, timeout: { type: 'string', default: '600' } });
467
+ const { file, values, on: onBoard } = parse('wait', args, { ...TIERS, events: { type: 'boolean' }, filer: { type: 'string' }, key: { type: 'string' }, timeout: { type: 'string', default: '600' }, into: { type: 'string' } });
437
468
  if (!/^\d{1,7}$/.test(values.timeout))
438
469
  throw fail('--timeout must be whole seconds, 0 or more', 'VALIDATION_ERROR', `Run \`sign-off-axi wait --help\``);
470
+ if (values.events && (values.json || values.full))
471
+ throw fail(`--events prints event lines, so it does not take --${values.json ? 'json' : 'full'}`, 'VALIDATION_ERROR', 'Run `sign-off-axi wait --filer <id> --events`');
439
472
  const filer = filerOf(values), on = onBoard + (filer ? ` --filer ${filer}` : '');
473
+ if (values.events && filer === undefined)
474
+ throw fail(`--events prints a filer's answer events, so it needs --filer`, 'VALIDATION_ERROR', 'Run `sign-off-axi wait --filer <id> --events`');
475
+ // Vault Give delivery: its own loop, before any board read, so no ordinary wait changes. Undocumented while the vault is OFF.
476
+ if (values.into !== undefined)
477
+ return waitInto(file, values, onBoard);
440
478
  if (values.key === undefined)
441
479
  ready(file, onBoard);
442
480
  const seconds = Number(values.timeout), deadline = Date.now() + seconds * 1000, key = values.key === undefined ? '' : ` --key ${values.key}`;
@@ -448,7 +486,7 @@ async function wait(args) {
448
486
  for (const r of readRounds(file))
449
487
  seenRounds.set(r.key, (seenRounds.get(r.key) ?? 0) + 1);
450
488
  // A save wakes the loop at once; the 1 second timer is the fallback where file events are unavailable.
451
- let wake = () => { }, watcher;
489
+ let wake = () => { }, watcher, vaultWarned = false;
452
490
  const name = basename(answersPath(file));
453
491
  try {
454
492
  try {
@@ -458,26 +496,65 @@ async function wait(args) {
458
496
  for (let first = true;; first = false) {
459
497
  const rows = mine(board(file, first ? warn : () => { })), waiting = rows.filter(waits).length, taken = filer === undefined ? undefined : seenBy(file);
460
498
  const hit = rows.filter((d) => (values.key !== undefined ? d.key === values.key : d.answer && (taken ? !taken(d.answer) : !before.has(d.key))));
461
- const counts = new Map();
462
- for (const r of readRounds(file, first ? warn : () => { }))
499
+ const counts = new Map(), asked = [];
500
+ for (const r of readRounds(file, first ? warn : () => { })) {
463
501
  counts.set(r.key, (counts.get(r.key) ?? 0) + 1);
502
+ if (counts.get(r.key) > (seenRounds.get(r.key) ?? 0))
503
+ asked.push(r);
504
+ }
464
505
  const fresh = rows.filter((d) => (counts.get(d.key) ?? 0) > (seenRounds.get(d.key) ?? 0)
465
506
  && (values.key === undefined || d.key === values.key));
466
507
  for (const [k, n] of counts)
467
508
  seenRounds.set(k, n);
468
509
  let found = [...new Map([...hit.filter((d) => d.answer), ...fresh].map((d) => [d.key, d])).values()];
510
+ // The answers this wait hands over: a filer wait without --key gives only the ones it has not taken yet.
511
+ let given = hit.flatMap((d) => (d.answer ? [d.answer] : []));
469
512
  if (found.length && filer !== undefined) {
470
513
  // Taken under a lock, so two waits for one filer never both hand over the same answer (another-round asks are not deduped here).
471
514
  found = await locked(`${seenPath(file)}.lock`, () => {
472
515
  const now = seenBy(file), left = found.filter((d) => values.key !== undefined || fresh.includes(d) || !now(d.answer));
473
- markSeen(file, left.flatMap((d) => (d.answer && !now(d.answer) ? [d.answer] : [])));
516
+ const taking = left.flatMap((d) => (d.answer && !now(d.answer) ? [d.answer] : []));
517
+ markSeen(file, taking);
518
+ if (values.key === undefined)
519
+ given = taking;
474
520
  return left;
475
521
  }, 'another wait is still taking answers');
476
522
  }
523
+ if (found.length && values.events)
524
+ return eventLines(file, found, given, asked.filter((r) => fresh.some((d) => d.key === r.key)));
477
525
  if (found.length) {
478
526
  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`]);
479
527
  }
480
- if (values.key === undefined && !waiting) {
528
+ // Private vault expiries and refusals reach a filer wait only once its ordinary answers are out; each is taken once.
529
+ let live = 0, unknown = false;
530
+ if (filer !== undefined && values.key === undefined) {
531
+ const vault = await takeVaultExpiries(file, filer).catch(() => undefined);
532
+ unknown = !vault;
533
+ if ((!vault || !vault.clean) && !vaultWarned) {
534
+ vaultWarned = true;
535
+ warn('Vault expiry status unavailable; ordinary answers still arrive.');
536
+ }
537
+ if (vault?.events.length)
538
+ return vaultEvent(file, vault.events, values);
539
+ live = vault?.live ?? 0;
540
+ }
541
+ // A keyed filer wait reads its own item's expiry or refusal, unless one of its board rows already holds the key.
542
+ if (filer !== undefined && values.key !== undefined && isKey(values.key) && !rows.some((d) => d.key === values.key)) {
543
+ let event;
544
+ try {
545
+ event = await readVaultExpiry(file, values.key, filer, true);
546
+ }
547
+ catch (e) {
548
+ if (e?.code !== 'READER_MISMATCH' && !vaultWarned) {
549
+ vaultWarned = true;
550
+ warn('Vault expiry status unavailable; ordinary answers still arrive.');
551
+ }
552
+ }
553
+ if (event)
554
+ return vaultEvent(file, [{ key: values.key, at: event.at, status: event.status }], values);
555
+ }
556
+ // A dismissed ask is hidden from the board, not withdrawn: it is not counted as waiting, but the agent still waits on it.
557
+ if (values.key === undefined && !rows.some((d) => waits({ ...d, dismissed: false })) && !live && !unknown) {
481
558
  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`);
482
559
  }
483
560
  const left = deadline - Date.now();
@@ -496,6 +573,55 @@ async function wait(args) {
496
573
  watcher?.close();
497
574
  }
498
575
  }
576
+ /** Metadata only: a fixed sentence, never a value, URL, path or filer. Failures exit 1; a delivery exits 0 with a label.
577
+ * One poll takes one kind, so all events share a status. */
578
+ const VAULT_EVENTS = {
579
+ expired: { sentence: errors.EXPIRED, code: 'EXPIRED' },
580
+ refused: { sentence: errors.REFUSED, code: 'REFUSED' },
581
+ 'delivery-uncertain': { sentence: errors.DELIVERY_UNCERTAIN, code: 'DELIVERY_UNCERTAIN' },
582
+ delivered: { sentence: 'Saved to the approved destination file.' },
583
+ };
584
+ function vaultEvent(file, events, values) {
585
+ const status = events[0].status, { sentence, code } = VAULT_EVENTS[status], at = (ms) => new Date(ms).toISOString().slice(0, 19) + 'Z';
586
+ if (code)
587
+ process.exitCode = 1;
588
+ if (values.json)
589
+ return JSON.stringify(Object.fromEntries(events.map((e) => [e.key, { answer: status, label: sentence, close: 'done', at: at(e.at) }])));
590
+ 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'] : []);
591
+ }
592
+ // Only these leave the item deliverable later; every other vault code is final for this wait.
593
+ const INTO_RETRY = ['BUSY', 'NOT_FOUND', 'VAULT_KEY_UNAVAILABLE'];
594
+ 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',
595
+ READER_MISMATCH: 'Check --key, --filer and --into against the item', VALIDATION_ERROR: 'Check --key, --filer and --into against the item' };
596
+ /** `wait --into`: delivers one Give once into its bound file, polling while it is pending. The destination is never printed. */
597
+ async function waitInto(file, values, onBoard) {
598
+ const { key, filer, into } = values, seconds = Number(values.timeout), deadline = Date.now() + seconds * 1000;
599
+ // An explicit --filer: an inherited SIGN_OFF_FILER must not route a secret write.
600
+ if (key === undefined || filer === undefined)
601
+ throw fail('wait --into needs --key and --filer', 'VALIDATION_ERROR', 'Pass the Give item\'s --key and your own --filer');
602
+ let warned = false;
603
+ for (;;) {
604
+ let done;
605
+ try {
606
+ done = await deliverVault(file, key, filer, into);
607
+ }
608
+ catch (e) {
609
+ const code = e instanceof VaultError ? e.code : 'BUSY';
610
+ if (!INTO_RETRY.includes(code))
611
+ throw fail(errors[code], code, INTO_HELP[code] ?? 'Ask for a fresh item under a new key');
612
+ if (code !== 'NOT_FOUND' && !warned) {
613
+ warned = true;
614
+ warn('Vault delivery status unavailable; still waiting.');
615
+ }
616
+ }
617
+ if (done)
618
+ return vaultEvent(file, [done], values);
619
+ const left = deadline - Date.now();
620
+ if (left <= 0)
621
+ 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`);
622
+ await new Promise((ok) => setTimeout(ok, Math.min(1000, left)));
623
+ }
624
+ }
499
625
  const slug = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 64).replace(/^-+|-+$/g, '') || 'decision';
500
626
  /** Runs fn while holding an exclusive lock file that names its pid, so two agents raising at once never drop a decision,
501
627
  * and a lock a crash left behind can be told apart from one in use. */
@@ -608,7 +734,13 @@ function replace(file, on, from, filer) {
608
734
  fits(i?.title, i?.key);
609
735
  const feed = fed.items.map((i) => ({ filed: first.get(i?.key)?.filed ?? now, ...i, ...(filer && { filer }) }));
610
736
  // An answer belongs to its question: an answered key may not come back asking something else.
611
- const question = (d) => JSON.stringify([d.title, [...d.options].sort((a, b) => a.value.localeCompare(b.value)), !!d.reply]);
737
+ const question = (d) => JSON.stringify([d.title, [...d.options].sort((a, b) => a.value.localeCompare(b.value)), !!d.reply, d.run, d.finding]);
738
+ // 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.
739
+ const saved = readAnswers(file);
740
+ for (const i of fed.items)
741
+ if ((i?.kind === 'gate' || i?.kind === 'finding') && !first.has(i?.key) && Object.hasOwn(saved, i?.key)) {
742
+ throw fail(`key ${i.key} was answered on ${tilde(file)} and is no longer there`, 'CONFLICT', 'Post it under a new key');
743
+ }
612
744
  for (const had of board(file)) {
613
745
  const d = incoming.find((x) => x.key === had.key);
614
746
  if (filer && d && had.filer && had.filer !== filer)
@@ -881,26 +1013,28 @@ function storeIncoming(file, on, { incoming, raw }, filer, exact = false) {
881
1013
  const current = board(file), items = existsSync(file) ? readItems(file).items : [], saved = readAnswers(file), now = new Date().toISOString();
882
1014
  const raised = incoming.map((d, i) => {
883
1015
  const had = current.find((c) => c.key === d.key);
1016
+ // A contract message refused for its key exits 2 like any other refusal (contract section 4); raise --title keeps exit 1.
1017
+ const contract = exact || raw[i]?.contract !== undefined || raw[i]?.kind !== undefined;
1018
+ const refuse = (why) => {
1019
+ if (contract)
1020
+ process.once('exit', () => { process.exitCode = 2; });
1021
+ return fail(`key ${d.key} ${why}`, 'CONFLICT', contract ? 'Post it under a new key' : 'Raise it under a new --key');
1022
+ };
884
1023
  // An answer outlives its item: a new question under that key would inherit it.
885
1024
  if (!had && Object.hasOwn(saved, d.key))
886
- 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');
1025
+ throw refuse(`already has a saved answer on ${tilde(file)} from an item no longer there`);
887
1026
  // The same question from another filer, or at another time, is the same question: it keeps its first filer and time.
888
- const same = (x) => JSON.stringify({ ...x, answer: undefined, filer: undefined, filed: undefined, options: [...x.options].sort((a, b) => a.value.localeCompare(b.value)) });
1027
+ 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)) });
889
1028
  // A message that names no filed time matches the one the board stamped.
890
1029
  const differs = () => exact ? canon({ ...items.find((x) => x?.key === d.key), ...(raw[i].filed === undefined && { filed: undefined }) }) !== canon(raw[i]) : same(had) !== same(d);
891
- if (had && differs()) {
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');
897
- }
1030
+ if (had && differs())
1031
+ throw refuse(`already holds a different ${contract ? 'message' : 'question'} on ${tilde(file)}`);
898
1032
  // Each new item records when it was raised, unless it names its own time.
899
1033
  if (!had) {
900
1034
  fits(d.title, d.key);
901
1035
  items.push({ filed: now, ...raw[i] });
902
1036
  }
903
- return { key: d.key, status: !had ? 'raised' : had.answer ? `already answered ${had.answer.answer}` : waits(had) ? 'already waiting' : 'already posted', title: d.title };
1037
+ 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 };
904
1038
  });
905
1039
  if (raised.some((r) => r.status === 'raised'))
906
1040
  writeItems(file, items);
@@ -1796,7 +1930,7 @@ function answered(a, d) {
1796
1930
  child.on('error', (e) => warn(`on_answer ${cmd} did not run: ${e.message}`));
1797
1931
  child.on('exit', (code, signal) => (code || signal) && warn(`on_answer ${cmd} exited with ${code ?? signal}`));
1798
1932
  child.stdin.on('error', () => { });
1799
- child.stdin.end(`${JSON.stringify({ ...a, ...(d && { title: d.title }), ...(d?.filer && { filer: d.filer }) })}\n`);
1933
+ child.stdin.end(`${JSON.stringify({ event: 'answer', ...kindOf(file)(a.key), ...a, ...(d && { title: d.title }), ...(d?.filer && { filer: d.filer }) })}\n`);
1800
1934
  }
1801
1935
  const server = createServer(async (req, res) => {
1802
1936
  const { port: bound } = server.address();
package/dist/contract.js CHANGED
@@ -10,17 +10,24 @@ export class ContractError extends Error {
10
10
  this.help = help;
11
11
  }
12
12
  }
13
- export const KINDS = ['ask', 'report', 'evidence', 'gate', 'finding'];
14
- /** The kinds this build checks, with the schema number of each. */
15
- export const SCHEMAS = { ask: 1, report: 1, evidence: 1 };
13
+ /** The kinds, with the schema number of each. */
14
+ export const SCHEMAS = { ask: 1, report: 1, evidence: 1, gate: 1, finding: 1 };
15
+ export const KINDS = Object.keys(SCHEMAS);
16
16
  const COMMON = ['contract', 'kind', 'schema', 'key', 'title', 'filer', 'link', 'about'];
17
17
  const FIELDS = {
18
18
  ask: [...COMMON, 'detail', 'options', 'recommend_value', 'default_if_silent', 'close'],
19
19
  report: [...COMMON, 'filed', 'group', 'decided'],
20
20
  evidence: [...COMMON, 'media', 'run'],
21
+ gate: [...COMMON, 'run', 'options', 'recommend_value', 'close'],
22
+ finding: [...COMMON, 'cause', 'incidents', 'days', 'evidence_ids', 'change_kind', 'proposal', 'options', 'recommend_value'],
21
23
  };
22
24
  /** A done-done run: its id, full commit and the four Checks lines. */
23
25
  const RUN = ['run_id', 'commit', 'outcome', 'repro', 'proof', 'evidence'];
26
+ /** The fixed options of a kind, each with its one label: a gate's are the actions of done-done respond --action. */
27
+ const ACTIONS = {
28
+ gate: { approve: 'Approve', skip: 'Skip the judge', fix: 'Fix first' },
29
+ finding: { apply: 'Apply', skip: 'Skip' },
30
+ };
24
31
  export const MEDIA_BYTES = 50_000_000;
25
32
  /** Parses one message; a field written twice in any object refuses it, since plain JSON.parse keeps the last one quietly. */
26
33
  export function parseMessage(bytes) {
@@ -92,6 +99,24 @@ export function checkMessage(m, want) {
92
99
  if ([...v].length > max)
93
100
  throw bad(field, `is ${[...v].length} characters, over ${max}`, `Shorten "${name}" to ${max} characters`);
94
101
  };
102
+ /** A done-done run block with exactly these fields: an id, a full commit, and on evidence the four Checks lines. */
103
+ const checkRun = (fields) => {
104
+ const r = m.run;
105
+ if (!r || typeof r !== 'object' || Array.isArray(r))
106
+ throw bad('run', 'must be an object', `Set "run" to {${fields.map(show).join(', ')}}`);
107
+ const odd = Object.keys(r).find((f) => !fields.includes(f));
108
+ if (odd !== undefined)
109
+ throw bad(`run.${escaped(odd) || '""'}`, 'is not a field of run', `Remove ${show(odd)}; run has only ${fields.join(', ')}`);
110
+ const missing = fields.find((f) => !Object.hasOwn(r, f));
111
+ if (missing)
112
+ throw bad(`run.${missing}`, 'is required', `Add "${missing}" to "run"`);
113
+ if (!isKey(r.run_id))
114
+ 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');
115
+ if (!(typeof r.commit === 'string' && /^[a-f0-9]{40}$/.test(r.commit)))
116
+ 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');
117
+ for (const f of fields.slice(2))
118
+ text(`run.${f}`, r[f], 1000, `the run's ${f} Checks line, in 1000 characters or fewer`);
119
+ };
95
120
  need('contract');
96
121
  if (m.contract !== 1)
97
122
  throw bad('contract', `must be 1, not ${show(m.contract)}`, whole(m.contract) ? 'Upgrade sign-off-axi: this one knows contract 1' : 'Set "contract": 1');
@@ -100,8 +125,6 @@ export function checkMessage(m, want) {
100
125
  throw bad('kind', `must be one of ${KINDS.join(', ')}, not ${show(m.kind)}`, `Set "kind" to one of ${KINDS.join(', ')}`);
101
126
  if (want !== undefined && m.kind !== want)
102
127
  throw bad('kind', `is ${m.kind}, not --kind ${want}`, `Pass --kind ${m.kind}, or name the ${want} message file`);
103
- if (!SCHEMAS[m.kind])
104
- throw bad('kind', `${m.kind} is not checked by this sign-off-axi yet`, `Upgrade sign-off-axi to a version that checks ${m.kind}`);
105
128
  need('schema');
106
129
  if (!whole(m.schema))
107
130
  throw bad('schema', `must be a whole number, not ${show(m.schema)}`, `Set "schema": ${SCHEMAS[m.kind]}`);
@@ -123,7 +146,7 @@ export function checkMessage(m, want) {
123
146
  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');
124
147
  }
125
148
  if (m.kind !== 'ask') {
126
- const what = m.kind === 'report' ? 'what happened' : 'what this shows';
149
+ const what = { report: 'what happened', gate: 'why the run is held', finding: 'the counts, with no record text' }[m.kind] ?? 'what this shows';
127
150
  if (!Object.hasOwn(m, 'about'))
128
151
  throw bad('about', 'is required', `Add "about": ${what}, in 500 characters or fewer`);
129
152
  text('about', m.about, 500, `${what}, in 500 characters or fewer`);
@@ -142,23 +165,8 @@ export function checkMessage(m, want) {
142
165
  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
166
  });
144
167
  }
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
- }
168
+ if (Object.hasOwn(m, 'run'))
169
+ checkRun(RUN);
162
170
  return { kind: m.kind, schema: m.schema };
163
171
  }
164
172
  if (m.kind === 'report') {
@@ -177,10 +185,12 @@ export function checkMessage(m, want) {
177
185
  text('about', m.about, 500, 'a short note, or leave it out');
178
186
  if (Object.hasOwn(m, 'detail'))
179
187
  text('detail', m.detail, 1000, 'the longer note, or leave it out');
180
- const options = m.options;
181
- if (!Array.isArray(options) || options.length < 2 || options.length > 6) {
182
- throw bad('options', Object.hasOwn(m, 'options') ? 'must list 2 to 6 options' : 'is required', 'Set "options" to 2-6 objects such as {"value":"yes","label":"Yes"}');
183
- }
188
+ // A gate offers 2 or 3 of done-done's actions and a finding both of apply and skip, in any order, each with its own label.
189
+ const options = m.options, actions = ACTIONS[m.kind], max = actions ? Object.keys(actions).length : 6;
190
+ const named = actions && Object.entries(actions).map(([value, label]) => show({ value, label })).join(', ');
191
+ const listed = !actions ? 'Set "options" to 2-6 objects such as {"value":"yes","label":"Yes"}' : max === 3 ? `Set "options" to 2 or 3 of ${named}` : `Set "options" to ${named}`;
192
+ if (!Array.isArray(options) || options.length < 2 || options.length > max)
193
+ throw bad('options', Object.hasOwn(m, 'options') ? `must list ${{ 2: '2', 3: '2 or 3' }[max] ?? '2 to 6'} options` : 'is required', listed);
184
194
  options.forEach((o, n) => {
185
195
  const field = `options[${n}]`;
186
196
  if (!o || typeof o !== 'object' || Array.isArray(o))
@@ -194,6 +204,9 @@ export function checkMessage(m, want) {
194
204
  throw bad(`${field}.value`, `repeats ${o.value}`, 'Give every option its own value');
195
205
  text(`${field}.label`, o.label, 80, 'the button text, in 80 characters or fewer');
196
206
  });
207
+ const odd = actions && options.find((o) => !Object.hasOwn(actions, o.value) || actions[o.value] !== o.label);
208
+ if (odd)
209
+ throw bad('options', `${show(odd)} is not a ${m.kind} action: ${Object.keys(actions).join(', ')}, each with its label`, listed);
197
210
  const values = options.map((o) => o.value), pick = `the option you would pick: one of ${values.join(', ')}`;
198
211
  if (!Object.hasOwn(m, 'recommend_value'))
199
212
  throw bad('recommend_value', 'is required', `Add "recommend_value": ${show(values[0])}, ${pick}`);
@@ -204,6 +217,35 @@ export function checkMessage(m, want) {
204
217
  }
205
218
  if (Object.hasOwn(m, 'close') && m.close !== 'done' && m.close !== 'release')
206
219
  throw bad('close', 'must be done or release', 'Set "close" to "done" or "release", or leave it out');
220
+ // A gate's run is its own rule (section 5), so it comes after the field rules of section 4.
221
+ if (m.kind === 'gate') {
222
+ if (!Object.hasOwn(m, 'run'))
223
+ throw bad('run', 'is required', 'Add "run": {"run_id": "<done-done run id>", "commit": "<full commit>"}, the run held for approval');
224
+ checkRun(RUN.slice(0, 2));
225
+ }
226
+ // A finding names its records by id only; the validator cannot see record text, so keeping it out is the producer's job.
227
+ if (m.kind === 'finding') {
228
+ const must = (f, ok, rule, help) => {
229
+ if (!Object.hasOwn(m, f))
230
+ throw bad(f, 'is required', help);
231
+ if (!ok(m[f]))
232
+ throw bad(f, rule, help);
233
+ };
234
+ must('cause', (v) => typeof v === 'string' && /^[a-z0-9-]{1,64}$/.test(v), 'must be 1-64 of a-z 0-9 -', 'Set "cause" to the repeated cause, such as "missing-env"');
235
+ for (const f of ['incidents', 'days'])
236
+ must(f, whole, 'must be a whole number, 0 or more', `Set "${f}" to a count, such as 4`);
237
+ must('evidence_ids', (v) => Array.isArray(v) && v.length <= 50, 'must list 0 to 50 record ids', 'Set "evidence_ids" to the ids of the records, such as ["rec-7f3a"], never their text');
238
+ m.evidence_ids.forEach((id, n) => {
239
+ if (!isKey(id))
240
+ throw bad(`evidence_ids[${n}]`, 'must be 1-128 of A-Z a-z 0-9 . _ -', 'Name each record by its id, never its text');
241
+ if (m.evidence_ids.indexOf(id) < n)
242
+ throw bad(`evidence_ids[${n}]`, `repeats ${id}`, 'List each record id once');
243
+ });
244
+ must('change_kind', (v) => ['doc', 'rule', 'code'].includes(v), 'must be doc, rule or code', 'Set "change_kind" to "doc", "rule" or "code"');
245
+ if (!Object.hasOwn(m, 'proposal'))
246
+ throw bad('proposal', 'is required', 'Add "proposal": the change, in 500 characters or fewer');
247
+ text('proposal', m.proposal, 500, 'the change, in 500 characters or fewer');
248
+ }
207
249
  return { kind: m.kind, schema: m.schema };
208
250
  }
209
251
  /** 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,
package/dist/store.js CHANGED
@@ -165,6 +165,10 @@ export function loadDecisions(path, board) {
165
165
  ...(d.steps && { steps: d.steps.map(({ title, marks }) => ({ title, ...(marks?.length && { marks }) })) }),
166
166
  ...(stored && d.media && { media: d.media.map(({ path, sha256, bytes }) => ({ path, sha256, bytes })) }),
167
167
  ...(d.type === 'evidence' && { number: d.number }),
168
+ // A gate's run is part of its question: the same key with another run is a different question.
169
+ ...(d.kind === 'gate' && d.run && { run: { run_id: d.run.run_id, commit: d.run.commit } }),
170
+ // So is a finding's proposal, with the counts and records behind it.
171
+ ...(d.kind === 'finding' && { finding: { cause: d.cause, incidents: d.incidents, days: d.days, evidence_ids: d.evidence_ids, change_kind: d.change_kind, proposal: d.proposal } }),
168
172
  ...(d.type === 'explainer' && d.chapters && { chapters: d.chapters.map(({ at, title }) => ({ at, title })) }),
169
173
  };
170
174
  });