sign-off-axi 0.5.2 → 0.5.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.
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,7 +85,7 @@ 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) |
@@ -97,7 +99,9 @@ One line on what the agent runs per kind; the full reference is [docs/question-k
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,9 +11,10 @@ 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 { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, guided, mediaFiles, parseMessage, reportPage } from './contract.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';
17
+ import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, diagramPages, diagramViews, 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';
19
20
  const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
@@ -29,13 +30,13 @@ 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
@@ -72,6 +73,9 @@ examples:
72
73
  sign-off-axi raise [board.json] --from <decisions.json> [--filer <id>]
73
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.
74
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.
75
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:
76
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.
77
81
  --why says what it is about and why it needs an answer now (200 characters or fewer).
@@ -90,8 +94,8 @@ Raising the same question again is a no-op that reports its status; a different
90
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.
91
95
  SIGN_OFF_FILER sets it for every command; with neither, the Claude Code session id is saved when there is one.
92
96
  examples:
93
- 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
94
- 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
95
99
  sign-off-axi raise --from ./decisions.json
96
100
  `,
97
101
  validate: `usage: sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid]
@@ -135,14 +139,16 @@ examples:
135
139
  Blocks until the user answers, then prints the answer the way answers does. Use it instead of a polling loop.
136
140
  --filer only the items posted with this --filer (or SIGN_OFF_FILER): returns every answer to them this filer has not taken yet,
137
141
  even one saved before wait started, and marks it taken, so the board shows "Seen by agent" on that card;
138
- 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
139
144
  --key wait for that decision; an answer saved before wait starts returns at once
140
145
  and a key not raised yet is waited for too
141
146
  without --key, wait for the next answer to any decision that still needs an answer; NOT_FOUND when nothing needs one
142
147
  --timeout seconds to wait, 600 (10 minutes) by default; then exits 1 with code TIMEOUT
143
148
  --full adds the button label, close and title
144
149
  --json {"<key>":{"answer","label","close","at","note"?}} for the new answers; note only when the user wrote one
145
- --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):
146
152
  {"event":"answer","contract":1,"kind","schema","key","title","answer","label","close","at","note"?,"filer"?,"comments"?}
147
153
  {"event":"round","contract":1,"kind","schema","key","title","note","at","filer"?,"comments"?}; a board-format item reports as ask schema 1
148
154
  A note column appears when any shown answer has a note.
@@ -191,7 +197,8 @@ examples:
191
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>]
192
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>]
193
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>]
194
- 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.
195
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,
196
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.
197
204
  --kind evidence copies its media, read from the message file's folder (the current folder for stdin), into evidence/<number>/ like --type evidence;
@@ -239,7 +246,7 @@ within 20 seconds, shows on the card and Open can try again. It is stopped after
239
246
  examples:
240
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
241
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
242
- 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
243
250
  sign-off-axi post --kind ask --from ask.json --filer agent-41
244
251
  sign-off-axi post --kind evidence --from proof/evidence.json --filer done-done
245
252
  sign-off-axi post --kind gate --from gate.json --filer done-done
@@ -308,9 +315,20 @@ examples:
308
315
  sign-off-axi init ./my-project --agent opencode
309
316
  sign-off-axi init --agent codex,gemini-cli
310
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.`,
311
329
  deeper: `usage: sign-off-axi deeper [board.json] [--filer <id>] [--all] [--json]
312
- sign-off-axi deeper [board.json] --id <id> --text "<answer>" [--more]
313
- 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.
314
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.
315
333
  --id the ask to answer --text the answer, or - to read stdin (2000 characters or fewer per piece)
316
334
  --more more pieces follow --all also the finished asks
@@ -435,6 +453,8 @@ function contractItems(items, from, file, replace = false) {
435
453
  checkMessage(check);
436
454
  if ((check.kind === 'report' || check.kind === 'plan') && !guided(check))
437
455
  reportPage(check, dirname(file));
456
+ if (guided(check))
457
+ diagramPages(check, dirname(file));
438
458
  if (check.kind === 'design')
439
459
  designFiles(check, dirname(file));
440
460
  }
