sign-off-axi 0.6.2 → 0.6.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 +24 -2
- package/dist/cli.js +171 -48
- package/dist/contract.js +115 -23
- package/dist/images.js +27 -8
- package/dist/store.js +34 -16
- package/fixtures/contract/README.md +1 -1
- package/fixtures/contract/design.v1/invalid-journeys-missing.json +1 -1
- package/fixtures/contract/design.v1/invalid-preview-escape.json +1 -1
- package/fixtures/contract/design.v1/invalid-unknown-field.json +1 -1
- package/fixtures/contract/design.v1/valid-basic.json +1 -1
- package/fixtures/contract/design.v1/valid-choice.json +1 -1
- package/fixtures/contract/design.v2/design/built-dark.png +0 -0
- package/fixtures/contract/design.v2/design/built.png +0 -0
- package/fixtures/contract/design.v2/design/desk-built.png +0 -0
- package/fixtures/contract/design.v2/design/desk-today.png +0 -0
- package/fixtures/contract/design.v2/design/preview.png +0 -0
- package/fixtures/contract/design.v2/design/target.png +0 -0
- package/fixtures/contract/design.v2/design/today.png +0 -0
- package/fixtures/contract/design.v2/invalid-about-jargon.json +1 -0
- package/fixtures/contract/design.v2/invalid-accepts-missing.json +1 -0
- package/fixtures/contract/design.v2/invalid-area-missing.json +1 -0
- package/fixtures/contract/design.v2/invalid-journeys-over-limit.json +1 -0
- package/fixtures/contract/design.v2/invalid-options-unknown.json +1 -0
- package/fixtures/contract/design.v2/invalid-title-not-question.json +1 -0
- package/fixtures/contract/design.v2/valid-basic.json +1 -0
- package/fixtures/contract/design.v2/valid-three.json +1 -0
- package/package.json +1 -1
- package/skills/sign-off-axi/SKILL.md +9 -4
- package/web/index.html +794 -148
- package/web/manifest.webmanifest +9 -5
- package/web/pwa.js +55 -0
- package/web/splash-1024x1366-dark.png +0 -0
- package/web/splash-1024x1366-light.png +0 -0
- package/web/splash-1024x768-dark.png +0 -0
- package/web/splash-1024x768-light.png +0 -0
- package/web/splash-1080x810-dark.png +0 -0
- package/web/splash-1080x810-light.png +0 -0
- package/web/splash-1180x820-dark.png +0 -0
- package/web/splash-1180x820-light.png +0 -0
- package/web/splash-1194x834-dark.png +0 -0
- package/web/splash-1194x834-light.png +0 -0
- package/web/splash-1366x1024-dark.png +0 -0
- package/web/splash-1366x1024-light.png +0 -0
- package/web/splash-768x1024-dark.png +0 -0
- package/web/splash-768x1024-light.png +0 -0
- package/web/splash-810x1080-dark.png +0 -0
- package/web/splash-810x1080-light.png +0 -0
- package/web/splash-820x1180-dark.png +0 -0
- package/web/splash-820x1180-light.png +0 -0
- package/web/splash-834x1194-dark.png +0 -0
- package/web/splash-834x1194-light.png +0 -0
- package/web/sw.js +57 -0
- package/web/ui.css +23 -1
package/README.md
CHANGED
|
@@ -45,6 +45,8 @@ You need Node 22.5 or newer. `init` installs the skill for each coding agent it
|
|
|
45
45
|
|
|
46
46
|
Four places, in the bar at the bottom of a phone and the sidebar on a desk.
|
|
47
47
|
|
|
48
|
+
The board installs as an app (More, then Install app; on an iPad, Share, then Add to Home Screen) and, once opened, reads offline: with the board unreachable it shows the last update under "Offline, showing the last update", and sends fail rather than queue. A phone needs the board on HTTPS or localhost to install it.
|
|
49
|
+
|
|
48
50
|
<table>
|
|
49
51
|
<tr>
|
|
50
52
|
<td align="center" width="25%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-today-dark.webp"><img src="docs/media/phone-today.webp" alt="Today: counts of what needs you, what was proven and new reports, then the waiting asks"></picture><br><b>Today</b><br>What needs you, what was proven, what is new.</td>
|
|
@@ -103,6 +105,26 @@ Agents pass `--priority` when they know it, and set their project's icon when it
|
|
|
103
105
|
|
|
104
106
|
Every card's screen also keeps its **Activity**, oldest first, from what the board already saved: who posted it, your answer and note (and the one a change replaced), when the agent picked it up, a new round you asked for and your comments. A ticket's page shows its cards' activity together.
|
|
105
107
|
|
|
108
|
+
### The leash: what agents may do without you
|
|
109
|
+
|
|
110
|
+
Every ask says what happens if you stay quiet (`--default`) and when (`--silent-by`). Your leash decides whether it happens. Set it in Today's More, or tap the **Leash** chip by the count on Decide. It is saved as `leash:` in the `config.yaml` beside the board.
|
|
111
|
+
|
|
112
|
+
- **Ask me first** (`ask`): nothing happens; agents wait for you on every ask.
|
|
113
|
+
- **When it matters** (`matters`, the default): when an ask's `--silent-by` time passes with no answer, the open board takes its default as the answer. Its card says **Taken by default at 13:00**, and the waiting agent gets it at once. A default that fell due while the board was closed is taken when it starts. An ask with no `--silent-by` waits.
|
|
114
|
+
- **Act, then tell me** (`act`): the agent's recommended pick is taken the moment it raises the ask. Its card says **Agent picked B: Friday, tell me if wrong**.
|
|
115
|
+
|
|
116
|
+
Both kinds wait under **Taken without you** in Decide until you open them, each with a one-tap **Change** on a phone. You can still answer or change them. The change reaches the agent as an answer that replaces the taken one, and the card's trail and Activity say what happened: Taken or Picked, then Delivered and Read.
|
|
117
|
+
|
|
118
|
+
Some asks are never answered for you, whatever the leash: an ask raised with `--guard destructive|irreversible|money|credential|security`, and an ask whose words talk of spending money, deleting, what can't be undone, passwords and keys, or access. Designs, plans and sign-ins always wait too. Such an ask says **Always waits for you** and why.
|
|
119
|
+
|
|
120
|
+
<table>
|
|
121
|
+
<tr>
|
|
122
|
+
<td align="center" width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-leash-decide-dark.webp"><img src="docs/media/phone-leash-decide.webp" alt="Decide: the asks that wait, then Taken without you with a default taken and an agent's pick, each with Change, and the Leash chip by the count"></picture><br><b>Taken without you</b><br>What the leash answered, with Change.</td>
|
|
123
|
+
<td align="center" width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-leash-agent-dark.webp"><img src="docs/media/phone-leash-agent.webp" alt="An ask the agent picked: Agent picked B: Friday, tell me if wrong, its reason, its trail from Picked to Read, and Change"></picture><br><b>Agent picked</b><br>On Act, then tell me: its pick, and Change.</td>
|
|
124
|
+
<td align="center" width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-leash-guard-dark.webp"><img src="docs/media/phone-leash-guard.webp" alt="An ask that spends money: Always waits for you, so no default or agent pick is ever taken"></picture><br><b>Always waits for you</b><br>Money, deletes, keys and access.</td>
|
|
125
|
+
</tr>
|
|
126
|
+
</table>
|
|
127
|
+
|
|
106
128
|
## What you can ask
|
|
107
129
|
|
|
108
130
|
One line on what the agent runs per kind; the full reference is [docs/question-kinds.md](docs/question-kinds.md) and the message rules are in [docs/contract.md](docs/contract.md).
|
|
@@ -111,11 +133,11 @@ One line on what the agent runs per kind; the full reference is [docs/question-k
|
|
|
111
133
|
|---|---|---|
|
|
112
134
|
| Yes or no, pick one | `raise --ask-ref <captain-message-id-or-hold-id> --title ... --recommend ...` | buttons, the recommended one first |
|
|
113
135
|
| 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, and Open full screen: the prototype over the whole screen with its own navigation, Phone or Desktop width and another option as a reference |
|
|
114
|
-
| Design review (
|
|
136
|
+
| Design review (one plain question) | `post --kind design --from design.json` | one question answered with **Yes** (a note optional) or **Change** (a note needed): each screen's frames side by side (Before and After, or Today, New and Target design), the changed area outlined and named on each and the frame Yes approves marked; post refuses an internal title and frames that look the same. Nothing in Decide: it waits under **Designs** for Main to review, and Main's `promote <key>` puts it in Decide |
|
|
115
137
|
| Proof update | `post --type evidence --media shot.png` | the capture, its result, Pass / Fail / Needs work |
|
|
116
138
|
| 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) |
|
|
117
139
|
| Explainer video | `post --type explainer --media clip.mp4 --chapter 0:00=Start` | a player with chapter buttons, then Yes or No |
|
|
118
|
-
| 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
|
|
140
|
+
| 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 under their part, one bar (Agree, Disagree, Comment, Ask) that acts on the part in view, and one Activity of every note, ask, reply and read |
|
|
119
141
|
| Diagram, interactive page | `post --type diagram --view flow.svg` | the picture on the card; interactive pages run sandboxed, and open full screen at phone or desktop width |
|
|
120
142
|
| 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 |
|
|
121
143
|
| Sign-in link | `post --type signin --service npm --link <url> --unlocks ...` | an Open button and the time left |
|
package/dist/cli.js
CHANGED
|
@@ -14,10 +14,10 @@ import { parseArgs } from 'node:util';
|
|
|
14
14
|
import { encode } from '@toon-format/toon';
|
|
15
15
|
import { acknowledgeOwner, checkOwner, deliveries, dispatchOwners, registerOwner } from './owner.js';
|
|
16
16
|
import { AxiError, runAxiCli } from 'axi-sdk-js';
|
|
17
|
-
import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendPartComment, annotationsPath, markCommentsRead, readPartComments, replyComment, asks, appendAnswer, appendDismissed, DISCARD_REASONS, readDiscards, UNDO_MS, appendPromoted, appendSendBack, appendTicket, readTickets, rollUp, appendTicketComment, ticketCommentFault, ticketFault, appendProject, readProjects, projectFault, iconPathFault, iconContentFault, ICON_BYTES, ICON_TYPES, PRIORITIES, REPORT_PARTS, REPORT_PURPOSES, appendDesignAnswer, answerLines, appendMark, readTrail, trailPath, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, appendOpened, readOpened, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, reading, readDismissed, readItems, readPromoted, sameDecision, readMessages, readRounds, roundsPath, sameRound, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
17
|
+
import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, guardOf, plainAsk, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendPartComment, annotationsPath, markCommentsRead, readPartComments, replyComment, asks, appendAnswer, appendDismissed, DISCARD_REASONS, readDiscards, UNDO_MS, appendPromoted, appendSendBack, appendTicket, readTickets, rollUp, appendTicketComment, ticketCommentFault, ticketFault, appendProject, readProjects, projectFault, iconPathFault, iconContentFault, ICON_BYTES, ICON_TYPES, PRIORITIES, REPORT_PARTS, REPORT_PURPOSES, appendDesignAnswer, answerLines, appendMark, readTrail, trailPath, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, appendOpened, readOpened, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, screenViews, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, reading, readDismissed, readItems, readPromoted, sameDecision, readMessages, readRounds, roundsPath, sameRound, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
18
18
|
import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, diagramPages, diagramViews, guided, mediaFiles, parseMessage, reportPage } from './contract.js';
|
|
19
19
|
import { faults, isStill } from './evidence-text.js';
|
|
20
|
-
import { copiesOf } from './images.js';
|
|
20
|
+
import { copiesOf, differs } from './images.js';
|
|
21
21
|
import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
|
|
22
22
|
/** The version this copy runs, read from package.json once: a pre-release version says so, and a git checkout whose HEAD carries no tag
|
|
23
23
|
* for that version is a dev build ahead of its release, `<version>-dev`. An installed copy has no .git, so it shows its version as published.
|
|
@@ -84,7 +84,7 @@ examples:
|
|
|
84
84
|
sign-off-axi
|
|
85
85
|
sign-off-axi list ./decisions.json --full
|
|
86
86
|
`,
|
|
87
|
-
raise: `usage: sign-off-axi raise [board.json] --title "<question>" [--key <key>] [--about <text>] [--detail <text>] --why <text> [--option <value>=<label>]... --outcome <value>=<text>... --recommend <value> --recommend-reason <text> --default <value> [--silent-by <ISO time>] [--merge <key>]... [--proof <key>] [--close done|release] [--filer <id>] [--ticket <id>] [--priority P0-P3]
|
|
87
|
+
raise: `usage: sign-off-axi raise [board.json] --title "<question>" [--key <key>] [--about <text>] [--detail <text>] --why <text> [--option <value>=<label>]... --outcome <value>=<text>... --recommend <value> --recommend-reason <text> --default <value> [--silent-by <ISO time>] [--guard <why>] [--merge <key>]... [--proof <key>] [--close done|release] [--filer <id>] [--ticket <id>] [--priority P0-P3]
|
|
88
88
|
sign-off-axi raise [board.json] --from <decisions.json> [--filer <id>] [--ticket <id>] [--priority P0-P3]
|
|
89
89
|
Adds questions to the board: yes/no, or 2 to 6 choices with one --option each. --key defaults to a slug of the title.
|
|
90
90
|
raise is the alias for post --type decision: same flags, same output.
|
|
@@ -96,7 +96,13 @@ Every question meets the question standard (docs/contract.md, "Asking a good que
|
|
|
96
96
|
--why says what it is about and why it needs an answer now (200 characters or fewer).
|
|
97
97
|
--outcome <value>=<text> says what happens next if that answer is picked (120 or fewer), one for every option: yes and no without --option.
|
|
98
98
|
--recommend names the answer you would pick, --recommend-reason why in one line (160 or fewer).
|
|
99
|
-
--default is what happens if the user stays quiet
|
|
99
|
+
--default is what happens if the user stays quiet; --silent-by (an ISO UTC time such as 2026-10-18T09:00Z) says when. The leash (leash: in config.yaml, the board's Leash control) says what is applied:
|
|
100
|
+
ask (Ask me first): nothing; every ask waits for the user.
|
|
101
|
+
matters (When it matters, the default): at --silent-by with no answer, the open board takes --default as the answer, saved with taken: default; an ask with no --silent-by waits.
|
|
102
|
+
act (Act, then tell me): raise takes your --recommend at once, saved with taken: agent; go ahead with it. The user sees it on the card and can change it.
|
|
103
|
+
Either way wait and answers hand it over like an answer, with its taken; the user can still answer or change it, and a change comes back with replaces.
|
|
104
|
+
--guard destructive|irreversible|money|credential|security marks an ask only the user may answer: no default or pick is ever taken for it, whatever the leash.
|
|
105
|
+
An ask whose title, why or outcomes talk of deleting, money, passwords, keys, access or what can't be undone is guarded the same way without it; designs, plans, sign-ins, gates and findings always wait.
|
|
100
106
|
The buttons match the text: a title or why that names choices A, B, C needs one --option per letter, each label starting with its letter, never Yes/No.
|
|
101
107
|
yes and no are labelled Yes and No; a question never takes Done, OK or Not now buttons.
|
|
102
108
|
One decision is one card: look at the open cards first. --merge <key> (up to 10) names other cards on the board that ask the same thing and offer every option of this one:
|
|
@@ -117,7 +123,7 @@ examples:
|
|
|
117
123
|
`,
|
|
118
124
|
validate: `usage: sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid]
|
|
119
125
|
Checks message files against the communication contract without posting them: ${CONTRACT}
|
|
120
|
-
A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, evidence, gate
|
|
126
|
+
A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, evidence, gate and finding at schema 1, design at schema 2 (schema 1 still passes), report at schema 5 (schemas 2 to 4 still pass) and plan at schema 3 (schemas 1 and 2 still pass).
|
|
121
127
|
A report 3 to 5 and a plan 2 or 3 are guided pages: fields the board draws as one scroll, no page of your own (see report init). A report 5 names what it is for in "purpose".
|
|
122
128
|
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.
|
|
123
129
|
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).
|
|
@@ -273,7 +279,10 @@ examples:
|
|
|
273
279
|
sign-off-axi post --kind finding --from finding.json --filer learn-from-mistakes
|
|
274
280
|
sign-off-axi post --kind plan --from plan.json --filer agent-41
|
|
275
281
|
sign-off-axi post --kind design --from design.json --filer agent-41
|
|
276
|
-
A design
|
|
282
|
+
A design (schema 2) asks one plain question Yes answers: its title, 12 words or fewer, ending in "?". Each screen shows 1-3 frames side by side (today, built or proposed, target),
|
|
283
|
+
each with the changed area as fractions of its picture and a one-line caption, and "accepts" names the frame Yes approves. Post refuses a new design without a plain question title,
|
|
284
|
+
words for agents in what the person reads (task or branch names, PR or #number, slice, agent or ticket codes, file names, commits), and two frames that look the same;
|
|
285
|
+
it checks every asset stays under the board folder. See docs/contract.md (design, schema 2) and fixtures/contract/design.v2.
|
|
277
286
|
A design shows under Designs for Main to review, never in the user's Decide or any count of asks, until Main runs promote (see promote --help).
|
|
278
287
|
sign-off-axi post .sign-off/decisions.json --type interactive --title "Does the sign-in flow feel right?" --view mocks/sign-in/demo.html --option yes=Yes --option no="Needs work" --recommend yes
|
|
279
288
|
sign-off-axi post --type evidence --title "Sign-in screen on a phone" --about "The new screen loads in 1 second." --media phone.png --media flow.mp4 --link https://github.com/acme/app/pull/12
|
|
@@ -352,18 +361,19 @@ examples:
|
|
|
352
361
|
`,
|
|
353
362
|
promote: `usage: sign-off-axi promote <key> [board.json] [--undo]
|
|
354
363
|
For Main only, never a lead. A design review a lead posts (post --kind design) shows under Designs, where Main reviews it; it never waits in Decide or counts as the user's ask.
|
|
355
|
-
Once Main has passed
|
|
364
|
+
Once Main has passed a design, Main promotes that one card (a design 2 asks the user one plain question, with Yes or Change):
|
|
356
365
|
it then waits in Decide, counts there and notifies like any ask until the user answers it. --undo puts it back under Designs only.
|
|
357
366
|
It writes one line to <board>.promoted.jsonl beside the board; no post, feed or board request writes that file.
|
|
358
367
|
examples:
|
|
359
368
|
sign-off-axi promote fleet-dashboard-prototype
|
|
360
369
|
sign-off-axi promote fleet-dashboard-prototype --undo
|
|
361
370
|
`,
|
|
362
|
-
feedback: `usage: sign-off-axi feedback <key> [board.json] (--read | --reply <text> [--link <url>] | --acted --link <url>) [--filer <id>]
|
|
371
|
+
feedback: `usage: sign-off-axi feedback <key> [board.json] (--delivered | --read | --reply <text> [--link <url>] | --acted --link <url>) [--at <epoch seconds>] [--filer <id>]
|
|
363
372
|
Tells the person how far their answer, notes, new round or comment on a card got: the board shows each one as Sent, Delivered, Read and then Replied, on the card, in its Activity,
|
|
364
373
|
as a badge in the lists, and a reply in Today. A wait hands an answer over and marks it (read when it is the filer's own wait, else delivered), and an on_answer hook that ran marks it delivered.
|
|
365
374
|
--read: the agent has read what was sent. --reply: what the agent did or says back, 500 characters or fewer, with --link to the new version or the pull request.
|
|
366
|
-
--acted --link: the agent acted, and the link shows what changed (the merge hook uses this).
|
|
375
|
+
--delivered: it was handed to the agent. --acted --link: the agent acted, and the link shows what changed (the merge hook uses this).
|
|
376
|
+
--at <epoch seconds>: the step happened then (replaying history). It cannot come before the send or lie in the future, a step the card already shows is left alone (nothing is written, and a reply never replaces one), so a replay never turns a later real state back into an older one and can be run twice. Posting the card again marks what was sent before it as acted on too.
|
|
367
377
|
A mark covers everything sent on that card up to now. --filer refuses a card that filer did not post. It writes one line to <board>.trail.jsonl beside the board.
|
|
368
378
|
examples:
|
|
369
379
|
sign-off-axi feedback fleet-dashboard-full --read
|
|
@@ -492,7 +502,7 @@ function fits(title, key) {
|
|
|
492
502
|
throw fail(`${key ? `key ${key}: ` : ''}title is ${n} characters, over ${TITLE_MAX}`, 'VALIDATION_ERROR', `Shorten the title to ${TITLE_MAX} characters and put the rest in about`);
|
|
493
503
|
}
|
|
494
504
|
/** The question standard's flags: a decision's why, each option's outcome, the reason for the recommendation, when silence decides, the cards it merges. */
|
|
495
|
-
const ASK_FLAGS = ['why', 'outcome', 'recommend-reason', 'silent-by', 'merge', 'proof', 'report'];
|
|
505
|
+
const ASK_FLAGS = ['why', 'outcome', 'recommend-reason', 'silent-by', 'guard', 'merge', 'proof', 'report'];
|
|
496
506
|
const MANY = ['option', 'outcome', 'merge'];
|
|
497
507
|
/** Board types that are contract kinds: a decision (the default type) is an ask. */
|
|
498
508
|
const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report', 'plan', 'design'];
|
|
@@ -553,7 +563,7 @@ function contractItems(items, from, file, replace = false) {
|
|
|
553
563
|
});
|
|
554
564
|
}
|
|
555
565
|
function designFiles(m, base) {
|
|
556
|
-
const paths = [m.preview, ...m.journeys.flatMap((j) => j.screens.flatMap(
|
|
566
|
+
const paths = [m.preview, ...m.journeys.flatMap((j) => j.screens.flatMap(screenViews))];
|
|
557
567
|
for (const path of paths) {
|
|
558
568
|
let actual;
|
|
559
569
|
try {
|
|
@@ -575,6 +585,19 @@ function designFiles(m, base) {
|
|
|
575
585
|
throw new ContractError(`design path ${JSON.stringify(path)} cannot be read`, 'Make the design view readable');
|
|
576
586
|
}
|
|
577
587
|
}
|
|
588
|
+
// Two frames of a screen that look the same compare nothing: the person would look for a change that is not there. Under 0.1% of pixels moved is the same look.
|
|
589
|
+
for (const j of m.journeys)
|
|
590
|
+
for (const s of j.screens)
|
|
591
|
+
(s.frames ?? []).forEach((f, i, frames) => {
|
|
592
|
+
for (const g of frames.slice(0, i))
|
|
593
|
+
for (const [v, p] of Object.entries(f.views)) {
|
|
594
|
+
if (g.views[v] === undefined)
|
|
595
|
+
continue;
|
|
596
|
+
const share = differs(readFileSync(resolve(base, g.views[v])), extname(g.views[v]), readFileSync(resolve(base, p)), extname(p));
|
|
597
|
+
if (share !== undefined && share < 0.001)
|
|
598
|
+
throw new ContractError(`screen ${s.id}: the ${g.role} and ${f.role} frames look the same (${v}: ${+(share * 100).toPrecision(2)}% of pixels differ)`, `Drop the ${g.role === 'today' ? 'today' : f.role} frame, or capture the screen in the state that shows the change`);
|
|
599
|
+
}
|
|
600
|
+
});
|
|
578
601
|
}
|
|
579
602
|
const RAISE_FIELDS = ['title', 'key', 'about', 'detail', 'option', 'recommend', 'default', 'close', ...ASK_FLAGS];
|
|
580
603
|
/** Parses one command's flags strictly; `on` carries a given board file into the next-step hints. */
|
|
@@ -723,7 +746,7 @@ function printAnswers(file, rows, waiting, values, help) {
|
|
|
723
746
|
const gone = rows.flatMap((d) => (d.discarded ? [{ key: d.key, reason: d.discarded.reason, label: DISCARD_REASONS[d.discarded.reason], at: d.discarded.at, title: d.title }] : []));
|
|
724
747
|
if (values.json) {
|
|
725
748
|
const asked = (key) => rounds.filter((r) => r.key === key).map(({ note, at, comments }) => ({ note, at, comments }));
|
|
726
|
-
const out = Object.fromEntries(done.map(({ key, answer, label, close, at, note, comments, notes, decisions, pick, replaces }) => [key, { answer, label, close, at, note, comments, notes, decisions, pick, replaces, ...(asked(key).length && { rounds: asked(key) }) }]));
|
|
749
|
+
const out = Object.fromEntries(done.map(({ key, answer, label, close, at, note, comments, notes, decisions, pick, replaces, taken, reason }) => [key, { answer, label, close, at, note, comments, notes, decisions, pick, replaces, taken, reason, ...(asked(key).length && { rounds: asked(key) }) }]));
|
|
727
750
|
for (const r of rounds)
|
|
728
751
|
if (!out[r.key])
|
|
729
752
|
out[r.key] = { rounds: asked(r.key) };
|
|
@@ -731,7 +754,7 @@ function printAnswers(file, rows, waiting, values, help) {
|
|
|
731
754
|
out[key] = { ...out[key], discarded: { reason, label, at } };
|
|
732
755
|
return JSON.stringify(out);
|
|
733
756
|
}
|
|
734
|
-
const notes = done.some((a) => a.note), changed = done.some((a) => a.replaces), sent = [...done, ...rounds].flatMap(({ key, comments = [] }) => comments.map(({ mock, ref, tag, words, text }) => ({ key, mock, ref, tag, words, text })));
|
|
757
|
+
const notes = done.some((a) => a.note), changed = done.some((a) => a.replaces), took = done.some((a) => a.taken), sent = [...done, ...rounds].flatMap(({ key, comments = [] }) => comments.map(({ mock, ref, tag, words, text }) => ({ key, mock, ref, tag, words, text })));
|
|
735
758
|
const plan = done.flatMap(({ key, notes: sections = [], decisions: picks = {} }) => [
|
|
736
759
|
...sections.map((n) => ({ key, section: n.section ?? `${n.journey}/${n.screen}`, verdict: n.verdict ?? 'change', note: n.note ?? '' })),
|
|
737
760
|
...Object.entries(picks).map(([decision, pick]) => ({ key, decision, pick })),
|
|
@@ -739,7 +762,8 @@ function printAnswers(file, rows, waiting, values, help) {
|
|
|
739
762
|
return toon({
|
|
740
763
|
board: tilde(file),
|
|
741
764
|
// A changed answer names the one it replaces, which the agent may already have acted on.
|
|
742
|
-
|
|
765
|
+
// An answer the leash took says how: default (the board took the ask's default at its silent_by) or agent (your recommended pick, on Act, then tell me).
|
|
766
|
+
answers: done.length ? done.map(({ key, answer, at, label, close, title, note = '', replaces, taken = '' }) => ({ key, answer, at, ...(values.full && { label, close, title }), ...(notes && { note }), ...(changed && { replaces: replaces?.answer ?? '' }), ...(took && { taken }) })) : '0 saved yet',
|
|
743
767
|
...(rounds.length && { rounds: rounds.map(({ key, note, at }) => ({ key, note, at })) }),
|
|
744
768
|
// Comments on mock elements that went with an answer or a round, each with the element it points at.
|
|
745
769
|
...(sent.length && { comments: sent }),
|
|
@@ -747,7 +771,7 @@ function printAnswers(file, rows, waiting, values, help) {
|
|
|
747
771
|
...(plan.length && { plan }),
|
|
748
772
|
...(gone.length && { discarded: gone.map(({ key, label, at }) => ({ key, reason: label, at })) }),
|
|
749
773
|
waiting,
|
|
750
|
-
}, help);
|
|
774
|
+
}, [...(took ? ['taken: default is the default the board took at the ask\'s silent_by, agent your own recommended pick (the leash Act, then tell me): go ahead, and keep waiting, as the person can still change it'] : []), ...help]);
|
|
751
775
|
}
|
|
752
776
|
function answers(args) {
|
|
753
777
|
const { file, values, on } = parse('answers', args, { ...TIERS, key: { type: 'string' } });
|
|
@@ -770,7 +794,7 @@ function eventLines(file, rows, answers, rounds) {
|
|
|
770
794
|
const kind = kindOf(file), item = new Map(rows.map((d) => [d.key, d]));
|
|
771
795
|
const head = (event, key) => ({ event, ...kind(key), key, title: item.get(key)?.title });
|
|
772
796
|
const tail = (key, comments) => ({ ...(item.get(key)?.filer && { filer: item.get(key).filer }), ...(comments?.length && { comments }) });
|
|
773
|
-
return [...answers.map(({ key, answer, label, close, at, note, comments, notes, decisions, pick, replaces }) => ({ at, line: { ...head('answer', key), answer, label, close, at, ...(note && { note }), ...(notes && { notes, decisions }), ...(pick !== undefined && { pick }), ...(replaces && { replaces }), ...tail(key, comments) } })),
|
|
797
|
+
return [...answers.map(({ key, answer, label, close, at, note, comments, notes, decisions, pick, replaces, taken, reason }) => ({ at, line: { ...head('answer', key), answer, label, close, at, ...(note && { note }), ...(notes && { notes, decisions }), ...(pick !== undefined && { pick }), ...(replaces && { replaces }), ...(taken && { taken, reason }), ...tail(key, comments) } })),
|
|
774
798
|
...rounds.map(({ key, note, at, comments }) => ({ at, line: { ...head('round', key), note, at, ...tail(key, comments) } }))]
|
|
775
799
|
.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)).map(({ line }) => JSON.stringify(line)).join('\n');
|
|
776
800
|
}
|
|
@@ -1355,18 +1379,18 @@ function promote(args) {
|
|
|
1355
1379
|
}
|
|
1356
1380
|
/** feedback <key> [board.json] (--read | --reply <text> [--link <url>] | --acted --link <url>): the agent tells the person how far what they sent on a card got. */
|
|
1357
1381
|
function feedback(args) {
|
|
1358
|
-
const at = args.findIndex((a, i) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json') && !['--reply', '--link', '--filer'].includes(args[i - 1])), key = args[at];
|
|
1382
|
+
const at = args.findIndex((a, i) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json') && !['--reply', '--link', '--filer', '--at'].includes(args[i - 1])), key = args[at];
|
|
1359
1383
|
if (at < 0 || !isKey(key))
|
|
1360
1384
|
throw fail('feedback needs the key of a card', 'VALIDATION_ERROR', 'sign-off-axi feedback <key> [board.json] --read');
|
|
1361
|
-
const { file, values, on } = parse('feedback', args.filter((_, i) => i !== at), { read: { type: 'boolean' }, reply: { type: 'string' }, acted: { type: 'boolean' }, link: { type: 'string' }, filer: { type: 'string' } });
|
|
1362
|
-
const ops = [values.read && 'read', values.reply !== undefined && 'reply', values.acted && 'acted'].filter(Boolean);
|
|
1385
|
+
const { file, values, on } = parse('feedback', args.filter((_, i) => i !== at), { read: { type: 'boolean' }, delivered: { type: 'boolean' }, reply: { type: 'string' }, acted: { type: 'boolean' }, link: { type: 'string' }, filer: { type: 'string' }, at: { type: 'string' } });
|
|
1386
|
+
const ops = [values.read && 'read', values.delivered && 'delivered', values.reply !== undefined && 'reply', values.acted && 'acted'].filter(Boolean);
|
|
1363
1387
|
if (ops.length !== 1)
|
|
1364
|
-
throw fail('feedback takes one of --read, --reply <text> or --acted --link <url>', 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --read`);
|
|
1388
|
+
throw fail('feedback takes one of --delivered, --read, --reply <text> or --acted --link <url>', 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --read`);
|
|
1365
1389
|
const text = typeof values.reply === 'string' ? values.reply.trim() : undefined, link = values.link;
|
|
1366
1390
|
if (text !== undefined && (!text || [...text].length > NOTE_MAX))
|
|
1367
1391
|
throw fail(`--reply must be text of ${NOTE_MAX} characters or fewer`, 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --reply "<what you did>"`);
|
|
1368
|
-
if (link !== undefined && (values.read || !/^https?:\/\/[^\s]{1,500}$/.test(link)))
|
|
1369
|
-
throw fail(values.read ? '--link goes with --reply or --acted' : '--link must be an http(s) address, such as the pull request', 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --acted --link <url>`);
|
|
1392
|
+
if (link !== undefined && (values.read || values.delivered || !/^https?:\/\/[^\s]{1,500}$/.test(link)))
|
|
1393
|
+
throw fail(values.read || values.delivered ? '--link goes with --reply or --acted' : '--link must be an http(s) address, such as the pull request', 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --acted --link <url>`);
|
|
1370
1394
|
if (values.acted && !link)
|
|
1371
1395
|
throw fail('--acted needs --link <url> to what changed', 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --acted --link <url>`);
|
|
1372
1396
|
ready(file, on);
|
|
@@ -1378,9 +1402,22 @@ function feedback(args) {
|
|
|
1378
1402
|
const sent = readTrail(file, loadDecisions(file)).filter((t) => t.key === key && t.kind !== 'ticket');
|
|
1379
1403
|
if (!sent.length)
|
|
1380
1404
|
throw fail(`nothing was sent on ${key} yet`, 'NOT_FOUND', `Run \`sign-off-axi answers${on} --key ${key}\` to see its answer`);
|
|
1381
|
-
|
|
1405
|
+
// --at <epoch seconds> records a step that happened earlier (replaying history). It cannot precede the send, a step the card already shows is left as it is, and it
|
|
1406
|
+
// never writes a reply over one that exists, so a replay never turns a later real state back into an older one; run twice it writes once.
|
|
1407
|
+
const op = values.read ? 'read' : values.delivered ? 'delivered' : 'reply';
|
|
1408
|
+
if (values.at !== undefined) {
|
|
1409
|
+
const secs = /^\d{9,11}$/.test(values.at) ? +values.at : NaN, first = Math.min(...sent.map((t) => Math.floor(Date.parse(t.at) / 1000)));
|
|
1410
|
+
if (!Number.isFinite(secs) || secs > Math.floor(Date.now() / 1000))
|
|
1411
|
+
throw fail('--at must be whole seconds since 1970, not in the future', 'VALIDATION_ERROR', `sign-off-axi feedback ${key}${on} --read --at 1792800000`);
|
|
1412
|
+
if (secs < first)
|
|
1413
|
+
throw fail(`--at ${values.at} is before what was sent on ${key} (${first}): a step cannot come before its send`, 'VALIDATION_ERROR', 'Use the time the step happened');
|
|
1414
|
+
const have = sent.some((t) => op === 'reply' ? t.reply : (t[op] ?? t.reply?.at) !== undefined);
|
|
1415
|
+
if (have)
|
|
1416
|
+
return toon({ board: tilde(file), key, shows: sent.at(-1).reply ? 'Replied' : sent.at(-1).read ? 'Read' : 'Delivered', covers: sent.length, unchanged: 'yes' }, [`${key} already shows that step; nothing was written`]);
|
|
1417
|
+
}
|
|
1418
|
+
appendMark(file, { op, key, ...(text && { text }), ...(link && { link }) }, values.at !== undefined ? new Date(+values.at * 1000).toISOString().replace(/\.\d+Z$/, 'Z') : undefined);
|
|
1382
1419
|
const now = readTrail(file, loadDecisions(file)).filter((t) => t.key === key && t.kind !== 'ticket').at(-1);
|
|
1383
|
-
return toon({ board: tilde(file), key, shows: now.reply ? 'Replied' : 'Read', covers: sent.length }, [values.read ? `Run \`sign-off-axi feedback ${key}${on} --reply "<what you did>"\` once you act on it` : `Run \`sign-off-axi wait${on}${filer ? ` --filer ${filer}` : ''}\` for the next answer`]);
|
|
1420
|
+
return toon({ board: tilde(file), key, shows: now.reply ? 'Replied' : now.read ? 'Read' : 'Delivered', covers: sent.length }, [values.read || values.delivered ? `Run \`sign-off-axi feedback ${key}${on} --reply "<what you did>"\` once you act on it` : `Run \`sign-off-axi wait${on}${filer ? ` --filer ${filer}` : ''}\` for the next answer`]);
|
|
1384
1421
|
}
|
|
1385
1422
|
/** ticket [board.json] [--id <id> [--title <text>] [--progress <0-100>] [--parent <id>] [--priority P0-P3] [--project <name>] [--hold yes|no] [--asked yes|no]]: lists the tickets,
|
|
1386
1423
|
* shows one with the cards that name it, or writes one. A ticket stands alone in <board>.tickets.jsonl: cards name it with --ticket, and nothing else reads it.
|
|
@@ -1756,7 +1793,7 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1756
1793
|
writeFileSync(join(dir, `${i + 1}${extname(src).toLowerCase()}`), bytes, { mode: 0o600 });
|
|
1757
1794
|
// The original stays as it was posted; the board shows a lighter copy of a still where one weighs less.
|
|
1758
1795
|
const made = isStill(src) ? copiesOf(bytes, extname(src)) : {}, refs = {};
|
|
1759
|
-
for (const size of ['light', 'thumb']) {
|
|
1796
|
+
for (const size of ['light', 'thumb', 'tile']) {
|
|
1760
1797
|
const c = made[size];
|
|
1761
1798
|
if (!c)
|
|
1762
1799
|
continue;
|
|
@@ -1849,8 +1886,9 @@ async function postKind(file, named, on, values, built) {
|
|
|
1849
1886
|
throw new ContractError(`${f} ${typeof m[f] === 'string' ? m[f] : JSON.stringify(m[f])} is not --${f} ${values[f]}`, `Leave "${f}" out of the message, or pass the same --${f}`);
|
|
1850
1887
|
m[f] = values[f];
|
|
1851
1888
|
}
|
|
1852
|
-
|
|
1853
|
-
|
|
1889
|
+
// A new ask is held to the question standard, and so is a new design's title; a card already on the board still re-posts as it was.
|
|
1890
|
+
const existing = !built && ['ask', 'design'].includes(kind) && existsSync(file) && readItems(file).items.some((i) => i?.key === m.key);
|
|
1891
|
+
checkMessage(m, built ? 'report' : kind, !built && kind === 'ask' && !existing, !built && kind === 'design' && !existing);
|
|
1854
1892
|
// Media paths read from the message file's folder, or from the current folder for stdin.
|
|
1855
1893
|
if (m.kind === 'evidence')
|
|
1856
1894
|
sources = mediaFiles(m, from === '-' ? process.cwd() : dirname(resolve(from)));
|
|
@@ -1984,7 +2022,7 @@ function buildIncoming(values, extra, filer, file) {
|
|
|
1984
2022
|
try {
|
|
1985
2023
|
const { title, key = slug(title), about, detail, why, recommend: recommend_value, default: default_if_silent, close } = values;
|
|
1986
2024
|
const ask = !Object.hasOwn(extra, 'type'), options = ask ? outcomesOf(values) : optionsOf(values);
|
|
1987
|
-
const told = ask && { why, recommend_reason: values['recommend-reason'], silent_by: values['silent-by'], merges: values.merge, proof: values.proof, report: values.report, ask_ref: values['ask-ref'] };
|
|
2025
|
+
const told = ask && { why, recommend_reason: values['recommend-reason'], silent_by: values['silent-by'], guard: values.guard, merges: values.merge, proof: values.proof, report: values.report, ask_ref: values['ask-ref'] };
|
|
1988
2026
|
writeFileSync(source, JSON.stringify({ schema: 2, items: [{ key, title, about, detail, ...told, options, recommend_value, default_if_silent, close, ticket: values.ticket, priority: values.priority, ...extra, filer }] }));
|
|
1989
2027
|
const incoming = loadDecisions(source, board), raw = readItems(source).items;
|
|
1990
2028
|
if (!ask)
|
|
@@ -2095,18 +2133,27 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
|
|
|
2095
2133
|
}
|
|
2096
2134
|
return { key: d.key, status: !had ? 'raised' : had.answer && !awaitsVerdict(had.type, had.answer) ? `already answered ${had.answer.answer}` : waits({ ...had, dismissed: false }) ? 'already waiting' : 'already posted', title: d.title };
|
|
2097
2135
|
});
|
|
2136
|
+
let picked = [];
|
|
2098
2137
|
if (raised.some((r) => r.status === 'raised')) {
|
|
2099
2138
|
const before = waitingKeys(file);
|
|
2100
2139
|
writeItems(file, items);
|
|
2140
|
+
// On the leash Act, then tell me, a new ask takes its agent's recommended pick at once: the person sees it and can still change it.
|
|
2141
|
+
if (leashOf(named) === 'act')
|
|
2142
|
+
picked = leashTake(file, 'act', raised.flatMap((r) => (r.status === 'raised' ? [r.key] : []))).took.map(({ a }) => a);
|
|
2143
|
+
for (const a of picked)
|
|
2144
|
+
raised.find((r) => r.key === a.key).status = `picked ${a.answer}`;
|
|
2101
2145
|
notifyEntered(file, named, before);
|
|
2102
2146
|
}
|
|
2103
2147
|
const key = raised.length === 1 ? ` --key ${raised[0].key}` : '';
|
|
2148
|
+
const goAhead = picked.map((a) => `The leash is Act, then tell me: go ahead with ${a.label} on ${a.key}; the person can still change it`);
|
|
2104
2149
|
// Reports take no answer, so a post with nothing waiting asks the user to read it, never to answer.
|
|
2105
|
-
const anyWaiting = incoming.some((d, i) => waits(d) && !raised[i].status.startsWith('already answered'));
|
|
2150
|
+
const anyWaiting = incoming.some((d, i) => waits(d) && !raised[i].status.startsWith('already answered') && !raised[i].status.startsWith('picked'));
|
|
2151
|
+
if (picked.length && !anyWaiting)
|
|
2152
|
+
return toon({ board: tilde(file), raised }, [...goAhead, ...(filer ? [`Run \`sign-off-axi wait${on} --filer ${filer}\` to hear if the person changes it`] : [`Run \`sign-off-axi answers${on}${key}\` to read ${key ? 'the answer' : 'the answers'}`])]);
|
|
2106
2153
|
if (incoming.length && !anyWaiting)
|
|
2107
2154
|
return toon({ board: tilde(file), raised }, [`Run \`sign-off-axi open${on}\` so the user can read ${incoming.length > 1 ? 'them' : 'it'}`]);
|
|
2108
2155
|
const open = anyWaiting ? [`Run \`sign-off-axi open${on}\` so the user can answer`] : [];
|
|
2109
|
-
return toon({ board: tilde(file), raised }, [...open, ...(filer ? [waitFor(on, filer)] : []), `Run \`sign-off-axi answers${on}${key}\` to read ${key ? 'the answer' : 'the answers'}`]);
|
|
2156
|
+
return toon({ board: tilde(file), raised }, [...goAhead, ...open, ...(filer ? [waitFor(on, filer)] : []), `Run \`sign-off-axi answers${on}${key}\` to read ${key ? 'the answer' : 'the answers'}`]);
|
|
2110
2157
|
});
|
|
2111
2158
|
}
|
|
2112
2159
|
async function open(args) {
|
|
@@ -2853,9 +2900,12 @@ async function serve(args) {
|
|
|
2853
2900
|
return;
|
|
2854
2901
|
await sweep('periodic');
|
|
2855
2902
|
reap();
|
|
2903
|
+
await holdLeash();
|
|
2856
2904
|
if (server.listening)
|
|
2857
2905
|
setTimeout(repeat, 5_000).unref();
|
|
2858
2906
|
};
|
|
2907
|
+
// Defaults that fell due while the board was closed are taken now; the next is timed to its silent_by, and every pass looks again.
|
|
2908
|
+
await holdLeash();
|
|
2859
2909
|
setTimeout(repeat, 5_000).unref();
|
|
2860
2910
|
const url = `http://${urlHost(bind)}:${bound}/`;
|
|
2861
2911
|
process.on('exit', () => { if (existsSync(record(file)) && readFileSync(record(file), 'utf8').split(' ')[0] === String(process.pid))
|
|
@@ -2925,7 +2975,7 @@ async function exportBoard(args) {
|
|
|
2925
2975
|
if (d.preview)
|
|
2926
2976
|
await inline(at(d.key, '', d.preview));
|
|
2927
2977
|
for (const [i, m] of (d.media ?? []).entries())
|
|
2928
|
-
for (const v of ['', ...['light', 'thumb'].filter((v) => m[v])])
|
|
2978
|
+
for (const v of ['', ...['light', 'thumb', 'tile'].filter((v) => m[v])])
|
|
2929
2979
|
await inline(`/api/media?key=${q(d.key)}&i=${i}${v && `&v=${v}`}`);
|
|
2930
2980
|
for (const m of d.evidenceCard?.media ?? [])
|
|
2931
2981
|
await inline(m.url);
|
|
@@ -2955,16 +3005,28 @@ const squeeze = (req, bytes) => {
|
|
|
2955
3005
|
return { bytes: gzipSync(bytes, { level: 6 }), encoding: 'gzip' };
|
|
2956
3006
|
return { bytes };
|
|
2957
3007
|
};
|
|
2958
|
-
|
|
3008
|
+
/** The board's design tokens, shared parts and typeface, favicon, home-screen icon and install manifest, served beside the page; see web/tokens.css, web/ui.css and web/icon.svg. */
|
|
3009
|
+
const ICONS = Object.fromEntries([['tokens.css', 'text/css; charset=utf-8'], ['ui.css', 'text/css; charset=utf-8'], ['inter.woff2', 'font/woff2'], ['icon.svg', 'image/svg+xml'], ['apple-touch-icon.png', 'image/png'], ['icon-192.png', 'image/png'], ['icon-512.png', 'image/png'], ['icon-maskable-512.png', 'image/png'], ['manifest.webmanifest', 'application/manifest+json']]
|
|
3010
|
+
.map(([name, type]) => [`/${name}`, { type, bytes: readFileSync(new URL(`../web/${name}`, import.meta.url)) }]));
|
|
3011
|
+
/** The board as an app: pwa.js (the install row and the offline line, named by its content like the stylesheets) and sw.js (the service worker, which must sit at the root and is asked again each time). */
|
|
3012
|
+
const APP = { pwa: readFileSync(new URL('../web/pwa.js', import.meta.url)), worker: readFileSync(new URL('../web/sw.js', import.meta.url)) };
|
|
3013
|
+
/** The iPad launch images (see scripts/make-splash.mjs), named <width>x<height>-<theme> in points, each linked for its own device, orientation and theme. */
|
|
3014
|
+
const SPLASH = readdirSync(new URL('../web/', import.meta.url)).flatMap((f) => { const m = /^splash-(\d+)x(\d+)-(light|dark)\.png$/.exec(f); return m ? [{ file: f, w: +m[1], h: +m[2], theme: m[3] }] : []; });
|
|
3015
|
+
for (const { file } of SPLASH)
|
|
3016
|
+
ICONS[`/${file}`] = { type: 'image/png', bytes: readFileSync(new URL(`../web/${file}`, import.meta.url)) };
|
|
3017
|
+
const splashLinks = SPLASH.map(({ file, w, h, theme }) => `<link rel="apple-touch-startup-image" href="${file}" media="(device-width: ${w}px) and (device-height: ${h}px) and (-webkit-device-pixel-ratio: 2) and (orientation: ${w < h ? 'portrait' : 'landscape'}) and (prefers-color-scheme: ${theme})">`).join('\n');
|
|
3018
|
+
/** The page names its two stylesheets by their content, so a phone holding an older copy (kept a day) fetches the new one with the new page: a row's badge is never drawn by half of an upgrade. */
|
|
3019
|
+
const served = Buffer.from(page.toString().replace(/href="(tokens|ui)\.css"/g, (_, name) => `href="${name}.css?v=${createHash('sha256').update(ICONS[`/${name}.css`].bytes).digest('hex').slice(0, 12)}"`)
|
|
3020
|
+
.replace('</head>', () => `${splashLinks}\n<script src="pwa.js?v=${createHash('sha256').update(APP.pwa).digest('hex').slice(0, 12)}" defer></script>\n</head>`));
|
|
3021
|
+
/** The worker's cache is named by the whole build, so a changed page, stylesheet or worker replaces the kept copy at the next visit and never mixes two. */
|
|
3022
|
+
const worker = Buffer.from(`const BUILD = ${JSON.stringify(createHash('sha256').update(served).update(APP.worker).update(APP.pwa).digest('hex').slice(0, 16))};\n${APP.worker}`);
|
|
3023
|
+
const pageTag = `"${createHash('sha256').update(served).digest('hex').slice(0, 32)}"`;
|
|
2959
3024
|
// Level 9 takes about 50 ms and level 11 over half a second for 7% less, so the page is squeezed at 9.
|
|
2960
3025
|
const squeezed = {};
|
|
2961
3026
|
const pageAs = (encoding) => (squeezed[encoding] ??= encoding === 'br'
|
|
2962
|
-
? brotliCompressSync(
|
|
3027
|
+
? brotliCompressSync(served, { params: { [zlib.BROTLI_PARAM_QUALITY]: 9, [zlib.BROTLI_PARAM_SIZE_HINT]: served.length } }) : gzipSync(served, { level: 9 }));
|
|
2963
3028
|
/** The browser keeps a reply and asks again with its tag, so an unchanged page, state or file costs a 304 and no body. */
|
|
2964
3029
|
const unchanged = (req, tag) => req.headers['if-none-match']?.split(/\s*,\s*/).includes(tag) ?? false;
|
|
2965
|
-
/** The board's design tokens, shared parts and typeface, favicon, home-screen icon and install manifest, served beside the page; see web/tokens.css, web/ui.css and web/icon.svg. */
|
|
2966
|
-
const ICONS = Object.fromEntries([['tokens.css', 'text/css; charset=utf-8'], ['ui.css', 'text/css; charset=utf-8'], ['inter.woff2', 'font/woff2'], ['icon.svg', 'image/svg+xml'], ['apple-touch-icon.png', 'image/png'], ['icon-192.png', 'image/png'], ['icon-512.png', 'image/png'], ['icon-maskable-512.png', 'image/png'], ['manifest.webmanifest', 'application/manifest+json']]
|
|
2967
|
-
.map(([name, type]) => [`/${name}`, { type, bytes: readFileSync(new URL(`../web/${name}`, import.meta.url)) }]));
|
|
2968
3030
|
/** Added to each design mock page and plan page so the person can point at a part of it; see web/point.js. */
|
|
2969
3031
|
const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
|
|
2970
3032
|
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
@@ -3047,6 +3109,14 @@ const warmGuard = () => new Promise((done) => {
|
|
|
3047
3109
|
* silent-by time, the default) or act (take the recommended pick now and report it). With the name: line it is the page's only setting. */
|
|
3048
3110
|
const LEASHES = ['ask', 'matters', 'act'];
|
|
3049
3111
|
const configLine = (text, key) => new RegExp(`^[ \\t]*${key}[ \\t]*:([^#\\n]*)`, 'm').exec(text)?.[1].trim() || undefined;
|
|
3112
|
+
const leashIn = (text) => ((leash) => (LEASHES.includes(leash) ? leash : 'matters'))(configLine(text, 'leash') ?? '');
|
|
3113
|
+
/** The leash of the config.yaml beside a board: matters when it has none, or no config.yaml. */
|
|
3114
|
+
const leashOf = (named) => { try {
|
|
3115
|
+
return leashIn(readFileSync(join(dirname(named), 'config.yaml'), 'utf8'));
|
|
3116
|
+
}
|
|
3117
|
+
catch {
|
|
3118
|
+
return 'matters';
|
|
3119
|
+
} };
|
|
3050
3120
|
/** The user's name and leash; none for a board with no config.yaml beside it. */
|
|
3051
3121
|
function profile() {
|
|
3052
3122
|
let text;
|
|
@@ -3056,8 +3126,34 @@ function profile() {
|
|
|
3056
3126
|
catch {
|
|
3057
3127
|
return {};
|
|
3058
3128
|
}
|
|
3059
|
-
|
|
3060
|
-
|
|
3129
|
+
return { name: configLine(text, 'name')?.slice(0, 40), leash: leashIn(text) };
|
|
3130
|
+
}
|
|
3131
|
+
/** What the leash answers for the person; run under the board lock, so a raise and the board never both take one ask. On Act, then tell me an open plain ask takes its
|
|
3132
|
+
* recommended pick at once (the agent's own pick, made when it is raised); on When it matters, and on Act for an ask with no pick, the board takes its default once its
|
|
3133
|
+
* silent_by has passed. A guarded ask (guardOf), one with neither, an ask a waiting ask merges, and every ask on Ask me first wait for the person.
|
|
3134
|
+
* `raised` limits it to the agent picks of those keys, as a raise takes them. Returns what it took and when the next default falls due. */
|
|
3135
|
+
function leashTake(file, leash, raised) {
|
|
3136
|
+
const took = [];
|
|
3137
|
+
let next;
|
|
3138
|
+
if (leash === 'ask' || !existsSync(file) || readItems(file).extra.readonly === true)
|
|
3139
|
+
return { took };
|
|
3140
|
+
const rows = board(file, () => { }), at = Date.now();
|
|
3141
|
+
for (const d of rows) {
|
|
3142
|
+
if ((raised && !raised.includes(d.key)) || d.answer || d.discarded || !plainAsk(d) || guardOf(d) || rows.some((x) => !x.answer && x.merges?.includes(d.key)))
|
|
3143
|
+
continue;
|
|
3144
|
+
const pick = leash === 'act' ? d.options.find((o) => o.value === d.recommend_value) : undefined, fallback = d.options.find((o) => o.value === d.default_if_silent);
|
|
3145
|
+
const due = d.silent_by ? Date.parse(d.silent_by) : NaN, save = (o, how) => { const a = appendAnswer(file, d, o, '', [], how); if (a)
|
|
3146
|
+
took.push({ d, a }); };
|
|
3147
|
+
if (pick)
|
|
3148
|
+
save(pick, { taken: 'agent', reason: `The leash is Act, then tell me: ${d.filer ?? 'the agent'} took its recommended pick` });
|
|
3149
|
+
else if (raised || !fallback || Number.isNaN(due))
|
|
3150
|
+
continue;
|
|
3151
|
+
else if (due <= at)
|
|
3152
|
+
save(fallback, { taken: 'default', reason: `No answer by ${d.silent_by}: the leash When it matters took the default` });
|
|
3153
|
+
else
|
|
3154
|
+
next = Math.min(next ?? due, due);
|
|
3155
|
+
}
|
|
3156
|
+
return { took, next };
|
|
3061
3157
|
}
|
|
3062
3158
|
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3063
3159
|
const state = () => {
|
|
@@ -3073,7 +3169,7 @@ const state = () => {
|
|
|
3073
3169
|
mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
|
|
3074
3170
|
// Who filed an item stays in the file, except as `by`, the name a card's thread says posted it and took its answer: never a bare session id, which names nobody.
|
|
3075
3171
|
// A sign-in's start command stays on this host too: the page only learns that a tap makes a new link, and how the last run went.
|
|
3076
|
-
decisions: items.filter((d) => !fakeEvidence(d)).map(({ filer, start, ...d }) => ({ ...d, ...(filer && !SESSION_ID.test(filer) && { by: filer }), ...(d.type === 'design' && answers[d.key]?.answer === 'approve' && { status: 'approved' }), ...(d.type === 'design' && designState(d, answers[d.key], lines)), ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
|
|
3172
|
+
decisions: items.filter((d) => !fakeEvidence(d)).map(({ filer, start, ...d }) => ({ ...d, ...(filer && !SESSION_ID.test(filer) && { by: filer }), ...(plainAsk(d) && guardOf(d) && { guard: guardOf(d) }), ...(d.type === 'design' && answers[d.key]?.answer === 'approve' && { status: 'approved' }), ...(d.type === 'design' && designState(d, answers[d.key], lines)), ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
|
|
3077
3173
|
...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
|
|
3078
3174
|
readonly: extra.readonly === true,
|
|
3079
3175
|
answers_only: extra.answers_only === true,
|
|
@@ -3114,7 +3210,7 @@ const mockHash = (path) => {
|
|
|
3114
3210
|
}
|
|
3115
3211
|
};
|
|
3116
3212
|
/** A design's files as the board serves them: its preview and every screen view. */
|
|
3117
|
-
const designPaths = (d) => [...new Set([d.preview, ...(d.journeys ?? []).flatMap((j) => j.screens.flatMap(
|
|
3213
|
+
const designPaths = (d) => [...new Set([d.preview, ...(d.journeys ?? []).flatMap((j) => j.screens.flatMap(screenViews))])].filter((p) => !!p);
|
|
3118
3214
|
/** A design's round: its time, screens and options and the bytes of its files, so the same key posted again with a new time or a changed screen is a new round. */
|
|
3119
3215
|
const designRound = (d) => sha256(Buffer.from(JSON.stringify([d.filed, d.journeys, d.options, designPaths(d).map((p) => [p, mockHash(p)])])));
|
|
3120
3216
|
/** What the page needs to know of a design beyond its fields: whether its answer is from an earlier round, the last answer given on an earlier round
|
|
@@ -3226,7 +3322,7 @@ async function route(req) {
|
|
|
3226
3322
|
if (req.method === 'GET' && req.url?.startsWith('/design/')) {
|
|
3227
3323
|
const [, , , key, ...parts] = new URL(req.url, 'http://localhost').pathname.split('/');
|
|
3228
3324
|
const path = parts.map(decodeURIComponent).join('/'), d = loadDecisions(file).find(x => x.key === key && x.type === 'design');
|
|
3229
|
-
const allowed = d?.preview === path || d?.journeys?.some(j => j.screens.some(s =>
|
|
3325
|
+
const allowed = d?.preview === path || d?.journeys?.some(j => j.screens.some(s => screenViews(s).includes(path)));
|
|
3230
3326
|
const type = assetTypes[extname(path).toLowerCase()];
|
|
3231
3327
|
try {
|
|
3232
3328
|
if (!allowed || !type)
|
|
@@ -3280,9 +3376,9 @@ async function route(req) {
|
|
|
3280
3376
|
}
|
|
3281
3377
|
if (req.method === 'GET' && req.url?.startsWith('/api/media?')) {
|
|
3282
3378
|
const q = new URL(req.url, 'http://localhost').searchParams;
|
|
3283
|
-
// v=light or v=
|
|
3379
|
+
// v=light, v=thumb or v=tile asks for the copy post made of a still; with no v, the file as it was posted.
|
|
3284
3380
|
const v = q.get('v'), posted = loadDecisions(file).find((d) => d.key === q.get('key'))?.media?.[Number(q.get('i') || NaN)];
|
|
3285
|
-
const m = v === null ? posted : v === 'light' || v === 'thumb' ? posted?.[v] : undefined, type = m && mediaTypes[extname(m.path).toLowerCase()];
|
|
3381
|
+
const m = v === null ? posted : v === 'light' || v === 'thumb' || v === 'tile' ? posted?.[v] : undefined, type = m && mediaTypes[extname(m.path).toLowerCase()];
|
|
3286
3382
|
if (!m || !type)
|
|
3287
3383
|
return [404, { error: 'Evidence is unavailable' }];
|
|
3288
3384
|
try {
|
|
@@ -3350,9 +3446,10 @@ async function route(req) {
|
|
|
3350
3446
|
return [404, { error: 'Not found' }];
|
|
3351
3447
|
if (readOnly())
|
|
3352
3448
|
return [403, { error: 'This board is read-only' }];
|
|
3449
|
+
// The leash is the person's own setting in config.yaml, which the board applies to the answers it takes, so an answers-only board takes it too.
|
|
3353
3450
|
// Dismiss only hides an item on this page and sends nothing to the feeding program, a reply opened (/api/trail) is only the person's own mark, and Go deeper and a comment
|
|
3354
3451
|
// on a part of a guided page go to the page's own author through its wait, so an answers-only board takes them all; a comment on a mock, a round and a prompt are for the feeding program, below.
|
|
3355
|
-
if (!['/api/answers', '/api/dismiss', '/api/signin', '/api/reads', '/api/trail', '/api/deeper', '/api/engagement', '/api/engagement/seen', '/api/annotations'].includes(req.url) && answersOnly())
|
|
3452
|
+
if (!['/api/answers', '/api/dismiss', '/api/signin', '/api/reads', '/api/trail', '/api/deeper', '/api/engagement', '/api/engagement/seen', '/api/annotations', '/api/leash'].includes(req.url) && answersOnly())
|
|
3356
3453
|
return [403, { error: 'This board takes answers only' }];
|
|
3357
3454
|
if (!req.headers['content-type']?.startsWith('application/json'))
|
|
3358
3455
|
return [415, { error: 'Send JSON' }];
|
|
@@ -3445,6 +3542,7 @@ async function route(req) {
|
|
|
3445
3542
|
}
|
|
3446
3543
|
const line = /^[ \t]*leash[ \t]*:.*$/m;
|
|
3447
3544
|
writeFileSync(config, line.test(text) ? text.replace(line, `leash: ${leash}`) : `${text.replace(/\n?$/, '\n')}leash: ${leash}\n`);
|
|
3545
|
+
await holdLeash();
|
|
3448
3546
|
return [200, state()];
|
|
3449
3547
|
}
|
|
3450
3548
|
if (req.url === '/api/dismiss') {
|
|
@@ -3468,9 +3566,9 @@ async function route(req) {
|
|
|
3468
3566
|
return [200, state()];
|
|
3469
3567
|
}
|
|
3470
3568
|
if (req.url === '/api/trail') {
|
|
3471
|
-
// The person opened a card with a reply on it: the reply leaves Today.
|
|
3569
|
+
// The person opened a card with a reply on it, or an answer the leash took: the reply leaves Today, the answer leaves Taken without you.
|
|
3472
3570
|
const { key } = JSON.parse(body) ?? {};
|
|
3473
|
-
if (!readTrail(file, loadDecisions(file)).some((t) => t.key === key && t.reply))
|
|
3571
|
+
if (!readTrail(file, loadDecisions(file)).some((t) => t.key === key && (t.reply || t.taken)))
|
|
3474
3572
|
return [404, { error: 'No reply on this card' }];
|
|
3475
3573
|
appendMark(file, { op: 'seen', key });
|
|
3476
3574
|
return [200, state()];
|
|
@@ -3575,6 +3673,27 @@ function answered(a, d) {
|
|
|
3575
3673
|
child.stdin.on('error', () => { });
|
|
3576
3674
|
child.stdin.end(`${JSON.stringify({ event: 'answer', ...kindOf(file)(a.key), ...a, ...(d && { title: d.title }), ...(d?.filer && { filer: d.filer }) })}\n`);
|
|
3577
3675
|
}
|
|
3676
|
+
/** Takes what the leash allows now (leashTake) and times the next default to its silent_by: run when the board starts, every few seconds, and when the leash changes. */
|
|
3677
|
+
let leashTimer;
|
|
3678
|
+
async function holdLeash() {
|
|
3679
|
+
clearTimeout(leashTimer);
|
|
3680
|
+
if (!server.listening)
|
|
3681
|
+
return;
|
|
3682
|
+
let next;
|
|
3683
|
+
try {
|
|
3684
|
+
({ next } = await locked(`${file}.lock`, () => {
|
|
3685
|
+
const r = leashTake(file, profile().leash ?? 'matters');
|
|
3686
|
+
for (const { d, a } of r.took)
|
|
3687
|
+
answered(a, d);
|
|
3688
|
+
return r;
|
|
3689
|
+
}, 'another raise is still writing the board'));
|
|
3690
|
+
}
|
|
3691
|
+
catch (e) {
|
|
3692
|
+
warn(`The leash took no answers: ${e.message}`);
|
|
3693
|
+
}
|
|
3694
|
+
if (next !== undefined)
|
|
3695
|
+
leashTimer = setTimeout(holdLeash, Math.min(Math.max(0, next - Date.now()), 2 ** 31 - 1)).unref();
|
|
3696
|
+
}
|
|
3578
3697
|
const runs = new Map(), failures = new Map();
|
|
3579
3698
|
const SIGNIN_WAIT = 20_000, SIGNIN_LIFE = 600_000;
|
|
3580
3699
|
const signinState = (key) => (runs.get(key)?.url ? 'live' : runs.has(key) ? 'starting' : failures.has(key) ? `failed: ${failures.get(key)}` : 'none');
|
|
@@ -3682,7 +3801,11 @@ const server = createServer(async (req, res) => {
|
|
|
3682
3801
|
if (given !== null)
|
|
3683
3802
|
return res.writeHead(303, { location: '/', 'set-cookie': `${cookie}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000` }).end();
|
|
3684
3803
|
}
|
|
3685
|
-
|
|
3804
|
+
if (req.method === 'GET' && req.url === '/sw.js')
|
|
3805
|
+
return res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' }).end(worker);
|
|
3806
|
+
if (req.method === 'GET' && (req.url ?? '').split('?')[0] === '/pwa.js')
|
|
3807
|
+
return res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=86400' }).end(APP.pwa);
|
|
3808
|
+
const icon = req.method === 'GET' ? ICONS[(req.url ?? '').split('?')[0]] : undefined;
|
|
3686
3809
|
if (icon)
|
|
3687
3810
|
return res.writeHead(200, { 'content-type': icon.type, 'cache-control': 'public, max-age=86400' }).end(icon.bytes);
|
|
3688
3811
|
if (req.method === 'GET' && req.url === '/favicon.ico')
|
|
@@ -3691,7 +3814,7 @@ const server = createServer(async (req, res) => {
|
|
|
3691
3814
|
const headers = { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'self' 'unsafe-inline'", etag: pageTag, 'cache-control': 'no-cache', vary: 'accept-encoding' };
|
|
3692
3815
|
if (unchanged(req, pageTag))
|
|
3693
3816
|
return res.writeHead(304, headers).end();
|
|
3694
|
-
const takes = String(req.headers['accept-encoding'] ?? ''), encoding = /\bbr\b/.test(takes) ? 'br' : /\bgzip\b/.test(takes) ? 'gzip' : undefined, bytes = encoding ? pageAs(encoding) :
|
|
3817
|
+
const takes = String(req.headers['accept-encoding'] ?? ''), encoding = /\bbr\b/.test(takes) ? 'br' : /\bgzip\b/.test(takes) ? 'gzip' : undefined, bytes = encoding ? pageAs(encoding) : served;
|
|
3695
3818
|
return res.writeHead(200, { ...headers, ...(encoding && { 'content-encoding': encoding }), 'content-length': bytes.length }).end(bytes);
|
|
3696
3819
|
}
|
|
3697
3820
|
let status, body;
|