sign-off-axi 0.4.7 → 0.4.8
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 +4 -1
- package/dist/cli.js +136 -23
- 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 +762 -316
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 passed proof only: an update whose linked run passed. A plain update like this links no run, so it waits in **Waiting on you** instead, as does a failed or unchecked run, each with its honest result and its verdicts and Dismiss, so the agent still gets an answer. Passed 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
|
|
@@ -354,7 +356,8 @@ cannot block can be woken instead: an `on_answer:` line in the
|
|
|
354
356
|
`config.yaml` beside the board names a command the board runs for every
|
|
355
357
|
answer. A `notify:` block in that config can send an HTTP POST when a card
|
|
356
358
|
enters Waiting on you, with its bearer token read from a named environment
|
|
357
|
-
variable
|
|
359
|
+
variable, or, with `receiver: muxr`, from the muxr host's `notify-token`
|
|
360
|
+
file; `sign-off-axi notify test` sends a test notice. The full reference, with the worked example, is in
|
|
358
361
|
[docs/commands.md](docs/commands.md).
|
|
359
362
|
|
|
360
363
|
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[18]:
|
|
32
|
+
(none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, report, signin, 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:
|
|
@@ -62,7 +62,7 @@ examples:
|
|
|
62
62
|
sign-off-axi
|
|
63
63
|
sign-off-axi list ./decisions.json --full
|
|
64
64
|
`,
|
|
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>]
|
|
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>]... [--proof <key>] [--close done|release] [--filer <id>]
|
|
66
66
|
sign-off-axi raise [board.json] --from <decisions.json> [--filer <id>]
|
|
67
67
|
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
68
|
raise is the alias for post --type decision: same flags, same output.
|
|
@@ -76,6 +76,7 @@ Every question meets the question standard (docs/contract.md, "Asking a good que
|
|
|
76
76
|
yes and no are labelled Yes and No; a question never takes Done, OK or Not now buttons.
|
|
77
77
|
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
78
|
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.
|
|
79
|
+
--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
80
|
The user answers with one tap and can undo it for a few seconds.
|
|
80
81
|
--from adds every decision in a decisions file: ${FORMAT}
|
|
81
82
|
Raising the same question again is a no-op that reports its status; a different question under a used key is refused.
|
|
@@ -286,6 +287,19 @@ It opens from disk with no server and makes no network request. --out defaults t
|
|
|
286
287
|
examples:
|
|
287
288
|
sign-off-axi export
|
|
288
289
|
sign-off-axi export ./decisions.json --out ~/board-12-oct.html
|
|
290
|
+
`,
|
|
291
|
+
notify: `usage: sign-off-axi notify [check|test] [dir]
|
|
292
|
+
Device notifications are off until the config.yaml beside the board has a notify: block. For the muxr receiver it is:
|
|
293
|
+
notify:
|
|
294
|
+
receiver: muxr
|
|
295
|
+
link_base: https://my-machine.my-tailnet.ts.net:4410/ (https or a tailnet address, so the link opens on the iPad)
|
|
296
|
+
url: http://127.0.0.1:8793/v1/notify (optional: this is the default)
|
|
297
|
+
The token is read at send time from the file <MUXR_HOME>/notify-token (default ~/.muxr/notify-token), never kept in the config.
|
|
298
|
+
check (the default): says whether notifications are off, misconfigured, waiting on the token file, or ready. It sends nothing.
|
|
299
|
+
test: sends one real "sign-off test" notice now and says plainly what happened (sent, no token file, token refused, receiver not running).
|
|
300
|
+
examples:
|
|
301
|
+
sign-off-axi notify
|
|
302
|
+
sign-off-axi notify test
|
|
289
303
|
`,
|
|
290
304
|
doctor: `usage: sign-off-axi doctor [dir]
|
|
291
305
|
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 +337,7 @@ function fits(title, key) {
|
|
|
323
337
|
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
338
|
}
|
|
325
339
|
/** 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'];
|
|
340
|
+
const ASK_FLAGS = ['why', 'outcome', 'recommend-reason', 'silent-by', 'merge', 'proof'];
|
|
327
341
|
const MANY = ['option', 'outcome', 'merge'];
|
|
328
342
|
/** Board types that are contract kinds: a decision (the default type) is an ask. */
|
|
329
343
|
const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report', 'plan', 'design'];
|
|
@@ -1304,7 +1318,7 @@ function buildIncoming(values, extra, filer, file) {
|
|
|
1304
1318
|
try {
|
|
1305
1319
|
const { title, key = slug(title), about, detail, why, recommend: recommend_value, default: default_if_silent, close } = values;
|
|
1306
1320
|
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 };
|
|
1321
|
+
const told = ask && { why, recommend_reason: values['recommend-reason'], silent_by: values['silent-by'], merges: values.merge, proof: values.proof };
|
|
1308
1322
|
writeFileSync(source, JSON.stringify({ schema: 2, items: [{ key, title, about, detail, ...told, options, recommend_value, default_if_silent, close, ...extra, filer }] }));
|
|
1309
1323
|
const incoming = loadDecisions(source, board), raw = readItems(source).items;
|
|
1310
1324
|
if (!ask)
|
|
@@ -1330,6 +1344,9 @@ const canon = (v) => JSON.stringify(v, (_, x) => (x && typeof x === 'object' &&
|
|
|
1330
1344
|
/** 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
1345
|
* 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
1346
|
function checkMerges(incoming, resulting, current) {
|
|
1347
|
+
for (const d of incoming)
|
|
1348
|
+
if (d.proof && resulting.find((x) => x.key === d.proof)?.type !== 'evidence')
|
|
1349
|
+
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
1350
|
const byKey = new Map(resulting.map((d) => [d.key, d])), before = new Map(current.map((c) => [c.key, c]));
|
|
1334
1351
|
const closed = (key) => !!(before.get(key)?.answer || before.get(key)?.dismissed);
|
|
1335
1352
|
const holder = (key, self) => resulting.find((x) => x.key !== self && !before.get(x.key)?.answer && x.merges?.includes(key));
|
|
@@ -1617,11 +1634,11 @@ function readSetup(so) {
|
|
|
1617
1634
|
readFileSync(join(so, 'config.yaml'), 'utf8').split(/\r?\n/).forEach((l, n) => {
|
|
1618
1635
|
// The notify block's indented lines keep their # , and } characters: only a # after a space starts a comment.
|
|
1619
1636
|
if (inNotify && (!l.trim() || /^\s/.test(l))) {
|
|
1620
|
-
const text = l.replace(/(^|\s+)#.*$/, '').trim(), field = text.match(/^(url|token_env|link_base)\s*:\s*(.+)$/);
|
|
1637
|
+
const text = l.replace(/(^|\s+)#.*$/, '').trim(), field = text.match(/^(url|token_env|link_base|receiver)\s*:\s*(.+)$/);
|
|
1621
1638
|
if (field)
|
|
1622
1639
|
notify[field[1]] = field[2].replace(/^(['"])(.*)\1$/, '$2');
|
|
1623
1640
|
else if (text)
|
|
1624
|
-
problems.push(`line ${n + 1} is not one "url", "token_env" or "link_base" line under notify`);
|
|
1641
|
+
problems.push(`line ${n + 1} is not one "receiver", "url", "token_env" or "link_base" line under notify`);
|
|
1625
1642
|
return;
|
|
1626
1643
|
}
|
|
1627
1644
|
inNotify = false;
|
|
@@ -1661,15 +1678,36 @@ function readSetup(so) {
|
|
|
1661
1678
|
catch {
|
|
1662
1679
|
return undefined;
|
|
1663
1680
|
} };
|
|
1681
|
+
const muxr = notify.receiver?.toLowerCase() === 'muxr';
|
|
1682
|
+
if (notifyBlock && notify.receiver && !muxr)
|
|
1683
|
+
problems.push('notify receiver must be muxr, or leave the receiver line out');
|
|
1684
|
+
if (muxr)
|
|
1685
|
+
notify.url ??= MUXR_URL;
|
|
1664
1686
|
const sendTo = parsed(notify.url), base = parsed(notify.link_base);
|
|
1665
1687
|
const usable = !!sendTo && !!base && (sendTo.protocol === 'https:' || (sendTo.protocol === 'http:' && (loopback(sendTo.hostname.replace(/^\[|\]$/g, '')) || sendTo.hostname === 'localhost')))
|
|
1666
1688
|
&& ['http:', 'https:'].includes(base.protocol) && (notify.token_env === undefined || /^[A-Za-z_]\w*$/.test(notify.token_env));
|
|
1667
1689
|
if (notifyBlock && !usable)
|
|
1668
1690
|
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');
|
|
1691
|
+
// 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.
|
|
1692
|
+
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)));
|
|
1693
|
+
if (notifyBlock && usable && muxr && !reachable)
|
|
1694
|
+
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
1695
|
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 };
|
|
1696
|
+
notify: notifyBlock && usable && (!muxr || reachable) ? { url: notify.url, token_env: notify.token_env, link_base: notify.link_base, muxr } : undefined };
|
|
1671
1697
|
}
|
|
1672
|
-
const
|
|
1698
|
+
const MUXR_URL = 'http://127.0.0.1:8793/v1/notify';
|
|
1699
|
+
/** The muxr receiver's token file, read at send time and never stored: <MUXR_HOME>/notify-token, MUXR_HOME defaulting to ~/.muxr. */
|
|
1700
|
+
const muxrTokenFile = () => join(process.env.MUXR_HOME || join(homedir(), '.muxr'), 'notify-token');
|
|
1701
|
+
/** Why a notice cannot be sent yet, in plain words; undefined when its token is there. */
|
|
1702
|
+
const tokenMissing = (n) => n.muxr
|
|
1703
|
+
? (() => { try {
|
|
1704
|
+
return readFileSync(muxrTokenFile(), 'utf8').trim() ? undefined : `${tilde(muxrTokenFile())} is empty`;
|
|
1705
|
+
}
|
|
1706
|
+
catch {
|
|
1707
|
+
return `${tilde(muxrTokenFile())} is not there yet (the muxr host writes it when it updates)`;
|
|
1708
|
+
} })()
|
|
1709
|
+
: n.token_env && !process.env[n.token_env] ? `${n.token_env} is not set` : undefined;
|
|
1710
|
+
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')})();`;
|
|
1673
1711
|
/** The cards waiting on the user before a write: a card waiting after the write but not before it has entered Waiting on you. */
|
|
1674
1712
|
function waitingKeys(file) {
|
|
1675
1713
|
try {
|
|
@@ -1685,7 +1723,7 @@ function waitingKeys(file) {
|
|
|
1685
1723
|
function notifyEntered(file, named, before) {
|
|
1686
1724
|
try {
|
|
1687
1725
|
const so = dirname(named), notify = before ? readSetup(so).notify : undefined;
|
|
1688
|
-
if (!before || !notify || (notify
|
|
1726
|
+
if (!before || !notify || tokenMissing(notify))
|
|
1689
1727
|
return;
|
|
1690
1728
|
const { items } = readItems(file), fresh = board(file, () => { }).filter((d) => waits(d) && !before.has(d.key) && !items.some((i) => i?.key === d.key && i.announced));
|
|
1691
1729
|
if (!fresh.length)
|
|
@@ -1700,13 +1738,18 @@ function notifyEntered(file, named, before) {
|
|
|
1700
1738
|
}
|
|
1701
1739
|
catch { }
|
|
1702
1740
|
}
|
|
1741
|
+
/** The JSON one card sends: sign-off's own shape, or muxr's {title (1-80 chars), body, url, id}. */
|
|
1742
|
+
function noticeBody(notify, d) {
|
|
1743
|
+
const kind = d.kind ?? { decision: 'ask', explainer: d.options.length ? 'ask' : 'report' }[d.type] ?? d.type;
|
|
1744
|
+
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)}`;
|
|
1745
|
+
if (notify.muxr)
|
|
1746
|
+
return { title: [...d.title].slice(0, 80).join('') || d.key, body: text, url, id: d.key };
|
|
1747
|
+
return { v: 1, source: 'sign-off', id: d.key, kind: ['ask', 'plan', 'design', 'evidence', 'report'].includes(kind) ? kind : 'ask',
|
|
1748
|
+
title: [...d.title].slice(0, 120).join(''), text, url, at: d.filed ?? new Date().toISOString() };
|
|
1749
|
+
}
|
|
1703
1750
|
function sendNotice(so, notify, d) {
|
|
1704
1751
|
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') } });
|
|
1752
|
+
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
1753
|
child.on('error', () => warn(`notification sender could not start for card ${d.key}`));
|
|
1711
1754
|
child.unref();
|
|
1712
1755
|
}
|
|
@@ -1722,7 +1765,7 @@ function init(args) {
|
|
|
1722
1765
|
}
|
|
1723
1766
|
catch { }
|
|
1724
1767
|
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`],
|
|
1768
|
+
[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
1769
|
[board, `{"schema": 2, "items": []}\n`],
|
|
1727
1770
|
[join(so, '.gitignore'), GITIGNORE],
|
|
1728
1771
|
];
|
|
@@ -1794,6 +1837,43 @@ function report(args) {
|
|
|
1794
1837
|
: `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
1838
|
]);
|
|
1796
1839
|
}
|
|
1840
|
+
/** `notify check|test`: whether device notifications can work from this setup, and one real test notice. Never prints the token. */
|
|
1841
|
+
async function notify(args) {
|
|
1842
|
+
const mode = ['check', 'test'].includes(args[0]) ? args.shift() : 'check';
|
|
1843
|
+
const { dir: folder, given } = repoDir('notify', args), dir = given ? folder : setupFolderHere() ?? folder, so = join(dir, '.sign-off');
|
|
1844
|
+
let setup;
|
|
1845
|
+
try {
|
|
1846
|
+
setup = readSetup(so);
|
|
1847
|
+
}
|
|
1848
|
+
catch {
|
|
1849
|
+
throw fail(`no readable config.yaml in ${tilde(so)}`, 'NOT_FOUND', `Run \`sign-off-axi init ${shellPath(dir)}\``);
|
|
1850
|
+
}
|
|
1851
|
+
const config = shellPath(join(so, 'config.yaml'));
|
|
1852
|
+
if (setup.problems.some((p) => p.startsWith('notify')))
|
|
1853
|
+
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`);
|
|
1854
|
+
const n = setup.notify;
|
|
1855
|
+
if (!n)
|
|
1856
|
+
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}`]);
|
|
1857
|
+
const why = tokenMissing(n);
|
|
1858
|
+
if (why)
|
|
1859
|
+
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`);
|
|
1860
|
+
if (mode === 'check')
|
|
1861
|
+
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']);
|
|
1862
|
+
const probe = { key: 'sign-off-test', title: 'sign-off test', about: 'Device notifications from sign-off work.', type: 'decision', options: [] };
|
|
1863
|
+
let status;
|
|
1864
|
+
try {
|
|
1865
|
+
const token = n.muxr ? readFileSync(muxrTokenFile(), 'utf8').trim() : n.token_env ? process.env[n.token_env] : undefined;
|
|
1866
|
+
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;
|
|
1867
|
+
}
|
|
1868
|
+
catch {
|
|
1869
|
+
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');
|
|
1870
|
+
}
|
|
1871
|
+
if ([401, 403].includes(status))
|
|
1872
|
+
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}`);
|
|
1873
|
+
if (status >= 300)
|
|
1874
|
+
throw fail(`the receiver answered HTTP ${status}, so the test notice was not accepted`, 'VALIDATION_ERROR', 'Check the receiver is the notification endpoint');
|
|
1875
|
+
return toon({ notifications: 'on', test: 'sent', http: status }, ['Check the device for the "sign-off test" notice']);
|
|
1876
|
+
}
|
|
1797
1877
|
async function doctor(args) {
|
|
1798
1878
|
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
1879
|
const checks = [];
|
|
@@ -1861,8 +1941,9 @@ async function doctor(args) {
|
|
|
1861
1941
|
check('config', `board ${basename(board)}, port ${port}`);
|
|
1862
1942
|
else
|
|
1863
1943
|
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
|
-
|
|
1944
|
+
const why = setup.notify && tokenMissing(setup.notify);
|
|
1945
|
+
if (setup.notify && why)
|
|
1946
|
+
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
1947
|
// A hosts: name is only of use when it leads to the bind address.
|
|
1867
1948
|
const astray = [];
|
|
1868
1949
|
for (const h of setup.hosts) {
|
|
@@ -1987,11 +2068,12 @@ async function doctor(args) {
|
|
|
1987
2068
|
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
2069
|
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
2070
|
}
|
|
1990
|
-
let file = '', evidenceRoot = '', bind = '127.0.0.1', hosts = [], onAnswer = [];
|
|
2071
|
+
let file = '', config = '', evidenceRoot = '', bind = '127.0.0.1', hosts = [], onAnswer = [];
|
|
1991
2072
|
async function serve(args) {
|
|
1992
2073
|
const parsed = parse('serve', args, BOARD_FLAGS), port = boardPort(parsed.named, parsed.values.port);
|
|
1993
2074
|
ready(parsed.file, parsed.on, parsed.values['evidence-root']);
|
|
1994
2075
|
bind = bindOf(parsed.named, parsed.values.bind);
|
|
2076
|
+
config = join(dirname(parsed.named), 'config.yaml');
|
|
1995
2077
|
({ hosts = [], onAnswer = [] } = setupOf(parsed.named) ?? {});
|
|
1996
2078
|
let warned = false;
|
|
1997
2079
|
const sweep = async (phase) => {
|
|
@@ -2064,6 +2146,7 @@ async function exportBoard(args) {
|
|
|
2064
2146
|
const parsed = parse('export', args, { out: { type: 'string' }, 'evidence-root': { type: 'string' } });
|
|
2065
2147
|
ready(parsed.file, parsed.on, parsed.values['evidence-root']);
|
|
2066
2148
|
[file, evidenceRoot] = [parsed.file, parsed.values['evidence-root'] ? resolve(parsed.values['evidence-root']) : ''];
|
|
2149
|
+
config = join(dirname(parsed.named), 'config.yaml');
|
|
2067
2150
|
guardReady = warmGuard();
|
|
2068
2151
|
await guardReady;
|
|
2069
2152
|
const { file: _, messages: _messages, annotations: _annotations, ...frozen } = state(), files = {}, q = encodeURIComponent;
|
|
@@ -2182,6 +2265,22 @@ const warmGuard = () => new Promise((done) => {
|
|
|
2182
2265
|
};
|
|
2183
2266
|
pass(true);
|
|
2184
2267
|
});
|
|
2268
|
+
/** 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
|
|
2269
|
+
* 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. */
|
|
2270
|
+
const LEASHES = ['ask', 'matters', 'act'];
|
|
2271
|
+
const configLine = (text, key) => new RegExp(`^[ \\t]*${key}[ \\t]*:([^#\\n]*)`, 'm').exec(text)?.[1].trim() || undefined;
|
|
2272
|
+
/** The user's name and leash; none for a board with no config.yaml beside it. */
|
|
2273
|
+
function profile() {
|
|
2274
|
+
let text;
|
|
2275
|
+
try {
|
|
2276
|
+
text = readFileSync(config, 'utf8');
|
|
2277
|
+
}
|
|
2278
|
+
catch {
|
|
2279
|
+
return {};
|
|
2280
|
+
}
|
|
2281
|
+
const leash = configLine(text, 'leash') ?? '';
|
|
2282
|
+
return { name: configLine(text, 'name')?.slice(0, 40), leash: LEASHES.includes(leash) ? leash : 'matters' };
|
|
2283
|
+
}
|
|
2185
2284
|
const state = () => {
|
|
2186
2285
|
reap();
|
|
2187
2286
|
// 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 +2295,7 @@ const state = () => {
|
|
|
2196
2295
|
...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
|
|
2197
2296
|
readonly: readOnly(),
|
|
2198
2297
|
answers_only: answersOnly(),
|
|
2298
|
+
...profile(),
|
|
2199
2299
|
answers,
|
|
2200
2300
|
seen: Object.values(answers).filter(taken).map((a) => a.key),
|
|
2201
2301
|
rounds,
|
|
@@ -2372,7 +2472,7 @@ async function route(req) {
|
|
|
2372
2472
|
clearTimeout(timer);
|
|
2373
2473
|
return [200, state()];
|
|
2374
2474
|
}
|
|
2375
|
-
if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin'].includes(req.url ?? ''))
|
|
2475
|
+
if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin', '/api/leash'].includes(req.url ?? ''))
|
|
2376
2476
|
return [404, { error: 'Not found' }];
|
|
2377
2477
|
if (readOnly())
|
|
2378
2478
|
return [403, { error: 'This board is read-only' }];
|
|
@@ -2440,6 +2540,21 @@ async function route(req) {
|
|
|
2440
2540
|
return board(file).find((x) => x.key === key)?.answer ? [200, { state: state() }] : [502, { error: e.message, state: state() }];
|
|
2441
2541
|
}
|
|
2442
2542
|
}
|
|
2543
|
+
if (req.url === '/api/leash') {
|
|
2544
|
+
const { leash } = JSON.parse(body) ?? {};
|
|
2545
|
+
if (!LEASHES.includes(leash))
|
|
2546
|
+
return [400, { error: `Send a leash of ${LEASHES.join(', ')}` }];
|
|
2547
|
+
let text;
|
|
2548
|
+
try {
|
|
2549
|
+
text = readFileSync(config, 'utf8');
|
|
2550
|
+
}
|
|
2551
|
+
catch {
|
|
2552
|
+
return [409, { error: 'This board has no config.yaml to keep the leash in; run sign-off-axi init' }];
|
|
2553
|
+
}
|
|
2554
|
+
const line = /^[ \t]*leash[ \t]*:.*$/m;
|
|
2555
|
+
writeFileSync(config, line.test(text) ? text.replace(line, `leash: ${leash}`) : `${text.replace(/\n?$/, '\n')}leash: ${leash}\n`);
|
|
2556
|
+
return [200, state()];
|
|
2557
|
+
}
|
|
2443
2558
|
if (req.url === '/api/dismiss') {
|
|
2444
2559
|
const { keys, dismissed } = JSON.parse(body) ?? {};
|
|
2445
2560
|
if (!Array.isArray(keys) || !keys.length || keys.length > 200 || typeof dismissed !== 'boolean')
|
|
@@ -2447,8 +2562,6 @@ async function route(req) {
|
|
|
2447
2562
|
const have = new Map(board(file).map((x) => [x.key, x]));
|
|
2448
2563
|
if (!keys.every((k) => isKey(k) && have.has(k)))
|
|
2449
2564
|
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
2565
|
appendDismissed(file, [...new Set(keys)], dismissed);
|
|
2453
2566
|
return [200, state()];
|
|
2454
2567
|
}
|
|
@@ -2675,7 +2788,7 @@ await runAxiCli({
|
|
|
2675
2788
|
topLevelHelp: TOP_HELP,
|
|
2676
2789
|
getCommandHelp: (cmd) => HELP[cmd],
|
|
2677
2790
|
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]) => {
|
|
2791
|
+
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), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
|
|
2679
2792
|
if (rest.length || (name && !HELP[name]))
|
|
2680
2793
|
throw fail(`unknown command or argument: ${[name, ...rest].join(' ')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi --help` to see the commands');
|
|
2681
2794
|
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');
|
package/dist/store.js
CHANGED
|
@@ -29,6 +29,8 @@ const isText = (v) => typeof v === 'string';
|
|
|
29
29
|
export const isKey = (v) => isText(v) && /^[A-Za-z0-9._-]{1,128}$/.test(v);
|
|
30
30
|
const ONE_OF = { close: ['done', 'release'], area: ['waiting', 'evidence', 'reports', 'answered'] };
|
|
31
31
|
/** What a step uses or can stop on: an AI model, a store, a gate, a hard fail. */
|
|
32
|
+
/** What a report explains with: the board draws one line icon per form. */
|
|
33
|
+
export const REPORT_FORMS = ['diagram', 'chart', 'before-after'];
|
|
32
34
|
const MARKS = ['ai', 'store', 'gate', 'fail'];
|
|
33
35
|
export const NOTE_MAX = 500;
|
|
34
36
|
const TRADEOFF_MAX = 1000;
|
|
@@ -87,6 +89,8 @@ export function loadDecisions(path, board) {
|
|
|
87
89
|
throw bad(f, 'must be text');
|
|
88
90
|
if (d.merges !== undefined && !(Array.isArray(d.merges) && d.merges.every(isKey)))
|
|
89
91
|
throw bad('merges', 'must list keys of 1-128 of A-Z a-z 0-9 . _ -');
|
|
92
|
+
if (d.proof !== undefined && !isKey(d.proof))
|
|
93
|
+
throw bad('proof', 'must be a key of 1-128 of A-Z a-z 0-9 . _ -');
|
|
90
94
|
if (d.evidence !== undefined && (!d.evidence || !/^[A-Za-z0-9._-]{1,128}$/.test(d.evidence.run_id ?? '')
|
|
91
95
|
|| !/^[a-f0-9]{40}$/.test(d.evidence.commit ?? '')
|
|
92
96
|
|| ['outcome', 'repro', 'proof', 'evidence'].some((f) => !isText(d.evidence[f]) || !d.evidence[f].trim() || [...d.evidence[f]].length > 1000))) {
|
|
@@ -120,6 +124,12 @@ export function loadDecisions(path, board) {
|
|
|
120
124
|
throw bad('group', 'must be text of 40 characters or fewer');
|
|
121
125
|
if (d.filed !== undefined && !(isText(d.filed) && /^\d{4}-\d{2}-\d{2}T/.test(d.filed) && !Number.isNaN(Date.parse(d.filed))))
|
|
122
126
|
throw bad('filed', 'must be an ISO 8601 date-time such as 2026-10-13T07:19:00Z');
|
|
127
|
+
if (d.form !== undefined && !REPORT_FORMS.includes(d.form))
|
|
128
|
+
throw bad('form', `must be one of ${REPORT_FORMS.join(', ')}`);
|
|
129
|
+
if (d.video !== undefined && !(isText(d.video) && d.video.trim() && [...d.video].length <= 10))
|
|
130
|
+
throw bad('video', 'must be text of 10 characters or fewer');
|
|
131
|
+
if (d.asked_by !== undefined && !(isText(d.asked_by) && d.asked_by.trim() && [...d.asked_by].length <= 40))
|
|
132
|
+
throw bad('asked_by', 'must be text of 40 characters or fewer');
|
|
123
133
|
if (d.decided !== undefined && !(isText(d.decided) && d.decided.trim() && [...d.decided].length <= 200))
|
|
124
134
|
throw bad('decided', 'must be text of 200 characters or fewer');
|
|
125
135
|
if (d.type === 'evidence' && !(Number.isInteger(d.number) && d.number >= 1))
|
|
@@ -204,20 +214,21 @@ export function loadDecisions(path, board) {
|
|
|
204
214
|
}
|
|
205
215
|
return {
|
|
206
216
|
key: d.key, title: d.title, about: d.about, detail: d.detail, options: options.map((o) => ({ value: o.value, label: o.label, ...(o.outcome && { outcome: o.outcome }) })),
|
|
207
|
-
...(d.why && { why: d.why }), ...(d.recommend_reason && { recommend_reason: d.recommend_reason }), ...(d.silent_by && { silent_by: d.silent_by }), ...(d.merges && { merges: [...d.merges] }),
|
|
217
|
+
...(d.why && { why: d.why }), ...(d.recommend_reason && { recommend_reason: d.recommend_reason }), ...(d.silent_by && { silent_by: d.silent_by }), ...(d.merges && { merges: [...d.merges] }), ...(d.proof && { proof: d.proof }),
|
|
208
218
|
recommend_value: d.recommend_value, default_if_silent: d.default_if_silent, close: d.close ?? 'done', evidence: d.evidence,
|
|
209
219
|
type: plan ? 'plan' : d.type ?? 'decision', tradeoff: d.tradeoff, link: d.link, mocks: d.mocks?.map((m) => ({ value: m.value, path: m.path })),
|
|
210
220
|
...(views && { view: d.view }), ...(report && views && { preview: d.preview }), ...(d.reply && { reply: true }), ...(d.filer && { filer: d.filer }), ...(d.area && { area: d.area }),
|
|
211
221
|
...(signin && { unlocks: d.unlocks, waits: d.waits, ...(d.expires && { expires: d.expires }), ...(d.start && { start: [...d.start] }) }),
|
|
212
222
|
...(d.group && { group: d.group }), ...(d.filed && { filed: d.filed }), ...(d.decided && { decided: d.decided }),
|
|
213
|
-
...(d.
|
|
223
|
+
...(d.form && { form: d.form }), ...(d.video && { video: d.video }), ...(d.asked_by && { asked_by: d.asked_by }),
|
|
224
|
+
...(d.steps && { steps: d.steps.map(({ title, marks, text }) => ({ title, ...(text && { text }), ...(marks?.length && { marks }) })) }),
|
|
214
225
|
...(stored && d.media && { media: d.media.map(({ path, sha256, bytes }) => ({ path, sha256, bytes })) }),
|
|
215
226
|
...(d.type === 'evidence' && { number: d.number }),
|
|
216
227
|
// A gate's run is part of its question: the same key with another run is a different question.
|
|
217
228
|
...(d.kind === 'gate' && d.run && { run: { run_id: d.run.run_id, commit: d.run.commit } }),
|
|
218
229
|
// So is a finding's proposal, with the counts and records behind it.
|
|
219
230
|
...(d.kind === 'finding' && { finding: { cause: d.cause, incidents: d.incidents, days: d.days, evidence_ids: d.evidence_ids, change_kind: d.change_kind, proposal: d.proposal } }),
|
|
220
|
-
...(plan && { sections: d.sections, decisions: d.decisions }),
|
|
231
|
+
...(plan && { sections: d.sections, decisions: d.decisions, ...(d.project && { project: d.project }) }),
|
|
221
232
|
...(design && { kind: 'design', type: 'design', group: d.group, filed: d.filed, status: d.status, preview: d.preview, journeys: d.journeys, ...(d.options && { options: d.options, recommend_value: d.recommend }) }),
|
|
222
233
|
...(d.type === 'explainer' && d.chapters && { chapters: d.chapters.map(({ at, title }) => ({ at, title })) }),
|
|
223
234
|
};
|
|
@@ -5,17 +5,14 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>Plan: show plans on the board</title>
|
|
7
7
|
<style>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
:root { color-scheme: light dark; }
|
|
9
|
+
body { font: 16px/1.5 system-ui, sans-serif; margin: 0; color: #111; background: transparent; }
|
|
10
|
+
@media (prefers-color-scheme: dark) { body { color: #eee; } }
|
|
11
|
+
svg { display: block; width: 100%; height: auto; }
|
|
11
12
|
svg text { font: 13px system-ui, sans-serif; fill: currentColor; }
|
|
12
|
-
.step { min-height: 3rem; }
|
|
13
|
-
button { font: inherit; min-height: 2.75rem; padding: 0 1rem; }
|
|
14
13
|
</style>
|
|
15
14
|
</head>
|
|
16
15
|
<body>
|
|
17
|
-
<h1>Show plans on the board</h1>
|
|
18
|
-
<p>Umer reviews an agent's plan on a phone: approve or change each section, answer each decision, then send it all back at once.</p>
|
|
19
16
|
<svg viewBox="0 0 340 110" role="img" aria-label="The agent posts a plan, the board shows it, Umer answers, and one send-back returns to the agent">
|
|
20
17
|
<rect x="2" y="25" width="70" height="40" rx="6" fill="none" stroke="currentColor"/><text x="37" y="50" text-anchor="middle">Agent</text>
|
|
21
18
|
<rect x="134" y="25" width="70" height="40" rx="6" fill="none" stroke="currentColor"/><text x="169" y="50" text-anchor="middle">Board</text>
|
|
@@ -23,20 +20,5 @@ button { font: inherit; min-height: 2.75rem; padding: 0 1rem; }
|
|
|
23
20
|
<path d="M74 40 H130 M206 40 H262" stroke="currentColor" stroke-width="2"/>
|
|
24
21
|
<path d="M301 67 V100 H37 V67" fill="none" stroke="currentColor" stroke-dasharray="4 3"/><text x="169" y="90" text-anchor="middle">one send-back</text>
|
|
25
22
|
</svg>
|
|
26
|
-
<h2>How it works</h2>
|
|
27
|
-
<p class="step" id="step" aria-live="polite"></p>
|
|
28
|
-
<p><button type="button" id="back">Back</button> <button type="button" id="next">Next</button></p>
|
|
29
|
-
<details><summary>Details</summary>
|
|
30
|
-
<p>Sections: goals and non-goals, architecture, slices and order, coding standards, risks. Each takes Approve or Change with a note.</p>
|
|
31
|
-
<p>Decisions: send every answer back as one event, and show plans under Waiting on you.</p>
|
|
32
|
-
</details>
|
|
33
|
-
<script>
|
|
34
|
-
const steps = ['1 of 4. The agent posts the plan.', '2 of 4. The board stores it under Waiting on you.', '3 of 4. Umer answers each section and decision.', '4 of 4. Send back returns every answer to the agent.'];
|
|
35
|
-
let i = 0;
|
|
36
|
-
const show = () => { document.getElementById('step').textContent = steps[i]; };
|
|
37
|
-
document.getElementById('back').onclick = () => { i = Math.max(0, i - 1); show(); };
|
|
38
|
-
document.getElementById('next').onclick = () => { i = Math.min(steps.length - 1, i + 1); show(); };
|
|
39
|
-
show();
|
|
40
|
-
</script>
|
|
41
23
|
</body>
|
|
42
24
|
</html>
|