sign-off-axi 0.4.7 → 0.4.9
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 -1
- package/dist/cli.js +172 -30
- package/dist/contract.js +32 -9
- package/dist/store.js +14 -3
- package/fixtures/contract/plan.v1/page/index.html +4 -22
- package/fixtures/contract/plan.v1/valid-full.json +69 -16
- package/fixtures/contract/report.v2/valid-full.json +1 -1
- package/package.json +1 -1
- package/web/demo/seed.json +14 -1
- package/web/index.html +765 -317
package/README.md
CHANGED
|
@@ -172,6 +172,8 @@ the new page on a phone." \
|
|
|
172
172
|
|
|
173
173
|
*Proof of a fix, with two captures. Pass, Fail, or Needs work.*
|
|
174
174
|
|
|
175
|
+
Evidence lists every update, never in **Waiting on you**: a passed run shows a tick, and a plain update that links no run, a failed run or an unchecked run says its honest result, each with its verdicts and Dismiss on the update's own screen. **Proven today** counts every update filed today, each with its own result. Updates are grouped by the project named in the run's proof line, `verify-<project>: ...` (trimmed, compared without case); a proof line with no such prefix goes under **Other**.
|
|
176
|
+
|
|
175
177
|
### Explainer video, with chapters
|
|
176
178
|
|
|
177
179
|
```sh
|
|
@@ -281,6 +283,8 @@ sign-off-axi signin done \
|
|
|
281
283
|
--key npm-login
|
|
282
284
|
```
|
|
283
285
|
|
|
286
|
+
To hide a card as the Dismiss button does, or bring it back: `sign-off-axi dismiss <key>` and `sign-off-axi restore <key>`.
|
|
287
|
+
|
|
284
288
|
<picture>
|
|
285
289
|
<source media="(prefers-color-scheme: dark)" srcset="docs/media/kind-signin-dark.webp">
|
|
286
290
|
<img src="docs/media/kind-signin.webp" alt="A sign-in card on top with an Open button, what it unlocks, who waits and the time left">
|
|
@@ -354,7 +358,8 @@ cannot block can be woken instead: an `on_answer:` line in the
|
|
|
354
358
|
`config.yaml` beside the board names a command the board runs for every
|
|
355
359
|
answer. A `notify:` block in that config can send an HTTP POST when a card
|
|
356
360
|
enters Waiting on you, with its bearer token read from a named environment
|
|
357
|
-
variable
|
|
361
|
+
variable, or, with `receiver: muxr`, from the muxr host's `notify-token`
|
|
362
|
+
file; `sign-off-axi notify test` sends a test notice. The full reference, with the worked example, is in
|
|
358
363
|
[docs/commands.md](docs/commands.md).
|
|
359
364
|
|
|
360
365
|
Every command takes `--help` and prints compact output with next-step hints.
|
package/dist/cli.js
CHANGED
|
@@ -28,8 +28,8 @@ const AGENTS = {
|
|
|
28
28
|
'gemini-cli': { bin: 'gemini', dir: '.agents/skills', home: join(homedir(), '.gemini', 'skills') },
|
|
29
29
|
};
|
|
30
30
|
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, doctor, validate, help
|
|
31
|
+
commands[20]:
|
|
32
|
+
(none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, report, signin, dismiss, restore, doctor, notify, validate, help
|
|
33
33
|
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
34
|
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
35
|
examples:
|
|
@@ -43,6 +43,7 @@ examples:
|
|
|
43
43
|
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
44
|
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
45
|
sign-off-axi signin done --key npm-login
|
|
46
|
+
sign-off-axi dismiss ship-the-new-sign-in-screen-this-week
|
|
46
47
|
sign-off-axi init
|
|
47
48
|
sign-off-axi doctor
|
|
48
49
|
sign-off-axi validate ask.json
|
|
@@ -62,7 +63,7 @@ examples:
|
|
|
62
63
|
sign-off-axi
|
|
63
64
|
sign-off-axi list ./decisions.json --full
|
|
64
65
|
`,
|
|
65
|
-
raise: `usage: sign-off-axi raise [board.json] --title "<question>" [--key <key>] [--about <text>] [--detail <text>] --why <text> [--option <value>=<label>]... --outcome <value>=<text>... --recommend <value> --recommend-reason <text> --default <value> [--silent-by <ISO time>] [--merge <key>]... [--close done|release] [--filer <id>]
|
|
66
|
+
raise: `usage: sign-off-axi raise [board.json] --title "<question>" [--key <key>] [--about <text>] [--detail <text>] --why <text> [--option <value>=<label>]... --outcome <value>=<text>... --recommend <value> --recommend-reason <text> --default <value> [--silent-by <ISO time>] [--merge <key>]... [--proof <key>] [--close done|release] [--filer <id>]
|
|
66
67
|
sign-off-axi raise [board.json] --from <decisions.json> [--filer <id>]
|
|
67
68
|
Adds questions to the board: yes/no, or 2 to 6 choices with one --option each. --key defaults to a slug of the title.
|
|
68
69
|
raise is the alias for post --type decision: same flags, same output.
|
|
@@ -76,6 +77,7 @@ Every question meets the question standard (docs/contract.md, "Asking a good que
|
|
|
76
77
|
yes and no are labelled Yes and No; a question never takes Done, OK or Not now buttons.
|
|
77
78
|
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:
|
|
78
79
|
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.
|
|
80
|
+
--proof <key> names an evidence update already on the board that this ask rests on: Decide shows its capture and result at the top.
|
|
79
81
|
The user answers with one tap and can undo it for a few seconds.
|
|
80
82
|
--from adds every decision in a decisions file: ${FORMAT}
|
|
81
83
|
Raising the same question again is a no-op that reports its status; a different question under a used key is refused.
|
|
@@ -247,6 +249,16 @@ Closes a sign-in card that post --type signin made: the sign-in is done, so the
|
|
|
247
249
|
Run it when you see the sign-in worked. A card posted with --start also closes by itself when its command ends with exit 0. wait --key <key> also returns once it is done.
|
|
248
250
|
examples:
|
|
249
251
|
sign-off-axi signin done --key npm-login
|
|
252
|
+
`,
|
|
253
|
+
dismiss: `usage: sign-off-axi dismiss <key> [board.json]
|
|
254
|
+
Hides a card from the board, like its Dismiss button. The agent is not told, and an ask it waits on keeps waiting.
|
|
255
|
+
restore <key> [board.json] brings it back. Find the key with \`sign-off-axi list --full\`.
|
|
256
|
+
examples:
|
|
257
|
+
sign-off-axi dismiss ship-the-new-sign-in-screen-this-week
|
|
258
|
+
sign-off-axi restore ship-the-new-sign-in-screen-this-week
|
|
259
|
+
`,
|
|
260
|
+
restore: `usage: sign-off-axi restore <key> [board.json]
|
|
261
|
+
Brings back a card that dismiss hid. See \`sign-off-axi dismiss --help\`.
|
|
250
262
|
`,
|
|
251
263
|
report: `usage: sign-off-axi report init <folder>
|
|
252
264
|
Writes a report page to fill: <folder>/index.html and its card picture <folder>/preview.svg. A file already there is kept.
|
|
@@ -286,6 +298,19 @@ It opens from disk with no server and makes no network request. --out defaults t
|
|
|
286
298
|
examples:
|
|
287
299
|
sign-off-axi export
|
|
288
300
|
sign-off-axi export ./decisions.json --out ~/board-12-oct.html
|
|
301
|
+
`,
|
|
302
|
+
notify: `usage: sign-off-axi notify [check|test] [dir]
|
|
303
|
+
Device notifications are off until the config.yaml beside the board has a notify: block. For the muxr receiver it is:
|
|
304
|
+
notify:
|
|
305
|
+
receiver: muxr
|
|
306
|
+
link_base: https://my-machine.my-tailnet.ts.net:4410/ (https or a tailnet address, so the link opens on the iPad)
|
|
307
|
+
url: http://127.0.0.1:8793/v1/notify (optional: this is the default)
|
|
308
|
+
The token is read at send time from the file <MUXR_HOME>/notify-token (default ~/.muxr/notify-token), never kept in the config.
|
|
309
|
+
check (the default): says whether notifications are off, misconfigured, waiting on the token file, or ready. It sends nothing.
|
|
310
|
+
test: sends one real "sign-off test" notice now and says plainly what happened (sent, no token file, token refused, receiver not running).
|
|
311
|
+
examples:
|
|
312
|
+
sign-off-axi notify
|
|
313
|
+
sign-off-axi notify test
|
|
289
314
|
`,
|
|
290
315
|
doctor: `usage: sign-off-axi doctor [dir]
|
|
291
316
|
Checks the repo setup init wrote: node version, config, board file, answer files, a lock a crashed command left behind, gitignore lines, folders, whether the dashboard port is free,
|
|
@@ -323,7 +348,7 @@ function fits(title, key) {
|
|
|
323
348
|
throw fail(`${key ? `key ${key}: ` : ''}title is ${n} characters, over ${TITLE_MAX}`, 'VALIDATION_ERROR', `Shorten the title to ${TITLE_MAX} characters and put the rest in about`);
|
|
324
349
|
}
|
|
325
350
|
/** The question standard's flags: a decision's why, each option's outcome, the reason for the recommendation, when silence decides, the cards it merges. */
|
|
326
|
-
const ASK_FLAGS = ['why', 'outcome', 'recommend-reason', 'silent-by', 'merge'];
|
|
351
|
+
const ASK_FLAGS = ['why', 'outcome', 'recommend-reason', 'silent-by', 'merge', 'proof'];
|
|
327
352
|
const MANY = ['option', 'outcome', 'merge'];
|
|
328
353
|
/** Board types that are contract kinds: a decision (the default type) is an ask. */
|
|
329
354
|
const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report', 'plan', 'design'];
|
|
@@ -488,7 +513,7 @@ function filerOf(values, session = false) {
|
|
|
488
513
|
const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
|
|
489
514
|
: `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
|
|
490
515
|
/** Still waiting on the user: not answered, not dismissed, and something to answer (an item with no options and no reply is only shown). */
|
|
491
|
-
const waits = (d) => !d.dismissed && awaitsVerdict(d.type, d.answer) && (d.options.length > 0 || !!d.reply || d.type === 'signin' || d.type === 'plan' || d.type === 'design');
|
|
516
|
+
const waits = (d) => d.type !== 'evidence' && !d.dismissed && awaitsVerdict(d.type, d.answer) && (d.options.length > 0 || !!d.reply || d.type === 'signin' || d.type === 'plan' || d.type === 'design');
|
|
492
517
|
/** The board's decisions with their latest answer; a missing board file is an empty board.
|
|
493
518
|
* A dismissal hides an item from the board and is not waiting here either, checked against the same dismissed log the board reads. */
|
|
494
519
|
function board(file, say = warn) {
|
|
@@ -529,7 +554,7 @@ function list(args) {
|
|
|
529
554
|
];
|
|
530
555
|
const where = tilde(file) + (existsSync(file) ? '' : ' (no file yet; raise creates it)');
|
|
531
556
|
if (values.full) {
|
|
532
|
-
return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: awaitsVerdict(d.type, d.answer) ? (d.dismissed ? 'dismissed' : 'waiting') : d.answer.answer, title: d.title, about: d.about ?? '', detail: d.detail ?? '',
|
|
557
|
+
return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: awaitsVerdict(d.type, d.answer) ? (d.dismissed ? 'dismissed' : d.type === 'evidence' ? 'shown' : 'waiting') : d.answer.answer, title: d.title, about: d.about ?? '', detail: d.detail ?? '',
|
|
533
558
|
recommend: d.recommend_value ?? '', default_if_silent: d.default_if_silent ?? '', close: d.close })) }, help);
|
|
534
559
|
}
|
|
535
560
|
return toon({
|
|
@@ -958,6 +983,22 @@ function signin(args) {
|
|
|
958
983
|
return toon({ board: tilde(file), closed: [{ key: d.key, status: saved ? 'done' : 'already done', service: d.title }] }, [`Run \`sign-off-axi list${on}\` to see what still waits on the user`]);
|
|
959
984
|
});
|
|
960
985
|
}
|
|
986
|
+
/** dismiss|restore <key> [board.json]: the board's Dismiss and Undo, for an agent. The same dismissed log the board writes, for a key that is on the board. */
|
|
987
|
+
function dismiss(restoring) {
|
|
988
|
+
const cmd = restoring ? 'restore' : 'dismiss';
|
|
989
|
+
return (args) => {
|
|
990
|
+
const at = args.findIndex((a) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json')), key = args[at];
|
|
991
|
+
if (at < 0)
|
|
992
|
+
throw fail(`${cmd} needs a card key`, 'VALIDATION_ERROR', `sign-off-axi ${cmd} <key> [board.json]`);
|
|
993
|
+
const { file, on } = parse(cmd, args.filter((_, i) => i !== at), {});
|
|
994
|
+
return locked(`${file}.lock`, () => {
|
|
995
|
+
if (!isKey(key) || !board(file).some((x) => x.key === key))
|
|
996
|
+
throw fail(`no card with key ${key} on ${tilde(file)}`, 'NOT_FOUND', `Run \`sign-off-axi list${on} --full\` to see the keys`);
|
|
997
|
+
appendDismissed(file, [key], !restoring);
|
|
998
|
+
return toon({ board: tilde(file), [restoring ? 'restored' : 'dismissed']: [key] }, [`Run \`sign-off-axi ${restoring ? 'dismiss' : 'restore'}${on} ${key}\` to undo`]);
|
|
999
|
+
});
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
961
1002
|
/** 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.
|
|
962
1003
|
* 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. */
|
|
963
1004
|
function replace(file, named, on, from, filer) {
|
|
@@ -1087,7 +1128,7 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1087
1128
|
if (asMessage(had) !== canon({ ...message, media: sources.length ? sources.map((src) => sha256(readFileSync(src))) : undefined })) {
|
|
1088
1129
|
throw fail(`key ${own} already holds a different message on ${tilde(file)}`, 'CONFLICT', 'Post it under a new key');
|
|
1089
1130
|
}
|
|
1090
|
-
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`]);
|
|
1131
|
+
return toon({ board: tilde(file), posted: [{ key: own, number: had.number, media: sources.length, title: had.title, status: 'already posted' }] }, explainer ? [`Run \`sign-off-axi open${on}\` so the user can give a verdict`] : []);
|
|
1091
1132
|
}
|
|
1092
1133
|
const answers = readAnswers(file);
|
|
1093
1134
|
if (message && own && Object.hasOwn(answers, own) && !awaitsVerdict(message.kind, answers[own]))
|
|
@@ -1143,7 +1184,7 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1143
1184
|
notifyEntered(file, named, before);
|
|
1144
1185
|
if (explainer)
|
|
1145
1186
|
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)]);
|
|
1146
|
-
return toon({ board: tilde(file), posted: [{ key, number, media: media.length, title, ...(message && { status: 'posted' }) }] }
|
|
1187
|
+
return toon({ board: tilde(file), posted: [{ key, number, media: media.length, title, ...(message && { status: 'posted' }) }] });
|
|
1147
1188
|
});
|
|
1148
1189
|
}
|
|
1149
1190
|
/** 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. */
|
|
@@ -1304,7 +1345,7 @@ function buildIncoming(values, extra, filer, file) {
|
|
|
1304
1345
|
try {
|
|
1305
1346
|
const { title, key = slug(title), about, detail, why, recommend: recommend_value, default: default_if_silent, close } = values;
|
|
1306
1347
|
const ask = !Object.hasOwn(extra, 'type'), options = ask ? outcomesOf(values) : optionsOf(values);
|
|
1307
|
-
const told = ask && { why, recommend_reason: values['recommend-reason'], silent_by: values['silent-by'], merges: values.merge };
|
|
1348
|
+
const told = ask && { why, recommend_reason: values['recommend-reason'], silent_by: values['silent-by'], merges: values.merge, proof: values.proof };
|
|
1308
1349
|
writeFileSync(source, JSON.stringify({ schema: 2, items: [{ key, title, about, detail, ...told, options, recommend_value, default_if_silent, close, ...extra, filer }] }));
|
|
1309
1350
|
const incoming = loadDecisions(source, board), raw = readItems(source).items;
|
|
1310
1351
|
if (!ask)
|
|
@@ -1330,6 +1371,9 @@ const canon = (v) => JSON.stringify(v, (_, x) => (x && typeof x === 'object' &&
|
|
|
1330
1371
|
/** A merge names cards on the board that take this card's answer: each is on the board, offers every option of this card, and merges nothing itself.
|
|
1331
1372
|
* A card is named by one ask until that ask is answered, dismissed or not. A merge new to the board also names a card still open. */
|
|
1332
1373
|
function checkMerges(incoming, resulting, current) {
|
|
1374
|
+
for (const d of incoming)
|
|
1375
|
+
if (d.proof && resulting.find((x) => x.key === d.proof)?.type !== 'evidence')
|
|
1376
|
+
throw fail(`key ${d.key} rests on ${d.proof}, which is not an evidence update on the board`, 'VALIDATION_ERROR', `Post the evidence update first, or leave out --proof`);
|
|
1333
1377
|
const byKey = new Map(resulting.map((d) => [d.key, d])), before = new Map(current.map((c) => [c.key, c]));
|
|
1334
1378
|
const closed = (key) => !!(before.get(key)?.answer || before.get(key)?.dismissed);
|
|
1335
1379
|
const holder = (key, self) => resulting.find((x) => x.key !== self && !before.get(x.key)?.answer && x.merges?.includes(key));
|
|
@@ -1617,11 +1661,11 @@ function readSetup(so) {
|
|
|
1617
1661
|
readFileSync(join(so, 'config.yaml'), 'utf8').split(/\r?\n/).forEach((l, n) => {
|
|
1618
1662
|
// The notify block's indented lines keep their # , and } characters: only a # after a space starts a comment.
|
|
1619
1663
|
if (inNotify && (!l.trim() || /^\s/.test(l))) {
|
|
1620
|
-
const text = l.replace(/(^|\s+)#.*$/, '').trim(), field = text.match(/^(url|token_env|link_base)\s*:\s*(.+)$/);
|
|
1664
|
+
const text = l.replace(/(^|\s+)#.*$/, '').trim(), field = text.match(/^(url|token_env|link_base|receiver)\s*:\s*(.+)$/);
|
|
1621
1665
|
if (field)
|
|
1622
1666
|
notify[field[1]] = field[2].replace(/^(['"])(.*)\1$/, '$2');
|
|
1623
1667
|
else if (text)
|
|
1624
|
-
problems.push(`line ${n + 1} is not one "url", "token_env" or "link_base" line under notify`);
|
|
1668
|
+
problems.push(`line ${n + 1} is not one "receiver", "url", "token_env" or "link_base" line under notify`);
|
|
1625
1669
|
return;
|
|
1626
1670
|
}
|
|
1627
1671
|
inNotify = false;
|
|
@@ -1661,19 +1705,42 @@ function readSetup(so) {
|
|
|
1661
1705
|
catch {
|
|
1662
1706
|
return undefined;
|
|
1663
1707
|
} };
|
|
1708
|
+
const muxr = notify.receiver?.toLowerCase() === 'muxr';
|
|
1709
|
+
if (notifyBlock && notify.receiver && !muxr)
|
|
1710
|
+
problems.push('notify receiver must be muxr, or leave the receiver line out');
|
|
1711
|
+
if (muxr)
|
|
1712
|
+
notify.url ??= MUXR_URL;
|
|
1664
1713
|
const sendTo = parsed(notify.url), base = parsed(notify.link_base);
|
|
1665
1714
|
const usable = !!sendTo && !!base && (sendTo.protocol === 'https:' || (sendTo.protocol === 'http:' && (loopback(sendTo.hostname.replace(/^\[|\]$/g, '')) || sendTo.hostname === 'localhost')))
|
|
1666
1715
|
&& ['http:', 'https:'].includes(base.protocol) && (notify.token_env === undefined || /^[A-Za-z_]\w*$/.test(notify.token_env));
|
|
1667
1716
|
if (notifyBlock && !usable)
|
|
1668
1717
|
problems.push('notify needs url (https://, or http:// on this machine) and link_base (http:// or https://) lines, and token_env must name an environment variable');
|
|
1718
|
+
// A phone or iPad opens the link, so a muxr notice's link is https or a tailnet address: 127.0.0.1 or a LAN name would be dead there.
|
|
1719
|
+
const reachable = !!base && (base.protocol === 'https:' || /\.ts\.net$/i.test(base.hostname) || (isIP(base.hostname) === 4 && /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(base.hostname)));
|
|
1720
|
+
if (notifyBlock && usable && muxr && !reachable)
|
|
1721
|
+
problems.push('notify link_base must be https:// or a tailnet address (a .ts.net name or 100.x address) so the link opens on the iPad');
|
|
1669
1722
|
return { board: join(so, goodBoard ? config.board : 'decisions.json'), port: goodPort ? Number(port) : 4410, bind, hosts, onAnswer, goodBoard, problems,
|
|
1670
|
-
notify: notifyBlock && usable ? { url: notify.url, token_env: notify.token_env, link_base: notify.link_base } : undefined };
|
|
1723
|
+
notify: notifyBlock && usable && (!muxr || reachable) ? { url: notify.url, token_env: notify.token_env, link_base: notify.link_base, muxr } : undefined };
|
|
1671
1724
|
}
|
|
1672
|
-
const
|
|
1673
|
-
/** The
|
|
1725
|
+
const MUXR_URL = 'http://127.0.0.1:8793/v1/notify';
|
|
1726
|
+
/** The muxr receiver's token file, read at send time and never stored: <MUXR_HOME>/notify-token, MUXR_HOME defaulting to ~/.muxr. */
|
|
1727
|
+
const muxrTokenFile = () => join(process.env.MUXR_HOME || join(homedir(), '.muxr'), 'notify-token');
|
|
1728
|
+
/** Why a notice cannot be sent yet, in plain words; undefined when its token is there. */
|
|
1729
|
+
const tokenMissing = (n) => n.muxr
|
|
1730
|
+
? (() => { try {
|
|
1731
|
+
return readFileSync(muxrTokenFile(), 'utf8').trim() ? undefined : `${tilde(muxrTokenFile())} is empty`;
|
|
1732
|
+
}
|
|
1733
|
+
catch {
|
|
1734
|
+
return `${tilde(muxrTokenFile())} is not there yet (the muxr host writes it when it updates)`;
|
|
1735
|
+
} })()
|
|
1736
|
+
: n.token_env && !process.env[n.token_env] ? `${n.token_env} is not set` : undefined;
|
|
1737
|
+
const NOTIFY_SENDER = `void (async()=>{const delays=[2000,10000,60000],f=process.env.SIGN_OFF_NOTIFY_TOKEN_FILE;for(let i=0;i<4;i++){try{const t=f?require('node:fs').readFileSync(f,'utf8').trim():process.env.SIGN_OFF_NOTIFY_TOKEN;if((await fetch(process.env.SIGN_OFF_NOTIFY_URL,{method:'POST',headers:{'content-type':'application/json',...(t&&{authorization:'Bearer '+t})},body:process.env.SIGN_OFF_NOTIFY_BODY,signal:AbortSignal.timeout(10000)})).ok)return}catch{}if(i<3)await new Promise(r=>setTimeout(r,delays[i]))}require('node:fs').appendFileSync(process.env.SIGN_OFF_NOTIFY_LOG,new Date().toISOString()+' notification send failed for card '+process.env.SIGN_OFF_NOTIFY_ID+'\\n')})();`;
|
|
1738
|
+
/** Cards worth a notice: those waiting on the user, and a new evidence update (shown, never waiting). */
|
|
1739
|
+
const notifies = (d) => waits(d) || (d.type === 'evidence' && !d.dismissed);
|
|
1740
|
+
/** The cards that would notify before a write: one that notifies after the write but not before it is new to the user. */
|
|
1674
1741
|
function waitingKeys(file) {
|
|
1675
1742
|
try {
|
|
1676
|
-
return new Set(board(file, () => { }).filter(
|
|
1743
|
+
return new Set(board(file, () => { }).filter(notifies).map((d) => d.key));
|
|
1677
1744
|
}
|
|
1678
1745
|
catch {
|
|
1679
1746
|
return undefined;
|
|
@@ -1685,9 +1752,9 @@ function waitingKeys(file) {
|
|
|
1685
1752
|
function notifyEntered(file, named, before) {
|
|
1686
1753
|
try {
|
|
1687
1754
|
const so = dirname(named), notify = before ? readSetup(so).notify : undefined;
|
|
1688
|
-
if (!before || !notify || (notify
|
|
1755
|
+
if (!before || !notify || tokenMissing(notify))
|
|
1689
1756
|
return;
|
|
1690
|
-
const { items } = readItems(file), fresh = board(file, () => { }).filter((d) =>
|
|
1757
|
+
const { items } = readItems(file), fresh = board(file, () => { }).filter((d) => notifies(d) && !before.has(d.key) && !items.some((i) => i?.key === d.key && i.announced));
|
|
1691
1758
|
if (!fresh.length)
|
|
1692
1759
|
return;
|
|
1693
1760
|
const at = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
|
|
@@ -1700,13 +1767,18 @@ function notifyEntered(file, named, before) {
|
|
|
1700
1767
|
}
|
|
1701
1768
|
catch { }
|
|
1702
1769
|
}
|
|
1770
|
+
/** The JSON one card sends: sign-off's own shape, or muxr's {title (1-80 chars), body, url, id}. */
|
|
1771
|
+
function noticeBody(notify, d) {
|
|
1772
|
+
const kind = d.kind ?? { decision: 'ask', explainer: d.options.length ? 'ask' : 'report' }[d.type] ?? d.type;
|
|
1773
|
+
const text = [...(d.about ?? d.detail ?? d.why ?? d.title).replace(/[\r\n]+/g, ' ')].slice(0, 200).join(''), url = `${notify.link_base.replace(/\/$/, '')}#waiting/${encodeURIComponent(d.key)}`;
|
|
1774
|
+
if (notify.muxr)
|
|
1775
|
+
return { title: [...d.title].slice(0, 80).join('') || d.key, body: text, url, id: d.key };
|
|
1776
|
+
return { v: 1, source: 'sign-off', id: d.key, kind: ['ask', 'plan', 'design', 'evidence', 'report'].includes(kind) ? kind : 'ask',
|
|
1777
|
+
title: [...d.title].slice(0, 120).join(''), text, url, at: d.filed ?? new Date().toISOString() };
|
|
1778
|
+
}
|
|
1703
1779
|
function sendNotice(so, notify, d) {
|
|
1704
1780
|
const token = notify.token_env ? process.env[notify.token_env] : undefined;
|
|
1705
|
-
const
|
|
1706
|
-
const body = JSON.stringify({ v: 1, source: 'sign-off', id: d.key, kind: ['ask', 'plan', 'design', 'evidence', 'report'].includes(kind) ? kind : 'ask',
|
|
1707
|
-
title: [...d.title].slice(0, 120).join(''), text: [...(d.about ?? d.detail ?? d.why ?? d.title).replace(/[\r\n]+/g, ' ')].slice(0, 200).join(''),
|
|
1708
|
-
url: `${notify.link_base.replace(/\/$/, '')}#waiting/${encodeURIComponent(d.key)}`, at: d.filed ?? new Date().toISOString() });
|
|
1709
|
-
const child = spawn(process.execPath, ['-e', NOTIFY_SENDER], { detached: true, stdio: 'ignore', env: { ...process.env, SIGN_OFF_NOTIFY_URL: notify.url, SIGN_OFF_NOTIFY_BODY: body, SIGN_OFF_NOTIFY_TOKEN: token ?? '', SIGN_OFF_NOTIFY_ID: d.key, SIGN_OFF_NOTIFY_LOG: join(so, 'notify.log') } });
|
|
1781
|
+
const child = spawn(process.execPath, ['-e', NOTIFY_SENDER], { detached: true, stdio: 'ignore', env: { ...process.env, SIGN_OFF_NOTIFY_URL: notify.url, SIGN_OFF_NOTIFY_BODY: JSON.stringify(noticeBody(notify, d)), SIGN_OFF_NOTIFY_TOKEN: notify.muxr ? '' : token ?? '', SIGN_OFF_NOTIFY_TOKEN_FILE: notify.muxr ? muxrTokenFile() : '', SIGN_OFF_NOTIFY_ID: d.key, SIGN_OFF_NOTIFY_LOG: join(so, 'notify.log') } });
|
|
1710
1782
|
child.on('error', () => warn(`notification sender could not start for card ${d.key}`));
|
|
1711
1783
|
child.unref();
|
|
1712
1784
|
}
|
|
@@ -1722,7 +1794,7 @@ function init(args) {
|
|
|
1722
1794
|
}
|
|
1723
1795
|
catch { }
|
|
1724
1796
|
const files = [
|
|
1725
|
-
[join(so, 'config.yaml'), `# sign-off repo setup. board: path under .sign-off/, port: dashboard port, agent: auto or the coding agents to install the skill for.\n# bind: 127.0.0.1 keeps the dashboard on this machine; a private address such as its VPN address lets a phone open it.\n# hosts: (optional) private DNS names that lead to that address, such as its mesh VPN name, commas between them.\n# on_answer: (optional) a command run for each answer, with the answer as one JSON line on its stdin; no shell, so | > && do nothing: on_answer: node hook.mjs\nboard: decisions.json\nport: 4410\nbind: 127.0.0.1\nagent: ${agent}\n`],
|
|
1797
|
+
[join(so, 'config.yaml'), `# sign-off repo setup. board: path under .sign-off/, port: dashboard port, agent: auto or the coding agents to install the skill for.\n# bind: 127.0.0.1 keeps the dashboard on this machine; a private address such as its VPN address lets a phone open it.\n# hosts: (optional) private DNS names that lead to that address, such as its mesh VPN name, commas between them.\n# on_answer: (optional) a command run for each answer, with the answer as one JSON line on its stdin; no shell, so | > && do nothing: on_answer: node hook.mjs\n# name: (optional) your name, shown on the board. leash: how much agents may do without asking: ask, matters (the default) or act; the board's Leash control sets it.\nboard: decisions.json\nport: 4410\nbind: 127.0.0.1\nagent: ${agent}\n`],
|
|
1726
1798
|
[board, `{"schema": 2, "items": []}\n`],
|
|
1727
1799
|
[join(so, '.gitignore'), GITIGNORE],
|
|
1728
1800
|
];
|
|
@@ -1794,6 +1866,43 @@ function report(args) {
|
|
|
1794
1866
|
: `Run \`sign-off-axi post <board.json> --type report --title "<the answer>" --about "<why it matters>" --view <path>/index.html --preview <path>/preview.svg\`, both paths from the board file's folder, which must hold this folder`,
|
|
1795
1867
|
]);
|
|
1796
1868
|
}
|
|
1869
|
+
/** `notify check|test`: whether device notifications can work from this setup, and one real test notice. Never prints the token. */
|
|
1870
|
+
async function notify(args) {
|
|
1871
|
+
const mode = ['check', 'test'].includes(args[0]) ? args.shift() : 'check';
|
|
1872
|
+
const { dir: folder, given } = repoDir('notify', args), dir = given ? folder : setupFolderHere() ?? folder, so = join(dir, '.sign-off');
|
|
1873
|
+
let setup;
|
|
1874
|
+
try {
|
|
1875
|
+
setup = readSetup(so);
|
|
1876
|
+
}
|
|
1877
|
+
catch {
|
|
1878
|
+
throw fail(`no readable config.yaml in ${tilde(so)}`, 'NOT_FOUND', `Run \`sign-off-axi init ${shellPath(dir)}\``);
|
|
1879
|
+
}
|
|
1880
|
+
const config = shellPath(join(so, 'config.yaml'));
|
|
1881
|
+
if (setup.problems.some((p) => p.startsWith('notify')))
|
|
1882
|
+
throw fail(`the notify: block in ${tilde(join(so, 'config.yaml'))} cannot be used: ${setup.problems.filter((p) => p.startsWith('notify')).join('; ')}`, 'VALIDATION_ERROR', `Edit ${config}, then run \`sign-off-axi notify\` again`);
|
|
1883
|
+
const n = setup.notify;
|
|
1884
|
+
if (!n)
|
|
1885
|
+
return toon({ notifications: 'off' }, [`Ask the user once whether they want device notifications; if yes, add \`notify:\`, \` receiver: muxr\` and \` link_base: <the board's https or tailnet address>\` lines to ${config}`]);
|
|
1886
|
+
const why = tokenMissing(n);
|
|
1887
|
+
if (why)
|
|
1888
|
+
throw fail(`notifications are on but cannot send: ${why}`, 'NOT_FOUND', n.muxr ? 'Update the muxr host so it writes its notify-token file, then run `sign-off-axi notify test`' : `Export ${n.token_env} with the receiver's token`);
|
|
1889
|
+
if (mode === 'check')
|
|
1890
|
+
return toon({ notifications: 'on', receiver: n.muxr ? 'muxr' : 'webhook', url: n.url, link_base: n.link_base, token: n.muxr ? tilde(muxrTokenFile()) : `$${n.token_env ?? '(none)'}` }, ['Run `sign-off-axi notify test` to send one real test notice']);
|
|
1891
|
+
const probe = { key: 'sign-off-test', title: 'sign-off test', about: 'Device notifications from sign-off work.', type: 'decision', options: [] };
|
|
1892
|
+
let status;
|
|
1893
|
+
try {
|
|
1894
|
+
const token = n.muxr ? readFileSync(muxrTokenFile(), 'utf8').trim() : n.token_env ? process.env[n.token_env] : undefined;
|
|
1895
|
+
status = (await fetch(n.url, { method: 'POST', headers: { 'content-type': 'application/json', ...(token && { authorization: `Bearer ${token}` }) }, body: JSON.stringify(noticeBody(n, probe)), signal: AbortSignal.timeout(10000) })).status;
|
|
1896
|
+
}
|
|
1897
|
+
catch {
|
|
1898
|
+
throw fail(`nothing answered at ${n.url}`, 'NOT_FOUND', n.muxr ? 'Start the muxr host on this machine, then run `sign-off-axi notify test` again' : 'Check the url: line, then run `sign-off-axi notify test` again');
|
|
1899
|
+
}
|
|
1900
|
+
if ([401, 403].includes(status))
|
|
1901
|
+
throw fail(`the receiver refused the token (HTTP ${status})`, 'VALIDATION_ERROR', n.muxr ? `Check that ${tilde(muxrTokenFile())} holds the token the muxr host expects` : `Check ${n.token_env}`);
|
|
1902
|
+
if (status >= 300)
|
|
1903
|
+
throw fail(`the receiver answered HTTP ${status}, so the test notice was not accepted`, 'VALIDATION_ERROR', 'Check the receiver is the notification endpoint');
|
|
1904
|
+
return toon({ notifications: 'on', test: 'sent', http: status }, ['Check the device for the "sign-off test" notice']);
|
|
1905
|
+
}
|
|
1797
1906
|
async function doctor(args) {
|
|
1798
1907
|
const { dir: folder, given } = repoDir('doctor', args), dir = given ? folder : setupFolderHere() ?? folder, so = join(dir, '.sign-off'), rerun = `sign-off-axi init ${shellPath(dir)}`;
|
|
1799
1908
|
const checks = [];
|
|
@@ -1861,8 +1970,9 @@ async function doctor(args) {
|
|
|
1861
1970
|
check('config', `board ${basename(board)}, port ${port}`);
|
|
1862
1971
|
else
|
|
1863
1972
|
check('config', `${basename(config)}: ${setup.problems.join('; ')}`, `Edit ${shellPath(config)} to hold "board: <name>.json", "port: <0-65535>" and "bind: <127.0.0.1 or a private address>" lines, such as board: decisions.json, port: 4410 and bind: 127.0.0.1, and hosts: only DNS names${setup.goodBoard ? '' : `, or delete it and run \`${rerun}\``}`);
|
|
1864
|
-
|
|
1865
|
-
|
|
1973
|
+
const why = setup.notify && tokenMissing(setup.notify);
|
|
1974
|
+
if (setup.notify && why)
|
|
1975
|
+
check('notify', `${why}, so no notice is sent`, setup.notify.muxr ? 'Update the muxr host so it writes its notify-token file, then run `sign-off-axi notify test`' : `Export ${setup.notify.token_env} with the receiver's token`);
|
|
1866
1976
|
// A hosts: name is only of use when it leads to the bind address.
|
|
1867
1977
|
const astray = [];
|
|
1868
1978
|
for (const h of setup.hosts) {
|
|
@@ -1987,11 +2097,12 @@ async function doctor(args) {
|
|
|
1987
2097
|
throw fail(`${bad.length} of ${checks.length} checks need a fix in ${tilde(dir)}`, 'NOT_FOUND', ...bad.map((c) => `${c.name}: ${c.ok}. ${c.fix}`));
|
|
1988
2098
|
return toon({ dir: tilde(dir), checks: checks.map(({ name, ok }) => ({ name, ok })) }, [`Run \`sign-off-axi open ${shellPath(board)}\` to show the board to the user`]);
|
|
1989
2099
|
}
|
|
1990
|
-
let file = '', evidenceRoot = '', bind = '127.0.0.1', hosts = [], onAnswer = [];
|
|
2100
|
+
let file = '', config = '', evidenceRoot = '', bind = '127.0.0.1', hosts = [], onAnswer = [];
|
|
1991
2101
|
async function serve(args) {
|
|
1992
2102
|
const parsed = parse('serve', args, BOARD_FLAGS), port = boardPort(parsed.named, parsed.values.port);
|
|
1993
2103
|
ready(parsed.file, parsed.on, parsed.values['evidence-root']);
|
|
1994
2104
|
bind = bindOf(parsed.named, parsed.values.bind);
|
|
2105
|
+
config = join(dirname(parsed.named), 'config.yaml');
|
|
1995
2106
|
({ hosts = [], onAnswer = [] } = setupOf(parsed.named) ?? {});
|
|
1996
2107
|
let warned = false;
|
|
1997
2108
|
const sweep = async (phase) => {
|
|
@@ -2064,6 +2175,7 @@ async function exportBoard(args) {
|
|
|
2064
2175
|
const parsed = parse('export', args, { out: { type: 'string' }, 'evidence-root': { type: 'string' } });
|
|
2065
2176
|
ready(parsed.file, parsed.on, parsed.values['evidence-root']);
|
|
2066
2177
|
[file, evidenceRoot] = [parsed.file, parsed.values['evidence-root'] ? resolve(parsed.values['evidence-root']) : ''];
|
|
2178
|
+
config = join(dirname(parsed.named), 'config.yaml');
|
|
2067
2179
|
guardReady = warmGuard();
|
|
2068
2180
|
await guardReady;
|
|
2069
2181
|
const { file: _, messages: _messages, annotations: _annotations, ...frozen } = state(), files = {}, q = encodeURIComponent;
|
|
@@ -2182,6 +2294,22 @@ const warmGuard = () => new Promise((done) => {
|
|
|
2182
2294
|
};
|
|
2183
2295
|
pass(true);
|
|
2184
2296
|
});
|
|
2297
|
+
/** How much agents may do without asking, from the leash: line of config.yaml: ask (wait on every ask), matters (take the default at the
|
|
2298
|
+
* silent-by time, the default) or act (take the recommended pick now and report it). With the name: line it is the page's only setting. */
|
|
2299
|
+
const LEASHES = ['ask', 'matters', 'act'];
|
|
2300
|
+
const configLine = (text, key) => new RegExp(`^[ \\t]*${key}[ \\t]*:([^#\\n]*)`, 'm').exec(text)?.[1].trim() || undefined;
|
|
2301
|
+
/** The user's name and leash; none for a board with no config.yaml beside it. */
|
|
2302
|
+
function profile() {
|
|
2303
|
+
let text;
|
|
2304
|
+
try {
|
|
2305
|
+
text = readFileSync(config, 'utf8');
|
|
2306
|
+
}
|
|
2307
|
+
catch {
|
|
2308
|
+
return {};
|
|
2309
|
+
}
|
|
2310
|
+
const leash = configLine(text, 'leash') ?? '';
|
|
2311
|
+
return { name: configLine(text, 'name')?.slice(0, 40), leash: LEASHES.includes(leash) ? leash : 'matters' };
|
|
2312
|
+
}
|
|
2185
2313
|
const state = () => {
|
|
2186
2314
|
reap();
|
|
2187
2315
|
// The page marks sent comments in annotations, so answers and rounds go without theirs: a read-only board or an export carries none.
|
|
@@ -2196,6 +2324,7 @@ const state = () => {
|
|
|
2196
2324
|
...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
|
|
2197
2325
|
readonly: readOnly(),
|
|
2198
2326
|
answers_only: answersOnly(),
|
|
2327
|
+
...profile(),
|
|
2199
2328
|
answers,
|
|
2200
2329
|
seen: Object.values(answers).filter(taken).map((a) => a.key),
|
|
2201
2330
|
rounds,
|
|
@@ -2372,7 +2501,7 @@ async function route(req) {
|
|
|
2372
2501
|
clearTimeout(timer);
|
|
2373
2502
|
return [200, state()];
|
|
2374
2503
|
}
|
|
2375
|
-
if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin'].includes(req.url ?? ''))
|
|
2504
|
+
if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin', '/api/leash'].includes(req.url ?? ''))
|
|
2376
2505
|
return [404, { error: 'Not found' }];
|
|
2377
2506
|
if (readOnly())
|
|
2378
2507
|
return [403, { error: 'This board is read-only' }];
|
|
@@ -2440,6 +2569,21 @@ async function route(req) {
|
|
|
2440
2569
|
return board(file).find((x) => x.key === key)?.answer ? [200, { state: state() }] : [502, { error: e.message, state: state() }];
|
|
2441
2570
|
}
|
|
2442
2571
|
}
|
|
2572
|
+
if (req.url === '/api/leash') {
|
|
2573
|
+
const { leash } = JSON.parse(body) ?? {};
|
|
2574
|
+
if (!LEASHES.includes(leash))
|
|
2575
|
+
return [400, { error: `Send a leash of ${LEASHES.join(', ')}` }];
|
|
2576
|
+
let text;
|
|
2577
|
+
try {
|
|
2578
|
+
text = readFileSync(config, 'utf8');
|
|
2579
|
+
}
|
|
2580
|
+
catch {
|
|
2581
|
+
return [409, { error: 'This board has no config.yaml to keep the leash in; run sign-off-axi init' }];
|
|
2582
|
+
}
|
|
2583
|
+
const line = /^[ \t]*leash[ \t]*:.*$/m;
|
|
2584
|
+
writeFileSync(config, line.test(text) ? text.replace(line, `leash: ${leash}`) : `${text.replace(/\n?$/, '\n')}leash: ${leash}\n`);
|
|
2585
|
+
return [200, state()];
|
|
2586
|
+
}
|
|
2443
2587
|
if (req.url === '/api/dismiss') {
|
|
2444
2588
|
const { keys, dismissed } = JSON.parse(body) ?? {};
|
|
2445
2589
|
if (!Array.isArray(keys) || !keys.length || keys.length > 200 || typeof dismissed !== 'boolean')
|
|
@@ -2447,8 +2591,6 @@ async function route(req) {
|
|
|
2447
2591
|
const have = new Map(board(file).map((x) => [x.key, x]));
|
|
2448
2592
|
if (!keys.every((k) => isKey(k) && have.has(k)))
|
|
2449
2593
|
return [404, { error: 'This item is no longer in the file' }];
|
|
2450
|
-
if (dismissed && keys.some((k) => have.get(k)?.type === 'plan' && !have.get(k)?.answer))
|
|
2451
|
-
return [400, { error: 'A plan waits until it is sent back, so it cannot be dismissed' }];
|
|
2452
2594
|
appendDismissed(file, [...new Set(keys)], dismissed);
|
|
2453
2595
|
return [200, state()];
|
|
2454
2596
|
}
|
|
@@ -2675,7 +2817,7 @@ await runAxiCli({
|
|
|
2675
2817
|
topLevelHelp: TOP_HELP,
|
|
2676
2818
|
getCommandHelp: (cmd) => HELP[cmd],
|
|
2677
2819
|
home: axi('list', list),
|
|
2678
|
-
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), doctor: axi('doctor', doctor), validate, help: ([name, ...rest]) => {
|
|
2820
|
+
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)), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
|
|
2679
2821
|
if (rest.length || (name && !HELP[name]))
|
|
2680
2822
|
throw fail(`unknown command or argument: ${[name, ...rest].join(' ')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi --help` to see the commands');
|
|
2681
2823
|
return name ? HELP[name] : TOP_HELP;
|
package/dist/contract.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The v1 communication contract (docs/contract.md): one strict, versioned shape per kind of message.
|
|
2
2
|
import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
3
3
|
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
|
-
import { isKey, MEDIA_MAX } from './store.js';
|
|
4
|
+
import { isKey, MEDIA_MAX, REPORT_FORMS } from './store.js';
|
|
5
5
|
/** A message that does not match: what is wrong, and the one line that says how to fix it. */
|
|
6
6
|
export class ContractError extends Error {
|
|
7
7
|
help;
|
|
@@ -15,12 +15,12 @@ export const SCHEMAS = { ask: 1, report: 2, evidence: 1, gate: 1, finding: 1, pl
|
|
|
15
15
|
export const KINDS = Object.keys(SCHEMAS);
|
|
16
16
|
const COMMON = ['contract', 'kind', 'schema', 'key', 'title', 'filer', 'link', 'about'];
|
|
17
17
|
const FIELDS = {
|
|
18
|
-
ask: [...COMMON, 'detail', 'why', 'options', 'recommend_value', 'recommend_reason', 'default_if_silent', 'silent_by', 'close', 'merges'],
|
|
19
|
-
report: [...COMMON, 'filed', 'group', 'decided', 'view', 'preview'],
|
|
18
|
+
ask: [...COMMON, 'detail', 'why', 'options', 'recommend_value', 'recommend_reason', 'default_if_silent', 'silent_by', 'close', 'merges', 'proof'],
|
|
19
|
+
report: [...COMMON, 'filed', 'group', 'decided', 'form', 'video', 'asked_by', 'view', 'preview'],
|
|
20
20
|
evidence: [...COMMON, 'media', 'run'],
|
|
21
21
|
gate: [...COMMON, 'run', 'options', 'recommend_value', 'close'],
|
|
22
22
|
finding: [...COMMON, 'cause', 'incidents', 'days', 'evidence_ids', 'change_kind', 'proposal', 'options', 'recommend_value'],
|
|
23
|
-
plan: [...COMMON, 'sections', 'decisions', 'steps', 'view'],
|
|
23
|
+
plan: [...COMMON, 'project', 'sections', 'decisions', 'steps', 'view'],
|
|
24
24
|
design: [...COMMON, 'group', 'filed', 'status', 'preview', 'journeys', 'options', 'recommend'],
|
|
25
25
|
};
|
|
26
26
|
/** A done-done run: its id, full commit and the four Checks lines. */
|
|
@@ -82,7 +82,7 @@ const show = (v) => JSON.stringify(v);
|
|
|
82
82
|
/** A field name as it reads inside a sentence: escaped, so a newline or control character shows as \n or \u001b. */
|
|
83
83
|
const escaped = (v) => show(v).slice(1, -1);
|
|
84
84
|
/** The raise flag each question field fills: a refusal of a flag post names that flag, not the field. */
|
|
85
|
-
const FLAG_OF = { why: 'why', about: 'about', detail: 'detail', title: 'title', default_if_silent: 'default', recommend_reason: 'recommend-reason', recommend_value: 'recommend', silent_by: 'silent-by', merges: 'merge', options: 'option', label: 'option', value: 'option', outcome: 'outcome' };
|
|
85
|
+
const FLAG_OF = { why: 'why', about: 'about', detail: 'detail', title: 'title', default_if_silent: 'default', recommend_reason: 'recommend-reason', recommend_value: 'recommend', silent_by: 'silent-by', merges: 'merge', proof: 'proof', options: 'option', label: 'option', value: 'option', outcome: 'outcome' };
|
|
86
86
|
/** The flag a field fills: options[1].outcome is --outcome, merges[0] is --merge. */
|
|
87
87
|
const flagOf = (field) => { const f = field.replace(/^options\[\d+\]\./, '').replace(/\[\d+\]$/, ''); return `--${FLAG_OF[f] ?? f}`; };
|
|
88
88
|
/** A field as a refusal names it: the JSON field, or the raise flag that fills it. */
|
|
@@ -249,6 +249,12 @@ export function checkMessage(m, want, flags = false) {
|
|
|
249
249
|
text('group', m.group, 40, 'the section it belongs to, in 40 characters or fewer, or leave it out');
|
|
250
250
|
if (Object.hasOwn(m, 'decided'))
|
|
251
251
|
text('decided', m.decided, 200, 'what was decided, in 200 characters or fewer, or leave it out');
|
|
252
|
+
if (Object.hasOwn(m, 'form') && !REPORT_FORMS.includes(m.form))
|
|
253
|
+
throw bad('form', `${show(m.form)} is not one of ${REPORT_FORMS.join(', ')}`, `Set "form" to ${REPORT_FORMS.join(', ')}, or leave it out`);
|
|
254
|
+
if (Object.hasOwn(m, 'video'))
|
|
255
|
+
text('video', m.video, 10, 'the video length such as "0:28", in 10 characters or fewer, or leave it out');
|
|
256
|
+
if (Object.hasOwn(m, 'asked_by'))
|
|
257
|
+
text('asked_by', m.asked_by, 40, 'who asked for it, such as "You asked" or "Docs agent", in 40 characters or fewer, or leave it out');
|
|
252
258
|
// A report is a page to tap through and a picture of it for the card: text alone is a status note, not a report.
|
|
253
259
|
const need = 'a report is a page to tap through, not text';
|
|
254
260
|
page('view', /\.html$/i, '.html', '"mocks/<key>/index.html"', need);
|
|
@@ -309,6 +315,8 @@ export function checkMessage(m, want, flags = false) {
|
|
|
309
315
|
}
|
|
310
316
|
// A plan is sections to approve or change, decisions with a recommended pick, steps to tap through, and where it goes as a diagram.
|
|
311
317
|
if (m.kind === 'plan') {
|
|
318
|
+
if (Object.hasOwn(m, 'project'))
|
|
319
|
+
text('project', m.project, 40, 'the project name, in 40 characters or fewer, or leave it out');
|
|
312
320
|
const ids = list('sections', 1, 8, ['id', 'heading', 'body'], '{"id":"goals","heading":"Goals and non-goals","body":"<what it does and does not do>"}').map((o, n) => {
|
|
313
321
|
if (!(typeof o.id === 'string' && /^[a-z0-9-]{1,40}$/.test(o.id)))
|
|
314
322
|
throw bad(`sections[${n}].id`, 'must be 1-40 of a-z 0-9 -', 'Set "id" to a short slug such as "goals"');
|
|
@@ -319,7 +327,7 @@ export function checkMessage(m, want, flags = false) {
|
|
|
319
327
|
const twice = ids.findIndex((id, n) => ids.indexOf(id) < n);
|
|
320
328
|
if (twice >= 0)
|
|
321
329
|
throw bad(`sections[${twice}].id`, `repeats ${ids[twice]}`, 'Give every section its own id');
|
|
322
|
-
list('decisions', 0, 5, ['key', 'question', 'why', 'options', 'recommend'], '{"key":"store","question":"Keep plans in the board file?","why":"...","options":[...],"recommend":"yes"}').forEach((o, n, all) => {
|
|
330
|
+
list('decisions', 0, 5, ['key', 'question', 'why', 'reason', 'after', 'options', 'recommend'], '{"key":"store","question":"Keep plans in the board file?","why":"...","options":[...],"recommend":"yes"}').forEach((o, n, all) => {
|
|
323
331
|
const at = `decisions[${n}]`;
|
|
324
332
|
if (!isKey(o.key))
|
|
325
333
|
throw bad(`${at}.key`, 'must be 1-128 of A-Z a-z 0-9 . _ -', 'Set "key" to a short id such as "store"');
|
|
@@ -333,13 +341,21 @@ export function checkMessage(m, want, flags = false) {
|
|
|
333
341
|
if (!/[\p{L}\p{N}]/u.test(q) || q.split(/\s+/).length > 12)
|
|
334
342
|
throw bad(`${at}.question`, 'must be one question of 12 words or fewer, ending in a question mark', 'Ask it in 12 words or fewer, ending in a question mark; put the rest in "why"');
|
|
335
343
|
text(`${at}.why`, o.why, 500, 'why it matters, in 500 characters or fewer');
|
|
344
|
+
if (Object.hasOwn(o, 'reason'))
|
|
345
|
+
text(`${at}.reason`, o.reason, 300, 'why the recommended option, in 300 characters or fewer, or leave it out');
|
|
346
|
+
if (Object.hasOwn(o, 'after') && !ids.includes(o.after))
|
|
347
|
+
throw bad(`${at}.after`, `must be the id of one of the plan's sections`, `Set "after" to one of: ${ids.join(', ')}`);
|
|
336
348
|
if (!Array.isArray(o.options) || o.options.length < 2 || o.options.length > 6)
|
|
337
349
|
throw bad(`${at}.options`, Object.hasOwn(o, 'options') ? 'must list 2 to 6 options' : 'is required', 'Set "options" to 2-6 objects such as {"value":"yes","label":"Yes","outcome":"Plans live in the board file"}');
|
|
338
350
|
checkOptions(`${at}.options`, o.options, 'plan');
|
|
339
351
|
recommend(`${at}.recommend`, o, o.options);
|
|
340
352
|
});
|
|
341
353
|
if (Object.hasOwn(m, 'steps'))
|
|
342
|
-
list('steps', 2, 12, ['title'], '{"title":"The agent posts the plan"}').forEach((o, n) =>
|
|
354
|
+
list('steps', 2, 12, ['title', 'text'], '{"title":"The agent posts the plan"}').forEach((o, n) => {
|
|
355
|
+
text(`steps[${n}].title`, o.title, 80, 'the step, in 80 characters or fewer');
|
|
356
|
+
if (Object.hasOwn(o, 'text'))
|
|
357
|
+
text(`steps[${n}].text`, o.text, 300, 'what happens in this step, in 300 characters or fewer, or leave it out');
|
|
358
|
+
});
|
|
343
359
|
page('view', /\.html$/i, '.html', '"mocks/<key>/index.html"', 'a plan shows where it goes as a diagram on a page', 'Set "view" to "mocks/<key>/index.html", a path from the board file\'s folder');
|
|
344
360
|
return { kind: m.kind, schema: m.schema };
|
|
345
361
|
}
|
|
@@ -393,6 +409,12 @@ export function checkMessage(m, want, flags = false) {
|
|
|
393
409
|
throw bad(`merges[${n}]`, `repeats ${k}`, 'List each key once');
|
|
394
410
|
});
|
|
395
411
|
}
|
|
412
|
+
if (Object.hasOwn(m, 'proof')) {
|
|
413
|
+
if (!isKey(m.proof))
|
|
414
|
+
throw bad('proof', 'must be 1-128 of A-Z a-z 0-9 . _ -', `Set ${named('proof', flags)} to the key of an evidence update on the board that this ask rests on, such as ${flags ? '--proof update-3' : '"update-3"'}`);
|
|
415
|
+
if (m.proof === m.key)
|
|
416
|
+
throw bad('proof', 'is this ask\'s own key', 'Name the evidence update the ask rests on, not the ask');
|
|
417
|
+
}
|
|
396
418
|
if (ask && flags)
|
|
397
419
|
question(m, bad, flags);
|
|
398
420
|
// A gate's run is its own rule (section 5), so it comes after the field rules of section 4.
|
|
@@ -554,9 +576,10 @@ export function reportPage(m, base) {
|
|
|
554
576
|
throw bad(`${page} has no dark mode`, 'Add a @media (prefers-color-scheme: dark) block, or start again from report init');
|
|
555
577
|
if (!/<(svg|img|canvas|video)\b/i.test(html))
|
|
556
578
|
throw bad(`${page} has no picture or diagram`, 'Draw the idea: add an <svg> or <img> under the answer and a diagram under it');
|
|
557
|
-
|
|
579
|
+
// A plan's page is its diagram: the steps and the details are on the board, so it needs nothing to tap and no fold.
|
|
580
|
+
if (m.kind !== 'plan' && !/<(button|input|select)\b/i.test(html))
|
|
558
581
|
throw bad(`${page} has nothing to tap or slide, so it is text, not a report`, 'Add a demo the reader can tap through, such as the scaffold\'s steps with Back and Next');
|
|
559
|
-
if (!/<details\b/i.test(html))
|
|
582
|
+
if (m.kind !== 'plan' && !/<details\b/i.test(html))
|
|
560
583
|
throw bad(`${page} has no folded detail`, 'Fold the detail into <details><summary>Details</summary>...</details> at the end');
|
|
561
584
|
if (/<(script|img|iframe|link|video|audio|source)\b[^>]*\b(src|href)\s*=\s*["']?\s*(https?:)?\/\//i.test(html))
|
|
562
585
|
throw bad(`${page} loads something from the web, which the board blocks`, 'Put scripts, styles and pictures in the page or beside it');
|