sign-off-axi 0.5.4 → 0.5.6

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 CHANGED
@@ -49,7 +49,7 @@ Four places, in the bar at the bottom of a phone and the sidebar on a desk.
49
49
  <tr>
50
50
  <td align="center" width="25%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-today-dark.webp"><img src="docs/media/phone-today.webp" alt="Today: counts of what needs you, what was proven and new reports, then the waiting asks"></picture><br><b>Today</b><br>What needs you, what was proven, what is new.</td>
51
51
  <td align="center" width="25%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-decide-dark.webp"><img src="docs/media/phone-decide.webp" alt="Decide: one card per ask that waits on you, filtered by type"></picture><br><b>Decide</b><br>Every ask that waits on you, one tap each.</td>
52
- <td align="center" width="25%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-evidence-dark.webp"><img src="docs/media/phone-evidence.webp" alt="Evidence: a capture of the finished work with its result, grouped by project"></picture><br><b>Evidence</b><br>Proof that a change works, with its honest result.</td>
52
+ <td align="center" width="25%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-evidence-dark.webp"><img src="docs/media/phone-evidence.webp" alt="Evidence: a capture of the finished work with its result, newest first"></picture><br><b>Evidence</b><br>Proof that a change works, with its honest result.</td>
53
53
  <td align="center" width="25%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-reports-dark.webp"><img src="docs/media/phone-reports.webp" alt="Reports: explainer pages you asked for"></picture><br><b>Reports</b><br>Explainers you asked for, to read.</td>
54
54
  </tr>
55
55
  </table>
@@ -77,7 +77,7 @@ A board that fills up with noise stops being read, so the rules sit at the door.
77
77
  - A question must say why it is asked, what each answer does, which one the agent recommends and what happens if you stay quiet; one that does not is refused with the fix ([the question standard](docs/contract.md#ask-schema-1)).
78
78
  - One decision is one card: raising the same question again changes nothing, and `--merge <key>` folds an older card into the better-asked one.
79
79
  - Proof posted as a Pass/Fail question is refused, and proof that is mostly text, a placeholder or a test card is refused too: proof goes to Evidence, as a capture of the real product; a report must be a page to tap, not a wall of words.
80
- - **Dismiss** hides a card from the board, **Undo** brings it back (`sign-off-axi dismiss <key>` and `restore <key>` do the same from a shell). `sign-off-axi dismiss --filer <id>` closes a finished task: every card that id still waits on leaves the board. Evidence and reports leave the main view a day after they were filed and wait under **Archived**; nothing is deleted, and Restore brings one back. A new ask that repeats an open ask on the same decision is refused, and answering one closes its twins.
80
+ - **Dismiss** hides a card from the board, **Undo** brings it back (`sign-off-axi dismiss <key>` and `restore <key>` do the same from a shell). `sign-off-axi dismiss --filer <id>` closes a finished task: every card that id still waits on leaves the board. Reports leave the main view a day after they were filed and wait under **Archived**; nothing is deleted, and Restore brings one back. Evidence stays on its screen for good, newest first, until dismissed. Posting an evidence message again under its key, from the same filer, replaces the card. A new ask that repeats an open ask on the same decision is refused, and answering one closes its twins.
81
81
 
82
82
  ## What you can ask
83
83
 
package/dist/cli.js CHANGED
@@ -13,7 +13,7 @@ import { parseArgs } from 'node:util';
13
13
  import { encode } from '@toon-format/toon';
14
14
  import { acknowledgeOwner, checkOwner, deliveries, dispatchOwners, registerOwner } from './owner.js';
15
15
  import { AxiError, runAxiCli } from 'axi-sdk-js';
16
- import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, asks, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, sameDecision, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
16
+ import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendPartComment, annotationsPath, markCommentsRead, readPartComments, replyComment, asks, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, reading, readDismissed, readItems, sameDecision, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
17
17
  import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, diagramPages, diagramViews, guided, mediaFiles, parseMessage, reportPage } from './contract.js';
18
18
  import { faults, isStill } from './evidence-text.js';
19
19
  import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
@@ -147,7 +147,7 @@ Blocks until the user answers, then prints the answer the way answers does. Use
147
147
  --timeout seconds to wait, 600 (10 minutes) by default; then exits 1 with code TIMEOUT
148
148
  --full adds the button label, close and title
149
149
  --json {"<key>":{"answer","label","close","at","note"?}} for the new answers; note only when the user wrote one
150
- --events needs --filer (or SIGN_OFF_FILER); JSON lines for new answers, another-round asks and Go deeper (each wait returns one batch).
150
+ --events needs --filer (or SIGN_OFF_FILER); JSON lines for new answers, another-round asks, Go deeper and comments on a part (each wait returns one batch).
151
151
  Answer and round lines come in the order they were saved (${CONTRACT}#7-answers):
152
152
  {"event":"answer","contract":1,"kind","schema","key","title","answer","label","close","at","note"?,"filer"?,"comments"?}
153
153
  {"event":"round","contract":1,"kind","schema","key","title","note","at","filer"?,"comments"?}; a board-format item reports as ask schema 1
@@ -329,6 +329,7 @@ Do not run an active wait listener for the same owner alongside the dispatcher.
329
329
  deeper: `usage: sign-off-axi deeper [board.json] [--filer <id>] [--all] [--json]
330
330
  sign-off-axi deeper [board.json] --filer <id> --id <id> --text "<answer>" [--more]
331
331
  Every part of a plan 3 or report 4 offers Ask a question, Go deeper, Simplify this, Show an example, Why?, and free text. Ask a question needs text and Send; Go deeper, Simplify this, Show an example and Why? send immediately. Free text and follow-ups use custom. Older guided schemas retain Go deeper. Engagement v1 replies require the owning --filer or registered owner session; stale targets are refused. The person taps it; wait --filer wakes with the ask, or this command lists the ones nobody finished.
332
+ The same --id --text replies to a comment on a part (the id in wait's comment event).
332
333
  Answer in plain words about the part they tapped, a few lines at a time: each --more piece streams into that spot on the board while they watch, and a last call without --more ends it.
333
334
  --id the ask to answer --text the answer, or - to read stdin (2000 characters or fewer per piece)
334
335
  --more more pieces follow --all also the finished asks
@@ -684,6 +685,16 @@ function deeperOut(file, asks, on, events) {
684
685
  return toon({ board: tilde(file), deeper: asks.map(({ id, key, part, about, q = '', at, action }) => ({ id, key, part, about, q, at, ...(action && { action }) })) }, ['The person tapped Go deeper: answer in plain words, a few lines at a time, as the part they tapped',
685
686
  `Run \`sign-off-axi deeper${on} --id <id> --text "<what you found>" --more\` for each piece, then once more without --more to finish`]);
