sign-off-axi 0.3.0 → 0.3.2
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 +6 -4
- package/dist/cli.js +100 -43
- package/dist/contract.js +84 -10
- package/dist/store.js +29 -14
- 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/package.json +1 -1
- package/web/index.html +25 -19
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 and evidence 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,11 +145,11 @@ 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, 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
|
|
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
153
|
- **Dismiss** (the × on an item in a list, **Dismiss** under Needs you first and beside **Skip for now** in Triage, or **Clear all done** on Answered) hides items from the board, Today included; **Undo** in the toast brings them back, and so does **Restore** under **Dismissed (N)** at the end of Needs you first and Triage. The agent is not told, so an ask it waits on keeps waiting. The storage is in [docs/file-formats.md](docs/file-formats.md).
|
|
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.
|
|
@@ -160,7 +160,7 @@ sign-off-axi export [--out <file.html>] write a frozen read-only copy o
|
|
|
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
162
|
- `--filer <id>` on `post` and `raise` saves who filed the item (an agent session id or any 1-128 of `A-Z a-z 0-9 . _ -`; `SIGN_OFF_FILER` sets it for every command, and a post with neither saves `CLAUDE_CODE_SESSION_ID` when it is set). `wait --filer <id>` then returns only answers to that filer's items it has not taken yet, marks each taken (`decisions.seen.jsonl`) and the card shows **Seen by agent**; it fails with `NOT_FOUND` when none of that filer's items is answered or waiting. The page never shows the filer id.
|
|
163
|
-
- `on_answer: <command> [args]` in the `config.yaml` beside the board runs that command for every saved answer, read when the board starts. It runs with no shell, in the board's folder, and gets the answer as one JSON line on stdin (`{"key","answer","label","close","at","note"?,"title"?,"filer"?}`); the answer is never put into its arguments, so a typed note cannot inject a command. A failure is logged and never blocks the answer. With no shell, `|`, `>` and `&&` are passed as plain arguments, so put a pipeline in a script and name the script; a relative path such as `./hook.sh` is found from the board's folder. `doctor` checks that the command can run.
|
|
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
165
|
- `wait` replaces a polling loop. With `--key` it returns at once if that decision is already answered, and waits for a key that is not raised yet; without it, it waits for the next answer to any decision still waiting and fails with `NOT_FOUND` when nothing is. It wakes on each save, gives up after `--timeout` seconds (600, ten minutes, by default) with code `TIMEOUT` and exit 1, and takes `--full` and `--json` like `answers`. Ctrl-C stops it without changing anything.
|
|
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.
|
|
@@ -202,6 +202,8 @@ npm run build:skill # regenerates skills/sign-off-axi/SKILL.md from sr
|
|
|
202
202
|
npm pack # builds the publishable sign-off-axi tarball
|
|
203
203
|
```
|
|
204
204
|
|
|
205
|
+
A release is a merged version-bump PR: CI publishes the new version to npm.
|
|
206
|
+
|
|
205
207
|
### Test runs never touch your board
|
|
206
208
|
|
|
207
209
|
With no board named, a bare command uses the nearest `.sign-off/` that `init` set up, looking from the working folder up to its repo root (the folder holding `.git`) or up to HOME, but never HOME's own `.sign-off/`. The working folder's own `.sign-off/` is always used. A folder above it is searched only when that climb reaches a repo root or HOME, so a `.sign-off/` above a folder with neither (outside HOME, no `.git`) is never used, and the account's own home is never used. Otherwise the default board is `~/.sign-off/decisions.json`, under `SIGN_OFF_HOME` when it is set (it takes HOME's place for the default board and for where the climb stops), else under HOME.
|
package/dist/cli.js
CHANGED
|
@@ -10,8 +10,8 @@ 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';
|
|
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
15
|
import { sweepVault } 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';
|
|
@@ -74,10 +74,11 @@ 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 and evidence, 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.
|
|
78
79
|
Post one with \`sign-off-axi post --kind <kind> --from <message.json>\`, or --from - to read stdin.
|
|
79
80
|
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
|
|
81
|
+
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
82
|
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
83
|
--kind each file must be this kind, so the wrong file is refused
|
|
83
84
|
--expect-invalid every file must be refused: exits 0 only then, 2 when any passes (the conformance check of invalid fixtures)
|
|
@@ -88,6 +89,7 @@ examples:
|
|
|
88
89
|
sign-off-axi validate ask.json
|
|
89
90
|
sign-off-axi validate --kind ask messages/*.json
|
|
90
91
|
sign-off-axi validate --kind report report.json
|
|
92
|
+
sign-off-axi validate --kind evidence proof/evidence.json
|
|
91
93
|
sign-off-axi validate --expect-invalid node_modules/sign-off-axi/fixtures/contract/*/invalid-*.json
|
|
92
94
|
`,
|
|
93
95
|
answers: `usage: sign-off-axi answers [board.json] [--key <key>] [--full] [--json]
|
|
@@ -156,6 +158,8 @@ examples:
|
|
|
156
158
|
Posts an item for the user. --type decision (the default) is a yes/no or pick-one question, exactly like raise.
|
|
157
159
|
--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
160
|
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
|
+
--kind evidence copies its media, read from the message file's folder (the current folder for stdin), into evidence/<number>/ like --type evidence;
|
|
162
|
+
a "run" is shown as the update's proof record. With no key it posts the next update-<number>.
|
|
159
163
|
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
164
|
--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
165
|
SIGN_OFF_FILER sets it for every command; with neither, the Claude Code session id is saved when there is one.
|
|
@@ -180,6 +184,7 @@ examples:
|
|
|
180
184
|
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
185
|
sign-off-axi post --type decision --title "Keep the weekly digest?" --recommend yes
|
|
182
186
|
sign-off-axi post --kind ask --from ask.json --filer agent-41
|
|
187
|
+
sign-off-axi post --kind evidence --from proof/evidence.json --filer done-done
|
|
183
188
|
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
189
|
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
190
|
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 +257,15 @@ const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report'];
|
|
|
252
257
|
/** 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
258
|
* A new item of a contract kind must carry them (contract section 2); one already on the board is not checked again. */
|
|
254
259
|
function contractItems(items, from, file) {
|
|
255
|
-
const
|
|
260
|
+
const on = new Map((existsSync(file) ? readItems(file).items : []).map((i) => [i?.key, i]));
|
|
256
261
|
items.forEach((m, i) => {
|
|
257
|
-
|
|
262
|
+
// 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.
|
|
263
|
+
// A stored board-format evidence item passes through a feed unchanged; any other evidence under a key is refused.
|
|
264
|
+
const kept = on.get(m?.key)?.type === 'evidence';
|
|
265
|
+
if ((m?.kind === 'evidence' && m?.type === undefined) || (!kept && (m?.kind ?? m?.type) === 'evidence')) {
|
|
266
|
+
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>');
|
|
267
|
+
}
|
|
268
|
+
if (on.has(m?.key))
|
|
258
269
|
return;
|
|
259
270
|
if (m?.contract === undefined && m?.kind === undefined) {
|
|
260
271
|
if (!CONTRACT_TYPES.includes(m?.type ?? 'decision'))
|
|
@@ -396,20 +407,20 @@ function printAnswers(file, rows, waiting, values, help) {
|
|
|
396
407
|
const done = rows.flatMap((d) => (d.answer ? [{ ...d.answer, title: d.title }] : []));
|
|
397
408
|
const rounds = readRounds(file).filter((r) => rows.some((d) => d.key === r.key));
|
|
398
409
|
if (values.json) {
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
return [key, { answer, label, close, at, note, ...(asked.length && { rounds: asked }) }];
|
|
402
|
-
}));
|
|
410
|
+
const asked = (key) => rounds.filter((r) => r.key === key).map(({ note, at, comments }) => ({ note, at, comments }));
|
|
411
|
+
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
412
|
for (const r of rounds)
|
|
404
413
|
if (!out[r.key])
|
|
405
|
-
out[r.key] = { rounds:
|
|
414
|
+
out[r.key] = { rounds: asked(r.key) };
|
|
406
415
|
return JSON.stringify(out);
|
|
407
416
|
}
|
|
408
|
-
const notes = done.some((a) => a.note);
|
|
417
|
+
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
418
|
return toon({
|
|
410
419
|
board: tilde(file),
|
|
411
420
|
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
421
|
...(rounds.length && { rounds: rounds.map(({ key, note, at }) => ({ key, note, at })) }),
|
|
422
|
+
// Comments on mock elements that went with an answer or a round, each with the element it points at.
|
|
423
|
+
...(sent.length && { comments: sent }),
|
|
413
424
|
waiting,
|
|
414
425
|
}, help);
|
|
415
426
|
}
|
|
@@ -565,7 +576,7 @@ function post(args, cmd = 'post') {
|
|
|
565
576
|
const size = statSync(actual);
|
|
566
577
|
if (!size.isFile() || size.size > MOCK_BYTES)
|
|
567
578
|
throw fail(`--view ${values.view} must be a file of 5 MB or less`, 'VALIDATION_ERROR');
|
|
568
|
-
return storeIncoming(file, on, buildIncoming(values, { type, view: values.view }, filer), filer);
|
|
579
|
+
return storeIncoming(file, on, buildIncoming(values, { type, view: values.view }, filer, file), filer);
|
|
569
580
|
}
|
|
570
581
|
const mocks = values.mock?.map((m) => {
|
|
571
582
|
const at = m.indexOf('=');
|
|
@@ -578,7 +589,7 @@ function post(args, cmd = 'post') {
|
|
|
578
589
|
throw fail(`--mock ${path} escapes the board folder`, 'VALIDATION_ERROR', 'Keep mock files next to the board file');
|
|
579
590
|
return { value: m.slice(0, at), path };
|
|
580
591
|
});
|
|
581
|
-
return storeIncoming(file, on, buildIncoming(values, { type, tradeoff: values.tradeoff, mocks }, filer), filer);
|
|
592
|
+
return storeIncoming(file, on, buildIncoming(values, { type, tradeoff: values.tradeoff, mocks }, filer, file), filer);
|
|
582
593
|
}
|
|
583
594
|
/** For a program that feeds the board: the --from file, checked like any post, becomes the whole board, items and top-level fields. Answers stay.
|
|
584
595
|
* With a filer it replaces only that filer's items (and items with no filer it names by key); every other item and top-level field stays. */
|
|
@@ -633,9 +644,9 @@ function replace(file, on, from, filer) {
|
|
|
633
644
|
return toon({ board: tilde(file), items: items.length, changed }, [`Run \`sign-off-axi answers${on}\` to read the answers`]);
|
|
634
645
|
});
|
|
635
646
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
function postMedia(file, on, values, type, filer) {
|
|
647
|
+
/** 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.
|
|
648
|
+
* `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`. */
|
|
649
|
+
function postMedia(file, on, values, type, filer, message) {
|
|
639
650
|
fits(values.title);
|
|
640
651
|
const explainer = type === 'explainer';
|
|
641
652
|
const extra = (explainer ? ['mock', 'tradeoff', 'view', 'link'] : ['key', 'option', 'recommend', 'default', 'mock', 'tradeoff', 'view', 'chapter']).find((f) => values[f] !== undefined);
|
|
@@ -647,7 +658,7 @@ function postMedia(file, on, values, type, filer) {
|
|
|
647
658
|
throw fail('--about is required for --type evidence: the short note', 'VALIDATION_ERROR', example);
|
|
648
659
|
if (explainer && values.media?.length !== 1)
|
|
649
660
|
throw fail('--type explainer needs one --media video (.mp4 or .webm)', 'VALIDATION_ERROR', example);
|
|
650
|
-
if (!values.media?.length || values.media.length > MEDIA_MAX)
|
|
661
|
+
if ((!message && !values.media?.length) || values.media.length > MEDIA_MAX)
|
|
651
662
|
throw fail(`--type evidence needs 1 to ${MEDIA_MAX} --media files`, 'VALIDATION_ERROR', example);
|
|
652
663
|
const kinds = explainer ? ['.mp4', '.webm'] : Object.keys(mediaTypes), options = optionsOf(values);
|
|
653
664
|
recommended([{ options, recommend_value: values.recommend }], true);
|
|
@@ -669,10 +680,18 @@ function postMedia(file, on, values, type, filer) {
|
|
|
669
680
|
});
|
|
670
681
|
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
671
682
|
return locked(`${file}.lock`, () => {
|
|
672
|
-
const items = existsSync(file) ? readItems(file)
|
|
683
|
+
const { items, extra } = existsSync(file) ? readItems(file) : { items: [], extra: {} };
|
|
673
684
|
// Boards in one folder share evidence/, so a number is taken only by creating its folder; one in use is never written into.
|
|
674
685
|
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));
|
|
686
|
+
const own = message ? message.key : explainer && (values.key ?? slug(values.title)), had = message && own && items.find((x) => x?.key === own);
|
|
687
|
+
if (had) {
|
|
688
|
+
// The stored item, read back as the message it came from: its run, and each media file by hash.
|
|
689
|
+
const asMessage = ({ type: _t, number: _n, filed: _f, evidence: run, media: kept, ...rest }) => canon({ ...rest, run, media: kept?.map((x) => x.sha256) });
|
|
690
|
+
if (asMessage(had) !== canon({ ...message, media: sources.length ? sources.map((src) => sha256(readFileSync(src))) : undefined })) {
|
|
691
|
+
throw fail(`key ${own} already holds a different message on ${tilde(file)}`, 'CONFLICT', 'Post it under a new key');
|
|
692
|
+
}
|
|
693
|
+
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`]);
|
|
694
|
+
}
|
|
676
695
|
if (own && current.some((d) => d.key === own))
|
|
677
696
|
throw fail(`key ${own} is already used on ${tilde(file)}`, 'CONFLICT', 'Post it under a new --key');
|
|
678
697
|
mkdirSync(store, { recursive: true, mode: 0o700 });
|
|
@@ -688,17 +707,27 @@ function postMedia(file, on, values, type, filer) {
|
|
|
688
707
|
}
|
|
689
708
|
}
|
|
690
709
|
const key = own || `update-${number}`, dir = join(store, String(number));
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
710
|
+
let media;
|
|
711
|
+
try {
|
|
712
|
+
media = sources.map((src, i) => {
|
|
713
|
+
const bytes = readFileSync(src), path = `evidence/${number}/${i + 1}${extname(src).toLowerCase()}`;
|
|
714
|
+
writeFileSync(join(dir, `${i + 1}${extname(src).toLowerCase()}`), bytes, { mode: 0o600 });
|
|
715
|
+
return { path, sha256: sha256(bytes), bytes: bytes.length };
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
catch (e) {
|
|
719
|
+
// A file that cannot be read after all leaves no copies behind.
|
|
720
|
+
rmSync(dir, { recursive: true, force: true });
|
|
721
|
+
throw e;
|
|
722
|
+
}
|
|
696
723
|
const { title, about, detail, close, link, recommend: recommend_value, default: default_if_silent } = values, filed = new Date().toISOString();
|
|
724
|
+
const { run, media: _paths, ...fields } = message ?? {};
|
|
697
725
|
items.push(explainer ? { key, type, title, about, detail, options, recommend_value, default_if_silent, close, chapters, media, filer, filed }
|
|
698
|
-
: { key, type, number,
|
|
726
|
+
: message ? { ...fields, key, type, number, ...(media.length ? { media } : {}), ...(run && { evidence: run }), filed }
|
|
727
|
+
: { key, type, number, title, about, detail, close, link, media, filer, filed });
|
|
699
728
|
// The whole board is checked before it is written, so a bad post leaves the board and evidence/ as they were.
|
|
700
729
|
const check = `${file}.${process.pid}.check`;
|
|
701
|
-
writeFileSync(check, JSON.stringify({ schema: 2, items }), { mode: 0o600 });
|
|
730
|
+
writeFileSync(check, JSON.stringify({ ...extra, schema: 2, items }), { mode: 0o600 });
|
|
702
731
|
try {
|
|
703
732
|
loadDecisions(check);
|
|
704
733
|
}
|
|
@@ -712,7 +741,7 @@ function postMedia(file, on, values, type, filer) {
|
|
|
712
741
|
writeItems(file, items);
|
|
713
742
|
if (explainer)
|
|
714
743
|
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)]);
|
|
744
|
+
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
745
|
});
|
|
717
746
|
}
|
|
718
747
|
/** 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 +759,13 @@ async function postKind(file, on, values) {
|
|
|
730
759
|
catch (e) {
|
|
731
760
|
throw fail(`${name}: cannot read: ${e.code}`, 'NOT_FOUND', 'Check the path names a readable file');
|
|
732
761
|
}
|
|
762
|
+
let sources = [];
|
|
733
763
|
try {
|
|
734
764
|
m = parseMessage(bytes);
|
|
735
765
|
checkMessage(m, kind);
|
|
766
|
+
// Media paths read from the message file's folder, or from the current folder for stdin.
|
|
767
|
+
if (m.kind === 'evidence')
|
|
768
|
+
sources = mediaFiles(m, from === '-' ? process.cwd() : dirname(resolve(from)));
|
|
736
769
|
}
|
|
737
770
|
catch (e) {
|
|
738
771
|
throw e instanceof ContractError ? fail(`${name}: ${e.message}`, 'VALIDATION_ERROR', e.help) : e;
|
|
@@ -742,6 +775,8 @@ async function postKind(file, on, values) {
|
|
|
742
775
|
}
|
|
743
776
|
const filer = m.filer ?? filerOf(values, true), scratch = mkdtempSync(join(tmpdir(), 'sign-off-')), source = join(scratch, basename(name));
|
|
744
777
|
try {
|
|
778
|
+
if (m.kind === 'evidence')
|
|
779
|
+
return await postMedia(file, on, { title: m.title, about: m.about, media: sources }, 'evidence', filer, { ...m, ...(filer && { filer }) });
|
|
745
780
|
writeFileSync(source, JSON.stringify({ schema: 2, items: [{ ...m, ...(filer && { filer }) }] }));
|
|
746
781
|
return await storeIncoming(file, on, buildIncoming({ from: source }, {}, filer, file), filer, true);
|
|
747
782
|
}
|
|
@@ -782,7 +817,10 @@ function validate(args) {
|
|
|
782
817
|
}
|
|
783
818
|
try {
|
|
784
819
|
m = parseMessage(raw);
|
|
785
|
-
|
|
820
|
+
const kind = checkMessage(m, want);
|
|
821
|
+
if (m.kind === 'evidence')
|
|
822
|
+
mediaFiles(m, dirname(resolve(file)));
|
|
823
|
+
return { file, ...kind, ok: true, error: '', help: '' };
|
|
786
824
|
}
|
|
787
825
|
catch (e) {
|
|
788
826
|
if (!(e instanceof ContractError))
|
|
@@ -810,13 +848,14 @@ function recommended(items, flags) {
|
|
|
810
848
|
if (d)
|
|
811
849
|
throw fail(`items[${items.indexOf(d)}] (key ${d.key}) lists options but no recommend_value`, 'VALIDATION_ERROR', `Add "recommend_value": "<value>", the option you would pick: one of ${values}`);
|
|
812
850
|
}
|
|
813
|
-
/** Flags are validated by the same loader as a file: write them as a one-decision file first. */
|
|
814
|
-
function buildIncoming(values, extra
|
|
851
|
+
/** Flags are validated by the same loader as a file: write them as a one-decision file first. Items are checked against the board they go to, so its answers_only or readonly holds. */
|
|
852
|
+
function buildIncoming(values, extra, filer, file) {
|
|
853
|
+
const board = existsSync(file) ? readItems(file).extra : {};
|
|
815
854
|
if (values.from !== undefined) {
|
|
816
855
|
// An item that names its own filer keeps it.
|
|
817
856
|
const source = resolve(values.from), raw = readItems(source).items;
|
|
818
857
|
contractItems(raw, values.from, file);
|
|
819
|
-
const incoming = loadDecisions(source);
|
|
858
|
+
const incoming = loadDecisions(source, board);
|
|
820
859
|
recommended(raw, false);
|
|
821
860
|
return { incoming: incoming.map((d) => ({ filer, ...d })), raw: raw.map((i) => ({ filer, ...i })) };
|
|
822
861
|
}
|
|
@@ -825,7 +864,7 @@ function buildIncoming(values, extra = {}, filer, file = '') {
|
|
|
825
864
|
try {
|
|
826
865
|
const { title, key = slug(title), about, detail, recommend: recommend_value, default: default_if_silent, close } = values;
|
|
827
866
|
writeFileSync(source, JSON.stringify({ schema: 2, items: [{ key, title, about, detail, options: optionsOf(values), recommend_value, default_if_silent, close, ...extra, filer }] }));
|
|
828
|
-
const incoming = loadDecisions(source), raw = readItems(source).items;
|
|
867
|
+
const incoming = loadDecisions(source, board), raw = readItems(source).items;
|
|
829
868
|
recommended(raw, true);
|
|
830
869
|
return { incoming, raw };
|
|
831
870
|
}
|
|
@@ -850,7 +889,11 @@ function storeIncoming(file, on, { incoming, raw }, filer, exact = false) {
|
|
|
850
889
|
// A message that names no filed time matches the one the board stamped.
|
|
851
890
|
const differs = () => exact ? canon({ ...items.find((x) => x?.key === d.key), ...(raw[i].filed === undefined && { filed: undefined }) }) !== canon(raw[i]) : same(had) !== same(d);
|
|
852
891
|
if (had && differs()) {
|
|
853
|
-
|
|
892
|
+
// A contract message refused for its key exits 2 like any other refusal (contract section 4); raise --title keeps exit 1.
|
|
893
|
+
const contract = exact || raw[i]?.contract !== undefined || raw[i]?.kind !== undefined;
|
|
894
|
+
if (contract)
|
|
895
|
+
process.once('exit', () => { process.exitCode = 2; });
|
|
896
|
+
throw fail(`key ${d.key} already holds a different ${contract ? 'message' : 'question'} on ${tilde(file)}`, 'CONFLICT', contract ? 'Post it under a new key' : 'Raise it under a new --key');
|
|
854
897
|
}
|
|
855
898
|
// Each new item records when it was raised, unless it names its own time.
|
|
856
899
|
if (!had) {
|
|
@@ -1473,7 +1516,9 @@ const page = readFileSync(new URL('../web/index.html', import.meta.url));
|
|
|
1473
1516
|
const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
|
|
1474
1517
|
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
1475
1518
|
const state = () => {
|
|
1476
|
-
|
|
1519
|
+
// The page marks sent comments in annotations, so answers and rounds go without theirs: a read-only board or an export carries none.
|
|
1520
|
+
const answers = Object.fromEntries(Object.entries(readAnswers(file, warn)).map(([k, { comments: _, ...a }]) => [k, a])), taken = seenBy(file);
|
|
1521
|
+
const rounds = readRounds(file, warn).map(({ comments: _, ...r }) => r);
|
|
1477
1522
|
return {
|
|
1478
1523
|
file,
|
|
1479
1524
|
mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
|
|
@@ -1486,7 +1531,7 @@ const state = () => {
|
|
|
1486
1531
|
seen: Object.values(answers).filter(taken).map((a) => a.key),
|
|
1487
1532
|
rounds,
|
|
1488
1533
|
messages: readMessages(file, warn),
|
|
1489
|
-
annotations: readOnly() || answersOnly() ? [] : readAnnotations(file, warn),
|
|
1534
|
+
annotations: readOnly() || answersOnly() ? [] : ((sent) => readAnnotations(file, warn).map((a) => (sent.has(a.id) ? { ...a, sent: true } : a)))(sentIds(file)),
|
|
1490
1535
|
dismissed: readDismissed(file, answers, rounds, warn),
|
|
1491
1536
|
};
|
|
1492
1537
|
};
|
|
@@ -1498,6 +1543,12 @@ const mockHash = (path) => { try {
|
|
|
1498
1543
|
catch {
|
|
1499
1544
|
return null;
|
|
1500
1545
|
} };
|
|
1546
|
+
/** 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. */
|
|
1547
|
+
const unsent = (d) => {
|
|
1548
|
+
const sent = sentIds(file), now = new Map((d.mocks ?? []).map((m) => [m.value, mockHash(m.path)]));
|
|
1549
|
+
return answersOnly() ? [] : readAnnotations(file).filter((a) => a.key === d.key && !sent.has(a.id) && now.get(a.mock) === a.hash)
|
|
1550
|
+
.map(({ id, mock, hash, ref, tag, words, text }) => ({ id, mock, hash, ref, tag, words, text }));
|
|
1551
|
+
};
|
|
1501
1552
|
/** A board whose file says `"readonly": true` shows its items and takes no answers, rounds or comments: another program feeds it. */
|
|
1502
1553
|
const readOnly = () => readItems(file).extra.readonly === true;
|
|
1503
1554
|
/** A board whose file says `"answers_only": true` takes answers and replies but no rounds, comments or prompts: another program feeds it. */
|
|
@@ -1656,7 +1707,10 @@ async function route(req) {
|
|
|
1656
1707
|
return [200, state()];
|
|
1657
1708
|
}
|
|
1658
1709
|
if (req.url === '/api/annotations') {
|
|
1659
|
-
const { id, deleted, key, mock, ref, tag, words, text, hash: shown } = JSON.parse(body) ?? {}, live = readAnnotations(file);
|
|
1710
|
+
const { id, deleted, key, mock, ref, tag, words, text, hash: shown } = JSON.parse(body) ?? {}, live = readAnnotations(file), sent = sentIds(file);
|
|
1711
|
+
// A comment that went with an answer or a round is the agent's now: it stays as it was sent.
|
|
1712
|
+
if (id !== undefined && sent.has(id))
|
|
1713
|
+
return [409, { error: 'This comment was sent' }];
|
|
1660
1714
|
if (deleted === true) {
|
|
1661
1715
|
if (!live.some((a) => a.id === id))
|
|
1662
1716
|
return [404, { error: 'This comment is gone' }];
|
|
@@ -1678,7 +1732,7 @@ async function route(req) {
|
|
|
1678
1732
|
if (id !== undefined && !live.some((a) => a.id === id))
|
|
1679
1733
|
return [404, { error: 'This comment is gone' }];
|
|
1680
1734
|
// One comment per element: another on the same element of the same file edits the first.
|
|
1681
|
-
const same = live.find((a) => a.key === key && a.mock === mock && a.hash === hash && a.ref === ref);
|
|
1735
|
+
const same = live.find((a) => a.key === key && a.mock === mock && a.hash === hash && a.ref === ref && !sent.has(a.id));
|
|
1682
1736
|
appendAnnotation(file, { id: same?.id, key, mock, hash, ref, tag, words, text: text.trim() });
|
|
1683
1737
|
return [200, state()];
|
|
1684
1738
|
}
|
|
@@ -1696,11 +1750,14 @@ async function route(req) {
|
|
|
1696
1750
|
const { key, note = '' } = JSON.parse(body) ?? {};
|
|
1697
1751
|
if (typeof note !== 'string' || [...note.trim()].length > NOTE_MAX)
|
|
1698
1752
|
return [400, { error: `The note must be text of ${NOTE_MAX} characters or fewer` }];
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
if (!loadDecisions(file).some((x) => x.key === key))
|
|
1753
|
+
const d = loadDecisions(file).find((x) => x.key === key);
|
|
1754
|
+
if (!d)
|
|
1702
1755
|
return [404, { error: 'This question is no longer in the file' }];
|
|
1703
|
-
|
|
1756
|
+
// Comments on the mocks are enough for a round; with none, the round needs a note.
|
|
1757
|
+
const comments = unsent(d);
|
|
1758
|
+
if (!note.trim() && !comments.length)
|
|
1759
|
+
return [400, { error: 'Write a note first: another round needs your comment' }];
|
|
1760
|
+
appendRound(file, key, note.trim(), comments);
|
|
1704
1761
|
return [200, state()];
|
|
1705
1762
|
}
|
|
1706
1763
|
const { key, answer, note = '' } = JSON.parse(body) ?? {};
|
|
@@ -1727,7 +1784,7 @@ async function route(req) {
|
|
|
1727
1784
|
const other = d.reply || d.options.length ? [REPLY] : [], option = d.options.find((o) => o.value === answer) ?? other.find((o) => o.value === answer);
|
|
1728
1785
|
if (!option)
|
|
1729
1786
|
return [400, { error: `The answer must be one of: ${[...d.options, ...other].map((o) => o.label).join(', ') || 'none, this item is only shown'}` }];
|
|
1730
|
-
answered(appendAnswer(file, d, option, note.trim()), d);
|
|
1787
|
+
answered(appendAnswer(file, d, option, note.trim(), unsent(d)), d);
|
|
1731
1788
|
return [200, state()];
|
|
1732
1789
|
}
|
|
1733
1790
|
/** 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. */
|
package/dist/contract.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// The v1 communication contract (docs/contract.md): one strict, versioned shape per kind of message.
|
|
2
|
-
import {
|
|
2
|
+
import { accessSync, constants, realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { isKey, MEDIA_MAX } from './store.js';
|
|
3
5
|
/** A message that does not match: what is wrong, and the one line that says how to fix it. */
|
|
4
6
|
export class ContractError extends Error {
|
|
5
7
|
help;
|
|
@@ -10,12 +12,16 @@ export class ContractError extends Error {
|
|
|
10
12
|
}
|
|
11
13
|
export const KINDS = ['ask', 'report', 'evidence', 'gate', 'finding'];
|
|
12
14
|
/** The kinds this build checks, with the schema number of each. */
|
|
13
|
-
export const SCHEMAS = { ask: 1, report: 1 };
|
|
15
|
+
export const SCHEMAS = { ask: 1, report: 1, evidence: 1 };
|
|
14
16
|
const COMMON = ['contract', 'kind', 'schema', 'key', 'title', 'filer', 'link', 'about'];
|
|
15
17
|
const FIELDS = {
|
|
16
18
|
ask: [...COMMON, 'detail', 'options', 'recommend_value', 'default_if_silent', 'close'],
|
|
17
19
|
report: [...COMMON, 'filed', 'group', 'decided'],
|
|
20
|
+
evidence: [...COMMON, 'media', 'run'],
|
|
18
21
|
};
|
|
22
|
+
/** A done-done run: its id, full commit and the four Checks lines. */
|
|
23
|
+
const RUN = ['run_id', 'commit', 'outcome', 'repro', 'proof', 'evidence'];
|
|
24
|
+
export const MEDIA_BYTES = 50_000_000;
|
|
19
25
|
/** Parses one message; a field written twice in any object refuses it, since plain JSON.parse keeps the last one quietly. */
|
|
20
26
|
export function parseMessage(bytes) {
|
|
21
27
|
let text, doc;
|
|
@@ -104,9 +110,9 @@ export function checkMessage(m, want) {
|
|
|
104
110
|
const fields = FIELDS[m.kind], extra = Object.keys(m).find((f) => !fields.includes(f));
|
|
105
111
|
if (extra === 'options' && m.kind === 'report')
|
|
106
112
|
throw bad('options', 'is not a field of report: a report takes no answer', 'Remove "options", or post an ask to get an answer');
|
|
107
|
-
if (extra)
|
|
108
|
-
throw bad(escaped(extra), `is not a field of ${m.kind} schema ${m.schema}`, `Remove ${show(extra)}; ${m.kind} takes ${fields.slice(3).join(', ')}`);
|
|
109
|
-
if (!isKey(m.key))
|
|
113
|
+
if (extra !== undefined)
|
|
114
|
+
throw bad(escaped(extra) || '""', `is not a field of ${m.kind} schema ${m.schema}`, `Remove ${show(extra)}; ${m.kind} takes ${fields.slice(3).join(', ')}`);
|
|
115
|
+
if (!(m.kind === 'evidence' && !Object.hasOwn(m, 'key')) && !isKey(m.key))
|
|
110
116
|
throw bad('key', Object.hasOwn(m, 'key') ? `${show(m.key)} must be 1-128 of A-Z a-z 0-9 . _ -` : 'is required', 'Set "key" to a short id such as "ship-signin-2"');
|
|
111
117
|
if (!Object.hasOwn(m, 'title'))
|
|
112
118
|
throw bad('title', 'is required', 'Add "title": the question, in 160 characters or fewer');
|
|
@@ -116,10 +122,46 @@ export function checkMessage(m, want) {
|
|
|
116
122
|
if (Object.hasOwn(m, 'link') && !(typeof m.link === 'string' && /^(https?:\/\/[^\s\p{Cc}\p{Cs}]{1,500}|[a-f0-9]{7,40})$/u.test(m.link))) {
|
|
117
123
|
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');
|
|
118
124
|
}
|
|
119
|
-
if (m.kind
|
|
125
|
+
if (m.kind !== 'ask') {
|
|
126
|
+
const what = m.kind === 'report' ? 'what happened' : 'what this shows';
|
|
120
127
|
if (!Object.hasOwn(m, 'about'))
|
|
121
|
-
throw bad('about', 'is required',
|
|
122
|
-
text('about', m.about, 500,
|
|
128
|
+
throw bad('about', 'is required', `Add "about": ${what}, in 500 characters or fewer`);
|
|
129
|
+
text('about', m.about, 500, `${what}, in 500 characters or fewer`);
|
|
130
|
+
}
|
|
131
|
+
if (m.kind === 'evidence') {
|
|
132
|
+
if (!Object.hasOwn(m, 'media') && !Object.hasOwn(m, 'run'))
|
|
133
|
+
throw bad('media', 'or run is required', 'Add "media": ["shot.png"], files next to the message, or "run": the done-done run it proves');
|
|
134
|
+
if (Object.hasOwn(m, 'media')) {
|
|
135
|
+
if (!Array.isArray(m.media) || !m.media.length || m.media.length > MEDIA_MAX)
|
|
136
|
+
throw bad('media', `must list 1 to ${MEDIA_MAX} files`, 'Set "media" to file paths next to the message, such as ["shot.png"]');
|
|
137
|
+
m.media.forEach((p, n) => {
|
|
138
|
+
text(`media[${n}]`, p, 1000, 'a file path from the message file\'s folder, such as "shot.png"');
|
|
139
|
+
if (/^([\\/]|[A-Za-z]:)/.test(p) || p.split(/[\\/]/).includes('..'))
|
|
140
|
+
throw bad(`media[${n}]`, `${show(p)} must be a relative path with no ..`, 'Give the path from the message file\'s folder, such as "evidence-out/after.png"');
|
|
141
|
+
if (!/\.(png|jpe?g|webp|mp4|webm)$/i.test(p))
|
|
142
|
+
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
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (Object.hasOwn(m, 'run')) {
|
|
146
|
+
const r = m.run;
|
|
147
|
+
if (!r || typeof r !== 'object' || Array.isArray(r))
|
|
148
|
+
throw bad('run', 'must be an object', `Set "run" to {${RUN.map(show).join(', ')}}`);
|
|
149
|
+
const odd = Object.keys(r).find((f) => !RUN.includes(f));
|
|
150
|
+
if (odd !== undefined)
|
|
151
|
+
throw bad(`run.${escaped(odd) || '""'}`, 'is not a field of run', `Remove ${show(odd)}; run has only ${RUN.join(', ')}`);
|
|
152
|
+
const missing = RUN.find((f) => !Object.hasOwn(r, f));
|
|
153
|
+
if (missing)
|
|
154
|
+
throw bad(`run.${missing}`, 'is required', `Add "${missing}" to "run"`);
|
|
155
|
+
if (!isKey(r.run_id))
|
|
156
|
+
throw bad('run.run_id', 'must be 1-128 of A-Z a-z 0-9 . _ -', 'Set "run_id" to the done-done run id');
|
|
157
|
+
if (!(typeof r.commit === 'string' && /^[a-f0-9]{40}$/.test(r.commit)))
|
|
158
|
+
throw bad('run.commit', 'must be a full 40-character lowercase commit', 'Set "commit" to the full hash, such as the output of git rev-parse HEAD');
|
|
159
|
+
for (const f of RUN.slice(2))
|
|
160
|
+
text(`run.${f}`, r[f], 1000, `the run's ${f} Checks line, in 1000 characters or fewer`);
|
|
161
|
+
}
|
|
162
|
+
return { kind: m.kind, schema: m.schema };
|
|
163
|
+
}
|
|
164
|
+
if (m.kind === 'report') {
|
|
123
165
|
// An exact UTC second that exists on the calendar: Date.parse would roll 2026-02-30 over to March.
|
|
124
166
|
const at = typeof m.filed === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(m.filed) ? new Date(m.filed) : undefined;
|
|
125
167
|
if (!at || Number.isNaN(at.getTime()) || at.toISOString().replace('.000', '') !== m.filed) {
|
|
@@ -144,8 +186,8 @@ export function checkMessage(m, want) {
|
|
|
144
186
|
if (!o || typeof o !== 'object' || Array.isArray(o))
|
|
145
187
|
throw bad(field, 'must be an object', 'Write each option as {"value":"<id>","label":"<text>"}');
|
|
146
188
|
const odd = Object.keys(o).find((f) => f !== 'value' && f !== 'label');
|
|
147
|
-
if (odd)
|
|
148
|
-
throw bad(`${field}.${escaped(odd)}`, 'is not a field of an option', `Remove ${show(odd)}; an option has only value and label`);
|
|
189
|
+
if (odd !== undefined)
|
|
190
|
+
throw bad(`${field}.${escaped(odd) || '""'}`, 'is not a field of an option', `Remove ${show(odd)}; an option has only value and label`);
|
|
149
191
|
if (!isKey(o.value))
|
|
150
192
|
throw bad(`${field}.value`, 'must be 1-128 of A-Z a-z 0-9 . _ -', 'Set "value" to a short id such as "yes"');
|
|
151
193
|
if (options.slice(0, n).some((p) => p.value === o.value))
|
|
@@ -164,3 +206,35 @@ export function checkMessage(m, want) {
|
|
|
164
206
|
throw bad('close', 'must be done or release', 'Set "close" to "done" or "release", or leave it out');
|
|
165
207
|
return { kind: m.kind, schema: m.schema };
|
|
166
208
|
}
|
|
209
|
+
/** 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,
|
|
210
|
+
* symlinks followed, stays inside that folder. Returns the real paths; validate checks them and copies nothing. */
|
|
211
|
+
export function mediaFiles(m, base) {
|
|
212
|
+
const root = realpathSync(base), at = isKey(m.key) ? `key ${m.key}: ` : '';
|
|
213
|
+
return (m.media ?? []).map((p, n) => {
|
|
214
|
+
const bad = (rule, help) => new ContractError(`${at}media[${n}] ${show(p)} ${rule}`, help);
|
|
215
|
+
let real;
|
|
216
|
+
try {
|
|
217
|
+
real = realpathSync(resolve(root, p));
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
throw bad('is not there', `Put ${p} in the message file's folder, or fix the path`);
|
|
221
|
+
}
|
|
222
|
+
const rel = relative(root, real);
|
|
223
|
+
if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel))
|
|
224
|
+
throw bad('leads outside the message file\'s folder', 'Copy the file into the message file\'s folder instead of linking to it');
|
|
225
|
+
const info = statSync(real);
|
|
226
|
+
if (!info.isFile())
|
|
227
|
+
throw bad('is not a file', 'Name a screenshot or recording file');
|
|
228
|
+
if (!/\.(png|jpe?g|webp|mp4|webm)$/i.test(real))
|
|
229
|
+
throw bad('links to a file that does not end in .png, .jpg, .jpeg, .webp, .mp4 or .webm', 'Name the screenshot or recording itself');
|
|
230
|
+
try {
|
|
231
|
+
accessSync(real, constants.R_OK);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
throw bad('cannot be read', 'Make the file readable, then post again');
|
|
235
|
+
}
|
|
236
|
+
if (info.size > MEDIA_BYTES)
|
|
237
|
+
throw bad(`is over ${MEDIA_BYTES} bytes`, 'Trim or compress the recording');
|
|
238
|
+
return real;
|
|
239
|
+
});
|
|
240
|
+
}
|
package/dist/store.js
CHANGED
|
@@ -56,8 +56,9 @@ export function writeItems(path, items, extra) {
|
|
|
56
56
|
writeFileSync(tmp, `${JSON.stringify({ ...(extra ?? old?.extra), schema: 2, items }, null, 2)}\n`, { mode: 0o600 });
|
|
57
57
|
renameSync(tmp, path);
|
|
58
58
|
}
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
/** `board`: the top-level fields of the board the items are posted to, which decide in place of the file's own. */
|
|
60
|
+
export function loadDecisions(path, board) {
|
|
61
|
+
const name = basename(path), { schema, items, extra: own } = readItems(path), list = schema === 1 ? 'decisions' : 'items', extra = board ?? own;
|
|
61
62
|
const fed = extra.readonly === true || extra.answers_only === true;
|
|
62
63
|
const seen = new Map();
|
|
63
64
|
return items.map((d, i) => {
|
|
@@ -76,7 +77,7 @@ export function loadDecisions(path) {
|
|
|
76
77
|
throw bad(f, 'must be text');
|
|
77
78
|
if (d.evidence !== undefined && (!d.evidence || !/^[A-Za-z0-9._-]{1,128}$/.test(d.evidence.run_id ?? '')
|
|
78
79
|
|| !/^[a-f0-9]{40}$/.test(d.evidence.commit ?? '')
|
|
79
|
-
|| ['outcome', 'repro', 'proof', 'evidence'].some((f) => !isText(d.evidence[f]) || !d.evidence[f].trim() || d.evidence[f].length > 1000))) {
|
|
80
|
+
|| ['outcome', 'repro', 'proof', 'evidence'].some((f) => !isText(d.evidence[f]) || !d.evidence[f].trim() || [...d.evidence[f]].length > 1000))) {
|
|
80
81
|
throw bad('evidence', 'must name a done-done run, full commit, and four Checks lines');
|
|
81
82
|
}
|
|
82
83
|
// Any lowercase type word loads; the page shows a plain card for a type it has no renderer for.
|
|
@@ -86,7 +87,8 @@ export function loadDecisions(path) {
|
|
|
86
87
|
if (d[f] !== undefined && !allowed.includes(d[f]))
|
|
87
88
|
throw bad(f, `must be ${allowed.join(' or ')}`);
|
|
88
89
|
const stored = Object.hasOwn(STORED, d.type ?? '') ? STORED[d.type] : undefined;
|
|
89
|
-
|
|
90
|
+
// An evidence update that links a done-done run may have no files of its own (contract evidence with run only).
|
|
91
|
+
if (stored && !(d.type === 'evidence' && d.media === undefined && d.evidence)) {
|
|
90
92
|
const [max, kinds, ext] = stored;
|
|
91
93
|
if (!Array.isArray(d.media) || !d.media.length || d.media.length > max || !d.media.every((m) => isText(m?.path)
|
|
92
94
|
&& /^evidence\/\d+\/\w[\w-]*\.\w+$/.test(m.path) && ext.test(m.path) && /^[a-f0-9]{64}$/.test(m.sha256 ?? '') && Number.isInteger(m.bytes) && m.bytes >= 0)) {
|
|
@@ -161,7 +163,7 @@ export function loadDecisions(path) {
|
|
|
161
163
|
...(views && { view: d.view }), ...(d.reply && { reply: true }), ...(d.filer && { filer: d.filer }), ...(d.area && { area: d.area }),
|
|
162
164
|
...(d.group && { group: d.group }), ...(d.filed && { filed: d.filed }), ...(d.decided && { decided: d.decided }),
|
|
163
165
|
...(d.steps && { steps: d.steps.map(({ title, marks }) => ({ title, ...(marks?.length && { marks }) })) }),
|
|
164
|
-
...(stored && { media: d.media.map(({ path, sha256, bytes }) => ({ path, sha256, bytes })) }),
|
|
166
|
+
...(stored && d.media && { media: d.media.map(({ path, sha256, bytes }) => ({ path, sha256, bytes })) }),
|
|
165
167
|
...(d.type === 'evidence' && { number: d.number }),
|
|
166
168
|
...(d.type === 'explainer' && d.chapters && { chapters: d.chapters.map(({ at, title }) => ({ at, title })) }),
|
|
167
169
|
};
|
|
@@ -172,29 +174,32 @@ export const roundsPath = (decisionsPath) => decisionsPath.replace(/\.json$/, ''
|
|
|
172
174
|
const readRaw = (file) => (existsSync(file) ? readFileSync(file, 'utf8') : '');
|
|
173
175
|
/** Latest answer per key; a line with an empty answer (an Undo with nothing to go back to) leaves the key unanswered. A torn line (a crash mid-write) is skipped and reported through warn. */
|
|
174
176
|
export function readAnswers(decisionsPath, warn = () => { }) {
|
|
175
|
-
const latest = new Map();
|
|
177
|
+
const latest = new Map(), sent = new Map();
|
|
176
178
|
readRaw(answersPath(decisionsPath)).split('\n').forEach((line, n) => {
|
|
177
179
|
try {
|
|
178
180
|
const a = line && JSON.parse(line);
|
|
179
181
|
if (a?.answer === '')
|
|
180
182
|
latest.delete(a.key);
|
|
181
|
-
else if (a)
|
|
183
|
+
else if (a) {
|
|
182
184
|
latest.set(a.key, a);
|
|
185
|
+
if (a.comments)
|
|
186
|
+
sent.set(a.key, [...(sent.get(a.key) ?? []), ...a.comments]);
|
|
187
|
+
}
|
|
183
188
|
}
|
|
184
189
|
catch {
|
|
185
190
|
warn(`skipped unreadable line ${n + 1} in ${answersPath(decisionsPath)}`);
|
|
186
191
|
}
|
|
187
192
|
});
|
|
188
|
-
return Object.fromEntries(latest);
|
|
193
|
+
return Object.fromEntries([...latest].map(([key, a]) => [key, sent.has(key) ? { ...a, comments: sent.get(key) } : a]));
|
|
189
194
|
}
|
|
190
195
|
/** The option an Undo saves when the item had no answer before: an empty answer, so the item waits again. */
|
|
191
196
|
export const CLEARED = { value: '', label: '' };
|
|
192
197
|
/** Appends one answer line unless it equals the current answer and note, and returns the saved answer. Starts a new line after a torn fragment. */
|
|
193
|
-
export function appendAnswer(decisionsPath, d, option, note = '') {
|
|
198
|
+
export function appendAnswer(decisionsPath, d, option, note = '', comments = []) {
|
|
194
199
|
const current = readAnswers(decisionsPath)[d.key];
|
|
195
|
-
if (option === CLEARED ? !current : current?.answer === option.value && (current.note ?? '') === note)
|
|
200
|
+
if (option === CLEARED ? !current : current?.answer === option.value && (current.note ?? '') === note && !comments.length)
|
|
196
201
|
return;
|
|
197
|
-
const answer = { key: d.key, answer: option.value, label: option.label, close: d.close, at: now(), ...(note && { note }) };
|
|
202
|
+
const answer = { key: d.key, answer: option.value, label: option.label, close: d.close, at: now(), ...(note && { note }), ...(comments.length && { comments }) };
|
|
198
203
|
appendLine(answersPath(decisionsPath), answer);
|
|
199
204
|
return answer;
|
|
200
205
|
}
|
|
@@ -235,10 +240,20 @@ export function readRounds(decisionsPath, warn = () => { }) {
|
|
|
235
240
|
});
|
|
236
241
|
return rounds;
|
|
237
242
|
}
|
|
238
|
-
/** Asks the agent for another round on a question, carrying the person's note. */
|
|
239
|
-
export function appendRound(decisionsPath, key, note) {
|
|
240
|
-
appendLine(roundsPath(decisionsPath), { key, note, at: now() });
|
|
243
|
+
/** Asks the agent for another round on a question, carrying the person's note and the comments on its mocks. */
|
|
244
|
+
export function appendRound(decisionsPath, key, note, comments = []) {
|
|
245
|
+
appendLine(roundsPath(decisionsPath), { key, note, at: now(), ...(comments.length && { comments }) });
|
|
241
246
|
}
|
|
247
|
+
/** Ids of the comments that went with an answer or a round: sent, so locked. */
|
|
248
|
+
export const sentIds = (decisionsPath) => new Set([answersPath, roundsPath].flatMap((path) => readRaw(path(decisionsPath)).split('\n').flatMap((line) => {
|
|
249
|
+
try {
|
|
250
|
+
const c = line && JSON.parse(line)?.comments;
|
|
251
|
+
return Array.isArray(c) ? c.map((x) => x?.id) : [];
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
})));
|
|
242
257
|
/** Items hidden from the board, one line per key and change; the agent is not told, and an ask it waits on keeps waiting. */
|
|
243
258
|
export const dismissedPath = (decisionsPath) => decisionsPath.replace(/\.json$/, '') + '.dismissed.jsonl';
|
|
244
259
|
export function appendDismissed(decisionsPath, keys, dismissed) {
|
|
@@ -9,7 +9,7 @@ fixtures/contract/<kind>.v<schema>/valid-<name>.json
|
|
|
9
9
|
fixtures/contract/<kind>.v<schema>/invalid-<field>-<rule>.json
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
- `<kind>` is `ask`, `report`, `evidence`, `gate` or `finding`, and `<schema>` its schema number, such as `ask.v1/`. A version ships a folder for every kind it validates. This version ships `ask.v1/` and `
|
|
12
|
+
- `<kind>` is `ask`, `report`, `evidence`, `gate` or `finding`, and `<schema>` its schema number, such as `ask.v1/`. A version ships a folder for every kind it validates. This version ships `ask.v1/`, `report.v1/` and `evidence.v1/`.
|
|
13
13
|
- Each file holds one message. An evidence fixture that names `media` ships those files next to it.
|
|
14
14
|
- Each kind has at least 2 valid files and 3 invalid ones: one with a required field missing, one over a limit, and one with an unknown field.
|
|
15
15
|
- An invalid file breaks exactly one rule. Its name gives the field and the rule, such as `invalid-title-too-long.json`, and sign-off's own tests check that the refusal names that field.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"evidence","schema":1,"title":"Sign-in screen on a phone","filer":"done-done","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456789abcdef0123456789abcdef01234567","outcome":"Umer can see the updated sign-in screen.","repro":"repro: n/a: no bug","proof":"verify-sign-off: real browser journey","evidence":"after.png"}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"evidence","schema":1,"title":"Sign-in screen on a phone","filer":"done-done","about":"Umer signs in on a phone. The screen loads in 1 second.","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456","outcome":"Umer can see the updated sign-in screen.","repro":"repro: n/a: no bug","proof":"verify-sign-off: real browser journey","evidence":"after.png"}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"evidence","schema":1,"title":"Sign-in screen on a phone","filer":"done-done","about":"Umer signs in on a phone. The screen loads in 1 second.","detail":"More words.","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456789abcdef0123456789abcdef01234567","outcome":"Umer can see the updated sign-in screen.","repro":"repro: n/a: no bug","proof":"verify-sign-off: real browser journey","evidence":"after.png"}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"evidence","schema":1,"title":"Sign-in screen on a phone","filer":"done-done","about":"Umer signs in on a phone. The screen loads in 1 second.","media":["gone.png"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"evidence","schema":1,"title":"Sign-in screen on a phone","filer":"done-done","about":"Umer signs in on a phone. The screen loads in 1 second.","media":["../shot.png"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"evidence","schema":1,"key":"too-many","title":"Sign-in screen, second pass","about":"Umer checks the new layout.","media":["shot.png","shot.png","shot.png","shot.png","shot.png","shot.png","shot.png","shot.png","shot.png","shot.png","shot.png","shot.png","shot.png"],"link":"https://example.com/pr/4"}
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"evidence","schema":1,"key":"signin-phone-2","title":"Sign-in screen, second pass","about":"Umer checks the new layout.","media":["shot.png"],"link":"https://example.com/pr/4"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"contract":1,"kind":"evidence","schema":1,"title":"Sign-in screen on a phone","filer":"done-done","about":"Umer signs in on a phone. The screen loads in 1 second.","run":{"run_id":"20261011T120000Z-a1b2c3d4","commit":"0123456789abcdef0123456789abcdef01234567","outcome":"Umer can see the updated sign-in screen.","repro":"repro: n/a: no bug","proof":"verify-sign-off: real browser journey","evidence":"after.png"}}
|
package/package.json
CHANGED
package/web/index.html
CHANGED
|
@@ -133,10 +133,11 @@
|
|
|
133
133
|
.sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
|
|
134
134
|
.sec-head h2 { margin: 0; } .sec-head:has(+ .in-evidence) { margin: 1rem 0 0.375rem; }
|
|
135
135
|
.rows { list-style: none; margin: 0; padding: 0 1rem; }
|
|
136
|
-
.rows li + li { border-top: 1px solid var(--line); }
|
|
136
|
+
.rows > li + li { border-top: 1px solid var(--line); }
|
|
137
137
|
.compact { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 0; color: var(--text); text-decoration: none; }
|
|
138
138
|
.compact .t { display: grid; flex: 1; min-width: 0; }
|
|
139
|
-
|
|
139
|
+
/* The answer sits under the title, never beside it: a long answer beside the title squeezed it to a letter a line. */
|
|
140
|
+
.ans { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 0.5rem; margin-top: 0.125rem; } .ans b { color: var(--c-answered); } .ans time { color: var(--muted); font-size: 0.875rem; }
|
|
140
141
|
.compact strong { font-size: 1.0625rem; font-weight: 600; line-height: 1.3; }
|
|
141
142
|
.compact .meta { margin: 0.125rem 0 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
142
143
|
.thumb, .compact .icon { width: 3.5rem; height: 3.5rem; border-radius: 0.625rem; }
|
|
@@ -238,7 +239,7 @@
|
|
|
238
239
|
background: none; color: var(--muted); font-size: 1.5rem; line-height: 1.25; }
|
|
239
240
|
.x:hover { background: var(--sunk); color: var(--text); }
|
|
240
241
|
.head .st + .x { margin-left: 0; }
|
|
241
|
-
.rows li { display: flex; align-items: center; }
|
|
242
|
+
.rows > li { display: flex; align-items: center; }
|
|
242
243
|
.rows li .compact { flex: 1; min-width: 0; }
|
|
243
244
|
.rows li .x { margin: 0; }
|
|
244
245
|
.clear { min-height: 2.75rem; padding: 0; border: 0; background: none; color: var(--accent); font-weight: 600; }
|
|
@@ -340,8 +341,8 @@
|
|
|
340
341
|
.reports { grid-template-columns: 1fr 1fr; align-items: start; }
|
|
341
342
|
/* Answered sets each row as its own card, two across; an opened row takes the whole width. */
|
|
342
343
|
.rows.i-answered { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; align-items: start; padding: 0; border: 0; background: none; box-shadow: none; }
|
|
343
|
-
.rows.i-answered li { padding: 0 0.5rem 0 1rem; border: 1px solid var(--line); border-radius: 1rem; background: var(--surface); box-shadow: var(--shadow); }
|
|
344
|
-
.rows.i-answered li:has(.done[open]) { grid-column: 1 / -1; }
|
|
344
|
+
.rows.i-answered > li { padding: 0 0.5rem 0 1rem; border: 1px solid var(--line); border-radius: 1rem; background: var(--surface); box-shadow: var(--shadow); }
|
|
345
|
+
.rows.i-answered > li:has(.done[open]) { grid-column: 1 / -1; }
|
|
345
346
|
.upd.portrait > .media { width: 8rem; }
|
|
346
347
|
/* Evidence sets its updates two across; a page that is not an update keeps the whole width. */
|
|
347
348
|
.list.in-evidence { grid-template-columns: 1fr 1fr; align-items: start; } .in-evidence > .row:not(.update) { grid-column: 1 / -1; }
|
|
@@ -393,7 +394,7 @@
|
|
|
393
394
|
const drop = clearIfSaved(d.key, drafts.get(d.key));
|
|
394
395
|
call('/api/answers', {
|
|
395
396
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, answer: o.value, note }),
|
|
396
|
-
},
|
|
397
|
+
}, () => { editing.delete(d.key); if (!typed) drop(); }).then(() => offerUndo(d, before)).then(() => typed && call('/api/messages', {
|
|
397
398
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'comment', key: d.key, text: typed }),
|
|
398
399
|
}, drop)).catch((e) => {
|
|
399
400
|
shown(err).textContent = `Couldn't save: ${e.message}`;
|
|
@@ -562,9 +563,10 @@
|
|
|
562
563
|
roundBtn.onclick = () => {
|
|
563
564
|
roundBtn.disabled = true;
|
|
564
565
|
const note = (drafts.get(d.key) ?? noteOf(d)).trim();
|
|
566
|
+
const drop = clearIfSaved(d.key, drafts.get(d.key));
|
|
565
567
|
call('/api/rounds', {
|
|
566
568
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: d.key, note }),
|
|
567
|
-
},
|
|
569
|
+
}, () => { editing.delete(d.key); drop(); }).catch((e) => {
|
|
568
570
|
shown(err).textContent = `Couldn't ask: ${e.message}`;
|
|
569
571
|
roundBtn.disabled = false;
|
|
570
572
|
});
|
|
@@ -580,7 +582,7 @@
|
|
|
580
582
|
const hint = !d.options.length ? 'Reply in a few words' : words ? `Your own answer in a few words. It also goes, as a note, ${also}.` : `A note, sent ${also}.`;
|
|
581
583
|
const fold = el('details', { class: 'other', ...((unfolded.has(d.key) || drafts.get(d.key)) && { open: '' }) },
|
|
582
584
|
el('summary', { 'aria-label': `${label}, ${d.title}` }, `${label}${status && ` · ${status}`}`),
|
|
583
|
-
asked.length ? el('p', { class: 'meta saved-note', dir: 'auto' }, `You asked for another round: ${asked[asked.length - 1].note}`) : '',
|
|
585
|
+
asked.length ? el('p', { class: 'meta saved-note', dir: 'auto' }, `You asked for another round: ${asked[asked.length - 1].note || 'with your comments on the mocks'}`) : '',
|
|
584
586
|
...comments.map((m) => el('p', { class: 'meta saved-note', dir: 'auto' }, `Your comment, ${m.delivered ? 'read by the agent' : 'not read yet'}: ${m.text}`)),
|
|
585
587
|
el('label', { for: box.id }, hint), box,
|
|
586
588
|
el('div', { class: 'actions' }, words ? sendBtn : '', round ? roundBtn : '', talk && !FROZEN ? commentBtn : ''));
|
|
@@ -668,16 +670,16 @@
|
|
|
668
670
|
}));
|
|
669
671
|
}
|
|
670
672
|
|
|
671
|
-
/** Comments on mock elements
|
|
673
|
+
/** Comments on mock elements: the open editor per item, then the unsent list, then the sent ones. An unsent comment shows only on the mock file it was made on. */
|
|
672
674
|
const editing = new Map(), talks = () => !state.readonly && !state.answers_only && !FROZEN;
|
|
673
675
|
const redraw = (d) => document.querySelectorAll('.ann').forEach((n) => n.dataset.ann === d.key && n.replaceWith(annotations(d)));
|
|
674
676
|
function annotations(d) {
|
|
675
677
|
const hash = new Map((d.mocks ?? []).map((m) => [m.value, m.hash])), open = editing.get(d.key);
|
|
676
|
-
//
|
|
678
|
+
// An unsent comment shows only on the mock file it was made on; the others are counted per mock. A sent one is always listed.
|
|
677
679
|
const mine = [], old = new Map(), name = (m) => d.options.find((o) => o.value === m)?.label ?? m;
|
|
678
680
|
for (const a of state.annotations ?? []) {
|
|
679
681
|
if (a.key !== d.key) continue;
|
|
680
|
-
if (hash.get(a.mock) === a.hash) mine.push(a);
|
|
682
|
+
if (a.sent || hash.get(a.mock) === a.hash) mine.push(a);
|
|
681
683
|
else old.set(a.mock, (old.get(a.mock) ?? 0) + 1);
|
|
682
684
|
}
|
|
683
685
|
const what = (a) => `${name(a.mock)} · ${a.tag}${a.words ? ` “${a.words.length > 40 ? `${a.words.slice(0, 40)}…` : a.words}”` : ''}`;
|
|
@@ -702,20 +704,24 @@
|
|
|
702
704
|
let form = '';
|
|
703
705
|
if (open && pages.length) {
|
|
704
706
|
const box = el('textarea', { id: `ann-text-${d.key}`, rows: 2, maxlength: 500, dir: 'auto' }), save = el('button', {}, 'Save comment'), cancel = el('button', { class: 'quick' }, 'Cancel');
|
|
705
|
-
box.value = open.text ?? mine.find((a) => a.mock === open.mock && a.ref === open.ref)?.text ?? '';
|
|
707
|
+
box.value = open.text ?? mine.find((a) => !a.sent && a.mock === open.mock && a.ref === open.ref)?.text ?? '';
|
|
706
708
|
box.oninput = () => (open.text = box.value);
|
|
707
709
|
save.onclick = () => post(save, { id: open.id, key: d.key, mock: open.mock, ref: open.ref, tag: open.tag, words: open.words, text: box.value, hash: open.hash }, () => editing.delete(d.key));
|
|
708
710
|
cancel.onclick = () => { editing.delete(d.key); redraw(d); };
|
|
709
711
|
form = el('div', {}, el('label', { for: box.id }, `Comment on ${what(open)}`), box, el('div', { class: 'actions' }, save, cancel));
|
|
710
712
|
}
|
|
711
|
-
const
|
|
713
|
+
const row = (a, ...buttons) => el('li', {}, el('div', {}, el('p', { class: 'meta' }, what(a)), el('p', { class: 'saved-note', dir: 'auto' }, a.text)), ...buttons);
|
|
714
|
+
// A comment goes with the card's next answer or round; once sent it is the agent's and stays as it was.
|
|
715
|
+
const sent = mine.filter((a) => a.sent).map((a) => row(a));
|
|
716
|
+
const rows = mine.filter((a) => !a.sent).map((a) => {
|
|
712
717
|
const edit = el('button', { class: 'quick', 'aria-label': `Edit comment, ${what(a)}` }, 'Edit'), del = el('button', { class: 'quick', 'aria-label': `Delete comment, ${what(a)}` }, 'Delete');
|
|
713
718
|
edit.onclick = () => { editing.set(d.key, { ...a }); redraw(d); };
|
|
714
719
|
del.onclick = () => post(del, { id: a.id, deleted: true }, () => { if (editing.get(d.key)?.mock === a.mock && editing.get(d.key)?.ref === a.ref) editing.delete(d.key); });
|
|
715
|
-
return
|
|
720
|
+
return row(a, el('span', { class: 'actions' }, edit, del));
|
|
716
721
|
});
|
|
717
722
|
return el('div', { class: 'ann', 'data-ann': d.key }, pages.length ? el('div', { class: 'actions' }, toggle) : '',
|
|
718
|
-
on ? el('p', { class: 'meta' }, 'Tap an element in a mock to comment on it. Turn this off to open a mock full size.') : '', form, rows.length ? el('p', { class: 'meta' }, 'Your comments on the mocks, not sent yet')
|
|
723
|
+
on ? el('p', { class: 'meta' }, 'Tap an element in a mock to comment on it. Turn this off to open a mock full size.') : '', form, ...(rows.length ? [el('p', { class: 'meta' }, 'Your comments on the mocks, not sent yet'), el('ul', {}, ...rows), el('p', { class: 'meta' }, 'They go with your pick, your Other answer or Another round.')] : []),
|
|
724
|
+
...(sent.length ? [el('p', { class: 'meta' }, 'Sent to the agent'), el('ul', {}, ...sent)] : []),
|
|
719
725
|
...[...old].map(([m, n]) => el('p', { class: 'meta' }, `${n} comment${n > 1 ? 's' : ''} on an earlier version of “${name(m)}” ${n > 1 ? 'are' : 'is'} hidden`)), err);
|
|
720
726
|
}
|
|
721
727
|
|
|
@@ -771,12 +777,12 @@
|
|
|
771
777
|
done.length > SHOWN ? el('p', { class: 'meta' }, `and ${done.length - SHOWN} earlier`) : '');
|
|
772
778
|
}
|
|
773
779
|
|
|
774
|
-
/** A short row: a picture (or the area's mark) and the title; an answered item has its answer and time
|
|
780
|
+
/** A short row: a picture (or the area's mark) and the title; an answered item has its answer and time under the title. */
|
|
775
781
|
const compact = (d, a) => {
|
|
776
782
|
const s = state.answers[d.key];
|
|
777
|
-
return [thumb(a, pic(d)), el('span', { class: 't' }, el('strong', { dir: 'auto' }, title(d)), s
|
|
778
|
-
|
|
779
|
-
|
|
783
|
+
return [thumb(a, pic(d)), el('span', { class: 't' }, el('strong', { dir: 'auto' }, title(d)), s
|
|
784
|
+
? el('span', { class: 'ans' }, el('b', { dir: 'auto' }, s.answer === 'text' ? 'Your reply' : s.label), el('time', {}, isToday(s.at) ? hhmm(s.at) : day(s.at)))
|
|
785
|
+
: el('span', { class: 'meta', dir: 'auto' }, d.about ?? NAMES[d.type] ?? d.type))];
|
|
780
786
|
};
|
|
781
787
|
let jump = null; // The item a Summary row was tapped for, scrolled to on its area's screen.
|
|
782
788
|
/** A short list of an area on the Summary: a picture and the title; a tap opens the area at that item. */
|