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 +8 -4
- package/dist/cli.js +140 -39
- package/dist/contract.js +68 -7
- package/dist/evidence-text.js +5 -2
- package/dist/owner.js +93 -0
- package/dist/store.js +95 -7
- package/fixtures/contract/README.md +1 -1
- package/fixtures/contract/ask.v1/valid-pick-one.json +1 -0
- package/fixtures/contract/ask.v1/valid-yes-no.json +1 -1
- package/fixtures/contract/plan.v3/invalid-filer-missing.json +130 -0
- package/fixtures/contract/plan.v3/invalid-sections-stage-unknown.json +42 -0
- package/fixtures/contract/plan.v3/invalid-understood-missing.json +29 -0
- package/fixtures/contract/plan.v3/valid-full.json +131 -0
- package/fixtures/contract/plan.v3/valid-minimal.json +35 -0
- package/fixtures/contract/report.v4/invalid-filer-missing.json +91 -0
- package/fixtures/contract/report.v4/invalid-sections-stage-unknown.json +46 -0
- package/fixtures/contract/report.v4/invalid-understood-missing.json +40 -0
- package/fixtures/contract/report.v4/valid-full.json +92 -0
- package/fixtures/contract/report.v4/valid-minimal.json +46 -0
- package/fixtures/engagement-v1.json +17 -0
- package/package.json +2 -2
- package/skills/sign-off-axi/SKILL.md +177 -0
- package/web/index.html +190 -34
- package/web/plan-template.json +1 -1
- package/web/point.js +2 -0
- package/web/report-template.json +1 -1
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
|
-
|
|
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[
|
|
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
|
-
|
|
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);
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
2674
|
-
return [200, { media: readFileSync(actual), type, point:
|
|
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;
|