sign-off-axi 0.6.3 → 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 (30) hide show
  1. package/README.md +22 -2
  2. package/dist/cli.js +124 -31
  3. package/dist/contract.js +115 -23
  4. package/dist/images.js +27 -8
  5. package/dist/store.js +32 -14
  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 +622 -54
  30. package/web/ui.css +12 -0
package/README.md CHANGED
@@ -105,6 +105,26 @@ Agents pass `--priority` when they know it, and set their project's icon when it
105
105
 
106
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.
107
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
+
108
128
  ## What you can ask
109
129
 
110
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).
@@ -113,11 +133,11 @@ One line on what the agent runs per kind; the full reference is [docs/question-k
113
133
  |---|---|---|
114
134
  | Yes or no, pick one | `raise --ask-ref <captain-message-id-or-hold-id> --title ... --recommend ...` | buttons, the recommended one first |
115
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 |
116
- | 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 |
117
137
  | Proof update | `post --type evidence --media shot.png` | the capture, its result, Pass / Fail / Needs work |
118
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) |
119
139
  | Explainer video | `post --type explainer --media clip.mp4 --chapter 0:00=Start` | a player with chapter buttons, then Yes or No |
120
- | 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 |
121
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 |
122
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 |
123
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,7 +361,7 @@ 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:
@@ -493,7 +502,7 @@ function fits(title, key) {
493
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`);
494
503
  }
495
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. */
496
- 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'];
497
506
  const MANY = ['option', 'outcome', 'merge'];
498
507
  /** Board types that are contract kinds: a decision (the default type) is an ask. */
499
508
  const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report', 'plan', 'design'];
@@ -554,7 +563,7 @@ function contractItems(items, from, file, replace = false) {
554
563
  });
555
564
  }
556
565
  function designFiles(m, base) {
557
- 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))];
558
567
  for (const path of paths) {
559
568
  let actual;
560
569
  try {
@@ -576,6 +585,19 @@ function designFiles(m, base) {
576
585
  throw new ContractError(`design path ${JSON.stringify(path)} cannot be read`, 'Make the design view readable');
577
586
  }
578
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
+ });
579
601
  }
580
602
  const RAISE_FIELDS = ['title', 'key', 'about', 'detail', 'option', 'recommend', 'default', 'close', ...ASK_FLAGS];
581
603
  /** Parses one command's flags strictly; `on` carries a given board file into the next-step hints. */
@@ -724,7 +746,7 @@ function printAnswers(file, rows, waiting, values, help) {
724
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 }] : []));
725
747
  if (values.json) {
726
748
  const asked = (key) => rounds.filter((r) => r.key === key).map(({ note, at, comments }) => ({ note, at, comments }));
727
- 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) }) }]));
728
750
  for (const r of rounds)
729
751
  if (!out[r.key])
730
752
  out[r.key] = { rounds: asked(r.key) };
@@ -732,7 +754,7 @@ function printAnswers(file, rows, waiting, values, help) {
732
754
  out[key] = { ...out[key], discarded: { reason, label, at } };
733
755
  return JSON.stringify(out);
734
756
  }
735
- 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 })));
736
758
  const plan = done.flatMap(({ key, notes: sections = [], decisions: picks = {} }) => [
737
759
  ...sections.map((n) => ({ key, section: n.section ?? `${n.journey}/${n.screen}`, verdict: n.verdict ?? 'change', note: n.note ?? '' })),
738
760
  ...Object.entries(picks).map(([decision, pick]) => ({ key, decision, pick })),
@@ -740,7 +762,8 @@ function printAnswers(file, rows, waiting, values, help) {
740
762
  return toon({
741
763
  board: tilde(file),
742
764
  // A changed answer names the one it replaces, which the agent may already have acted on.
743
- 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',
744
767
  ...(rounds.length && { rounds: rounds.map(({ key, note, at }) => ({ key, note, at })) }),
745
768
  // Comments on mock elements that went with an answer or a round, each with the element it points at.
746
769
  ...(sent.length && { comments: sent }),
@@ -748,7 +771,7 @@ function printAnswers(file, rows, waiting, values, help) {
748
771
  ...(plan.length && { plan }),
749
772
  ...(gone.length && { discarded: gone.map(({ key, label, at }) => ({ key, reason: label, at })) }),
750
773
  waiting,
751
- }, 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]);
752
775
  }
753
776
  function answers(args) {
754
777
  const { file, values, on } = parse('answers', args, { ...TIERS, key: { type: 'string' } });
@@ -771,7 +794,7 @@ function eventLines(file, rows, answers, rounds) {
771
794
  const kind = kindOf(file), item = new Map(rows.map((d) => [d.key, d]));
772
795
  const head = (event, key) => ({ event, ...kind(key), key, title: item.get(key)?.title });
773
796
  const tail = (key, comments) => ({ ...(item.get(key)?.filer && { filer: item.get(key).filer }), ...(comments?.length && { comments }) });
774
- 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) } })),
775
798
  ...rounds.map(({ key, note, at, comments }) => ({ at, line: { ...head('round', key), note, at, ...tail(key, comments) } }))]
776
799
  .sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)).map(({ line }) => JSON.stringify(line)).join('\n');
777
800
  }
@@ -1770,7 +1793,7 @@ function postMedia(file, named, on, values, type, filer, message) {
1770
1793
  writeFileSync(join(dir, `${i + 1}${extname(src).toLowerCase()}`), bytes, { mode: 0o600 });
1771
1794
  // The original stays as it was posted; the board shows a lighter copy of a still where one weighs less.
1772
1795
  const made = isStill(src) ? copiesOf(bytes, extname(src)) : {}, refs = {};
1773
- for (const size of ['light', 'thumb']) {
1796
+ for (const size of ['light', 'thumb', 'tile']) {
1774
1797
  const c = made[size];
1775
1798
  if (!c)
1776
1799
  continue;
@@ -1863,8 +1886,9 @@ async function postKind(file, named, on, values, built) {
1863
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}`);
1864
1887
  m[f] = values[f];
