sign-off-axi 0.5.2 → 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,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,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,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
@@ -549,8 +567,8 @@ function filerOf(values, session = false) {
549
567
  }
550
568
  const waitFor = (on, filer, key) => (filer ? `Run \`sign-off-axi wait${on} --filer ${filer}\` to block until the user answers`
551
569
  : `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);
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);
554
572
  /** The items that archive after a day: all of them, except on a board another program feeds, which owns what it shows. */
555
573
  const archivable = (file, decisions) => ((x) => (x.readonly === true || x.answers_only === true ? [] : decisions))(readItems(file).extra);
556
574
  /** The board's decisions with their latest answer; a missing board file is an empty board.
@@ -659,12 +677,40 @@ function eventLines(file, rows, answers, rounds) {
659
677
  function deeperOut(file, asks, on, events) {
660
678
  if (events) {
661
679
  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');
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');
663
681
  }
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',
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',
665
683
  `Run \`sign-off-axi deeper${on} --id <id> --text "<what you found>" --more\` for each piece, then once more without --more to finish`]);
666
684
  }
667
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
+ }
668
714
  async function deeper(args) {
669
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' } });
670
716
  ready(file, on);
@@ -679,11 +725,23 @@ async function deeper(args) {
679
725
  if (!open.length)
680
726
  return toon({ board: tilde(file), deeper: '0 open' }, [`Run \`sign-off-axi wait${on}\` to be woken when the person taps Go deeper`]);
681
727
  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`]);
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`]);
683
729
  }
684
730
  const ask = readDeeper(file).find((x) => x.id === values.id);
685
731
  if (!ask)
686
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
+ }
687
745
  if (ask.done)
688
746
  throw fail(`ask ${values.id} is already answered`, 'CONFLICT', 'The person can tap Go deeper again for a follow-up');
689
747
  let text = values.text;
@@ -691,7 +749,11 @@ async function deeper(args) {
691
749
  text = readFileSync(0, 'utf8');
692
750
  if (typeof text !== 'string' || !text.trim() || [...text.trim()].length > 2000)
693
751
  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);
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
+ });
695
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`]);
696
758
  }
697
759
  async function wait(args) {
@@ -756,12 +818,12 @@ async function wait(args) {
756
818
  if (found.length && values.events)
757
819
  return eventLines(file, found, given, asked.filter((r) => fresh.some((d) => d.key === r.key)));
758
820
  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`]);
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`]);
760
822
  }
761
823
  // 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) {
824
+ if (filer !== undefined) {
763
825
  const asks = await locked(`${deeperPath(file)}.lock`, () => {
764
- 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));
765
827
  markDeeperRead(file, got.map((x) => x.id));
766
828
  return got;
767
829
  }, 'another wait is still taking Go deeper asks');
@@ -797,7 +859,7 @@ async function wait(args) {
797
859
  return vaultEvent(file, [{ key: values.key, at: event.at, status: event.status }], values);
798
860
  }
799
861
  // 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) {
862
+ if (values.key === undefined && !rows.some((d) => waits({ ...d, dismissed: false }) || (filer !== undefined && d.guided)) && !live && !unknown) {
801
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`);
802
864
  }
803
865
  const left = deadline - Date.now();
