sign-off-axi 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/dist/cli.js +209 -54
- package/dist/contract.js +134 -18
- package/dist/store.js +30 -12
- package/dist/vault.js +254 -21
- package/fixtures/contract/README.md +1 -1
- package/fixtures/contract/evidence.v1/invalid-about-missing.json +1 -0
- package/fixtures/contract/evidence.v1/invalid-commit-short.json +1 -0
- package/fixtures/contract/evidence.v1/invalid-detail-unknown.json +1 -0
- package/fixtures/contract/evidence.v1/invalid-media-not-there.json +1 -0
- package/fixtures/contract/evidence.v1/invalid-media-parent-path.json +1 -0
- package/fixtures/contract/evidence.v1/invalid-media-too-many.json +1 -0
- package/fixtures/contract/evidence.v1/shot.png +0 -0
- package/fixtures/contract/evidence.v1/valid-media.json +1 -0
- package/fixtures/contract/evidence.v1/valid-run.json +1 -0
- 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 +64 -35
package/README.md
CHANGED
|
@@ -131,7 +131,7 @@ sign-off-axi post --type explainer --title "<question>" --media <video> [--about
|
|
|
131
131
|
sign-off-axi init [dir] [--agent auto|<name>] write the repo setup under <dir>/.sign-off/ (config, board, mocks/ and evidence/ folders)
|
|
132
132
|
and install the skill for each chosen coding agent (auto: each one on PATH)
|
|
133
133
|
sign-off-axi doctor [dir] check that setup and each agent's skill; every failure names its fix
|
|
134
|
-
sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid] check ask and
|
|
134
|
+
sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid] check ask, report, evidence, gate and finding messages against the contract (docs/contract.md) without posting
|
|
135
135
|
sign-off-axi open [--port 4410] [--bind <address>] [--evidence-root <dir>] start the board in the background; prints its URL and pid
|
|
136
136
|
sign-off-axi status is the board open started for this file running? its URL and pid
|
|
137
137
|
sign-off-axi stop stop that board
|
|
@@ -145,12 +145,12 @@ sign-off-axi export [--out <file.html>] write a frozen read-only copy o
|
|
|
145
145
|
|
|
146
146
|
- The board is the one you name right after the command (`sign-off-axi answers ./decisions.json`). With none, it is the nearest `.sign-off/` board that `init` set up, found by the walk described in [Test runs never touch your board](#test-runs-never-touch-your-board); otherwise the default board there (`~/.sign-off/decisions.json`, or under `SIGN_OFF_HOME`). Answers are saved next to it.
|
|
147
147
|
- Next-step commands in the output name paths under the working folder relative to it, and say `npx -y sign-off-axi` when sign-off-axi runs through `npx`.
|
|
148
|
-
- `post --type design` asks for a pick between up to 6 mock options, each with an optional `--mock <value>=<path>` file (`.html`, `.svg`, `.png`, `.jpg`, `.jpeg`, `.webp`) shown as a tall phone frame above its button (the recommended one first; on a phone the frames swipe sideways, the next one peeking in, and a tap opens one full size), plus a `--tradeoff` line and a `--recommend` badge. Mock paths are relative to the board file's folder and never escape it. One `.html` page can hold every variant: `--mock a=mocks/<key>/index.html?variant=a` loads the page with `?variant=a`. An `.html` mock or view loads the css, scripts, images and fonts beside it by relative name (`style.css`, `img/dot.png`), from its own folder only, never anything above it. The board adds a small pointing script to each `.html` mock: once the board turns it on for that frame, a tap on a part of the mock is kept from the mock and named back to the board as a path to that element, its tag and its words; it passes only text and gives the mock no reach into the board. With **Comment on the mocks** on, a tap opens a comment on that element: one per element, listed under the mocks with Edit and Delete, kept on the board and shown only on the exact mock file it was made on. An option with no mock, or whose file is gone, says "No mock for this option". `post --type decision` (the default) is exactly `raise`, and `raise` stays as its alias.
|
|
148
|
+
- `post --type design` asks for a pick between up to 6 mock options, each with an optional `--mock <value>=<path>` file (`.html`, `.svg`, `.png`, `.jpg`, `.jpeg`, `.webp`) shown as a tall phone frame above its button (the recommended one first; on a phone the frames swipe sideways, the next one peeking in, and a tap opens one full size), plus a `--tradeoff` line and a `--recommend` badge. Mock paths are relative to the board file's folder and never escape it. One `.html` page can hold every variant: `--mock a=mocks/<key>/index.html?variant=a` loads the page with `?variant=a`. An `.html` mock or view loads the css, scripts, images and fonts beside it by relative name (`style.css`, `img/dot.png`), from its own folder only, never anything above it. The board adds a small pointing script to each `.html` mock: once the board turns it on for that frame, a tap on a part of the mock is kept from the mock and named back to the board as a path to that element, its tag and its words; it passes only text and gives the mock no reach into the board. With **Comment on the mocks** on, a tap opens a comment on that element: one per element, listed under the mocks with Edit and Delete (words typed but not yet saved stay with their element while the page is open, so tapping another element and back keeps them; a reload drops them), kept on the board and shown, until it is sent, only on the exact mock file it was made on. The comments go to the agent with the next answer on that card (a pick, Other answer or **Another round**, which may then have no note), each with its element, in `wait` and `answers` (`comments`); once sent they are listed as sent and can no longer change. An option with no mock, or whose file is gone, says "No mock for this option". `post --type decision` (the default) is exactly `raise`, and `raise` stays as its alias.
|
|
149
149
|
- `post --type evidence` posts the next numbered update (`update-1`, `update-2`, ...) with 1 to 12 screenshots or recordings (`.png`, `.jpg`, `.jpeg`, `.webp`, `.mp4`, `.webm`, up to 50 MB each), a short note and an optional commit or PR link. The files are copied into `evidence/<number>/` next to the board, so the board is the store: no hosted service. The page shows the updates newest first; each file is served only while its size and SHA-256 still match what was posted, and recordings play inline on a phone. The person gives a verdict, **Pass**, **Fail** or **Needs work**, with an optional note, read back with `answers` or `wait --key update-<number>`.
|
|
150
150
|
- `post --type diagram` and `post --type interactive` show a question with a picture: `--view <path>` names one board-relative file, an image (`.svg`, `.png`, `.jpg`, `.jpeg`, `.webp`) drawn at card width for a diagram, or one self-contained `.html` page that runs in a sandboxed frame for interactive. Every mock and view is served under a Content-Security-Policy with no `fetch`, no remote files and no access to the dashboard or its tab. They answer like a decision.
|
|
151
151
|
- `post --type explainer` shows a question with a narrated video the agent made itself (stills plus speech from a free local engine such as piper or espeak-ng, joined with ffmpeg; the skill has the recipe). `--media` names one `.mp4` or `.webm`, copied into `evidence/<number>/` and served hash-checked like evidence media; it plays on the card with its own controls, at phone width too. `--about` is a short summary and each `--chapter m:ss=<title>` is a button that plays the video from there. sign-off itself never makes the video: it calls no speech engine, no ffmpeg and no network service.
|
|
152
|
-
- The person can also tap **Another round** inside **Other answer** (with the typed note), which wakes a waiting agent the same way an answer does; the agent reads it back in `answers --json` under `rounds
|
|
153
|
-
- **Dismiss** (the × on an item in a list, **Dismiss** under Needs you first and beside **Skip for now** in Triage, or **Clear all done** on Answered) hides items from the board, Today included; **Undo** in the toast brings them back, and so does **Restore** under **Dismissed (N)** at the end of Needs you first and Triage.
|
|
152
|
+
- The person can also tap **Another round** inside **Other answer** (with the typed note), which wakes a waiting agent the same way an answer does; the agent reads it back in `answers --json` under `rounds`, with any comments on mock elements it carried.
|
|
153
|
+
- **Dismiss** (the × on an item in a list, **Dismiss** under Needs you first and beside **Skip for now** in Triage, or **Clear all done** on Answered) hides items from the board, Today included; **Undo** in the toast brings them back, and so does **Restore** under **Dismissed (N)** at the end of Needs you first and Triage. `list` counts a dismissed item as neither waiting nor answered, as the board does (`list --full` says `dismissed` for an unanswered one and still its answer for an answered one), and `answers` does not count it as waiting; the agent is not told, so an ask it waits on keeps waiting, and `answers` still returns a dismissed answer. The storage is in [docs/file-formats.md](docs/file-formats.md).
|
|
154
154
|
- The person can comment on any item (**Send as a comment**, also inside **Other answer**: each card has that one place to type) and queue a free-text prompt for the agent (**Ask the agent**). `poll` blocks until one arrives and prints every new one once (`--json` prints `{"messages":[{"id","kind","key"?,"text","at"}]}`); the page then shows it as read by the agent. Typed notes that are not sent yet stay in the browser across reloads; everything sent is saved next to the board and survives a restart.
|
|
155
155
|
- The board runs on your own machine only: no hosted service, no recurring cost. It listens on `127.0.0.1` unless `--bind <address>` (or `bind:` in the `config.yaml` that `init` writes) names one private address of this machine, such as its VPN address, so a phone or tablet on your own network can open it. A `hosts:` line in that config names private DNS names that lead to the bind address, such as its mesh VPN name (commas between them), so the board also opens by name; `doctor` checks each one resolves to the bind address. All interfaces and public addresses are refused. Beyond `127.0.0.1` the board makes an access token once (`decisions.token`, mode 600, next to the board), `open` prints it in the link, and every request without it is refused; the link sets a cookie, so the page and its media work after the first load. Delete the token file to shut old links out at once, then `stop` and `open` for a new one.
|
|
156
156
|
- `export` writes one self-contained HTML file (`decisions.export.html` by default): every item, answer, note and another-round request with its local mocks and media inlined; comments and queued prompts are left out. `--out` must end in `.html`, so a wrong path never overwrites the board. It opens from disk with no server, is read-only, and makes no network request. A mock page is inlined on its own, so the css and images beside it show only on the running board.
|
|
@@ -159,10 +159,10 @@ sign-off-axi export [--out <file.html>] write a frozen read-only copy o
|
|
|
159
159
|
- Raising a question that is already on the board is a no-op that reports its status, so an agent can re-run it safely. `--key` defaults to a slug of the title.
|
|
160
160
|
- Each raised or posted item records when it was filed (`filed`), so Triage shows `Waiting since 09:15` and the Waiting on you tile `Oldest 09:15`; `post --replace` keeps an item's first time.
|
|
161
161
|
- `open` is a no-op when the same board is already open on the same address, and fails with `CONFLICT` when it runs on another one. `--evidence-root` is the folder holding `.done-done/`, for decisions that link run evidence.
|
|
162
|
-
- `--filer <id>` on `post` and `raise` saves who filed the item (an agent session id or any 1-128 of `A-Z a-z 0-9 . _ -`; `SIGN_OFF_FILER` sets it for every command, and a post with neither saves `CLAUDE_CODE_SESSION_ID` when it is set). `wait --filer <id>` then returns only answers to that filer's items it has not taken yet, marks each taken (`decisions.seen.jsonl`) and the card shows **Seen by agent**; it fails with `NOT_FOUND` when none of that filer's items
|
|
163
|
-
- `on_answer: <command> [args]` in the `config.yaml` beside the board runs that command for every saved answer, read when the board starts. It runs with no shell, in the board's folder, and gets the answer as one JSON line on stdin (`{"key","answer","label","close","at","note"?,"title"?,"filer"?}`); the answer is never put into its arguments, so a typed note cannot inject a command. A failure is logged and never blocks the answer. With no shell, `|`, `>` and `&&` are passed as plain arguments, so put a pipeline in a script and name the script; a relative path such as `./hook.sh` is found from the board's folder. `doctor` checks that the command can run.
|
|
162
|
+
- `--filer <id>` on `post` and `raise` saves who filed the item (an agent session id or any 1-128 of `A-Z a-z 0-9 . _ -`; `SIGN_OFF_FILER` sets it for every command, and a post with neither saves `CLAUDE_CODE_SESSION_ID` when it is set). `wait --filer <id>` then returns only answers to that filer's items it has not taken yet, marks each taken (`decisions.seen.jsonl`) and the card shows **Seen by agent**; it fails with `NOT_FOUND` when none of that filer's items still needs an answer. The page never shows the filer id.
|
|
163
|
+
- `on_answer: <command> [args]` in the `config.yaml` beside the board runs that command for every saved answer, read when the board starts. It runs with no shell, in the board's folder, and gets the answer as one JSON line on stdin (`{"key","answer","label","close","at","note"?,"title"?,"filer"?,"comments"?}`, where `comments`, present when the answer carried comments on mock elements, lists each as `id`, `mock`, `hash`, `ref`, `tag`, `words` and `text`); the answer is never put into its arguments, so a typed note cannot inject a command. A failure is logged and never blocks the answer. With no shell, `|`, `>` and `&&` are passed as plain arguments, so put a pipeline in a script and name the script; a relative path such as `./hook.sh` is found from the board's folder. `doctor` checks that the command can run.
|
|
164
164
|
- `post --from <file> --replace --filer <id>` lets several programs feed one board: it replaces only that filer's items (and items with no filer that the file names by key), keeps every other item, the board's other top-level fields and all answers, and an empty items list removes that filer's items.
|
|
165
|
-
- `wait` replaces a polling loop. With `--key` it returns at once if that decision is already answered, and waits for a key that is not raised yet; without it, it waits for the next answer to any decision still
|
|
165
|
+
- `wait` replaces a polling loop. With `--key` it returns at once if that decision is already answered, and waits for a key that is not raised yet; without it, it waits for the next answer to any decision that still needs an answer and fails with `NOT_FOUND` when nothing needs one. It wakes on each save, gives up after `--timeout` seconds (600, ten minutes, by default) with code `TIMEOUT` and exit 1, and takes `--full` and `--json` like `answers`. Ctrl-C stops it without changing anything.
|
|
166
166
|
- `open` records the board's pid, port and address next to the board file (`decisions.board.pid`). `status` and `stop` read it and only act on a pid that really serves that board, so no one has to guess a pid to kill. `open` on a board already served by `serve` records that server, so `stop` can close it too.
|
|
167
167
|
|
|
168
168
|
```
|
package/dist/cli.js
CHANGED
|
@@ -10,9 +10,9 @@ import { basename, delimiter, dirname, extname, isAbsolute, join, relative, reso
|
|
|
10
10
|
import { parseArgs } from 'node:util';
|
|
11
11
|
import { encode } from '@toon-format/toon';
|
|
12
12
|
import { AxiError, runAxiCli } from 'axi-sdk-js';
|
|
13
|
-
import { CHAPTERS_MAX, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendMessage, appendRound, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, writeItems } from './store.js';
|
|
14
|
-
import { ContractError, KINDS, checkMessage, parseMessage } from './contract.js';
|
|
15
|
-
import { sweepVault } from './vault.js';
|
|
13
|
+
import { CHAPTERS_MAX, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendMessage, appendRound, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, writeItems } from './store.js';
|
|
14
|
+
import { ContractError, KINDS, MEDIA_BYTES, checkMessage, mediaFiles, parseMessage } from './contract.js';
|
|
15
|
+
import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
|
|
16
16
|
const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
|
|
17
17
|
const CONTRACT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/contract.md';
|
|
18
18
|
/** Coding agents init installs the skill for: the `npx skills add -a` name, the command that shows it is installed,
|
|
@@ -74,10 +74,13 @@ examples:
|
|
|
74
74
|
`,
|
|
75
75
|
validate: `usage: sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid]
|
|
76
76
|
Checks message files against the communication contract without posting them: ${CONTRACT}
|
|
77
|
-
A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask and
|
|
77
|
+
A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, report, evidence, gate and finding, schema 1 each.
|
|
78
|
+
An evidence message's media paths read from the message file's folder: each file must be there, inside it, and 50 MB or less.
|
|
79
|
+
A gate names its done-done run (run_id and commit) and offers 2 or 3 of approve (Approve), skip (Skip the judge) and fix (Fix first).
|
|
80
|
+
A finding gives a repeated cause, its counts, record ids (never record text) and a proposed change, and offers apply (Apply) and skip (Skip).
|
|
78
81
|
Post one with \`sign-off-axi post --kind <kind> --from <message.json>\`, or --from - to read stdin.
|
|
79
82
|
A board file given to raise --from or post --from may hold such messages as items: each is checked the same way before it is stored, and a report shows under Reports with no answer.
|
|
80
|
-
In such a file a new item of a contract kind (a decision, the default, an ask
|
|
83
|
+
In such a file a new item of a contract kind (a decision, the default, an ask or a report) must carry contract, kind and schema; new evidence is posted with post --kind evidence, one at a time.
|
|
81
84
|
Each file gets a row with ok, or the error and one help line that names the fix. A field written twice, an unknown field or a broken limit refuses the file.
|
|
82
85
|
--kind each file must be this kind, so the wrong file is refused
|
|
83
86
|
--expect-invalid every file must be refused: exits 0 only then, 2 when any passes (the conformance check of invalid fixtures)
|
|
@@ -88,6 +91,9 @@ examples:
|
|
|
88
91
|
sign-off-axi validate ask.json
|
|
89
92
|
sign-off-axi validate --kind ask messages/*.json
|
|
90
93
|
sign-off-axi validate --kind report report.json
|
|
94
|
+
sign-off-axi validate --kind evidence proof/evidence.json
|
|
95
|
+
sign-off-axi validate --kind gate gate.json
|
|
96
|
+
sign-off-axi validate --kind finding finding.json
|
|
91
97
|
sign-off-axi validate --expect-invalid node_modules/sign-off-axi/fixtures/contract/*/invalid-*.json
|
|
92
98
|
`,
|
|
93
99
|
answers: `usage: sign-off-axi answers [board.json] [--key <key>] [--full] [--json]
|
|
@@ -104,10 +110,10 @@ examples:
|
|
|
104
110
|
Blocks until the user answers, then prints the answer the way answers does. Use it instead of a polling loop.
|
|
105
111
|
--filer only the items posted with this --filer (or SIGN_OFF_FILER): returns every answer to them this filer has not taken yet,
|
|
106
112
|
even one saved before wait started, and marks it taken, so the board shows "Seen by agent" on that card;
|
|
107
|
-
NOT_FOUND when none of them
|
|
113
|
+
NOT_FOUND when none of them still needs an answer. Without --filer or SIGN_OFF_FILER, wait takes no filer
|
|
108
114
|
--key wait for that decision; an answer saved before wait starts returns at once
|
|
109
115
|
and a key not raised yet is waited for too
|
|
110
|
-
without --key, wait for the next answer to any decision still
|
|
116
|
+
without --key, wait for the next answer to any decision that still needs an answer; NOT_FOUND when nothing needs one
|
|
111
117
|
--timeout seconds to wait, 600 (10 minutes) by default; then exits 1 with code TIMEOUT
|
|
112
118
|
--full adds the button label, close and title
|
|
113
119
|
--json {"<key>":{"answer","label","close","at","note"?}} for the new answers; note only when the user wrote one
|
|
@@ -156,6 +162,10 @@ examples:
|
|
|
156
162
|
Posts an item for the user. --type decision (the default) is a yes/no or pick-one question, exactly like raise.
|
|
157
163
|
--kind posts one contract message (${CONTRACT}), read from a file or from stdin with --from -: it must be that kind and pass every rule,
|
|
158
164
|
else it is refused with one fix line and nothing is written. A key already on the board takes only the same message again; anything else exits 2.
|
|
165
|
+
--kind evidence copies its media, read from the message file's folder (the current folder for stdin), into evidence/<number>/ like --type evidence;
|
|
166
|
+
a "run" is shown as the update's proof record. With no key it posts the next update-<number>.
|
|
167
|
+
--kind gate holds a done-done run for the user: it waits like an ask; run done-done respond --action <the picked value>.
|
|
168
|
+
--kind finding asks the user to apply or skip a retro's proposed change: it waits like an ask.
|
|
159
169
|
Listing --option needs --recommend <value>, on every type: the option you would pick, shown first and marked. --from --replace feeds are not held to it.
|
|
160
170
|
--filer names who posts (your agent session or any id of 1-128 of A-Z a-z 0-9 . _ -), saved on the item; then \`wait --filer <id>\` gets the answer.
|
|
161
171
|
SIGN_OFF_FILER sets it for every command; with neither, the Claude Code session id is saved when there is one.
|
|
@@ -180,6 +190,9 @@ examples:
|
|
|
180
190
|
sign-off-axi post .sign-off/decisions.json --type design --key sign-in --title "Pick the sign-in layout" --option a=A --option b=B --option c=C --mock a=mocks/sign-in/index.html?variant=a --mock b=mocks/sign-in/index.html?variant=b --mock c=mocks/sign-in/index.html?variant=c --tradeoff "..." --recommend b
|
|
181
191
|
sign-off-axi post --type decision --title "Keep the weekly digest?" --recommend yes
|
|
182
192
|
sign-off-axi post --kind ask --from ask.json --filer agent-41
|
|
193
|
+
sign-off-axi post --kind evidence --from proof/evidence.json --filer done-done
|
|
194
|
+
sign-off-axi post --kind gate --from gate.json --filer done-done
|
|
195
|
+
sign-off-axi post --kind finding --from finding.json --filer learn-from-mistakes
|
|
183
196
|
sign-off-axi post .sign-off/decisions.json --type interactive --title "Does the sign-in flow feel right?" --view mocks/sign-in/demo.html --option yes=Yes --option no="Needs work" --recommend yes
|
|
184
197
|
sign-off-axi post --type evidence --title "Sign-in screen on a phone" --about "The new screen loads in 1 second." --media phone.png --media flow.mp4 --link https://github.com/acme/app/pull/12
|
|
185
198
|
sign-off-axi post --type explainer --title "Is the new sign-in clear?" --about "The new sign-in has 2 steps." --media explainer.mp4 --chapter 0:00=Start --chapter 0:12="Step 2"
|
|
@@ -252,9 +265,15 @@ const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report'];
|
|
|
252
265
|
/** Items of a --from file that carry contract or kind are contract messages: each is checked against its kind's rules before anything is stored.
|
|
253
266
|
* A new item of a contract kind must carry them (contract section 2); one already on the board is not checked again. */
|
|
254
267
|
function contractItems(items, from, file) {
|
|
255
|
-
const
|
|
268
|
+
const on = new Map((existsSync(file) ? readItems(file).items : []).map((i) => [i?.key, i]));
|
|
256
269
|
items.forEach((m, i) => {
|
|
257
|
-
|
|
270
|
+
// Evidence in message form (no type) never comes in a feed, even under a key on the board: its media must be copied and numbered.
|
|
271
|
+
// A stored board-format evidence item passes through a feed unchanged; any other evidence under a key is refused.
|
|
272
|
+
const kept = on.get(m?.key)?.type === 'evidence';
|
|
273
|
+
if ((m?.kind === 'evidence' && m?.type === undefined) || (!kept && (m?.kind ?? m?.type) === 'evidence')) {
|
|
274
|
+
throw fail(`${from}: items[${i}]${isKey(m?.key) ? ` key ${m.key}` : ''}: evidence is posted one at a time, not in a --from file`, 'VALIDATION_ERROR', 'Post it with sign-off-axi post --kind evidence --from <message.json>');
|
|
275
|
+
}
|
|
276
|
+
if (on.has(m?.key))
|
|
258
277
|
return;
|
|
259
278
|
if (m?.contract === undefined && m?.kind === undefined) {
|
|
260
279
|
if (!CONTRACT_TYPES.includes(m?.type ?? 'decision'))
|
|
@@ -351,17 +370,19 @@ function filerOf(values, session = false) {
|
|
|
351
370
|
}
|
|
352
371
|
const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
|
|
353
372
|
: `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
|
|
354
|
-
/** Still waiting on the user: not answered, and something to answer (an item with no options and no reply is only shown). */
|
|
355
|
-
const waits = (d) => !d.answer && (d.options.length > 0 || !!d.reply);
|
|
356
|
-
/** The board's decisions with their latest answer; a missing board file is an empty board.
|
|
373
|
+
/** Still waiting on the user: not answered, not dismissed, and something to answer (an item with no options and no reply is only shown). */
|
|
374
|
+
const waits = (d) => !d.dismissed && !d.answer && (d.options.length > 0 || !!d.reply);
|
|
375
|
+
/** The board's decisions with their latest answer; a missing board file is an empty board.
|
|
376
|
+
* A dismissal hides an item from the board and is not waiting here either, checked against the same dismissed log the board reads. */
|
|
357
377
|
function board(file, say = warn) {
|
|
358
378
|
if (!existsSync(file))
|
|
359
379
|
return [];
|
|
360
380
|
const decisions = loadDecisions(file), answers = readAnswers(file, say);
|
|
381
|
+
const hidden = new Set(readDismissed(file, answers, readRounds(file), say));
|
|
361
382
|
for (const key of Object.keys(answers))
|
|
362
383
|
if (!decisions.some((d) => d.key === key))
|
|
363
384
|
say(`left out the answer for ${key}: that question is no longer in ${basename(file)}`);
|
|
364
|
-
return decisions.map((d) => ({ ...d, answer: Object.hasOwn(answers, d.key) ? answers[d.key] : undefined }));
|
|
385
|
+
return decisions.map((d) => ({ ...d, answer: Object.hasOwn(answers, d.key) ? answers[d.key] : undefined, dismissed: hidden.has(d.key) }));
|
|
365
386
|
}
|
|
366
387
|
function ready(file, on, root) {
|
|
367
388
|
if (!existsSync(file))
|
|
@@ -372,9 +393,9 @@ function ready(file, on, root) {
|
|
|
372
393
|
}
|
|
373
394
|
function list(args) {
|
|
374
395
|
const { file, values, on } = parse('list', args, TIERS);
|
|
375
|
-
const rows = board(file), waiting = rows.filter(waits), answered = rows.filter((d) => d.answer).length;
|
|
396
|
+
const rows = board(file), waiting = rows.filter(waits), answered = rows.filter((d) => d.answer && !d.dismissed).length;
|
|
376
397
|
if (values.json)
|
|
377
|
-
return JSON.stringify({ board: file, decisions: rows });
|
|
398
|
+
return JSON.stringify({ board: file, decisions: rows.map(({ dismissed, ...d }) => (dismissed ? { ...d, dismissed } : d)) });
|
|
378
399
|
const help = [
|
|
379
400
|
...(waiting.length ? [`Run \`sign-off-axi open${on}\` to show the board to the user`] : []),
|
|
380
401
|
...(answered ? [`Run \`sign-off-axi answers${on}\` to read the ${answered} saved answers`] : []),
|
|
@@ -382,7 +403,7 @@ function list(args) {
|
|
|
382
403
|
];
|
|
383
404
|
const where = tilde(file) + (existsSync(file) ? '' : ' (no file yet; raise creates it)');
|
|
384
405
|
if (values.full) {
|
|
385
|
-
return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: d.answer?.answer ?? 'waiting', title: d.title, about: d.about ?? '', detail: d.detail ?? '',
|
|
406
|
+
return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: d.answer?.answer ?? (d.dismissed ? 'dismissed' : 'waiting'), title: d.title, about: d.about ?? '', detail: d.detail ?? '',
|
|
386
407
|
recommend: d.recommend_value ?? '', default_if_silent: d.default_if_silent ?? '', close: d.close })) }, help);
|
|
387
408
|
}
|
|
388
409
|
return toon({
|
|
@@ -396,20 +417,20 @@ function printAnswers(file, rows, waiting, values, help) {
|
|
|
396
417
|
const done = rows.flatMap((d) => (d.answer ? [{ ...d.answer, title: d.title }] : []));
|
|
397
418
|
const rounds = readRounds(file).filter((r) => rows.some((d) => d.key === r.key));
|
|
398
419
|
if (values.json) {
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
return [key, { answer, label, close, at, note, ...(asked.length && { rounds: asked }) }];
|
|
402
|
-
}));
|
|
420
|
+
const asked = (key) => rounds.filter((r) => r.key === key).map(({ note, at, comments }) => ({ note, at, comments }));
|
|
421
|
+
const out = Object.fromEntries(done.map(({ key, answer, label, close, at, note, comments }) => [key, { answer, label, close, at, note, comments, ...(asked(key).length && { rounds: asked(key) }) }]));
|
|
403
422
|
for (const r of rounds)
|
|
404
423
|
if (!out[r.key])
|
|
405
|
-
out[r.key] = { rounds:
|
|
424
|
+
out[r.key] = { rounds: asked(r.key) };
|
|
406
425
|
return JSON.stringify(out);
|
|
407
426
|
}
|
|
408
|
-
const notes = done.some((a) => a.note);
|
|
427
|
+
const notes = done.some((a) => a.note), sent = [...done, ...rounds].flatMap(({ key, comments = [] }) => comments.map(({ mock, ref, tag, words, text }) => ({ key, mock, ref, tag, words, text })));
|
|
409
428
|
return toon({
|
|
410
429
|
board: tilde(file),
|
|
411
430
|
answers: done.length ? done.map(({ key, answer, at, label, close, title, note = '' }) => ({ key, answer, at, ...(values.full && { label, close, title }), ...(notes && { note }) })) : '0 saved yet',
|
|
412
431
|
...(rounds.length && { rounds: rounds.map(({ key, note, at }) => ({ key, note, at })) }),
|
|
432
|
+
// Comments on mock elements that went with an answer or a round, each with the element it points at.
|
|
433
|
+
...(sent.length && { comments: sent }),
|
|
413
434
|
waiting,
|
|
414
435
|
}, help);
|
|
415
436
|
}
|
|
@@ -422,10 +443,13 @@ function answers(args) {
|
|
|
422
443
|
return printAnswers(file, rows, waiting, values, waiting ? [`Run \`sign-off-axi open${on}\` so the user can answer the ${waiting} waiting`, `Run \`sign-off-axi wait${on}${key}\` to block until they answer`] : []);
|
|
423
444
|
}
|
|
424
445
|
async function wait(args) {
|
|
425
|
-
const { file, values, on: onBoard } = parse('wait', args, { ...TIERS, filer: { type: 'string' }, key: { type: 'string' }, timeout: { type: 'string', default: '600' } });
|
|
446
|
+
const { file, values, on: onBoard } = parse('wait', args, { ...TIERS, filer: { type: 'string' }, key: { type: 'string' }, timeout: { type: 'string', default: '600' }, into: { type: 'string' } });
|
|
426
447
|
if (!/^\d{1,7}$/.test(values.timeout))
|
|
427
448
|
throw fail('--timeout must be whole seconds, 0 or more', 'VALIDATION_ERROR', `Run \`sign-off-axi wait --help\``);
|
|
428
449
|
const filer = filerOf(values), on = onBoard + (filer ? ` --filer ${filer}` : '');
|
|
450
|
+
// Vault Give delivery: its own loop, before any board read, so no ordinary wait changes. Undocumented while the vault is OFF.
|
|
451
|
+
if (values.into !== undefined)
|
|
452
|
+
return waitInto(file, values, onBoard);
|
|
429
453
|
if (values.key === undefined)
|
|
430
454
|
ready(file, onBoard);
|
|
431
455
|
const seconds = Number(values.timeout), deadline = Date.now() + seconds * 1000, key = values.key === undefined ? '' : ` --key ${values.key}`;
|
|
@@ -437,7 +461,7 @@ async function wait(args) {
|
|
|
437
461
|
for (const r of readRounds(file))
|
|
438
462
|
seenRounds.set(r.key, (seenRounds.get(r.key) ?? 0) + 1);
|
|
439
463
|
// A save wakes the loop at once; the 1 second timer is the fallback where file events are unavailable.
|
|
440
|
-
let wake = () => { }, watcher;
|
|
464
|
+
let wake = () => { }, watcher, vaultWarned = false;
|
|
441
465
|
const name = basename(answersPath(file));
|
|
442
466
|
try {
|
|
443
467
|
try {
|
|
@@ -466,7 +490,36 @@ async function wait(args) {
|
|
|
466
490
|
if (found.length) {
|
|
467
491
|
return printAnswers(file, found, waiting, values, [waiting ? `Run \`sign-off-axi wait${on}\` for the next of the ${waiting} waiting` : `Run \`sign-off-axi stop${onBoard}\` to close the board`]);
|
|
468
492
|
}
|
|
469
|
-
|
|
493
|
+
// Private vault expiries and refusals reach a filer wait only once its ordinary answers are out; each is taken once.
|
|
494
|
+
let live = 0, unknown = false;
|
|
495
|
+
if (filer !== undefined && values.key === undefined) {
|
|
496
|
+
const vault = await takeVaultExpiries(file, filer).catch(() => undefined);
|
|
497
|
+
unknown = !vault;
|
|
498
|
+
if ((!vault || !vault.clean) && !vaultWarned) {
|
|
499
|
+
vaultWarned = true;
|
|
500
|
+
warn('Vault expiry status unavailable; ordinary answers still arrive.');
|
|
501
|
+
}
|
|
502
|
+
if (vault?.events.length)
|
|
503
|
+
return vaultEvent(file, vault.events, values);
|
|
504
|
+
live = vault?.live ?? 0;
|
|
505
|
+
}
|
|
506
|
+
// A keyed filer wait reads its own item's expiry or refusal, unless one of its board rows already holds the key.
|
|
507
|
+
if (filer !== undefined && values.key !== undefined && isKey(values.key) && !rows.some((d) => d.key === values.key)) {
|
|
508
|
+
let event;
|
|
509
|
+
try {
|
|
510
|
+
event = await readVaultExpiry(file, values.key, filer, true);
|
|
511
|
+
}
|
|
512
|
+
catch (e) {
|
|
513
|
+
if (e?.code !== 'READER_MISMATCH' && !vaultWarned) {
|
|
514
|
+
vaultWarned = true;
|
|
515
|
+
warn('Vault expiry status unavailable; ordinary answers still arrive.');
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (event)
|
|
519
|
+
return vaultEvent(file, [{ key: values.key, at: event.at, status: event.status }], values);
|
|
520
|
+
}
|
|
521
|
+
// A dismissed ask is hidden from the board, not withdrawn: it is not counted as waiting, but the agent still waits on it.
|
|
522
|
+
if (values.key === undefined && !rows.some((d) => waits({ ...d, dismissed: false })) && !live && !unknown) {
|
|
470
523
|
throw fail(`nothing${filer ? ` posted by ${filer}` : ''} is waiting on ${tilde(file)}`, 'NOT_FOUND', `Run \`sign-off-axi answers${onBoard}\` to read the saved answers`, `Run \`sign-off-axi raise${on} --title "<question>"\` to ask a new one`);
|
|
471
524
|
}
|
|
472
525
|
const left = deadline - Date.now();
|
|
@@ -485,6 +538,55 @@ async function wait(args) {
|
|
|
485
538
|
watcher?.close();
|
|
486
539
|
}
|
|
487
540
|
}
|
|
541
|
+
/** Metadata only: a fixed sentence, never a value, URL, path or filer. Failures exit 1; a delivery exits 0 with a label.
|
|
542
|
+
* One poll takes one kind, so all events share a status. */
|
|
543
|
+
const VAULT_EVENTS = {
|
|
544
|
+
expired: { sentence: errors.EXPIRED, code: 'EXPIRED' },
|
|
545
|
+
refused: { sentence: errors.REFUSED, code: 'REFUSED' },
|
|
546
|
+
'delivery-uncertain': { sentence: errors.DELIVERY_UNCERTAIN, code: 'DELIVERY_UNCERTAIN' },
|
|
547
|
+
delivered: { sentence: 'Saved to the approved destination file.' },
|
|
548
|
+
};
|
|
549
|
+
function vaultEvent(file, events, values) {
|
|
550
|
+
const status = events[0].status, { sentence, code } = VAULT_EVENTS[status], at = (ms) => new Date(ms).toISOString().slice(0, 19) + 'Z';
|
|
551
|
+
if (code)
|
|
552
|
+
process.exitCode = 1;
|
|
553
|
+
if (values.json)
|
|
554
|
+
return JSON.stringify(Object.fromEntries(events.map((e) => [e.key, { answer: status, label: sentence, close: 'done', at: at(e.at) }])));
|
|
555
|
+
return toon({ board: tilde(file), [status]: events.map((e) => ({ key: e.key, at: at(e.at) })), ...(code ? { error: sentence, code } : { label: sentence }) }, code ? ['Ask for a fresh item under a new key'] : []);
|
|
556
|
+
}
|
|
557
|
+
// Only these leave the item deliverable later; every other vault code is final for this wait.
|
|
558
|
+
const INTO_RETRY = ['BUSY', 'NOT_FOUND', 'VAULT_KEY_UNAVAILABLE'];
|
|
559
|
+
const INTO_HELP = { DESTINATION_DENIED: 'Fix the destination folder locally, then run the same wait again', DESTINATION_EXISTS: 'Fix the destination folder locally, then run the same wait again',
|
|
560
|
+
READER_MISMATCH: 'Check --key, --filer and --into against the item', VALIDATION_ERROR: 'Check --key, --filer and --into against the item' };
|
|
561
|
+
/** `wait --into`: delivers one Give once into its bound file, polling while it is pending. The destination is never printed. */
|
|
562
|
+
async function waitInto(file, values, onBoard) {
|
|
563
|
+
const { key, filer, into } = values, seconds = Number(values.timeout), deadline = Date.now() + seconds * 1000;
|
|
564
|
+
// An explicit --filer: an inherited SIGN_OFF_FILER must not route a secret write.
|
|
565
|
+
if (key === undefined || filer === undefined)
|
|
566
|
+
throw fail('wait --into needs --key and --filer', 'VALIDATION_ERROR', 'Pass the Give item\'s --key and your own --filer');
|
|
567
|
+
let warned = false;
|
|
568
|
+
for (;;) {
|
|
569
|
+
let done;
|
|
570
|
+
try {
|
|
571
|
+
done = await deliverVault(file, key, filer, into);
|
|
572
|
+
}
|
|
573
|
+
catch (e) {
|
|
574
|
+
const code = e instanceof VaultError ? e.code : 'BUSY';
|
|
575
|
+
if (!INTO_RETRY.includes(code))
|
|
576
|
+
throw fail(errors[code], code, INTO_HELP[code] ?? 'Ask for a fresh item under a new key');
|
|
577
|
+
if (code !== 'NOT_FOUND' && !warned) {
|
|
578
|
+
warned = true;
|
|
579
|
+
warn('Vault delivery status unavailable; still waiting.');
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (done)
|
|
583
|
+
return vaultEvent(file, [done], values);
|
|
584
|
+
const left = deadline - Date.now();
|
|
585
|
+
if (left <= 0)
|
|
586
|
+
throw fail(`no vault item with key ${key} was ready to deliver within ${seconds}s`, 'TIMEOUT', `Run \`sign-off-axi wait${onBoard} --key ${key} --filer ${filer} --into <file> --timeout ${seconds}\` to keep waiting`);
|
|
587
|
+
await new Promise((ok) => setTimeout(ok, Math.min(1000, left)));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
488
590
|
const slug = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 64).replace(/^-+|-+$/g, '') || 'decision';
|
|
489
591
|
/** Runs fn while holding an exclusive lock file that names its pid, so two agents raising at once never drop a decision,
|
|
490
592
|
* and a lock a crash left behind can be told apart from one in use. */
|
|
@@ -597,7 +699,13 @@ function replace(file, on, from, filer) {
|
|
|
597
699
|
fits(i?.title, i?.key);
|
|
598
700
|
const feed = fed.items.map((i) => ({ filed: first.get(i?.key)?.filed ?? now, ...i, ...(filer && { filer }) }));
|
|
599
701
|
// An answer belongs to its question: an answered key may not come back asking something else.
|
|
600
|
-
const question = (d) => JSON.stringify([d.title, [...d.options].sort((a, b) => a.value.localeCompare(b.value)), !!d.reply]);
|
|
702
|
+
const question = (d) => JSON.stringify([d.title, [...d.options].sort((a, b) => a.value.localeCompare(b.value)), !!d.reply, d.run, d.finding]);
|
|
703
|
+
// A gate's answer is the action for its run, and a finding's the verdict on its proposal, so a dropped answered one cannot come back.
|
|
704
|
+
const saved = readAnswers(file);
|
|
705
|
+
for (const i of fed.items)
|
|
706
|
+
if ((i?.kind === 'gate' || i?.kind === 'finding') && !first.has(i?.key) && Object.hasOwn(saved, i?.key)) {
|
|
707
|
+
throw fail(`key ${i.key} was answered on ${tilde(file)} and is no longer there`, 'CONFLICT', 'Post it under a new key');
|
|
708
|
+
}
|
|
601
709
|
for (const had of board(file)) {
|
|
602
710
|
const d = incoming.find((x) => x.key === had.key);
|
|
603
711
|
if (filer && d && had.filer && had.filer !== filer)
|
|
@@ -633,9 +741,9 @@ function replace(file, on, from, filer) {
|
|
|
633
741
|
return toon({ board: tilde(file), items: items.length, changed }, [`Run \`sign-off-axi answers${on}\` to read the answers`]);
|
|
634
742
|
});
|
|
635
743
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
function postMedia(file, on, values, type, filer) {
|
|
744
|
+
/** Copies the media into evidence/<n>/ next to the board and adds the item, numbered under the board lock: update-<n>, or an explainer under its own key.
|
|
745
|
+
* `message` is a checked contract evidence message (post --kind evidence): its key, if any, takes only the same message again, and its run is stored as `evidence`. */
|
|
746
|
+
function postMedia(file, on, values, type, filer, message) {
|
|
639
747
|
fits(values.title);
|
|
640
748
|
const explainer = type === 'explainer';
|
|
641
749
|
const extra = (explainer ? ['mock', 'tradeoff', 'view', 'link'] : ['key', 'option', 'recommend', 'default', 'mock', 'tradeoff', 'view', 'chapter']).find((f) => values[f] !== undefined);
|
|
@@ -647,7 +755,7 @@ function postMedia(file, on, values, type, filer) {
|
|
|
647
755
|
throw fail('--about is required for --type evidence: the short note', 'VALIDATION_ERROR', example);
|
|
648
756
|
if (explainer && values.media?.length !== 1)
|
|
649
757
|
throw fail('--type explainer needs one --media video (.mp4 or .webm)', 'VALIDATION_ERROR', example);
|
|
650
|
-
if (!values.media?.length || values.media.length > MEDIA_MAX)
|
|
758
|
+
if ((!message && !values.media?.length) || values.media.length > MEDIA_MAX)
|
|
651
759
|
throw fail(`--type evidence needs 1 to ${MEDIA_MAX} --media files`, 'VALIDATION_ERROR', example);
|
|
652
760
|
const kinds = explainer ? ['.mp4', '.webm'] : Object.keys(mediaTypes), options = optionsOf(values);
|
|
653
761
|
recommended([{ options, recommend_value: values.recommend }], true);
|
|
@@ -672,7 +780,15 @@ function postMedia(file, on, values, type, filer) {
|
|
|
672
780
|
const { items, extra } = existsSync(file) ? readItems(file) : { items: [], extra: {} };
|
|
673
781
|
// Boards in one folder share evidence/, so a number is taken only by creating its folder; one in use is never written into.
|
|
674
782
|
const current = board(file), store = join(dirname(file), 'evidence'), taken = current.map((d) => Number(/^update-(\d+)$/.exec(d.key)?.[1] ?? 0));
|
|
675
|
-
const own = explainer && (values.key ?? slug(values.title));
|
|
783
|
+
const own = message ? message.key : explainer && (values.key ?? slug(values.title)), had = message && own && items.find((x) => x?.key === own);
|
|
784
|
+
if (had) {
|
|
785
|
+
// The stored item, read back as the message it came from: its run, and each media file by hash.
|
|
786
|
+
const asMessage = ({ type: _t, number: _n, filed: _f, evidence: run, media: kept, ...rest }) => canon({ ...rest, run, media: kept?.map((x) => x.sha256) });
|
|
787
|
+
if (asMessage(had) !== canon({ ...message, media: sources.length ? sources.map((src) => sha256(readFileSync(src))) : undefined })) {
|
|
788
|
+
throw fail(`key ${own} already holds a different message on ${tilde(file)}`, 'CONFLICT', 'Post it under a new key');
|
|
789
|
+
}
|
|
790
|
+
return toon({ board: tilde(file), posted: [{ key: own, number: had.number, media: sources.length, title: had.title, status: 'already posted' }] }, [`Run \`sign-off-axi open${on}\` so the user can give a verdict`]);
|
|
791
|
+
}
|
|
676
792
|
if (own && current.some((d) => d.key === own))
|
|
677
793
|
throw fail(`key ${own} is already used on ${tilde(file)}`, 'CONFLICT', 'Post it under a new --key');
|
|
678
794
|
mkdirSync(store, { recursive: true, mode: 0o700 });
|
|
@@ -688,14 +804,24 @@ function postMedia(file, on, values, type, filer) {
|
|
|
688
804
|
}
|
|
689
805
|
}
|
|
690
806
|
const key = own || `update-${number}`, dir = join(store, String(number));
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
807
|
+
let media;
|
|
808
|
+
try {
|
|
809
|
+
media = sources.map((src, i) => {
|
|
810
|
+
const bytes = readFileSync(src), path = `evidence/${number}/${i + 1}${extname(src).toLowerCase()}`;
|
|
811
|
+
writeFileSync(join(dir, `${i + 1}${extname(src).toLowerCase()}`), bytes, { mode: 0o600 });
|
|
812
|
+
return { path, sha256: sha256(bytes), bytes: bytes.length };
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
catch (e) {
|
|
816
|
+
// A file that cannot be read after all leaves no copies behind.
|
|
817
|
+
rmSync(dir, { recursive: true, force: true });
|
|
818
|
+
throw e;
|
|
819
|
+
}
|
|
696
820
|
const { title, about, detail, close, link, recommend: recommend_value, default: default_if_silent } = values, filed = new Date().toISOString();
|
|
821
|
+
const { run, media: _paths, ...fields } = message ?? {};
|
|
697
822
|
items.push(explainer ? { key, type, title, about, detail, options, recommend_value, default_if_silent, close, chapters, media, filer, filed }
|
|
698
|
-
: { key, type, number,
|
|
823
|
+
: message ? { ...fields, key, type, number, ...(media.length ? { media } : {}), ...(run && { evidence: run }), filed }
|
|
824
|
+
: { key, type, number, title, about, detail, close, link, media, filer, filed });
|
|
699
825
|
// The whole board is checked before it is written, so a bad post leaves the board and evidence/ as they were.
|
|
700
826
|
const check = `${file}.${process.pid}.check`;
|
|
701
827
|
writeFileSync(check, JSON.stringify({ ...extra, schema: 2, items }), { mode: 0o600 });
|
|
@@ -712,7 +838,7 @@ function postMedia(file, on, values, type, filer) {
|
|
|
712
838
|
writeItems(file, items);
|
|
713
839
|
if (explainer)
|
|
714
840
|
return toon({ board: tilde(file), raised: [{ key, status: 'raised', title }] }, [`Run \`sign-off-axi open${on}\` so the user can watch and answer`, waitFor(on, filer, key)]);
|
|
715
|
-
return toon({ board: tilde(file), posted: [{ key, number, media: media.length, title }] }, [`Run \`sign-off-axi open${on}\` so the user can give a verdict`, waitFor(on, filer, key)]);
|
|
841
|
+
return toon({ board: tilde(file), posted: [{ key, number, media: media.length, title, ...(message && { status: 'posted' }) }] }, [`Run \`sign-off-axi open${on}\` so the user can give a verdict`, waitFor(on, filer, key)]);
|
|
716
842
|
});
|
|
717
843
|
}
|
|
718
844
|
/** Posts one contract message (docs/contract.md), from a file or from stdin with --from -: checked against --kind, then stored like a raised item. */
|
|
@@ -730,9 +856,13 @@ async function postKind(file, on, values) {
|
|
|
730
856
|
catch (e) {
|
|
731
857
|
throw fail(`${name}: cannot read: ${e.code}`, 'NOT_FOUND', 'Check the path names a readable file');
|
|
732
858
|
}
|
|
859
|
+
let sources = [];
|
|
733
860
|
try {
|
|
734
861
|
m = parseMessage(bytes);
|
|
735
862
|
checkMessage(m, kind);
|
|
863
|
+
// Media paths read from the message file's folder, or from the current folder for stdin.
|
|
864
|
+
if (m.kind === 'evidence')
|
|
865
|
+
sources = mediaFiles(m, from === '-' ? process.cwd() : dirname(resolve(from)));
|
|
736
866
|
}
|
|
737
867
|
catch (e) {
|
|
738
868
|
throw e instanceof ContractError ? fail(`${name}: ${e.message}`, 'VALIDATION_ERROR', e.help) : e;
|
|
@@ -742,6 +872,8 @@ async function postKind(file, on, values) {
|
|
|
742
872
|
}
|
|
743
873
|
const filer = m.filer ?? filerOf(values, true), scratch = mkdtempSync(join(tmpdir(), 'sign-off-')), source = join(scratch, basename(name));
|
|
744
874
|
try {
|
|
875
|
+
if (m.kind === 'evidence')
|
|
876
|
+
return await postMedia(file, on, { title: m.title, about: m.about, media: sources }, 'evidence', filer, { ...m, ...(filer && { filer }) });
|
|
745
877
|
writeFileSync(source, JSON.stringify({ schema: 2, items: [{ ...m, ...(filer && { filer }) }] }));
|
|
746
878
|
return await storeIncoming(file, on, buildIncoming({ from: source }, {}, filer, file), filer, true);
|
|
747
879
|
}
|
|
@@ -782,7 +914,10 @@ function validate(args) {
|
|
|
782
914
|
}
|
|
783
915
|
try {
|
|
784
916
|
m = parseMessage(raw);
|
|
785
|
-
|
|
917
|
+
const kind = checkMessage(m, want);
|
|
918
|
+
if (m.kind === 'evidence')
|
|
919
|
+
mediaFiles(m, dirname(resolve(file)));
|
|
920
|
+
return { file, ...kind, ok: true, error: '', help: '' };
|
|
786
921
|
}
|
|
787
922
|
catch (e) {
|
|
788
923
|
if (!(e instanceof ContractError))
|
|
@@ -843,22 +978,28 @@ function storeIncoming(file, on, { incoming, raw }, filer, exact = false) {
|
|
|
843
978
|
const current = board(file), items = existsSync(file) ? readItems(file).items : [], saved = readAnswers(file), now = new Date().toISOString();
|
|
844
979
|
const raised = incoming.map((d, i) => {
|
|
845
980
|
const had = current.find((c) => c.key === d.key);
|
|
981
|
+
// A contract message refused for its key exits 2 like any other refusal (contract section 4); raise --title keeps exit 1.
|
|
982
|
+
const contract = exact || raw[i]?.contract !== undefined || raw[i]?.kind !== undefined;
|
|
983
|
+
const refuse = (why) => {
|
|
984
|
+
if (contract)
|
|
985
|
+
process.once('exit', () => { process.exitCode = 2; });
|
|
986
|
+
return fail(`key ${d.key} ${why}`, 'CONFLICT', contract ? 'Post it under a new key' : 'Raise it under a new --key');
|
|
987
|
+
};
|
|
846
988
|
// An answer outlives its item: a new question under that key would inherit it.
|
|
847
989
|
if (!had && Object.hasOwn(saved, d.key))
|
|
848
|
-
throw
|
|
990
|
+
throw refuse(`already has a saved answer on ${tilde(file)} from an item no longer there`);
|
|
849
991
|
// The same question from another filer, or at another time, is the same question: it keeps its first filer and time.
|
|
850
|
-
const same = (x) => JSON.stringify({ ...x, answer: undefined, filer: undefined, filed: undefined, options: [...x.options].sort((a, b) => a.value.localeCompare(b.value)) });
|
|
992
|
+
const same = (x) => JSON.stringify({ ...x, answer: undefined, dismissed: undefined, filer: undefined, filed: undefined, options: [...x.options].sort((a, b) => a.value.localeCompare(b.value)) });
|
|
851
993
|
// A message that names no filed time matches the one the board stamped.
|
|
852
994
|
const differs = () => exact ? canon({ ...items.find((x) => x?.key === d.key), ...(raw[i].filed === undefined && { filed: undefined }) }) !== canon(raw[i]) : same(had) !== same(d);
|
|
853
|
-
if (had && differs())
|
|
854
|
-
throw
|
|
855
|
-
}
|
|
995
|
+
if (had && differs())
|
|
996
|
+
throw refuse(`already holds a different ${contract ? 'message' : 'question'} on ${tilde(file)}`);
|
|
856
997
|
// Each new item records when it was raised, unless it names its own time.
|
|
857
998
|
if (!had) {
|
|
858
999
|
fits(d.title, d.key);
|
|
859
1000
|
items.push({ filed: now, ...raw[i] });
|
|
860
1001
|
}
|
|
861
|
-
return { key: d.key, status: !had ? 'raised' : had.answer ? `already answered ${had.answer.answer}` : waits(had) ? 'already waiting' : 'already posted', title: d.title };
|
|
1002
|
+
return { key: d.key, status: !had ? 'raised' : had.answer ? `already answered ${had.answer.answer}` : waits({ ...had, dismissed: false }) ? 'already waiting' : 'already posted', title: d.title };
|
|
862
1003
|
});
|
|
863
1004
|
if (raised.some((r) => r.status === 'raised'))
|
|
864
1005
|
writeItems(file, items);
|
|
@@ -1474,7 +1615,9 @@ const page = readFileSync(new URL('../web/index.html', import.meta.url));
|
|
|
1474
1615
|
const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
|
|
1475
1616
|
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
1476
1617
|
const state = () => {
|
|
1477
|
-
|
|
1618
|
+
// The page marks sent comments in annotations, so answers and rounds go without theirs: a read-only board or an export carries none.
|
|
1619
|
+
const answers = Object.fromEntries(Object.entries(readAnswers(file, warn)).map(([k, { comments: _, ...a }]) => [k, a])), taken = seenBy(file);
|
|
1620
|
+
const rounds = readRounds(file, warn).map(({ comments: _, ...r }) => r);
|
|
1478
1621
|
return {
|
|
1479
1622
|
file,
|
|
1480
1623
|
mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
|
|
@@ -1487,7 +1630,7 @@ const state = () => {
|
|
|
1487
1630
|
seen: Object.values(answers).filter(taken).map((a) => a.key),
|
|
1488
1631
|
rounds,
|
|
1489
1632
|
messages: readMessages(file, warn),
|
|
1490
|
-
annotations: readOnly() || answersOnly() ? [] : readAnnotations(file, warn),
|
|
1633
|
+
annotations: readOnly() || answersOnly() ? [] : ((sent) => readAnnotations(file, warn).map((a) => (sent.has(a.id) ? { ...a, sent: true } : a)))(sentIds(file)),
|
|
1491
1634
|
dismissed: readDismissed(file, answers, rounds, warn),
|
|
1492
1635
|
};
|
|
1493
1636
|
};
|
|
@@ -1499,6 +1642,12 @@ const mockHash = (path) => { try {
|
|
|
1499
1642
|
catch {
|
|
1500
1643
|
return null;
|
|
1501
1644
|
} };
|
|
1645
|
+
/** The comments that go with a card's next answer or round: not sent yet, and made on the mock file the card shows now. */
|
|
1646
|
+
const unsent = (d) => {
|
|
1647
|
+
const sent = sentIds(file), now = new Map((d.mocks ?? []).map((m) => [m.value, mockHash(m.path)]));
|
|
1648
|
+
return answersOnly() ? [] : readAnnotations(file).filter((a) => a.key === d.key && !sent.has(a.id) && now.get(a.mock) === a.hash)
|
|
1649
|
+
.map(({ id, mock, hash, ref, tag, words, text }) => ({ id, mock, hash, ref, tag, words, text }));
|
|
1650
|
+
};
|
|
1502
1651
|
/** A board whose file says `"readonly": true` shows its items and takes no answers, rounds or comments: another program feeds it. */
|
|
1503
1652
|
const readOnly = () => readItems(file).extra.readonly === true;
|
|
1504
1653
|
/** A board whose file says `"answers_only": true` takes answers and replies but no rounds, comments or prompts: another program feeds it. */
|
|
@@ -1657,7 +1806,10 @@ async function route(req) {
|
|
|
1657
1806
|
return [200, state()];
|
|
1658
1807
|
}
|
|
1659
1808
|
if (req.url === '/api/annotations') {
|
|
1660
|
-
const { id, deleted, key, mock, ref, tag, words, text, hash: shown } = JSON.parse(body) ?? {}, live = readAnnotations(file);
|
|
1809
|
+
const { id, deleted, key, mock, ref, tag, words, text, hash: shown } = JSON.parse(body) ?? {}, live = readAnnotations(file), sent = sentIds(file);
|
|
1810
|
+
// A comment that went with an answer or a round is the agent's now: it stays as it was sent.
|
|
1811
|
+
if (id !== undefined && sent.has(id))
|
|
1812
|
+
return [409, { error: 'This comment was sent' }];
|
|
1661
1813
|
if (deleted === true) {
|
|
1662
1814
|
if (!live.some((a) => a.id === id))
|
|
1663
1815
|
return [404, { error: 'This comment is gone' }];
|
|
@@ -1679,7 +1831,7 @@ async function route(req) {
|
|
|
1679
1831
|
if (id !== undefined && !live.some((a) => a.id === id))
|
|
1680
1832
|
return [404, { error: 'This comment is gone' }];
|
|
1681
1833
|
// One comment per element: another on the same element of the same file edits the first.
|
|
1682
|
-
const same = live.find((a) => a.key === key && a.mock === mock && a.hash === hash && a.ref === ref);
|
|
1834
|
+
const same = live.find((a) => a.key === key && a.mock === mock && a.hash === hash && a.ref === ref && !sent.has(a.id));
|
|
1683
1835
|
appendAnnotation(file, { id: same?.id, key, mock, hash, ref, tag, words, text: text.trim() });
|
|
1684
1836
|
return [200, state()];
|
|
1685
1837
|
}
|
|
@@ -1697,11 +1849,14 @@ async function route(req) {
|
|
|
1697
1849
|
const { key, note = '' } = JSON.parse(body) ?? {};
|
|
1698
1850
|
if (typeof note !== 'string' || [...note.trim()].length > NOTE_MAX)
|
|
1699
1851
|
return [400, { error: `The note must be text of ${NOTE_MAX} characters or fewer` }];
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
if (!loadDecisions(file).some((x) => x.key === key))
|
|
1852
|
+
const d = loadDecisions(file).find((x) => x.key === key);
|
|
1853
|
+
if (!d)
|
|
1703
1854
|
return [404, { error: 'This question is no longer in the file' }];
|
|
1704
|
-
|
|
1855
|
+
// Comments on the mocks are enough for a round; with none, the round needs a note.
|
|
1856
|
+
const comments = unsent(d);
|
|
1857
|
+
if (!note.trim() && !comments.length)
|
|
1858
|
+
return [400, { error: 'Write a note first: another round needs your comment' }];
|
|
1859
|
+
appendRound(file, key, note.trim(), comments);
|
|
1705
1860
|
return [200, state()];
|
|
1706
1861
|
}
|
|
1707
1862
|
const { key, answer, note = '' } = JSON.parse(body) ?? {};
|
|
@@ -1728,7 +1883,7 @@ async function route(req) {
|
|
|
1728
1883
|
const other = d.reply || d.options.length ? [REPLY] : [], option = d.options.find((o) => o.value === answer) ?? other.find((o) => o.value === answer);
|
|
1729
1884
|
if (!option)
|
|
1730
1885
|
return [400, { error: `The answer must be one of: ${[...d.options, ...other].map((o) => o.label).join(', ') || 'none, this item is only shown'}` }];
|
|
1731
|
-
answered(appendAnswer(file, d, option, note.trim()), d);
|
|
1886
|
+
answered(appendAnswer(file, d, option, note.trim(), unsent(d)), d);
|
|
1732
1887
|
return [200, state()];
|
|
1733
1888
|
}
|
|
1734
1889
|
/** Runs the config's on_answer command for a saved answer, with no shell: the answer goes in as one JSON line on its stdin, never in its arguments. */
|