@@ -549,8 +569,8 @@ function filerOf(values, session = false) {
549
569
  }
550
570
  const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
551
571
  : `Run \`sign-off-axi wait${on}${key ? ` --key ${key}` : ''}\` to block until the user answers`);
552
- /** Still waiting on the user: not answered, not dismissed, and something to answer (an item with no options and no reply is only shown). */
553
- const waits = (d) => asks(d) && !d.dismissed && awaitsVerdict(d.type, d.answer);
572
+ /** 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). */
573
+ const waits = (d) => asks(d) && !lapsed(d) && !d.dismissed && awaitsVerdict(d.type, d.answer);
554
574
  /** The items that archive after a day: all of them, except on a board another program feeds, which owns what it shows. */
555
575
  const archivable = (file, decisions) => ((x) => (x.readonly === true || x.answers_only === true ? [] : decisions))(readItems(file).extra);
556
576
  /** The board's decisions with their latest answer; a missing board file is an empty board.
@@ -659,12 +679,40 @@ function eventLines(file, rows, answers, rounds) {
659
679
  function deeperOut(file, asks, on, events) {
660
680
  if (events) {
661
681
  const kind = kindOf(file), titles = new Map(board(file).map((d) => [d.key, d.title]));
662
- 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');
682
+ 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');
663
683
  }
664
- 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',
684
+ 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',
665
685
  `Run \`sign-off-axi deeper${on} --id <id> --text "<what you found>" --more\` for each piece, then once more without --more to finish`]);
666
686
  }
667
687
  /** `deeper`: the Go deeper asks nobody has finished, or with --id and --text one piece of the answer the board streams under the part the person tapped. */
688
+ async function owner(args) {
689
+ 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' } });
690
+ ready(file, '');
691
+ if (values.ack !== undefined) {
692
+ acknowledgeOwner(file, values.ack);
693
+ markDeeperRead(file, [values.ack]);
694
+ }
695
+ else if (values.session !== undefined) {
696
+ const filer = filerOf(values);
697
+ if (!filer)
698
+ throw new InputError('Registration needs --filer');
699
+ await locked(`${file}.owners.lock`, () => registerOwner(file, filer, { host: values.host, session: values.session, cwd: values.cwd, ...(values.model && { model: values.model }) }));
700
+ }
701
+ else if (values.dispatch) {
702
+ if (values.timeout !== undefined && !/^\d{1,7}$/.test(values.timeout))
703
+ throw new InputError('--timeout must be whole seconds');
704
+ const until = Date.now() + Number(values.timeout ?? 0) * 1000;
705
+ await locked(`${file}.dispatch.lock`, async () => {
706
+ do {
707
+ await dispatchOwners(file, process.argv[1], filerOf(values), values.verbose);
708
+ if (Date.now() >= until)
709
+ break;
710
+ await new Promise(ok => setTimeout(ok, 1000));
711
+ } while (Date.now() < until);
712
+ }, 'another owner dispatcher is active', 0);
713
+ }
714
+ return JSON.stringify({ delivery: deliveries(file) });
715
+ }
668
716
  async function deeper(args) {
669
717
  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' } });
670
718
  ready(file, on);
@@ -679,11 +727,23 @@ async function deeper(args) {
679
727
  if (!open.length)
680
728
  return toon({ board: tilde(file), deeper: '0 open' }, [`Run \`sign-off-axi wait${on}\` to be woken when the person taps Go deeper`]);
681
729
  markDeeperRead(file, open.filter((x) => !x.read).map((x) => x.id));
682
- 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`]);
730
+ 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`]);
683
731
  }
684
732
  const ask = readDeeper(file).find((x) => x.id === values.id);
685
733
  if (!ask)
