sign-off-axi 0.6.2 → 0.6.4

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.
Files changed (53) hide show
  1. package/README.md +24 -2
  2. package/dist/cli.js +171 -48
  3. package/dist/contract.js +115 -23
  4. package/dist/images.js +27 -8
  5. package/dist/store.js +34 -16
  6. package/fixtures/contract/README.md +1 -1
  7. package/fixtures/contract/design.v1/invalid-journeys-missing.json +1 -1
  8. package/fixtures/contract/design.v1/invalid-preview-escape.json +1 -1
  9. package/fixtures/contract/design.v1/invalid-unknown-field.json +1 -1
  10. package/fixtures/contract/design.v1/valid-basic.json +1 -1
  11. package/fixtures/contract/design.v1/valid-choice.json +1 -1
  12. package/fixtures/contract/design.v2/design/built-dark.png +0 -0
  13. package/fixtures/contract/design.v2/design/built.png +0 -0
  14. package/fixtures/contract/design.v2/design/desk-built.png +0 -0
  15. package/fixtures/contract/design.v2/design/desk-today.png +0 -0
  16. package/fixtures/contract/design.v2/design/preview.png +0 -0
  17. package/fixtures/contract/design.v2/design/target.png +0 -0
  18. package/fixtures/contract/design.v2/design/today.png +0 -0
  19. package/fixtures/contract/design.v2/invalid-about-jargon.json +1 -0
  20. package/fixtures/contract/design.v2/invalid-accepts-missing.json +1 -0
  21. package/fixtures/contract/design.v2/invalid-area-missing.json +1 -0
  22. package/fixtures/contract/design.v2/invalid-journeys-over-limit.json +1 -0
  23. package/fixtures/contract/design.v2/invalid-options-unknown.json +1 -0
  24. package/fixtures/contract/design.v2/invalid-title-not-question.json +1 -0
  25. package/fixtures/contract/design.v2/valid-basic.json +1 -0
  26. package/fixtures/contract/design.v2/valid-three.json +1 -0
  27. package/package.json +1 -1
  28. package/skills/sign-off-axi/SKILL.md +9 -4
  29. package/web/index.html +794 -148
  30. package/web/manifest.webmanifest +9 -5
  31. package/web/pwa.js +55 -0
  32. package/web/splash-1024x1366-dark.png +0 -0
  33. package/web/splash-1024x1366-light.png +0 -0
  34. package/web/splash-1024x768-dark.png +0 -0
  35. package/web/splash-1024x768-light.png +0 -0
  36. package/web/splash-1080x810-dark.png +0 -0
  37. package/web/splash-1080x810-light.png +0 -0
  38. package/web/splash-1180x820-dark.png +0 -0
  39. package/web/splash-1180x820-light.png +0 -0
  40. package/web/splash-1194x834-dark.png +0 -0
  41. package/web/splash-1194x834-light.png +0 -0
  42. package/web/splash-1366x1024-dark.png +0 -0
  43. package/web/splash-1366x1024-light.png +0 -0
  44. package/web/splash-768x1024-dark.png +0 -0
  45. package/web/splash-768x1024-light.png +0 -0
  46. package/web/splash-810x1080-dark.png +0 -0
  47. package/web/splash-810x1080-light.png +0 -0
  48. package/web/splash-820x1180-dark.png +0 -0
  49. package/web/splash-820x1180-light.png +0 -0
  50. package/web/splash-834x1194-dark.png +0 -0
  51. package/web/splash-834x1194-light.png +0 -0
  52. package/web/sw.js +57 -0
  53. package/web/ui.css +23 -1
package/README.md CHANGED
@@ -45,6 +45,8 @@ You need Node 22.5 or newer. `init` installs the skill for each coding agent it
45
45
 
46
46
  Four places, in the bar at the bottom of a phone and the sidebar on a desk.
47
47
 
48
+ The board installs as an app (More, then Install app; on an iPad, Share, then Add to Home Screen) and, once opened, reads offline: with the board unreachable it shows the last update under "Offline, showing the last update", and sends fail rather than queue. A phone needs the board on HTTPS or localhost to install it.
49
+
48
50
  <table>
49
51
  <tr>
50
52
  <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>
@@ -103,6 +105,26 @@ Agents pass `--priority` when they know it, and set their project's icon when it
103
105
 
104
106
  Every card's screen also keeps its **Activity**, oldest first, from what the board already saved: who posted it, your answer and note (and the one a change replaced), when the agent picked it up, a new round you asked for and your comments. A ticket's page shows its cards' activity together.
105
107
 
108
+ ### The leash: what agents may do without you
109
+
110
+ Every ask says what happens if you stay quiet (`--default`) and when (`--silent-by`). Your leash decides whether it happens. Set it in Today's More, or tap the **Leash** chip by the count on Decide. It is saved as `leash:` in the `config.yaml` beside the board.
111
+
112
+ - **Ask me first** (`ask`): nothing happens; agents wait for you on every ask.
113
+ - **When it matters** (`matters`, the default): when an ask's `--silent-by` time passes with no answer, the open board takes its default as the answer. Its card says **Taken by default at 13:00**, and the waiting agent gets it at once. A default that fell due while the board was closed is taken when it starts. An ask with no `--silent-by` waits.
114
+ - **Act, then tell me** (`act`): the agent's recommended pick is taken the moment it raises the ask. Its card says **Agent picked B: Friday, tell me if wrong**.
115
+
116
+ Both kinds wait under **Taken without you** in Decide until you open them, each with a one-tap **Change** on a phone. You can still answer or change them. The change reaches the agent as an answer that replaces the taken one, and the card's trail and Activity say what happened: Taken or Picked, then Delivered and Read.
117
+
118
+ Some asks are never answered for you, whatever the leash: an ask raised with `--guard destructive|irreversible|money|credential|security`, and an ask whose words talk of spending money, deleting, what can't be undone, passwords and keys, or access. Designs, plans and sign-ins always wait too. Such an ask says **Always waits for you** and why.
119
+
120
+ <table>
121
+ <tr>
122
+ <td align="center" width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-leash-decide-dark.webp"><img src="docs/media/phone-leash-decide.webp" alt="Decide: the asks that wait, then Taken without you with a default taken and an agent's pick, each with Change, and the Leash chip by the count"></picture><br><b>Taken without you</b><br>What the leash answered, with Change.</td>
123
+ <td align="center" width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-leash-agent-dark.webp"><img src="docs/media/phone-leash-agent.webp" alt="An ask the agent picked: Agent picked B: Friday, tell me if wrong, its reason, its trail from Picked to Read, and Change"></picture><br><b>Agent picked</b><br>On Act, then tell me: its pick, and Change.</td>
124
+ <td align="center" width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-leash-guard-dark.webp"><img src="docs/media/phone-leash-guard.webp" alt="An ask that spends money: Always waits for you, so no default or agent pick is ever taken"></picture><br><b>Always waits for you</b><br>Money, deletes, keys and access.</td>
125
+ </tr>
126
+ </table>
127
+
106
128
  ## What you can ask
107
129
 
108
130
  One line on what the agent runs per kind; the full reference is [docs/question-kinds.md](docs/question-kinds.md) and the message rules are in [docs/contract.md](docs/contract.md).