1865
1888
  }
1866
- const existingAsk = !built && kind === 'ask' && existsSync(file) && readItems(file).items.some((i) => i?.key === m.key);
1867
- 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);
1868
1892
  // Media paths read from the message file's folder, or from the current folder for stdin.
1869
1893
  if (m.kind === 'evidence')
1870
1894
  sources = mediaFiles(m, from === '-' ? process.cwd() : dirname(resolve(from)));
@@ -1998,7 +2022,7 @@ function buildIncoming(values, extra, filer, file) {
1998
2022
  try {
1999
2023
  const { title, key = slug(title), about, detail, why, recommend: recommend_value, default: default_if_silent, close } = values;
2000
2024
  const ask = !Object.hasOwn(extra, 'type'), options = ask ? outcomesOf(values) : optionsOf(values);
2001
- 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'] };
2002
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 }] }));
2003
2027
  const incoming = loadDecisions(source, board), raw = readItems(source).items;
2004
2028
  if (!ask)
@@ -2109,18 +2133,27 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
2109
2133
  }
2110
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 };
2111
2135
  });
2136
+ let picked = [];
2112
2137
  if (raised.some((r) => r.status === 'raised')) {
2113
2138
  const before = waitingKeys(file);
2114
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}`;
2115
2145
  notifyEntered(file, named, before);
2116
2146
  }
2117
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`);
2118
2149
  // Reports take no answer, so a post with nothing waiting asks the user to read it, never to answer.
2119
- 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'}`])]);
2120
2153
  if (incoming.length && !anyWaiting)
2121
2154
  return toon({ board: tilde(file), raised }, [`Run \`sign-off-axi open${on}\` so the user can read ${incoming.length > 1 ? 'them' : 'it'}`]);
2122
2155
  const open = anyWaiting ? [`Run \`sign-off-axi open${on}\` so the user can answer`] : [];
2123
- 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'}`]);
2124
2157
  });
2125
2158
  }
