sign-off-axi 0.3.2 → 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.
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, 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
@@ -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, 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.
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
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
@@ -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,
@@ -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]
@@ -106,10 +110,10 @@ examples:
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
@@ -160,6 +164,8 @@ Posts an item for the user. --type decision (the default) is a yes/no or pick-on
160
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.
161
165
  --kind evidence copies its media, read from the message file's folder (the current folder for stdin), into evidence/<number>/ like --type evidence;
162
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.
163
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.
164
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.
165
171
  SIGN_OFF_FILER sets it for every command; with neither, the Claude Code session id is saved when there is one.
@@ -185,6 +191,8 @@ examples:
185
191
  sign-off-axi post --type decision --title "Keep the weekly digest?" --recommend yes
186
192
  sign-off-axi post --kind ask --from ask.json --filer agent-41
187
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
188
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
189
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
190
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"
@@ -362,17 +370,19 @@ function filerOf(values, session = false) {
362
370
  }
363
371
  const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
364
372
  : `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. */
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. */
368
377
  function board(file, say = warn) {
369
378
  if (!existsSync(file))
370
379
  return [];
371
380
  const decisions = loadDecisions(file), answers = readAnswers(file, say);
381
+ const hidden = new Set(readDismissed(file, answers, readRounds(file), say));
372
382
  for (const key of Object.keys(answers))
373
383
  if (!decisions.some((d) => d.key === key))
374
384
  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 }));
385
+ return decisions.map((d) => ({ ...d, answer: Object.hasOwn(answers, d.key) ? answers[d.key] : undefined, dismissed: hidden.has(d.key) }));
376
386
  }
377
387
  function ready(file, on, root) {
378
388
  if (!existsSync(file))
@@ -383,9 +393,9 @@ function ready(file, on, root) {
383
393
  }
384
394
  function list(args) {
385
395
  const { file, values, on } = parse('list', args, TIERS);
386
- 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;
387
397
  if (values.json)
388
- return JSON.stringify({ board: file, decisions: rows });
398
+ return JSON.stringify({ board: file, decisions: rows.map(({ dismissed, ...d }) => (dismissed ? { ...d, dismissed } : d)) });
389
399
  const help = [
390
400
  ...(waiting.length ? [`Run \`sign-off-axi open${on}\` to show the board to the user`] : []),
391
401
  ...(answered ? [`Run \`sign-off-axi answers${on}\` to read the ${answered} saved answers`] : []),
@@ -393,7 +403,7 @@ function list(args) {
393
403
  ];
394
404
  const where = tilde(file) + (existsSync(file) ? '' : ' (no file yet; raise creates it)');
395
405
  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 ?? '',
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 ?? '',
397
407
  recommend: d.recommend_value ?? '', default_if_silent: d.default_if_silent ?? '', close: d.close })) }, help);
398
408
  }
399
409
  return toon({
@@ -433,10 +443,13 @@ function answers(args) {
433
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`] : []);
434
444
  }
435
445
  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' } });
446
+ const { file, values, on: onBoard } = parse('wait', args, { ...TIERS, filer: { type: 'string' }, key: { type: 'string' }, timeout: { type: 'string', default: '600' }, into: { type: 'string' } });
437
447
  if (!/^\d{1,7}$/.test(values.timeout))
438
448
  throw fail('--timeout must be whole seconds, 0 or more', 'VALIDATION_ERROR', `Run \`sign-off-axi wait --help\``);
439
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);
440
453
  if (values.key === undefined)
441
454
  ready(file, onBoard);
442
455
  const seconds = Number(values.timeout), deadline = Date.now() + seconds * 1000, key = values.key === undefined ? '' : ` --key ${values.key}`;
@@ -448,7 +461,7 @@ async function wait(args) {
448
461
  for (const r of readRounds(file))
449
462
  seenRounds.set(r.key, (seenRounds.get(r.key) ?? 0) + 1);
450
463
  // A save wakes the loop at once; the 1 second timer is the fallback where file events are unavailable.
451
- let wake = () => { }, watcher;
464
+ let wake = () => { }, watcher, vaultWarned = false;
452
465
  const name = basename(answersPath(file));
453
466
  try {
454
467
  try {
@@ -477,7 +490,36 @@ async function wait(args) {
477
490
  if (found.length) {
478
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`]);
479
492
  }
480
- 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) {
481
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`);
482
524
  }
483
525
  const left = deadline - Date.now();
@@ -496,6 +538,55 @@ async function wait(args) {
496
538
  watcher?.close();
497
539
  }
498
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
+ }
499
590
  const slug = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 64).replace(/^-+|-+$/g, '') || 'decision';
500
591
  /** Runs fn while holding an exclusive lock file that names its pid, so two agents raising at once never drop a decision,
501
592
  * and a lock a crash left behind can be told apart from one in use. */
@@ -608,7 +699,13 @@ function replace(file, on, from, filer) {
608
699
  fits(i?.title, i?.key);
609
700
  const feed = fed.items.map((i) => ({ filed: first.get(i?.key)?.filed ?? now, ...i, ...(filer && { filer }) }));
610
701
  // 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]);
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
+ }
612
709
  for (const had of board(file)) {
613
710
  const d = incoming.find((x) => x.key === had.key);
614
711
  if (filer && d && had.filer && had.filer !== filer)
@@ -881,26 +978,28 @@ function storeIncoming(file, on, { incoming, raw }, filer, exact = false) {
881
978
  const current = board(file), items = existsSync(file) ? readItems(file).items : [], saved = readAnswers(file), now = new Date().toISOString();
882
979
  const raised = incoming.map((d, i) => {
883
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
+ };
884
988
  // An answer outlives its item: a new question under that key would inherit it.
885
989
  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');
990
+ throw refuse(`already has a saved answer on ${tilde(file)} from an item no longer there`);
887
991
  // 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)) });
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)) });
889
993
  // A message that names no filed time matches the one the board stamped.
890
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);
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
- }
995
+ if (had && differs())
996
+ throw refuse(`already holds a different ${contract ? 'message' : 'question'} on ${tilde(file)}`);
898
997
  // Each new item records when it was raised, unless it names its own time.