686
687
  }
688
+ /** Comments on the parts of a guided page as the agent reads them; answer one with `deeper --id <id> --text`. */
689
+ function commentOut(file, said, on, events) {
690
+ const titles = new Map(board(file).map((d) => [d.key, d.title]));
691
+ if (events) {
692
+ const kind = kindOf(file);
693
+ return said.map(({ id, key, part, about, verdict, text, at }) => JSON.stringify({ event: 'comment', ...kind(key), key, title: titles.get(key), id, part, about, ...(verdict && { verdict }), ...(text && { text }), at })).join('\n');
694
+ }
695
+ return toon({ board: tilde(file), comments: said.map(({ id, key, part, about, verdict = '', text = '', at }) => ({ id, key, part, about, verdict, text, at })) }, ['The person commented on a part: read it, change the page if it is right, and reply in plain words',
696
+ `Run \`sign-off-axi deeper${on} --id <id> --text "<your reply>"\` to answer one`]);
697
+ }
687
698
  /** `deeper`: the Go deeper asks nobody has finished, or with --id and --text one piece of the answer the board streams under the part the person tapped. */
688
699
  async function owner(args) {
689
700
  const { file, values } = parse('owner', args, { filer: { type: 'string' }, host: { type: 'string' }, session: { type: 'string' }, cwd: { type: 'string' }, model: { type: 'string' }, dispatch: { type: 'boolean' }, verbose: { type: 'boolean' }, ack: { type: 'string' }, timeout: { type: 'string' } });
@@ -729,6 +740,18 @@ async function deeper(args) {
729
740
  markDeeperRead(file, open.filter((x) => !x.read).map((x) => x.id));
730
741
  return toon({ board: tilde(file), deeper: open.map(({ id, key, part, about, q = '', at, done, action }) => ({ id, key, part, about, q, at, done: !!done, ...(action && { action }) })) }, [`Run \`sign-off-axi deeper${on} --id <id> --text "<answer>"\` to answer one`]);
731
742
  }
743
+ const said = readPartComments(file).find((x) => x.id === values.id);
744
+ if (said) {
745
+ if (filer !== undefined && !board(file).some((d) => d.key === said.key && d.filer === filer))
746
+ throw new InputError('Request does not belong to this filer');
747
+ if (said.reply !== undefined)
748
+ throw fail(`comment ${values.id} is already answered`, 'CONFLICT', 'The person can comment again');
749
+ const reply = values.text === '-' ? readFileSync(0, 'utf8') : values.text;
750
+ if (typeof reply !== 'string' || !reply.trim() || [...reply.trim()].length > 2000)
751
+ throw fail('--text must be the reply, 2000 characters or fewer', 'VALIDATION_ERROR', 'Run `sign-off-axi deeper --help`');
752
+ replyComment(file, said.id, reply.trim());
753
+ return toon({ id: said.id, key: said.key, part: said.part, answered: 'done' }, [`Run \`sign-off-axi wait${on}\` for the next answer or ask`]);
754
+ }
732
755
  const ask = readDeeper(file).find((x) => x.id === values.id);
733
756
  if (!ask)
734
757
  throw fail(`no Go deeper ask with id ${values.id}`, 'NOT_FOUND', `Run \`sign-off-axi deeper${on}\` to see the open ones`);
@@ -784,10 +807,10 @@ async function wait(args) {
784
807
  seenRounds.set(r.key, (seenRounds.get(r.key) ?? 0) + 1);
785
808
  // A save wakes the loop at once; the 1 second timer is the fallback where file events are unavailable.
786
809
  let wake = () => { }, watcher, vaultWarned = false;
787
- const name = basename(answersPath(file)), asking = basename(deeperPath(file));
810
+ const name = basename(answersPath(file)), asking = basename(deeperPath(file)), talking = basename(annotationsPath(file));
788
811
  try {
789
812
  try {
790
- watcher = watch(dirname(file), (_, changed) => (changed === name || changed === asking) && wake()).on('error', () => { });
813
+ watcher = watch(dirname(file), (_, changed) => (changed === name || changed === asking || changed === talking) && wake()).on('error', () => { });
791
814
  }
792
815
  catch { }
793
816
  for (let first = true;; first = false) {
@@ -831,6 +854,14 @@ async function wait(args) {
831
854
  }, 'another wait is still taking Go deeper asks');
832
855
  if (asks.length)
833
856
  return deeperOut(file, asks, onBoard + (filer ? ` --filer ${filer}` : ''), values.events);
857
+ // A comment on a part of this filer's page wakes the wait once, and is marked read when it is handed over.
858
+ const said = await locked(`${annotationsPath(file)}.lock`, () => {
859
+ const got = readPartComments(file).filter((x) => !x.read && (values.key === undefined || x.key === values.key) && rows.some((d) => d.key === x.key));
860
+ markCommentsRead(file, got.map((x) => x.id));
861
+ return got;
862
+ }, 'another wait is still taking comments');
863
+ if (said.length)
864
+ return commentOut(file, said, on, values.events);
834
865
  }
835
866
  // Private vault expiries and refusals reach a filer wait only once its ordinary answers are out; each is taken once.
836
867
  let live = 0, unknown = false;
@@ -1352,18 +1383,25 @@ function postMedia(file, named, on, values, type, filer, message) {
1352
1383
  // Boards in one folder share evidence/, so a number is taken only by creating its folder; one in use is never written into.
1353
1384
  const current = board(file), store = join(dirname(file), 'evidence'), taken = current.map((d) => Number(/^update-(\d+)$/.exec(d.key)?.[1] ?? 0));
1354
1385
  const own = message ? message.key : explainer && (values.key ?? slug(values.title)), had = message && own && items.find((x) => x?.key === own);
1386
+ // The same filer posting a key again with a changed proof replaces the card it posted, as a new update filed now; another filer's, or one already answered, is refused.
1387
+ let replaced = false;
1355
1388
  if (had) {
1356
1389
  // The stored item, read back as the message it came from: its run, and each media file by hash. The notice record is the board's, not the message's.
1357
1390
  const asMessage = ({ type: _t, number: _n, filed: _f, announced: _a, evidence: run, media: kept, ...rest }) => canon({ ...rest, run, media: kept?.map((x) => x.sha256) });
1358
1391
  if (asMessage(had) !== canon({ ...message, media: sources.length ? sources.map((src) => sha256(readFileSync(src))) : undefined })) {
1359
- throw fail(`key ${own} already holds a different message on ${tilde(file)}`, 'CONFLICT', 'Post it under a new key');
1392
+ if (had.filer === undefined || had.filer !== message.filer || Object.hasOwn(readAnswers(file), own))
1393
+ throw fail(`key ${own} already holds a different message on ${tilde(file)}`, 'CONFLICT', 'Post it under a new key');
1394
+ items.splice(items.indexOf(had), 1);
1395
+ replaced = true;
1360
1396
  }
1397
+ }
1398
+ if (had && !replaced) {
1361
1399
  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`] : []);
1362
1400
  }
1363
1401
  const answers = readAnswers(file);
1364
1402
  if (message && own && Object.hasOwn(answers, own) && !awaitsVerdict(message.kind, answers[own]))
1365
1403
  throw fail(`key ${own} was answered on ${tilde(file)} and is no longer there`, 'CONFLICT', 'Post it under a new key');
1366
- if (own && current.some((d) => d.key === own))
1404
+ if (own && !replaced && current.some((d) => d.key === own))
1367
1405
  throw fail(`key ${own} is already used on ${tilde(file)}`, 'CONFLICT', 'Post it under a new --key');
1368
1406
  mkdirSync(store, { recursive: true, mode: 0o700 });
1369
1407
  let number = Math.max(0, ...taken, ...readdirSync(store).map(Number).filter(Number.isInteger)) + 1;
@@ -2460,7 +2498,7 @@ async function exportBoard(args) {
2460
2498
  config = join(dirname(parsed.named), 'config.yaml');
2461
2499
  guardReady = warmGuard();
2462
2500
  await guardReady;
2463
- const { file: _, messages: _messages, annotations: _annotations, ...frozen } = state(), files = {}, q = encodeURIComponent;
2501
+ const { file: _, messages: _messages, annotations: _annotations, comments: _comments, ...frozen } = state(), files = {}, q = encodeURIComponent;
2464
2502
  let missing = 0;
2465
2503
  // The same URLs the page asks the server for, read through the same hash and path checks.
2466
2504
  const inline = async (url, query, document = false) => {
@@ -2501,12 +2539,14 @@ async function exportBoard(args) {
2501
2539
  return toon({ board: tilde(parsed.file), exported: tilde(out), items: frozen.decisions.length, inlined: Object.keys(files).length, ...(missing && { missing }) }, [`Open ${tilde(out)} in a browser: one read-only file, no server needed`]);
2502
2540
  }
2503
2541
  const page = readFileSync(new URL('../web/index.html', import.meta.url));
2504
- /** The board's favicon, home-screen icon and install manifest, served beside the page; see web/icon.svg. */
2505
- const ICONS = Object.fromEntries([['icon.svg', 'image/svg+xml'], ['apple-touch-icon.png', 'image/png'], ['icon-192.png', 'image/png'], ['icon-512.png', 'image/png'], ['icon-maskable-512.png', 'image/png'], ['manifest.webmanifest', 'application/manifest+json']]
2542
+ /** The board's design tokens, favicon, home-screen icon and install manifest, served beside the page; see web/tokens.css and web/icon.svg. */
2543
+ const ICONS = Object.fromEntries([['tokens.css', 'text/css; charset=utf-8'], ['icon.svg', 'image/svg+xml'], ['apple-touch-icon.png', 'image/png'], ['icon-192.png', 'image/png'], ['icon-512.png', 'image/png'], ['icon-maskable-512.png', 'image/png'], ['manifest.webmanifest', 'application/manifest+json']]
2506
2544
  .map(([name, type]) => [`/${name}`, { type, bytes: readFileSync(new URL(`../web/${name}`, import.meta.url)) }]));
2507
2545
  /** Added to each design mock page and plan page so the person can point at a part of it; see web/point.js. */
2508
2546
  const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
2509
2547
  const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
2548
+ /** Added to each diagram page: on the page opened by itself, a way back to its plan or report, and a phone's drawing-alone page sized to the drawing; see web/diagram-page.js. */
2549
+ const diagramPage = (back, label) => Buffer.from(`<script data-back="${back}" data-label="${label}">${readFileSync(new URL('../web/diagram-page.js', import.meta.url), 'utf8')}</script>`);
2510
2550
  /** A card whose picture is a colour-bar test card or a placeholder is not shown: a card posted before the guard can hold one. Read by the
2511
2551
  * stored hash, so each picture is measured once. */
2512
2552
  const unfitMedia = new Map();
@@ -2620,6 +2660,7 @@ const state = () => {
2620
2660
  rounds,
2621
2661
  messages: readMessages(file, warn),
2622
2662
  annotations: readOnly() || answersOnly() ? [] : ((sent) => readAnnotations(file, warn).map((a) => (sent.has(a.id) ? { ...a, sent: true } : a)))(sentIds(file)),
2663
+ comments: readOnly() || answersOnly() ? [] : readPartComments(file),
2623
2664
  dismissed: readDismissed(file, answers, rounds, warn, archivable(file, loadDecisions(file))),
2624
2665
  };
2625
2666
  };
@@ -2755,7 +2796,8 @@ async function route(req) {
2755
2796
  const d = loadDecisions(file).find((x) => x.key === key);
2756
2797
  // A guided document's diagram views: ~diagram.<n>, n counting them in the order visual, then each section and its items.
2757
2798
  const dgs = d?.guided ? diagramViews(d) : [];
2758
- const mock = value?.startsWith('diagram.') ? dgs[Number(value.slice(8))]?.src : value ? d?.mocks?.find((m) => m.value === value)?.path : value === '' && d?.view;
2799
+ const diagram = value?.startsWith('diagram.') ? dgs[Number(value.slice(8))] : undefined;
2800
+ const mock = diagram ? diagram.src : value ? d?.mocks?.find((m) => m.value === value)?.path : value === '' && d?.view;
2759
2801
  try {
2760
2802
  const path = rest.map(decodeURIComponent).join('/'), type = assetTypes[extname(path).toLowerCase()];
2761
2803
  if (!mock || !type)
@@ -2763,8 +2805,12 @@ async function route(req) {
2763
2805
  const actual = under(dirname(under(dirname(file), mockFile(mock))), path);
2764
2806
  if (statSync(actual).size > MOCK_BYTES)
2765
2807
  return [404, { error: 'Mock is unavailable' }];
2766
- // Every HTML view gets the bridge: document readers need its size handshake too.
2767
- return [200, { media: readFileSync(actual), type, point: type === 'text/html' }];
2808
+ const bytes = readFileSync(actual);
2809
+ // A diagram is its one pinned page: the file the plan posted, or nothing. A plan posted before the pin carries none and is served as it was.
2810
+ if (diagram?.sha256 && (actual !== under(dirname(file), mockFile(mock)) || bytes.length !== diagram.bytes || sha256(bytes) !== diagram.sha256))
2811
+ return [404, { error: 'Diagram file changed since it was posted' }];
2812
+ // Every HTML view gets the bridge: document readers need its size handshake too. A diagram also gets its way back.
2813
+ return [200, { media: bytes, type, point: type === 'text/html', ...(diagram && type === 'text/html' && d && { page: diagramPage(`/#${d.type === 'plan' && !reading(d) ? 'waiting' : 'reports'}/${d.key}`, `Back to the ${d.type === 'plan' ? 'plan' : 'report'}`) }) }];
2768
2814
  }
2769
2815
  catch {
2770
2816
  return [404, { error: 'Mock is unavailable' }];
@@ -2828,8 +2874,8 @@ async function route(req) {
2828
2874
  return [404, { error: 'Not found' }];
2829
2875
  if (readOnly())
2830
2876
  return [403, { error: 'This board is read-only' }];
2831
- // Dismiss only hides an item on this page and sends nothing to the feeding program, so an answers-only board takes it too.
2832
- if (!['/api/answers', '/api/dismiss', '/api/signin', '/api/reads'].includes(req.url) && answersOnly())
2877
+ // Dismiss only hides an item on this page and sends nothing to the feeding program, and Go deeper only asks for an explanation, so an answers-only board takes both.
2878
+ if (!['/api/answers', '/api/dismiss', '/api/signin', '/api/reads', '/api/deeper', '/api/engagement', '/api/engagement/seen'].includes(req.url) && answersOnly())
2833
2879
  return [403, { error: 'This board takes answers only' }];
2834
2880
  if (!req.headers['content-type']?.startsWith('application/json'))
2835
2881
  return [415, { error: 'Send JSON' }];
@@ -2848,7 +2894,22 @@ async function route(req) {
2848
2894
  return [200, state()];
2849
2895
  }
2850
2896
  if (req.url === '/api/annotations') {
2851
- const { id, deleted, key, mock, ref, tag, words, text, hash: shown } = JSON.parse(body) ?? {}, live = readAnnotations(file), sent = sentIds(file);
2897
+ const c = JSON.parse(body) ?? {};
2898
+ // A comment on a part of a guided page: Agree, Disagree or words, kept beside the design notes for the author agent's wait to take.
2899
+ if (c.part !== undefined) {
2900
+ const d = loadDecisions(file).find((x) => x.key === c.key), about = typeof c.part === 'string' && d && guidePart(d, c.part);
2901
+ if (!about)
2902
+ return [404, { error: 'This part is no longer on the board' }];
2903
+ if (c.verdict !== undefined && c.verdict !== 'agree' && c.verdict !== 'disagree')
2904
+ return [400, { error: 'Send agree or disagree' }];
2905
+ if (c.text !== undefined && (typeof c.text !== 'string' || [...c.text.trim()].length > NOTE_MAX))
2906
+ return [400, { error: `Write the comment in ${NOTE_MAX} characters or fewer` }];
2907
+ if (!c.verdict && !c.text?.trim())
2908
+ return [400, { error: 'Write the comment first' }];
2909
+ appendPartComment(file, c.key, c.part, about, c.verdict, c.text?.trim());
2910
+ return [200, state()];
2911
+ }
2912
+ const { id, deleted, key, mock, ref, tag, words, text, hash: shown } = c, live = readAnnotations(file), sent = sentIds(file);
2852
2913
  // A comment that went with an answer or a round is the agent's now: it stays as it was sent.
2853
2914
  if (id !== undefined && sent.has(id))
2854
2915
  return [409, { error: 'This comment was sent' }];
@@ -3153,7 +3214,7 @@ const server = createServer(async (req, res) => {
3153
3214
  return bytes ? pipeline(createReadStream(path, { start, end }), res, () => { }) : res.end();
3154
3215
  }
3155
3216
  if (body && typeof body === 'object' && 'media' in body) {
3156
- const { media: bytes, type, point } = body, media = point ? Buffer.concat([bytes, pointer]) : bytes;
3217
+ const { media: bytes, type, point, page } = body, media = point ? Buffer.concat([bytes, pointer, ...(page ? [page] : [])]) : bytes;
3157
3218
  const headers = { 'content-type': type, 'x-content-type-options': 'nosniff', 'content-security-policy': FRAME_CSP, 'accept-ranges': 'bytes', 'content-length': media.length };
3158
3219
  // One byte range, so phone browsers can play and seek a recording.
3159
3220
  const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? '');
package/dist/contract.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // The v1 communication contract (docs/contract.md): one strict, versioned shape per kind of message.
2
+ import { createHash } from 'node:crypto';
2
3
  import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
3
4
  import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
4
5
  import { isKey, MEDIA_MAX, REPORT_FORMS } from './store.js';
@@ -211,8 +212,12 @@ export function checkMessage(m, want, flags = false) {
211
212
  text(`${field}[${n}].label`, o.label, 40, 'the number\'s name, in 40 characters or fewer');
212
213
  text(`${field}[${n}].value`, o.value, 120, 'the number and its unit, in 120 characters or fewer');
213
214
  });
214
- /** A diagram: 1 to 3 views of one picture (Today, After), each a board-relative .html file the board frames. */
215
- const diagrams = (field, v) => list(field, 1, 3, ['label', 'src'], '{"label":"Today","src":"diagrams/flow.html"}', v).forEach((o, n) => {
215
+ /** A diagram: 1 to 3 views of one picture (Today, After), each a board-relative .html file the board frames. Post adds each file's `sha256` and `bytes`, as it does for media. */
216
+ const diagrams = (field, v) => list(field, 1, 3, ['label', 'src', 'sha256', 'bytes'], '{"label":"Today","src":"diagrams/flow.html"}', v).forEach((o, n) => {
217
+ if (Object.hasOwn(o, 'sha256') && !/^[a-f0-9]{64}$/.test(o.sha256))
218
+ throw bad(`${field}[${n}].sha256`, 'must be 64 lowercase hex digits', 'Leave "sha256" and "bytes" out; post pins the file');
219
+ if (Object.hasOwn(o, 'bytes') && !(Number.isInteger(o.bytes) && o.bytes >= 0))
220
+ throw bad(`${field}[${n}].bytes`, 'must be a whole number of bytes', 'Leave "sha256" and "bytes" out; post pins the file');
216
221
  text(`${field}[${n}].label`, o.label, 40, 'the view name, such as "Today", in 40 characters or fewer');
217
222
  text(`${field}[${n}].src`, o.src, 256, 'a page path from the board file\'s folder, such as "diagrams/flow.html"');
218
223
  if (/^([\\/]|[A-Za-z]:)/.test(o.src) || o.src.split(/[\\/]/).includes('..') || /[?#\\]/.test(o.src) || !/\.html?$/i.test(o.src))
@@ -663,7 +668,8 @@ export const diagramViews = (m) => [m?.visual?.diagram, ...(m?.sections ?? []).f
663
668
  const DIAGRAM_MIN_PX = 12;
664
669
  const PHONE_PX = 390, PHONE_DRAW_PX = PHONE_PX - 16;
665
670
  /** A guided plan's or report's diagram pages, from `base`, the board file's folder: each a file of 5 MB or less whose real path stays inside it,
666
- * with a drawing whose smallest text is at least 12 px at 390 and no brand marks (their licenses differ). The first broken rule throws. */
671
+ * with a drawing whose smallest text is at least 12 px at 390 and no brand marks (their licenses differ). The first broken rule throws.
672
+ * Each view that passes is pinned with the file's `sha256` and `bytes`, so the board serves only the file that was posted and the revision covers its content. */
667
673
  export function diagramPages(m, base) {
668
674
  const at = isKey(m.key) ? `key ${m.key}: ` : '';
669
675
  let root = resolve(base);
@@ -692,7 +698,7 @@ export function diagramPages(m, base) {
692
698
  catch {
693
699
  throw bad('cannot be read', 'Make the file readable, then post again');
694
700
  }
695
- const html = readFileSync(real, 'utf8'), svg = html.match(/<svg\b[^>]*\bviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+[\d.]+[^>]*>([\s\S]*?)<\/svg>/i);
701
+ const file = readFileSync(real), html = file.toString('utf8'), svg = html.match(/<svg\b[^>]*\bviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+[\d.]+[^>]*>([\s\S]*?)<\/svg>/i);
696
702
  if (!svg)
697
703
  throw bad('has no <svg> drawing with a viewBox', 'Render it with the archify skill: an architecture diagram with a viewBox 320 to 340 wide and its legend hidden');
698
704
  if (/data-brand-mark/i.test(html))
@@ -701,6 +707,8 @@ export function diagramPages(m, base) {
701
707
  const small = Math.min(...sizes), real12 = small * PHONE_DRAW_PX / Number(svg[1]);
702
708
  if (sizes.length && real12 < DIAGRAM_MIN_PX)
703
709
  throw bad(`has text of ${Math.round(real12 * 10) / 10} px at ${PHONE_PX} (${small} in a ${svg[1]} wide drawing), under ${DIAGRAM_MIN_PX}`, `Draw it narrower, an architecture viewBox 320 to 340 wide with no sublabels and the legend hidden ("legend": {"mode": "hidden"} in "meta"), so every text is ${DIAGRAM_MIN_PX} px or more on a phone`);
710
+ v.sha256 = createHash('sha256').update(file).digest('hex');
711
+ v.bytes = file.length;
704
712
  }
705
713
  }
706
714
  /** The longest a block of plain text on a report page may run: about five lines on a phone. */
package/dist/owner.js CHANGED
@@ -49,7 +49,7 @@ export async function dispatchOwners(file, cli, filer, verbose = false) {
49
49
  for (const ask of readDeeper(file).filter(x => !x.done && items.some(d => d.key === x.key && (filer === undefined || d.filer === filer)))) {
50
50
  const owner = ownerFor(file, ask);
51
51
  if (!owner) {
52
- record(file, ask.id, 'queued', 'No registered owner session');
52
+ record(file, ask.id, 'queued', 'No agent is watching this document right now. Your ask is saved for when one does.');
53
53
  continue;
54
54
  }
55
55
  // One final reply is atomic in the existing deeper log; replay never appends a second answer.
package/dist/store.js CHANGED
@@ -325,7 +325,7 @@ export const SCREENS_ONLY = 'screens';
325
325
  /** True while an answer leaves the item waiting: none yet, or only screen notes. */
326
326
  export const awaitsVerdict = (type, a) => !a || (type === 'design' && a.answer === SCREENS_ONLY);
327
327
  const DESIGN_LABEL = { approve: 'Approved', change: 'Changes requested', [SCREENS_ONLY]: 'Screens reviewed' };
328
- /** Saves a design answer with screen-specific notes and an optional listed direction pick. A screen save omits the verdict: the design keeps its saved verdict, a change on a screen of an approved design sets it to change, and with none yet it waits as screens-only. */
328
+ /** Saves a design answer with screen-specific notes and an optional listed direction pick. A screen save omits the verdict: the design keeps its saved verdict, a requested change on a screen sets it to change, and any other screen save with none yet waits as screens-only. */
329
329
  export function appendDesignAnswer(decisionsPath, d, verdict, notes, pick, requested) {
330
330
  const bad = (rule) => new InputError(`key ${d.key}: ${rule}`);
331
331
  if (verdict !== undefined && verdict !== 'approve' && verdict !== 'change')
@@ -352,7 +352,7 @@ export function appendDesignAnswer(decisionsPath, d, verdict, notes, pick, reque
352
352
  throw bad('pick must be one listed option, and a design verdict needs one when options were offered');
353
353
  const current = readAnswers(decisionsPath)[d.key];
354
354
  const decided = current?.answer === 'approve' || current?.answer === 'change' ? current.answer : undefined;
355
- const kept = (verdict ?? (decided === 'approve' && requested === true ? 'change' : decided ?? SCREENS_ONLY));
355
+ const kept = (verdict ?? (requested === true ? 'change' : decided ?? SCREENS_ONLY));
356
356
  const keptPick = (pick ?? (options.some(o => o.value === current?.pick) ? current?.pick : undefined));
357
357
  const answer = { key: d.key, answer: kept, label: DESIGN_LABEL[kept], close: d.close ?? 'done', at: now(), notes, ...(keptPick !== undefined && { pick: keptPick }) };
358
358
  if (current?.answer === answer.answer && JSON.stringify([current.notes, current.pick]) === JSON.stringify([answer.notes, answer.pick]))
@@ -462,10 +462,10 @@ export const asks = (d) => d.type !== 'evidence' && !reading(d) && (d.options.le
462
462
  export const reading = (d) => d.type === 'plan' && !!d.guided && !d.decisions?.length;
463
463
  /** A sign-in whose fixed link has expired has nothing left to open: it is not waiting, here or on the board's page (web/index.html lapsed() says the same). A start command makes a new link per tap and never lapses. */
464
464
  export const lapsed = (d) => d.type === 'signin' && !d.start && !!d.expires && Date.parse(d.expires) <= Date.now();
465
- /** Evidence and reports (nothing to answer) archive on their own this long after they were filed: dismissed, never deleted. */
465
+ /** Reports (nothing to answer) archive on their own this long after they were filed: dismissed, never deleted. Evidence is kept for good: only a dismiss line hides a proof. */
466
466
  export const ARCHIVE_MS = 24 * 3_600_000;
467
467
  /** Keys hidden now: the last line for each dismissed it, and no answer or round came after it. A torn line is skipped and reported through warn.
468
- * `items`, when given, adds the archive: evidence and reports filed over a day ago that no log line ever named. Restore writes a line, so a restored item stays. */
468
+ * `items`, when given, adds the archive: reports filed over a day ago that no log line ever named. Restore writes a line, so a restored item stays. */
469
469
  export function readDismissed(decisionsPath, answers, rounds, warn = () => { }, items = []) {
470
470
  const hidden = new Map(), named = new Set();
471
471
  readRaw(dismissedPath(decisionsPath)).split('\n').forEach((line, n) => {
@@ -482,7 +482,7 @@ export function readDismissed(decisionsPath, answers, rounds, warn = () => { },
482
482
  warn(`skipped unreadable line ${n + 1} in ${dismissedPath(decisionsPath)}`);
483
483
  }
484
484
  });
485
- const archived = items.filter((d) => !asks(d) && !named.has(d.key) && Date.now() - Date.parse(d.filed ?? '') > ARCHIVE_MS).map((d) => d.key);
485
+ const archived = items.filter((d) => d.type !== 'evidence' && !asks(d) && !named.has(d.key) && Date.now() - Date.parse(d.filed ?? '') > ARCHIVE_MS).map((d) => d.key);
486
486
  return [...[...hidden].filter(([key, at]) => !(answers[key]?.at > at) && !rounds.some((r) => r.key === key && r.at > at)).map(([key]) => key), ...archived];
487
487
  }
488
488
  export const messagesPath = (decisionsPath) => decisionsPath.replace(/\.json$/, '') + '.messages.jsonl';
@@ -521,7 +521,7 @@ export function readAnnotations(decisionsPath, warn = () => { }) {
521
521
  all.delete(a.id);
522
522
  else if (a && ['id', 'key', 'mock', 'hash', 'ref', 'tag', 'words', 'text'].every((k) => typeof a[k] === 'string'))
523
523
  all.set(a.id, a);
524
- else if (a)
524
+ else if (a && a.part === undefined && a.op === undefined)
525
525
  warn(`skipped unreadable line ${n + 1} in ${annotationsPath(decisionsPath)}`);
526
526
  }
527
527
  catch {
@@ -530,6 +530,33 @@ export function readAnnotations(decisionsPath, warn = () => { }) {
530
530
  });
531
531
  return [...all.values()];
532
532
  }
533
+ export function readPartComments(decisionsPath) {
534
+ const all = new Map();
535
+ readRaw(annotationsPath(decisionsPath)).split('\n').forEach((line) => {
536
+ try {
537
+ const x = line && JSON.parse(line), c = x && all.get(x.id);
538
+ if (typeof x?.part === 'string' && typeof x.id === 'string')
539
+ all.set(x.id, { id: x.id, key: x.key, part: x.part, about: x.about, ...(x.verdict && { verdict: x.verdict }), ...(x.text && { text: x.text }), at: x.at });
540
+ else if (c && x.op === 'read')
541
+ c.read ??= x.at;
542
+ else if (c && x.op === 'reply') {
543
+ c.reply = x.text;
544
+ c.replied = x.at;
545
+ c.read ??= x.at;
546
+ }
547
+ }
548
+ catch { }
549
+ });
550
+ return [...all.values()];
551
+ }
552
+ export function appendPartComment(decisionsPath, key, part, about, verdict, text) {
553
+ const id = randomBytes(6).toString('hex'), at = now();
554
+ appendLine(annotationsPath(decisionsPath), { id, key, part, about, ...(verdict && { verdict }), ...(text && { text }), at });
555
+ return { id, key, part, about, ...(verdict && { verdict }), ...(text && { text }), at };
556
+ }
557
+ export const markCommentsRead = (decisionsPath, ids) => { for (const id of ids)
558
+ appendLine(annotationsPath(decisionsPath), { op: 'read', id, at: now() }); };
559
+ export const replyComment = (decisionsPath, id, text) => appendLine(annotationsPath(decisionsPath), { op: 'reply', id, text, at: now() });
533
560
  /** Saves a comment (a new id unless it edits one) or, with only an id, deletes it. */
534
561
  export const appendAnnotation = (decisionsPath, a) => appendLine(annotationsPath(decisionsPath), { ...a, id: a.id ?? randomBytes(6).toString('hex'), at: now() });
535
562
  /** The parts of a guided page that can be read and asked about, with the name the agent hears: this, understood, each section and item, each decision, next. Undefined for a part it has not got. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "One review board for evidence, quick yes/no decisions and retro changes.",
5
5
  "type": "module",
6
6
  "bin": { "sign-off-axi": "dist/cli.js" },
@@ -73,7 +73,7 @@ sign-off refuses a question that breaks these rules, naming the field and one fi
73
73
  - `--recommend` the answer you would pick, with `--recommend-reason` in one line.
74
74
  - `--default`: what happens if the user stays quiet, and `--silent-by <ISO UTC time>` when.
75
75
  - A card you posted by mistake: `npx -y sign-off-axi dismiss <key>` hides it, `restore <key>` brings it back. When the user dismisses a card, you are not told: `answers` still returns a dismissed answer.
76
- - When your task ends (merged, dropped, or answered some other way), run `npx -y sign-off-axi dismiss --filer <id>`: every card you posted that still waits leaves Decide. Evidence and reports leave the main view by themselves a day after they were filed; they wait under Archived on their screen and `restore <key>` brings one back. Nothing is deleted.
76
+ - When your task ends (merged, dropped, or answered some other way), run `npx -y sign-off-axi dismiss --filer <id>`: every card you posted that still waits leaves Decide. Reports leave the main view by themselves a day after they were filed; they wait under Archived on their screen and `restore <key>` brings one back. Evidence stays on the Evidence screen until dismissed, and posting it again under the same key with a changed proof replaces the card. Nothing is deleted.
77
77
 
78
78
  A good yes/no question:
79
79
 
@@ -105,18 +105,18 @@ npx -y sign-off-axi raise --ask-ref <captain-message-id-or-hold-id> --title "Whi
105
105
  - Write each part for the reader, in the order the board shows it: *What this is* (the answer and one picture: bars, steps or tiles), *What I understood* (the request in your words, so they can say it is off), *What I found*, *What I recommend* (plans), *What you decide* (plans, at most five questions, each with your pick and why), *Next step*.
106
106
  - Answer first: the heading of the picture is the answer, one plain sentence.
107
107
  - One picture per point; the numbers go in `details`, behind a tap, never in the sentence.
108
- - Short lines: `text` one line, `more` the part that opens in place; never a paragraph the reader must scroll.
108
+ - Short lines: `text` one line, `more` the part that opens in place, with a line break between points; never a paragraph the reader must scroll.
109
109
  - Plain words for a person who does not write code; no ids, no file paths, no tool names.
110
110
  - Name the date the facts were true if they will go stale.
111
111
  - A research report (a comparison, an option study, a "should we use X?" question) must clear a higher bar than an explainer. A few options backed only by your own numbers is rejected.
112
112
  - Name the must-haves first. Survey the whole real field, then short-list against them. Say in one line why each option was cut.
113
- - Give every short-listed option the same axes and its full pros and cons, as one comparable table: one `found` item per option, pros and cons in `text`, and each axis as "Axis: value." in the same order in `more`.
113
+ - Give every short-listed option the same axes and its full pros and cons, as one comparable table: one `found` item per option, pros and cons in `text`, and in `more` each axis on its own line as "Axis: value.", the same axes in the same order. The board then draws the section as one table; an older board shows the same lines as text.
114
114
  - Back each claim with outside proof and its link: GitHub issues (open bugs, regressions, upgrade breaks, how the maintainers answered), Reddit, Hacker News, X, forums and blogs. If a site blocks fetching, say what you could not read. Vendor marketing, star counts and your own team's work-log numbers are not proof.
115
115
  - Keep what you checked yourself apart from what others claim: a separate section, or each line starting "Checked:" or "Reported:".
116
116
  - End with one clear recommendation and a runner-up, each with its reasons.
117
117
  - A plan clears the same research bar for every choice in it, then gives what a go-ahead needs: the options with their pros and cons, your recommendation, the scope (what is in and what is out), the steps in order, each risk with what you do about it, and how done is proven (`Done when` in `understood`, and where the proof lands in `next`).
118
118
  - New guided defaults are plan schema 3 and report schema 4 (contract 1), with your required filer. Older schemas still load. Every part offers **Ask a question**, **Go deeper**, **Simplify this**, **Show an example**, **Why?**, plus free text and follow-ups. Ask a question focuses the section composer; text and Send are required. Other presets send immediately. Question/custom need nonblank text (at most 500 code points); free text and follow-ups use custom. Answer the exact action and question in its section: explain, give detail, use simpler words, give an example, or explain why. All use one engagement path: version 1 carries action, immutable owner, document revision and target, with an optional selection (authored field, code-point start/end, exact quote): when present, answer about those quoted words in their place in the document. Read these fields with `deeper --json --all`; answer the requested action from the original document, never rewrite text or change plan decisions. Active replies need `--filer <your id>`; registered sessions acknowledge the exact id first. Stale targets and other owners are refused. The answer expands in place and stays New across reload until visibly read. Keep the owner dispatcher/listener alive after reports and sent plans, and after every reply.
119
- - The board shows who read each part and when, and the supported presets under every part. When the person taps one, `wait --filer <id>` returns the ask (including action, part and any question), or run `npx -y sign-off-axi deeper <board> --filer <id>` to list the open ones. Active listeners answer in plain words, a few lines at a time: `npx -y sign-off-axi deeper <board> --filer <id> --id <id> --text "<what you found>" --more` for each piece, then once without `--more` to finish. Registered dormant owners instead acknowledge and send one final answer as described above. The board streams each piece into the spot they tapped. Never answer with a long essay: say less, they can ask a follow-up.
119
+ - The board shows who read each part and when, and the supported presets under every part. When the person taps one, `wait --filer <id>` returns the ask (including action, part and any question), or run `npx -y sign-off-axi deeper <board> --filer <id>` to list the open ones. Active listeners answer in plain words, a few lines at a time: `npx -y sign-off-axi deeper <board> --filer <id> --id <id> --text "<what you found>" --more` for each piece, then once without `--more` to finish. Registered dormant owners instead acknowledge and send one final answer as described above. The board streams each piece into the spot they tapped. A person's Agree, Disagree or Comment on a part reaches the same wait as an event with the part and the words; reply to it with the same `deeper --id <id> --text` and change the page if they are right. Never answer with a long essay: say less, they can ask a follow-up.
120
120
  - Open the board at 390 px wide and look before you tell the person it is there: the first screen must give the answer and the picture.
121
121
  - Only if you must keep an HTML page of your own (a demo to tap), run `report init <folder> --page` and post it with `--type report --view --preview`.
122
122
 
@@ -126,7 +126,7 @@ Only where structure, order or dependency is the point: how a change flows, what
126
126
 
127
127
  - Add `"diagram": [{"label": "Today", "src": "diagrams/flow.html"}]` (1 to 3 views, shown as tabs) to the `visual`, a section or an item, beside the rows, which stay as the list. `src` is a page next to the plan or report file, as `view` is.
128
128
  - Draw an `architecture` diagram with a `viewBox` 320 to 340 wide (320 is Archify's smallest): two columns, labels under about 16 characters, no sublabels, `"legend": {"mode": "hidden"}` in `meta`. Sequence, workflow and `compare` pages are too wide for a phone; show the same flow as a numbered column instead, and draw Today and After as two pages, one per tab.
129
- - Post refuses a diagram whose smallest text is under 12 px on a 390 px phone, one with brand marks (never set `brand`), or one that is not there.
129
+ - Post refuses a diagram whose smallest text is under 12 px on a 390 px phone, one with brand marks (never set `brand`), or one that is not there. Post pins each page (its `sha256` and `bytes`, written for you): a page changed after posting is not shown, so draw a changed page again and post the plan again.
130
130
  - Run `ARCHIFY_UPDATE_CHECK_DISABLED=1 archify finalize` with `--quality showcase`, so it makes no network call. Archify draws in its own mono type; sign-off cannot change it.
131
131
  - On a phone the board shows the picture and **Full size**, which opens the drawing alone at the full width of the screen; on a desk **Show me** opens the interactive page in place. Look at both at 390 before you say it is posted.
132
132
 
@@ -149,7 +149,7 @@ When the user must choose how something looks or works, do this:
149
149
  4. Write a short trade-off in plain words (5 lines or fewer: what each variant does well and badly) and recommend one.
150
150
  5. Post it on the project board with `npx -y sign-off-axi post .sign-off/decisions.json --type design --key <key>`, one `--option` and one `--mock` per variant, `--tradeoff` and `--recommend` (see `post --help`). Mock paths are relative to `.sign-off/`: `--mock a=mocks/<key>/a.html`, or `--mock a=mocks/<key>/index.html?variant=a` for the one-page form. When a variant also runs live at a URL, add `--link a=<http or https URL>` for it (one per variant): the card shows an Open live link under that mock, which opens in a new tab. Then run `npx -y sign-off-axi open .sign-off/decisions.json` and give the user the URL.
151
151
  6. Wait with `npx -y sign-off-axi wait .sign-off/decisions.json --key <key>`.
152
- 7. Read the answer with `npx -y sign-off-axi answers .sign-off/decisions.json --key <key> --json`. A pick holds `answer` and maybe a `note`; `"answer": "text"` is the user's own answer (Other answer), in its `note`. A `rounds` entry with no `answer`, or newer than the `answer`'s `at`, means the user wants another round: read its note, build 3 new variants and post them under a new key. A `comments` list on the answer or the round holds the user's comments on parts of a mock: `mock` is the option, `ref`, `tag` and `words` name the element, `text` is the comment; apply them to that variant.
152
+ 7. Read the answer with `npx -y sign-off-axi answers .sign-off/decisions.json --key <key> --json`. A pick holds `answer` and maybe a `note`, the comment the user sent with it; `"answer": "text"` is the user's own answer (Send as my own answer, or Send reply), in its `note`. A `rounds` entry with no `answer`, or newer than the `answer`'s `at`, means the user wants another round: read its note, build 3 new variants and post them under a new key. A `comments` list on the answer or the round holds the user's comments on parts of a mock: `mock` is the option, `ref`, `tag` and `words` name the element, `text` is the comment; apply them to that variant.
153
153
  8. Build the winner into the real code, then delete `.sign-off/mocks/<key>/`.
154
154
 
155
155
  Build mocks with a fast, cheap model: a mock is throwaway work. If first-draft is installed, it can build the mocks and write its `Open in first-draft: <label> <url>` links at the end of `--tradeoff`; the board shows them as real links.
@@ -0,0 +1,22 @@
1
+ // Added to each diagram page the board serves. Only on the page opened by itself (Full size, not the card's frame): a way back to the plan or report,
2
+ // and on a phone's drawing-alone page (embed=1) the page is sized to the drawing, with the drawing's own surface carried down the rest of the screen, so no grey area follows it.
3
+ (() => {
4
+ if (parent !== window) return;
5
+ const me = document.currentScript, back = me.dataset.back, label = me.dataset.label;
6
+ const style = document.createElement('style');
7
+ style.textContent = '.so-back { display: flex; align-items: center; gap: 0.375rem; box-sizing: border-box; width: fit-content; min-height: 2.75rem; margin: 0.25rem 0.5rem; padding: 0 0.875rem; border: 1px solid currentColor; border-radius: 999px; color: inherit; font: 600 0.9375rem/1 system-ui, sans-serif; text-decoration: none; }'
8
+ + '.so-back:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }';
9
+ const surface = document.querySelector('.diagram-container');
10
+ if (new URLSearchParams(location.search).get('embed') === '1') {
11
+ style.textContent += 'html, body { height: auto !important; min-height: 0 !important; }';
12
+ if (surface) {
13
+ // The drawing sits on its own surface over the page's colour; the page takes that surface too, so the screen below the drawing is the drawing's own.
14
+ const s = getComputedStyle(surface);
15
+ style.textContent += `body { background-image: ${s.backgroundImage}, linear-gradient(${s.backgroundColor}, ${s.backgroundColor}) !important; background-size: ${s.backgroundSize}, auto !important; }`
16
+ + '.diagram-container { background: none !important; }';
17
+ }
18
+ }
19
+ const link = document.createElement('a');
20
+ link.className = 'so-back'; link.href = back; link.textContent = `← ${label}`;
21
+ document.head.append(style); document.body.prepend(link);
22
+ })();