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