sign-off-axi 0.5.0 → 0.5.1
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 +2 -1
- package/dist/cli.js +215 -32
- package/dist/contract.js +120 -9
- package/dist/store.js +119 -16
- package/fixtures/contract/README.md +2 -2
- package/fixtures/contract/plan.v2/invalid-decisions-recommend-unknown.json +13 -0
- package/fixtures/contract/plan.v2/invalid-next-lines-over-4.json +13 -0
- package/fixtures/contract/plan.v2/invalid-sections-stage-unknown.json +13 -0
- package/fixtures/contract/plan.v2/invalid-understood-missing.json +12 -0
- package/fixtures/contract/plan.v2/invalid-visual-rows-one.json +13 -0
- package/fixtures/contract/plan.v2/valid-full.json +73 -0
- package/fixtures/contract/plan.v2/valid-minimal.json +13 -0
- package/fixtures/contract/report.v3/invalid-next-lines-over-4.json +13 -0
- package/fixtures/contract/report.v3/invalid-sections-stage-unknown.json +13 -0
- package/fixtures/contract/report.v3/invalid-understood-missing.json +12 -0
- package/fixtures/contract/report.v3/invalid-visual-rows-one.json +13 -0
- package/fixtures/contract/report.v3/valid-full.json +53 -0
- package/fixtures/contract/report.v3/valid-minimal.json +13 -0
- package/package.json +1 -1
- package/web/index.html +31 -13
package/README.md
CHANGED
|
@@ -75,7 +75,7 @@ A board that fills up with noise stops being read, so the rules sit at the door.
|
|
|
75
75
|
- A question must say why it is asked, what each answer does, which one the agent recommends and what happens if you stay quiet; one that does not is refused with the fix ([the question standard](docs/contract.md#ask-schema-1)).
|
|
76
76
|
- One decision is one card: raising the same question again changes nothing, and `--merge <key>` folds an older card into the better-asked one.
|
|
77
77
|
- Proof posted as a Pass/Fail question is refused, and proof that is mostly text, a placeholder or a test card is refused too: proof goes to Evidence, as a capture of the real product; a report must be a page to tap, not a wall of words.
|
|
78
|
-
- **Dismiss** hides a card from the board, **Undo** brings it back (`sign-off-axi dismiss <key>` and `restore <key>` do the same from a shell).
|
|
78
|
+
- **Dismiss** hides a card from the board, **Undo** brings it back (`sign-off-axi dismiss <key>` and `restore <key>` do the same from a shell). `sign-off-axi dismiss --filer <id>` closes a finished task: every card that id still waits on leaves the board. Evidence and reports leave the main view a day after they were filed and wait under **Archived**; nothing is deleted, and Restore brings one back. A new ask that repeats an open ask on the same decision is refused, and answering one closes its twins.
|
|
79
79
|
|
|
80
80
|
## What you can ask
|
|
81
81
|
|
|
@@ -86,6 +86,7 @@ One line on what the agent runs per kind; the full reference is [docs/question-k
|
|
|
86
86
|
| Yes or no, pick one | `raise --title ... --recommend ...` | buttons, the recommended one first |
|
|
87
87
|
| Design pick (mocks A/B/C) | `post --type design --mock a=... --mock b=... --link a=<url>` | mocks side by side, an Open live link under each option that has a `--link`, a note on any part |
|
|
88
88
|
| Proof update | `post --type evidence --media shot.png` | the capture, its result, Pass / Fail / Needs work |
|
|
89
|
+
| Proof with a file to install | `post --type evidence --media shot.png --attach app.apk` | the capture, and the file with its size and a Download button (any type, up to 1 GB, `SIGN_OFF_ATTACH_MAX_MB` to change) |
|
|
89
90
|
| Explainer video | `post --type explainer --media clip.mp4 --chapter 0:00=Start` | a player with chapter buttons, then Yes or No |
|
|
90
91
|
| Report page | `report init <folder>`, then `post --type report --view ...` | a page to tap through |
|
|
91
92
|
| Diagram, interactive page | `post --type diagram --view flow.svg` | the picture on the card; interactive pages run sandboxed |
|
package/dist/cli.js
CHANGED
|
@@ -3,16 +3,17 @@ import { createServer } from 'node:http';
|
|
|
3
3
|
import { isIP } from 'node:net';
|
|
4
4
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
5
5
|
import { spawn, spawnSync } from 'node:child_process';
|
|
6
|
-
import { accessSync, closeSync, constants, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
|
|
6
|
+
import { accessSync, chmodSync, closeSync, constants, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, readSync, realpathSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
|
|
7
7
|
import { lookup } from 'node:dns/promises';
|
|
8
8
|
import { availableParallelism, homedir, tmpdir, userInfo } from 'node:os';
|
|
9
9
|
import { Worker } from 'node:worker_threads';
|
|
10
|
+
import { pipeline } from 'node:stream';
|
|
10
11
|
import { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
11
12
|
import { parseArgs } from 'node:util';
|
|
12
13
|
import { encode } from '@toon-format/toon';
|
|
13
14
|
import { AxiError, runAxiCli } from 'axi-sdk-js';
|
|
14
|
-
import { CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
15
|
-
import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, mediaFiles, parseMessage, reportPage } from './contract.js';
|
|
15
|
+
import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, asks, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, appendRead, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, sameDecision, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
16
|
+
import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, guided, mediaFiles, parseMessage, reportPage } from './contract.js';
|
|
16
17
|
import { faults, isStill } from './evidence-text.js';
|
|
17
18
|
import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
|
|
18
19
|
const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
|
|
@@ -28,8 +29,8 @@ const AGENTS = {
|
|
|
28
29
|
'gemini-cli': { bin: 'gemini', dir: '.agents/skills', home: join(homedir(), '.gemini', 'skills') },
|
|
29
30
|
};
|
|
30
31
|
const TOP_HELP = `usage: sign-off-axi [command] [board.json] [flags]
|
|
31
|
-
commands[
|
|
32
|
-
(none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, report, signin, dismiss, restore, refile, doctor, notify, validate, help
|
|
32
|
+
commands[22]:
|
|
33
|
+
(none)=list, raise, post, answers, wait, poll, deeper, open, status, stop, serve, export, init, report, signin, dismiss, restore, refile, doctor, notify, validate, help
|
|
33
34
|
board: the nearest .sign-off/ board (the walk's bounds and SIGN_OFF_HOME: README, "Test runs never touch your board"), else ~/.sign-off/decisions.json; a board file given always wins
|
|
34
35
|
flags: --full and --json on list, answers and wait, --events on wait, --json on status, --filer on post, raise and wait, --help on every command, -v/--version
|
|
35
36
|
examples:
|
|
@@ -40,6 +41,7 @@ examples:
|
|
|
40
41
|
sign-off-axi post .sign-off/decisions.json --type diagram --title "Is this the sign-in flow?" --view mocks/sign-in/flow.svg
|
|
41
42
|
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
|
|
42
43
|
sign-off-axi report init .sign-off/mocks/sign-in-flow
|
|
44
|
+
sign-off-axi deeper
|
|
43
45
|
sign-off-axi post .sign-off/decisions.json --type report --title "Sign-in now takes 2 steps" --about "Why the code step moved first." --view mocks/sign-in-flow/index.html --preview mocks/sign-in-flow/preview.svg
|
|
44
46
|
sign-off-axi post --type signin --key npm-login --service npm --link https://example.com/login --unlocks "Publish the release" --waits "Main" --expires 15m
|
|
45
47
|
sign-off-axi signin done --key npm-login
|
|
@@ -77,6 +79,7 @@ Every question meets the question standard (docs/contract.md, "Asking a good que
|
|
|
77
79
|
yes and no are labelled Yes and No; a question never takes Done, OK or Not now buttons.
|
|
78
80
|
One decision is one card: look at the open cards first. --merge <key> (up to 10) names other cards on the board that ask the same thing and offer every option of this one:
|
|
79
81
|
they are hidden while this one waits, and its answer closes them too, with the same value and a note naming this key. A merged card merges nothing itself, a card is named by one ask until that ask is answered, and an answered or dismissed card cannot be merged.
|
|
82
|
+
A new ask that repeats an open ask on the same decision (the same answers, titles that share at least 80% of their words), from anyone, is not posted: the refusal names the card to wait on. When one is answered, every open ask on the same decision takes the answer and leaves Decide.
|
|
80
83
|
--proof <key> names an evidence update already on the board that this ask rests on: Decide shows its capture and result at the top.
|
|
81
84
|
The user answers with one tap and can undo it for a few seconds.
|
|
82
85
|
--from adds every decision in a decisions file: ${FORMAT}
|
|
@@ -90,7 +93,8 @@ examples:
|
|
|
90
93
|
`,
|
|
91
94
|
validate: `usage: sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid]
|
|
92
95
|
Checks message files against the communication contract without posting them: ${CONTRACT}
|
|
93
|
-
A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, evidence, gate, finding
|
|
96
|
+
A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, evidence, gate, finding and design at schema 1, report at schema 3 (schema 2 still passes) and plan at schema 2 (schema 1 still passes).
|
|
97
|
+
A report 3 and a plan 2 are guided pages: fields the board draws as one scroll, no page of your own.
|
|
94
98
|
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.
|
|
95
99
|
A report is a page to tap through: its "view" (.html) and "preview" (its picture, beside it) are checked for their shape here; the files themselves are checked at post, from the board file's folder, filled with no FILL: lines left (see report init).
|
|
96
100
|
A plan's "view" (.html) is checked for its shape here; the file itself is checked at post, from the board file's folder.
|
|
@@ -177,7 +181,7 @@ examples:
|
|
|
177
181
|
sign-off-axi serve --port 0
|
|
178
182
|
`,
|
|
179
183
|
post: `usage: sign-off-axi post [board.json] [--type decision|design|diagram|interactive] --title "<question>" [--key <key>] [--about <text>] [--option <value>=<label>]... [--mock <value>=<path>]... [--link <value>=<url>]... [--view <path>] [--tradeoff <text>] [--recommend <value>] [--default <value>] [--close done|release] [--filer <id>]
|
|
180
|
-
sign-off-axi post [board.json] --type evidence --title "<what this shows>" --about "<short note>" --media <file>... [--link <commit or URL>] [--detail <text>] [--filer <id>]
|
|
184
|
+
sign-off-axi post [board.json] --type evidence --title "<what this shows>" --about "<short note>" --media <file>... [--attach <file>]... [--link <commit or URL>] [--detail <text>] [--filer <id>]
|
|
181
185
|
sign-off-axi post [board.json] --from <board file> --replace [--filer <id>]
|
|
182
186
|
sign-off-axi post [board.json] --kind <kind> --from <message.json|-> [--filer <id>]
|
|
183
187
|
sign-off-axi post [board.json] --type explainer --title "<question>" --media <video> [--about <summary>] [--chapter <m:ss>=<title>]... [--key <key>] [--option <value>=<label>]... [--recommend <value>]
|
|
@@ -212,6 +216,7 @@ An empty items list removes that filer's items.
|
|
|
212
216
|
an image (.svg, .png, .jpg, .jpeg, .webp, 5 MB or less) shown at card width for a diagram, or one self-contained .html page for interactive.
|
|
213
217
|
The page runs in a sandboxed frame: no fetch, no remote files, no access to the dashboard or its tab. Answers work as for a decision.
|
|
214
218
|
--type evidence posts the next numbered update (key update-<number>): 1 to ${MEDIA_MAX} screenshots or recordings (.png, .jpg, .jpeg, .webp, .mp4, .webm, 50 MB each), copied into evidence/<number>/ next to the board, with a short note and an optional commit or PR link. A still that is not a picture of the product is refused: mostly text (a log, receipt or summary), a colour-bar test card or a placeholder card. Post a real capture of the running product; a colour-bar or placeholder card already posted is not shown on the board.
|
|
219
|
+
--attach <file> (up to ${ATTACH_MAX}, any file type, such as an Android .apk) adds a file the user downloads from the card, over the board link, on a phone or tablet. It is copied (never read into memory) into evidence/<number>/ and streamed on download. A file over ${ATTACH_DEFAULT_MB} MB is refused; raise the limit with SIGN_OFF_ATTACH_MAX_MB=<megabytes>. An update may have only --attach files and no --media.
|
|
215
220
|
The user gives a verdict, pass, fail or needs-work, with an optional note; read it with answers or wait --key update-<number>.
|
|
216
221
|
--type explainer is a decision shown with a narrated video you made: one --media .mp4 or .webm (50 MB or less), copied into evidence/<number>/ like evidence
|
|
217
222
|
and played on the card. --about is its short summary; up to ${CHAPTERS_MAX} --chapter m:ss=<title> (in order) are buttons that jump the video there. Answers work as for a decision.
|
|
@@ -241,6 +246,7 @@ examples:
|
|
|
241
246
|
A design message lists project journeys, per-screen image or HTML view variants, and a card preview; post checks every asset stays under the board folder.
|
|
242
247
|
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
|
|
243
248
|
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
|
|
249
|
+
sign-off-axi post --type evidence --title "Debug build for the phone" --about "The app installs and signs in." --media phone.png --attach app-debug.apk
|
|
244
250
|
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"
|
|
245
251
|
sign-off-axi post .sign-off/decisions.json --type report --title "Sign-in now takes 2 steps" --about "Why the code step moved first." --view mocks/sign-in-flow/index.html --preview mocks/sign-in-flow/preview.svg
|
|
246
252
|
sign-off-axi post --type signin --key npm-login --service npm --link https://example.com/login --unlocks "Publish the release" --waits "Main" --expires 15m
|
|
@@ -253,10 +259,14 @@ examples:
|
|
|
253
259
|
sign-off-axi signin done --key npm-login
|
|
254
260
|
`,
|
|
255
261
|
dismiss: `usage: sign-off-axi dismiss <key> [board.json]
|
|
262
|
+
sign-off-axi dismiss --filer <id> [board.json]
|
|
256
263
|
Hides a card from the board, like its Dismiss button. The agent is not told, and an ask it waits on keeps waiting.
|
|
264
|
+
--filer <id> closes a finished task: every card that id still has waiting leaves the board. Run it when your work ends, so nothing of yours is left on Decide.
|
|
265
|
+
Evidence and reports leave the main view by themselves a day after they were filed; they stay under Archived on their screen, and restore brings one back for good.
|
|
257
266
|
restore <key> [board.json] brings it back. Find the key with \`sign-off-axi list --full\`.
|
|
258
267
|
examples:
|
|
259
268
|
sign-off-axi dismiss ship-the-new-sign-in-screen-this-week
|
|
269
|
+
sign-off-axi dismiss --filer agent-41
|
|
260
270
|
sign-off-axi restore ship-the-new-sign-in-screen-this-week
|
|
261
271
|
`,
|
|
262
272
|
refile: `usage: sign-off-axi refile <key> [board.json] --media <file>... [--link <commit or URL>]
|
|
@@ -288,6 +298,17 @@ examples:
|
|
|
288
298
|
sign-off-axi init
|
|
289
299
|
sign-off-axi init ./my-project --agent opencode
|
|
290
300
|
sign-off-axi init --agent codex,gemini-cli
|
|
301
|
+
`,
|
|
302
|
+
deeper: `usage: sign-off-axi deeper [board.json] [--filer <id>] [--all] [--json]
|
|
303
|
+
sign-off-axi deeper [board.json] --id <id> --text "<answer>" [--more]
|
|
304
|
+
Go deeper is the button on every part of a plan 2 or report 3. The person taps it; wait --filer wakes with the ask, or this command lists the ones nobody finished.
|
|
305
|
+
Answer in plain words about the part they tapped, a few lines at a time: each --more piece streams into that spot on the board while they watch, and a last call without --more ends it.
|
|
306
|
+
--id the ask to answer --text the answer, or - to read stdin (2000 characters or fewer per piece)
|
|
307
|
+
--more more pieces follow --all also the finished asks
|
|
308
|
+
examples:
|
|
309
|
+
sign-off-axi deeper
|
|
310
|
+
sign-off-axi deeper --id 3fa9c1 --text "I timed all three paths on 12 Oct." --more
|
|
311
|
+
sign-off-axi deeper --id 3fa9c1 --text "The After bars are goals, not measurements."
|
|
291
312
|
`,
|
|
292
313
|
poll: `usage: sign-off-axi poll [board.json] [--timeout 600] [--json]
|
|
293
314
|
Blocks until the user comments on an item or queues a prompt for the agent on the page, then prints every new one once and marks it read.
|
|
@@ -403,7 +424,7 @@ function contractItems(items, from, file, replace = false) {
|
|
|
403
424
|
}
|
|
404
425
|
try {
|
|
405
426
|
checkMessage(check);
|
|
406
|
-
if (check.kind === 'report' || check.kind === 'plan')
|
|
427
|
+
if ((check.kind === 'report' || check.kind === 'plan') && !guided(check))
|
|
407
428
|
reportPage(check, dirname(file));
|
|
408
429
|
if (check.kind === 'design')
|
|
409
430
|
designFiles(check, dirname(file));
|
|
@@ -520,14 +541,16 @@ function filerOf(values, session = false) {
|
|
|
520
541
|
const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
|
|
521
542
|
: `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
|
|
522
543
|
/** Still waiting on the user: not answered, not dismissed, and something to answer (an item with no options and no reply is only shown). */
|
|
523
|
-
const waits = (d) => d
|
|
544
|
+
const waits = (d) => asks(d) && !d.dismissed && awaitsVerdict(d.type, d.answer);
|
|
545
|
+
/** The items that archive after a day: all of them, except on a board another program feeds, which owns what it shows. */
|
|
546
|
+
const archivable = (file, decisions) => ((x) => (x.readonly === true || x.answers_only === true ? [] : decisions))(readItems(file).extra);
|
|
524
547
|
/** The board's decisions with their latest answer; a missing board file is an empty board.
|
|
525
548
|
* A dismissal hides an item from the board and is not waiting here either, checked against the same dismissed log the board reads. */
|
|
526
549
|
function board(file, say = warn) {
|
|
527
550
|
if (!existsSync(file))
|
|
528
551
|
return [];
|
|
529
552
|
const decisions = loadDecisions(file), answers = readAnswers(file, say);
|
|
530
|
-
const hidden = new Set(readDismissed(file, answers, readRounds(file), say));
|
|
553
|
+
const hidden = new Set(readDismissed(file, answers, readRounds(file), say, archivable(file, decisions)));
|
|
531
554
|
for (const key of Object.keys(answers))
|
|
532
555
|
if (!decisions.some((d) => d.key === key))
|
|
533
556
|
say(`left out the answer for ${key}: that question is no longer in ${basename(file)}`);
|
|
@@ -623,6 +646,45 @@ function eventLines(file, rows, answers, rounds) {
|
|
|
623
646
|
...rounds.map(({ key, note, at, comments }) => ({ at, line: { ...head('round', key), note, at, ...tail(key, comments) } }))]
|
|
624
647
|
.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)).map(({ line }) => JSON.stringify(line)).join('\n');
|
|
625
648
|
}
|
|
649
|
+
/** Go deeper asks as the agent reads them: what part, what it is about, the question if the person typed one, and how to answer. */
|
|
650
|
+
function deeperOut(file, asks, on, events) {
|
|
651
|
+
if (events) {
|
|
652
|
+
const kind = kindOf(file), titles = new Map(board(file).map((d) => [d.key, d.title]));
|
|
653
|
+
return asks.map(({ id, key, part, about, q, at }) => JSON.stringify({ event: 'deeper', ...kind(key), key, title: titles.get(key), id, part, about, ...(q && { q }), at })).join('\n');
|
|
654
|
+
}
|
|
655
|
+
return toon({ board: tilde(file), deeper: asks.map(({ id, key, part, about, q = '', at }) => ({ id, key, part, about, q, at })) }, ['The person tapped Go deeper: answer in plain words, a few lines at a time, as the part they tapped',
|
|
656
|
+
`Run \`sign-off-axi deeper${on} --id <id> --text "<what you found>" --more\` for each piece, then once more without --more to finish`]);
|
|
657
|
+
}
|
|
658
|
+
/** `deeper`: the Go deeper asks nobody has finished, or with --id and --text one piece of the answer the board streams under the part the person tapped. */
|
|
659
|
+
async function deeper(args) {
|
|
660
|
+
const { file, values, on } = parse('deeper', args, { filer: { type: 'string' }, id: { type: 'string' }, text: { type: 'string' }, more: { type: 'boolean' }, all: { type: 'boolean' }, json: { type: 'boolean' } });
|
|
661
|
+
ready(file, on);
|
|
662
|
+
const filer = filerOf(values);
|
|
663
|
+
if (values.id === undefined) {
|
|
664
|
+
if (values.text !== undefined || values.more)
|
|
665
|
+
throw fail('--text and --more answer one ask, so they need --id', 'VALIDATION_ERROR', 'Run `sign-off-axi deeper` to see the ids');
|
|
666
|
+
const mine = new Set(board(file).filter((d) => filer === undefined || d.filer === filer).map((d) => d.key));
|
|
667
|
+
const open = readDeeper(file).filter((x) => mine.has(x.key) && (values.all || !x.done));
|
|
668
|
+
if (values.json)
|
|
669
|
+
return JSON.stringify({ deeper: open });
|
|
670
|
+
if (!open.length)
|
|
671
|
+
return toon({ board: tilde(file), deeper: '0 open' }, [`Run \`sign-off-axi wait${on}\` to be woken when the person taps Go deeper`]);
|
|
672
|
+
markDeeperRead(file, open.filter((x) => !x.read).map((x) => x.id));
|
|
673
|
+
return toon({ board: tilde(file), deeper: open.map(({ id, key, part, about, q = '', at, done }) => ({ id, key, part, about, q, at, done: !!done })) }, [`Run \`sign-off-axi deeper${on} --id <id> --text "<answer>"\` to answer one`]);
|
|
674
|
+
}
|
|
675
|
+
const ask = readDeeper(file).find((x) => x.id === values.id);
|
|
676
|
+
if (!ask)
|
|
677
|
+
throw fail(`no Go deeper ask with id ${values.id}`, 'NOT_FOUND', `Run \`sign-off-axi deeper${on}\` to see the open ones`);
|
|
678
|
+
if (ask.done)
|
|
679
|
+
throw fail(`ask ${values.id} is already answered`, 'CONFLICT', 'The person can tap Go deeper again for a follow-up');
|
|
680
|
+
let text = values.text;
|
|
681
|
+
if (text === '-')
|
|
682
|
+
text = readFileSync(0, 'utf8');
|
|
683
|
+
if (typeof text !== 'string' || !text.trim() || [...text.trim()].length > 2000)
|
|
684
|
+
throw fail('--text must be the answer, 2000 characters or fewer', 'VALIDATION_ERROR', 'Run `sign-off-axi deeper --help`');
|
|
685
|
+
answerDeeper(file, ask.id, text.trim(), !!values.more);
|
|
686
|
+
return toon({ id: ask.id, key: ask.key, part: ask.part, answered: values.more ? 'piece sent, more to come' : 'done' }, values.more ? [`Run \`sign-off-axi deeper${on} --id ${ask.id} --text "<next piece>" [--more]\``] : [`Run \`sign-off-axi wait${on}\` for the next answer or ask`]);
|
|
687
|
+
}
|
|
626
688
|
async function wait(args) {
|
|
627
689
|
const { file, values, on: onBoard } = parse('wait', args, { ...TIERS, events: { type: 'boolean' }, filer: { type: 'string' }, key: { type: 'string' }, timeout: { type: 'string', default: '600' }, into: { type: 'string' } });
|
|
628
690
|
if (!/^\d{1,7}$/.test(values.timeout))
|
|
@@ -649,10 +711,10 @@ async function wait(args) {
|
|
|
649
711
|
seenRounds.set(r.key, (seenRounds.get(r.key) ?? 0) + 1);
|
|
650
712
|
// A save wakes the loop at once; the 1 second timer is the fallback where file events are unavailable.
|
|
651
713
|
let wake = () => { }, watcher, vaultWarned = false;
|
|
652
|
-
const name = basename(answersPath(file));
|
|
714
|
+
const name = basename(answersPath(file)), asking = basename(deeperPath(file));
|
|
653
715
|
try {
|
|
654
716
|
try {
|
|
655
|
-
watcher = watch(dirname(file), (_, changed) => changed === name && wake()).on('error', () => { });
|
|
717
|
+
watcher = watch(dirname(file), (_, changed) => (changed === name || changed === asking) && wake()).on('error', () => { });
|
|
656
718
|
}
|
|
657
719
|
catch { }
|
|
658
720
|
for (let first = true;; first = false) {
|
|
@@ -687,6 +749,16 @@ async function wait(args) {
|
|
|
687
749
|
if (found.length) {
|
|
688
750
|
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`]);
|
|
689
751
|
}
|
|
752
|
+
// A Go deeper ask on this filer's guided page wakes the wait once, and is marked read when it is handed over.
|
|
753
|
+
if (values.key === undefined) {
|
|
754
|
+
const asks = await locked(`${deeperPath(file)}.lock`, () => {
|
|
755
|
+
const got = readDeeper(file).filter((x) => !x.read && rows.some((d) => d.key === x.key));
|
|
756
|
+
markDeeperRead(file, got.map((x) => x.id));
|
|
757
|
+
return got;
|
|
758
|
+
}, 'another wait is still taking Go deeper asks');
|
|
759
|
+
if (asks.length)
|
|
760
|
+
return deeperOut(file, asks, onBoard + (filer ? ` --filer ${filer}` : ''), values.events);
|
|
761
|
+
}
|
|
690
762
|
// Private vault expiries and refusals reach a filer wait only once its ordinary answers are out; each is taken once.
|
|
691
763
|
let live = 0, unknown = false;
|
|
692
764
|
if (filer !== undefined && values.key === undefined) {
|
|
@@ -815,11 +887,11 @@ const mockFile = (path) => path.split('?')[0];
|
|
|
815
887
|
/** Posts an item. `raise` is the alias for `post --type decision`: same flags as before, same output. */
|
|
816
888
|
function post(args, cmd = 'post') {
|
|
817
889
|
const typed = cmd === 'post' ? { replace: { type: 'boolean' }, kind: { type: 'string' }, type: { type: 'string' }, tradeoff: { type: 'string' }, mock: { type: 'string', multiple: true },
|
|
818
|
-
media: { type: 'string', multiple: true }, link: { type: 'string', multiple: true }, view: { type: 'string' }, preview: { type: 'string' }, chapter: { type: 'string', multiple: true },
|
|
890
|
+
media: { type: 'string', multiple: true }, attach: { type: 'string', multiple: true }, link: { type: 'string', multiple: true }, view: { type: 'string' }, preview: { type: 'string' }, chapter: { type: 'string', multiple: true },
|
|
819
891
|
...Object.fromEntries(SIGNIN_FIELDS.map((f) => [f, { type: 'string' }])) } : {};
|
|
820
892
|
const { file, named, values, on } = parse(cmd, args, { from: { type: 'string' }, filer: { type: 'string' }, ...typed,
|
|
821
893
|
...Object.fromEntries(RAISE_FIELDS.map((f) => [f, { type: 'string', multiple: MANY.includes(f) }])) });
|
|
822
|
-
const given = [...RAISE_FIELDS, 'type', 'tradeoff', 'mock', 'media', 'link', 'view', 'preview', 'chapter', ...SIGNIN_FIELDS].filter((f) => values[f] !== undefined);
|
|
894
|
+
const given = [...RAISE_FIELDS, 'type', 'tradeoff', 'mock', 'media', 'attach', 'link', 'view', 'preview', 'chapter', ...SIGNIN_FIELDS].filter((f) => values[f] !== undefined);
|
|
823
895
|
// Only a design takes --link more than once (one per option); any other type keeps its one item link, the last given.
|
|
824
896
|
const optionLinks = values.link ?? [];
|
|
825
897
|
if (values.type !== 'design' && optionLinks.length)
|
|
@@ -855,8 +927,8 @@ function post(args, cmd = 'post') {
|
|
|
855
927
|
throw fail(`--type must be decision, design, evidence, diagram, interactive, explainer, report or signin`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
|
|
856
928
|
if (type === 'evidence' || type === 'explainer')
|
|
857
929
|
return postMedia(file, named, on, values, type, filer);
|
|
858
|
-
if (values.media !== undefined || (type !== 'design' && values.link !== undefined) || values.chapter !== undefined)
|
|
859
|
-
throw fail('--media, --link and --chapter need --type evidence or explainer (--link also goes on --type design, one per option)', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
|
|
930
|
+
if (values.media !== undefined || values.attach !== undefined || (type !== 'design' && values.link !== undefined) || values.chapter !== undefined)
|
|
931
|
+
throw fail('--media, --attach, --link and --chapter need --type evidence or explainer (--link also goes on --type design, one per option)', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
|
|
860
932
|
if (type !== 'design' && (values.tradeoff !== undefined || values.mock !== undefined))
|
|
861
933
|
throw fail('--tradeoff and --mock need --type design', 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
|
|
862
934
|
const view = type === 'diagram' || type === 'interactive';
|
|
@@ -1002,11 +1074,21 @@ function signin(args) {
|
|
|
1002
1074
|
function dismiss(restoring) {
|
|
1003
1075
|
const cmd = restoring ? 'restore' : 'dismiss';
|
|
1004
1076
|
return (args) => {
|
|
1005
|
-
|
|
1006
|
-
|
|
1077
|
+
// dismiss --filer <id> closes a finished task: every card that filer still has waiting leaves the board.
|
|
1078
|
+
const f = restoring ? -1 : args.findIndex((a) => a === '--filer'), filer = f < 0 ? undefined : args[f + 1], rest = f < 0 ? args : args.filter((_, i) => i !== f && i !== f + 1);
|
|
1079
|
+
if (f >= 0 && !isKey(filer))
|
|
1080
|
+
throw fail('--filer needs an id of 1-128 of A-Z a-z 0-9 . _ -', 'VALIDATION_ERROR', 'Pass the id you posted with');
|
|
1081
|
+
const at = rest.findIndex((a) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json')), key = rest[at];
|
|
1082
|
+
if (at < 0 && f < 0)
|
|
1007
1083
|
throw fail(`${cmd} needs a card key`, 'VALIDATION_ERROR', `sign-off-axi ${cmd} <key> [board.json]`);
|
|
1008
|
-
const { file, on } = parse(cmd,
|
|
1084
|
+
const { file, on } = parse(cmd, rest.filter((_, i) => i !== at), {});
|
|
1009
1085
|
return locked(`${file}.lock`, () => {
|
|
1086
|
+
if (filer) {
|
|
1087
|
+
const keys = board(file).filter((d) => d.filer === filer && waits(d)).map((d) => d.key);
|
|
1088
|
+
if (keys.length)
|
|
1089
|
+
appendDismissed(file, keys, true);
|
|
1090
|
+
return toon({ board: tilde(file), dismissed: keys.length ? keys : `0 cards of ${filer} were waiting` }, keys.length ? [`Run \`sign-off-axi restore${on} <key>\` to bring one back`] : []);
|
|
1091
|
+
}
|
|
1010
1092
|
if (!isKey(key) || !board(file).some((x) => x.key === key))
|
|
1011
1093
|
throw fail(`no card with key ${key} on ${tilde(file)}`, 'NOT_FOUND', `Run \`sign-off-axi list${on} --full\` to see the keys`);
|
|
1012
1094
|
appendDismissed(file, [key], !restoring);
|
|
@@ -1103,10 +1185,47 @@ const UNFIT = {
|
|
|
1103
1185
|
'colour bars': (path) => `--media ${path} is a colour-bar test card, not evidence`,
|
|
1104
1186
|
placeholder: (path) => `--media ${path} is a placeholder card, not evidence`,
|
|
1105
1187
|
};
|
|
1188
|
+
/** The attachment size limit in bytes: SIGN_OFF_ATTACH_MAX_MB, else 1 GB. */
|
|
1189
|
+
function attachLimit() {
|
|
1190
|
+
const raw = process.env.SIGN_OFF_ATTACH_MAX_MB, mb = raw === undefined || raw === '' ? ATTACH_DEFAULT_MB : Number(raw);
|
|
1191
|
+
if (!(mb > 0))
|
|
1192
|
+
throw fail(`SIGN_OFF_ATTACH_MAX_MB=${raw} must be a number of megabytes above 0`, 'VALIDATION_ERROR', 'Unset it, or set it to a size such as 1024');
|
|
1193
|
+
return mb * 1024 * 1024;
|
|
1194
|
+
}
|
|
1195
|
+
/** One --attach file: it must be a real file within the limit. Its download name is made safe here (letters, digits and . _ ( ) + - only), and never names a place on disk. */
|
|
1196
|
+
function attachment(given) {
|
|
1197
|
+
const path = resolve(given);
|
|
1198
|
+
let info;
|
|
1199
|
+
try {
|
|
1200
|
+
info = statSync(path, { throwIfNoEntry: false });
|
|
1201
|
+
}
|
|
1202
|
+
catch (e) {
|
|
1203
|
+
throw fail(`--attach ${given} ${reasonOf(e.code)}`, 'NOT_FOUND', 'Name a file that is there');
|
|
1204
|
+
}
|
|
1205
|
+
if (!info?.isFile())
|
|
1206
|
+
throw fail(`--attach ${given} ${info || existsSync(path) ? 'is not a file' : 'is not there'}`, 'NOT_FOUND', 'Name a file that is there');
|
|
1207
|
+
const limit = attachLimit();
|
|
1208
|
+
if (info.size > limit)
|
|
1209
|
+
throw fail(`--attach ${tilde(path)} is ${Math.ceil(info.size / 1048576)} MB, over the ${Math.floor(limit / 1048576)} MB limit`, 'VALIDATION_ERROR', 'Raise the limit with SIGN_OFF_ATTACH_MAX_MB=<megabytes>, or attach a smaller file');
|
|
1210
|
+
const ext = extname(path).toLowerCase(), name = basename(path).replace(/[^\w.()+ -]/g, '_').replace(/^\.+/, '_').slice(-120);
|
|
1211
|
+
return { src: path, ext: /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : '.bin', name: ATTACH_NAME.test(name) ? name : 'file' };
|
|
1212
|
+
}
|
|
1213
|
+
/** The SHA-256 of a file, read in 1 MB chunks. */
|
|
1214
|
+
function hashFile(path) {
|
|
1215
|
+
const hash = createHash('sha256'), buf = Buffer.allocUnsafe(1 << 20), fd = openSync(path, 'r');
|
|
1216
|
+
try {
|
|
1217
|
+
for (let n; (n = readSync(fd, buf, 0, buf.length, null)) > 0;)
|
|
1218
|
+
hash.update(buf.subarray(0, n));
|
|
1219
|
+
}
|
|
1220
|
+
finally {
|
|
1221
|
+
closeSync(fd);
|
|
1222
|
+
}
|
|
1223
|
+
return hash.digest('hex');
|
|
1224
|
+
}
|
|
1106
1225
|
function postMedia(file, named, on, values, type, filer, message) {
|
|
1107
1226
|
fits(values.title);
|
|
1108
1227
|
const explainer = type === 'explainer';
|
|
1109
|
-
const extra = (explainer ? ['mock', 'tradeoff', 'view', 'link'] : ['key', 'option', 'recommend', 'default', 'mock', 'tradeoff', 'view', 'chapter']).find((f) => values[f] !== undefined);
|
|
1228
|
+
const extra = (explainer ? ['mock', 'tradeoff', 'view', 'link', 'attach'] : ['key', 'option', 'recommend', 'default', 'mock', 'tradeoff', 'view', 'chapter']).find((f) => values[f] !== undefined);
|
|
1110
1229
|
if (extra)
|
|
1111
1230
|
throw fail(`--${extra} does not apply to --type ${type}`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
|
|
1112
1231
|
const example = explainer ? 'sign-off-axi post --type explainer --title "<question>" --about "<short summary>" --media explainer.mp4'
|
|
@@ -1115,8 +1234,10 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1115
1234
|
throw fail('--about is required for --type evidence: the short note', 'VALIDATION_ERROR', example);
|
|
1116
1235
|
if (explainer && values.media?.length !== 1)
|
|
1117
1236
|
throw fail('--type explainer needs one --media video (.mp4 or .webm)', 'VALIDATION_ERROR', example);
|
|
1118
|
-
if ((!message && !values.media?.length) || values.media
|
|
1119
|
-
throw fail(`--type evidence needs 1 to ${MEDIA_MAX} --media files`, 'VALIDATION_ERROR', example);
|
|
1237
|
+
if ((!message && !values.media?.length && !values.attach?.length) || (values.media?.length ?? 0) > MEDIA_MAX)
|
|
1238
|
+
throw fail(`--type evidence needs 1 to ${MEDIA_MAX} --media files, or an --attach file`, 'VALIDATION_ERROR', example);
|
|
1239
|
+
if ((values.attach?.length ?? 0) > ATTACH_MAX)
|
|
1240
|
+
throw fail(`--attach takes up to ${ATTACH_MAX} files`, 'VALIDATION_ERROR', 'Zip the files together, or post a second update');
|
|
1120
1241
|
const kinds = explainer ? ['.mp4', '.webm'] : Object.keys(mediaTypes), options = optionsOf(values);
|
|
1121
1242
|
recommended([{ options, recommend_value: values.recommend }], true);
|
|
1122
1243
|
const chapters = values.chapter?.map((c) => {
|
|
@@ -1125,7 +1246,7 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1125
1246
|
throw fail(`--chapter ${c} must be <m:ss>=<title>`, 'VALIDATION_ERROR', 'sign-off-axi post --type explainer ... --chapter 0:00=Start --chapter 0:12="Step 2"');
|
|
1126
1247
|
return { at: Number(m[1]) * 60 + Number(m[2]), title: m[3] };
|
|
1127
1248
|
});
|
|
1128
|
-
const sources = values.media.map((m) => {
|
|
1249
|
+
const sources = (values.media ?? []).map((m) => {
|
|
1129
1250
|
const path = resolve(m);
|
|
1130
1251
|
let info;
|
|
1131
1252
|
try {
|
|
@@ -1142,6 +1263,7 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1142
1263
|
throw fail(`--media ${tilde(path)} is over 50 MB`, 'VALIDATION_ERROR', 'Trim or compress the recording');
|
|
1143
1264
|
return path;
|
|
1144
1265
|
});
|
|
1266
|
+
const attached = (values.attach ?? []).map((a) => attachment(a));
|
|
1145
1267
|
// A still that is not a picture of the product (text, colour bars or a placeholder) is measured before anything is copied.
|
|
1146
1268
|
const [unfit] = explainer ? [] : sources.flatMap((src) => isStill(src) ? faults(src).map((f) => ({ src, f })) : []);
|
|
1147
1269
|
if (unfit)
|
|
@@ -1178,13 +1300,20 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1178
1300
|
}
|
|
1179
1301
|
}
|
|
1180
1302
|
const key = own || `update-${number}`, dir = join(store, String(number));
|
|
1181
|
-
let media;
|
|
1303
|
+
let media, attachments = [];
|
|
1182
1304
|
try {
|
|
1183
1305
|
media = sources.map((src, i) => {
|
|
1184
1306
|
const bytes = readFileSync(src), path = `evidence/${number}/${i + 1}${extname(src).toLowerCase()}`;
|
|
1185
1307
|
writeFileSync(join(dir, `${i + 1}${extname(src).toLowerCase()}`), bytes, { mode: 0o600 });
|
|
1186
1308
|
return { path, sha256: sha256(bytes), bytes: bytes.length };
|
|
1187
1309
|
});
|
|
1310
|
+
// An attachment is copied by the kernel and hashed in chunks: the file is never held in memory.
|
|
1311
|
+
attachments = attached.map(({ src, ext, name }, i) => {
|
|
1312
|
+
const stored = `attach-${i + 1}${ext}`, dest = join(dir, stored);
|
|
1313
|
+
copyFileSync(src, dest, constants.COPYFILE_EXCL);
|
|
1314
|
+
chmodSync(dest, 0o600);
|
|
1315
|
+
return { path: `evidence/${number}/${stored}`, name, sha256: hashFile(dest), bytes: statSync(dest).size };
|
|
1316
|
+
});
|
|
1188
1317
|
}
|
|
1189
1318
|
catch (e) {
|
|
1190
1319
|
// A file that cannot be read after all leaves no copies behind.
|
|
@@ -1195,7 +1324,7 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1195
1324
|
const { run, media: _paths, ...fields } = message ?? {};
|
|
1196
1325
|
items.push(explainer ? { key, type, title, about, detail, options, recommend_value, default_if_silent, close, chapters, media, filer, filed }
|
|
1197
1326
|
: message ? { ...fields, key, type, number, ...(media.length ? { media } : {}), ...(run && { evidence: run }), filed }
|
|
1198
|
-
: { key, type, number, title, about, detail, close, link, media, filer, filed });
|
|
1327
|
+
: { key, type, number, title, about, detail, close, link, ...(media.length && { media }), ...(attachments.length && { attachments }), filer, filed });
|
|
1199
1328
|
// The whole board is checked before it is written, so a bad post leaves the board and evidence/ as they were.
|
|
1200
1329
|
const check = `${file}.${process.pid}.check`;
|
|
1201
1330
|
writeFileSync(check, JSON.stringify({ ...extra, schema: 2, items }), { mode: 0o600 });
|
|
@@ -1214,7 +1343,7 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1214
1343
|
notifyEntered(file, named, before);
|
|
1215
1344
|
if (explainer)
|
|
1216
1345
|
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)]);
|
|
1217
|
-
return toon({ board: tilde(file), posted: [{ key, number, media: media.length, title, ...(message && { status: 'posted' }) }] });
|
|
1346
|
+
return toon({ board: tilde(file), posted: [{ key, number, media: media.length, ...(attachments.length && { attached: attachments.length }), title, ...(message && { status: 'posted' }) }] });
|
|
1218
1347
|
});
|
|
1219
1348
|
}
|
|
1220
1349
|
/** post --type report: the flags as one report message, posted as post --kind report posts it. A report with no page is refused before anything is read. */
|
|
@@ -1259,7 +1388,7 @@ async function postKind(file, named, on, values, built) {
|
|
|
1259
1388
|
if (m.kind === 'evidence')
|
|
1260
1389
|
sources = mediaFiles(m, from === '-' ? process.cwd() : dirname(resolve(from)));
|
|
1261
1390
|
// A report's or plan's page is served from the board file's folder, as --view is.
|
|
1262
|
-
if (m.kind === 'report' || m.kind === 'plan')
|
|
1391
|
+
if ((m.kind === 'report' || m.kind === 'plan') && !guided(m))
|
|
1263
1392
|
reportPage(m, dirname(file));
|
|
1264
1393
|
if (m.kind === 'design')
|
|
1265
1394
|
designFiles(m, dirname(file));
|
|
@@ -1440,6 +1569,12 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
|
|
|
1440
1569
|
const proof = incoming.find((d) => d.type === 'decision' && !d.kind && !current.some((c) => c.key === d.key) && ['pass', 'fail'].every((v) => d.options.some((o) => o.value === v)));
|
|
1441
1570
|
if (proof)
|
|
1442
1571
|
throw fail(`key ${proof.key} offers Pass and Fail: a proof is not an ask and would wait on you`, 'VALIDATION_ERROR', 'Post it with sign-off-axi post --type evidence --title "<what this shows>" --about "<short note>" --media <file>');
|
|
1572
|
+
// One decision is one card: a new ask that repeats an open one, whoever filed it, is not posted. Merging it into that card is the way to ask it better.
|
|
1573
|
+
for (const d of incoming) {
|
|
1574
|
+
const open = current.some((c) => c.key === d.key) ? undefined : current.find((c) => waits(c) && !d.merges?.includes(c.key) && sameDecision(c, d));
|
|
1575
|
+
if (open)
|
|
1576
|
+
throw fail(`key ${d.key} asks what ${open.key} already asks, and ${open.key} still waits`, 'CONFLICT', `Wait on it: \`sign-off-axi wait${on} --key ${open.key}\`; or post this ask with --merge ${open.key} to ask it better`);
|
|
1577
|
+
}
|
|
1443
1578
|
const raised = incoming.map((d, i) => {
|
|
1444
1579
|
const had = current.find((c) => c.key === d.key);
|
|
1445
1580
|
// A contract message refused for its key exits 2 like any other refusal (contract section 4); raise --title keeps exit 1.
|
|
@@ -1686,7 +1821,7 @@ const skillInstalled = (dir, agent) => [join(dir, AGENTS[agent].dir), AGENTS[age
|
|
|
1686
1821
|
.some((skills) => existsSync(join(skills, 'sign-off-axi', 'SKILL.md')));
|
|
1687
1822
|
const SKILL_ADD = ['-y', 'skills', 'add', 'umeranjum17/sign-off', '--skill', 'sign-off-axi', '-y', '-a'];
|
|
1688
1823
|
const skillAdd = (agents) => `npx ${[...SKILL_ADD, ...agents].join(' ')}`;
|
|
1689
|
-
const GITIGNORE = 'mocks/\nevidence/\n*.answers.jsonl\n*.rounds.jsonl\n*.messages.jsonl\n*.annotations.jsonl\n*.seen.jsonl\n*.dismissed.jsonl\n*.board.pid\n*.board.log\nnotify.log\n*.token\n*.export.html\n';
|
|
1824
|
+
const GITIGNORE = 'mocks/\nevidence/\n*.answers.jsonl\n*.rounds.jsonl\n*.messages.jsonl\n*.annotations.jsonl\n*.seen.jsonl\n*.dismissed.jsonl\n*.reads.jsonl\n*.deeper.jsonl\n*.board.pid\n*.board.log\nnotify.log\n*.token\n*.export.html\n';
|
|
1690
1825
|
/** The board, port, bind and hosts in <so>/config.yaml (the `key: value` lines init writes). A bad line falls back on its own default
|
|
1691
1826
|
* (decisions.json, 4410) and `problems` says what is wrong; a file that cannot be read throws. */
|
|
1692
1827
|
function readSetup(so) {
|
|
@@ -2364,10 +2499,13 @@ const state = () => {
|
|
|
2364
2499
|
...profile(),
|
|
2365
2500
|
answers,
|
|
2366
2501
|
seen: Object.values(answers).filter(taken).map((a) => a.key),
|
|
2502
|
+
seen_at: Object.fromEntries(Object.values(answers).flatMap((a) => { const t = seenAt(file)(a); return t ? [[a.key, t]] : []; })),
|
|
2503
|
+
reads: readReads(file),
|
|
2504
|
+
deeper: readDeeper(file),
|
|
2367
2505
|
rounds,
|
|
2368
2506
|
messages: readMessages(file, warn),
|
|
2369
2507
|
annotations: readOnly() || answersOnly() ? [] : ((sent) => readAnnotations(file, warn).map((a) => (sent.has(a.id) ? { ...a, sent: true } : a)))(sentIds(file)),
|
|
2370
|
-
dismissed: readDismissed(file, answers, rounds, warn),
|
|
2508
|
+
dismissed: readDismissed(file, answers, rounds, warn, archivable(file, loadDecisions(file))),
|
|
2371
2509
|
};
|
|
2372
2510
|
};
|
|
2373
2511
|
/** A mock file's sha256 now, or null when it is gone: a comment shows only on the exact file it was made on.
|
|
@@ -2391,6 +2529,8 @@ const answersOnly = () => readItems(file).extra.answers_only === true;
|
|
|
2391
2529
|
const mediaTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.mp4': 'video/mp4', '.webm': 'video/webm' };
|
|
2392
2530
|
const mockTypes = { '.html': 'text/html', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' };
|
|
2393
2531
|
/** What a mock's folder may serve beside it: pages, styles, scripts, images, fonts and clips; never the board, answers, token or config. */
|
|
2532
|
+
/** Download types for --attach files; any other extension downloads as application/octet-stream. */
|
|
2533
|
+
const attachTypes = { '.apk': 'application/vnd.android.package-archive', '.zip': 'application/zip', '.pdf': 'application/pdf', '.txt': 'text/plain; charset=utf-8', '.json': 'application/json', '.csv': 'text/csv', '.gz': 'application/gzip' };
|
|
2394
2534
|
const assetTypes = { ...mediaTypes, ...mockTypes, '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript', '.gif': 'image/gif', '.woff2': 'font/woff2' };
|
|
2395
2535
|
const MOCK_BYTES = 5_000_000;
|
|
2396
2536
|
/** A sandboxed mock frame sends no cookie, so beyond loopback its URLs carry this pass in the path: made from the token, it opens mock folders only. */
|
|
@@ -2543,6 +2683,22 @@ async function route(req) {
|
|
|
2543
2683
|
return [404, { error: 'Evidence is unavailable' }];
|
|
2544
2684
|
}
|
|
2545
2685
|
}
|
|
2686
|
+
if (req.method === 'GET' && req.url?.startsWith('/api/attachment?')) {
|
|
2687
|
+
const q = new URL(req.url, 'http://localhost').searchParams;
|
|
2688
|
+
const a = loadDecisions(file).find((d) => d.key === q.get('key'))?.attachments?.[Number(q.get('i') || NaN)];
|
|
2689
|
+
if (!a)
|
|
2690
|
+
return [404, { error: 'Attachment is unavailable' }];
|
|
2691
|
+
try {
|
|
2692
|
+
// Only its size is checked per request: hashing a file of hundreds of MB on every download would block the board.
|
|
2693
|
+
const actual = under(dirname(file), a.path);
|
|
2694
|
+
if (statSync(actual).size !== a.bytes)
|
|
2695
|
+
return [404, { error: 'Attachment changed since it was posted' }];
|
|
2696
|
+
return [200, { attachment: { path: actual, name: a.name, bytes: a.bytes, type: attachTypes[extname(a.path)] ?? 'application/octet-stream' } }];
|
|
2697
|
+
}
|
|
2698
|
+
catch {
|
|
2699
|
+
return [404, { error: 'Attachment is unavailable' }];
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2546
2702
|
if (req.method === 'GET' && req.url === '/api/board')
|
|
2547
2703
|
return [200, { file, pid: process.pid }];
|
|
2548
2704
|
if (req.method === 'GET' && req.url === '/api/decisions') {
|
|
@@ -2551,12 +2707,12 @@ async function route(req) {
|
|
|
2551
2707
|
clearTimeout(timer);
|
|
2552
2708
|
return [200, state()];
|
|
2553
2709
|
}
|
|
2554
|
-
if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin', '/api/leash'].includes(req.url ?? ''))
|
|
2710
|
+
if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin', '/api/leash', '/api/deeper', '/api/reads'].includes(req.url ?? ''))
|
|
2555
2711
|
return [404, { error: 'Not found' }];
|
|
2556
2712
|
if (readOnly())
|
|
2557
2713
|
return [403, { error: 'This board is read-only' }];
|
|
2558
2714
|
// Dismiss only hides an item on this page and sends nothing to the feeding program, so an answers-only board takes it too.
|
|
2559
|
-
if (!['/api/answers', '/api/dismiss', '/api/signin'].includes(req.url) && answersOnly())
|
|
2715
|
+
if (!['/api/answers', '/api/dismiss', '/api/signin', '/api/reads'].includes(req.url) && answersOnly())
|
|
2560
2716
|
return [403, { error: 'This board takes answers only' }];
|
|
2561
2717
|
if (!req.headers['content-type']?.startsWith('application/json'))
|
|
2562
2718
|
return [415, { error: 'Send JSON' }];
|
|
@@ -2644,6 +2800,21 @@ async function route(req) {
|
|
|
2644
2800
|
appendDismissed(file, [...new Set(keys)], dismissed);
|
|
2645
2801
|
return [200, state()];
|
|
2646
2802
|
}
|
|
2803
|
+
if (req.url === '/api/reads' || req.url === '/api/deeper') {
|
|
2804
|
+
const { key, part, q } = JSON.parse(body) ?? {}, d = loadDecisions(file).find((x) => x.key === key), about = typeof part === 'string' && d ? guidePart(d, part) : undefined;
|
|
2805
|
+
if (!about)
|
|
2806
|
+
return [404, { error: 'This part is no longer on the board' }];
|
|
2807
|
+
if (req.url === '/api/reads') {
|
|
2808
|
+
appendRead(file, key, part);
|
|
2809
|
+
return [200, state()];
|
|
2810
|
+
}
|
|
2811
|
+
if (q !== undefined && (typeof q !== 'string' || [...q.trim()].length > NOTE_MAX))
|
|
2812
|
+
return [400, { error: `The question must be text of ${NOTE_MAX} characters or fewer` }];
|
|
2813
|
+
// One ask at a time per part: a second tap while the agent is answering keeps the first.
|
|
2814
|
+
if (!readDeeper(file).some((x) => x.key === key && x.part === part && !x.done))
|
|
2815
|
+
askDeeper(file, key, part, about.slice(0, 160), q?.trim() || undefined);
|
|
2816
|
+
return [200, state()];
|
|
2817
|
+
}
|
|
2647
2818
|
if (req.url === '/api/rounds') {
|
|
2648
2819
|
const { key, note = '' } = JSON.parse(body) ?? {};
|
|
2649
2820
|
if (typeof note !== 'string' || [...note.trim()].length > NOTE_MAX)
|
|
@@ -2844,6 +3015,18 @@ const server = createServer(async (req, res) => {
|
|
|
2844
3015
|
status = e instanceof InputError ? (req.method === 'GET' ? 422 : 409) : e instanceof SyntaxError ? 400 : 500;
|
|
2845
3016
|
body = { error: e.message };
|
|
2846
3017
|
}
|
|
3018
|
+
if (body && typeof body === 'object' && 'attachment' in body) {
|
|
3019
|
+
// Streamed from disk in the range asked for, so a file of hundreds of MB never sits in memory.
|
|
3020
|
+
const { path, name, bytes, type } = body.attachment;
|
|
3021
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? ''), ranged = !!range && !!(range[1] || range[2]);
|
|
3022
|
+
const start = !ranged ? 0 : range[1] ? Number(range[1]) : Math.max(0, bytes - Number(range[2]));
|
|
3023
|
+
const end = ranged && range[1] && range[2] ? Math.min(Number(range[2]), bytes - 1) : bytes - 1;
|
|
3024
|
+
if (ranged && start > end)
|
|
3025
|
+
return res.writeHead(416, { 'content-range': `bytes */${bytes}` }).end();
|
|
3026
|
+
res.writeHead(ranged ? 206 : 200, { 'content-type': type, 'content-disposition': `attachment; filename="${name}"`, 'x-content-type-options': 'nosniff', 'cache-control': 'private, no-store', 'accept-ranges': 'bytes',
|
|
3027
|
+
'content-length': Math.max(0, end - start + 1), ...(ranged && { 'content-range': `bytes ${start}-${end}/${bytes}` }) });
|
|
3028
|
+
return bytes ? pipeline(createReadStream(path, { start, end }), res, () => { }) : res.end();
|
|
3029
|
+
}
|
|
2847
3030
|
if (body && typeof body === 'object' && 'media' in body) {
|
|
2848
3031
|
const { media: bytes, type, point } = body, media = point ? Buffer.concat([bytes, pointer]) : bytes;
|
|
2849
3032
|
const headers = { 'content-type': type, 'x-content-type-options': 'nosniff', 'content-security-policy': FRAME_CSP, 'accept-ranges': 'bytes', 'content-length': media.length };
|
|
@@ -2870,7 +3053,7 @@ await runAxiCli({
|
|
|
2870
3053
|
topLevelHelp: TOP_HELP,
|
|
2871
3054
|
getCommandHelp: (cmd) => HELP[cmd],
|
|
2872
3055
|
home: axi('list', list),
|
|
2873
|
-
commands: { list: axi('list', list), raise: axi('raise', (args) => post(args, 'raise')), post: axi('post', post), answers: axi('answers', answers), wait: axi('wait', wait), poll: axi('poll', poll), open: axi('open', open), status: axi('status', status), stop: axi('stop', stop), serve: axi('serve', serve), export: axi('export', exportBoard), init: axi('init', init), report: axi('report', report), signin: axi('signin', signin), dismiss: axi('dismiss', dismiss(false)), restore: axi('restore', dismiss(true)), refile: axi('refile', refile), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
|
|
3056
|
+
commands: { list: axi('list', list), raise: axi('raise', (args) => post(args, 'raise')), post: axi('post', post), answers: axi('answers', answers), wait: axi('wait', wait), poll: axi('poll', poll), open: axi('open', open), status: axi('status', status), stop: axi('stop', stop), serve: axi('serve', serve), export: axi('export', exportBoard), init: axi('init', init), report: axi('report', report), deeper: axi('deeper', deeper), signin: axi('signin', signin), dismiss: axi('dismiss', dismiss(false)), restore: axi('restore', dismiss(true)), refile: axi('refile', refile), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
|
|
2874
3057
|
if (rest.length || (name && !HELP[name]))
|
|
2875
3058
|
throw fail(`unknown command or argument: ${[name, ...rest].join(' ')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi --help` to see the commands');
|
|
2876
3059
|
return name ? HELP[name] : TOP_HELP;
|