899
998
  if (!had) {
900
999
  fits(d.title, d.key);
901
1000
  items.push({ filed: now, ...raw[i] });
902
1001
  }
903
- 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 };
904
1003
  });
905
1004
  if (raised.some((r) => r.status === 'raised'))
906
1005
  writeItems(file, items);
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
  });
package/dist/vault.js CHANGED
@@ -1,10 +1,10 @@
1
- // Vault remains OFF. The server imports only expiry housekeeping, never payload access.
2
- import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
1
+ // Vault remains OFF. The server imports only expiry functions; `wait --into` reaches payload only through deliverVault.
2
+ import { closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, isAbsolute, join, parse, resolve } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
- import { createCipheriv, createHash, randomBytes } from 'node:crypto';
5
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
6
6
  import { isKey } from './store.js';
7
- const errors = {
7
+ export const errors = {
8
8
  VALIDATION_ERROR: 'Vault input is invalid: check mode, expiry, provider and destination.',
9
9
  EXPIRED: 'Vault item expired; request a fresh item.',
10
10
  VAULT_KEY_UNAVAILABLE: 'Vault key is unavailable; encrypted payload cannot be read.',
@@ -13,6 +13,10 @@ const errors = {
13
13
  TIMEOUT: 'Vault expiry wait timed out; retry status later.',
14
14
  READER_MISMATCH: 'This item belongs to a different filer or destination.',
15
15
  REFUSED: 'User refused this vault item; no value was delivered.',
16
+ DESTINATION_DENIED: 'Destination is outside an approved private config root or has unsafe permissions.',
17
+ DESTINATION_EXISTS: 'Destination exists; nothing was overwritten.',
18
+ ALREADY_READ: 'Vault item was already delivered; value is unavailable.',
19
+ DELIVERY_UNCERTAIN: 'Delivery interrupted; do not retry this item. Check destination locally, then request a fresh item if needed.',
16
20
  };
17
21
  export class VaultError extends Error {
18
22
  code;
@@ -35,6 +39,17 @@ function boardFile(board) {
35
39
  bad('NOT_FOUND');
36
40
  }
37
41
  }
42
+ // Never used: callers stay silent with no permission walk, lock or state creation.
43
+ const unused = (file) => {
44
+ try {
45
+ return !lstatSync(join(stateHome(), 'sign-off', 'vault'), { throwIfNoEntry: false }) || !lstatSync(join(dirname(file), '.sign-off-vault-id'), { throwIfNoEntry: false });
46
+ }
47
+ catch (e) {
48
+ if (e?.code === 'ENOENT' || e?.code === 'ENOTDIR')
49
+ return true;
50
+ throw e;
51
+ }
52
+ };
38
53
  function boardId(file, create) {
39
54
  const side = join(dirname(file), '.sign-off-vault-id');
40
55
  if (!lstatSync(side, { throwIfNoEntry: false })) {
@@ -48,10 +63,12 @@ function boardId(file, create) {
48
63
  bad('VALIDATION_ERROR');
49
64
  return id;
50
65
  }
66
+ // Absolute and free of raw '.' or '..' segments, which resolve() would otherwise hide.
67
+ const plain = (p) => isAbsolute(p) && !p.split('/').some((s) => s === '.' || s === '..');
51
68
  function metadata(m) {
52
69
  if (!m || typeof m.board !== 'string' || !VAULT_ID.test(m.board) || !isKey(m.key) || !isKey(m.filer) || !['give', 'receive'].includes(m.mode)
53
70
  || !Number.isSafeInteger(m.expires) || m.expires <= 0
54
- || (m.mode === 'give' ? typeof m.destination !== 'string' || !isAbsolute(m.destination) : m.destination !== undefined))
71
+ || (m.mode === 'give' ? typeof m.destination !== 'string' || !plain(m.destination) : m.destination !== undefined))
55
72
  bad('VALIDATION_ERROR');
56
73
  return { board: m.board, key: m.key, mode: m.mode, filer: m.filer, expires: m.expires,
57
74
  ...(m.destination !== undefined && { destination: resolve(m.destination) }) };
@@ -61,8 +78,9 @@ function privateFile(path) {
61
78
  if (!s.isFile() || s.nlink !== 1 || s.uid !== process.getuid?.() || (s.mode & 0o777) !== 0o600 || s.size > 32_768)
62
79
  bad('VAULT_KEY_UNAVAILABLE');
63
80
  }
81
+ const stateHome = () => process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state');
64
82
  function root(create = true) {
65
- const state = process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state');
83
+ const state = stateHome();
66
84
  if (!isAbsolute(state) || !process.getuid)
67
85
  bad('VAULT_KEY_UNAVAILABLE');
68
86
  const owner = join(state, 'sign-off'), vault = join(owner, 'vault');
@@ -87,6 +105,45 @@ function root(create = true) {
87
105
  }
88
106
  return vault;
89
107
  }
108
+ const within = (child, parent) => child === parent || child.startsWith(parent.replace(/\/$/, '') + '/');
109
+ function gitFree(path) {
110
+ let p = parse(path).root;
111
+ for (const part of path.slice(p.length).split('/')) {
112
+ p = join(p, part);
113
+ if (existsSync(join(p, '.git')))
114
+ return false;
115
+ }
116
+ return true;
117
+ }
118
+ // Owner-only list of approved destination directories: one canonical absolute path per line, `#` comments. Any bad line denies all.
119
+ // ponytail: exact-directory approvals, so a destination must be a direct child; ancestors get only the .git walk.
120
+ function approvedDirs(file, dir) {
121
+ const list = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'sign-off', 'vault-destinations');
122
+ if (!isAbsolute(list))
123
+ bad('DESTINATION_DENIED');
124
+ let text;
125
+ try {
126
+ privateFile(list);
127
+ text = readFileSync(list, 'utf8');
128
+ }
129
+ catch {
130
+ bad('DESTINATION_DENIED');
131
+ }
132
+ const vaultDir = realpathSync(dir), home = realpathSync(homedir()), boardDir = dirname(file);
133
+ return text.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#')).map((line) => {
134
+ const s = lstatSync(line, { throwIfNoEntry: false });
135
+ if (!plain(line) || !s?.isDirectory() || realpathSync(line) !== line || s.uid !== process.getuid?.() || (s.mode & 0o777) !== 0o700
136
+ || !gitFree(line) || within(home, line) || [vaultDir, boardDir].some((o) => within(o, line) || within(line, o)))
137
+ bad('DESTINATION_DENIED');
138
+ return line;
139
+ });
140
+ }
141
+ function checkDestination(dest, file, dir) {
142
+ if (!approvedDirs(file, dir).includes(dirname(dest)))
143
+ bad('DESTINATION_DENIED');
144
+ if (lstatSync(dest, { throwIfNoEntry: false }))
145
+ bad('DESTINATION_EXISTS'); // any entry, including a dangling symlink
146
+ }
90
147
  function syncDir(path) {
91
148
  const fd = openSync(path, 'r');
92
149
  try {
@@ -145,12 +202,32 @@ function seal(dir, meta, value) {
145
202
  key.fill(0);
146
203
  }
147
204
  }
148
- async function lockedVault(dir, fn) {
205
+ function unseal(key, meta, s) {
206
+ const d = createDecipheriv('aes-256-gcm', key, Buffer.from(s.nonce, 'base64'));
207
+ d.setAAD(aad(meta));
208
+ d.setAuthTag(Buffer.from(s.tag, 'base64'));
209
+ return Buffer.concat([d.update(Buffer.from(s.data, 'base64')), d.final()]);
210
+ }
211
+ // A stop signal with no listener ends the process at once, even inside the locked section, and the lock it leaves fails closed for good.
212
+ // So a listener takes each stop signal the process has none for: it runs only between turns of the event loop, after a section has
213
+ // removed its lock, and raises the signal again with no listener, so the process still ends by it. It stays on once added, since
214
+ // Node drops a signal that came just before its listener was removed.
215
+ const STOPS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
216
+ function raise(signal) {
217
+ for (const s of STOPS)
218
+ process.off(s, raise);
219
+ if (!process.listenerCount(signal))
220
+ process.kill(process.pid, signal);
221
+ }
222
+ async function lockedVault(dir, fn, attempts = 50) {
223
+ for (const s of STOPS)
224
+ if (!process.listenerCount(s))
225
+ process.on(s, raise);
149
226
  let lock;
150
227
  try {
151
228
  const candidate = join(dir, 'lock');
152
229
  // ponytail: one global lock; split by board only if custody throughput requires it. Stale locks fail closed.
153
- for (let tries = 0;; tries++) {
230
+ for (let tries = 1;; tries++) {
154
231
  try {
155
232
  const fd = openSync(candidate, 'wx', 0o600);
156
233
  closeSync(fd);
@@ -158,7 +235,7 @@ async function lockedVault(dir, fn) {
158
235
  break;
159
236
  }
160
237
  catch (e) {
161
- if (e.code !== 'EEXIST' || tries === 49)
238
+ if (e.code !== 'EEXIST' || tries === attempts)
162
239
  bad('BUSY');
163
240
  }
164
241
  await new Promise((ok) => setTimeout(ok, 20));
@@ -195,11 +272,18 @@ function readRecord(path) {
195
272
  bad('VALIDATION_ERROR');
196
273
  }
197
274
  if (!record || record.schema !== 1 || !VAULT_ID.test(record.meta.board)
198
- || JSON.stringify(metadata(record.meta)) !== JSON.stringify(record.meta) || !['pending', 'ready', 'refused', 'expired'].includes(record.status)
275
+ || JSON.stringify(metadata(record.meta)) !== JSON.stringify(record.meta) || !['pending', 'ready', 'refused', 'expired', 'claimed', 'delivered'].includes(record.status)
199
276
  || path !== join(dirname(path), `${digest(record.meta.board, record.meta.key)}.json`))
200
277
  bad('VALIDATION_ERROR');
201
278
  return record;
202
279
  }
280
+ // A refusal is admitted only while the item is live (`operate` expires first), so only an expiry is bound to the deadline.
281
+ function expiryEvent(id, record) {
282
+ const status = (record.status === 'claimed' ? 'delivery-uncertain' : record.status);
283
+ if (!Number.isSafeInteger(record.at) || record.at <= 0 || (status === 'expired' && record.at < record.meta.expires) || record.at > 8.64e15 || Object.hasOwn(record, 'sealed'))
284
+ bad('VALIDATION_ERROR');
285
+ return { schema: 1, id: `${digest(id, record.meta.key)}:${status}`, status, at: record.at };
286
+ }
203
287
  function expire(path, record) {
204
288
  if (['pending', 'ready'].includes(record.status) && record.meta.expires <= Date.now()) {
205
289
  record = { schema: 1, meta: record.meta, status: 'expired', at: Date.now() };
@@ -258,30 +342,112 @@ export async function sweepVault(board) {
258
342
  return false;
259
343
  }
260
344
  }
261
- /** Private, replayable expiry observation; never a payload or public answer consumer. */
262
- export async function readVaultExpiry(board, key, filer) {
345
+ /** Replayable expiry observation; never a payload consumer. The public keyed wait (`publicWait`) also expires its own item on access,
346
+ * gets a refusal, delivery or uncertain delivery as an event and tries the lock once; the private observer reports only expiry. */
347
+ export async function readVaultExpiry(board, key, filer, publicWait = false) {
263
348
  if (!isKey(key) || !isKey(filer))
264
349
  bad('VALIDATION_ERROR');
265
350
  try {
266
- const { file, dir } = custody(board, false);
351
+ // Never used: silent before any custody or permission walk.
352
+ const file = boardFile(board);
353
+ if (unused(file))
354
+ return;
355
+ const { dir } = custody(board, false);
267
356
  if (!dir)
268
357
  return;
269
358
  return await lockedVault(dir, () => {
270
359
  if (!lstatSync(join(dirname(file), '.sign-off-vault-id'), { throwIfNoEntry: false }))
271
360
  return;
272
- const { id, record } = boundRecord(file, dir, key, false);
361
+ const { id, path, record } = boundRecord(file, dir, key, false);
273
362
  if (!record)
274
363
  return;
275
364
  if (record.meta.filer !== filer)
276
365
  bad('READER_MISMATCH');
277
- if (record.status === 'refused')
366
+ if (record.status === 'refused' && !publicWait)
278
367
  bad('REFUSED');
279
- if (record.status !== 'expired')
280
- return;
281
- if (!Number.isSafeInteger(record.at) || record.at <= 0 || record.at < record.meta.expires || Object.hasOwn(record, 'sealed'))
282
- bad('VALIDATION_ERROR');
283
- return { schema: 1, id: `${digest(id, key)}:expired`, status: 'expired', at: record.at };
284
- });
368
+ const current = publicWait ? expire(path, record) : record;
369
+ const terminal = publicWait ? ['expired', 'refused', 'claimed', 'delivered'] : ['expired', 'refused'];
370
+ return terminal.includes(current.status) ? expiryEvent(id, current) : undefined;
371
+ }, publicWait ? 1 : 50);
372
+ }
373
+ catch (e) {
374
+ throw e instanceof VaultError ? e : new VaultError('BUSY');
375
+ }
376
+ }
377
+ /** For an ordinary filer wait: expires this filer's elapsed items in the board's folder, then takes each untaken terminal event once.
378
+ * A private `.<kind>.taken` marker per event dedupes filer waits; markers are durable before any event is returned.
379
+ * One kind per poll (expired, refused, delivery-uncertain, delivered), so one output never mixes two codes and a failure is never
380
+ * hidden behind a delivery's exit 0. */
381
+ export async function takeVaultExpiries(board, filer) {
382
+ if (!isKey(filer))
383
+ bad('VALIDATION_ERROR');
384
+ const none = { events: [], live: 0, clean: true };
385
+ try {
386
+ // Never used: silent, with no permission walk, lock or state creation.
387
+ const file = boardFile(board);
388
+ if (unused(file))
389
+ return none;
390
+ const { dir } = custody(board, false);
391
+ if (!dir)
392
+ return none;
393
+ // One try: an ordinary wait never queues behind custody; BUSY means unknown this poll.
394
+ return await lockedVault(dir, () => {
395
+ const id = boardId(file, false), found = [], made = [];
396
+ let live = 0, clean = true;
397
+ for (const name of readdirSync(dir).filter((n) => n.endsWith('.json')).sort()) {
398
+ try {
399
+ const path = join(dir, name), record = readRecord(path);
400
+ if (record.meta.board !== id || record.meta.filer !== filer)
401
+ continue;
402
+ const r = expire(path, record);
403
+ if (r.status === 'pending' || r.status === 'ready')
404
+ live++;
405
+ else
406
+ found.push({ path, key: r.meta.key, event: expiryEvent(id, r) });
407
+ }
408
+ catch {
409
+ clean = false;
410
+ }
411
+ }
412
+ try {
413
+ const take = (kind) => found.filter(({ path, event }) => {
414
+ if (event.status !== kind)
415
+ return false;
416
+ const marker = path.replace(/\.json$/, `.${kind}.taken`);
417
+ let fd;
418
+ try {
419
+ fd = openSync(marker, 'wx', 0o600);
420
+ }
421
+ catch (e) {
422
+ if (e.code !== 'EEXIST')
423
+ throw e;
424
+ privateFile(marker);
425
+ return false;
426
+ }
427
+ made.push(marker);
428
+ try {
429
+ writeFileSync(fd, event.id);
430
+ fsyncSync(fd);
431
+ }
432
+ finally {
433
+ closeSync(fd);
434
+ }
435
+ return true;
436
+ });
437
+ let fresh = [];
438
+ for (const kind of ['expired', 'refused', 'delivery-uncertain', 'delivered'])
439
+ if (!fresh.length)
440
+ fresh = take(kind);
441
+ if (made.length)
442
+ syncDir(dir);
443
+ return { events: fresh.map(({ key, event }) => ({ key, at: event.at, status: event.status })), live, clean };
444
+ }
445
+ catch (e) {
446
+ for (const m of made)
447
+ rmSync(m, { force: true });
448
+ throw e;
449
+ }
450
+ }, 1);
285
451
  }
286
452
  catch (e) {
287
453
  throw e instanceof VaultError ? e : new VaultError('BUSY');
@@ -310,6 +476,10 @@ const active = (r) => {
310
476
  bad('REFUSED');
311
477
  if (r.status === 'expired')
312
478
  bad('EXPIRED');
479
+ if (r.status === 'delivered')
480
+ bad('ALREADY_READ');
481
+ if (r.status === 'claimed')
482
+ bad('DELIVERY_UNCERTAIN');
313
483
  return r;
314
484
  };
315
485
  export async function createVault(input, value) {
@@ -325,6 +495,8 @@ export async function createVault(input, value) {
325
495
  bad('VALIDATION_ERROR'); // Terminal keys are tombstones, never reusable.
326
496
  if (meta.expires <= Date.now())
327
497
  bad('EXPIRED');
498
+ if (meta.destination !== undefined)
499
+ checkDestination(meta.destination, boardFile(input.board), dir);
328
500
  if (!value)
329
501
  master(dir);
330
502
  const record = { schema: 1, meta, status: value ? 'ready' : 'pending', at: Date.now(), ...(value && { sealed: seal(dir, meta, value) }) };
@@ -352,3 +524,64 @@ export async function refuseVault(board, key) {
352
524
  return summary(record);
353
525
  });
354
526
  }
527
+ /** Give delivery for the bound filer and destination. Pending, or a never-used vault (nothing is created), returns undefined so the
528
+ * caller keeps waiting. Any failure after the claim leaves the record `claimed`, which is terminal (DELIVERY_UNCERTAIN) and never
529
+ * decrypts again. */
530
+ export async function deliverVault(board, key, filer, into, afterClaim) {
531
+ if (!isKey(key) || !isKey(filer) || typeof into !== 'string' || !plain(into))
532
+ bad('VALIDATION_ERROR');
533
+ try {
534
+ if (unused(boardFile(board)))
535
+ return undefined;
536
+ }
537
+ catch (e) {
538
+ throw e instanceof VaultError ? e : new VaultError('BUSY');
539
+ }
540
+ return operate(board, key, false, (dir, path, old) => {
541
+ if (old && old.meta.filer !== filer)
542
+ bad('READER_MISMATCH');
543
+ const r = active(old);
544
+ if (r.meta.mode !== 'give' || resolve(into) !== r.meta.destination)
545
+ bad(r.meta.mode === 'give' ? 'READER_MISMATCH' : 'VALIDATION_ERROR');
546
+ if (r.status === 'pending')
547
+ return undefined;
548
+ const dest = r.meta.destination;
549
+ checkDestination(dest, boardFile(board), dir); // pre-claim: the payload stays ready on any denial
550
+ const secret = master(dir);
551
+ // Commit point: the ciphertext leaves disk here, so no later process can decrypt it.
552
+ publish(path, JSON.stringify({ schema: 1, meta: r.meta, status: 'claimed', at: Date.now() }));
553
+ let made;
554
+ try {
555
+ afterClaim?.();
556
+ const value = unseal(secret, r.meta, r.sealed);
557
+ const fd = openSync(dest, 'wx', 0o600);
558
+ try {
559
+ const st = fstatSync(fd);
560
+ made = { dev: st.dev, ino: st.ino };
561
+ writeFileSync(fd, value);
562
+ fsyncSync(fd);
563
+ }
564
+ finally {
565
+ closeSync(fd);
566
+ value.fill(0);
567
+ }
568
+ syncDir(dirname(dest));
569
+ const done = { schema: 1, meta: r.meta, status: 'delivered', at: Date.now() };
570
+ publish(path, JSON.stringify(done));
571
+ return { key: r.meta.key, at: done.at, status: 'delivered' };
572
+ }
573
+ catch {
574
+ // Remove only a file this call created; a lost wx race is someone else's file and stays.
575
+ try {
576
+ const s = lstatSync(dest);
577
+ if (made && s.dev === made.dev && s.ino === made.ino)
578
+ rmSync(dest);
579
+ }
580
+ catch { /* best effort */ }
581
+ bad('DELIVERY_UNCERTAIN');
582
+ }
583
+ finally {
584
+ secret.fill(0);
585
+ }
586
+ });
587
+ }
@@ -9,7 +9,7 @@ fixtures/contract/<kind>.v<schema>/valid-<name>.json
9
9
  fixtures/contract/<kind>.v<schema>/invalid-<field>-<rule>.json
10
10
  ```
11
11
 
12
- - `<kind>` is `ask`, `report`, `evidence`, `gate` or `finding`, and `<schema>` its schema number, such as `ask.v1/`. A version ships a folder for every kind it validates. This version ships `ask.v1/`, `report.v1/` and `evidence.v1/`.
12
+ - `<kind>` is `ask`, `report`, `evidence`, `gate` or `finding`, and `<schema>` its schema number, such as `ask.v1/`. A version ships a folder for every kind it validates. This version ships `ask.v1/`, `report.v1/`, `evidence.v1/`, `gate.v1/` and `finding.v1/`. This is the one list of shipped folders; other docs point here.
13
13
  - Each file holds one message. An evidence fixture that names `media` ships those files next to it.
14
14
  - Each kind has at least 2 valid files and 3 invalid ones: one with a required field missing, one over a limit, and one with an unknown field.
15
15
  - An invalid file breaks exactly one rule. Its name gives the field and the rule, such as `invalid-title-too-long.json`, and sign-off's own tests check that the refusal names that field.
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"finding","schema":1,"key":"lfm-missing-env","title":"Repeated cause: missing env var in CI","filer":"learn-from-mistakes","about":"4 incidents over 3 days in 2 sources.","incidents":4,"days":3,"evidence_ids":["rec-7f3a","rec-91be"],"change_kind":"doc","proposal":"Add the env var to the CI setup page.","options":[{"value":"apply","label":"Apply"},{"value":"skip","label":"Skip"}],"recommend_value":"apply"}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"finding","schema":1,"key":"lfm-missing-env","title":"Repeated cause: missing env var in CI","filer":"learn-from-mistakes","about":"4 incidents over 3 days in 2 sources.","cause":"missing-env","incidents":4,"days":3,"evidence_ids":["rec-7f3a","rec-91be"],"change_kind":"process","proposal":"Add the env var to the CI setup page.","options":[{"value":"apply","label":"Apply"},{"value":"skip","label":"Skip"}],"recommend_value":"apply"}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"finding","schema":1,"key":"lfm-missing-env","title":"Repeated cause: missing env var in CI","filer":"learn-from-mistakes","about":"4 incidents over 3 days in 2 sources.","cause":"missing-env","incidents":4,"days":3,"evidence_ids":["rec-7f3a","rec-7f3a"],"change_kind":"doc","proposal":"Add the env var to the CI setup page.","options":[{"value":"apply","label":"Apply"},{"value":"skip","label":"Skip"}],"recommend_value":"apply"}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"finding","schema":1,"key":"lfm-missing-env","title":"Repeated cause: missing env var in CI","filer":"learn-from-mistakes","about":"4 incidents over 3 days in 2 sources.","cause":"missing-env","incidents":4,"days":3,"evidence_ids":["rec-7f3a","rec-91be"],"change_kind":"doc","proposal":"Add the env var to the CI setup page.","options":[{"value":"apply","label":"Apply"},{"value":"skip","label":"Leave it"}],"recommend_value":"apply"}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"finding","schema":1,"key":"lfm-missing-env","title":"Repeated cause: missing env var in CI","filer":"learn-from-mistakes","about":"4 incidents over 3 days in 2 sources.","cause":"missing-env","incidents":4,"days":3,"evidence_ids":["rec-7f3a","rec-91be"],"change_kind":"doc","proposal":"Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. Add the env var to the CI setup page. ","options":[{"value":"apply","label":"Apply"},{"value":"skip","label":"Skip"}],"recommend_value":"apply"}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"finding","schema":1,"key":"lfm-missing-env","title":"Repeated cause: missing env var in CI","filer":"learn-from-mistakes","about":"4 incidents over 3 days in 2 sources.","cause":"missing-env","incidents":4,"days":3,"evidence_ids":["rec-7f3a","rec-91be"],"change_kind":"doc","proposal":"Add the env var to the CI setup page.","options":[{"value":"apply","label":"Apply"},{"value":"skip","label":"Skip"}],"recommend_value":"apply","record_text":"CI failed: API_TOKEN is not set"}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"finding","schema":1,"key":"lfm-missing-env","title":"Repeated cause: missing env var in CI","filer":"learn-from-mistakes","about":"4 incidents over 3 days in 2 sources.","cause":"missing-env","incidents":4,"days":3,"evidence_ids":["rec-7f3a","rec-91be"],"change_kind":"doc","proposal":"Add the env var to the CI setup page.","options":[{"value":"apply","label":"Apply"},{"value":"skip","label":"Skip"}],"recommend_value":"apply"}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"finding","schema":1,"key":"lfm-flaky-wait","title":"Repeated cause: a test waits on a fixed sleep","about":"2 incidents in 1 day in Umer's runs.","cause":"fixed-sleep","incidents":2,"days":1,"evidence_ids":[],"change_kind":"code","proposal":"Wait for the page to be ready instead of sleeping.","options":[{"value":"skip","label":"Skip"},{"value":"apply","label":"Apply"}],"recommend_value":"skip"}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"gate","schema":1,"key":"dd-gate-c4","title":"Move the sign-in run on past its judge?","about":"The judge could not tell the screens apart.","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456789abcdef0123456789abcdef01234567"},"options":[{"value":"approve","label":"Approve"},{"value":"fix","label":"Fix first"}],"recommend_value":"fix","default_if_silent":"fix"}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"gate","schema":1,"key":"dd-gate-c3","title":"Move the sign-in run on past its judge?","about":"The judge could not tell the screens apart.","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456789abcdef0123456789abcdef01234567"},"options":[{"value":"approve","label":"Ship it"},{"value":"fix","label":"Fix first"}],"recommend_value":"fix"}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"gate","schema":1,"key":"dd-gate-c2","title":"Move the sign-in run on past its judge?","about":"The judge could not tell the screens apart.","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456789abcdef0123456789abcdef01234567"},"options":[{"value":"approve","label":"Approve"}],"recommend_value":"approve"}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"gate","schema":1,"key":"dd-gate-c1","title":"Move the sign-in run on past its judge?","about":"The judge could not tell the screens apart.","options":[{"value":"approve","label":"Approve"},{"value":"fix","label":"Fix first"}],"recommend_value":"fix"}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"gate","schema":1,"key":"dd-gate-c5","title":"Move the sign-in run on past its judge?","about":"The judge could not tell the screens apart.","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456789abcdef0123456789abcdef01234567","outcome":"Umer can see it."},"options":[{"value":"approve","label":"Approve"},{"value":"fix","label":"Fix first"}],"recommend_value":"fix"}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"gate","schema":1,"key":"dd-gate-a1b2c3d4","title":"Move the sign-in run on past its judge?","filer":"done-done","about":"The judge could not tell the screens apart.","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456789abcdef0123456789abcdef01234567"},"options":[{"value":"approve","label":"Approve"},{"value":"skip","label":"Skip the judge"},{"value":"fix","label":"Fix first"}],"recommend_value":"fix","close":"done"}
@@ -0,0 +1 @@
1
+ {"contract":1,"kind":"gate","schema":1,"key":"dd-gate-b5c6d7e8","title":"Approve the checkout run for Umer?","about":"Umer's checkout run passed every step but the judge.","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456789abcdef0123456789abcdef01234567"},"options":[{"value":"fix","label":"Fix first"},{"value":"approve","label":"Approve"}],"recommend_value":"approve"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "One review board for evidence, quick yes/no decisions and retro changes.",
5
5
  "type": "module",
6
6
  "bin": { "sign-off-axi": "dist/cli.js" },
package/web/index.html CHANGED
@@ -38,13 +38,15 @@
38
38
  .tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
39
39
  .card, .tile { background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; box-shadow: var(--shadow); }
40
40
  .card { padding: 1rem; }
41
- .tile { position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-content: start; gap: 0.25rem 0.5rem; padding: 0.625rem 0.875rem; color: var(--text); text-decoration: none; }
41
+ .tile { position: relative; display: grid; grid-template-columns: auto auto minmax(0, 1fr); align-content: start; gap: 0.25rem 0.5rem; padding: 0.625rem 0.875rem; color: var(--text); text-decoration: none; }
42
42
  .tile::after { content: "›" / ""; position: absolute; top: 0.5rem; right: 0.875rem; color: var(--muted); font-size: 1.5rem; line-height: 1.2; }
43
43
  .tile .name { grid-column: 1 / -1; font-size: 0.875rem; font-weight: 600; color: var(--muted); line-height: 1.3; }
44
44
  .tile .detail { grid-column: 1 / -1; }
45
- .tile .n { grid-column: 1 / 3; font-size: 2.125rem; font-weight: 700; line-height: 1.15; font-variant-numeric: tabular-nums; }
45
+ .tile .n { grid-column: 1 / 3; font-size: 2.125rem; font-weight: 700; line-height: 1.15; font-variant-numeric: tabular-nums; white-space: nowrap; }
46
46
  .tile .detail { font-size: 0.8125rem; color: var(--muted); display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
47
- .vis { align-self: center; display: flex; align-items: center; gap: 0.25rem; }
47
+ /* The marks give way to the count: one line at the right, and a mark with no room left wraps out of sight. */
48
+ .vis { align-self: center; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 0.25rem; height: 2.25rem; overflow: hidden; }
49
+ .vis::before { content: ""; height: 100%; }
48
50
  .vis img { width: 1.5rem; height: 2.25rem; object-fit: cover; object-position: top; border: 1px solid var(--line); border-radius: 0.25rem; }
49
51
  .mark { width: 0.875rem; height: 0.875rem; border-radius: 50%; border: 2px solid var(--area); }
50
52
  /* Waiting shows one clock and Answered up to three checks, drawn in the area's colour. */
@@ -123,15 +125,16 @@
123
125
  .foot .meta { margin: 0 auto 0 0; }
124
126
  .foot a { color: var(--accent); font-weight: 600; text-decoration: none; }
125
127
  .foot button { border-color: transparent; border-radius: 999px; background: var(--sunk); font-weight: 600; }
126
- .day { position: relative; height: 4.5rem; border-bottom: 1px solid var(--border); }
128
+ /* Tall enough for the tallest column, six dots under its count, so no count reaches the caption above. */
129
+ .day { position: relative; height: 6.5rem; border-bottom: 1px solid var(--border); }
127
130
  .col { position: absolute; bottom: 0.25rem; display: flex; flex-direction: column-reverse; align-items: center; gap: 0.1875rem; transform: translateX(-50%);
128
- font-size: 0.8125rem; font-weight: 700; line-height: 1.2; }
131
+ font-size: 0.8125rem; font-weight: 700; line-height: 1.2; overflow-wrap: normal; }
129
132
  .dot { width: 0.625rem; height: 0.625rem; border-radius: 50%; background: var(--c-answered); }
130
133
  .hours { display: flex; justify-content: space-between; color: var(--muted); font-size: 0.8125rem; font-variant-numeric: tabular-nums; }
131
134
  .events { list-style: none; margin: 0.75rem 0 0; padding: 0; display: grid; gap: 0.25rem; font-size: 0.9375rem; }
132
135
  .events time, .sec-head .meta { font-weight: 700; font-variant-numeric: tabular-nums; margin-right: 0.5rem; }
133
136
  .sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
134
- .sec-head h2 { margin: 0; } .sec-head:has(+ .in-evidence) { margin: 1rem 0 0.375rem; }
137
+ .sec-head h2 { margin: 0; overflow-wrap: normal; } /* A long count beside a section name wraps itself, never the name mid-word. */ .sec-head:has(+ .in-evidence) { margin: 1rem 0 0.375rem; }
135
138
  .rows { list-style: none; margin: 0; padding: 0 1rem; }
136
139
  .rows > li + li { border-top: 1px solid var(--line); }
137
140
  .compact { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 0; color: var(--text); text-decoration: none; }
@@ -394,7 +397,7 @@
394
397
  const drop = clearIfSaved(d.key, drafts.get(d.key));
395
398
  call('/api/answers', {
396
399
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, answer: o.value, note }),
397
- }, () => { editing.delete(d.key); if (!typed) drop(); }).then(() => offerUndo(d, before)).then(() => typed && call('/api/messages', {
400
+ }, () => { forget(d.key); if (!typed) drop(); }).then(() => offerUndo(d, before)).then(() => typed && call('/api/messages', {
398
401
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'comment', key: d.key, text: typed }),
399
402
  }, drop)).catch((e) => {
400
403
  shown(err).textContent = `Couldn't save: ${e.message}`;
@@ -566,7 +569,7 @@
566
569
  const drop = clearIfSaved(d.key, drafts.get(d.key));
567
570
  call('/api/rounds', {
568
571
  method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, note }),
569
- }, () => { editing.delete(d.key); drop(); }).catch((e) => {
572
+ }, () => { forget(d.key); drop(); }).catch((e) => {
570
573
  shown(err).textContent = `Couldn't ask: ${e.message}`;
571
574
  roundBtn.disabled = false;
572
575
  });
@@ -672,6 +675,10 @@
672
675
 
673
676
  /** Comments on mock elements: the open editor per item, then the unsent list, then the sent ones. An unsent comment shows only on the mock file it was made on. */
674
677
  const editing = new Map(), talks = () => !state.readonly && !state.answers_only && !FROZEN;
678
+ // Words typed for an element and not saved yet, kept in page memory (not across a reload) so tapping another element or another Edit loses nothing.
679
+ const unsaved = new Map(), spot = (key, a) => `${key}~${a.mock}~${a.ref}`;
680
+ // Sent with an answer or round: the card's editor closes and its unsaved words go.
681
+ const forget = (key) => { editing.delete(key); for (const k of unsaved.keys()) if (k.startsWith(`${key}~`)) unsaved.delete(k); };
675
682
  const redraw = (d) => document.querySelectorAll('.ann').forEach((n) => n.dataset.ann === d.key && n.replaceWith(annotations(d)));
676
683
  function annotations(d) {
677
684
  const hash = new Map((d.mocks ?? []).map((m) => [m.value, m.hash])), open = editing.get(d.key);
@@ -704,10 +711,18 @@
704
711
  let form = '';
705
712
  if (open && pages.length) {
706
713
  const box = el('textarea', { id: `ann-text-${d.key}`, rows: 2, maxlength: 500, dir: 'auto' }), save = el('button', {}, 'Save comment'), cancel = el('button', { class: 'quick' }, 'Cancel');
707
- box.value = open.text ?? mine.find((a) => !a.sent && a.mock === open.mock && a.ref === open.ref)?.text ?? '';
708
- box.oninput = () => (open.text = box.value);
709
- save.onclick = () => post(save, { id: open.id, key: d.key, mock: open.mock, ref: open.ref, tag: open.tag, words: open.words, text: box.value, hash: open.hash }, () => editing.delete(d.key));
710
- cancel.onclick = () => { editing.delete(d.key); redraw(d); };
714
+ const at = spot(d.key, open);
715
+ box.value = unsaved.get(at) ?? mine.find((a) => !a.sent && a.mock === open.mock && a.ref === open.ref)?.text ?? '';
716
+ box.oninput = () => unsaved.set(at, box.value);
717
+ // Saved, only this editor closes: one opened while the save was on its way stays open with its words.
718
+ save.onclick = () => {
719
+ const text = box.value;
720
+ post(save, { id: open.id, key: d.key, mock: open.mock, ref: open.ref, tag: open.tag, words: open.words, text, hash: open.hash }, () => {
721
+ if (unsaved.get(at) === text) unsaved.delete(at);
722
+ if (editing.get(d.key) === open) editing.delete(d.key);
723
+ });
724
+ };
725
+ cancel.onclick = () => { unsaved.delete(at); editing.delete(d.key); redraw(d); };
711
726
  form = el('div', {}, el('label', { for: box.id }, `Comment on ${what(open)}`), box, el('div', { class: 'actions' }, save, cancel));
712
727
  }
713
728
  const row = (a, ...buttons) => el('li', {}, el('div', {}, el('p', { class: 'meta' }, what(a)), el('p', { class: 'saved-note', dir: 'auto' }, a.text)), ...buttons);
@@ -716,7 +731,7 @@
716
731
  const rows = mine.filter((a) => !a.sent).map((a) => {
717
732
  const edit = el('button', { class: 'quick', 'aria-label': `Edit comment, ${what(a)}` }, 'Edit'), del = el('button', { class: 'quick', 'aria-label': `Delete comment, ${what(a)}` }, 'Delete');
718
733
  edit.onclick = () => { editing.set(d.key, { ...a }); redraw(d); };
719
- del.onclick = () => post(del, { id: a.id, deleted: true }, () => { if (editing.get(d.key)?.mock === a.mock && editing.get(d.key)?.ref === a.ref) editing.delete(d.key); });
734
+ del.onclick = () => post(del, { id: a.id, deleted: true }, () => { unsaved.delete(spot(d.key, a)); if (editing.get(d.key)?.mock === a.mock && editing.get(d.key)?.ref === a.ref) editing.delete(d.key); });
720
735
  return row(a, el('span', { class: 'actions' }, edit, del));
721
736
  });
722
737
  return el('div', { class: 'ann', 'data-ann': d.key }, pages.length ? el('div', { class: 'actions' }, toggle) : '',
@@ -759,21 +774,23 @@
759
774
  el('span', { class: 'n' }, String(items.length)), el('span', { class: 'vis', 'aria-hidden': 'true' }, ...marks), el('span', { class: 'detail', dir: 'auto' }, detail));
760
775
  }
761
776
 
762
- /** The day so far: a mark per answer on a line of hours, and the list of them. Only answers have a place on it; an item's `filed` time is not shown here. */
777
+ /** The day so far: a mark per answer on a line of hours, and the list of them. Only answers have a place on it; an item's `filed` time is not shown here.
778
+ * Each column carries its own count, so the columns add up to the day's total in the caption; columns sit at least a tenth of the line apart, so two counts never touch. */
763
779
  function today(answered) {
764
- const day = new Date().toDateString(), done = answered.map((d) => ({ d, t: new Date(state.answers[d.key].at) }))
765
- .filter((e) => e.t.toDateString() === day).sort((x, y) => x.t - y.t);
780
+ // `answered` arrives newest first with ties in the Answered list's order, so the list keeps that order as given; the chart sorts a copy by time.
781
+ const day = new Date().toDateString(), fresh = answered.map((d) => ({ d, t: new Date(state.answers[d.key].at) }))
782
+ .filter((e) => e.t.toDateString() === day), done = [...fresh].sort((x, y) => x.t - y.t);
766
783
  const head = (range) => el('div', { class: 'sec-head' }, el('h2', {}, 'Today'), range ? el('span', { class: 'meta' }, range) : '');
767
784
  if (!done.length) return el('section', { class: 'card today' }, head(), el('p', { class: 'meta' }, 'Nothing answered today yet.'));
768
785
  const h0 = done[0].t.getHours(), step = Math.ceil((done.at(-1).t.getHours() + 1 - h0) / 4), h1 = h0 + step * Math.ceil((done.at(-1).t.getHours() + 1 - h0) / step);
769
786
  const x = (t) => ((t.getHours() + t.getMinutes() / 60 - h0) / (h1 - h0)) * 100, cols = new Map();
770
- for (const e of done) { const k = Math.round(x(e.t) / 4) * 4; cols.set(k, [...(cols.get(k) ?? []), e]); }
787
+ for (const e of done) { const k = Math.round(x(e.t) / 10) * 10; cols.set(k, [...(cols.get(k) ?? []), e]); }
771
788
  const hours = Array.from({ length: (h1 - h0) / step + 1 }, (_, i) => `${(h0 + i * step) % 24}:00`), SHOWN = 8;
772
789
  return el('section', { class: 'card today' }, head(`${done.length} answered, ${hours[0]} to ${hours.at(-1)}`),
773
790
  el('div', { class: 'day', 'aria-hidden': 'true' }, ...[...cols].map(([k, es]) => el('span', { class: 'col', style: `left: ${k}%` },
774
- ...es.slice(0, 6).map(() => el('span', { class: 'dot' })), es.length > 1 ? String(es.length) : ''))),
791
+ ...es.slice(0, 6).map(() => el('span', { class: 'dot' })), String(es.length)))),
775
792
  el('div', { class: 'hours', 'aria-hidden': 'true' }, ...hours.map((h) => el('span', {}, h))),
776
- el('ol', { class: 'events' }, ...done.slice(-SHOWN).map((e) => el('li', { dir: 'auto' }, el('time', {}, hhmm(e.t)), `${title(e.d)} · ${state.answers[e.d.key].label}`))),
793
+ el('ol', { class: 'events' }, ...fresh.slice(0, SHOWN).map((e) => el('li', { dir: 'auto' }, el('time', {}, hhmm(e.t)), `${title(e.d)} · ${state.answers[e.d.key].label}`))),
777
794
  done.length > SHOWN ? el('p', { class: 'meta' }, `and ${done.length - SHOWN} earlier`) : '');
778
795
  }
779
796
 
@@ -967,6 +984,12 @@
967
984
  pointing.set(frame.dataset.mock, on);
968
985
  aim(frame);
969
986
  }
987
+ // While pointing is on, a tap never opens the mock full size, even one that reaches the board instead of the frame:
988
+ // one on the frame's edge, one the browser sends there until it has drawn the frame taking taps, or one before a frame drawn anew is ready.
989
+ addEventListener('click', (e) => {
990
+ const frame = e.target.closest?.('.shot')?.querySelector('iframe[data-mock]');
991
+ if (frame && talks() && pointing.get(frame.dataset.mock) === true) e.preventDefault();
992
+ });
970
993
  addEventListener('message', (e) => {
971
994
  const frame = mockFrame(e.source), msg = e.data;
972
995
  if (!frame || !msg) return;