sign-off-axi 0.5.4 → 0.5.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 +2 -2
- package/dist/cli.js +77 -16
- package/dist/contract.js +12 -4
- package/dist/owner.js +1 -1
- package/dist/store.js +33 -6
- package/package.json +1 -1
- package/skills/sign-off-axi/SKILL.md +6 -6
- package/web/diagram-page.js +22 -0
- package/web/index.html +896 -287
- package/web/tokens.css +29 -0
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ Four places, in the bar at the bottom of a phone and the sidebar on a desk.
|
|
|
49
49
|
<tr>
|
|
50
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: counts of what needs you, what was proven and new reports, then the waiting asks"></picture><br><b>Today</b><br>What needs you, what was proven, what is new.</td>
|
|
51
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: one card per ask that waits on you, filtered by type"></picture><br><b>Decide</b><br>Every ask that waits on you, one tap each.</td>
|
|
52
|
-
<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: a capture of the finished work with its result,
|
|
52
|
+
<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: a capture of the finished work with its result, newest first"></picture><br><b>Evidence</b><br>Proof that a change works, with its honest result.</td>
|
|
53
53
|
<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>
|
|
54
54
|
</tr>
|
|
55
55
|
</table>
|
|
@@ -77,7 +77,7 @@ A board that fills up with noise stops being read, so the rules sit at the door.
|
|
|
77
77
|
- A question must say why it is asked, what each answer does, which one the agent recommends and what happens if you stay quiet; one that does not is refused with the fix ([the question standard](docs/contract.md#ask-schema-1)).
|
|
78
78
|
- One decision is one card: raising the same question again changes nothing, and `--merge <key>` folds an older card into the better-asked one.
|
|
79
79
|
- Proof posted as a Pass/Fail question is refused, and proof that is mostly text, a placeholder or a test card is refused too: proof goes to Evidence, as a capture of the real product; a report must be a page to tap, not a wall of words.
|
|
80
|
-
- **Dismiss** hides a card from the board, **Undo** brings it back (`sign-off-axi dismiss <key>` and `restore <key>` do the same from a shell). `sign-off-axi dismiss --filer <id>` closes a finished task: every card that id still waits on leaves the board.
|
|
80
|
+
- **Dismiss** hides a card from the board, **Undo** brings it back (`sign-off-axi dismiss <key>` and `restore <key>` do the same from a shell). `sign-off-axi dismiss --filer <id>` closes a finished task: every card that id still waits on leaves the board. Reports leave the main view a day after they were filed and wait under **Archived**; nothing is deleted, and Restore brings one back. Evidence stays on its screen for good, newest first, until dismissed. Posting an evidence message again under its key, from the same filer, replaces the card. A new ask that repeats an open ask on the same decision is refused, and answering one closes its twins.
|
|
81
81
|
|
|
82
82
|
## What you can ask
|
|
83
83
|
|
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ import { parseArgs } from 'node:util';
|
|
|
13
13
|
import { encode } from '@toon-format/toon';
|
|
14
14
|
import { acknowledgeOwner, checkOwner, deliveries, dispatchOwners, registerOwner } from './owner.js';
|
|
15
15
|
import { AxiError, runAxiCli } from 'axi-sdk-js';
|
|
16
|
-
import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, asks, appendAnswer, appendDismissed, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, readDismissed, readItems, sameDecision, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
16
|
+
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, appendSendBack, appendDesignAnswer, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, reading, readDismissed, readItems, sameDecision, readMessages, readRounds, roundsPath, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
|
|
17
17
|
import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, diagramPages, diagramViews, guided, mediaFiles, parseMessage, reportPage } from './contract.js';
|
|
18
18
|
import { faults, isStill } from './evidence-text.js';
|
|
19
19
|
import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
|
|
@@ -147,7 +147,7 @@ Blocks until the user answers, then prints the answer the way answers does. Use
|
|
|
147
147
|
--timeout seconds to wait, 600 (10 minutes) by default; then exits 1 with code TIMEOUT
|
|
148
148
|
--full adds the button label, close and title
|
|
149
149
|
--json {"<key>":{"answer","label","close","at","note"?}} for the new answers; note only when the user wrote one
|
|
150
|
-
--events needs --filer (or SIGN_OFF_FILER); JSON lines for new answers, another-round asks
|
|
150
|
+
--events needs --filer (or SIGN_OFF_FILER); JSON lines for new answers, another-round asks, Go deeper and comments on a part (each wait returns one batch).
|
|
151
151
|
Answer and round lines come in the order they were saved (${CONTRACT}#7-answers):
|
|
152
152
|
{"event":"answer","contract":1,"kind","schema","key","title","answer","label","close","at","note"?,"filer"?,"comments"?}
|
|
153
153
|
{"event":"round","contract":1,"kind","schema","key","title","note","at","filer"?,"comments"?}; a board-format item reports as ask schema 1
|
|
@@ -329,6 +329,7 @@ Do not run an active wait listener for the same owner alongside the dispatcher.
|
|
|
329
329
|
deeper: `usage: sign-off-axi deeper [board.json] [--filer <id>] [--all] [--json]
|
|
330
330
|
sign-off-axi deeper [board.json] --filer <id> --id <id> --text "<answer>" [--more]
|
|
331
331
|
Every part of a plan 3 or report 4 offers Ask a question, Go deeper, Simplify this, Show an example, Why?, and free text. Ask a question needs text and Send; Go deeper, Simplify this, Show an example and Why? send immediately. Free text and follow-ups use custom. Older guided schemas retain Go deeper. Engagement v1 replies require the owning --filer or registered owner session; stale targets are refused. The person taps it; wait --filer wakes with the ask, or this command lists the ones nobody finished.
|
|
332
|
+
The same --id --text replies to a comment on a part (the id in wait's comment event).
|
|
332
333
|
Answer in plain words about the part they tapped, a few lines at a time: each --more piece streams into that spot on the board while they watch, and a last call without --more ends it.
|
|
333
334
|
--id the ask to answer --text the answer, or - to read stdin (2000 characters or fewer per piece)
|
|
334
335
|
--more more pieces follow --all also the finished asks
|
|
@@ -684,6 +685,16 @@ function deeperOut(file, asks, on, events) {
|
|
|
684
685
|
return toon({ board: tilde(file), deeper: asks.map(({ id, key, part, about, q = '', at, action }) => ({ id, key, part, about, q, at, ...(action && { action }) })) }, ['The person tapped Go deeper: answer in plain words, a few lines at a time, as the part they tapped',
|
|
685
686
|
`Run \`sign-off-axi deeper${on} --id <id> --text "<what you found>" --more\` for each piece, then once more without --more to finish`]);
|
|
686
687
|
}
|
|
688
|
+
/** Comments on the parts of a guided page as the agent reads them; answer one with `deeper --id <id> --text`. */
|
|
689
|
+
function commentOut(file, said, on, events) {
|
|
690
|
+
const titles = new Map(board(file).map((d) => [d.key, d.title]));
|
|
691
|
+
if (events) {
|
|
692
|
+
const kind = kindOf(file);
|
|
693
|
+
return said.map(({ id, key, part, about, verdict, text, at }) => JSON.stringify({ event: 'comment', ...kind(key), key, title: titles.get(key), id, part, about, ...(verdict && { verdict }), ...(text && { text }), at })).join('\n');
|
|
694
|
+
}
|
|
695
|
+
return toon({ board: tilde(file), comments: said.map(({ id, key, part, about, verdict = '', text = '', at }) => ({ id, key, part, about, verdict, text, at })) }, ['The person commented on a part: read it, change the page if it is right, and reply in plain words',
|
|
696
|
+
`Run \`sign-off-axi deeper${on} --id <id> --text "<your reply>"\` to answer one`]);
|
|
697
|
+
}
|
|
687
698
|
/** `deeper`: the Go deeper asks nobody has finished, or with --id and --text one piece of the answer the board streams under the part the person tapped. */
|
|
688
699
|
async function owner(args) {
|
|
689
700
|
const { file, values } = parse('owner', args, { filer: { type: 'string' }, host: { type: 'string' }, session: { type: 'string' }, cwd: { type: 'string' }, model: { type: 'string' }, dispatch: { type: 'boolean' }, verbose: { type: 'boolean' }, ack: { type: 'string' }, timeout: { type: 'string' } });
|
|
@@ -729,6 +740,18 @@ async function deeper(args) {
|
|
|
729
740
|
markDeeperRead(file, open.filter((x) => !x.read).map((x) => x.id));
|
|
730
741
|
return toon({ board: tilde(file), deeper: open.map(({ id, key, part, about, q = '', at, done, action }) => ({ id, key, part, about, q, at, done: !!done, ...(action && { action }) })) }, [`Run \`sign-off-axi deeper${on} --id <id> --text "<answer>"\` to answer one`]);
|
|
731
742
|
}
|
|
743
|
+
const said = readPartComments(file).find((x) => x.id === values.id);
|
|
744
|
+
if (said) {
|
|
745
|
+
if (filer !== undefined && !board(file).some((d) => d.key === said.key && d.filer === filer))
|
|
746
|
+
throw new InputError('Request does not belong to this filer');
|
|
747
|
+
if (said.reply !== undefined)
|
|
748
|
+
throw fail(`comment ${values.id} is already answered`, 'CONFLICT', 'The person can comment again');
|
|
749
|
+
const reply = values.text === '-' ? readFileSync(0, 'utf8') : values.text;
|
|
750
|
+
if (typeof reply !== 'string' || !reply.trim() || [...reply.trim()].length > 2000)
|
|
751
|
+
throw fail('--text must be the reply, 2000 characters or fewer', 'VALIDATION_ERROR', 'Run `sign-off-axi deeper --help`');
|
|
752
|
+
replyComment(file, said.id, reply.trim());
|
|
753
|
+
return toon({ id: said.id, key: said.key, part: said.part, answered: 'done' }, [`Run \`sign-off-axi wait${on}\` for the next answer or ask`]);
|
|
754
|
+
}
|
|
732
755
|
const ask = readDeeper(file).find((x) => x.id === values.id);
|
|
733
756
|
if (!ask)
|
|
734
757
|
throw fail(`no Go deeper ask with id ${values.id}`, 'NOT_FOUND', `Run \`sign-off-axi deeper${on}\` to see the open ones`);
|
|
@@ -784,10 +807,10 @@ async function wait(args) {
|
|
|
784
807
|
seenRounds.set(r.key, (seenRounds.get(r.key) ?? 0) + 1);
|
|
785
808
|
// A save wakes the loop at once; the 1 second timer is the fallback where file events are unavailable.
|
|
786
809
|
let wake = () => { }, watcher, vaultWarned = false;
|
|
787
|
-
const name = basename(answersPath(file)), asking = basename(deeperPath(file));
|
|
810
|
+
const name = basename(answersPath(file)), asking = basename(deeperPath(file)), talking = basename(annotationsPath(file));
|
|
788
811
|
try {
|
|
789
812
|
try {
|
|
790
|
-
watcher = watch(dirname(file), (_, changed) => (changed === name || changed === asking) && wake()).on('error', () => { });
|
|
813
|
+
watcher = watch(dirname(file), (_, changed) => (changed === name || changed === asking || changed === talking) && wake()).on('error', () => { });
|
|
791
814
|
}
|
|
792
815
|
catch { }
|
|
793
816
|
for (let first = true;; first = false) {
|
|
@@ -831,6 +854,14 @@ async function wait(args) {
|
|
|
831
854
|
}, 'another wait is still taking Go deeper asks');
|
|
832
855
|
if (asks.length)
|
|
833
856
|
return deeperOut(file, asks, onBoard + (filer ? ` --filer ${filer}` : ''), values.events);
|
|
857
|
+
// A comment on a part of this filer's page wakes the wait once, and is marked read when it is handed over.
|
|
858
|
+
const said = await locked(`${annotationsPath(file)}.lock`, () => {
|
|
859
|
+
const got = readPartComments(file).filter((x) => !x.read && (values.key === undefined || x.key === values.key) && rows.some((d) => d.key === x.key));
|
|
860
|
+
markCommentsRead(file, got.map((x) => x.id));
|
|
861
|
+
return got;
|
|
862
|
+
}, 'another wait is still taking comments');
|
|
863
|
+
if (said.length)
|
|
864
|
+
return commentOut(file, said, on, values.events);
|
|
834
865
|
}
|
|
835
866
|
// Private vault expiries and refusals reach a filer wait only once its ordinary answers are out; each is taken once.
|
|
836
867
|
let live = 0, unknown = false;
|
|
@@ -1352,18 +1383,25 @@ function postMedia(file, named, on, values, type, filer, message) {
|
|
|
1352
1383
|
// Boards in one folder share evidence/, so a number is taken only by creating its folder; one in use is never written into.
|
|
1353
1384
|
const current = board(file), store = join(dirname(file), 'evidence'), taken = current.map((d) => Number(/^update-(\d+)$/.exec(d.key)?.[1] ?? 0));
|
|
1354
1385
|
const own = message ? message.key : explainer && (values.key ?? slug(values.title)), had = message && own && items.find((x) => x?.key === own);
|
|
1386
|
+
// The same filer posting a key again with a changed proof replaces the card it posted, as a new update filed now; another filer's, or one already answered, is refused.
|
|
1387
|
+
let replaced = false;
|
|
1355
1388
|
if (had) {
|
|
1356
1389
|
// The stored item, read back as the message it came from: its run, and each media file by hash. The notice record is the board's, not the message's.
|
|
1357
1390
|
const asMessage = ({ type: _t, number: _n, filed: _f, announced: _a, evidence: run, media: kept, ...rest }) => canon({ ...rest, run, media: kept?.map((x) => x.sha256) });
|
|
1358
1391
|
if (asMessage(had) !== canon({ ...message, media: sources.length ? sources.map((src) => sha256(readFileSync(src))) : undefined })) {
|
|
1359
|
-
|
|
1392
|
+
if (had.filer === undefined || had.filer !== message.filer || Object.hasOwn(readAnswers(file), own))
|
|
1393
|
+
throw fail(`key ${own} already holds a different message on ${tilde(file)}`, 'CONFLICT', 'Post it under a new key');
|
|
1394
|
+
items.splice(items.indexOf(had), 1);
|
|
1395
|
+
replaced = true;
|
|
1360
1396
|
}
|
|
1397
|
+
}
|
|
1398
|
+
if (had && !replaced) {
|
|
1361
1399
|
return toon({ board: tilde(file), posted: [{ key: own, number: had.number, media: sources.length, title: had.title, status: 'already posted' }] }, explainer ? [`Run \`sign-off-axi open${on}\` so the user can give a verdict`] : []);
|
|
1362
1400
|
}
|
|
1363
1401
|
const answers = readAnswers(file);
|
|
1364
1402
|
if (message && own && Object.hasOwn(answers, own) && !awaitsVerdict(message.kind, answers[own]))
|
|
1365
1403
|
throw fail(`key ${own} was answered on ${tilde(file)} and is no longer there`, 'CONFLICT', 'Post it under a new key');
|
|
1366
|
-
if (own && current.some((d) => d.key === own))
|
|
1404
|
+
if (own && !replaced && current.some((d) => d.key === own))
|
|
1367
1405
|
throw fail(`key ${own} is already used on ${tilde(file)}`, 'CONFLICT', 'Post it under a new --key');
|
|
1368
1406
|
mkdirSync(store, { recursive: true, mode: 0o700 });
|
|
1369
1407
|
let number = Math.max(0, ...taken, ...readdirSync(store).map(Number).filter(Number.isInteger)) + 1;
|
|
@@ -2460,7 +2498,7 @@ async function exportBoard(args) {
|
|
|
2460
2498
|
config = join(dirname(parsed.named), 'config.yaml');
|
|
2461
2499
|
guardReady = warmGuard();
|
|
2462
2500
|
await guardReady;
|
|
2463
|
-
const { file: _, messages: _messages, annotations: _annotations, ...frozen } = state(), files = {}, q = encodeURIComponent;
|
|
2501
|
+
const { file: _, messages: _messages, annotations: _annotations, comments: _comments, ...frozen } = state(), files = {}, q = encodeURIComponent;
|
|
2464
2502
|
let missing = 0;
|
|
2465
2503
|
// The same URLs the page asks the server for, read through the same hash and path checks.
|
|
2466
2504
|
const inline = async (url, query, document = false) => {
|
|
@@ -2501,12 +2539,14 @@ async function exportBoard(args) {
|
|
|
2501
2539
|
return toon({ board: tilde(parsed.file), exported: tilde(out), items: frozen.decisions.length, inlined: Object.keys(files).length, ...(missing && { missing }) }, [`Open ${tilde(out)} in a browser: one read-only file, no server needed`]);
|
|
2502
2540
|
}
|
|
2503
2541
|
const page = readFileSync(new URL('../web/index.html', import.meta.url));
|
|
2504
|
-
/** The board's favicon, home-screen icon and install manifest, served beside the page; see web/icon.svg. */
|
|
2505
|
-
const ICONS = Object.fromEntries([['icon.svg', 'image/svg+xml'], ['apple-touch-icon.png', 'image/png'], ['icon-192.png', 'image/png'], ['icon-512.png', 'image/png'], ['icon-maskable-512.png', 'image/png'], ['manifest.webmanifest', 'application/manifest+json']]
|
|
2542
|
+
/** The board's design tokens, favicon, home-screen icon and install manifest, served beside the page; see web/tokens.css and web/icon.svg. */
|
|
2543
|
+
const ICONS = Object.fromEntries([['tokens.css', 'text/css; charset=utf-8'], ['icon.svg', 'image/svg+xml'], ['apple-touch-icon.png', 'image/png'], ['icon-192.png', 'image/png'], ['icon-512.png', 'image/png'], ['icon-maskable-512.png', 'image/png'], ['manifest.webmanifest', 'application/manifest+json']]
|
|
2506
2544
|
.map(([name, type]) => [`/${name}`, { type, bytes: readFileSync(new URL(`../web/${name}`, import.meta.url)) }]));
|
|
2507
2545
|
/** Added to each design mock page and plan page so the person can point at a part of it; see web/point.js. */
|
|
2508
2546
|
const pointer = Buffer.from(`<script>${readFileSync(new URL('../web/point.js', import.meta.url), 'utf8')}</script>`);
|
|
2509
2547
|
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
2548
|
+
/** Added to each diagram page: on the page opened by itself, a way back to its plan or report, and a phone's drawing-alone page sized to the drawing; see web/diagram-page.js. */
|
|
2549
|
+
const diagramPage = (back, label) => Buffer.from(`<script data-back="${back}" data-label="${label}">${readFileSync(new URL('../web/diagram-page.js', import.meta.url), 'utf8')}</script>`);
|
|
2510
2550
|
/** A card whose picture is a colour-bar test card or a placeholder is not shown: a card posted before the guard can hold one. Read by the
|
|
2511
2551
|
* stored hash, so each picture is measured once. */
|
|
2512
2552
|
const unfitMedia = new Map();
|
|
@@ -2620,6 +2660,7 @@ const state = () => {
|
|
|
2620
2660
|
rounds,
|
|
2621
2661
|
messages: readMessages(file, warn),
|
|
2622
2662
|
annotations: readOnly() || answersOnly() ? [] : ((sent) => readAnnotations(file, warn).map((a) => (sent.has(a.id) ? { ...a, sent: true } : a)))(sentIds(file)),
|
|
2663
|
+
comments: readOnly() || answersOnly() ? [] : readPartComments(file),
|
|
2623
2664
|
dismissed: readDismissed(file, answers, rounds, warn, archivable(file, loadDecisions(file))),
|
|
2624
2665
|
};
|
|
2625
2666
|
};
|
|
@@ -2755,7 +2796,8 @@ async function route(req) {
|
|
|
2755
2796
|
const d = loadDecisions(file).find((x) => x.key === key);
|
|
2756
2797
|
// A guided document's diagram views: ~diagram.<n>, n counting them in the order visual, then each section and its items.
|
|
2757
2798
|
const dgs = d?.guided ? diagramViews(d) : [];
|
|
2758
|
-
const
|
|
2799
|
+
const diagram = value?.startsWith('diagram.') ? dgs[Number(value.slice(8))] : undefined;
|
|
2800
|
+
const mock = diagram ? diagram.src : value ? d?.mocks?.find((m) => m.value === value)?.path : value === '' && d?.view;
|
|
2759
2801
|
try {
|
|
2760
2802
|
const path = rest.map(decodeURIComponent).join('/'), type = assetTypes[extname(path).toLowerCase()];
|
|
2761
2803
|
if (!mock || !type)
|
|
@@ -2763,8 +2805,12 @@ async function route(req) {
|
|
|
2763
2805
|
const actual = under(dirname(under(dirname(file), mockFile(mock))), path);
|
|
2764
2806
|
if (statSync(actual).size > MOCK_BYTES)
|
|
2765
2807
|
return [404, { error: 'Mock is unavailable' }];
|
|
2766
|
-
|
|
2767
|
-
|
|
2808
|
+
const bytes = readFileSync(actual);
|
|
2809
|
+
// 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.
|
|
2810
|
+
if (diagram?.sha256 && (actual !== under(dirname(file), mockFile(mock)) || bytes.length !== diagram.bytes || sha256(bytes) !== diagram.sha256))
|
|
2811
|
+
return [404, { error: 'Diagram file changed since it was posted' }];
|
|
2812
|
+
// Every HTML view gets the bridge: document readers need its size handshake too. A diagram also gets its way back.
|
|
2813
|
+
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'}`) }) }];
|
|
2768
2814
|
}
|
|
2769
2815
|
catch {
|
|
2770
2816
|
return [404, { error: 'Mock is unavailable' }];
|
|
@@ -2828,8 +2874,8 @@ async function route(req) {
|
|
|
2828
2874
|
return [404, { error: 'Not found' }];
|
|
2829
2875
|
if (readOnly())
|
|
2830
2876
|
return [403, { error: 'This board is read-only' }];
|
|
2831
|
-
// Dismiss only hides an item on this page and sends nothing to the feeding program, so an answers-only board takes
|
|
2832
|
-
if (!['/api/answers', '/api/dismiss', '/api/signin', '/api/reads'].includes(req.url) && answersOnly())
|
|
2877
|
+
// Dismiss only hides an item on this page and sends nothing to the feeding program, and Go deeper only asks for an explanation, so an answers-only board takes both.
|
|
2878
|
+
if (!['/api/answers', '/api/dismiss', '/api/signin', '/api/reads', '/api/deeper', '/api/engagement', '/api/engagement/seen'].includes(req.url) && answersOnly())
|
|
2833
2879
|
return [403, { error: 'This board takes answers only' }];
|
|
2834
2880
|
if (!req.headers['content-type']?.startsWith('application/json'))
|
|
2835
2881
|
return [415, { error: 'Send JSON' }];
|
|
@@ -2848,7 +2894,22 @@ async function route(req) {
|
|
|
2848
2894
|
return [200, state()];
|
|
2849
2895
|
}
|
|
2850
2896
|
if (req.url === '/api/annotations') {
|
|
2851
|
-
const
|
|
2897
|
+
const c = JSON.parse(body) ?? {};
|
|
2898
|
+
// A comment on a part of a guided page: Agree, Disagree or words, kept beside the design notes for the author agent's wait to take.
|
|
2899
|
+
if (c.part !== undefined) {
|
|
2900
|
+
const d = loadDecisions(file).find((x) => x.key === c.key), about = typeof c.part === 'string' && d && guidePart(d, c.part);
|
|
2901
|
+
if (!about)
|
|
2902
|
+
return [404, { error: 'This part is no longer on the board' }];
|
|
2903
|
+
if (c.verdict !== undefined && c.verdict !== 'agree' && c.verdict !== 'disagree')
|
|
2904
|
+
return [400, { error: 'Send agree or disagree' }];
|
|
2905
|
+
if (c.text !== undefined && (typeof c.text !== 'string' || [...c.text.trim()].length > NOTE_MAX))
|
|
2906
|
+
return [400, { error: `Write the comment in ${NOTE_MAX} characters or fewer` }];
|
|
2907
|
+
if (!c.verdict && !c.text?.trim())
|
|
2908
|
+
return [400, { error: 'Write the comment first' }];
|
|
2909
|
+
appendPartComment(file, c.key, c.part, about, c.verdict, c.text?.trim());
|
|
2910
|
+
return [200, state()];
|
|
2911
|
+
}
|
|
2912
|
+
const { id, deleted, key, mock, ref, tag, words, text, hash: shown } = c, live = readAnnotations(file), sent = sentIds(file);
|
|
2852
2913
|
// A comment that went with an answer or a round is the agent's now: it stays as it was sent.
|
|
2853
2914
|
if (id !== undefined && sent.has(id))
|
|
2854
2915
|
return [409, { error: 'This comment was sent' }];
|
|
@@ -3153,7 +3214,7 @@ const server = createServer(async (req, res) => {
|
|
|
3153
3214
|
return bytes ? pipeline(createReadStream(path, { start, end }), res, () => { }) : res.end();
|
|
3154
3215
|
}
|
|
3155
3216
|
if (body && typeof body === 'object' && 'media' in body) {
|
|
3156
|
-
const { media: bytes, type, point } = body, media = point ? Buffer.concat([bytes, pointer]) : bytes;
|
|
3217
|
+
const { media: bytes, type, point, page } = body, media = point ? Buffer.concat([bytes, pointer, ...(page ? [page] : [])]) : bytes;
|
|
3157
3218
|
const headers = { 'content-type': type, 'x-content-type-options': 'nosniff', 'content-security-policy': FRAME_CSP, 'accept-ranges': 'bytes', 'content-length': media.length };
|
|
3158
3219
|
// One byte range, so phone browsers can play and seek a recording.
|
|
3159
3220
|
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? '');
|
package/dist/contract.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// The v1 communication contract (docs/contract.md): one strict, versioned shape per kind of message.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
3
4
|
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
5
|
import { isKey, MEDIA_MAX, REPORT_FORMS } from './store.js';
|
|
@@ -211,8 +212,12 @@ export function checkMessage(m, want, flags = false) {
|
|
|
211
212
|
text(`${field}[${n}].label`, o.label, 40, 'the number\'s name, in 40 characters or fewer');
|
|
212
213
|
text(`${field}[${n}].value`, o.value, 120, 'the number and its unit, in 120 characters or fewer');
|
|
213
214
|
});
|
|
214
|
-
/** A diagram: 1 to 3 views of one picture (Today, After), each a board-relative .html file the board frames. */
|
|
215
|
-
const diagrams = (field, v) => list(field, 1, 3, ['label', 'src'], '{"label":"Today","src":"diagrams/flow.html"}', v).forEach((o, n) => {
|
|
215
|
+
/** A diagram: 1 to 3 views of one picture (Today, After), each a board-relative .html file the board frames. Post adds each file's `sha256` and `bytes`, as it does for media. */
|
|
216
|
+
const diagrams = (field, v) => list(field, 1, 3, ['label', 'src', 'sha256', 'bytes'], '{"label":"Today","src":"diagrams/flow.html"}', v).forEach((o, n) => {
|
|
217
|
+
if (Object.hasOwn(o, 'sha256') && !/^[a-f0-9]{64}$/.test(o.sha256))
|
|
218
|
+
throw bad(`${field}[${n}].sha256`, 'must be 64 lowercase hex digits', 'Leave "sha256" and "bytes" out; post pins the file');
|
|
219
|
+
if (Object.hasOwn(o, 'bytes') && !(Number.isInteger(o.bytes) && o.bytes >= 0))
|
|
220
|
+
throw bad(`${field}[${n}].bytes`, 'must be a whole number of bytes', 'Leave "sha256" and "bytes" out; post pins the file');
|
|
216
221
|
text(`${field}[${n}].label`, o.label, 40, 'the view name, such as "Today", in 40 characters or fewer');
|
|
217
222
|
text(`${field}[${n}].src`, o.src, 256, 'a page path from the board file\'s folder, such as "diagrams/flow.html"');
|
|
218
223
|
if (/^([\\/]|[A-Za-z]:)/.test(o.src) || o.src.split(/[\\/]/).includes('..') || /[?#\\]/.test(o.src) || !/\.html?$/i.test(o.src))
|
|
@@ -663,7 +668,8 @@ export const diagramViews = (m) => [m?.visual?.diagram, ...(m?.sections ?? []).f
|
|
|
663
668
|
const DIAGRAM_MIN_PX = 12;
|
|
664
669
|
const PHONE_PX = 390, PHONE_DRAW_PX = PHONE_PX - 16;
|
|
665
670
|
/** A guided plan's or report's diagram pages, from `base`, the board file's folder: each a file of 5 MB or less whose real path stays inside it,
|
|
666
|
-
* with a drawing whose smallest text is at least 12 px at 390 and no brand marks (their licenses differ). The first broken rule throws.
|
|
671
|
+
* with a drawing whose smallest text is at least 12 px at 390 and no brand marks (their licenses differ). The first broken rule throws.
|
|
672
|
+
* Each view that passes is pinned with the file's `sha256` and `bytes`, so the board serves only the file that was posted and the revision covers its content. */
|
|
667
673
|
export function diagramPages(m, base) {
|
|
668
674
|
const at = isKey(m.key) ? `key ${m.key}: ` : '';
|
|
669
675
|
let root = resolve(base);
|
|
@@ -692,7 +698,7 @@ export function diagramPages(m, base) {
|
|
|
692
698
|
catch {
|
|
693
699
|
throw bad('cannot be read', 'Make the file readable, then post again');
|
|
694
700
|
}
|
|
695
|
-
const
|
|
701
|
+
const file = readFileSync(real), html = file.toString('utf8'), svg = html.match(/<svg\b[^>]*\bviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+[\d.]+[^>]*>([\s\S]*?)<\/svg>/i);
|
|
696
702
|
if (!svg)
|
|
697
703
|
throw bad('has no <svg> drawing with a viewBox', 'Render it with the archify skill: an architecture diagram with a viewBox 320 to 340 wide and its legend hidden');
|
|
698
704
|
if (/data-brand-mark/i.test(html))
|
|
@@ -701,6 +707,8 @@ export function diagramPages(m, base) {
|
|
|
701
707
|
const small = Math.min(...sizes), real12 = small * PHONE_DRAW_PX / Number(svg[1]);
|
|
702
708
|
if (sizes.length && real12 < DIAGRAM_MIN_PX)
|
|
703
709
|
throw bad(`has text of ${Math.round(real12 * 10) / 10} px at ${PHONE_PX} (${small} in a ${svg[1]} wide drawing), under ${DIAGRAM_MIN_PX}`, `Draw it narrower, an architecture viewBox 320 to 340 wide with no sublabels and the legend hidden ("legend": {"mode": "hidden"} in "meta"), so every text is ${DIAGRAM_MIN_PX} px or more on a phone`);
|
|
710
|
+
v.sha256 = createHash('sha256').update(file).digest('hex');
|
|
711
|
+
v.bytes = file.length;
|
|
704
712
|
}
|
|
705
713
|
}
|
|
706
714
|
/** The longest a block of plain text on a report page may run: about five lines on a phone. */
|
package/dist/owner.js
CHANGED
|
@@ -49,7 +49,7 @@ export async function dispatchOwners(file, cli, filer, verbose = false) {
|
|
|
49
49
|
for (const ask of readDeeper(file).filter(x => !x.done && items.some(d => d.key === x.key && (filer === undefined || d.filer === filer)))) {
|
|
50
50
|
const owner = ownerFor(file, ask);
|
|
51
51
|
if (!owner) {
|
|
52
|
-
record(file, ask.id, 'queued', 'No
|
|
52
|
+
record(file, ask.id, 'queued', 'No agent is watching this document right now. Your ask is saved for when one does.');
|
|
53
53
|
continue;
|
|
54
54
|
}
|
|
55
55
|
// One final reply is atomic in the existing deeper log; replay never appends a second answer.
|
package/dist/store.js
CHANGED
|
@@ -325,7 +325,7 @@ export const SCREENS_ONLY = 'screens';
|
|
|
325
325
|
/** True while an answer leaves the item waiting: none yet, or only screen notes. */
|
|
326
326
|
export const awaitsVerdict = (type, a) => !a || (type === 'design' && a.answer === SCREENS_ONLY);
|
|
327
327
|
const DESIGN_LABEL = { approve: 'Approved', change: 'Changes requested', [SCREENS_ONLY]: 'Screens reviewed' };
|
|
328
|
-
/** Saves a design answer with screen-specific notes and an optional listed direction pick. A screen save omits the verdict: the design keeps its saved verdict, a change on a screen
|
|
328
|
+
/** Saves a design answer with screen-specific notes and an optional listed direction pick. A screen save omits the verdict: the design keeps its saved verdict, a requested change on a screen sets it to change, and any other screen save with none yet waits as screens-only. */
|
|
329
329
|
export function appendDesignAnswer(decisionsPath, d, verdict, notes, pick, requested) {
|
|
330
330
|
const bad = (rule) => new InputError(`key ${d.key}: ${rule}`);
|
|
331
331
|
if (verdict !== undefined && verdict !== 'approve' && verdict !== 'change')
|
|
@@ -352,7 +352,7 @@ export function appendDesignAnswer(decisionsPath, d, verdict, notes, pick, reque
|
|
|
352
352
|
throw bad('pick must be one listed option, and a design verdict needs one when options were offered');
|
|
353
353
|
const current = readAnswers(decisionsPath)[d.key];
|
|
354
354
|
const decided = current?.answer === 'approve' || current?.answer === 'change' ? current.answer : undefined;
|
|
355
|
-
const kept = (verdict ?? (
|
|
355
|
+
const kept = (verdict ?? (requested === true ? 'change' : decided ?? SCREENS_ONLY));
|
|
356
356
|
const keptPick = (pick ?? (options.some(o => o.value === current?.pick) ? current?.pick : undefined));
|
|
357
357
|
const answer = { key: d.key, answer: kept, label: DESIGN_LABEL[kept], close: d.close ?? 'done', at: now(), notes, ...(keptPick !== undefined && { pick: keptPick }) };
|
|
358
358
|
if (current?.answer === answer.answer && JSON.stringify([current.notes, current.pick]) === JSON.stringify([answer.notes, answer.pick]))
|
|
@@ -462,10 +462,10 @@ export const asks = (d) => d.type !== 'evidence' && !reading(d) && (d.options.le
|
|
|
462
462
|
export const reading = (d) => d.type === 'plan' && !!d.guided && !d.decisions?.length;
|
|
463
463
|
/** A sign-in whose fixed link has expired has nothing left to open: it is not waiting, here or on the board's page (web/index.html lapsed() says the same). A start command makes a new link per tap and never lapses. */
|
|
464
464
|
export const lapsed = (d) => d.type === 'signin' && !d.start && !!d.expires && Date.parse(d.expires) <= Date.now();
|
|
465
|
-
/**
|
|
465
|
+
/** Reports (nothing to answer) archive on their own this long after they were filed: dismissed, never deleted. Evidence is kept for good: only a dismiss line hides a proof. */
|
|
466
466
|
export const ARCHIVE_MS = 24 * 3_600_000;
|
|
467
467
|
/** Keys hidden now: the last line for each dismissed it, and no answer or round came after it. A torn line is skipped and reported through warn.
|
|
468
|
-
* `items`, when given, adds the archive:
|
|
468
|
+
* `items`, when given, adds the archive: reports filed over a day ago that no log line ever named. Restore writes a line, so a restored item stays. */
|
|
469
469
|
export function readDismissed(decisionsPath, answers, rounds, warn = () => { }, items = []) {
|
|
470
470
|
const hidden = new Map(), named = new Set();
|
|
471
471
|
readRaw(dismissedPath(decisionsPath)).split('\n').forEach((line, n) => {
|
|
@@ -482,7 +482,7 @@ export function readDismissed(decisionsPath, answers, rounds, warn = () => { },
|
|
|
482
482
|
warn(`skipped unreadable line ${n + 1} in ${dismissedPath(decisionsPath)}`);
|
|
483
483
|
}
|
|
484
484
|
});
|
|
485
|
-
const archived = items.filter((d) => !asks(d) && !named.has(d.key) && Date.now() - Date.parse(d.filed ?? '') > ARCHIVE_MS).map((d) => d.key);
|
|
485
|
+
const archived = items.filter((d) => d.type !== 'evidence' && !asks(d) && !named.has(d.key) && Date.now() - Date.parse(d.filed ?? '') > ARCHIVE_MS).map((d) => d.key);
|
|
486
486
|
return [...[...hidden].filter(([key, at]) => !(answers[key]?.at > at) && !rounds.some((r) => r.key === key && r.at > at)).map(([key]) => key), ...archived];
|
|
487
487
|
}
|
|
488
488
|
export const messagesPath = (decisionsPath) => decisionsPath.replace(/\.json$/, '') + '.messages.jsonl';
|
|
@@ -521,7 +521,7 @@ export function readAnnotations(decisionsPath, warn = () => { }) {
|
|
|
521
521
|
all.delete(a.id);
|
|
522
522
|
else if (a && ['id', 'key', 'mock', 'hash', 'ref', 'tag', 'words', 'text'].every((k) => typeof a[k] === 'string'))
|
|
523
523
|
all.set(a.id, a);
|
|
524
|
-
else if (a)
|
|
524
|
+
else if (a && a.part === undefined && a.op === undefined)
|
|
525
525
|
warn(`skipped unreadable line ${n + 1} in ${annotationsPath(decisionsPath)}`);
|
|
526
526
|
}
|
|
527
527
|
catch {
|
|
@@ -530,6 +530,33 @@ export function readAnnotations(decisionsPath, warn = () => { }) {
|
|
|
530
530
|
});
|
|
531
531
|
return [...all.values()];
|
|
532
532
|
}
|
|
533
|
+
export function readPartComments(decisionsPath) {
|
|
534
|
+
const all = new Map();
|
|
535
|
+
readRaw(annotationsPath(decisionsPath)).split('\n').forEach((line) => {
|
|
536
|
+
try {
|
|
537
|
+
const x = line && JSON.parse(line), c = x && all.get(x.id);
|
|
538
|
+
if (typeof x?.part === 'string' && typeof x.id === 'string')
|
|
539
|
+
all.set(x.id, { id: x.id, key: x.key, part: x.part, about: x.about, ...(x.verdict && { verdict: x.verdict }), ...(x.text && { text: x.text }), at: x.at });
|
|
540
|
+
else if (c && x.op === 'read')
|
|
541
|
+
c.read ??= x.at;
|
|
542
|
+
else if (c && x.op === 'reply') {
|
|
543
|
+
c.reply = x.text;
|
|
544
|
+
c.replied = x.at;
|
|
545
|
+
c.read ??= x.at;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
catch { }
|
|
549
|
+
});
|
|
550
|
+
return [...all.values()];
|
|
551
|
+
}
|
|
552
|
+
export function appendPartComment(decisionsPath, key, part, about, verdict, text) {
|
|
553
|
+
const id = randomBytes(6).toString('hex'), at = now();
|
|
554
|
+
appendLine(annotationsPath(decisionsPath), { id, key, part, about, ...(verdict && { verdict }), ...(text && { text }), at });
|
|
555
|
+
return { id, key, part, about, ...(verdict && { verdict }), ...(text && { text }), at };
|
|
556
|
+
}
|
|
557
|
+
export const markCommentsRead = (decisionsPath, ids) => { for (const id of ids)
|
|
558
|
+
appendLine(annotationsPath(decisionsPath), { op: 'read', id, at: now() }); };
|
|
559
|
+
export const replyComment = (decisionsPath, id, text) => appendLine(annotationsPath(decisionsPath), { op: 'reply', id, text, at: now() });
|
|
533
560
|
/** Saves a comment (a new id unless it edits one) or, with only an id, deletes it. */
|
|
534
561
|
export const appendAnnotation = (decisionsPath, a) => appendLine(annotationsPath(decisionsPath), { ...a, id: a.id ?? randomBytes(6).toString('hex'), at: now() });
|
|
535
562
|
/** The parts of a guided page that can be read and asked about, with the name the agent hears: this, understood, each section and item, each decision, next. Undefined for a part it has not got. */
|
package/package.json
CHANGED
|
@@ -73,7 +73,7 @@ sign-off refuses a question that breaks these rules, naming the field and one fi
|
|
|
73
73
|
- `--recommend` the answer you would pick, with `--recommend-reason` in one line.
|
|
74
74
|
- `--default`: what happens if the user stays quiet, and `--silent-by <ISO UTC time>` when.
|
|
75
75
|
- A card you posted by mistake: `npx -y sign-off-axi dismiss <key>` hides it, `restore <key>` brings it back. When the user dismisses a card, you are not told: `answers` still returns a dismissed answer.
|
|
76
|
-
- When your task ends (merged, dropped, or answered some other way), run `npx -y sign-off-axi dismiss --filer <id>`: every card you posted that still waits leaves Decide.
|
|
76
|
+
- When your task ends (merged, dropped, or answered some other way), run `npx -y sign-off-axi dismiss --filer <id>`: every card you posted that still waits leaves Decide. Reports leave the main view by themselves a day after they were filed; they wait under Archived on their screen and `restore <key>` brings one back. Evidence stays on the Evidence screen until dismissed, and posting it again under the same key with a changed proof replaces the card. Nothing is deleted.
|
|
77
77
|
|
|
78
78
|
A good yes/no question:
|
|
79
79
|
|
|
@@ -105,18 +105,18 @@ npx -y sign-off-axi raise --ask-ref <captain-message-id-or-hold-id> --title "Whi
|
|
|
105
105
|
- Write each part for the reader, in the order the board shows it: *What this is* (the answer and one picture: bars, steps or tiles), *What I understood* (the request in your words, so they can say it is off), *What I found*, *What I recommend* (plans), *What you decide* (plans, at most five questions, each with your pick and why), *Next step*.
|
|
106
106
|
- Answer first: the heading of the picture is the answer, one plain sentence.
|
|
107
107
|
- One picture per point; the numbers go in `details`, behind a tap, never in the sentence.
|
|
108
|
-
- Short lines: `text` one line, `more` the part that opens in place; never a paragraph the reader must scroll.
|
|
108
|
+
- Short lines: `text` one line, `more` the part that opens in place, with a line break between points; never a paragraph the reader must scroll.
|
|
109
109
|
- Plain words for a person who does not write code; no ids, no file paths, no tool names.
|
|
110
110
|
- Name the date the facts were true if they will go stale.
|
|
111
111
|
- A research report (a comparison, an option study, a "should we use X?" question) must clear a higher bar than an explainer. A few options backed only by your own numbers is rejected.
|
|
112
112
|
- Name the must-haves first. Survey the whole real field, then short-list against them. Say in one line why each option was cut.
|
|
113
|
-
- Give every short-listed option the same axes and its full pros and cons, as one comparable table: one `found` item per option, pros and cons in `text`, and each axis as "Axis: value." in the same order
|
|
113
|
+
- Give every short-listed option the same axes and its full pros and cons, as one comparable table: one `found` item per option, pros and cons in `text`, and in `more` each axis on its own line as "Axis: value.", the same axes in the same order. The board then draws the section as one table; an older board shows the same lines as text.
|
|
114
114
|
- Back each claim with outside proof and its link: GitHub issues (open bugs, regressions, upgrade breaks, how the maintainers answered), Reddit, Hacker News, X, forums and blogs. If a site blocks fetching, say what you could not read. Vendor marketing, star counts and your own team's work-log numbers are not proof.
|
|
115
115
|
- Keep what you checked yourself apart from what others claim: a separate section, or each line starting "Checked:" or "Reported:".
|
|
116
116
|
- End with one clear recommendation and a runner-up, each with its reasons.
|
|
117
117
|
- A plan clears the same research bar for every choice in it, then gives what a go-ahead needs: the options with their pros and cons, your recommendation, the scope (what is in and what is out), the steps in order, each risk with what you do about it, and how done is proven (`Done when` in `understood`, and where the proof lands in `next`).
|
|
118
118
|
- New guided defaults are plan schema 3 and report schema 4 (contract 1), with your required filer. Older schemas still load. Every part offers **Ask a question**, **Go deeper**, **Simplify this**, **Show an example**, **Why?**, plus free text and follow-ups. Ask a question focuses the section composer; text and Send are required. Other presets send immediately. Question/custom need nonblank text (at most 500 code points); free text and follow-ups use custom. Answer the exact action and question in its section: explain, give detail, use simpler words, give an example, or explain why. All use one engagement path: version 1 carries action, immutable owner, document revision and target, with an optional selection (authored field, code-point start/end, exact quote): when present, answer about those quoted words in their place in the document. Read these fields with `deeper --json --all`; answer the requested action from the original document, never rewrite text or change plan decisions. Active replies need `--filer <your id>`; registered sessions acknowledge the exact id first. Stale targets and other owners are refused. The answer expands in place and stays New across reload until visibly read. Keep the owner dispatcher/listener alive after reports and sent plans, and after every reply.
|
|
119
|
-
- The board shows who read each part and when, and the supported presets under every part. When the person taps one, `wait --filer <id>` returns the ask (including action, part and any question), or run `npx -y sign-off-axi deeper <board> --filer <id>` to list the open ones. Active listeners answer in plain words, a few lines at a time: `npx -y sign-off-axi deeper <board> --filer <id> --id <id> --text "<what you found>" --more` for each piece, then once without `--more` to finish. Registered dormant owners instead acknowledge and send one final answer as described above. The board streams each piece into the spot they tapped. Never answer with a long essay: say less, they can ask a follow-up.
|
|
119
|
+
- The board shows who read each part and when, and the supported presets under every part. When the person taps one, `wait --filer <id>` returns the ask (including action, part and any question), or run `npx -y sign-off-axi deeper <board> --filer <id>` to list the open ones. Active listeners answer in plain words, a few lines at a time: `npx -y sign-off-axi deeper <board> --filer <id> --id <id> --text "<what you found>" --more` for each piece, then once without `--more` to finish. Registered dormant owners instead acknowledge and send one final answer as described above. The board streams each piece into the spot they tapped. A person's Agree, Disagree or Comment on a part reaches the same wait as an event with the part and the words; reply to it with the same `deeper --id <id> --text` and change the page if they are right. Never answer with a long essay: say less, they can ask a follow-up.
|
|
120
120
|
- Open the board at 390 px wide and look before you tell the person it is there: the first screen must give the answer and the picture.
|
|
121
121
|
- Only if you must keep an HTML page of your own (a demo to tap), run `report init <folder> --page` and post it with `--type report --view --preview`.
|
|
122
122
|
|
|
@@ -126,7 +126,7 @@ Only where structure, order or dependency is the point: how a change flows, what
|
|
|
126
126
|
|
|
127
127
|
- Add `"diagram": [{"label": "Today", "src": "diagrams/flow.html"}]` (1 to 3 views, shown as tabs) to the `visual`, a section or an item, beside the rows, which stay as the list. `src` is a page next to the plan or report file, as `view` is.
|
|
128
128
|
- Draw an `architecture` diagram with a `viewBox` 320 to 340 wide (320 is Archify's smallest): two columns, labels under about 16 characters, no sublabels, `"legend": {"mode": "hidden"}` in `meta`. Sequence, workflow and `compare` pages are too wide for a phone; show the same flow as a numbered column instead, and draw Today and After as two pages, one per tab.
|
|
129
|
-
- Post refuses a diagram whose smallest text is under 12 px on a 390 px phone, one with brand marks (never set `brand`), or one that is not there.
|
|
129
|
+
- Post refuses a diagram whose smallest text is under 12 px on a 390 px phone, one with brand marks (never set `brand`), or one that is not there. Post pins each page (its `sha256` and `bytes`, written for you): a page changed after posting is not shown, so draw a changed page again and post the plan again.
|
|
130
130
|
- Run `ARCHIFY_UPDATE_CHECK_DISABLED=1 archify finalize` with `--quality showcase`, so it makes no network call. Archify draws in its own mono type; sign-off cannot change it.
|
|
131
131
|
- On a phone the board shows the picture and **Full size**, which opens the drawing alone at the full width of the screen; on a desk **Show me** opens the interactive page in place. Look at both at 390 before you say it is posted.
|
|
132
132
|
|
|
@@ -149,7 +149,7 @@ When the user must choose how something looks or works, do this:
|
|
|
149
149
|
4. Write a short trade-off in plain words (5 lines or fewer: what each variant does well and badly) and recommend one.
|
|
150
150
|
5. Post it on the project board with `npx -y sign-off-axi post .sign-off/decisions.json --type design --key <key>`, one `--option` and one `--mock` per variant, `--tradeoff` and `--recommend` (see `post --help`). Mock paths are relative to `.sign-off/`: `--mock a=mocks/<key>/a.html`, or `--mock a=mocks/<key>/index.html?variant=a` for the one-page form. When a variant also runs live at a URL, add `--link a=<http or https URL>` for it (one per variant): the card shows an Open live link under that mock, which opens in a new tab. Then run `npx -y sign-off-axi open .sign-off/decisions.json` and give the user the URL.
|
|
151
151
|
6. Wait with `npx -y sign-off-axi wait .sign-off/decisions.json --key <key>`.
|
|
152
|
-
7. Read the answer with `npx -y sign-off-axi answers .sign-off/decisions.json --key <key> --json`. A pick holds `answer` and maybe a `note
|
|
152
|
+
7. Read the answer with `npx -y sign-off-axi answers .sign-off/decisions.json --key <key> --json`. A pick holds `answer` and maybe a `note`, the comment the user sent with it; `"answer": "text"` is the user's own answer (Send as my own answer, or Send reply), in its `note`. A `rounds` entry with no `answer`, or newer than the `answer`'s `at`, means the user wants another round: read its note, build 3 new variants and post them under a new key. A `comments` list on the answer or the round holds the user's comments on parts of a mock: `mock` is the option, `ref`, `tag` and `words` name the element, `text` is the comment; apply them to that variant.
|
|
153
153
|
8. Build the winner into the real code, then delete `.sign-off/mocks/<key>/`.
|
|
154
154
|
|
|
155
155
|
Build mocks with a fast, cheap model: a mock is throwaway work. If first-draft is installed, it can build the mocks and write its `Open in first-draft: <label> <url>` links at the end of `--tradeoff`; the board shows them as real links.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Added to each diagram page the board serves. Only on the page opened by itself (Full size, not the card's frame): a way back to the plan or report,
|
|
2
|
+
// and on a phone's drawing-alone page (embed=1) the page is sized to the drawing, with the drawing's own surface carried down the rest of the screen, so no grey area follows it.
|
|
3
|
+
(() => {
|
|
4
|
+
if (parent !== window) return;
|
|
5
|
+
const me = document.currentScript, back = me.dataset.back, label = me.dataset.label;
|
|
6
|
+
const style = document.createElement('style');
|
|
7
|
+
style.textContent = '.so-back { display: flex; align-items: center; gap: 0.375rem; box-sizing: border-box; width: fit-content; min-height: 2.75rem; margin: 0.25rem 0.5rem; padding: 0 0.875rem; border: 1px solid currentColor; border-radius: 999px; color: inherit; font: 600 0.9375rem/1 system-ui, sans-serif; text-decoration: none; }'
|
|
8
|
+
+ '.so-back:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }';
|
|
9
|
+
const surface = document.querySelector('.diagram-container');
|
|
10
|
+
if (new URLSearchParams(location.search).get('embed') === '1') {
|
|
11
|
+
style.textContent += 'html, body { height: auto !important; min-height: 0 !important; }';
|
|
12
|
+
if (surface) {
|
|
13
|
+
// The drawing sits on its own surface over the page's colour; the page takes that surface too, so the screen below the drawing is the drawing's own.
|
|
14
|
+
const s = getComputedStyle(surface);
|
|
15
|
+
style.textContent += `body { background-image: ${s.backgroundImage}, linear-gradient(${s.backgroundColor}, ${s.backgroundColor}) !important; background-size: ${s.backgroundSize}, auto !important; }`
|
|
16
|
+
+ '.diagram-container { background: none !important; }';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const link = document.createElement('a');
|
|
20
|
+
link.className = 'so-back'; link.href = back; link.textContent = `← ${label}`;
|
|
21
|
+
document.head.append(style); document.body.prepend(link);
|
|
22
|
+
})();
|