@@ -898,7 +960,7 @@ function post(args, cmd = 'post') {
898
960
  const typed = cmd === 'post' ? { replace: { type: 'boolean' }, kind: { type: 'string' }, type: { type: 'string' }, tradeoff: { type: 'string' }, mock: { type: 'string', multiple: true },
899
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 },
900
962
  ...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,
963
+ const { file, named, values, on } = parse(cmd, args, { from: { type: 'string' }, filer: { type: 'string' }, 'ask-ref': { type: 'string' }, ...typed,
902
964
  ...Object.fromEntries(RAISE_FIELDS.map((f) => [f, { type: 'string', multiple: MANY.includes(f) }])) });
903
965
  const given = [...RAISE_FIELDS, 'type', 'tradeoff', 'mock', 'media', 'attach', 'link', 'view', 'preview', 'chapter', ...SIGNIN_FIELDS].filter((f) => values[f] !== undefined);
904
966
  // Only a design takes --link more than once (one per option); any other type keeps its one item link, the last given.
@@ -914,7 +976,7 @@ function post(args, cmd = 'post') {
914
976
  if (values.from !== undefined && given.length)
915
977
  throw fail(`--from cannot be combined with --${given[0]}`, 'VALIDATION_ERROR', `Put every field in the --from file, or ${cmd} with flags only`);
916
978
  if (values.replace)
917
- return replace(file, named, on, values.from, filerOf(values));
979
+ return replace(file, named, on, values.from, filerOf(values), values['ask-ref']);
918
980
  const filer = filerOf(values, true);
919
981
  const type = values.type ?? 'decision', told = ASK_FLAGS.find((f) => values[f] !== undefined);
920
982
  if (told && type !== 'decision')
@@ -1122,7 +1184,7 @@ async function refile(args) {
1122
1184
  }
1123
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.
1124
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. */
1125
- function replace(file, named, on, from, filer) {
1187
+ function replace(file, named, on, from, filer, askRef) {
1126
1188
  if (from === undefined)
1127
1189
  throw fail('--replace needs --from <board file>', 'VALIDATION_ERROR', 'sign-off-axi post board.json --from feed.json --replace');
1128
1190
  const source = resolve(from), fed = readItems(source);
@@ -1146,6 +1208,11 @@ function replace(file, named, on, from, filer) {
1146
1208
  throw fail(`key ${i.key} was answered on ${tilde(file)} and is no longer there`, 'CONFLICT', 'Post it under a new key');
1147
1209
  }
1148
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);
1149
1216
  for (const had of current) {
1150
1217
  const d = incoming.find((x) => x.key === had.key);
1151
1218
  if (filer && d && had.filer && had.filer !== filer)
@@ -1408,6 +1475,8 @@ async function postKind(file, named, on, values, built) {
1408
1475
  if (m.filer !== undefined && values.filer !== undefined && m.filer !== values.filer) {
1409
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');
1410
1477
  }
1478
+ if (m.kind === 'ask' && values['ask-ref'] !== undefined)
1479
+ m.ask_ref = values['ask-ref'];
1411
1480
  const filer = m.filer ?? filerOf(values, true), scratch = mkdtempSync(join(tmpdir(), 'sign-off-')), source = join(scratch, basename(name));
1412
1481
  try {
1413
1482
  if (m.kind === 'evidence')
@@ -1504,16 +1573,20 @@ function buildIncoming(values, extra, filer, file) {
1504
1573
  // An item that names its own filer keeps it.
1505
1574
  const source = resolve(values.from), raw = readItems(source).items;
1506
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'];
1507
1580
  const incoming = loadDecisions(source, board);
1508
1581
  recommended(raw, false);
1509
- 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 })) };
1510
1583
  }
1511
1584
  const scratch = mkdtempSync(join(tmpdir(), 'sign-off-'));
1512
1585
  const source = join(scratch, 'flags');
1513
1586
  try {
1514
1587
  const { title, key = slug(title), about, detail, why, recommend: recommend_value, default: default_if_silent, close } = values;
1515
1588
  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 };
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'] };
1517
1590
  writeFileSync(source, JSON.stringify({ schema: 2, items: [{ key, title, about, detail, ...told, options, recommend_value, default_if_silent, close, ...extra, filer }] }));
1518
1591
  const incoming = loadDecisions(source, board), raw = readItems(source).items;
1519
1592
  if (!ask)
@@ -1568,11 +1641,23 @@ function checkMerges(incoming, resulting, current) {
1568
1641
  }
1569
1642
  }
1570
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
+ }
1571
1655
  /** exact: a contract post, where a key takes only the same message again, filer and filed included (contract section 4). */
1572
1656
  function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false) {
1573
1657
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
1574
1658
  return locked(`${file}.lock`, () => {
1575
1659
  const current = board(file), items = existsSync(file) ? readItems(file).items : [], saved = readAnswers(file), now = new Date().toISOString();
1660
+ checkAskRefs(incoming, raw, current);
1576
1661
  checkMerges(incoming, [...current.filter((c) => !incoming.some((d) => d.key === c.key)), ...incoming], current);
1577
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.
1578
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)));
@@ -1597,7 +1682,7 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
1597
1682
  if (!had && Object.hasOwn(saved, d.key) && !awaitsVerdict(d.type, saved[d.key]))
1598
1683
  throw refuse(`already has a saved answer on ${tilde(file)} from an item no longer there`);
1599
1684
  // 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)) });
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)) });
1601
1686
  // A message that names no filed time matches the one the board stamped.
1602
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);
1603
1688
  if (had && differs())
@@ -1830,7 +1915,7 @@ const skillInstalled = (dir, agent) => [join(dir, AGENTS[agent].dir), AGENTS[age
1830
1915
  .some((skills) => existsSync(join(skills, 'sign-off-axi', 'SKILL.md')));
1831
1916
  const SKILL_ADD = ['-y', 'skills', 'add', 'umeranjum17/sign-off', '--skill', 'sign-off-axi', '-y', '-a'];
1832
1917
  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';
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';
1834
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
1835
1920
  * (decisions.json, 4410) and `problems` says what is wrong; a file that cannot be read throws. */
1836
1921
  function readSetup(so) {
@@ -2335,7 +2420,7 @@ async function serve(args) {
2335
2420
  rmSync(record(file), { force: true }); });
2336
2421
  for (const sig of ['SIGINT', 'SIGTERM'])
2337
2422
  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`]);
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)`]);
2339
2424
  }
