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 +8 -8
- package/dist/cli.js +165 -31
- package/dist/contract.js +69 -27
- package/dist/store.js +4 -0
- package/dist/vault.js +307 -23
- package/fixtures/contract/README.md +1 -1
- package/fixtures/contract/finding.v1/invalid-cause-missing.json +1 -0
- package/fixtures/contract/finding.v1/invalid-change_kind-unknown.json +1 -0
- package/fixtures/contract/finding.v1/invalid-evidence_ids-repeated.json +1 -0
- package/fixtures/contract/finding.v1/invalid-options-label-changed.json +1 -0
- package/fixtures/contract/finding.v1/invalid-proposal-too-long.json +1 -0
- package/fixtures/contract/finding.v1/invalid-record_text-unknown.json +1 -0
- package/fixtures/contract/finding.v1/valid-apply.json +1 -0
- package/fixtures/contract/finding.v1/valid-skip-no-records.json +1 -0
- package/fixtures/contract/gate.v1/invalid-default_if_silent-unknown.json +1 -0
- package/fixtures/contract/gate.v1/invalid-options-label-changed.json +1 -0
- package/fixtures/contract/gate.v1/invalid-options-too-few.json +1 -0
- package/fixtures/contract/gate.v1/invalid-run-missing.json +1 -0
- package/fixtures/contract/gate.v1/invalid-run-outcome-unknown.json +1 -0
- package/fixtures/contract/gate.v1/valid-three-actions.json +1 -0
- package/fixtures/contract/gate.v1/valid-two-actions.json +1 -0
- package/package.json +1 -1
- package/web/index.html +42 -19
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
export const
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
});
|