686
734
  throw fail(`no Go deeper ask with id ${values.id}`, 'NOT_FOUND', `Run \`sign-off-axi deeper${on}\` to see the open ones`);
735
+ checkEngagement(file, ask);
736
+ if (ask.version === 1 && !process.env.SIGN_OFF_OWNER_SESSION && filer !== ask.filer)
737
+ throw new InputError('Engagement reply requires its owning --filer');
738
+ if (filer !== undefined && !board(file).some(d => d.key === ask.key && d.filer === filer))
739
+ throw new InputError('Request does not belong to this filer');
740
+ if (process.env.SIGN_OFF_OWNER_SESSION) {
741
+ checkOwner(file, ask);
742
+ if (ask.done)
743
+ return toon({ id: ask.id, answered: 'already done' }, []);
744
+ if (values.more || deliveries(file)[ask.id]?.state !== 'owner-read')
745
+ throw new InputError('Owner must acknowledge first and send one final answer without --more');
746
+ }
687
747
  if (ask.done)
688
748
  throw fail(`ask ${values.id} is already answered`, 'CONFLICT', 'The person can tap Go deeper again for a follow-up');
689
749
  let text = values.text;
@@ -691,7 +751,11 @@ async function deeper(args) {
691
751
  text = readFileSync(0, 'utf8');
692
752
  if (typeof text !== 'string' || !text.trim() || [...text.trim()].length > 2000)
693
753
  throw fail('--text must be the answer, 2000 characters or fewer', 'VALIDATION_ERROR', 'Run `sign-off-axi deeper --help`');
694
- answerDeeper(file, ask.id, text.trim(), !!values.more);
754
+ await locked(`${deeperPath(file)}.lock`, () => {
755
+ checkEngagement(file, ask);
756
+ if (!process.env.SIGN_OFF_OWNER_SESSION || !readDeeper(file).find(x => x.id === ask.id)?.done)
757
+ answerDeeper(file, ask.id, text.trim(), !!values.more);
758
+ });
695
759
  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`]);
696
760
  }
697
761
  async function wait(args) {
@@ -756,12 +820,12 @@ async function wait(args) {
756
820
  if (found.length && values.events)
757
821
  return eventLines(file, found, given, asked.filter((r) => fresh.some((d) => d.key === r.key)));
758
822
  if (found.length) {
759
- 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`]);
823
+ 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`]);
760
824
  }
761
825
  // A Go deeper ask on this filer's guided page wakes the wait once, and is marked read when it is handed over.
762
- if (values.key === undefined) {
826
+ if (filer !== undefined) {
763
827
  const asks = await locked(`${deeperPath(file)}.lock`, () => {
764
- const got = readDeeper(file).filter((x) => !x.read && rows.some((d) => d.key === x.key));
828
+ const got = readDeeper(file).filter((x) => !x.read && !x.done && (values.key === undefined || x.key === values.key) && rows.some((d) => d.key === x.key));
765
829
  markDeeperRead(file, got.map((x) => x.id));
766
830
  return got;
767
831
  }, 'another wait is still taking Go deeper asks');
@@ -797,7 +861,7 @@ async function wait(args) {
797
861
  return vaultEvent(file, [{ key: values.key, at: event.at, status: event.status }], values);
798
862
  }
799
863
  // A dismissed ask is hidden from the board, not withdrawn: it is not counted as waiting, but the agent still waits on it.
800
- if (values.key === undefined && !rows.some((d) => waits({ ...d, dismissed: false })) && !live && !unknown) {
864
+ if (values.key === undefined && !rows.some((d) => waits({ ...d, dismissed: false }) || (filer !== undefined && d.guided)) && !live && !unknown) {
801
865
  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`);
802
866
  }
803
867
  const left = deadline - Date.now();
@@ -898,7 +962,7 @@ function post(args, cmd = 'post') {
898
962
  const typed = cmd === 'post' ? { replace: { type: 'boolean' }, kind: { type: 'string' }, type: { type: 'string' }, tradeoff: { type: 'string' }, mock: { type: 'string', multiple: true },
899
963
  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 },
900
964
  ...Object.fromEntries(SIGNIN_FIELDS.map((f) => [f, { type: 'string' }])) } : {};