2340
2425
  /** Every new comment and queued prompt, each handed over once. */
2341
2426
  async function poll(args) {
@@ -2374,13 +2459,14 @@ async function exportBoard(args) {
2374
2459
  const { file: _, messages: _messages, annotations: _annotations, ...frozen } = state(), files = {}, q = encodeURIComponent;
2375
2460
  let missing = 0;
2376
2461
  // The same URLs the page asks the server for, read through the same hash and path checks.
2377
- const inline = async (url, query) => {
2462
+ const inline = async (url, query, document = false) => {
2378
2463
  const [code, body] = await route({ method: 'GET', url }).catch(() => [404, null]);
2379
2464
  if (code !== 200) {
2380
2465
  missing++;
2381
2466
  return;
2382
2467
  }
2383
- const { media, type } = body;
2468
+ const { media: bytes, type } = body;
2469
+ const media = document && type === 'text/html' ? Buffer.concat([bytes, pointer]) : bytes;
2384
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.
2385
2471
  files[url] = query && type === 'text/html' ? `data:text/html;charset=utf-8,${q(media.toString())}%3C!--?${query}` : `data:${type};base64,${media.toString('base64')}`;
2386
2472
  };
@@ -2393,7 +2479,7 @@ async function exportBoard(args) {
2393
2479
  }
2394
2480
  // A diagram or an interactive page is the item's own view, inlined the same way.
2395
2481
  if (d.view)
2396
- await inline(at(d.key, '', d.view));
2482
+ await inline(at(d.key, '', d.view), undefined, true);
2397
2483
  if (d.preview)
2398
2484
  await inline(at(d.key, '', d.preview));
2399
2485
  for (const i of (d.media ?? []).keys())
@@ -2526,6 +2612,7 @@ const state = () => {
2526
2612
  seen_at: Object.fromEntries(Object.values(answers).flatMap((a) => { const t = seenAt(file)(a); return t ? [[a.key, t]] : []; })),
2527
2613
  reads: readReads(file),
2528
2614
  deeper: readDeeper(file),
2615
+ delivery: deliveries(file),
2529
2616
  rounds,
2530
2617
  messages: readMessages(file, warn),
2531
2618
  annotations: readOnly() || answersOnly() ? [] : ((sent) => readAnnotations(file, warn).map((a) => (sent.has(a.id) ? { ...a, sent: true } : a)))(sentIds(file)),
@@ -2670,8 +2757,8 @@ async function route(req) {
2670
2757
  const actual = under(dirname(under(dirname(file), mockFile(mock))), path);
2671
2758
  if (statSync(actual).size > MOCK_BYTES)
2672
2759
  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' }];
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' }];
2675
2762
  }
2676
2763
  catch {
2677
2764
  return [404, { error: 'Mock is unavailable' }];
@@ -2731,7 +2818,7 @@ async function route(req) {
2731
2818
  clearTimeout(timer);
2732
2819
  return [200, state()];
2733
2820
  }
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 ?? ''))
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 ?? ''))
2735
2822
  return [404, { error: 'Not found' }];
2736
2823
  if (readOnly())
2737
2824
  return [403, { error: 'This board is read-only' }];
@@ -2824,6 +2911,14 @@ async function route(req) {
2824
2911
  appendDismissed(file, [...new Set(keys)], dismissed);
2825
2912
  return [200, state()];
2826
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
+ }
2827
2922
  if (req.url === '/api/reads' || req.url === '/api/deeper') {
2828
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;
2829
2924
  if (!about)
@@ -3077,7 +3172,7 @@ await runAxiCli({
3077
3172
  topLevelHelp: TOP_HELP,
3078
3173
  getCommandHelp: (cmd) => HELP[cmd],
3079
3174
  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]) => {
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]) => {
3081
3176
  if (rest.length || (name && !HELP[name]))
3082
3177
  throw fail(`unknown command or argument: ${[name, ...rest].join(' ')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi --help` to see the commands');
3083
3178
  return name ? HELP[name] : TOP_HELP;
package/dist/contract.js CHANGED
@@ -11,15 +11,15 @@ export class ContractError extends Error {
11
11
  }
12
12
  }
13
13
  /** The kinds, with the schema number of each. */
14
- export const SCHEMAS = { ask: 1, report: 3, evidence: 1, gate: 1, finding: 1, plan: 2, design: 1 };
14
+ export const SCHEMAS = { ask: 1, report: 4, evidence: 1, gate: 1, finding: 1, plan: 3, design: 1 };
15
15
  /** Older schemas this sign-off-axi still takes, so a board that holds them loads and re-posts: the page-based report 2 and plan 1. */
16
- const OLDER = { report: [2], plan: [1] };
16
+ const OLDER = { report: [2, 3], plan: [1, 2] };
17
17
  /** The guided schemas: a report 3 and a plan 2 are fields the board draws as one guided page, not a page the agent draws. */
18
- export const guided = (m) => (m?.kind === 'report' && m.schema === 3) || (m?.kind === 'plan' && m.schema === 2);
18
+ export const guided = (m) => (m?.kind === 'report' && [3, 4].includes(m.schema)) || (m?.kind === 'plan' && [2, 3].includes(m.schema));
19
19
  export const KINDS = Object.keys(SCHEMAS);
20
20
  const COMMON = ['contract', 'kind', 'schema', 'key', 'title', 'filer', 'link', 'about'];
21
21
  const FIELDS = {
22
- ask: [...COMMON, 'detail', 'why', 'options', 'recommend_value', 'recommend_reason', 'default_if_silent', 'silent_by', 'close', 'merges', 'proof'],
22
+ ask: [...COMMON, 'ask_ref', 'detail', 'why', 'options', 'recommend_value', 'recommend_reason', 'default_if_silent', 'silent_by', 'close', 'merges', 'proof'],
23
23
  report: [...COMMON, 'filed', 'group', 'decided', 'form', 'video', 'asked_by', 'view', 'preview'],
24
24
  evidence: [...COMMON, 'media', 'run'],
25
25
  gate: [...COMMON, 'run', 'options', 'recommend_value', 'close'],
@@ -314,6 +314,8 @@ export function checkMessage(m, want, flags = false) {
314
314
  if (!Object.hasOwn(m, 'title'))
315
315
  throw bad('title', 'is required', 'Add "title": the question, in 160 characters or fewer');
316
316
  text('title', m.title, 160, 'the question, in 160 characters or fewer');
317
+ if (g && m.schema === SCHEMAS[m.kind] && !isKey(m.filer))
318
+ throw bad('filer', 'is required', 'Set "filer" to the owning agent id');
317
319
  if (Object.hasOwn(m, 'filer') && !isKey(m.filer))
318
320
  throw bad('filer', 'must be 1-128 of A-Z a-z 0-9 . _ -', 'Set "filer" to your agent session id or tool name, or leave it out');
319
321
  if (Object.hasOwn(m, 'link') && !(typeof m.link === 'string' && /^(https?:\/\/[^\s\p{Cc}\p{Cs}]{1,500}|[a-f0-9]{7,40})$/u.test(m.link))) {
@@ -14,8 +14,11 @@ const SATURATED = 96;
14
14
  const BAND_MIN = 0.05;
15
15
  const BARS_MIN = 5;
16
16
  const BARS_SHARE_MIN = 0.6;
17
- /** A placeholder is one flat colour over this share of the picture; the real captures measured top out at 75%, the text renders are text. */
17
+ /** A placeholder is one flat colour over this share of the picture, with almost nothing drawn on it. A real screen can be as empty (a chat
18
+ * with no messages is 90% paper) but its header, message, composer and tab bar draw 7% or more of the picture in other colours; a flat card
19
+ * with a label or icon draws under 1%. */
18
20
  const PLACEHOLDER_MIN = 0.9;
21
+ const PLACEHOLDER_DETAIL_MAX = 0.05;
19
22
  /** A picture with no ink at all (a flat colour) is not text, so it passes. */
20
23
  const INK_MIN = 0.005;
21
24
  /** How close (0-255 RGB distance) a pixel must be to the paper or to the paper-ink line to count as text. */
@@ -85,7 +88,7 @@ function faultsOf(share) {
85
88
  return [
86
89
  ...(text ? ['text'] : []),
87
90
  ...(share.bars >= BARS_MIN && share.barShare >= BARS_SHARE_MIN ? ['colour bars'] : []),
88
- ...(!text && share.top >= PLACEHOLDER_MIN ? ['placeholder'] : []),
91
+ ...(!text && share.top >= PLACEHOLDER_MIN && share.ink + share.off <= PLACEHOLDER_DETAIL_MAX ? ['placeholder'] : []),
89
92
  ];
90
93
  }
91
94
  export function faults(file) {