@@ -111,11 +133,11 @@ One line on what the agent runs per kind; the full reference is [docs/question-k
111
133
  |---|---|---|
112
134
  | Yes or no, pick one | `raise --ask-ref <captain-message-id-or-hold-id> --title ... --recommend ...` | buttons, the recommended one first |
113
135
  | Design pick (mocks A/B/C) | `post --type design --mock a=... --mock b=... --link a=<url>` | mocks side by side, an Open live link under each option that has a `--link`, a note on any part, and Open full screen: the prototype over the whole screen with its own navigation, Phone or Desktop width and another option as a reference |
114
- | Design review (screens of a product) | `post --kind design --from design.json` | nothing in Decide: it waits under **Designs** for Main to review; once Main passes every screen, the lead posts one card per product that opens them all, phone and desktop, and Main's `promote <key>` puts that card in Decide |
136
+ | Design review (one plain question) | `post --kind design --from design.json` | one question answered with **Yes** (a note optional) or **Change** (a note needed): each screen's frames side by side (Before and After, or Today, New and Target design), the changed area outlined and named on each and the frame Yes approves marked; post refuses an internal title and frames that look the same. Nothing in Decide: it waits under **Designs** for Main to review, and Main's `promote <key>` puts it in Decide |
115
137
  | Proof update | `post --type evidence --media shot.png` | the capture, its result, Pass / Fail / Needs work |
116
138
  | Proof with a file to install | `post --type evidence --media shot.png --attach app.apk` | the capture, and the file with its size and a Download button (any type, up to 1 GB, `SIGN_OFF_ATTACH_MAX_MB` to change) |
117
139
  | Explainer video | `post --type explainer --media clip.mp4 --chapter 0:00=Start` | a player with chapter buttons, then Yes or No |
118
- | Report | `report init <folder>`, fill `report.json`, then `post --kind report --from <folder>/report.json` | one scroll: the answer and a picture, what I understood, what I found, the next step; numbers behind Details, Go deeper on every part, who read it and when |
140
+ | Report | `report init <folder>`, fill `report.json`, then `post --kind report --from <folder>/report.json` | one scroll: the answer and a picture, what I understood, what I found, the next step; numbers under their part, one bar (Agree, Disagree, Comment, Ask) that acts on the part in view, and one Activity of every note, ask, reply and read |
119
141
  | Diagram, interactive page | `post --type diagram --view flow.svg` | the picture on the card; interactive pages run sandboxed, and open full screen at phone or desktop width |
120
142
  | Plan | `report init <folder> --plan`, fill `plan.json`, then `post --kind plan --from plan.json` | the same scroll, plus each decision with my pick and a Go ahead |
121
143
  | Sign-in link | `post --type signin --service npm --link <url> --unlocks ...` | an Open button and the time left |
package/dist/cli.js CHANGED
@@ -14,10 +14,10 @@ import { parseArgs } from 'node:util';
14
14
  import { encode } from '@toon-format/toon';
15
15
  import { acknowledgeOwner, checkOwner, deliveries, dispatchOwners, registerOwner } from './owner.js';
16
16
  import { AxiError, runAxiCli } from 'axi-sdk-js';
17
- 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, DISCARD_REASONS, readDiscards, UNDO_MS, appendPromoted, appendSendBack, appendTicket, readTickets, rollUp, appendTicketComment, ticketCommentFault, ticketFault, appendProject, readProjects, projectFault, iconPathFault, iconContentFault, ICON_BYTES, ICON_TYPES, PRIORITIES, REPORT_PARTS, REPORT_PURPOSES, appendDesignAnswer, answerLines, appendMark, readTrail, trailPath, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, appendOpened, readOpened, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, reading, readDismissed, readItems, readPromoted, sameDecision, readMessages, readRounds, roundsPath, sameRound, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
17
+ import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, guardOf, plainAsk, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendPartComment, annotationsPath, markCommentsRead, readPartComments, replyComment, asks, appendAnswer, appendDismissed, DISCARD_REASONS, readDiscards, UNDO_MS, appendPromoted, appendSendBack, appendTicket, readTickets, rollUp, appendTicketComment, ticketCommentFault, ticketFault, appendProject, readProjects, projectFault, iconPathFault, iconContentFault, ICON_BYTES, ICON_TYPES, PRIORITIES, REPORT_PARTS, REPORT_PURPOSES, appendDesignAnswer, answerLines, appendMark, readTrail, trailPath, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, appendOpened, readOpened, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, screenViews, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, reading, readDismissed, readItems, readPromoted, sameDecision, readMessages, readRounds, roundsPath, sameRound, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
18
18
  import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, diagramPages, diagramViews, guided, mediaFiles, parseMessage, reportPage } from './contract.js';
19
19
  import { faults, isStill } from './evidence-text.js';
20
- import { copiesOf } from './images.js';
20
+ import { copiesOf, differs } from './images.js';
21
21
  import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