2126
2159
  async function open(args) {
@@ -2867,9 +2900,12 @@ async function serve(args) {
2867
2900
  return;
2868
2901
  await sweep('periodic');
2869
2902
  reap();
2903
+ await holdLeash();
2870
2904
  if (server.listening)
2871
2905
  setTimeout(repeat, 5_000).unref();
2872
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();
2873
2909
  setTimeout(repeat, 5_000).unref();
2874
2910
  const url = `http://${urlHost(bind)}:${bound}/`;
2875
2911
  process.on('exit', () => { if (existsSync(record(file)) && readFileSync(record(file), 'utf8').split(' ')[0] === String(process.pid))
@@ -2939,7 +2975,7 @@ async function exportBoard(args) {
2939
2975
  if (d.preview)
2940
2976
  await inline(at(d.key, '', d.preview));
2941
2977
  for (const [i, m] of (d.media ?? []).entries())
2942
- for (const v of ['', ...['light', 'thumb'].filter((v) => m[v])])
2978
+ for (const v of ['', ...['light', 'thumb', 'tile'].filter((v) => m[v])])
2943
2979
  await inline(`/api/media?key=${q(d.key)}&i=${i}${v && `&v=${v}`}`);
2944
2980
  for (const m of d.evidenceCard?.media ?? [])
2945
2981
  await inline(m.url);
@@ -3073,6 +3109,14 @@ const warmGuard = () => new Promise((done) => {
3073
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. */
3074
3110
  const LEASHES = ['ask', 'matters', 'act'];
3075
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
+ } };
3076
3120
  /** The user's name and leash; none for a board with no config.yaml beside it. */
3077
3121
  function profile() {
3078
3122
  let text;
@@ -3082,8 +3126,34 @@ function profile() {
3082
3126
  catch {
3083
3127
  return {};
3084
3128
  }
3085
- const leash = configLine(text, 'leash') ?? '';
3086
- 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 };
3087
3157
  }
3088
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;
3089
3159
  const state = () => {
@@ -3099,7 +3169,7 @@ const state = () => {
3099
3169
  mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
3100
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.
3101
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.
3102
- 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,
3103
3173
  ...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
3104
3174
  readonly: extra.readonly === true,
3105
3175
  answers_only: extra.answers_only === true,
@@ -3140,7 +3210,7 @@ const mockHash = (path) => {
3140
3210
  }
3141
3211
  };
3142
3212
  /** A design's files as the board serves them: its preview and every screen view. */
3143
- 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);
3144
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. */
3145
3215
  const designRound = (d) => sha256(Buffer.from(JSON.stringify([d.filed, d.journeys, d.options, designPaths(d).map((p) => [p, mockHash(p)])])));
3146
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
@@ -3252,7 +3322,7 @@ async function route(req) {
3252
3322
  if (req.method === 'GET' && req.url?.startsWith('/design/')) {
3253
3323
  const [, , , key, ...parts] = new URL(req.url, 'http://localhost').pathname.split('/');
3254
3324
  const path = parts.map(decodeURIComponent).join('/'), d = loadDecisions(file).find(x => x.key === key && x.type === 'design');
3255
- 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)));
3256
3326
  const type = assetTypes[extname(path).toLowerCase()];
3257
3327
  try {
3258
3328
  if (!allowed || !type)
@@ -3306,9 +3376,9 @@ async function route(req) {
3306
3376
  }
3307
3377
  if (req.method === 'GET' && req.url?.startsWith('/api/media?')) {
3308
3378
  const q = new URL(req.url, 'http://localhost').searchParams;
3309
- // 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.
3310
3380
  const v = q.get('v'), posted = loadDecisions(file).find((d) => d.key === q.get('key'))?.media?.[Number(q.get('i') || NaN)];
3311
- 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()];
3312
3382
  if (!m || !type)
3313
3383
  return [404, { error: 'Evidence is unavailable' }];
3314
3384
  try {
@@ -3376,9 +3446,10 @@ async function route(req) {
3376
3446
  return [404, { error: 'Not found' }];
3377
3447
  if (readOnly())
3378
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.
3379
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
3380
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.
3381
- 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())
3382
3453
  return [403, { error: 'This board takes answers only' }];
3383
3454
  if (!req.headers['content-type']?.startsWith('application/json'))
3384
3455
  return [415, { error: 'Send JSON' }];
@@ -3471,6 +3542,7 @@ async function route(req) {
3471
3542
  }
3472
3543
  const line = /^[ \t]*leash[ \t]*:.*$/m;
3473
3544
  writeFileSync(config, line.test(text) ? text.replace(line, `leash: ${leash}`) : `${text.replace(/\n?$/, '\n')}leash: ${leash}\n`);
3545
+ await holdLeash();
3474
3546
  return [200, state()];
3475
3547
  }
3476
3548
  if (req.url === '/api/dismiss') {
@@ -3494,9 +3566,9 @@ async function route(req) {
3494
3566
  return [200, state()];
3495
3567
  }
3496
3568
  if (req.url === '/api/trail') {
3497
- // 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.
3498
3570
  const { key } = JSON.parse(body) ?? {};
3499
- 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)))
3500
3572
  return [404, { error: 'No reply on this card' }];
3501
3573
  appendMark(file, { op: 'seen', key });
3502
3574
  return [200, state()];
@@ -3601,6 +3673,27 @@ function answered(a, d) {
3601
3673
  child.stdin.on('error', () => { });
3602
3674
  child.stdin.end(`${JSON.stringify({ event: 'answer', ...kindOf(file)(a.key), ...a, ...(d && { title: d.title }), ...(d?.filer && { filer: d.filer }) })}\n`);
3603
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
+ }
3604
3697
  const runs = new Map(), failures = new Map();
3605
3698
  const SIGNIN_WAIT = 20_000, SIGNIN_LIFE = 600_000;
3606
3699
  const signinState = (key) => (runs.get(key)?.url ? 'live' : runs.has(key) ? 'starting' : failures.has(key) ? `failed: ${failures.get(key)}` : 'none');