901
- const { file, named, values, on } = parse(cmd, args, { from: { type: 'string' }, filer: { type: 'string' }, ...typed,
965
+ const { file, named, values, on } = parse(cmd, args, { from: { type: 'string' }, filer: { type: 'string' }, 'ask-ref': { type: 'string' }, ...typed,
902
966
  ...Object.fromEntries(RAISE_FIELDS.map((f) => [f, { type: 'string', multiple: MANY.includes(f) }])) });
903
967
  const given = [...RAISE_FIELDS, 'type', 'tradeoff', 'mock', 'media', 'attach', 'link', 'view', 'preview', 'chapter', ...SIGNIN_FIELDS].filter((f) => values[f] !== undefined);
904
968
  // Only a design takes --link more than once (one per option); any other type keeps its one item link, the last given.
@@ -914,7 +978,7 @@ function post(args, cmd = 'post') {
914
978
  if (values.from !== undefined && given.length)
915
979
  throw fail(`--from cannot be combined with --${given[0]}`, 'VALIDATION_ERROR', `Put every field in the --from file, or ${cmd} with flags only`);
916
980
  if (values.replace)
917
- return replace(file, named, on, values.from, filerOf(values));
981
+ return replace(file, named, on, values.from, filerOf(values), values['ask-ref']);
918
982
  const filer = filerOf(values, true);
919
983
  const type = values.type ?? 'decision', told = ASK_FLAGS.find((f) => values[f] !== undefined);
920
984
  if (told && type !== 'decision')
@@ -1122,7 +1186,7 @@ async function refile(args) {
1122
1186
  }
1123
1187
  /** 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.
1124
1188
  * 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. */
1125
- function replace(file, named, on, from, filer) {
1189
+ function replace(file, named, on, from, filer, askRef) {
1126
1190
  if (from === undefined)
1127
1191
  throw fail('--replace needs --from <board file>', 'VALIDATION_ERROR', 'sign-off-axi post board.json --from feed.json --replace');
1128
1192
  const source = resolve(from), fed = readItems(source);
@@ -1146,6 +1210,11 @@ function replace(file, named, on, from, filer) {
1146
1210
  throw fail(`key ${i.key} was answered on ${tilde(file)} and is no longer there`, 'CONFLICT', 'Post it under a new key');
1147
1211
  }
1148
1212
  const current = board(file);
1213
+ if (askRef !== undefined)
1214
+ for (const [i, d] of incoming.entries())
1215
+ if (['decision', 'ask'].includes(feed[i]?.kind ?? feed[i]?.type ?? 'decision') && !current.some((c) => c.key === d.key))
1216
+ feed[i].ask_ref = askRef;
1217
+ checkAskRefs(incoming, feed, current);
1149
1218
  for (const had of current) {
1150
1219
  const d = incoming.find((x) => x.key === had.key);
1151
1220
  if (filer && d && had.filer && had.filer !== filer)
@@ -1399,6 +1468,8 @@ async function postKind(file, named, on, values, built) {
1399
1468
  // A report's or plan's page is served from the board file's folder, as --view is.
1400
1469
  if ((m.kind === 'report' || m.kind === 'plan') && !guided(m))
1401
1470
  reportPage(m, dirname(file));
1471
+ if (guided(m))
1472
+ diagramPages(m, dirname(file));
1402
1473
  if (m.kind === 'design')
1403
1474
  designFiles(m, dirname(file));
1404
1475
  }
@@ -1408,6 +1479,8 @@ async function postKind(file, named, on, values, built) {
1408
1479
  if (m.filer !== undefined && values.filer !== undefined && m.filer !== values.filer) {
1409
1480
  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');
1410
1481
  }
1482
+ if (m.kind === 'ask' && values['ask-ref'] !== undefined)
1483
+ m.ask_ref = values['ask-ref'];
1411
1484
  const filer = m.filer ?? filerOf(values, true), scratch = mkdtempSync(join(tmpdir(), 'sign-off-')), source = join(scratch, basename(name));
1412
1485
  try {
1413
1486
  if (m.kind === 'evidence')
@@ -1504,16 +1577,20 @@ function buildIncoming(values, extra, filer, file) {
1504
1577
  // An item that names its own filer keeps it.
1505
1578
  const source = resolve(values.from), raw = readItems(source).items;
1506
1579
  contractItems(raw, values.from, file);
1580
+ if (values['ask-ref'] !== undefined)
1581
+ for (const item of raw)
1582
+ if ((item.kind ?? item.type ?? 'decision') === 'ask' || (item.type ?? 'decision') === 'decision' && !item.kind)
1583
+ item.ask_ref = values['ask-ref'];
1507
1584
  const incoming = loadDecisions(source, board);
1508
1585
  recommended(raw, false);
1509
- return { incoming: incoming.map((d) => ({ filer, ...d })), raw: raw.map((i) => ({ filer, ...i })) };
1586
+ 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 })) };
1510
1587
  }
1511
1588
  const scratch = mkdtempSync(join(tmpdir(), 'sign-off-'));
1512
1589
  const source = join(scratch, 'flags');
1513
1590
  try {
1514
1591
  const { title, key = slug(title), about, detail, why, recommend: recommend_value, default: default_if_silent, close } = values;
1515
1592
  const ask = !Object.hasOwn(extra, 'type'), options = ask ? outcomesOf(values) : optionsOf(values);
1516
- const told = ask && { why, recommend_reason: values['recommend-reason'], silent_by: values['silent-by'], merges: values.merge, proof: values.proof };
1593
+ 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'] };
1517
1594
  writeFileSync(source, JSON.stringify({ schema: 2, items: [{ key, title, about, detail, ...told, options, recommend_value, default_if_silent, close, ...extra, filer }] }));
1518
1595
  const incoming = loadDecisions(source, board), raw = readItems(source).items;
1519
1596
  if (!ask)
@@ -1568,11 +1645,23 @@ function checkMerges(incoming, resulting, current) {
1568
1645
  }
1569
1646
  }
1570
1647
  }
1648
+ /** New decision cards need the captain message or hold that prompted them; old cards still re-post. */
1649
+ function checkAskRefs(incoming, raw, current) {
1650
+ for (const [i, d] of incoming.entries()) {
1651
+ if (!['decision', 'ask'].includes(raw[i]?.kind ?? raw[i]?.type ?? 'decision') || current.some((c) => c.key === d.key))
1652
+ continue;
1653
+ const ref = raw[i]?.ask_ref;
1654
+ if (typeof ref !== 'string' || !ref.trim() || ref.length > 256 || /[\s\x00-\x1f\x7f]/.test(ref)) {
1655
+ 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');
1656
+ }
1657
+ }
1658
+ }
1571
1659
  /** exact: a contract post, where a key takes only the same message again, filer and filed included (contract section 4). */
1572
1660
  function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false) {
1573
1661
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
1574
1662
  return locked(`${file}.lock`, () => {
1575
1663
  const current = board(file), items = existsSync(file) ? readItems(file).items : [], saved = readAnswers(file), now = new Date().toISOString();
1664
+ checkAskRefs(incoming, raw, current);
1576
1665
  checkMerges(incoming, [...current.filter((c) => !incoming.some((d) => d.key === c.key)), ...incoming], current);
1577
1666
  // Proof is evidence: a new ask that offers Pass and Fail would sit in Waiting on you as an ask, so it is refused.
1578
1667
  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)));
@@ -1597,7 +1686,7 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
1597
1686
  if (!had && Object.hasOwn(saved, d.key) && !awaitsVerdict(d.type, saved[d.key]))
1598
1687
  throw refuse(`already has a saved answer on ${tilde(file)} from an item no longer there`);
1599
1688
  // The same question from another filer, or at another time, is the same question: it keeps its first filer and time.
1600
- 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)) });
1689
+ 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)) });
1601
1690
  // A message that names no filed time matches the one the board stamped.