22
22
  /** The version this copy runs, read from package.json once: a pre-release version says so, and a git checkout whose HEAD carries no tag
23
23
  * for that version is a dev build ahead of its release, `<version>-dev`. An installed copy has no .git, so it shows its version as published.
@@ -84,7 +84,7 @@ examples:
84
84
  sign-off-axi
85
85
  sign-off-axi list ./decisions.json --full
86
86
  `,
87
- 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>] [--ticket <id>] [--priority P0-P3]
87
+ 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>] [--guard <why>] [--merge <key>]... [--proof <key>] [--close done|release] [--filer <id>] [--ticket <id>] [--priority P0-P3]
88
88
  sign-off-axi raise [board.json] --from <decisions.json> [--filer <id>] [--ticket <id>] [--priority P0-P3]
89
89
  Adds questions to the board: yes/no, or 2 to 6 choices with one --option each. --key defaults to a slug of the title.
90
90
  raise is the alias for post --type decision: same flags, same output.
@@ -96,7 +96,13 @@ Every question meets the question standard (docs/contract.md, "Asking a good que
96
96
  --why says what it is about and why it needs an answer now (200 characters or fewer).
97
97
  --outcome <value>=<text> says what happens next if that answer is picked (120 or fewer), one for every option: yes and no without --option.
98
98
  --recommend names the answer you would pick, --recommend-reason why in one line (160 or fewer).
99
- --default is what happens if the user stays quiet, shown and never applied; --silent-by (an ISO UTC time such as 2026-10-18T09:00Z) says when.
99
+ --default is what happens if the user stays quiet; --silent-by (an ISO UTC time such as 2026-10-18T09:00Z) says when. The leash (leash: in config.yaml, the board's Leash control) says what is applied:
100
+ ask (Ask me first): nothing; every ask waits for the user.
101
+ matters (When it matters, the default): at --silent-by with no answer, the open board takes --default as the answer, saved with taken: default; an ask with no --silent-by waits.
102
+ act (Act, then tell me): raise takes your --recommend at once, saved with taken: agent; go ahead with it. The user sees it on the card and can change it.
103
+ Either way wait and answers hand it over like an answer, with its taken; the user can still answer or change it, and a change comes back with replaces.
104
+ --guard destructive|irreversible|money|credential|security marks an ask only the user may answer: no default or pick is ever taken for it, whatever the leash.
105
+ An ask whose title, why or outcomes talk of deleting, money, passwords, keys, access or what can't be undone is guarded the same way without it; designs, plans, sign-ins, gates and findings always wait.
100
106
  The buttons match the text: a title or why that names choices A, B, C needs one --option per letter, each label starting with its letter, never Yes/No.
101
107
  yes and no are labelled Yes and No; a question never takes Done, OK or Not now buttons.
102
108
  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:
@@ -117,7 +123,7 @@ examples:
117
123
  `,
118
124
  validate: `usage: sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid]
119
125
  Checks message files against the communication contract without posting them: ${CONTRACT}
120
- A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, evidence, gate, finding and design at schema 1, report at schema 5 (schemas 2 to 4 still pass) and plan at schema 3 (schemas 1 and 2 still pass).
126
+ A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, evidence, gate and finding at schema 1, design at schema 2 (schema 1 still passes), report at schema 5 (schemas 2 to 4 still pass) and plan at schema 3 (schemas 1 and 2 still pass).
121
127
  A report 3 to 5 and a plan 2 or 3 are guided pages: fields the board draws as one scroll, no page of your own (see report init). A report 5 names what it is for in "purpose".
122
128
  An evidence message's media paths read from the message file's folder: each file must be there, inside it, and 50 MB or less.
123
129
  A report is a page to tap through: its "view" (.html) and "preview" (its picture, beside it) are checked for their shape here; the files themselves are checked at post, from the board file's folder, filled with no FILL: lines left (see report init).
@@ -273,7 +279,10 @@ examples:
273
279
  sign-off-axi post --kind finding --from finding.json --filer learn-from-mistakes
274
280
  sign-off-axi post --kind plan --from plan.json --filer agent-41
275
281
  sign-off-axi post --kind design --from design.json --filer agent-41
276
- A design message lists project journeys, per-screen image or HTML view variants, and a card preview; post checks every asset stays under the board folder.
282
+ A design (schema 2) asks one plain question Yes answers: its title, 12 words or fewer, ending in "?". Each screen shows 1-3 frames side by side (today, built or proposed, target),
283
+ each with the changed area as fractions of its picture and a one-line caption, and "accepts" names the frame Yes approves. Post refuses a new design without a plain question title,
284
+ words for agents in what the person reads (task or branch names, PR or #number, slice, agent or ticket codes, file names, commits), and two frames that look the same;
285
+ it checks every asset stays under the board folder. See docs/contract.md (design, schema 2) and fixtures/contract/design.v2.
277
286
  A design shows under Designs for Main to review, never in the user's Decide or any count of asks, until Main runs promote (see promote --help).
278
287
  sign-off-axi post .sign-off/decisions.json --type interactive --title "Does the sign-in flow feel right?" --view mocks/sign-in/demo.html --option yes=Yes --option no="Needs work" --recommend yes
279
288
  sign-off-axi post --type evidence --title "Sign-in screen on a phone" --about "The new screen loads in 1 second." --media phone.png --media flow.mp4 --link https://github.com/acme/app/pull/12
@@ -352,18 +361,19 @@ examples:
352
361
  `,
353
362
  promote: `usage: sign-off-axi promote <key> [board.json] [--undo]
354
363
  For Main only, never a lead. A design review a lead posts (post --kind design) shows under Designs, where Main reviews it; it never waits in Decide or counts as the user's ask.
355
- Once Main has passed every screen, the lead posts one design per product that opens all its screens, phone and desktop, and Main promotes that one card:
364
+ Once Main has passed a design, Main promotes that one card (a design 2 asks the user one plain question, with Yes or Change):
356
365
  it then waits in Decide, counts there and notifies like any ask until the user answers it. --undo puts it back under Designs only.
357
366
  It writes one line to <board>.promoted.jsonl beside the board; no post, feed or board request writes that file.
358
367
  examples:
359
368
  sign-off-axi promote fleet-dashboard-prototype
360
369
  sign-off-axi promote fleet-dashboard-prototype --undo
361
370
  `,
362
- feedback: `usage: sign-off-axi feedback <key> [board.json] (--read | --reply <text> [--link <url>] | --acted --link <url>) [--filer <id>]
371
+ feedback: `usage: sign-off-axi feedback <key> [board.json] (--delivered | --read | --reply <text> [--link <url>] | --acted --link <url>) [--at <epoch seconds>] [--filer <id>]
363
372
  Tells the person how far their answer, notes, new round or comment on a card got: the board shows each one as Sent, Delivered, Read and then Replied, on the card, in its Activity,
364
373
  as a badge in the lists, and a reply in Today. A wait hands an answer over and marks it (read when it is the filer's own wait, else delivered), and an on_answer hook that ran marks it delivered.
365
374
  --read: the agent has read what was sent. --reply: what the agent did or says back, 500 characters or fewer, with --link to the new version or the pull request.
366
- --acted --link: the agent acted, and the link shows what changed (the merge hook uses this). Posting the card again marks what was sent before it as acted on too.
375
+ --delivered: it was handed to the agent. --acted --link: the agent acted, and the link shows what changed (the merge hook uses this).
376
+ --at <epoch seconds>: the step happened then (replaying history). It cannot come before the send or lie in the future, a step the card already shows is left alone (nothing is written, and a reply never replaces one), so a replay never turns a later real state back into an older one and can be run twice. Posting the card again marks what was sent before it as acted on too.
367
377
  A mark covers everything sent on that card up to now. --filer refuses a card that filer did not post. It writes one line to <board>.trail.jsonl beside the board.
368
378
  examples:
369
379
  sign-off-axi feedback fleet-dashboard-full --read
@@ -492,7 +502,7 @@ function fits(title, key) {
492
502
  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`);
493
503
  }
494
504
  /** 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. */
495
- const ASK_FLAGS = ['why', 'outcome', 'recommend-reason', 'silent-by', 'merge', 'proof', 'report'];
505
+ const ASK_FLAGS = ['why', 'outcome', 'recommend-reason', 'silent-by', 'guard', 'merge', 'proof', 'report'];
496
506
  const MANY = ['option', 'outcome', 'merge'];
497
507
  /** Board types that are contract kinds: a decision (the default type) is an ask. */
498
508
  const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report', 'plan', 'design'];
@@ -553,7 +563,7 @@ function contractItems(items, from, file, replace = false) {
553
563
  });
554
564
  }
