sign-off-axi 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,6 +7,8 @@
7
7
 
8
8
  <h1 align="center">sign-off</h1>
9
9
 
10
+ Guided plans and reports now offer **Simplify this** under each part (and for any words you select inside one paragraph): the same owner explains it in place, with pending and persistent **New** states. New defaults require a filer; older documents still load. [Owner setup and engagement contract](docs/contract.md#report-schema-4-and-plan-schema-3-engagement-v1).
11
+
10
12
  <p align="center"><b>One calm page for every question the programs working for you need answered.</b><br>Agents ask; you answer with one tap on your own machine; the answer is saved for the asker to read. Proof of finished work lands on the same page, so you check it instead of taking anyone's word.</p>
11
13
 
12
14
  <p align="center"><b>▶ <a href="https://umeranjum17.github.io/sign-off/demo/">Try it live</a></b>: the real board on demo data. No install, nothing leaves your browser. · <a href="https://umeranjum17.github.io/sign-off/">Website</a></p>
@@ -29,7 +31,7 @@ sign-off-axi open # 3. start the board and open the printed l
29
31
  Then ask your first question, the way an agent would, and answer it on the board:
30
32
 
31
33
  ```sh
32
- sign-off-axi raise \
34
+ sign-off-axi raise --ask-ref message-example \
33
35
  --title "Keep the weekly digest?" \
34
36
  --why "The paid plan renews Monday." \
35
37
  --outcome yes="It keeps going out." --outcome no="It stops this week." \
@@ -83,21 +85,23 @@ One line on what the agent runs per kind; the full reference is [docs/question-k
83
85
 
84
86
  | Kind | The agent runs | You see |
85
87
  |---|---|---|
86
- | Yes or no, pick one | `raise --title ... --recommend ...` | buttons, the recommended one first |
88
+ | Yes or no, pick one | `raise --ask-ref <captain-message-id-or-hold-id> --title ... --recommend ...` | buttons, the recommended one first |
87
89
  | 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 |
88
90
  | Proof update | `post --type evidence --media shot.png` | the capture, its result, Pass / Fail / Needs work |
89
91
  | 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) |
90
92
  | Explainer video | `post --type explainer --media clip.mp4 --chapter 0:00=Start` | a player with chapter buttons, then Yes or No |
91
- | Report page | `report init <folder>`, then `post --type report --view ...` | a page to tap through |
93
+ | 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 |
92
94
  | Diagram, interactive page | `post --type diagram --view flow.svg` | the picture on the card; interactive pages run sandboxed |
93
- | Plan | `post --kind plan --from plan.json` | sections to approve or change, decisions to answer |
95
+ | 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 |
94
96
  | Sign-in link | `post --type signin --service npm --link <url> --unlocks ...` | an Open button and the time left |
95
97
 
96
98
  ## How agents use it
97
99
 
98
100
  Every item records who filed it. The agent raises with `--filer my-agent`, then blocks on `sign-off-axi wait --filer my-agent`, which returns that filer's answers, each once, and the card shows **Seen by agent**. An agent that cannot block can be woken instead: an `on_answer:` line in the `config.yaml` beside the board names a command the board runs for every answer, and a `notify:` block can send an HTTP POST when a card enters Decide. The worked examples are in [docs/commands.md](docs/commands.md).
99
101
 
100
- sign-off itself calls no model and inherits the calling agent's model routing. The shipped skill is deliberately thin: it tells the agent how to write a good card and defers to `sign-off-axi --help` for every flag, so an installed copy never goes stale.
102
+ For Go deeper on a guided plan or report, an idle **Claude Code** author can opt in to native session resumption: register its actual UUID with `owner --filer <id> --host claude --session <UUID> --cwd <absolute project folder>`, then run a separate `owner --dispatch --timeout 3600` process for the same board. `owner` shows queued delivery failures and distinguishes delivered from owner-read. No Firstmate is needed; other hosts are not supported for this wake path. See the [standalone owner recipe](docs/commands.md#standalone-go-deeper-owner).
103
+
104
+ Each ordinary sign-off command calls no model; the opt-in dispatcher invokes the installed Claude Code with its normal credentials and optional registered model. The shipped skill is deliberately thin: it tells the agent how to write a good card and defers to `sign-off-axi --help` for every flag, so an installed copy never goes stale.
101
105
 
102
106
  ## Works alone, or with its siblings
103
107
 
@@ -150,7 +154,7 @@ always wins.
150
154
  tmp=$(mktemp -d)
151
155
  export SIGN_OFF_HOME="$tmp"
152
156
  (cd "$tmp" &&
153
- sign-off-axi raise \
157
+ sign-off-axi raise --ask-ref message-example \
154
158
  --title "Probe?")
155
159
  ```
156
160
 
package/dist/cli.js CHANGED
@@ -11,8 +11,9 @@ import { pipeline } from 'node:stream';
11
11
  import { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
12
12
  import { parseArgs } from 'node:util';
13
13
  import { encode } from '@toon-format/toon';
14
+ import { acknowledgeOwner, checkOwner, deliveries, dispatchOwners, registerOwner } from './owner.js';
14
15
  import { AxiError, runAxiCli } from 'axi-sdk-js';
15
- import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, asks, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, appendRead, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, sameDecision, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
16
+ import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, asks, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, sameDecision, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
16
17
  import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, guided, mediaFiles, parseMessage, reportPage } from './contract.js';
17
18
  import { faults, isStill } from './evidence-text.js';
18
19
  import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
@@ -29,18 +30,21 @@ const AGENTS = {
29
30
  'gemini-cli': { bin: 'gemini', dir: '.agents/skills', home: join(homedir(), '.gemini', 'skills') },
30
31
  };
31
32
  const TOP_HELP = `usage: sign-off-axi [command] [board.json] [flags]
32
- commands[22]:
33
- (none)=list, raise, post, answers, wait, poll, deeper, open, status, stop, serve, export, init, report, signin, dismiss, restore, refile, doctor, notify, validate, help
33
+ commands[23]:
34
+ (none)=list, raise, post, answers, wait, poll, deeper, owner, open, status, stop, serve, export, init, report, signin, dismiss, restore, refile, doctor, notify, validate, help
34
35
  board: the nearest .sign-off/ board (the walk's bounds and SIGN_OFF_HOME: README, "Test runs never touch your board"), else ~/.sign-off/decisions.json; a board file given always wins
35
36
  flags: --full and --json on list, answers and wait, --events on wait, --json on status, --filer on post, raise and wait, --help on every command, -v/--version
36
37
  examples:
37
38
  sign-off-axi
38
- sign-off-axi raise --title "Ship the new sign-in screen this week?" --why "The screen is ready; the release goes out Friday." --outcome yes="It ships Friday." --outcome no="It waits a week." --recommend yes --recommend-reason "Tests pass on every phone." --default no
39
+ sign-off-axi raise --ask-ref message-example --title "Ship the new sign-in screen this week?" --why "The screen is ready; the release goes out Friday." --outcome yes="It ships Friday." --outcome no="It waits a week." --recommend yes --recommend-reason "Tests pass on every phone." --default no
39
40
  sign-off-axi post --type evidence --title "Sign-in screen on a phone" --about "The new screen loads in 1 second." --media shot.png
40
41
  sign-off-axi post .sign-off/decisions.json --type design --title "Pick the sign-in layout" --option a=A --option b=B --mock a=mocks/a.html --mock b=mocks/b.html --recommend b
41
42
  sign-off-axi post .sign-off/decisions.json --type diagram --title "Is this the sign-in flow?" --view mocks/sign-in/flow.svg
42
43
  sign-off-axi post --type explainer --title "Is the new sign-in clear?" --about "The new sign-in has 2 steps." --media explainer.mp4 --chapter 0:00=Start
43
- sign-off-axi report init .sign-off/mocks/sign-in-flow
44
+ sign-off-axi report init .sign-off/reports/sign-in-flow
45
+ sign-off-axi post --kind report --from .sign-off/reports/sign-in-flow/report.json
46
+ sign-off-axi report init .sign-off/plans/sign-in --plan
47
+ sign-off-axi post --kind plan --from .sign-off/plans/sign-in/plan.json --filer my-session-id
44
48
  sign-off-axi deeper
45
49
  sign-off-axi post .sign-off/decisions.json --type report --title "Sign-in now takes 2 steps" --about "Why the code step moved first." --view mocks/sign-in-flow/index.html --preview mocks/sign-in-flow/preview.svg
46
50
  sign-off-axi post --type signin --key npm-login --service npm --link https://example.com/login --unlocks "Publish the release" --waits "Main" --expires 15m
@@ -69,6 +73,9 @@ examples:
69
73
  sign-off-axi raise [board.json] --from <decisions.json> [--filer <id>]
70
74
  Adds questions to the board: yes/no, or 2 to 6 choices with one --option each. --key defaults to a slug of the title.
71
75
  raise is the alias for post --type decision: same flags, same output.
76
+ New Decide questions require --ask-ref <captain-message-id-or-hold-id>, saved as ask_ref (1-256 characters, no whitespace or controls).
77
+ For --kind ask or a feed, put ask_ref in the message, or pass --ask-ref. Existing cards need no new reference.
78
+ Follow-ups belong in the backlog, not the board. Plans, reports, evidence and engagement records are unchanged.
72
79
  Every question meets the question standard (docs/contract.md, "Asking a good question"); a question that does not is refused with the field and one fix line:
73
80
  --title is one plain question the buttons answer: it ends in "?", has 12 words or fewer, no "Say yes or no:", "Captain hold:" or "Captain:" prefix and no key-like word.
74
81
  --why says what it is about and why it needs an answer now (200 characters or fewer).
@@ -87,14 +94,14 @@ Raising the same question again is a no-op that reports its status; a different
87
94
  --filer names who asks (your agent session or any id of 1-128 of A-Z a-z 0-9 . _ -), saved on the item so \`wait --filer\` with the same id gets the answer.
88
95
  SIGN_OFF_FILER sets it for every command; with neither, the Claude Code session id is saved when there is one.
89
96
  examples:
90
- sign-off-axi raise --title "Keep the weekly digest?" --why "Open rate held steady; the paid plan ends Monday." --outcome yes="It keeps going out each Monday." --outcome no="It stops after this week." --recommend yes --recommend-reason "Readers still open it." --default yes --silent-by 2026-10-19T09:00Z
91
- sign-off-axi raise --title "Which day should the release ship?" --why "Two fixes are still in review." --option a="A: Today" --option b="B: Friday" --option c="C: Next week" --outcome a="Ships now without the fixes." --outcome b="Ships with both fixes." --outcome c="Ships with the redesign too." --recommend b --recommend-reason "Both fixes land by Thursday." --default b --merge release-day
97
+ sign-off-axi raise --ask-ref message-example --title "Keep the weekly digest?" --why "Open rate held steady; the paid plan ends Monday." --outcome yes="It keeps going out each Monday." --outcome no="It stops after this week." --recommend yes --recommend-reason "Readers still open it." --default yes --silent-by 2026-10-19T09:00Z
98
+ sign-off-axi raise --ask-ref message-example --title "Which day should the release ship?" --why "Two fixes are still in review." --option a="A: Today" --option b="B: Friday" --option c="C: Next week" --outcome a="Ships now without the fixes." --outcome b="Ships with both fixes." --outcome c="Ships with the redesign too." --recommend b --recommend-reason "Both fixes land by Thursday." --default b --merge release-day
92
99
  sign-off-axi raise --from ./decisions.json
93
100
  `,
94
101
  validate: `usage: sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid]
95
102
  Checks message files against the communication contract without posting them: ${CONTRACT}
96
103
  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 3 (schema 2 still passes) and plan at schema 2 (schema 1 still passes).
97
- A report 3 and a plan 2 are guided pages: fields the board draws as one scroll, no page of your own.
104
+ A report 3 and a plan 2 are guided pages: fields the board draws as one scroll, no page of your own (see report init).
98
105
  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.
99
106
  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).
100
107
  A plan's "view" (.html) is checked for its shape here; the file itself is checked at post, from the board file's folder.
@@ -132,14 +139,16 @@ examples:
132
139
  Blocks until the user answers, then prints the answer the way answers does. Use it instead of a polling loop.
133
140
  --filer only the items posted with this --filer (or SIGN_OFF_FILER): returns every answer to them this filer has not taken yet,
134
141
  even one saved before wait started, and marks it taken, so the board shows "Seen by agent" on that card;
135
- NOT_FOUND when none of them still needs an answer. Without --filer or SIGN_OFF_FILER, wait takes no filer
142
+ guided plans and reports keep listening for Go deeper, even after send-back; otherwise NOT_FOUND when none needs an answer.
143
+ Without --filer or SIGN_OFF_FILER, wait takes no filer and never consumes Go deeper
136
144
  --key wait for that decision; an answer saved before wait starts returns at once
137
145
  and a key not raised yet is waited for too
138
146
  without --key, wait for the next answer to any decision that still needs an answer; NOT_FOUND when nothing needs one
139
147
  --timeout seconds to wait, 600 (10 minutes) by default; then exits 1 with code TIMEOUT
140
148
  --full adds the button label, close and title
141
149
  --json {"<key>":{"answer","label","close","at","note"?}} for the new answers; note only when the user wrote one
142
- --events needs --filer (or SIGN_OFF_FILER); one JSON line per new answer and per another-round ask, in the order they were saved (${CONTRACT}#7-answers):
150
+ --events needs --filer (or SIGN_OFF_FILER); JSON lines for new answers, another-round asks and Go deeper (each wait returns one batch).
151
+ Answer and round lines come in the order they were saved (${CONTRACT}#7-answers):
143
152
  {"event":"answer","contract":1,"kind","schema","key","title","answer","label","close","at","note"?,"filer"?,"comments"?}
144
153
  {"event":"round","contract":1,"kind","schema","key","title","note","at","filer"?,"comments"?}; a board-format item reports as ask schema 1
145
154
  A note column appears when any shown answer has a note.
@@ -188,7 +197,8 @@ examples:
188
197
  sign-off-axi post [board.json] --type report --title "<the answer>" --about "<why it matters>" --view <page.html> --preview <picture> [--key <key>] [--link <commit or URL>] [--filer <id>]
189
198
  sign-off-axi post [board.json] --type signin --service "<name>" --link <sign-in URL> --unlocks "<what it unlocks>" --waits "<who waits>" [--expires <ISO time or 15m>] [--key <key>] [--filer <id>]
190
199
  sign-off-axi post [board.json] --type signin --service "<name>" --start '<command as a JSON list>' --unlocks "<what it unlocks>" --waits "<who waits>" [--key <key>] [--filer <id>]
191
- Posts an item for the user. --type decision (the default) is a yes/no or pick-one question, exactly like raise, with the same question standard and flags (--why, --outcome, --recommend-reason, --silent-by, --merge).
200
+ Posts an item for the user. --type decision (the default) is a yes/no or pick-one question, exactly like raise, with the same question standard and flags (--ask-ref, --why, --outcome, --recommend-reason, --silent-by, --merge).
201
+ A new Decide question requires --ask-ref <captain-message-id-or-hold-id> (ask_ref in a message or feed); follow-ups belong in the backlog, not the board. Existing cards, plans, reports, evidence and engagement records are unchanged.
192
202
  --kind posts one contract message (${CONTRACT}), read from a file or from stdin with --from -: it must be that kind and pass every rule,
193
203
  else it is refused with one fix line and nothing is written. A key already on the board takes only the same message again; anything else exits 2.
194
204
  --kind evidence copies its media, read from the message file's folder (the current folder for stdin), into evidence/<number>/ like --type evidence;
@@ -236,7 +246,7 @@ within 20 seconds, shows on the card and Open can try again. It is stopped after
236
246
  examples:
237
247
  sign-off-axi post --type design --title "Pick the sign-in layout" --option a="A: single column" --option b="B: split screen" --mock a=mocks/a.html --mock b=mocks/b.html --tradeoff "B scans fastest; A is simplest." --recommend b
238
248
  sign-off-axi post .sign-off/decisions.json --type design --key sign-in --title "Pick the sign-in layout" --option a=A --option b=B --option c=C --mock a=mocks/sign-in/index.html?variant=a --mock b=mocks/sign-in/index.html?variant=b --mock c=mocks/sign-in/index.html?variant=c --tradeoff "..." --recommend b
239
- sign-off-axi post --type decision --title "Keep the weekly digest?" --why "Open rate held steady." --outcome yes="It keeps going out." --outcome no="It stops this week." --recommend yes --recommend-reason "Readers still open it." --default yes
249
+ sign-off-axi post --type decision --ask-ref message-example --title "Keep the weekly digest?" --why "Open rate held steady." --outcome yes="It keeps going out." --outcome no="It stops this week." --recommend yes --recommend-reason "Readers still open it." --default yes
240
250
  sign-off-axi post --kind ask --from ask.json --filer agent-41
241
251
  sign-off-axi post --kind evidence --from proof/evidence.json --filer done-done
242
252
  sign-off-axi post --kind gate --from gate.json --filer done-done
@@ -277,7 +287,13 @@ examples:
277
287
  restore: `usage: sign-off-axi restore <key> [board.json]
278
288
  Brings back a card that dismiss hid. See \`sign-off-axi dismiss --help\`.
279
289
  `,
280
- report: `usage: sign-off-axi report init <folder>
290
+ report: `usage: sign-off-axi report init <folder> [--plan] [--page]
291
+ Writes the guided template to fill: <folder>/report.json, or <folder>/plan.json with --plan. A file already there is kept.
292
+ The board draws it as one scroll: what this is (the answer and one picture), what I understood, what I found and recommend, what you decide (plans), and the next step.
293
+ Numbers sit behind a Details tap; every part has a Go deeper button that asks you (see \`sign-off-axi deeper --help\`) and a read receipt that shows when the person read it.
294
+ Replace every FILL: line, then post it: post --kind report --from <folder>/report.json, or post --kind plan --from <folder>/plan.json --filer <your id>.
295
+ A report is for an explanation the user asked for, or a concept, design or trade-off they must understand. Proof that something works is evidence.
296
+ --page writes the older scaffold page instead, for a report that needs a page of its own (report schema 2). It is:
281
297
  Writes a report page to fill: <folder>/index.html and its card picture <folder>/preview.svg. A file already there is kept.
282
298
  The page is one self-contained file, phone first, light and dark, in four parts, each one idea:
283
299
  1. the answer in one plain sentence and a picture of it 2. a diagram of how it works
@@ -299,9 +315,20 @@ examples:
299
315
  sign-off-axi init ./my-project --agent opencode
300
316
  sign-off-axi init --agent codex,gemini-cli
301
317
  `,
318
+ owner: `usage: sign-off-axi owner [board.json] --filer <id> --host claude --session <UUID> --cwd <absolute folder> [--model <id>]
319
+ sign-off-axi owner [board.json] --dispatch [--filer <id>] [--timeout <seconds>] [--verbose]
320
+ sign-off-axi owner [board.json] --ack <request id>
321
+ sign-off-axi owner [board.json]
322
+ Register the actual author session locally, never in document JSON. Only Claude Code is supported.
323
+ --dispatch retries unfinished Go deeper requests, once by default; --timeout keeps polling for that many seconds.
324
+ --verbose writes the real host result to stderr for local diagnostics.
325
+ The dispatcher uses the normal installed claude and its credentials, argv only, and resumes the exact session.
326
+ Run one dispatcher per board, separately from the idle owner. Leave inaccessible owners queued; inspect delivery errors with owner.
327
+ Delivered means process handoff, not read. The resumed owner must --ack before answering once, without --more.
328
+ Do not run an active wait listener for the same owner alongside the dispatcher. No substitute answers.`,
302
329
  deeper: `usage: sign-off-axi deeper [board.json] [--filer <id>] [--all] [--json]
303
- sign-off-axi deeper [board.json] --id <id> --text "<answer>" [--more]
304
- Go deeper is the button on every part of a plan 2 or report 3. The person taps it; wait --filer wakes with the ask, or this command lists the ones nobody finished.
330
+ sign-off-axi deeper [board.json] --filer <id> --id <id> --text "<answer>" [--more]
331
+ Every part of a plan 3 or report 4 offers Ask a question, Go deeper, Simplify this, Show an example, Why?, and free text. Ask a question needs text and Send; Go deeper, Simplify this, Show an example and Why? send immediately. Free text and follow-ups use custom. Older guided schemas retain Go deeper. Engagement v1 replies require the owning --filer or registered owner session; stale targets are refused. The person taps it; wait --filer wakes with the ask, or this command lists the ones nobody finished.
305
332
  Answer in plain words about the part they tapped, a few lines at a time: each --more piece streams into that spot on the board while they watch, and a last call without --more ends it.
306
333
  --id the ask to answer --text the answer, or - to read stdin (2000 characters or fewer per piece)
307
334
  --more more pieces follow --all also the finished asks
@@ -540,8 +567,8 @@ function filerOf(values, session = false) {
540
567
  }
541
568
  const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
542
569
  : `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
543
- /** Still waiting on the user: not answered, not dismissed, and something to answer (an item with no options and no reply is only shown). */
544
- const waits = (d) => asks(d) && !d.dismissed && awaitsVerdict(d.type, d.answer);
570
+ /** Still waiting on the user: not answered, not dismissed, not an expired sign-in, and something to answer (an item with no options and no reply is only shown). */
571
+ const waits = (d) => asks(d) && !lapsed(d) && !d.dismissed && awaitsVerdict(d.type, d.answer);
545
572
  /** The items that archive after a day: all of them, except on a board another program feeds, which owns what it shows. */
546
573
  const archivable = (file, decisions) => ((x) => (x.readonly === true || x.answers_only === true ? [] : decisions))(readItems(file).extra);
547
574
  /** The board's decisions with their latest answer; a missing board file is an empty board.
@@ -650,12 +677,40 @@ function eventLines(file, rows, answers, rounds) {
650
677
  function deeperOut(file, asks, on, events) {
651
678
  if (events) {
652
679
  const kind = kindOf(file), titles = new Map(board(file).map((d) => [d.key, d.title]));
653
- return asks.map(({ id, key, part, about, q, at }) => JSON.stringify({ event: 'deeper', ...kind(key), key, title: titles.get(key), id, part, about, ...(q && { q }), at })).join('\n');
680
+ return asks.map(({ id, key, part, about, q, at, ...rest }) => JSON.stringify({ event: 'deeper', ...kind(key), key, title: titles.get(key), id, part, about, ...(q && { q }), at, ...(rest.version === 1 && rest) })).join('\n');
654
681
  }
655
- return toon({ board: tilde(file), deeper: asks.map(({ id, key, part, about, q = '', at }) => ({ id, key, part, about, q, at })) }, ['The person tapped Go deeper: answer in plain words, a few lines at a time, as the part they tapped',
682
+ return toon({ board: tilde(file), deeper: asks.map(({ id, key, part, about, q = '', at, action }) => ({ id, key, part, about, q, at, ...(action && { action }) })) }, ['The person tapped Go deeper: answer in plain words, a few lines at a time, as the part they tapped',
656
683
  `Run \`sign-off-axi deeper${on} --id <id> --text "<what you found>" --more\` for each piece, then once more without --more to finish`]);
657
684
  }
658
685
  /** `deeper`: the Go deeper asks nobody has finished, or with --id and --text one piece of the answer the board streams under the part the person tapped. */
686
+ async function owner(args) {
687
+ const { file, values } = parse('owner', args, { filer: { type: 'string' }, host: { type: 'string' }, session: { type: 'string' }, cwd: { type: 'string' }, model: { type: 'string' }, dispatch: { type: 'boolean' }, verbose: { type: 'boolean' }, ack: { type: 'string' }, timeout: { type: 'string' } });
688
+ ready(file, '');
689
+ if (values.ack !== undefined) {
690
+ acknowledgeOwner(file, values.ack);
691
+ markDeeperRead(file, [values.ack]);
692
+ }
693
+ else if (values.session !== undefined) {
694
+ const filer = filerOf(values);
695
+ if (!filer)
696
+ throw new InputError('Registration needs --filer');
697
+ await locked(`${file}.owners.lock`, () => registerOwner(file, filer, { host: values.host, session: values.session, cwd: values.cwd, ...(values.model && { model: values.model }) }));
698
+ }
699
+ else if (values.dispatch) {
700
+ if (values.timeout !== undefined && !/^\d{1,7}$/.test(values.timeout))
701
+ throw new InputError('--timeout must be whole seconds');
702
+ const until = Date.now() + Number(values.timeout ?? 0) * 1000;
703
+ await locked(`${file}.dispatch.lock`, async () => {
704
+ do {
705
+ await dispatchOwners(file, process.argv[1], filerOf(values), values.verbose);
706
+ if (Date.now() >= until)
707
+ break;
708
+ await new Promise(ok => setTimeout(ok, 1000));
709
+ } while (Date.now() < until);
710
+ }, 'another owner dispatcher is active', 0);
711
+ }
712
+ return JSON.stringify({ delivery: deliveries(file) });
713
+ }
659
714
  async function deeper(args) {
660
715
  const { file, values, on } = parse('deeper', args, { filer: { type: 'string' }, id: { type: 'string' }, text: { type: 'string' }, more: { type: 'boolean' }, all: { type: 'boolean' }, json: { type: 'boolean' } });
661
716
  ready(file, on);
@@ -670,11 +725,23 @@ async function deeper(args) {
670
725
  if (!open.length)
671
726
  return toon({ board: tilde(file), deeper: '0 open' }, [`Run \`sign-off-axi wait${on}\` to be woken when the person taps Go deeper`]);
672
727
  markDeeperRead(file, open.filter((x) => !x.read).map((x) => x.id));
673
- return toon({ board: tilde(file), deeper: open.map(({ id, key, part, about, q = '', at, done }) => ({ id, key, part, about, q, at, done: !!done })) }, [`Run \`sign-off-axi deeper${on} --id <id> --text "<answer>"\` to answer one`]);
728
+ return toon({ board: tilde(file), deeper: open.map(({ id, key, part, about, q = '', at, done, action }) => ({ id, key, part, about, q, at, done: !!done, ...(action && { action }) })) }, [`Run \`sign-off-axi deeper${on} --id <id> --text "<answer>"\` to answer one`]);
674
729
  }
675
730
  const ask = readDeeper(file).find((x) => x.id === values.id);
676
731
  if (!ask)
677
732
  throw fail(`no Go deeper ask with id ${values.id}`, 'NOT_FOUND', `Run \`sign-off-axi deeper${on}\` to see the open ones`);
733
+ checkEngagement(file, ask);
734
+ if (ask.version === 1 && !process.env.SIGN_OFF_OWNER_SESSION && filer !== ask.filer)
735
+ throw new InputError('Engagement reply requires its owning --filer');
736
+ if (filer !== undefined && !board(file).some(d => d.key === ask.key && d.filer === filer))
737
+ throw new InputError('Request does not belong to this filer');
738
+ if (process.env.SIGN_OFF_OWNER_SESSION) {
739
+ checkOwner(file, ask);
740
+ if (ask.done)
741
+ return toon({ id: ask.id, answered: 'already done' }, []);
742
+ if (values.more || deliveries(file)[ask.id]?.state !== 'owner-read')
743
+ throw new InputError('Owner must acknowledge first and send one final answer without --more');
744
+ }
678
745
  if (ask.done)
679
746
  throw fail(`ask ${values.id} is already answered`, 'CONFLICT', 'The person can tap Go deeper again for a follow-up');
680
747
  let text = values.text;
@@ -682,7 +749,11 @@ async function deeper(args) {
682
749
  text = readFileSync(0, 'utf8');
683
750
  if (typeof text !== 'string' || !text.trim() || [...text.trim()].length > 2000)
684
751
  throw fail('--text must be the answer, 2000 characters or fewer', 'VALIDATION_ERROR', 'Run `sign-off-axi deeper --help`');
685
- answerDeeper(file, ask.id, text.trim(), !!values.more);
752
+ await locked(`${deeperPath(file)}.lock`, () => {
753
+ checkEngagement(file, ask);
754
+ if (!process.env.SIGN_OFF_OWNER_SESSION || !readDeeper(file).find(x => x.id === ask.id)?.done)
755
+ answerDeeper(file, ask.id, text.trim(), !!values.more);
756
+ });
686
757
  return toon({ id: ask.id, key: ask.key, part: ask.part, answered: values.more ? 'piece sent, more to come' : 'done' }, values.more ? [`Run \`sign-off-axi deeper${on} --id ${ask.id} --text "<next piece>" [--more]\``] : [`Run \`sign-off-axi wait${on}\` for the next answer or ask`]);
687
758
  }
688
759
  async function wait(args) {
@@ -747,12 +818,12 @@ async function wait(args) {
747
818
  if (found.length && values.events)
748
819
  return eventLines(file, found, given, asked.filter((r) => fresh.some((d) => d.key === r.key)));
749
820
  if (found.length) {
750
- return printAnswers(file, found, waiting, values, [waiting ? `Run \`sign-off-axi wait${on}\` for the next of the ${waiting} waiting` : `Run \`sign-off-axi stop${onBoard}\` to close the board`]);
821
+ return printAnswers(file, found, waiting, values, [waiting ? `Run \`sign-off-axi wait${on}\` for the next of the ${waiting} waiting` : filer !== undefined && rows.some((d) => d.guided) ? `Run \`sign-off-axi wait${on}\` for the next Go deeper ask` : `Run \`sign-off-axi stop${onBoard}\` to close the board`]);
751
822
  }
752
823
  // A Go deeper ask on this filer's guided page wakes the wait once, and is marked read when it is handed over.
753
- if (values.key === undefined) {
824
+ if (filer !== undefined) {
754
825
  const asks = await locked(`${deeperPath(file)}.lock`, () => {
755
- const got = readDeeper(file).filter((x) => !x.read && rows.some((d) => d.key === x.key));
826
+ const got = readDeeper(file).filter((x) => !x.read && !x.done && (values.key === undefined || x.key === values.key) && rows.some((d) => d.key === x.key));
756
827
  markDeeperRead(file, got.map((x) => x.id));
757
828
  return got;
758
829
  }, 'another wait is still taking Go deeper asks');
@@ -788,7 +859,7 @@ async function wait(args) {
788
859
  return vaultEvent(file, [{ key: values.key, at: event.at, status: event.status }], values);
789
860
  }
790
861
  // A dismissed ask is hidden from the board, not withdrawn: it is not counted as waiting, but the agent still waits on it.
791
- if (values.key === undefined && !rows.some((d) => waits({ ...d, dismissed: false })) && !live && !unknown) {
862
+ if (values.key === undefined && !rows.some((d) => waits({ ...d, dismissed: false }) || (filer !== undefined && d.guided)) && !live && !unknown) {
792
863
  throw fail(`nothing${filer ? ` posted by ${filer}` : ''} is waiting on ${tilde(file)}`, 'NOT_FOUND', `Run \`sign-off-axi answers${onBoard}\` to read the saved answers`, `Run \`sign-off-axi raise${on} --title "<question>"\` to ask a new one`);
793
864
  }
794
865
  const left = deadline - Date.now();
@@ -889,7 +960,7 @@ function post(args, cmd = 'post') {
889
960
  const typed = cmd === 'post' ? { replace: { type: 'boolean' }, kind: { type: 'string' }, type: { type: 'string' }, tradeoff: { type: 'string' }, mock: { type: 'string', multiple: true },
890
961
  media: { type: 'string', multiple: true }, attach: { type: 'string', multiple: true }, link: { type: 'string', multiple: true }, view: { type: 'string' }, preview: { type: 'string' }, chapter: { type: 'string', multiple: true },
891
962
  ...Object.fromEntries(SIGNIN_FIELDS.map((f) => [f, { type: 'string' }])) } : {};
892
- const { file, named, values, on } = parse(cmd, args, { from: { type: 'string' }, filer: { type: 'string' }, ...typed,
963
+ const { file, named, values, on } = parse(cmd, args, { from: { type: 'string' }, filer: { type: 'string' }, 'ask-ref': { type: 'string' }, ...typed,
893
964
  ...Object.fromEntries(RAISE_FIELDS.map((f) => [f, { type: 'string', multiple: MANY.includes(f) }])) });
894
965
  const given = [...RAISE_FIELDS, 'type', 'tradeoff', 'mock', 'media', 'attach', 'link', 'view', 'preview', 'chapter', ...SIGNIN_FIELDS].filter((f) => values[f] !== undefined);
895
966
  // Only a design takes --link more than once (one per option); any other type keeps its one item link, the last given.
@@ -905,7 +976,7 @@ function post(args, cmd = 'post') {
905
976
  if (values.from !== undefined && given.length)
906
977
  throw fail(`--from cannot be combined with --${given[0]}`, 'VALIDATION_ERROR', `Put every field in the --from file, or ${cmd} with flags only`);
907
978
  if (values.replace)
908
- return replace(file, named, on, values.from, filerOf(values));
979
+ return replace(file, named, on, values.from, filerOf(values), values['ask-ref']);
909
980
  const filer = filerOf(values, true);
910
981
  const type = values.type ?? 'decision', told = ASK_FLAGS.find((f) => values[f] !== undefined);
911
982
  if (told && type !== 'decision')
@@ -1113,7 +1184,7 @@ async function refile(args) {
1113
1184
  }
1114
1185
  /** For a program that feeds the board: the --from file, checked like any post, becomes the whole board, items and top-level fields. Answers stay.
1115
1186
  * With a filer it replaces only that filer's items (and items with no filer it names by key); every other item and top-level field stays. */
1116
- function replace(file, named, on, from, filer) {
1187
+ function replace(file, named, on, from, filer, askRef) {
1117
1188
  if (from === undefined)
1118
1189
  throw fail('--replace needs --from <board file>', 'VALIDATION_ERROR', 'sign-off-axi post board.json --from feed.json --replace');
1119
1190
  const source = resolve(from), fed = readItems(source);
@@ -1137,6 +1208,11 @@ function replace(file, named, on, from, filer) {
1137
1208
  throw fail(`key ${i.key} was answered on ${tilde(file)} and is no longer there`, 'CONFLICT', 'Post it under a new key');
1138
1209
  }
1139
1210
  const current = board(file);
1211
+ if (askRef !== undefined)
1212
+ for (const [i, d] of incoming.entries())
1213
+ if (['decision', 'ask'].includes(feed[i]?.kind ?? feed[i]?.type ?? 'decision') && !current.some((c) => c.key === d.key))
1214
+ feed[i].ask_ref = askRef;
1215
+ checkAskRefs(incoming, feed, current);
1140
1216
  for (const had of current) {
1141
1217
  const d = incoming.find((x) => x.key === had.key);
1142
1218
  if (filer && d && had.filer && had.filer !== filer)
@@ -1353,7 +1429,7 @@ function postReport(file, named, on, values) {
1353
1429
  throw fail(`--${odd} does not apply to --type report: a report takes no answer`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
1354
1430
  const missing = ['title', 'about', 'view', 'preview'].filter((f) => values[f] === undefined).map((f) => `--${f}`);
1355
1431
  if (missing.length) {
1356
- throw fail(`a report is a page to tap through, not text: ${missing.join(' and ').replace(/ and (?=.* and )/g, ', ')} ${missing.length > 1 ? 'are' : 'is'} missing`, 'VALIDATION_ERROR', `Run \`${REPORT_INIT}\` to make the page and its picture, fill them, then post --type report --title "<the answer>" --about "<why it matters>" --view <folder>/index.html --preview <folder>/preview.svg`);
1432
+ throw fail(`a report is a page to tap through, not text: ${missing.join(' and ').replace(/ and (?=.* and )/g, ', ')} ${missing.length > 1 ? 'are' : 'is'} missing`, 'VALIDATION_ERROR', `Run \`${REPORT_INIT} --page\` to make the page and its picture, fill them, then post --type report --title "<the answer>" --about "<why it matters>" --view <folder>/index.html --preview <folder>/preview.svg`);
1357
1433
  }
1358
1434
  const key = values.key ?? slug(values.title), had = existsSync(file) ? readItems(file).items.find((i) => i?.key === key) : undefined;
1359
1435
  // Posting the same report again is a no-op, so it keeps the time it was first filed.
@@ -1399,6 +1475,8 @@ async function postKind(file, named, on, values, built) {
1399
1475
  if (m.filer !== undefined && values.filer !== undefined && m.filer !== values.filer) {
1400
1476
  throw fail(`${name}: key ${m.key}: filer ${m.filer} is not --filer ${values.filer}`, 'VALIDATION_ERROR', 'Leave "filer" out of the message, or pass the same --filer');
1401
1477
  }
1478
+ if (m.kind === 'ask' && values['ask-ref'] !== undefined)
1479
+ m.ask_ref = values['ask-ref'];
1402
1480
  const filer = m.filer ?? filerOf(values, true), scratch = mkdtempSync(join(tmpdir(), 'sign-off-')), source = join(scratch, basename(name));
1403
1481
  try {
1404
1482
  if (m.kind === 'evidence')
@@ -1495,16 +1573,20 @@ function buildIncoming(values, extra, filer, file) {
1495
1573
  // An item that names its own filer keeps it.
1496
1574
  const source = resolve(values.from), raw = readItems(source).items;
1497
1575
  contractItems(raw, values.from, file);
1576
+ if (values['ask-ref'] !== undefined)
1577
+ for (const item of raw)
1578
+ if ((item.kind ?? item.type ?? 'decision') === 'ask' || (item.type ?? 'decision') === 'decision' && !item.kind)
1579
+ item.ask_ref = values['ask-ref'];
1498
1580
  const incoming = loadDecisions(source, board);
1499
1581
  recommended(raw, false);
1500
- return { incoming: incoming.map((d) => ({ filer, ...d })), raw: raw.map((i) => ({ filer, ...i })) };
1582
+ return { incoming: incoming.map((d, i) => ({ filer, ...d, ...(raw[i].ask_ref !== undefined && { ask_ref: raw[i].ask_ref }) })), raw: raw.map((i) => ({ filer, ...i })) };
1501
1583
  }
1502
1584
  const scratch = mkdtempSync(join(tmpdir(), 'sign-off-'));
1503
1585
  const source = join(scratch, 'flags');
1504
1586
  try {
1505
1587
  const { title, key = slug(title), about, detail, why, recommend: recommend_value, default: default_if_silent, close } = values;
1506
1588
  const ask = !Object.hasOwn(extra, 'type'), options = ask ? outcomesOf(values) : optionsOf(values);
1507
- const told = ask && { why, recommend_reason: values['recommend-reason'], silent_by: values['silent-by'], merges: values.merge, proof: values.proof };
1589
+ const told = ask && { why, recommend_reason: values['recommend-reason'], silent_by: values['silent-by'], merges: values.merge, proof: values.proof, ask_ref: values['ask-ref'] };
1508
1590
  writeFileSync(source, JSON.stringify({ schema: 2, items: [{ key, title, about, detail, ...told, options, recommend_value, default_if_silent, close, ...extra, filer }] }));
1509
1591
  const incoming = loadDecisions(source, board), raw = readItems(source).items;
1510
1592
  if (!ask)
@@ -1559,11 +1641,23 @@ function checkMerges(incoming, resulting, current) {
1559
1641
  }
1560
1642
  }
1561
1643
  }
1644
+ /** New decision cards need the captain message or hold that prompted them; old cards still re-post. */
1645
+ function checkAskRefs(incoming, raw, current) {
1646
+ for (const [i, d] of incoming.entries()) {
1647
+ if (!['decision', 'ask'].includes(raw[i]?.kind ?? raw[i]?.type ?? 'decision') || current.some((c) => c.key === d.key))
1648
+ continue;
1649
+ const ref = raw[i]?.ask_ref;
1650
+ if (typeof ref !== 'string' || !ref.trim() || ref.length > 256 || /[\s\x00-\x1f\x7f]/.test(ref)) {
1651
+ throw fail(`key ${d.key}: a new Decide card needs a captain ask reference`, 'VALIDATION_ERROR', 'Pass --ask-ref <captain-message-id-or-hold-id> (ask_ref in a message or feed). Follow-ups belong in the backlog, not the board');
1652
+ }
1653
+ }
1654
+ }
1562
1655
  /** exact: a contract post, where a key takes only the same message again, filer and filed included (contract section 4). */
1563
1656
  function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false) {
1564
1657
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
1565
1658
  return locked(`${file}.lock`, () => {
1566
1659
  const current = board(file), items = existsSync(file) ? readItems(file).items : [], saved = readAnswers(file), now = new Date().toISOString();
1660
+ checkAskRefs(incoming, raw, current);
1567
1661
  checkMerges(incoming, [...current.filter((c) => !incoming.some((d) => d.key === c.key)), ...incoming], current);
1568
1662
  // Proof is evidence: a new ask that offers Pass and Fail would sit in Waiting on you as an ask, so it is refused.
1569
1663
  const proof = incoming.find((d) => d.type === 'decision' && !d.kind && !current.some((c) => c.key === d.key) && ['pass', 'fail'].every((v) => d.options.some((o) => o.value === v)));
@@ -1588,7 +1682,7 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
1588
1682
  if (!had && Object.hasOwn(saved, d.key) && !awaitsVerdict(d.type, saved[d.key]))
1589
1683
  throw refuse(`already has a saved answer on ${tilde(file)} from an item no longer there`);
1590
1684
  // The same question from another filer, or at another time, is the same question: it keeps its first filer and time.
1591
- const same = (x) => JSON.stringify({ ...x, answer: undefined, dismissed: undefined, filer: undefined, filed: undefined, options: [...x.options].sort((a, b) => a.value.localeCompare(b.value)) });
1685
+ const same = (x) => JSON.stringify({ ...x, answer: undefined, dismissed: undefined, filer: undefined, filed: undefined, ask_ref: undefined, options: [...x.options].sort((a, b) => a.value.localeCompare(b.value)) });
1592
1686
  // A message that names no filed time matches the one the board stamped.
1593
1687
  const differs = () => exact ? canon({ ...items.find((x) => x?.key === d.key), ...(raw[i].filed === undefined && { filed: undefined }), announced: undefined }) !== canon(raw[i]) : same(had) !== same(d);
1594
1688
  if (had && differs())
@@ -1821,7 +1915,7 @@ const skillInstalled = (dir, agent) => [join(dir, AGENTS[agent].dir), AGENTS[age
1821
1915
  .some((skills) => existsSync(join(skills, 'sign-off-axi', 'SKILL.md')));
1822
1916
  const SKILL_ADD = ['-y', 'skills', 'add', 'umeranjum17/sign-off', '--skill', 'sign-off-axi', '-y', '-a'];
1823
1917
  const skillAdd = (agents) => `npx ${[...SKILL_ADD, ...agents].join(' ')}`;
1824
- const GITIGNORE = 'mocks/\nevidence/\n*.answers.jsonl\n*.rounds.jsonl\n*.messages.jsonl\n*.annotations.jsonl\n*.seen.jsonl\n*.dismissed.jsonl\n*.reads.jsonl\n*.deeper.jsonl\n*.board.pid\n*.board.log\nnotify.log\n*.token\n*.export.html\n';
1918
+ const GITIGNORE = 'mocks/\nevidence/\n*.answers.jsonl\n*.rounds.jsonl\n*.messages.jsonl\n*.annotations.jsonl\n*.seen.jsonl\n*.dismissed.jsonl\n*.reads.jsonl\n*.deeper.jsonl\n*.owners.json\n*.delivery.jsonl\n*.board.pid\n*.board.log\nnotify.log\n*.token\n*.export.html\n';
1825
1919
  /** The board, port, bind and hosts in <so>/config.yaml (the `key: value` lines init writes). A bad line falls back on its own default
1826
1920
  * (decisions.json, 4410) and `problems` says what is wrong; a file that cannot be read throws. */
1827
1921
  function readSetup(so) {
@@ -2002,21 +2096,38 @@ function init(args) {
2002
2096
  `Run \`sign-off-axi doctor ${shellPath(dir)}\` to check the setup`, `Run \`sign-off-axi open ${shellPath(board)}\` to show the board to the user`
2003
2097
  ]);
2004
2098
  }
2005
- /** report init <folder>: the scaffold page and its picture, kept when already there, with the post command that names them. */
2099
+ /** report init <folder>: the guided template (report.json, or plan.json with --plan) to fill in, or with --page the older scaffold page and its picture. */
2006
2100
  function report(args) {
2007
2101
  let parsed;
2008
2102
  try {
2009
- parsed = parseArgs({ args, allowPositionals: true, strict: true, options: {} });
2103
+ parsed = parseArgs({ args, allowPositionals: true, strict: true, options: { plan: { type: 'boolean' }, page: { type: 'boolean' } } });
2010
2104
  }
2011
2105
  catch (e) {
2012
2106
  throw fail(e.message, 'VALIDATION_ERROR', 'Run `sign-off-axi report --help`');
2013
2107
  }
2014
- const [sub, dir, extra] = parsed.positionals;
2015
- if (sub !== 'init' || dir === undefined || extra !== undefined) {
2016
- throw fail(sub !== 'init' ? `unknown report command ${sub ?? '(none)'}` : extra !== undefined ? `unexpected argument ${extra}: report init takes one folder` : 'report init needs the folder to write the page in', 'VALIDATION_ERROR', 'sign-off-axi report init .sign-off/mocks/<key>');
2108
+ const [sub, dir, extra] = parsed.positionals, { plan, page } = parsed.values;
2109
+ if (sub !== 'init' || dir === undefined || extra !== undefined || (plan && page)) {
2110
+ throw fail(sub !== 'init' ? `unknown report command ${sub ?? '(none)'}` : extra !== undefined ? `unexpected argument ${extra}: report init takes one folder` : plan && page ? '--plan and --page do not go together' : 'report init needs the folder to write the template in', 'VALIDATION_ERROR', 'sign-off-axi report init <folder> [--plan]');
2017
2111
  }
2018
2112
  const out = resolve(dir), created = [], kept = [];
2019
2113
  mkdirSync(out, { recursive: true });
2114
+ // The board serves a page from its own folder, so the post command names it from there.
2115
+ const board = boardHere(), rel = relative(dirname(board), out).split(sep).join('/'), served = rel && !rel.startsWith('../') && rel !== '..' && !isAbsolute(rel);
2116
+ if (!page) {
2117
+ const kind = plan ? 'plan' : 'report', path = join(out, `${kind}.json`);
2118
+ if (existsSync(path))
2119
+ kept.push(shellPath(path));
2120
+ else {
2121
+ const filed = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
2122
+ writeFileSync(path, readFileSync(new URL(`../web/${kind}-template.json`, import.meta.url), 'utf8').replace('"FILL: UTC time"', JSON.stringify(filed)), { flag: 'wx' });
2123
+ created.push(shellPath(path));
2124
+ }
2125
+ return toon({ folder: shellPath(out), created: created.length ? created : 'nothing new (re-run is a no-op)', kept }, [
2126
+ `Replace every FILL: line in ${kind}.json with your own words: the answer first, one picture (bars, steps or tiles), what you understood, then the sections${plan ? ' and the decisions' : ''}`,
2127
+ 'Keep each line short: the board shows it in one scroll, the numbers behind a Details tap and the long answer behind Go deeper',
2128
+ `Run \`sign-off-axi post --kind ${kind} --from ${shellPath(path)}\` to put it on the board`,
2129
+ ]);
2130
+ }
2020
2131
  for (const [name, from] of [['index.html', 'report.html'], ['preview.svg', 'report-preview.svg']]) {
2021
2132
  const path = join(out, name);
2022
2133
  if (existsSync(path))
@@ -2026,8 +2137,6 @@ function report(args) {
2026
2137
  created.push(shellPath(path));
2027
2138
  }
2028
2139
  }
2029
- // The board serves a report's page from its own folder, so the post command names the page from there.
2030
- const board = boardHere(), rel = relative(dirname(board), out).split(sep).join('/'), served = rel && !rel.startsWith('../') && rel !== '..' && !isAbsolute(rel);
2031
2140
  return toon({ folder: shellPath(out), created: created.length ? created : 'nothing new (re-run is a no-op)', kept }, [
2032
2141
  'Replace every FILL: line: the answer and its picture first, then the diagram, the steps to tap, and the folded detail',
2033
2142
  'Open index.html in a browser 390 px wide and look before posting: mostly text means it is not ready',
@@ -2311,7 +2420,7 @@ async function serve(args) {
2311
2420
  rmSync(record(file), { force: true }); });
2312
2421
  for (const sig of ['SIGINT', 'SIGTERM'])
2313
2422
  process.on(sig, () => process.exit(0));
2314
- return toon({ board: tilde(file), url, pid: process.pid }, [linkHelp(file, url), `Run \`sign-off-axi answers${parsed.on}\` to read their answers`]);
2423
+ return toon({ board: tilde(file), url, pid: process.pid }, [linkHelp(file, url), `Run \`sign-off-axi answers${parsed.on}\` to read their answers`, `Run \`sign-off-axi stop${parsed.on}\` to close the board (no shell kill needed)`]);
2315
2424
  }
2316
2425
  /** Every new comment and queued prompt, each handed over once. */
2317
2426
  async function poll(args) {
@@ -2350,13 +2459,14 @@ async function exportBoard(args) {
2350
2459
  const { file: _, messages: _messages, annotations: _annotations, ...frozen } = state(), files = {}, q = encodeURIComponent;
2351
2460
  let missing = 0;
2352
2461
  // The same URLs the page asks the server for, read through the same hash and path checks.
2353
- const inline = async (url, query) => {
2462
+ const inline = async (url, query, document = false) => {
2354
2463
  const [code, body] = await route({ method: 'GET', url }).catch(() => [404, null]);
2355
2464
  if (code !== 200) {
2356
2465
  missing++;
2357
2466
  return;
2358
2467
  }
2359
- const { media, type } = body;
2468
+ const { media: bytes, type } = body;
2469
+ const media = document && type === 'text/html' ? Buffer.concat([bytes, pointer]) : bytes;
2360
2470
  // A data: URL keeps a one-page mock's query where the mock page reads it, but also appends it to the page text: a trailing comment hides it.
2361
2471
  files[url] = query && type === 'text/html' ? `data:text/html;charset=utf-8,${q(media.toString())}%3C!--?${query}` : `data:${type};base64,${media.toString('base64')}`;
2362
2472
  };
@@ -2369,7 +2479,7 @@ async function exportBoard(args) {
2369
2479
  }
2370
2480
  // A diagram or an interactive page is the item's own view, inlined the same way.
2371
2481
  if (d.view)
2372
- await inline(at(d.key, '', d.view));
2482
+ await inline(at(d.key, '', d.view), undefined, true);
2373
2483
  if (d.preview)
2374
2484
  await inline(at(d.key, '', d.preview));
2375
2485
  for (const i of (d.media ?? []).keys())
@@ -2502,6 +2612,7 @@ const state = () => {
2502
2612
  seen_at: Object.fromEntries(Object.values(answers).flatMap((a) => { const t = seenAt(file)(a); return t ? [[a.key, t]] : []; })),
2503
2613
  reads: readReads(file),
2504
2614
  deeper: readDeeper(file),
2615
+ delivery: deliveries(file),
2505
2616
  rounds,
2506
2617
  messages: readMessages(file, warn),
2507
2618
  annotations: readOnly() || answersOnly() ? [] : ((sent) => readAnnotations(file, warn).map((a) => (sent.has(a.id) ? { ...a, sent: true } : a)))(sentIds(file)),
@@ -2646,8 +2757,8 @@ async function route(req) {
2646
2757
  const actual = under(dirname(under(dirname(file), mockFile(mock))), path);
2647
2758
  if (statSync(actual).size > MOCK_BYTES)
2648
2759
  return [404, { error: 'Mock is unavailable' }];
2649
- // A design mock page, and a plan's page, get the pointing bridge when served; export inlines the page as written.
2650
- return [200, { media: readFileSync(actual), type, point: (Boolean(value) || d.type === 'plan') && type === 'text/html' }];
2760
+ // Every HTML view gets the bridge: document readers need its size handshake too.
2761
+ return [200, { media: readFileSync(actual), type, point: type === 'text/html' }];
2651
2762
  }
2652
2763
  catch {
2653
2764
  return [404, { error: 'Mock is unavailable' }];
@@ -2707,7 +2818,7 @@ async function route(req) {
2707
2818
  clearTimeout(timer);
2708
2819
  return [200, state()];
2709
2820
  }
2710
- if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin', '/api/leash', '/api/deeper', '/api/reads'].includes(req.url ?? ''))
2821
+ if (req.method !== 'POST' || !['/api/answers', '/api/rounds', '/api/messages', '/api/annotations', '/api/dismiss', '/api/signin', '/api/leash', '/api/deeper', '/api/reads', '/api/engagement', '/api/engagement/seen'].includes(req.url ?? ''))
2711
2822
  return [404, { error: 'Not found' }];
2712
2823
  if (readOnly())
2713
2824
  return [403, { error: 'This board is read-only' }];
@@ -2800,6 +2911,14 @@ async function route(req) {
2800
2911
  appendDismissed(file, [...new Set(keys)], dismissed);
2801
2912
  return [200, state()];
2802
2913
  }
2914
+ if (req.url === '/api/engagement' || req.url === '/api/engagement/seen') {
2915
+ const input = JSON.parse(body);
2916
+ if (req.url.endsWith('/seen'))
2917
+ markEngagementSeen(file, input.id, input.answerRevision);
2918
+ else
2919
+ askEngagement(file, input);
2920
+ return [200, state()];
2921
+ }
2803
2922
  if (req.url === '/api/reads' || req.url === '/api/deeper') {
2804
2923
  const { key, part, q } = JSON.parse(body) ?? {}, d = loadDecisions(file).find((x) => x.key === key), about = typeof part === 'string' && d ? guidePart(d, part) : undefined;
2805
2924
  if (!about)
@@ -3053,7 +3172,7 @@ await runAxiCli({
3053
3172
  topLevelHelp: TOP_HELP,
3054
3173
  getCommandHelp: (cmd) => HELP[cmd],
3055
3174
  home: axi('list', list),
3056
- commands: { list: axi('list', list), raise: axi('raise', (args) => post(args, 'raise')), post: axi('post', post), answers: axi('answers', answers), wait: axi('wait', wait), poll: axi('poll', poll), open: axi('open', open), status: axi('status', status), stop: axi('stop', stop), serve: axi('serve', serve), export: axi('export', exportBoard), init: axi('init', init), report: axi('report', report), deeper: axi('deeper', deeper), signin: axi('signin', signin), dismiss: axi('dismiss', dismiss(false)), restore: axi('restore', dismiss(true)), refile: axi('refile', refile), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
3175
+ commands: { list: axi('list', list), raise: axi('raise', (args) => post(args, 'raise')), post: axi('post', post), answers: axi('answers', answers), wait: axi('wait', wait), poll: axi('poll', poll), open: axi('open', open), status: axi('status', status), stop: axi('stop', stop), serve: axi('serve', serve), export: axi('export', exportBoard), init: axi('init', init), report: axi('report', report), owner: axi('owner', owner), deeper: axi('deeper', deeper), signin: axi('signin', signin), dismiss: axi('dismiss', dismiss(false)), restore: axi('restore', dismiss(true)), refile: axi('refile', refile), doctor: axi('doctor', doctor), notify: axi('notify', notify), validate, help: ([name, ...rest]) => {
3057
3176
  if (rest.length || (name && !HELP[name]))
3058
3177
  throw fail(`unknown command or argument: ${[name, ...rest].join(' ')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi --help` to see the commands');
3059
3178
  return name ? HELP[name] : TOP_HELP;