sign-off-axi 0.6.4 → 0.6.6
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 +32 -17
- package/dist/cli.js +37 -9
- package/dist/contract.js +189 -5
- package/dist/store.js +12 -2
- package/package.json +1 -1
- package/skills/sign-off-axi/SKILL.md +13 -8
- package/web/index.html +266 -27
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Guided plans and reports now offer **Simplify this** under each part (and for an
|
|
|
16
16
|
<p align="center">
|
|
17
17
|
<picture>
|
|
18
18
|
<source media="(prefers-color-scheme: dark)" srcset="docs/media/desk-today-dark.webp">
|
|
19
|
-
<img src="docs/media/desk-today.webp" alt="The board at desk width: Today with
|
|
19
|
+
<img src="docs/media/desk-today.webp" alt="The board at desk width: Today with the next ask, what waits, designs to review and what was proven">
|
|
20
20
|
</picture>
|
|
21
21
|
</p>
|
|
22
22
|
|
|
@@ -43,25 +43,43 @@ You need Node 22.5 or newer. `init` installs the skill for each coding agent it
|
|
|
43
43
|
|
|
44
44
|
## The board
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
Five places, in the dock at the bottom of a phone and the sidebar on a desk: **Today**, **Decide**, **Evidence**, **Reports** and **Tickets**.
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
<table>
|
|
49
|
+
<tr>
|
|
50
|
+
<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: the next ask with its answer buttons, what waits, designs to review and what was cleared and proven"></picture><br><b>Today</b><br>What needs you, what was proven.</td>
|
|
51
|
+
<td align="center" width="25"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-decide-dark.webp"><img src="docs/media/phone-decide.webp" alt="Decide: asks with their priority and ticket progress, and Taken without you with Change"></picture><br><b>Decide</b><br>Every ask that waits, one tap each.</td>
|
|
52
|
+
<td align="center" width="25"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-reader-dark.webp"><img src="docs/media/phone-reader.webp" alt="A report in the reader: its answer first, and the one action bar with Agree, Disagree, Comment and Ask"></picture><br><b>Reader</b><br>One action bar on the part in view.</td>
|
|
53
|
+
<td align="center" width="25"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-evidence-dark.webp"><img src="docs/media/phone-evidence.webp" alt="Evidence with Previews on: each proof with its first capture and the check's result"></picture><br><b>Evidence</b><br>Proof, with its first capture inline.</td>
|
|
54
|
+
</tr>
|
|
55
|
+
</table>
|
|
49
56
|
|
|
50
57
|
<table>
|
|
51
58
|
<tr>
|
|
52
|
-
<td align="center" width="
|
|
53
|
-
<td align="center" width="
|
|
54
|
-
<td align="center" width="
|
|
55
|
-
<td align="center" width="25%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-reports-dark.webp"><img src="docs/media/phone-reports.webp" alt="Reports: explainer pages you asked for"></picture><br><b>Reports</b><br>Explainers you asked for, to read.</td>
|
|
59
|
+
<td align="center" width="33"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-tickets-dark.webp"><img src="docs/media/phone-tickets.webp" alt="A ticket: its progress, the epic it is part of, its sub-tasks and a linked pull request"></picture><br><b>Tickets</b><br>Epics, tickets and tasks that roll up.</td>
|
|
60
|
+
<td align="center" width="33"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-designs-dark.webp"><img src="docs/media/phone-designs.webp" alt="Designs: the design waiting for review, with a thumbnail of its first screen"></picture><br><b>Designs</b><br>Frames to review, one question each.</td>
|
|
61
|
+
<td align="center" width="33"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-install-dark.webp"><img src="docs/media/phone-install.webp" alt="Today's More: the leash, Install app and the version the board runs"></picture><br><b>Install</b><br>An app on your home screen.</td>
|
|
56
62
|
</tr>
|
|
57
63
|
</table>
|
|
58
64
|
|
|
65
|
+
Every card opens to its own screen with the same parts: one **action bar** (the answer, or Agree, Disagree, Comment and Ask on a report), one **Activity** (who posted it, each answer, note, comment and reply, and how far each got), and **Previous** and **Next** through the list you opened it from (swipe on a phone, `J` and `K` on a desk). The board names its own version (`--version`, the sidebar's foot, Today's More), and a reply links the new version of the work.
|
|
66
|
+
|
|
67
|
+
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.
|
|
68
|
+
|
|
69
|
+
On a phone, larger Decide lists open quickly and **Show more** brings in the next asks, Later items or answers. The counts always include the whole board.
|
|
70
|
+
|
|
59
71
|
### Evidence is not a decision
|
|
60
72
|
|
|
61
|
-
**Decide** holds only real asks: things an agent cannot do without your answer. **Evidence** holds proof (screenshots or a short recording of the running product) with the result of the check that made it: Passed, Not run, Failed, Did not finish or Could not check. A proof update is never counted as something that needs you; you can give it a verdict (Pass, Fail or Needs work) or dismiss it. **Today** counts the two separately: what needs you, and what was proven today.
|
|
73
|
+
**Decide** holds only real asks: things an agent cannot do without your answer. **Evidence** holds proof (screenshots or a short recording of the running product) with the result of the check that made it: Passed, Not run, Failed, Did not finish or Could not check. Each row shows its first capture and the check's word; tap one and it opens in place with every capture in a strip, and **Previews** shows every row large. A proof update is never counted as something that needs you; you can give it a verdict (Pass, Fail or Needs work) or dismiss it. **Today** counts the two separately: what needs you, and what was proven today.
|
|
74
|
+
|
|
75
|
+
### You see how far each word got
|
|
76
|
+
|
|
77
|
+
What you send on a card (an answer, a note, a new round, a comment) shows its trail: **Sent**, **Delivered** (the agent's wait or hook got it), **Read** and **Replied**, with the agent's words and a link to the pull request or new version. It shows on the card, in its Activity, as a badge in the lists, and a reply waits in Today until you open it. An agent closes the loop with `sign-off-axi feedback <key> --reply "..." --link <url>`; `--read`, `--delivered` and `--acted --link` mark the other steps, and `--at <epoch seconds>` replays a step that happened earlier without turning a later real one back.
|
|
62
78
|
|
|
63
79
|
### Plans and reports, on their own pages
|
|
64
80
|
|
|
81
|
+
<!-- MARKER: report-writing and the report gallery for design output are not described here yet; they land with the report kit PRs. -->
|
|
82
|
+
|
|
65
83
|
<table>
|
|
66
84
|
<tr>
|
|
67
85
|
<td align="center" width="33%"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/phone-plan-dark.webp"><img src="docs/media/phone-plan.webp" alt="A plan page: the idea in one picture, then each section to approve or change"></picture><br><b>Plan</b><br>Approve or change each section, answer each decision, send it all back at once.</td>
|
|
@@ -83,10 +101,11 @@ A board that fills up with noise stops being read, so the rules sit at the door.
|
|
|
83
101
|
|
|
84
102
|
### One ticket ties a task's cards together
|
|
85
103
|
|
|
86
|
-
A ticket is one piece of work, with a title and how far along it is, and optionally its priority, its project
|
|
104
|
+
A ticket is one piece of work, with a title and how far along it is, and optionally its priority, its project, its release, its state and its pull requests. Tickets nest as **epics > tickets > tasks**, and each is as far along as the average of what is under it (a roll-up from the tasks up). The **Tickets** tab shows your asks (and the system's own work) done, in progress, on hold and to do; a ticket's page shows its release, its state, each linked pull request and every card on it. Post any card with `--ticket <id>` (an ask, a design, proof, a report or a plan) and its screen shows a **Ticket** row (on an ask, **Its ask** in the panel under the question) with the ticket's title and progress; tap it for the ticket, which lists the tickets under it and every card on it, newest first, with their Activity. It is optional and only a link: a card without one, or naming a ticket the board has not got, works exactly as before.
|
|
87
105
|
|
|
88
106
|
```sh
|
|
89
|
-
sign-off-axi ticket --id
|
|
107
|
+
sign-off-axi ticket --id E-1 --title "Settings, start to finish" --release 0.6.5
|
|
108
|
+
sign-off-axi ticket --id T-29 --title "Ship the new settings page" --parent E-1 --progress 40 --state "in progress" --pr https://github.com/owner/repo/pull/241
|
|
90
109
|
sign-off-axi post --type evidence --ticket T-29 --title "Settings on a phone" --about "Umer changes the theme in one tap." --media shot.png
|
|
91
110
|
```
|
|
92
111
|
|
|
@@ -117,13 +136,7 @@ Both kinds wait under **Taken without you** in Decide until you open them, each
|
|
|
117
136
|
|
|
118
137
|
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
138
|
|
|
120
|
-
<
|
|
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>
|
|
139
|
+
<p align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/desk-decide-dark.webp"><img src="docs/media/desk-decide.webp" alt="Decide on a desk: the Leash chip by the count, the asks that wait, Taken without you, and a money ask that always waits for you"></picture></p>
|
|
127
140
|
|
|
128
141
|
## What you can ask
|
|
129
142
|
|
|
@@ -138,6 +151,8 @@ One line on what the agent runs per kind; the full reference is [docs/question-k
|
|
|
138
151
|
| 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) |
|
|
139
152
|
| Explainer video | `post --type explainer --media clip.mp4 --chapter 0:00=Start` | a player with chapter buttons, then Yes or No |
|
|
140
153
|
| 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 |
|
|
154
|
+
| Design output report | `report init <folder> --gallery`, put the screenshots under the board's folder, fill the gallery (label, model, run time, a caption and device per frame), then `post --kind report` | a swipeable strip of options on a phone, a row on a desk: each option's phone and desktop frame side by side, the caption with the model and run time under each, a tap to open full size with Previous and Next |
|
|
155
|
+
| Report kit blocks | add `blocks` to a section of `report.json`: `callout`, `beats` (a stepper), `video`, `table`, `compare` (before and after), `evidence` rows, with files under the board's folder | the board draws each in its own type and colours, light and dark: full width, one gutter, no card inside a card; a gallery and a video may run wider than the reading column on a desk |
|
|
141
156
|
| 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 |
|
|
142
157
|
| 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 |
|
|
143
158
|
| Sign-in link | `post --type signin --service npm --link <url> --unlocks ...` | an Open button and the time left |
|
package/dist/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ 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
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
|
-
import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, diagramPages, diagramViews, guided, mediaFiles, parseMessage, reportPage } from './contract.js';
|
|
18
|
+
import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, diagramPages, diagramViews, galleryFrames, guided, kitWarnings, mediaFiles, parseMessage, reportPage } from './contract.js';
|
|
19
19
|
import { faults, isStill } from './evidence-text.js';
|
|
20
20
|
import { copiesOf, differs } from './images.js';
|
|
21
21
|
import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
|
|
@@ -380,7 +380,7 @@ examples:
|
|
|
380
380
|
sign-off-axi feedback fleet-dashboard-full --reply "The colours now come from the new tokens." --link https://github.com/owner/repo/pull/245
|
|
381
381
|
sign-off-axi feedback fleet-dashboard-full --acted --link https://github.com/owner/repo/pull/245
|
|
382
382
|
`,
|
|
383
|
-
report: `usage: sign-off-axi report init <folder> [--purpose research|plan|decision|audit|incident|status|other] [--plan] [--page]
|
|
383
|
+
report: `usage: sign-off-axi report init <folder> [--purpose research|plan|decision|audit|incident|status|other] [--plan] [--page] [--gallery]
|
|
384
384
|
Writes the guided template to fill: <folder>/report.json, or <folder>/plan.json with --plan. A file already there is kept.
|
|
385
385
|
Every report names what it is for in "purpose", its kind: research (research and discovery), plan (implementation plan), decision (decision and recommendation),
|
|
386
386
|
audit, incident (incident and retro), status, or other. --purpose fills it in and writes that kind's parts to fill, one section each, which post then requires
|
|
@@ -391,6 +391,7 @@ The board draws it as one scroll: what this is (the answer and one picture), wha
|
|
|
391
391
|
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.
|
|
392
392
|
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>.
|
|
393
393
|
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.
|
|
394
|
+
--gallery adds a gallery to the first section: a design output's options, each with its model, run time and a phone and a desktop frame (an image or page, as a path from the board file's folder), which the board draws as a swipeable strip with each frame opening full size.
|
|
394
395
|
--page writes the older scaffold page instead, for a report that needs a page of its own (report schema 2). It is:
|
|
395
396
|
Writes a report page to fill: <folder>/index.html and its card picture <folder>/preview.svg. A file already there is kept.
|
|
396
397
|
The page is one self-contained file, phone first, light and dark, in four parts, each one idea:
|
|
@@ -1895,8 +1896,11 @@ async function postKind(file, named, on, values, built) {
|
|
|
1895
1896
|
// A report's or plan's page is served from the board file's folder, as --view is.
|
|
1896
1897
|
if ((m.kind === 'report' || m.kind === 'plan') && !guided(m))
|
|
1897
1898
|
reportPage(m, dirname(file));
|
|
1898
|
-
if (guided(m))
|
|
1899
|
+
if (guided(m)) {
|
|
1899
1900
|
diagramPages(m, dirname(file));
|
|
1901
|
+
for (const w of kitWarnings(m))
|
|
1902
|
+
process.stderr.write(`warning: ${name}: ${w}\n`);
|
|
1903
|
+
}
|
|
1900
1904
|
if (m.kind === 'design')
|
|
1901
1905
|
designFiles(m, dirname(file));
|
|
1902
1906
|
}
|
|
@@ -2580,12 +2584,14 @@ const kindSections = (purpose) => REPORT_PARTS[purpose].map(([part, name]) => {
|
|
|
2580
2584
|
function report(args) {
|
|
2581
2585
|
let parsed;
|
|
2582
2586
|
try {
|
|
2583
|
-
parsed = parseArgs({ args, allowPositionals: true, strict: true, options: { plan: { type: 'boolean' }, page: { type: 'boolean' }, purpose: { type: 'string' } } });
|
|
2587
|
+
parsed = parseArgs({ args, allowPositionals: true, strict: true, options: { plan: { type: 'boolean' }, page: { type: 'boolean' }, purpose: { type: 'string' }, gallery: { type: 'boolean' } } });
|
|
2584
2588
|
}
|
|
2585
2589
|
catch (e) {
|
|
2586
2590
|
throw fail(e.message, 'VALIDATION_ERROR', 'Run `sign-off-axi report --help`');
|
|
2587
2591
|
}
|
|
2588
|
-
const [sub, dir, extra] = parsed.positionals, { plan, page, purpose } = parsed.values;
|
|
2592
|
+
const [sub, dir, extra] = parsed.positionals, { plan, page, purpose, gallery } = parsed.values;
|
|
2593
|
+
if (gallery && (plan || page))
|
|
2594
|
+
throw fail(`--gallery does not go with --${plan ? 'plan' : 'page'}: a gallery is a section of a guided report`, 'VALIDATION_ERROR', 'sign-off-axi report init <folder> --gallery');
|
|
2589
2595
|
if (purpose !== undefined && (plan || page))
|
|
2590
2596
|
throw fail(`--purpose does not go with --${plan ? 'plan' : 'page'}: ${plan ? 'a plan is always an implementation plan' : 'a page report has no purpose field'}`, 'VALIDATION_ERROR', 'sign-off-axi report init <folder> --purpose research');
|
|
2591
2597
|
if (purpose !== undefined && !REPORT_PURPOSES.includes(purpose))
|
|
@@ -2606,13 +2612,20 @@ function report(args) {
|
|
|
2606
2612
|
const template = readFileSync(new URL(`../web/${kind}-template.json`, import.meta.url), 'utf8').replace('"FILL: UTC time"', JSON.stringify(filed));
|
|
2607
2613
|
// With --purpose the template holds that kind's parts, one section each; with none, the general shape and purpose to fill.
|
|
2608
2614
|
const kinded = purpose && { ...JSON.parse(template), purpose, ...(REPORT_PARTS[purpose].length && { sections: kindSections(purpose) }) };
|
|
2609
|
-
|
|
2615
|
+
// With --gallery the first section also holds a gallery to fill: one option, its phone and desktop frame.
|
|
2616
|
+
const doc = gallery ? { ...(kinded || JSON.parse(template)) } : kinded;
|
|
2617
|
+
if (gallery)
|
|
2618
|
+
doc.sections = doc.sections.map((sec, n) => n ? sec : { ...sec, gallery: [{ label: 'FILL: Option A', model: 'FILL: the model that made it', ran: 'FILL: UTC time it ran', frames: [
|
|
2619
|
+
{ device: 'phone', src: 'FILL: path from the board file\'s folder, such as gallery/a-phone.png, 390 wide', caption: 'FILL: what the phone frame shows' }, { device: 'desktop', src: 'FILL: path from the board file\'s folder, such as gallery/a-desktop.png, 1280 wide', caption: 'FILL: what the desktop frame shows' }
|
|
2620
|
+
] }] });
|
|
2621
|
+
writeFileSync(path, doc ? `${JSON.stringify(doc, null, 2)}\n` : template, { flag: 'wx' });
|
|
2610
2622
|
created.push(shellPath(path));
|
|
2611
2623
|
}
|
|
2612
2624
|
return toon({ folder: shellPath(out), created: created.length ? created : 'nothing new (re-run is a no-op)', kept }, [
|
|
2613
2625
|
...(plan || purpose ? [] : [`Set "purpose" to what the report is for: ${REPORT_PURPOSES.join(', ')} (or run report init again with --purpose <kind> in a new folder)`]),
|
|
2614
2626
|
`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' : ''}`,
|
|
2615
2627
|
`${plan ? 'A plan' : 'A design or architecture report'} needs one real diagram: a flow or architecture drawing as inline SVG, in light and dark, as "diagram" on the visual (see the skill)`,
|
|
2628
|
+
...(gallery ? ['Save each option\'s screenshots (or pages) under the board file\'s folder, a phone frame 390 wide and a desktop frame 1280 wide, and fill the gallery: its label, model, run time (UTC), and per frame its path from that folder and a caption'] : []),
|
|
2616
2629
|
'Keep each line short: the board shows it in one scroll, the numbers behind a Details tap and the long answer behind Go deeper',
|
|
2617
2630
|
`Run \`sign-off-axi post --kind ${kind} --from ${shellPath(path)}\` to put it on the board`,
|
|
2618
2631
|
]);
|
|
@@ -3236,6 +3249,13 @@ const mockTypes = { '.html': 'text/html', '.svg': 'image/svg+xml', '.png': 'imag
|
|
|
3236
3249
|
const attachTypes = { '.apk': 'application/vnd.android.package-archive', '.zip': 'application/zip', '.pdf': 'application/pdf', '.txt': 'text/plain; charset=utf-8', '.json': 'application/json', '.csv': 'text/csv', '.gz': 'application/gzip' };
|
|
3237
3250
|
const assetTypes = { ...mediaTypes, ...mockTypes, '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript', '.gif': 'image/gif', '.woff2': 'font/woff2' };
|
|
3238
3251
|
const MOCK_BYTES = 5_000_000;
|
|
3252
|
+
/** The lighter copy of a gallery frame's image, made once per file and kept for the board's life: the frame shows at most 1280 px wide, so a larger still is sent smaller. */
|
|
3253
|
+
const lights = new Map();
|
|
3254
|
+
const galleryLight = (key, bytes, ext) => {
|
|
3255
|
+
if (!lights.has(key))
|
|
3256
|
+
lights.set(key, copiesOf(bytes, ext).light);
|
|
3257
|
+
return lights.get(key);
|
|
3258
|
+
};
|
|
3239
3259
|
/** A sandboxed mock frame sends no cookie, so beyond loopback its URLs carry this pass in the path: made from the token, it opens mock folders only. */
|
|
3240
3260
|
const mockPass = (token) => sha256(Buffer.from(`mock ${token}`));
|
|
3241
3261
|
/** Every served file is its own sandboxed page, even opened full size: no fetch, no remote files, no frames, and no reach into the dashboard or its tab. */
|
|
@@ -3344,18 +3364,26 @@ async function route(req) {
|
|
|
3344
3364
|
// A guided document's diagram views: ~diagram.<n>, n counting them in the order visual, then each section and its items.
|
|
3345
3365
|
const dgs = d?.guided ? diagramViews(d) : [];
|
|
3346
3366
|
const diagram = value?.startsWith('diagram.') ? dgs[Number(value.slice(8))] : undefined;
|
|
3347
|
-
|
|
3367
|
+
// A gallery frame is served the same way, pinned to the file posted; an image can be asked for as ?v=light, a copy that is no wider than the board shows it.
|
|
3368
|
+
const frame = value?.startsWith('gallery.') && d?.guided ? galleryFrames(d)[Number(value.slice(8))] : undefined;
|
|
3369
|
+
const mock = diagram || frame ? (diagram ?? frame).src : value ? d?.mocks?.find((m) => m.value === value)?.path : value === '' && d?.view;
|
|
3348
3370
|
try {
|
|
3349
3371
|
const path = rest.map(decodeURIComponent).join('/'), type = assetTypes[extname(path).toLowerCase()];
|
|
3350
3372
|
if (!mock || !type)
|
|
3351
3373
|
return [404, { error: 'Mock is unavailable' }];
|
|
3352
3374
|
const actual = under(dirname(under(dirname(file), mockFile(mock))), path);
|
|
3353
|
-
if (statSync(actual).size > MOCK_BYTES)
|
|
3375
|
+
if (statSync(actual).size > (type.startsWith('video/') && frame ? MEDIA_BYTES : MOCK_BYTES))
|
|
3354
3376
|
return [404, { error: 'Mock is unavailable' }];
|
|
3355
3377
|
const bytes = readFileSync(actual);
|
|
3356
3378
|
// A diagram is its one pinned page: the file the plan posted, or nothing. A plan posted before the pin carries none and is served as it was.
|
|
3357
|
-
|
|
3379
|
+
const pinned = diagram ?? frame;
|
|
3380
|
+
if (pinned?.sha256 && (actual !== under(dirname(file), mockFile(mock)) || bytes.length !== pinned.bytes || sha256(bytes) !== pinned.sha256))
|
|
3358
3381
|
return [404, { error: 'Diagram file changed since it was posted' }];
|
|
3382
|
+
if (frame && new URL(req.url, 'http://localhost').searchParams.get('v') === 'light' && type.startsWith('image/')) {
|
|
3383
|
+
const light = galleryLight(pinned.sha256 ?? sha256(bytes), bytes, extname(path));
|
|
3384
|
+
if (light)
|
|
3385
|
+
return [200, { media: light.bytes, type: mediaTypes[light.ext] }];
|
|
3386
|
+
}
|
|
3359
3387
|
// Every HTML view gets the bridge: document readers need its size handshake too. A diagram also gets its way back.
|
|
3360
3388
|
return [200, { media: bytes, type, point: type === 'text/html', ...(diagram && type === 'text/html' && d && { page: diagramPage(`/#${d.type === 'plan' && !reading(d) ? 'waiting' : 'reports'}/${d.key}`, `Back to the ${d.type === 'plan' ? 'plan' : 'report'}`) }) }];
|
|
3361
3389
|
}
|
package/dist/contract.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
4
4
|
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
5
|
-
import { GUARDS, isKey, MEDIA_MAX, PRIORITIES, REPORT_FORMS, REPORT_PARTS, REPORT_PURPOSES } from './store.js';
|
|
5
|
+
import { CAPTION_MAX, EVIDENCE_STATUSES, GUARDS, isKey, MEDIA_MAX, PRIORITIES, REPORT_FORMS, REPORT_PARTS, REPORT_PURPOSES } from './store.js';
|
|
6
6
|
/** A message that does not match: what is wrong, and the one line that says how to fix it. */
|
|
7
7
|
export class ContractError extends Error {
|
|
8
8
|
help;
|
|
@@ -24,7 +24,7 @@ const COMMON = ['contract', 'kind', 'schema', 'key', 'title', 'filer', 'ticket',
|
|
|
24
24
|
const FIELDS = {
|
|
25
25
|
ask: [...COMMON, 'ask_ref', 'detail', 'why', 'options', 'recommend_value', 'recommend_reason', 'default_if_silent', 'silent_by', 'guard', 'close', 'merges', 'proof', 'report'],
|
|
26
26
|
report: [...COMMON, 'filed', 'group', 'decided', 'form', 'video', 'asked_by', 'view', 'preview'],
|
|
27
|
-
evidence: [...COMMON, 'media', 'run'],
|
|
27
|
+
evidence: [...COMMON, 'media', 'run', 'status', 'captions'],
|
|
28
28
|
gate: [...COMMON, 'run', 'options', 'recommend_value', 'close'],
|
|
29
29
|
finding: [...COMMON, 'cause', 'incidents', 'days', 'evidence_ids', 'change_kind', 'proposal', 'options', 'recommend_value'],
|
|
30
30
|
plan: [...COMMON, 'project', 'sections', 'decisions', 'steps', 'view'],
|
|
@@ -46,6 +46,14 @@ const ACTIONS = {
|
|
|
46
46
|
gate: { approve: 'Approve', skip: 'Skip the judge', fix: 'Fix first' },
|
|
47
47
|
finding: { apply: 'Apply', skip: 'Skip' },
|
|
48
48
|
};
|
|
49
|
+
/** The screens a gallery frame shows, and the files it may be. */
|
|
50
|
+
export const GALLERY_DEVICES = ['phone', 'desktop'];
|
|
51
|
+
const GALLERY_FILE = /\.(png|jpe?g|webp|html?)$/i;
|
|
52
|
+
/** The report kit's blocks, the files a block may name, and the words a status may take. */
|
|
53
|
+
export const KIT_BLOCKS = ['callout', 'beats', 'video', 'table', 'compare', 'evidence'];
|
|
54
|
+
const KIT_IMAGE = /\.(png|jpe?g|webp)$/i, KIT_VIDEO = /\.(mp4|webm)$/i;
|
|
55
|
+
export const KIT_STATUS = { fixed: 'Fixed', progress: 'In progress', waiting: 'Waiting on you' };
|
|
56
|
+
const KIT_TONES = ['note', 'warn', 'ask'];
|
|
49
57
|
export const MEDIA_BYTES = 50_000_000;
|
|
50
58
|
/** The command that makes a report's page, named in every refusal of a report without one. */
|
|
51
59
|
export const REPORT_INIT = 'sign-off-axi report init <folder>';
|
|
@@ -230,6 +238,119 @@ export function checkMessage(m, want, flags = false, fresh = false) {
|
|
|
230
238
|
if (/^([\\/]|[A-Za-z]:)/.test(o.src) || o.src.split(/[\\/]/).includes('..') || /[?#\\]/.test(o.src) || !/\.html?$/i.test(o.src))
|
|
231
239
|
throw bad(`${field}[${n}].src`, `${show(o.src)} must be a relative .html path with no .., ?, # or \\`, 'Draw the diagram as inline SVG in an .html page (by hand or with the archify skill), then set "src" to that file next to the message');
|
|
232
240
|
});
|
|
241
|
+
/** A gallery: 1 to 6 options of a design output, each with its model, the time it ran and 1 or 2 frames (a phone's, a desktop's), every frame an image or page with the device it shows and a caption. Post pins each frame's file like a diagram's. */
|
|
242
|
+
const gallery = (field, v) => list(field, 1, 6, ['label', 'model', 'ran', 'frames'], '{"label":"Option A","model":"Haiku 5.5","ran":"2026-10-24T09:30:00Z","frames":[{"device":"phone","src":"gallery/a-phone.png","caption":"Sign-in on a phone"}]}', v).forEach((o, n) => {
|
|
243
|
+
const at = `${field}[${n}]`;
|
|
244
|
+
text(`${at}.label`, o.label, 40, 'the option\'s name, such as "Option A", in 40 characters or fewer');
|
|
245
|
+
text(`${at}.model`, o.model, 40, 'the model that made it, such as "Haiku 5.5", in 40 characters or fewer');
|
|
246
|
+
const ran = typeof o.ran === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(o.ran) ? new Date(o.ran) : undefined;
|
|
247
|
+
if (!ran || Number.isNaN(ran.getTime()) || ran.toISOString().replace('.000', '') !== o.ran)
|
|
248
|
+
throw bad(`${at}.ran`, Object.hasOwn(o, 'ran') ? `${show(o.ran)} is not a UTC time such as 2026-10-13T07:19:00Z` : 'is required', 'Set "ran" to when the model made it, exactly YYYY-MM-DDTHH:MM:SSZ in UTC');
|
|
249
|
+
list(`${at}.frames`, 1, 2, ['device', 'src', 'caption', 'sha256', 'bytes'], '{"device":"phone","src":"gallery/a-phone.png","caption":"Sign-in on a phone"}', o.frames).forEach((f, k) => {
|
|
250
|
+
const fa = `${at}.frames[${k}]`, DEVICE = 'Set "device" to "phone" (390 wide) or "desktop" (1280 wide)';
|
|
251
|
+
if (!Object.hasOwn(f, 'device'))
|
|
252
|
+
throw bad(`${fa}.device`, 'is required: the screen the frame shows', DEVICE);
|
|
253
|
+
if (!GALLERY_DEVICES.includes(f.device))
|
|
254
|
+
throw bad(`${fa}.device`, `${show(f.device)} is not one of ${GALLERY_DEVICES.join(', ')}`, DEVICE);
|
|
255
|
+
if (o.frames.slice(0, k).some((p) => p.device === f.device))
|
|
256
|
+
throw bad(`${fa}.device`, `repeats ${f.device}`, 'Give an option one phone frame and one desktop frame at most');
|
|
257
|
+
text(`${fa}.caption`, f.caption, 120, 'what the frame shows, in 120 characters or fewer');
|
|
258
|
+
text(`${fa}.src`, f.src, 256, 'an image or page path from the board file\'s folder, such as "gallery/a-phone.png"');
|
|
259
|
+
if (/^([\\/]|[A-Za-z]:)/.test(f.src) || f.src.split(/[\\/]/).includes('..') || /[?#\\]/.test(f.src) || !GALLERY_FILE.test(f.src))
|
|
260
|
+
throw bad(`${fa}.src`, `${show(f.src)} must be a relative .png, .jpg, .jpeg, .webp or .html path with no .., ?, # or \\`, 'Set "src" to the frame\'s file next to the message, such as "gallery/a-phone.png"');
|
|
261
|
+
if (Object.hasOwn(f, 'sha256') && !/^[a-f0-9]{64}$/.test(f.sha256))
|
|
262
|
+
throw bad(`${fa}.sha256`, 'must be 64 lowercase hex digits', 'Leave "sha256" and "bytes" out; post pins the file');
|
|
263
|
+
if (Object.hasOwn(f, 'bytes') && !(Number.isInteger(f.bytes) && f.bytes >= 0))
|
|
264
|
+
throw bad(`${fa}.bytes`, 'must be a whole number of bytes', 'Leave "sha256" and "bytes" out; post pins the file');
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
/** A kit block's file: a path from the board file's folder to an image (or, for a video, a .mp4 or .webm), pinned by post like a gallery frame. */
|
|
268
|
+
const kitFile = (field, v, re, kinds, example) => {
|
|
269
|
+
text(field, v, 256, `${kinds} path from the board file's folder, such as "${example}"`);
|
|
270
|
+
if (/^([\\/]|[A-Za-z]:)/.test(v) || v.split(/[\\/]/).includes('..') || /[?#\\]/.test(v) || !re.test(v))
|
|
271
|
+
throw bad(field, `${show(v)} must be a relative ${kinds} path with no .., ?, # or \\`, `Set "${field.replace(/^.*\./, '')}" to the file next to the message, such as "${example}"`);
|
|
272
|
+
};
|
|
273
|
+
/** The report kit: 1 to 12 blocks a section draws in the board's own style, in the order given. A block is data (words, files, rows); it never carries markup or colour. */
|
|
274
|
+
const blocks = (field, v) => {
|
|
275
|
+
if (!Array.isArray(v) || v.length < 1 || v.length > 12)
|
|
276
|
+
throw bad(field, 'must list 1 to 12 blocks', `Set "${field}" to blocks such as {"type":"callout","title":"Read this first","text":"..."}; the types are ${KIT_BLOCKS.join(', ')}`);
|
|
277
|
+
v.forEach((o, n) => {
|
|
278
|
+
const at = `${field}[${n}]`;
|
|
279
|
+
if (!o || typeof o !== 'object' || Array.isArray(o))
|
|
280
|
+
throw bad(at, 'must be an object', `Write each block as {"type":"callout",...}; the types are ${KIT_BLOCKS.join(', ')}`);
|
|
281
|
+
if (!KIT_BLOCKS.includes(o.type))
|
|
282
|
+
throw bad(`${at}.type`, `${show(o.type)} is not one of ${KIT_BLOCKS.join(', ')}`, `Set "type" to ${KIT_BLOCKS.join(', ')}; a gallery is the section's "gallery", not a block`);
|
|
283
|
+
const only = (fields, example) => one(at, o, ['type', ...fields], example);
|
|
284
|
+
if (o.type === 'callout') {
|
|
285
|
+
only(['tone', 'title', 'text'], '{"type":"callout","tone":"warn","title":"Not measured","text":"How much of the plan\'s window this took."}');
|
|
286
|
+
if (Object.hasOwn(o, 'tone') && !KIT_TONES.includes(o.tone))
|
|
287
|
+
throw bad(`${at}.tone`, `${show(o.tone)} is not one of ${KIT_TONES.join(', ')}`, `Set "tone" to ${KIT_TONES.join(', ')}, or leave it out`);
|
|
288
|
+
text(`${at}.title`, o.title, 60, 'the callout\'s bold first line, in 60 characters or fewer');
|
|
289
|
+
text(`${at}.text`, o.text, 280, 'the callout\'s words, in 280 characters or fewer');
|
|
290
|
+
}
|
|
291
|
+
else if (o.type === 'beats') {
|
|
292
|
+
only(['beats'], '{"type":"beats","beats":[{"image":"beats/1.png","time":"0:00","title":"The pain","text":"..."}]}');
|
|
293
|
+
list(`${at}.beats`, 2, 12, ['image', 'time', 'title', 'text'], '{"image":"beats/1.png","time":"0:00","title":"The pain","text":"One line."}', o.beats).forEach((b, k) => {
|
|
294
|
+
kitFile(`${at}.beats[${k}].image`, b.image, KIT_IMAGE, 'a .png, .jpg, .jpeg or .webp', 'beats/1.png');
|
|
295
|
+
if (Object.hasOwn(b, 'time'))
|
|
296
|
+
text(`${at}.beats[${k}].time`, b.time, 12, 'the beat\'s time, such as "0:11", in 12 characters or fewer');
|
|
297
|
+
text(`${at}.beats[${k}].title`, b.title, 80, 'the beat\'s title, in 80 characters or fewer');
|
|
298
|
+
text(`${at}.beats[${k}].text`, b.text, 280, 'the beat\'s words, in 280 characters or fewer');
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
else if (o.type === 'video') {
|
|
302
|
+
only(['src', 'poster', 'length', 'caption'], '{"type":"video","src":"film.mp4","poster":"film.jpg","length":"0:10","caption":"The promo, 10 seconds."}');
|
|
303
|
+
kitFile(`${at}.src`, o.src, KIT_VIDEO, 'a .mp4 or .webm', 'film.mp4');
|
|
304
|
+
kitFile(`${at}.poster`, o.poster, KIT_IMAGE, 'a .png, .jpg, .jpeg or .webp', 'film.jpg');
|
|
305
|
+
if (Object.hasOwn(o, 'length'))
|
|
306
|
+
text(`${at}.length`, o.length, 12, 'the film\'s length, such as "0:10", in 12 characters or fewer');
|
|
307
|
+
text(`${at}.caption`, o.caption, 280, 'what the film shows, in 280 characters or fewer');
|
|
308
|
+
}
|
|
309
|
+
else if (o.type === 'table') {
|
|
310
|
+
only(['columns', 'rows'], '{"type":"table","columns":["Measured","App promo","Cinematic"],"rows":[["AI calls","3","1"]]}');
|
|
311
|
+
if (!Array.isArray(o.columns) || o.columns.length < 2 || o.columns.length > 5)
|
|
312
|
+
throw bad(`${at}.columns`, 'must list 2 to 5 column names', 'Set "columns" to 2-5 short names, such as ["Measured","App promo","Cinematic"]');
|
|
313
|
+
o.columns.forEach((c, k) => text(`${at}.columns[${k}]`, c, 40, 'the column\'s name, in 40 characters or fewer'));
|
|
314
|
+
if (!Array.isArray(o.rows) || o.rows.length < 1 || o.rows.length > 30)
|
|
315
|
+
throw bad(`${at}.rows`, 'must list 1 to 30 rows', 'Set "rows" to arrays of text, one per column');
|
|
316
|
+
o.rows.forEach((r, k) => {
|
|
317
|
+
if (!Array.isArray(r) || r.length !== o.columns.length)
|
|
318
|
+
throw bad(`${at}.rows[${k}]`, `must have ${o.columns.length} cells, one per column`, 'Write each row as an array of text with one cell for every column');
|
|
319
|
+
r.forEach((c, j) => text(`${at}.rows[${k}][${j}]`, c, 120, 'the cell\'s words, in 120 characters or fewer'));
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
else if (o.type === 'compare') {
|
|
323
|
+
only(['before', 'after'], '{"type":"compare","before":{"image":"old.png","caption":"As filed"},"after":{"image":"new.png","caption":"In the kit"}}');
|
|
324
|
+
for (const side of ['before', 'after']) {
|
|
325
|
+
if (!Object.hasOwn(o, side))
|
|
326
|
+
throw bad(`${at}.${side}`, 'is required', `Add "${side}": {"image":"${side}.png","caption":"what it shows"}`);
|
|
327
|
+
one(`${at}.${side}`, o[side], ['image', 'caption'], `{"image":"${side}.png","caption":"what it shows"}`);
|
|
328
|
+
kitFile(`${at}.${side}.image`, o[side].image, KIT_IMAGE, 'a .png, .jpg, .jpeg or .webp', `${side}.png`);
|
|
329
|
+
text(`${at}.${side}.caption`, o[side].caption, 80, 'what the frame shows, in 80 characters or fewer');
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
only(['rows'], '{"type":"evidence","rows":[{"image":"a.png","title":"Line wrapped","note":"Version 1","status":"fixed"}]}');
|
|
334
|
+
list(`${at}.rows`, 1, 12, ['image', 'title', 'note', 'status'], '{"image":"a.png","title":"Line wrapped","note":"Version 1","status":"fixed"}', o.rows).forEach((r, k) => {
|
|
335
|
+
if (Object.hasOwn(r, 'image'))
|
|
336
|
+
kitFile(`${at}.rows[${k}].image`, r.image, KIT_IMAGE, 'a .png, .jpg, .jpeg or .webp', 'a.png');
|
|
337
|
+
text(`${at}.rows[${k}].title`, r.title, 80, 'what the capture shows, in 80 characters or fewer');
|
|
338
|
+
if (Object.hasOwn(r, 'note'))
|
|
339
|
+
text(`${at}.rows[${k}].note`, r.note, 80, 'a second line, in 80 characters or fewer');
|
|
340
|
+
if (!Object.hasOwn(r, 'status') || !(r.status in KIT_STATUS))
|
|
341
|
+
throw bad(`${at}.rows[${k}].status`, Object.hasOwn(r, 'status') ? `${show(r.status)} is not one of ${Object.keys(KIT_STATUS).join(', ')}` : 'is required', `Set "status" to ${Object.keys(KIT_STATUS).join(', ')}`);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
};
|
|
346
|
+
/** The files a section's blocks name, pinned like gallery frames: each path once, with the hash and size post adds. */
|
|
347
|
+
const pinList = (field, v) => list(field, 1, 60, ['src', 'sha256', 'bytes'], '{"src":"beats/1.png"}', v).forEach((o, n) => {
|
|
348
|
+
text(`${field}[${n}].src`, o.src, 256, 'a path from the board file\'s folder');
|
|
349
|
+
if (Object.hasOwn(o, 'sha256') && !/^[a-f0-9]{64}$/.test(o.sha256))
|
|
350
|
+
throw bad(`${field}[${n}].sha256`, 'must be 64 lowercase hex digits', 'Leave "pins" out; post pins the files');
|
|
351
|
+
if (Object.hasOwn(o, 'bytes') && !(Number.isInteger(o.bytes) && o.bytes >= 0))
|
|
352
|
+
throw bad(`${field}[${n}].bytes`, 'must be a whole number of bytes', 'Leave "pins" out; post pins the files');
|
|
353
|
+
});
|
|
233
354
|
/** A report 5's parts: each part of its kind once, in a section naming it, with what that part needs (a source's link, a finding's confidence, a slice's size, a design's diagram). */
|
|
234
355
|
const kindParts = (parts) => {
|
|
235
356
|
const init = `Run \`${REPORT_INIT.replace('<folder>', '<new folder>')} --purpose ${m.purpose}\` for a template with every part, or add the section`;
|
|
@@ -302,7 +423,7 @@ export function checkMessage(m, want, flags = false, fresh = false) {
|
|
|
302
423
|
diagrams('visual.diagram', v.diagram);
|
|
303
424
|
// A report 5 is built of its kind's parts: up to 10 sections, each naming the part it is, and items that carry a source's link, a finding's confidence or a slice's size.
|
|
304
425
|
const v5 = m.kind === 'report' && m.schema >= 5, parts = v5 ? REPORT_PARTS[m.purpose] ?? [] : [];
|
|
305
|
-
const ids = list('sections', minSections, v5 ? 10 : 6, ['id', 'stage', 'heading', 'body', 'items', 'details', 'diagram', ...(v5 ? ['part'] : [])], '{"id":"steps","stage":"recommend","heading":"Eight steps, 22 to 31 Oct","body":"..."}').map((o, n) => {
|
|
426
|
+
const ids = list('sections', minSections, v5 ? 10 : 6, ['id', 'stage', 'heading', 'body', 'items', 'details', 'diagram', 'gallery', 'blocks', 'pins', ...(v5 ? ['part'] : [])], '{"id":"steps","stage":"recommend","heading":"Eight steps, 22 to 31 Oct","body":"..."}').map((o, n) => {
|
|
306
427
|
const at = `sections[${n}]`;
|
|
307
428
|
if (!(typeof o.id === 'string' && /^[a-z0-9-]{1,40}$/.test(o.id)))
|
|
308
429
|
throw bad(`${at}.id`, 'must be 1-40 of a-z 0-9 -', 'Set "id" to a short slug such as "steps"');
|
|
@@ -335,6 +456,12 @@ export function checkMessage(m, want, flags = false, fresh = false) {
|
|
|
335
456
|
details(`${at}.details`, o.details);
|
|
336
457
|
if (Object.hasOwn(o, 'diagram'))
|
|
337
458
|
diagrams(`${at}.diagram`, o.diagram);
|
|
459
|
+
if (Object.hasOwn(o, 'gallery'))
|
|
460
|
+
gallery(`${at}.gallery`, o.gallery);
|
|
461
|
+
if (Object.hasOwn(o, 'blocks'))
|
|
462
|
+
blocks(`${at}.blocks`, o.blocks);
|
|
463
|
+
if (Object.hasOwn(o, 'pins'))
|
|
464
|
+
pinList(`${at}.pins`, o.pins);
|
|
338
465
|
return o.id;
|
|
339
466
|
});
|
|
340
467
|
const twice = ids.findIndex((id, n) => ids.indexOf(id) < n);
|
|
@@ -399,8 +526,8 @@ export function checkMessage(m, want, flags = false, fresh = false) {
|
|
|
399
526
|
text('about', m.about, 500, `${what}, in 500 characters or fewer`);
|
|
400
527
|
}
|
|
401
528
|
if (m.kind === 'evidence') {
|
|
402
|
-
if (!Object.hasOwn(m, 'media') && !Object.hasOwn(m, 'run'))
|
|
403
|
-
throw bad('media', 'or run is required', 'Add "media": ["shot.png"], files next to the message, or "run": the done-done run it proves');
|
|
529
|
+
if (!Object.hasOwn(m, 'media') && !Object.hasOwn(m, 'run') && !Object.hasOwn(m, 'status'))
|
|
530
|
+
throw bad('media', 'or run is required', 'Add "media": ["shot.png"], files next to the message, or "run": the done-done run it proves, or a "status" to post the card with its frame pending');
|
|
404
531
|
if (Object.hasOwn(m, 'media')) {
|
|
405
532
|
if (!Array.isArray(m.media) || !m.media.length || m.media.length > MEDIA_MAX)
|
|
406
533
|
throw bad('media', `must list 1 to ${MEDIA_MAX} files`, 'Set "media" to file paths next to the message, such as ["shot.png"]');
|
|
@@ -412,6 +539,13 @@ export function checkMessage(m, want, flags = false, fresh = false) {
|
|
|
412
539
|
throw bad(`media[${n}]`, `${show(p)} must end in .png, .jpg, .jpeg, .webp, .mp4 or .webm`, 'Post a screenshot or recording in one of those formats');
|
|
413
540
|
});
|
|
414
541
|
}
|
|
542
|
+
if (Object.hasOwn(m, 'status') && !EVIDENCE_STATUSES.includes(m.status))
|
|
543
|
+
throw bad('status', `${show(m.status)} is not one of ${EVIDENCE_STATUSES.join(', ')}`, `Set "status" to where the work stands: ${EVIDENCE_STATUSES.join(', ')}, or leave it out`);
|
|
544
|
+
if (Object.hasOwn(m, 'captions')) {
|
|
545
|
+
if (!Array.isArray(m.captions) || !Array.isArray(m.media) || m.captions.length !== m.media.length)
|
|
546
|
+
throw bad('captions', 'must list one caption for each file in "media"', 'Set "captions" to one short caption per capture, such as ["Before", "After"], or leave it out');
|
|
547
|
+
m.captions.forEach((c, n) => text(`captions[${n}]`, c, CAPTION_MAX, 'a caption such as "Before, phone", in 40 characters or fewer'));
|
|
548
|
+
}
|
|
415
549
|
if (Object.hasOwn(m, 'run'))
|
|
416
550
|
checkRun(RUN);
|
|
417
551
|
return { kind: m.kind, schema: m.schema };
|
|
@@ -817,6 +951,21 @@ export function mediaFiles(m, base) {
|
|
|
817
951
|
}
|
|
818
952
|
/** The diagram views of a guided plan or report, in the order the board counts them for `diagram.<n>`: the picture's, then each section's and its items'. */
|
|
819
953
|
export const diagramViews = (m) => [m?.visual?.diagram, ...(m?.sections ?? []).flatMap((s) => [s.diagram, ...(s.items ?? []).map((i) => i.diagram)])].filter(Boolean).flat();
|
|
954
|
+
/** The gallery frames of a guided report or plan, in the order the board counts them for `gallery.<n>`: each section's options, each option's frames. */
|
|
955
|
+
export const galleryFrames = (m) => [...(m?.sections ?? []).flatMap((s) => (s.gallery ?? []).flatMap((o) => o.frames)), ...(m?.sections ?? []).flatMap((s) => s.pins ?? [])];
|
|
956
|
+
/** The files a section's blocks name, each once, in the order the blocks use them. */
|
|
957
|
+
export const blockFiles = (s) => [...new Set((s?.blocks ?? []).flatMap((b) => b.type === 'beats' ? b.beats.map((x) => x.image) : b.type === 'video' ? [b.poster, b.src] : b.type === 'compare' ? [b.before.image, b.after.image] : b.type === 'evidence' ? b.rows.map((r) => r.image).filter(Boolean) : []))];
|
|
958
|
+
const SAME_WORDS = 0.75;
|
|
959
|
+
/** What the kit warns about, never refuses: a lead whose first sentence says again (most of its words are in) what the report's summary already said, so the reader reads it twice. */
|
|
960
|
+
export function kitWarnings(m) {
|
|
961
|
+
if (!guided(m))
|
|
962
|
+
return [];
|
|
963
|
+
const words = (t) => new Set(String(t ?? '').toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean));
|
|
964
|
+
const first = (t) => String(t ?? '').split(/(?<=[.!?])\s/)[0];
|
|
965
|
+
const same = (a, b) => { const x = words(first(a)), y = words(first(b)); return x.size >= 5 && y.size >= 5 && [...x].filter((w) => y.has(w)).length / Math.min(x.size, y.size) >= SAME_WORDS; };
|
|
966
|
+
const leads = [['visual.text', m.visual?.text], ...(m.sections ?? []).slice(0, 1).map((s, n) => [`sections[${n}].body`, s.body])].filter(([, t]) => t);
|
|
967
|
+
return leads.filter(([, t]) => same(m.about, t)).map(([f]) => `${f} says again what "about" already says; the reader reads it twice. Keep "about" as the summary and start ${f} with what it does not say`);
|
|
968
|
+
}
|
|
820
969
|
/** The text size a diagram must keep on a phone: its smallest text, drawn as wide as Full size draws it on a 390 px screen (the chrome-free page, 8 px of margin each side). */
|
|
821
970
|
const DIAGRAM_MIN_PX = 12;
|
|
822
971
|
const PHONE_PX = 390, PHONE_DRAW_PX = PHONE_PX - 16;
|
|
@@ -863,6 +1012,41 @@ export function diagramPages(m, base) {
|
|
|
863
1012
|
v.sha256 = createHash('sha256').update(file).digest('hex');
|
|
864
1013
|
v.bytes = file.length;
|
|
865
1014
|
}
|
|
1015
|
+
// The files a section's blocks name are pinned as a section's "pins", each path once; they are checked and pinned below with the gallery frames.
|
|
1016
|
+
for (const sec of m.sections ?? []) {
|
|
1017
|
+
const files = blockFiles(sec);
|
|
1018
|
+
if (files.length)
|
|
1019
|
+
sec.pins = files.map((src) => ({ src }));
|
|
1020
|
+
else
|
|
1021
|
+
delete sec.pins;
|
|
1022
|
+
}
|
|
1023
|
+
// A gallery frame or block file is an image or a page of 5 MB or less (a video, 50 MB), inside the folder; it is pinned the same way.
|
|
1024
|
+
for (const f of galleryFrames(m)) {
|
|
1025
|
+
const bad = (rule, help) => new ContractError(`${at}gallery frame ${show(f.src)} ${rule}`, help);
|
|
1026
|
+
let real;
|
|
1027
|
+
try {
|
|
1028
|
+
real = realpathSync(resolve(root, f.src));
|
|
1029
|
+
}
|
|
1030
|
+
catch {
|
|
1031
|
+
throw bad('is not there', `Put the file at ${f.src} from the board file's folder, or fix the path`);
|
|
1032
|
+
}
|
|
1033
|
+
const rel = relative(root, real);
|
|
1034
|
+
if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel))
|
|
1035
|
+
throw bad('leads outside the board file\'s folder', 'Copy the file into the board file\'s folder, such as under gallery/');
|
|
1036
|
+
const info = statSync(real);
|
|
1037
|
+
const video = KIT_VIDEO.test(f.src);
|
|
1038
|
+
if (!info.isFile() || info.size > (video ? MEDIA_BYTES : 5_000_000))
|
|
1039
|
+
throw bad(video ? 'must be a file of 50 MB or less' : 'must be a file of 5 MB or less', video ? 'Render the film smaller' : 'Save the screenshot again at the device\'s own width (390 or 1280)');
|
|
1040
|
+
try {
|
|
1041
|
+
accessSync(real, constants.R_OK);
|
|
1042
|
+
}
|
|
1043
|
+
catch {
|
|
1044
|
+
throw bad('cannot be read', 'Make the file readable, then post again');
|
|
1045
|
+
}
|
|
1046
|
+
const file = readFileSync(real);
|
|
1047
|
+
f.sha256 = createHash('sha256').update(file).digest('hex');
|
|
1048
|
+
f.bytes = file.length;
|
|
1049
|
+
}
|
|
866
1050
|
}
|
|
867
1051
|
/** The longest a block of plain text on a report page may run: about five lines on a phone. */
|
|
868
1052
|
const BLOCK_CHARS = 240;
|
package/dist/store.js
CHANGED
|
@@ -21,6 +21,10 @@ export const SIGNED_IN = { value: 'done', label: 'Signed in' };
|
|
|
21
21
|
export const REPLY = { value: 'text', label: 'Reply' };
|
|
22
22
|
const VERDICTS = [{ value: 'pass', label: 'Pass' }, { value: 'fail', label: 'Fail' }, { value: 'needs-work', label: 'Needs work' }];
|
|
23
23
|
export const MEDIA_MAX = 12;
|
|
24
|
+
/** Where a concern stands, as an evidence update says it in plain words. */
|
|
25
|
+
export const EVIDENCE_STATUSES = ['Fixed', 'In progress', 'Waiting on you'];
|
|
26
|
+
/** A capture's caption, such as "Before, phone": 40 characters or fewer. */
|
|
27
|
+
export const CAPTION_MAX = 40;
|
|
24
28
|
export const ATTACH_MAX = 5;
|
|
25
29
|
export const ATTACH_DEFAULT_MB = 1024;
|
|
26
30
|
/** A download name: letters, digits and . _ ( ) + - or a space, never a path. */
|
|
@@ -138,7 +142,7 @@ export function loadDecisions(path, board) {
|
|
|
138
142
|
&& /^[a-f0-9]{64}$/.test(a.sha256 ?? '') && Number.isInteger(a.bytes) && a.bytes >= 0))) {
|
|
139
143
|
throw bad('attachments', `must list 1 to ${ATTACH_MAX} files on an evidence update, each under evidence/<number>/ with its name, sha256 and bytes`);
|
|
140
144
|
}
|
|
141
|
-
if (stored && !(d.type === 'evidence' && d.media === undefined && (d.evidence || d.attachments))) {
|
|
145
|
+
if (stored && !(d.type === 'evidence' && d.media === undefined && (d.evidence || d.attachments || d.status))) {
|
|
142
146
|
const [max, kinds, ext] = stored;
|
|
143
147
|
if (!Array.isArray(d.media) || !d.media.length || d.media.length > max || !d.media.every((m) => isText(m?.path)
|
|
144
148
|
&& /^evidence\/\d+\/\w[\w-]*\.\w+$/.test(m.path) && ext.test(m.path) && /^[a-f0-9]{64}$/.test(m.sha256 ?? '') && Number.isInteger(m.bytes) && m.bytes >= 0)) {
|
|
@@ -150,6 +154,12 @@ export function loadDecisions(path, board) {
|
|
|
150
154
|
throw bad('media', 'light, thumb and tile copies must sit beside their file as <n>-light, <n>-thumb or <n>-tile .jpg or .png, each with its sha256, bytes, width and height');
|
|
151
155
|
}
|
|
152
156
|
}
|
|
157
|
+
if (d.type === 'evidence' && d.status !== undefined && !EVIDENCE_STATUSES.includes(d.status))
|
|
158
|
+
throw bad('status', `must be one of ${EVIDENCE_STATUSES.join(', ')}`);
|
|
159
|
+
if (d.captions !== undefined && (d.type !== 'evidence' || !Array.isArray(d.media) || !Array.isArray(d.captions) || d.captions.length !== d.media.length
|
|
160
|
+
|| !d.captions.every((c) => isText(c) && c.trim() && [...c].length <= CAPTION_MAX))) {
|
|
161
|
+
throw bad('captions', `must list one caption of ${CAPTION_MAX} characters or fewer for each capture of an evidence update`);
|
|
162
|
+
}
|
|
153
163
|
if (d.type === 'explainer' && d.chapters !== undefined && (!Array.isArray(d.chapters) || !d.chapters.length || d.chapters.length > CHAPTERS_MAX
|
|
154
164
|
|| !d.chapters.every((c, n) => Number.isInteger(c?.at) && c.at >= 0 && (!n || c.at > d.chapters[n - 1].at) && isText(c.title) && c.title.trim() && c.title.length <= 120))) {
|
|
155
165
|
throw bad('chapters', `must list 1 to ${CHAPTERS_MAX} items, each a start in whole seconds after the one before and a title of 120 characters or fewer`);
|
|
@@ -273,7 +283,7 @@ export function loadDecisions(path, board) {
|
|
|
273
283
|
...(d.steps && { steps: d.steps.map(({ title, marks, text }) => ({ title, ...(text && { text }), ...(marks?.length && { marks }) })) }),
|
|
274
284
|
...(stored && d.media && { media: d.media.map(({ path, sha256, bytes, light, thumb, tile }) => ({ path, sha256, bytes, ...(light && { light: copyRef(light) }), ...(thumb && { thumb: copyRef(thumb) }), ...(tile && { tile: copyRef(tile) }) })) }),
|
|
275
285
|
...(d.attachments && { attachments: d.attachments.map(({ path, name, sha256, bytes }) => ({ path, name, sha256, bytes })) }),
|
|
276
|
-
...(d.type === 'evidence' && { number: d.number }),
|
|
286
|
+
...(d.type === 'evidence' && { number: d.number, ...(d.status && { status: d.status }), ...(d.captions && { captions: d.captions }) }),
|
|
277
287
|
// A gate's run is part of its question: the same key with another run is a different question.
|
|
278
288
|
// A report keeps its kind, so an ask can name it (--report) and the board can find it.
|
|
279
289
|
...(report && { kind: 'report' }),
|