sign-off-axi 0.6.3 → 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 +22 -2
- package/dist/cli.js +124 -31
- package/dist/contract.js +115 -23
- package/dist/images.js +27 -8
- package/dist/store.js +32 -14
- 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 +622 -54
- package/web/ui.css +12 -0
package/README.md
CHANGED
|
@@ -105,6 +105,26 @@ Agents pass `--priority` when they know it, and set their project's icon when it
|
|
|
105
105
|
|
|
106
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.
|
|
107
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
|
+
|
|
108
128
|
## What you can ask
|
|
109
129
|
|
|
110
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).
|
|
@@ -113,11 +133,11 @@ One line on what the agent runs per kind; the full reference is [docs/question-k
|
|
|
113
133
|
|---|---|---|
|
|
114
134
|
| Yes or no, pick one | `raise --ask-ref <captain-message-id-or-hold-id> --title ... --recommend ...` | buttons, the recommended one first |
|
|
115
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 |
|
|
116
|
-
| 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 |
|
|
117
137
|
| Proof update | `post --type evidence --media shot.png` | the capture, its result, Pass / Fail / Needs work |
|
|
118
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) |
|
|
119
139
|
| Explainer video | `post --type explainer --media clip.mp4 --chapter 0:00=Start` | a player with chapter buttons, then Yes or No |
|
|
120
|
-
| 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 |
|
|
121
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 |
|
|
122
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 |
|
|
123
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,7 +361,7 @@ 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:
|
|
@@ -493,7 +502,7 @@ function fits(title, key) {
|
|
|
493
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`);
|
|
494
503
|
}
|
|
495
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. */
|
|
496
|
-
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'];
|
|
497
506
|
const MANY = ['option', 'outcome', 'merge'];
|
|
498
507
|
/** Board types that are contract kinds: a decision (the default type) is an ask. */
|
|
499
508
|
const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report', 'plan', 'design'];
|
|
@@ -554,7 +563,7 @@ function contractItems(items, from, file, replace = false) {
|
|
|
554
563
|
});
|
|
555
564
|
}
|
|
556
565
|
function designFiles(m, base) {
|
|
557
|
-
const paths = [m.preview, ...m.journeys.flatMap((j) => j.screens.flatMap(
|
|
566
|
+
const paths = [m.preview, ...m.journeys.flatMap((j) => j.screens.flatMap(screenViews))];
|
|
558
567
|
for (const path of paths) {
|
|
559
568
|
let actual;
|
|
560
569
|
try {
|
|
@@ -576,6 +585,19 @@ function designFiles(m, base) {
|
|
|
576
585
|
throw new ContractError(`design path ${JSON.stringify(path)} cannot be read`, 'Make the design view readable');
|
|
577
586
|
}
|
|
578
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
|
+
});
|
|
579
601
|
}
|
|
580
602
|
const RAISE_FIELDS = ['title', 'key', 'about', 'detail', 'option', 'recommend', 'default', 'close', ...ASK_FLAGS];
|
|
581
603
|
/** Parses one command's flags strictly; `on` carries a given board file into the next-step hints. */
|
|
@@ -724,7 +746,7 @@ function printAnswers(file, rows, waiting, values, help) {
|
|
|
724
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 }] : []));
|
|
725
747
|
if (values.json) {
|
|
726
748
|
const asked = (key) => rounds.filter((r) => r.key === key).map(({ note, at, comments }) => ({ note, at, comments }));
|
|
727
|
-
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) }) }]));
|
|
728
750
|
for (const r of rounds)
|
|
729
751
|
if (!out[r.key])
|
|
730
752
|
out[r.key] = { rounds: asked(r.key) };
|
|
@@ -732,7 +754,7 @@ function printAnswers(file, rows, waiting, values, help) {
|
|
|
732
754
|
out[key] = { ...out[key], discarded: { reason, label, at } };
|
|
733
755
|
return JSON.stringify(out);
|
|
734
756
|
}
|
|
735
|
-
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 })));
|
|
736
758
|
const plan = done.flatMap(({ key, notes: sections = [], decisions: picks = {} }) => [
|
|
737
759
|
...sections.map((n) => ({ key, section: n.section ?? `${n.journey}/${n.screen}`, verdict: n.verdict ?? 'change', note: n.note ?? '' })),
|
|
738
760
|
...Object.entries(picks).map(([decision, pick]) => ({ key, decision, pick })),
|
|
@@ -740,7 +762,8 @@ function printAnswers(file, rows, waiting, values, help) {
|
|
|
740
762
|
return toon({
|
|
741
763
|
board: tilde(file),
|
|
742
764
|
// A changed answer names the one it replaces, which the agent may already have acted on.
|
|
743
|
-
|
|
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',
|
|
744
767
|
...(rounds.length && { rounds: rounds.map(({ key, note, at }) => ({ key, note, at })) }),
|
|
745
768
|
// Comments on mock elements that went with an answer or a round, each with the element it points at.
|
|
746
769
|
...(sent.length && { comments: sent }),
|
|
@@ -748,7 +771,7 @@ function printAnswers(file, rows, waiting, values, help) {
|
|
|
748
771
|
...(plan.length && { plan }),
|
|
749
772
|
...(gone.length && { discarded: gone.map(({ key, label, at }) => ({ key, reason: label, at })) }),
|
|
750
773
|
waiting,
|
|
751
|
-
}, 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]);
|
|
752
775
|
}
|
|
753
776
|
function answers(args) {
|
|
754
777
|
const { file, values, on } = parse('answers', args, { ...TIERS, key: { type: 'string' } });
|
|
@@ -771,7 +794,7 @@ function eventLines(file, rows, answers, rounds) {
|
|
|
771
794
|
const kind = kindOf(file), item = new Map(rows.map((d) => [d.key, d]));
|
|
772
795
|
const head = (event, key) => ({ event, ...kind(key), key, title: item.get(key)?.title });
|
|
773
796
|
const tail = (key, comments) => ({ ...(item.get(key)?.filer && { filer: item.get(key).filer }), ...(comments?.length && { comments }) });
|
|
774
|
-
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) } })),
|
|
775
798
|
...rounds.map(({ key, note, at, comments }) => ({ at, line: { ...head('round', key), note, at, ...tail(key, comments) } }))]
|
|
776
799
|
.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)).map(({ line }) => JSON.stringify(line)).join('\n');
|
|
777
800
|
}
|
|
@@ -1770,7 +1793,7 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1770
1793
|
writeFileSync(join(dir, `${i + 1}${extname(src).toLowerCase()}`), bytes, { mode: 0o600 });
|
|
1771
1794
|
// The original stays as it was posted; the board shows a lighter copy of a still where one weighs less.
|
|
1772
1795
|
const made = isStill(src) ? copiesOf(bytes, extname(src)) : {}, refs = {};
|
|
1773
|
-
for (const size of ['light', 'thumb']) {
|
|
1796
|
+
for (const size of ['light', 'thumb', 'tile']) {
|
|
1774
1797
|
const c = made[size];
|
|
1775
1798
|
if (!c)
|
|
1776
1799
|
continue;
|
|
@@ -1863,8 +1886,9 @@ async function postKind(file, named, on, values, built) {
|
|
|
1863
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}`);
|
|
1864
1887
|
m[f] = values[f];
|
|
1865
1888
|
}
|
|
1866
|
-
|
|
1867
|
-
|
|
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);
|
|
1868
1892
|
// Media paths read from the message file's folder, or from the current folder for stdin.
|
|
1869
1893
|
if (m.kind === 'evidence')
|
|
1870
1894
|
sources = mediaFiles(m, from === '-' ? process.cwd() : dirname(resolve(from)));
|
|
@@ -1998,7 +2022,7 @@ function buildIncoming(values, extra, filer, file) {
|
|
|
1998
2022
|
try {
|
|
1999
2023
|
const { title, key = slug(title), about, detail, why, recommend: recommend_value, default: default_if_silent, close } = values;
|
|
2000
2024
|
const ask = !Object.hasOwn(extra, 'type'), options = ask ? outcomesOf(values) : optionsOf(values);
|
|
2001
|
-
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'] };
|
|
2002
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 }] }));
|
|
2003
2027
|
const incoming = loadDecisions(source, board), raw = readItems(source).items;
|
|
2004
2028
|
if (!ask)
|
|
@@ -2109,18 +2133,27 @@ function storeIncoming(file, named, on, { incoming, raw }, filer, exact = false)
|
|
|
2109
2133
|
}
|
|
2110
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 };
|
|
2111
2135
|
});
|
|
2136
|
+
let picked = [];
|
|
2112
2137
|
if (raised.some((r) => r.status === 'raised')) {
|
|
2113
2138
|
const before = waitingKeys(file);
|
|
2114
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}`;
|
|
2115
2145
|
notifyEntered(file, named, before);
|
|
2116
2146
|
}
|
|
2117
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`);
|
|
2118
2149
|
// Reports take no answer, so a post with nothing waiting asks the user to read it, never to answer.
|
|
2119
|
-
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'}`])]);
|
|
2120
2153
|
if (incoming.length && !anyWaiting)
|
|
2121
2154
|
return toon({ board: tilde(file), raised }, [`Run \`sign-off-axi open${on}\` so the user can read ${incoming.length > 1 ? 'them' : 'it'}`]);
|
|
2122
2155
|
const open = anyWaiting ? [`Run \`sign-off-axi open${on}\` so the user can answer`] : [];
|
|
2123
|
-
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'}`]);
|
|
2124
2157
|
});
|
|
2125
2158
|
}
|
|
2126
2159
|
async function open(args) {
|
|
@@ -2867,9 +2900,12 @@ async function serve(args) {
|
|
|
2867
2900
|
return;
|
|
2868
2901
|
await sweep('periodic');
|
|
2869
2902
|
reap();
|
|
2903
|
+
await holdLeash();
|
|
2870
2904
|
if (server.listening)
|
|
2871
2905
|
setTimeout(repeat, 5_000).unref();
|
|
2872
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();
|
|
2873
2909
|
setTimeout(repeat, 5_000).unref();
|
|
2874
2910
|
const url = `http://${urlHost(bind)}:${bound}/`;
|
|
2875
2911
|
process.on('exit', () => { if (existsSync(record(file)) && readFileSync(record(file), 'utf8').split(' ')[0] === String(process.pid))
|
|
@@ -2939,7 +2975,7 @@ async function exportBoard(args) {
|
|
|
2939
2975
|
if (d.preview)
|
|
2940
2976
|
await inline(at(d.key, '', d.preview));
|
|
2941
2977
|
for (const [i, m] of (d.media ?? []).entries())
|
|
2942
|
-
for (const v of ['', ...['light', 'thumb'].filter((v) => m[v])])
|
|
2978
|
+
for (const v of ['', ...['light', 'thumb', 'tile'].filter((v) => m[v])])
|
|
2943
2979
|
await inline(`/api/media?key=${q(d.key)}&i=${i}${v && `&v=${v}`}`);
|
|
2944
2980
|
for (const m of d.evidenceCard?.media ?? [])
|
|
2945
2981
|
await inline(m.url);
|
|
@@ -3073,6 +3109,14 @@ const warmGuard = () => new Promise((done) => {
|
|
|
3073
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. */
|
|
3074
3110
|
const LEASHES = ['ask', 'matters', 'act'];
|
|
3075
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
|
+
} };
|
|
3076
3120
|
/** The user's name and leash; none for a board with no config.yaml beside it. */
|
|
3077
3121
|
function profile() {
|
|
3078
3122
|
let text;
|
|
@@ -3082,8 +3126,34 @@ function profile() {
|
|
|
3082
3126
|
catch {
|
|
3083
3127
|
return {};
|
|
3084
3128
|
}
|
|
3085
|
-
|
|
3086
|
-
|
|
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 };
|
|
3087
3157
|
}
|
|
3088
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;
|
|
3089
3159
|
const state = () => {
|
|
@@ -3099,7 +3169,7 @@ const state = () => {
|
|
|
3099
3169
|
mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
|
|
3100
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.
|
|
3101
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.
|
|
3102
|
-
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,
|
|
3103
3173
|
...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
|
|
3104
3174
|
readonly: extra.readonly === true,
|
|
3105
3175
|
answers_only: extra.answers_only === true,
|
|
@@ -3140,7 +3210,7 @@ const mockHash = (path) => {
|
|
|
3140
3210
|
}
|
|
3141
3211
|
};
|
|
3142
3212
|
/** A design's files as the board serves them: its preview and every screen view. */
|
|
3143
|
-
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);
|
|
3144
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. */
|
|
3145
3215
|
const designRound = (d) => sha256(Buffer.from(JSON.stringify([d.filed, d.journeys, d.options, designPaths(d).map((p) => [p, mockHash(p)])])));
|
|
3146
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
|
|
@@ -3252,7 +3322,7 @@ async function route(req) {
|
|
|
3252
3322
|
if (req.method === 'GET' && req.url?.startsWith('/design/')) {
|
|
3253
3323
|
const [, , , key, ...parts] = new URL(req.url, 'http://localhost').pathname.split('/');
|
|
3254
3324
|
const path = parts.map(decodeURIComponent).join('/'), d = loadDecisions(file).find(x => x.key === key && x.type === 'design');
|
|
3255
|
-
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)));
|
|
3256
3326
|
const type = assetTypes[extname(path).toLowerCase()];
|
|
3257
3327
|
try {
|
|
3258
3328
|
if (!allowed || !type)
|
|
@@ -3306,9 +3376,9 @@ async function route(req) {
|
|
|
3306
3376
|
}
|
|
3307
3377
|
if (req.method === 'GET' && req.url?.startsWith('/api/media?')) {
|
|
3308
3378
|
const q = new URL(req.url, 'http://localhost').searchParams;
|
|
3309
|
-
// 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.
|
|
3310
3380
|
const v = q.get('v'), posted = loadDecisions(file).find((d) => d.key === q.get('key'))?.media?.[Number(q.get('i') || NaN)];
|
|
3311
|
-
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()];
|
|
3312
3382
|
if (!m || !type)
|
|
3313
3383
|
return [404, { error: 'Evidence is unavailable' }];
|
|
3314
3384
|
try {
|
|
@@ -3376,9 +3446,10 @@ async function route(req) {
|
|
|
3376
3446
|
return [404, { error: 'Not found' }];
|
|
3377
3447
|
if (readOnly())
|
|
3378
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.
|
|
3379
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
|
|
3380
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.
|
|
3381
|
-
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())
|
|
3382
3453
|
return [403, { error: 'This board takes answers only' }];
|
|
3383
3454
|
if (!req.headers['content-type']?.startsWith('application/json'))
|
|
3384
3455
|
return [415, { error: 'Send JSON' }];
|
|
@@ -3471,6 +3542,7 @@ async function route(req) {
|
|
|
3471
3542
|
}
|
|
3472
3543
|
const line = /^[ \t]*leash[ \t]*:.*$/m;
|
|
3473
3544
|
writeFileSync(config, line.test(text) ? text.replace(line, `leash: ${leash}`) : `${text.replace(/\n?$/, '\n')}leash: ${leash}\n`);
|
|
3545
|
+
await holdLeash();
|
|
3474
3546
|
return [200, state()];
|
|
3475
3547
|
}
|
|
3476
3548
|
if (req.url === '/api/dismiss') {
|
|
@@ -3494,9 +3566,9 @@ async function route(req) {
|
|
|
3494
3566
|
return [200, state()];
|
|
3495
3567
|
}
|
|
3496
3568
|
if (req.url === '/api/trail') {
|
|
3497
|
-
// 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.
|
|
3498
3570
|
const { key } = JSON.parse(body) ?? {};
|
|
3499
|
-
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)))
|
|
3500
3572
|
return [404, { error: 'No reply on this card' }];
|
|
3501
3573
|
appendMark(file, { op: 'seen', key });
|
|
3502
3574
|
return [200, state()];
|
|
@@ -3601,6 +3673,27 @@ function answered(a, d) {
|
|
|
3601
3673
|
child.stdin.on('error', () => { });
|
|
3602
3674
|
child.stdin.end(`${JSON.stringify({ event: 'answer', ...kindOf(file)(a.key), ...a, ...(d && { title: d.title }), ...(d?.filer && { filer: d.filer }) })}\n`);
|
|
3603
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
|
+
}
|
|
3604
3697
|
const runs = new Map(), failures = new Map();
|
|
3605
3698
|
const SIGNIN_WAIT = 20_000, SIGNIN_LIFE = 600_000;
|
|
3606
3699
|
const signinState = (key) => (runs.get(key)?.url ? 'live' : runs.has(key) ? 'starting' : failures.has(key) ? `failed: ${failures.get(key)}` : 'none');
|