555
565
  function designFiles(m, base) {
556
- const paths = [m.preview, ...m.journeys.flatMap((j) => j.screens.flatMap((s) => Object.values(s.views)))];
566
+ const paths = [m.preview, ...m.journeys.flatMap((j) => j.screens.flatMap(screenViews))];
557
567
  for (const path of paths) {
558
568
  let actual;
559
569
  try {
@@ -575,6 +585,19 @@ function designFiles(m, base) {
575
585
  throw new ContractError(`design path ${JSON.stringify(path)} cannot be read`, 'Make the design view readable');
576
586
  }
577
587
  }
588
+ // Two frames of a screen that look the same compare nothing: the person would look for a change that is not there. Under 0.1% of pixels moved is the same look.
589
+ for (const j of m.journeys)
590
+ for (const s of j.screens)
591
+ (s.frames ?? []).forEach((f, i, frames) => {
592
+ for (const g of frames.slice(0, i))
593
+ for (const [v, p] of Object.entries(f.views)) {
594
+ if (g.views[v] === undefined)
595
+ continue;
596
+ const share = differs(readFileSync(resolve(base, g.views[v])), extname(g.views[v]), readFileSync(resolve(base, p)), extname(p));
597
+ if (share !== undefined && share < 0.001)
598
+ throw new ContractError(`screen ${s.id}: the ${g.role} and ${f.role} frames look the same (${v}: ${+(share * 100).toPrecision(2)}% of pixels differ)`, `Drop the ${g.role === 'today' ? 'today' : f.role} frame, or capture the screen in the state that shows the change`);
599
+ }
600
+ });
578
601
  }
579
602
  const RAISE_FIELDS = ['title', 'key', 'about', 'detail', 'option', 'recommend', 'default', 'close', ...ASK_FLAGS];
580
603
  /** Parses one command's flags strictly; `on` carries a given board file into the next-step hints. */
@@ -723,7 +746,7 @@ function printAnswers(file, rows, waiting, values, help) {
723
746
  const gone = rows.flatMap((d) => (d.discarded ? [{ key: d.key, reason: d.discarded.reason, label: DISCARD_REASONS[d.discarded.reason], at: d.discarded.at, title: d.title }] : []));
724
747
  if (values.json) {
725
748
  const asked = (key) => rounds.filter((r) => r.key === key).map(({ note, at, comments }) => ({ note, at, comments }));
726
- const out = Object.fromEntries(done.map(({ key, answer, label, close, at, note, comments, notes, decisions, pick, replaces }) => [key, { answer, label, close, at, note, comments, notes, decisions, pick, replaces, ...(asked(key).length && { rounds: asked(key) }) }]));
749
+ const out = Object.fromEntries(done.map(({ key, answer, label, close, at, note, comments, notes, decisions, pick, replaces, taken, reason }) => [key, { answer, label, close, at, note, comments, notes, decisions, pick, replaces, taken, reason, ...(asked(key).length && { rounds: asked(key) }) }]));
727
750
  for (const r of rounds)
728
751
  if (!out[r.key])
729
752
  out[r.key] = { rounds: asked(r.key) };
@@ -731,7 +754,7 @@ function printAnswers(file, rows, waiting, values, help) {
731
754
  out[key] = { ...out[key], discarded: { reason, label, at } };
732
755
  return JSON.stringify(out);
733
756
  }
734
- const notes = done.some((a) => a.note), changed = done.some((a) => a.replaces), sent = [...done, ...rounds].flatMap(({ key, comments = [] }) => comments.map(({ mock, ref, tag, words, text }) => ({ key, mock, ref, tag, words, text })));
757
+ const notes = done.some((a) => a.note), changed = done.some((a) => a.replaces), took = done.some((a) => a.taken), sent = [...done, ...rounds].flatMap(({ key, comments = [] }) => comments.map(({ mock, ref, tag, words, text }) => ({ key, mock, ref, tag, words, text })));
735
758
  const plan = done.flatMap(({ key, notes: sections = [], decisions: picks = {} }) => [
736
759
  ...sections.map((n) => ({ key, section: n.section ?? `${n.journey}/${n.screen}`, verdict: n.verdict ?? 'change', note: n.note ?? '' })),
737
760
  ...Object.entries(picks).map(([decision, pick]) => ({ key, decision, pick })),
@@ -739,7 +762,8 @@ function printAnswers(file, rows, waiting, values, help) {
739
762
  return toon({
740
763
  board: tilde(file),
741
764
  // A changed answer names the one it replaces, which the agent may already have acted on.
742
- answers: done.length ? done.map(({ key, answer, at, label, close, title, note = '', replaces }) => ({ key, answer, at, ...(values.full && { label, close, title }), ...(notes && { note }), ...(changed && { replaces: replaces?.answer ?? '' }) })) : '0 saved yet',
765
+ // An answer the leash took says how: default (the board took the ask's default at its silent_by) or agent (your recommended pick, on Act, then tell me).
766
+ answers: done.length ? done.map(({ key, answer, at, label, close, title, note = '', replaces, taken = '' }) => ({ key, answer, at, ...(values.full && { label, close, title }), ...(notes && { note }), ...(changed && { replaces: replaces?.answer ?? '' }), ...(took && { taken }) })) : '0 saved yet',
743
767
  ...(rounds.length && { rounds: rounds.map(({ key, note, at }) => ({ key, note, at })) }),
744
768
  // Comments on mock elements that went with an answer or a round, each with the element it points at.
745
769
  ...(sent.length && { comments: sent }),
@@ -747,7 +771,7 @@ function printAnswers(file, rows, waiting, values, help) {
747
771
  ...(plan.length && { plan }),
748
772
  ...(gone.length && { discarded: gone.map(({ key, label, at }) => ({ key, reason: label, at })) }),
749
773
  waiting,
750
- }, help);
774
+ }, [...(took ? ['taken: default is the default the board took at the ask\'s silent_by, agent your own recommended pick (the leash Act, then tell me): go ahead, and keep waiting, as the person can still change it'] : []), ...help]);
751
775
  }
752
776
  function answers(args) {
753
777
  const { file, values, on } = parse('answers', args, { ...TIERS, key: { type: 'string' } });
@@ -770,7 +794,7 @@ function eventLines(file, rows, answers, rounds) {
770
794
  const kind = kindOf(file), item = new Map(rows.map((d) => [d.key, d]));
771
795
  const head = (event, key) => ({ event, ...kind(key), key, title: item.get(key)?.title });
772
796
  const tail = (key, comments) => ({ ...(item.get(key)?.filer && { filer: item.get(key).filer }), ...(comments?.length && { comments }) });
773
- return [...answers.map(({ key, answer, label, close, at, note, comments, notes, decisions, pick, replaces }) => ({ at, line: { ...head('answer', key), answer, label, close, at, ...(note && { note }), ...(notes && { notes, decisions }), ...(pick !== undefined && { pick }), ...(replaces && { replaces }), ...tail(key, comments) } })),
797
+ return [...answers.map(({ key, answer, label, close, at, note, comments, notes, decisions, pick, replaces, taken, reason }) => ({ at, line: { ...head('answer', key), answer, label, close, at, ...(note && { note }), ...(notes && { notes, decisions }), ...(pick !== undefined && { pick }), ...(replaces && { replaces }), ...(taken && { taken, reason }), ...tail(key, comments) } })),
774
798
  ...rounds.map(({ key, note, at, comments }) => ({ at, line: { ...head('round', key), note, at, ...tail(key, comments) } }))]
775
799
  .sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)).map(({ line }) => JSON.stringify(line)).join('\n');
776
800
  }
@@ -1355,18 +1379,18 @@ function promote(args) {
1355
1379
  }
1356
1380
  /** feedback <key> [board.json] (--read | --reply <text> [--link <url>] | --acted --link <url>): the agent tells the person how far what they sent on a card got. */
1357
1381
  function feedback(args) {
1358
- const at = args.findIndex((a, i) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json') && !['--reply', '--link', '--filer'].includes(args[i - 1])), key = args[at];
1382
+ const at = args.findIndex((a, i) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json') && !['--reply', '--link', '--filer', '--at'].includes(args[i - 1])), key = args[at];
1359
1383
  if (at < 0 || !isKey(key))
1360
1384
  throw fail('feedback needs the key of a card', 'VALIDATION_ERROR', 'sign-off-axi feedback <key> [board.json] --read');
1361
- const { file, values, on } = parse('feedback', args.filter((_, i) => i !== at), { read: { type: 'boolean' }, reply: { type: 'string' }, acted: { type: 'boolean' }, link: { type: 'string' }, filer: { type: 'string' } });
1362
- const ops = [values.read && 'read', values.reply !== undefined && 'reply', values.acted && 'acted'].filter(Boolean);
1385
+ const { file, values, on } = parse('feedback', args.filter((_, i) => i !== at), { read: { type: 'boolean' }, delivered: { type: 'boolean' }, reply: { type: 'string' }, acted: { type: 'boolean' }, link: { type: 'string' }, filer: { type: 'string' }, at: { type: 'string' } });
1386
+ const ops = [values.read && 'read', values.delivered && 'delivered', values.reply !== undefined && 'reply', values.acted && 'acted'].filter(Boolean);
1363
1387
  if (ops.length !== 1)
1364
- throw fail('feedback takes one of --read, --reply <text> or --acted --link <url>', 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --read`);
1388
+ throw fail('feedback takes one of --delivered, --read, --reply <text> or --acted --link <url>', 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --read`);
1365
1389
  const text = typeof values.reply === 'string' ? values.reply.trim() : undefined, link = values.link;
1366
1390
  if (text !== undefined && (!text || [...text].length > NOTE_MAX))
1367
1391
  throw fail(`--reply must be text of ${NOTE_MAX} characters or fewer`, 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --reply "<what you did>"`);
1368
- if (link !== undefined && (values.read || !/^https?:\/\/[^\s]{1,500}$/.test(link)))
1369
- throw fail(values.read ? '--link goes with --reply or --acted' : '--link must be an http(s) address, such as the pull request', 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --acted --link <url>`);
1392
+ if (link !== undefined && (values.read || values.delivered || !/^https?:\/\/[^\s]{1,500}$/.test(link)))
1393
+ throw fail(values.read || values.delivered ? '--link goes with --reply or --acted' : '--link must be an http(s) address, such as the pull request', 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --acted --link <url>`);
1370
1394
  if (values.acted && !link)
1371
1395
  throw fail('--acted needs --link <url> to what changed', 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --acted --link <url>`);
1372
1396
  ready(file, on);
@@ -1378,9 +1402,22 @@ function feedback(args) {
1378
1402
  const sent = readTrail(file, loadDecisions(file)).filter((t) => t.key === key && t.kind !== 'ticket');
1379
1403
  if (!sent.length)
1380
1404
  throw fail(`nothing was sent on ${key} yet`, 'NOT_FOUND', `Run \`sign-off-axi answers${on} --key ${key}\` to see its answer`);
1381
- appendMark(file, { op: values.read ? 'read' : 'reply', key, ...(text && { text }), ...(link && { link }) });
1405
+ // --at <epoch seconds> records a step that happened earlier (replaying history). It cannot precede the send, a step the card already shows is left as it is, and it
1406
+ // never writes a reply over one that exists, so a replay never turns a later real state back into an older one; run twice it writes once.
1407
+ const op = values.read ? 'read' : values.delivered ? 'delivered' : 'reply';
1408
+ if (values.at !== undefined) {
1409
+ const secs = /^\d{9,11}$/.test(values.at) ? +values.at : NaN, first = Math.min(...sent.map((t) => Math.floor(Date.parse(t.at) / 1000)));
1410
+ if (!Number.isFinite(secs) || secs > Math.floor(Date.now() / 1000))
1411
+ throw fail('--at must be whole seconds since 1970, not in the future', 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --read --at 1792800000`);
1412
+ if (secs < first)
1413
+ throw fail(`--at ${values.at} is before what was sent on ${key} (${first}): a step cannot come before its send`, 'VALIDATION_ERROR', 'Use the time the step happened');
1414
+ const have = sent.some((t) => op === 'reply' ? t.reply : (t[op] ?? t.reply?.at) !== undefined);
1415
+ if (have)
1416
+ return toon({ board: tilde(file), key, shows: sent.at(-1).reply ? 'Replied' : sent.at(-1).read ? 'Read' : 'Delivered', covers: sent.length, unchanged: 'yes' }, [`${key} already shows that step; nothing was written`]);
1417
+ }
1418
+ appendMark(file, { op, key, ...(text && { text }), ...(link && { link }) }, values.at !== undefined ? new Date(+values.at * 1000).toISOString().replace(/\.\d+Z$/, 'Z') : undefined);
1382
1419
  const now = readTrail(file, loadDecisions(file)).filter((t) => t.key === key && t.kind !== 'ticket').at(-1);
1383
- return toon({ board: tilde(file), key, shows: now.reply ? 'Replied' : 'Read', covers: sent.length }, [values.read ? `Run \`sign-off-axi feedback ${key}${on} --reply "<what you did>"\` once you act on it` : `Run \`sign-off-axi wait${on}${filer ? ` --filer ${filer}` : ''}\` for the next answer`]);
1420
+ return toon({ board: tilde(file), key, shows: now.reply ? 'Replied' : now.read ? 'Read' : 'Delivered', covers: sent.length }, [values.read || values.delivered ? `Run \`sign-off-axi feedback ${key}${on} --reply "<what you did>"\` once you act on it` : `Run \`sign-off-axi wait${on}${filer ? ` --filer ${filer}` : ''}\` for the next answer`]);
1384
1421
  }
1385
1422
  /** ticket [board.json] [--id <id> [--title <text>] [--progress <0-100>] [--parent <id>] [--priority P0-P3] [--project <name>] [--hold yes|no] [--asked yes|no]]: lists the tickets,
1386
1423
  * shows one with the cards that name it, or writes one. A ticket stands alone in <board>.tickets.jsonl: cards name it with --ticket, and nothing else reads it.
@@ -1756,7 +1793,7 @@ function postMedia(file, named, on, values, type, filer, message) {
1756
1793
  writeFileSync(join(dir, `${i + 1}${extname(src).toLowerCase()}`), bytes, { mode: 0o600 });
1757
1794
  // The original stays as it was posted; the board shows a lighter copy of a still where one weighs less.
1758
1795
  const made = isStill(src) ? copiesOf(bytes, extname(src)) : {}, refs = {};
1759
- for (const size of ['light', 'thumb']) {
1796
+ for (const size of ['light', 'thumb', 'tile']) {
1760
1797
  const c = made[size];
1761
1798
  if (!c)
1762
1799
  continue;
@@ -1849,8 +1886,9 @@ async function postKind(file, named, on, values, built) {
1849
1886
  throw new ContractError(`${f} ${typeof m[f] === 'string' ? m[f] : JSON.stringify(m[f])} is not --${f} ${values[f]}`, `Leave "${f}" out of the message, or pass the same --${f}`);
1850
1887
  m[f] = values[f];
1851
1888
  }
1852
- const existingAsk = !built && kind === 'ask' && existsSync(file) && readItems(file).items.some((i) => i?.key === m.key);
1853
- checkMessage(m, built ? 'report' : kind, !built && kind === 'ask' && !existingAsk);
1889
+ // A new ask is held to the question standard, and so is a new design's title; a card already on the board still re-posts as it was.
1890
+ const existing = !built && ['ask', 'design'].includes(kind) && existsSync(file) && readItems(file).items.some((i) => i?.key === m.key);
1891
+ checkMessage(m, built ? 'report' : kind, !built && kind === 'ask' && !existing, !built && kind === 'design' && !existing);
1854
1892
  // Media paths read from the message file's folder, or from the current folder for stdin.
1855
1893
  if (m.kind === 'evidence')
1856
1894
  sources = mediaFiles(m, from === '-' ? process.cwd() : dirname(resolve(from)));
@@ -1984,7 +2022,7 @@ function buildIncoming(values, extra, filer, file) {
1984
2022
  try {
1985
2023
  const { title, key = slug(title), about, detail, why, recommend: recommend_value, default: default_if_silent, close } = values;
1986
2024
  const ask = !Object.hasOwn(extra, 'type'), options = ask ? outcomesOf(values) : optionsOf(values);
1987
- const told = ask && { why, recommend_reason: values['recommend-reason'], silent_by: values['silent-by'], merges: values.merge, proof: values.proof, report: values.report, ask_ref: values['ask-ref'] };
2025
+ const told = ask && { why, recommend_reason: values['recommend-reason'], silent_by: values['silent-by'], guard: values.guard, merges: values.merge, proof: values.proof, report: values.report, ask_ref: values['ask-ref'] };
1988
2026
  writeFileSync(source, JSON.stringify({ schema: 2, items: [{ key, title, about, detail, ...told, options, recommend_value, default_if_silent, close, ticket: values.ticket, priority: values.priority, ...extra, filer }] }));
1989
2027
  const incoming = loadDecisions(source, board), raw = readItems(source).items;
1990
2028
  if (!ask)
@@ -2095,18 +2133,27 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
2095
2133
  }
2096
2134
  return { key: d.key, status: !had ? 'raised' : had.answer && !awaitsVerdict(had.type, had.answer) ? `already answered ${had.answer.answer}` : waits({ ...had, dismissed: false }) ? 'already waiting' : 'already posted', title: d.title };
2097
2135
  });
2136
+ let picked = [];
2098
2137
  if (raised.some((r) => r.status === 'raised')) {
2099
2138
  const before = waitingKeys(file);
2100
2139
  writeItems(file, items);
2140
+ // On the leash Act, then tell me, a new ask takes its agent's recommended pick at once: the person sees it and can still change it.
2141
+ if (leashOf(named) === 'act')
2142
+ picked = leashTake(file, 'act', raised.flatMap((r) => (r.status === 'raised' ? [r.key] : []))).took.map(({ a }) => a);
2143
+ for (const a of picked)
2144
+ raised.find((r) => r.key === a.key).status = `picked ${a.answer}`;
2101
2145
  notifyEntered(file, named, before);
2102
2146
  }
2103
2147
  const key = raised.length === 1 ? ` --key ${raised[0].key}` : '';
2148
+ const goAhead = picked.map((a) => `The leash is Act, then tell me: go ahead with ${a.label} on ${a.key}; the person can still change it`);
2104
2149
  // Reports take no answer, so a post with nothing waiting asks the user to read it, never to answer.
2105
- const anyWaiting = incoming.some((d, i) => waits(d) && !raised[i].status.startsWith('already answered'));
2150
+ const anyWaiting = incoming.some((d, i) => waits(d) && !raised[i].status.startsWith('already answered') && !raised[i].status.startsWith('picked'));
2151
+ if (picked.length && !anyWaiting)
2152
+ return toon({ board: tilde(file), raised }, [...goAhead, ...(filer ? [`Run \`sign-off-axi wait${on} --filer ${filer}\` to hear if the person changes it`] : [`Run \`sign-off-axi answers${on}${key}\` to read ${key ? 'the answer' : 'the answers'}`])]);
2106
2153
  if (incoming.length && !anyWaiting)
2107
2154
  return toon({ board: tilde(file), raised }, [`Run \`sign-off-axi open${on}\` so the user can read ${incoming.length > 1 ? 'them' : 'it'}`]);
2108
2155
  const open = anyWaiting ? [`Run \`sign-off-axi open${on}\` so the user can answer`] : [];
2109
- return toon({ board: tilde(file), raised }, [...open, ...(filer ? [waitFor(on, filer)] : []), `Run \`sign-off-axi answers${on}${key}\` to read ${key ? 'the answer' : 'the answers'}`]);
2156
+ return toon({ board: tilde(file), raised }, [...goAhead, ...open, ...(filer ? [waitFor(on, filer)] : []), `Run \`sign-off-axi answers${on}${key}\` to read ${key ? 'the answer' : 'the answers'}`]);
2110
2157
  });
2111
2158
  }
2112
2159
  async function open(args) {
@@ -2853,9 +2900,12 @@ async function serve(args) {
2853
2900
  return;
2854
2901
  await sweep('periodic');
2855
2902
  reap();
2903
+ await holdLeash();
2856
2904
  if (server.listening)
2857
2905
  setTimeout(repeat, 5_000).unref();
2858
2906
  };
2907
+ // Defaults that fell due while the board was closed are taken now; the next is timed to its silent_by, and every pass looks again.
2908
+ await holdLeash();
2859
2909
  setTimeout(repeat, 5_000).unref();
2860
2910
  const url = `http://${urlHost(bind)}:${bound}/`;
2861
2911
  process.on('exit', () => { if (existsSync(record(file)) && readFileSync(record(file), 'utf8').split(' ')[0] === String(process.pid))
@@ -2925,7 +2975,7 @@ async function exportBoard(args) {
2925
2975
  if (d.preview)
2926
2976
  await inline(at(d.key, '', d.preview));
2927
2977
  for (const [i, m] of (d.media ?? []).entries())
2928
- for (const v of ['', ...['light', 'thumb'].filter((v) => m[v])])
2978
+ for (const v of ['', ...['light', 'thumb', 'tile'].filter((v) => m[v])])
2929
2979
  await inline(`/api/media?key=${q(d.key)}&i=${i}${v && `&v=${v}`}`);
2930
2980
  for (const m of d.evidenceCard?.media ?? [])
2931
2981
  await inline(m.url);
@@ -2955,16 +3005,28 @@ const squeeze = (req, bytes) => {
2955
3005
  return { bytes: gzipSync(bytes, { level: 6 }), encoding: 'gzip' };
2956
3006
  return { bytes };
2957
3007
  };
2958
- const pageTag = `"${createHash('sha256').update(page).digest('hex').slice(0, 32)}"`;
3008
+ /** The board's design tokens, shared parts and typeface, favicon, home-screen icon and install manifest, served beside the page; see web/tokens.css, web/ui.css and web/icon.svg. */
3009
+ const ICONS = Object.fromEntries([['tokens.css', 'text/css; charset=utf-8'], ['ui.css', 'text/css; charset=utf-8'], ['inter.woff2', 'font/woff2'], ['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']]
3010
+ .map(([name, type]) => [`/${name}`, { type, bytes: readFileSync(new URL(`../web/${name}`, import.meta.url)) }]));
3011
+ /** The board as an app: pwa.js (the install row and the offline line, named by its content like the stylesheets) and sw.js (the service worker, which must sit at the root and is asked again each time). */
3012
+ const APP = { pwa: readFileSync(new URL('../web/pwa.js', import.meta.url)), worker: readFileSync(new URL('../web/sw.js', import.meta.url)) };
3013
+ /** The iPad launch images (see scripts/make-splash.mjs), named <width>x<height>-<theme> in points, each linked for its own device, orientation and theme. */
3014
+ const SPLASH = readdirSync(new URL('../web/', import.meta.url)).flatMap((f) => { const m = /^splash-(\d+)x(\d+)-(light|dark)\.png$/.exec(f); return m ? [{ file: f, w: +m[1], h: +m[2], theme: m[3] }] : []; });
3015
+ for (const { file } of SPLASH)
3016
+ ICONS[`/${file}`] = { type: 'image/png', bytes: readFileSync(new URL(`../web/${file}`, import.meta.url)) };
3017
+ const splashLinks = SPLASH.map(({ file, w, h, theme }) => `<link rel="apple-touch-startup-image" href="${file}" media="(device-width: ${w}px) and (device-height: ${h}px) and (-webkit-device-pixel-ratio: 2) and (orientation: ${w < h ? 'portrait' : 'landscape'}) and (prefers-color-scheme: ${theme})">`).join('\n');
3018
+ /** The page names its two stylesheets by their content, so a phone holding an older copy (kept a day) fetches the new one with the new page: a row's badge is never drawn by half of an upgrade. */
3019
+ const served = Buffer.from(page.toString().replace(/href="(tokens|ui)\.css"/g, (_, name) => `href="${name}.css?v=${createHash('sha256').update(ICONS[`/${name}.css`].bytes).digest('hex').slice(0, 12)}"`)
3020
+ .replace('</head>', () => `${splashLinks}\n<script src="pwa.js?v=${createHash('sha256').update(APP.pwa).digest('hex').slice(0, 12)}" defer></script>\n</head>`));
3021
+ /** The worker's cache is named by the whole build, so a changed page, stylesheet or worker replaces the kept copy at the next visit and never mixes two. */
3022
+ const worker = Buffer.from(`const BUILD = ${JSON.stringify(createHash('sha256').update(served).update(APP.worker).update(APP.pwa).digest('hex').slice(0, 16))};\n${APP.worker}`);
3023
+ const pageTag = `"${createHash('sha256').update(served).digest('hex').slice(0, 32)}"`;
2959
3024
  // Level 9 takes about 50 ms and level 11 over half a second for 7% less, so the page is squeezed at 9.
2960
3025
  const squeezed = {};
2961
3026
  const pageAs = (encoding) => (squeezed[encoding] ??= encoding === 'br'
2962
- ? brotliCompressSync(page, { params: { [zlib.BROTLI_PARAM_QUALITY]: 9, [zlib.BROTLI_PARAM_SIZE_HINT]: page.length } }) : gzipSync(page, { level: 9 }));
3027
+ ? brotliCompressSync(served, { params: { [zlib.BROTLI_PARAM_QUALITY]: 9, [zlib.BROTLI_PARAM_SIZE_HINT]: served.length } }) : gzipSync(served, { level: 9 }));
2963
3028
  /** The browser keeps a reply and asks again with its tag, so an unchanged page, state or file costs a 304 and no body. */
2964
3029
  const unchanged = (req, tag) => req.headers['if-none-match']?.split(/\s*,\s*/).includes(tag) ?? false;
2965
- /** The board's design tokens, shared parts and typeface, favicon, home-screen icon and install manifest, served beside the page; see web/tokens.css, web/ui.css and web/icon.svg. */
2966
- const ICONS = Object.fromEntries([['tokens.css', 'text/css; charset=utf-8'], ['ui.css', 'text/css; charset=utf-8'], ['inter.woff2', 'font/woff2'], ['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']]
2967
- .map(([name, type]) => [`/${name}`, { type, bytes: readFileSync(new URL(`../web/${name}`, import.meta.url)) }]));
2968
3030
  /** Added to each design mock page and plan page so the person can point at a part of it; see web/point.js. */
2969
3031
  const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
2970
3032
  const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
@@ -3047,6 +3109,14 @@ const warmGuard = () => new Promise((done) => {
3047
3109
  * 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. */
3048
3110
  const LEASHES = ['ask', 'matters', 'act'];
3049
3111
  const configLine = (text, key) => new RegExp(`^[ \\t]*${key}[ \\t]*:([^#\\n]*)`, 'm').exec(text)?.[1].trim() || undefined;
3112
+ const leashIn = (text) => ((leash) => (LEASHES.includes(leash) ? leash : 'matters'))(configLine(text, 'leash') ?? '');
3113
+ /** The leash of the config.yaml beside a board: matters when it has none, or no config.yaml. */
3114
+ const leashOf = (named) => { try {
3115
+ return leashIn(readFileSync(join(dirname(named), 'config.yaml'), 'utf8'));
3116
+ }
3117
+ catch {
3118
+ return 'matters';
3119
+ } };
3050
3120
  /** The user's name and leash; none for a board with no config.yaml beside it. */
3051
3121
  function profile() {
3052
3122
  let text;
@@ -3056,8 +3126,34 @@ function profile() {
3056
3126
  catch {
3057
3127
  return {};
3058
3128
  }
3059
- const leash = configLine(text, 'leash') ?? '';
3060
- return { name: configLine(text, 'name')?.slice(0, 40), leash: LEASHES.includes(leash) ? leash : 'matters' };
3129
+ return { name: configLine(text, 'name')?.slice(0, 40), leash: leashIn(text) };
3130
+ }
3131
+ /** What the leash answers for the person; run under the board lock, so a raise and the board never both take one ask. On Act, then tell me an open plain ask takes its
3132
+ * recommended pick at once (the agent's own pick, made when it is raised); on When it matters, and on Act for an ask with no pick, the board takes its default once its
3133
+ * silent_by has passed. A guarded ask (guardOf), one with neither, an ask a waiting ask merges, and every ask on Ask me first wait for the person.
3134
+ * `raised` limits it to the agent picks of those keys, as a raise takes them. Returns what it took and when the next default falls due. */
3135
+ function leashTake(file, leash, raised) {
3136
+ const took = [];
3137
+ let next;
3138
+ if (leash === 'ask' || !existsSync(file) || readItems(file).extra.readonly === true)
3139
+ return { took };
3140
+ const rows = board(file, () => { }), at = Date.now();
3141
+ for (const d of rows) {
3142
+ if ((raised && !raised.includes(d.key)) || d.answer || d.discarded || !plainAsk(d) || guardOf(d) || rows.some((x) => !x.answer && x.merges?.includes(d.key)))
3143
+ continue;
3144
+ const pick = leash === 'act' ? d.options.find((o) => o.value === d.recommend_value) : undefined, fallback = d.options.find((o) => o.value === d.default_if_silent);
3145
+ const due = d.silent_by ? Date.parse(d.silent_by) : NaN, save = (o, how) => { const a = appendAnswer(file, d, o, '', [], how); if (a)
3146
+ took.push({ d, a }); };
3147
+ if (pick)
3148
+ save(pick, { taken: 'agent', reason: `The leash is Act, then tell me: ${d.filer ?? 'the agent'} took its recommended pick` });
3149
+ else if (raised || !fallback || Number.isNaN(due))
3150
+ continue;
3151
+ else if (due <= at)
3152
+ save(fallback, { taken: 'default', reason: `No answer by ${d.silent_by}: the leash When it matters took the default` });
3153
+ else
3154
+ next = Math.min(next ?? due, due);
3155
+ }
3156
+ return { took, next };
3061
3157
  }
3062
3158
  const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3063
3159
  const state = () => {
@@ -3073,7 +3169,7 @@ const state = () => {
3073
3169
  mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
3074
3170
  // Who filed an item stays in the file, except as `by`, the name a card's thread says posted it and took its answer: never a bare session id, which names nobody.
3075
3171
  // A sign-in's start command stays on this host too: the page only learns that a tap makes a new link, and how the last run went.
3076
- decisions: items.filter((d) => !fakeEvidence(d)).map(({ filer, start, ...d }) => ({ ...d, ...(filer && !SESSION_ID.test(filer) && { by: filer }), ...(d.type === 'design' && answers[d.key]?.answer === 'approve' && { status: 'approved' }), ...(d.type === 'design' && designState(d, answers[d.key], lines)), ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
3172
+ decisions: items.filter((d) => !fakeEvidence(d)).map(({ filer, start, ...d }) => ({ ...d, ...(filer && !SESSION_ID.test(filer) && { by: filer }), ...(plainAsk(d) && guardOf(d) && { guard: guardOf(d) }), ...(d.type === 'design' && answers[d.key]?.answer === 'approve' && { status: 'approved' }), ...(d.type === 'design' && designState(d, answers[d.key], lines)), ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
3077
3173
  ...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
3078
3174
  readonly: extra.readonly === true,
3079
3175
  answers_only: extra.answers_only === true,
@@ -3114,7 +3210,7 @@ const mockHash = (path) => {
3114
3210
  }
3115
3211
  };
3116
3212
  /** A design's files as the board serves them: its preview and every screen view. */
3117
- const designPaths = (d) => [...new Set([d.preview, ...(d.journeys ?? []).flatMap((j) => j.screens.flatMap((s) => Object.values(s.views)))])].filter((p) => !!p);
3213
+ const designPaths = (d) => [...new Set([d.preview, ...(d.journeys ?? []).flatMap((j) => j.screens.flatMap(screenViews))])].filter((p) => !!p);
3118
3214
  /** A design's round: its time, screens and options and the bytes of its files, so the same key posted again with a new time or a changed screen is a new round. */
3119
3215
  const designRound = (d) => sha256(Buffer.from(JSON.stringify([d.filed, d.journeys, d.options, designPaths(d).map((p) => [p, mockHash(p)])])));
3120
3216
  /** What the page needs to know of a design beyond its fields: whether its answer is from an earlier round, the last answer given on an earlier round
@@ -3226,7 +3322,7 @@ async function route(req) {
3226
3322
  if (req.method === 'GET' && req.url?.startsWith('/design/')) {
3227
3323
  const [, , , key, ...parts] = new URL(req.url, 'http://localhost').pathname.split('/');
3228
3324
  const path = parts.map(decodeURIComponent).join('/'), d = loadDecisions(file).find(x => x.key === key && x.type === 'design');
3229
- const allowed = d?.preview === path || d?.journeys?.some(j => j.screens.some(s => Object.values(s.views).includes(path)));
3325
+ const allowed = d?.preview === path || d?.journeys?.some(j => j.screens.some(s => screenViews(s).includes(path)));
3230
3326
  const type = assetTypes[extname(path).toLowerCase()];
3231
3327
  try {
3232
3328
  if (!allowed || !type)
@@ -3280,9 +3376,9 @@ async function route(req) {
3280
3376
  }
3281
3377
  if (req.method === 'GET' && req.url?.startsWith('/api/media?')) {
3282
3378
  const q = new URL(req.url, 'http://localhost').searchParams;
3283
- // v=light or v=thumb asks for the copy post made of a still; with no v, the file as it was posted.
3379
+ // v=light, v=thumb or v=tile asks for the copy post made of a still; with no v, the file as it was posted.
3284
3380
  const v = q.get('v'), posted = loadDecisions(file).find((d) => d.key === q.get('key'))?.media?.[Number(q.get('i') || NaN)];
3285
- const m = v === null ? posted : v === 'light' || v === 'thumb' ? posted?.[v] : undefined, type = m && mediaTypes[extname(m.path).toLowerCase()];
3381
+ const m = v === null ? posted : v === 'light' || v === 'thumb' || v === 'tile' ? posted?.[v] : undefined, type = m && mediaTypes[extname(m.path).toLowerCase()];
3286
3382
  if (!m || !type)
3287
3383
  return [404, { error: 'Evidence is unavailable' }];
3288
3384
  try {
@@ -3350,9 +3446,10 @@ async function route(req) {
3350
3446
  return [404, { error: 'Not found' }];
3351
3447
  if (readOnly())
3352
3448
  return [403, { error: 'This board is read-only' }];
3449
+ // The leash is the person's own setting in config.yaml, which the board applies to the answers it takes, so an answers-only board takes it too.
3353
3450
  // Dismiss only hides an item on this page and sends nothing to the feeding program, a reply opened (/api/trail) is only the person's own mark, and Go deeper and a comment
3354
3451
  // on a part of a guided page go to the page's own author through its wait, so an answers-only board takes them all; a comment on a mock, a round and a prompt are for the feeding program, below.
3355
- if (!['/api/answers', '/api/dismiss', '/api/signin', '/api/reads', '/api/trail', '/api/deeper', '/api/engagement', '/api/engagement/seen', '/api/annotations'].includes(req.url) && answersOnly())
3452
+ if (!['/api/answers', '/api/dismiss', '/api/signin', '/api/reads', '/api/trail', '/api/deeper', '/api/engagement', '/api/engagement/seen', '/api/annotations', '/api/leash'].includes(req.url) && answersOnly())
3356
3453
  return [403, { error: 'This board takes answers only' }];
3357
3454
  if (!req.headers['content-type']?.startsWith('application/json'))
3358
3455
  return [415, { error: 'Send JSON' }];
@@ -3445,6 +3542,7 @@ async function route(req) {
3445
3542
  }
3446
3543
  const line = /^[ \t]*leash[ \t]*:.*$/m;
3447
3544
  writeFileSync(config, line.test(text) ? text.replace(line, `leash: ${leash}`) : `${text.replace(/\n?$/, '\n')}leash: ${leash}\n`);
3545
+ await holdLeash();
3448
3546
  return [200, state()];
3449
3547
  }
3450
3548
  if (req.url === '/api/dismiss') {
@@ -3468,9 +3566,9 @@ async function route(req) {
3468
3566
  return [200, state()];
3469
3567
  }
3470
3568
  if (req.url === '/api/trail') {
3471
- // The person opened a card with a reply on it: the reply leaves Today.
3569
+ // The person opened a card with a reply on it, or an answer the leash took: the reply leaves Today, the answer leaves Taken without you.
3472
3570
  const { key } = JSON.parse(body) ?? {};
3473
- if (!readTrail(file, loadDecisions(file)).some((t) => t.key === key && t.reply))
3571
+ if (!readTrail(file, loadDecisions(file)).some((t) => t.key === key && (t.reply || t.taken)))
3474
3572
  return [404, { error: 'No reply on this card' }];
3475
3573
  appendMark(file, { op: 'seen', key });
3476
3574
  return [200, state()];
@@ -3575,6 +3673,27 @@ function answered(a, d) {
3575
3673
  child.stdin.on('error', () => { });
3576
3674
  child.stdin.end(`${JSON.stringify({ event: 'answer', ...kindOf(file)(a.key), ...a, ...(d && { title: d.title }), ...(d?.filer && { filer: d.filer }) })}\n`);
3577
3675
  }
3676
+ /** Takes what the leash allows now (leashTake) and times the next default to its silent_by: run when the board starts, every few seconds, and when the leash changes. */
3677
+ let leashTimer;
3678
+ async function holdLeash() {
3679
+ clearTimeout(leashTimer);
3680
+ if (!server.listening)
3681
+ return;
3682
+ let next;
3683
+ try {
3684
+ ({ next } = await locked(`${file}.lock`, () => {
3685
+ const r = leashTake(file, profile().leash ?? 'matters');
3686
+ for (const { d, a } of r.took)
3687
+ answered(a, d);
3688
+ return r;
3689
+ }, 'another raise is still writing the board'));
3690
+ }
3691
+ catch (e) {
3692
+ warn(`The leash took no answers: ${e.message}`);
3693
+ }
3694
+ if (next !== undefined)
3695
+ leashTimer = setTimeout(holdLeash, Math.min(Math.max(0, next - Date.now()), 2 ** 31 - 1)).unref();
3696
+ }
3578
3697
  const runs = new Map(), failures = new Map();
3579
3698
  const SIGNIN_WAIT = 20_000, SIGNIN_LIFE = 600_000;
3580
3699
  const signinState = (key) => (runs.get(key)?.url ? 'live' : runs.has(key) ? 'starting' : failures.has(key) ? `failed: ${failures.get(key)}` : 'none');
@@ -3682,7 +3801,11 @@ const server = createServer(async (req, res) => {
3682
3801
  if (given !== null)
3683
3802
  return res.writeHead(303, { location: '/', 'set-cookie': `${cookie}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000` }).end();
3684
3803
  }
3685
- const icon = req.method === 'GET' ? ICONS[req.url ?? ''] : undefined;
3804
+ if (req.method === 'GET' && req.url === '/sw.js')
3805
+ return res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' }).end(worker);
3806
+ if (req.method === 'GET' && (req.url ?? '').split('?')[0] === '/pwa.js')
3807
+ return res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=86400' }).end(APP.pwa);
3808
+ const icon = req.method === 'GET' ? ICONS[(req.url ?? '').split('?')[0]] : undefined;
3686
3809
  if (icon)
3687
3810
  return res.writeHead(200, { 'content-type': icon.type, 'cache-control': 'public, max-age=86400' }).end(icon.bytes);
3688
3811
  if (req.method === 'GET' && req.url === '/favicon.ico')
@@ -3691,7 +3814,7 @@ const server = createServer(async (req, res) => {
3691
3814
  const headers = { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'self' 'unsafe-inline'", etag: pageTag, 'cache-control': 'no-cache', vary: 'accept-encoding' };
3692
3815
  if (unchanged(req, pageTag))
3693
3816
  return res.writeHead(304, headers).end();
3694
- const takes = String(req.headers['accept-encoding'] ?? ''), encoding = /\bbr\b/.test(takes) ? 'br' : /\bgzip\b/.test(takes) ? 'gzip' : undefined, bytes = encoding ? pageAs(encoding) : page;
3817
+ const takes = String(req.headers['accept-encoding'] ?? ''), encoding = /\bbr\b/.test(takes) ? 'br' : /\bgzip\b/.test(takes) ? 'gzip' : undefined, bytes = encoding ? pageAs(encoding) : served;
3695
3818
  return res.writeHead(200, { ...headers, ...(encoding && { 'content-encoding': encoding }), 'content-length': bytes.length }).end(bytes);
3696
3819
  }
3697
3820
  let status, body;