1602
1691
  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);
1603
1692
  if (had && differs())
@@ -1830,7 +1919,7 @@ const skillInstalled = (dir, agent) => [join(dir, AGENTS[agent].dir), AGENTS[age
1830
1919
  .some((skills) => existsSync(join(skills, 'sign-off-axi', 'SKILL.md')));
1831
1920
  const SKILL_ADD = ['-y', 'skills', 'add', 'umeranjum17/sign-off', '--skill', 'sign-off-axi', '-y', '-a'];
1832
1921
  const skillAdd = (agents) => `npx ${[...SKILL_ADD, ...agents].join(' ')}`;
1833
- 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';
1922
+ 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';
1834
1923
  /** 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
1835
1924
  * (decisions.json, 4410) and `problems` says what is wrong; a file that cannot be read throws. */
1836
1925
  function readSetup(so) {
@@ -2335,7 +2424,7 @@ async function serve(args) {
2335
2424
  rmSync(record(file), { force: true }); });
2336
2425
  for (const sig of ['SIGINT', 'SIGTERM'])
2337
2426
  process.on(sig, () => process.exit(0));
2338
- return toon({ board: tilde(file), url, pid: process.pid }, [linkHelp(file, url), `Run \`sign-off-axi answers${parsed.on}\` to read their answers`]);
2427
+ 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)`]);
2339
2428
  }
2340
2429
  /** Every new comment and queued prompt, each handed over once. */
2341
2430
  async function poll(args) {
@@ -2374,13 +2463,14 @@ async function exportBoard(args) {
2374
2463
  const { file: _, messages: _messages, annotations: _annotations, ...frozen } = state(), files = {}, q = encodeURIComponent;
2375
2464
  let missing = 0;
2376
2465
  // The same URLs the page asks the server for, read through the same hash and path checks.
2377
- const inline = async (url, query) => {
2466
+ const inline = async (url, query, document = false) => {
2378
2467
  const [code, body] = await route({ method: 'GET', url }).catch(() => [404, null]);
2379
2468
  if (code !== 200) {
2380
2469
  missing++;
2381
2470
  return;
2382
2471
  }
2383
- const { media, type } = body;
2472
+ const { media: bytes, type } = body;
2473
+ const media = document && type === 'text/html' ? Buffer.concat([bytes, pointer]) : bytes;
2384
2474
  // 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.
2385
2475
  files[url] = query && type === 'text/html' ? `data:text/html;charset=utf-8,${q(media.toString())}%3C!--?${query}` : `data:${type};base64,${media.toString('base64')}`;
2386
2476
  };
@@ -2393,7 +2483,7 @@ async function exportBoard(args) {
2393
2483
  }
2394
2484
  // A diagram or an interactive page is the item's own view, inlined the same way.
2395
2485
  if (d.view)
2396
- await inline(at(d.key, '', d.view));
2486
+ await inline(at(d.key, '', d.view), undefined, true);
2397
2487
  if (d.preview)
2398
2488
  await inline(at(d.key, '', d.preview));
2399
2489
  for (const i of (d.media ?? []).keys())
@@ -2526,6 +2616,7 @@ const state = () => {
2526
2616
  seen_at: Object.fromEntries(Object.values(answers).flatMap((a) => { const t = seenAt(file)(a); return t ? [[a.key, t]] : []; })),
2527
2617
  reads: readReads(file),
2528
2618
  deeper: readDeeper(file),
2619
+ delivery: deliveries(file),
2529
2620
  rounds,
2530
2621
  messages: readMessages(file, warn),
2531
2622
  annotations: readOnly() || answersOnly() ? [] : ((sent) => readAnnotations(file, warn).map((a) => (sent.has(a.id) ? { ...a, sent: true } : a)))(sentIds(file)),
@@ -2662,7 +2753,9 @@ async function route(req) {
2662
2753
  // and the files beside it in its own folder, so a mock page's relative css, scripts and images load.
2663
2754
  const [item, ...rest] = new URL(req.url, 'http://localhost').pathname.split('/').slice(3), [key, value] = item?.split('~') ?? [];
2664
2755
  const d = loadDecisions(file).find((x) => x.key === key);
2665
- const mock = value ? d?.mocks?.find((m) => m.value === value)?.path : value === '' && d?.view;
2756
+ // A guided document's diagram views: ~diagram.<n>, n counting them in the order visual, then each section and its items.
2757
+ const dgs = d?.guided ? diagramViews(d) : [];
2758
+ const mock = value?.startsWith('diagram.') ? dgs[Number(value.slice(8))]?.src : value ? d?.mocks?.find((m) => m.value === value)?.path : value === '' && d?.view;
2666
2759
  try {
2667
2760
  const path = rest.map(decodeURIComponent).join('/'), type = assetTypes[extname(path).toLowerCase()];
2668
2761
  if (!mock || !type)
@@ -2670,8 +2763,8 @@ async function route(req) {
2670
2763
  const actual = under(dirname(under(dirname(file), mockFile(mock))), path);
2671
2764
  if (statSync(actual).size > MOCK_BYTES)
2672
2765
  return [404, { error: 'Mock is unavailable' }];
2673
- // A design mock page, and a plan's page, get the pointing bridge when served; export inlines the page as written.
2674
- return [200, { media: readFileSync(actual), type, point: (Boolean(value) || d.type === 'plan') && type === 'text/html' }];
2766
+ // Every HTML view gets the bridge: document readers need its size handshake too.
2767
+ return [200, { media: readFileSync(actual), type, point: type === 'text/html' }];
2675
2768
  }
2676
2769
  catch {
2677
2770
  return [404, { error: 'Mock is unavailable' }];
@@ -2731,7 +2824,7 @@ async function route(req) {
2731
2824
  clearTimeout(timer);
2732
2825
  return [200, state()];
2733
2826
  }
2734
- 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 ?? ''))
2827
+ 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 ?? ''))
2735
2828
  return [404, { error: 'Not found' }];
2736
2829
  if (readOnly())
2737
2830
  return [403, { error: 'This board is read-only' }];
@@ -2824,6 +2917,14 @@ async function route(req) {
2824
2917
  appendDismissed(file, [...new Set(keys)], dismissed);
2825
2918
  return [200, state()];
2826
2919
  }
2920
+ if (req.url === '/api/engagement' || req.url === '/api/engagement/seen') {
2921
+ const input = JSON.parse(body);
2922
+ if (req.url.endsWith('/seen'))
2923
+ markEngagementSeen(file, input.id, input.answerRevision);
2924
+ else
2925
+ askEngagement(file, input);
2926
+ return [200, state()];
2927
+ }
2827
2928
  if (req.url === '/api/reads' || req.url === '/api/deeper') {
2828
2929
  const { key, part, q } = JSON.parse(body) ?? {}, d = loadDecisions(file).find((x) => x.key === key), about = typeof part === 'string' && d ? guidePart(d, part) : undefined;
2829
2930
  if (!about)
@@ -3077,7 +3178,7 @@ await runAxiCli({
3077
3178
  topLevelHelp: TOP_HELP,
3078
3179
  getCommandHelp: (cmd) => HELP[cmd],
3079
3180
  home: axi('list', list),
3080
- 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]) => {
3181
+ 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]) => {
3081
3182
  if (rest.length || (name && !HELP[name]))
3082
3183
  throw fail(`unknown command or argument: ${[name, ...rest].join(' ')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi --help` to see the commands');
3083
3184
  return name ? HELP[name] : TOP_HELP;