sign-off-axi 0.6.0 → 0.6.2

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 CHANGED
@@ -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. 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.
80
+ - **Discard** takes a card off the board with one tap on a reason (Not relevant, Replaced by newer or Done elsewhere): it moves to **Archived**, **Undo** brings it back for 10 seconds, and its agent is told why once those 10 seconds are over. **Select** in Decide and Designs discards several at once, and **Restore** under Archived brings one back (`sign-off-axi dismiss <key> --reason replaced` and `restore <key>` do the same from a shell; without `--reason`, `dismiss` hides a card and tells no one). `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
  ### One ticket ties a task's cards together
83
83
 
package/dist/cli.js CHANGED
@@ -14,11 +14,21 @@ 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, appendPromoted, appendSendBack, appendTicket, readTickets, rollUp, appendTicketComment, ticketCommentFault, ticketFault, appendProject, readProjects, projectFault, iconPathFault, iconContentFault, ICON_BYTES, ICON_TYPES, PRIORITIES, appendDesignAnswer, answerLines, appendMark, readTrail, 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, 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';
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
20
  import { copiesOf } from './images.js';
21
21
  import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
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
+ * 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.
24
+ * --version prints `version`; the help header and the board (the sidebar's foot, Today's More) show `label`. */
25
+ const VERSION = ((version) => {
26
+ if (version.includes('-'))
27
+ return { version, label: `${version}, pre-release` };
28
+ const root = new URL('..', import.meta.url);
29
+ const tags = existsSync(new URL('.git', root)) && (spawnSync('git', ['tag', '--points-at', 'HEAD'], { cwd: root, encoding: 'utf8', timeout: 5000 }).stdout ?? '').split('\n');
30
+ return tags && !tags.some((t) => t === version || t === `v${version}`) ? { version: `${version}-dev`, label: `${version}-dev` } : { version, label: version };
31
+ })(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
22
32
  const FORMAT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/file-formats.md';
23
33
  const CONTRACT = 'https://github.com/umeranjum17/sign-off/blob/main/docs/contract.md';
24
34
  /** Coding agents init installs the skill for: the `npx skills add -a` name, the command that shows it is installed,
@@ -31,7 +41,8 @@ const AGENTS = {
31
41
  pi: { bin: 'pi', dir: '.agents/skills', home: join(homedir(), '.agents', 'skills') },
32
42
  'gemini-cli': { bin: 'gemini', dir: '.agents/skills', home: join(homedir(), '.gemini', 'skills') },
33
43
  };
34
- const TOP_HELP = `usage: sign-off-axi [command] [board.json] [flags]
44
+ const TOP_HELP = `version: ${VERSION.label}
45
+ usage: sign-off-axi [command] [board.json] [flags]
35
46
  commands[26]:
36
47
  (none)=list, raise, post, answers, wait, poll, deeper, owner, open, status, stop, serve, export, init, report, signin, dismiss, restore, promote, feedback, ticket, project, refile, doctor, notify, validate, help
37
48
  board: the nearest .sign-off/ board (the walk's bounds and SIGN_OFF_HOME: README, "Test runs never touch your board"), else ~/.sign-off/decisions.json; a board file given always wins
@@ -43,7 +54,7 @@ examples:
43
54
  sign-off-axi post .sign-off/decisions.json --type design --title "Pick the sign-in layout" --option a=A --option b=B --mock a=mocks/a.html --mock b=mocks/b.html --recommend b
44
55
  sign-off-axi post .sign-off/decisions.json --type diagram --title "Is this the sign-in flow?" --view mocks/sign-in/flow.svg
45
56
  sign-off-axi post --type explainer --title "Is the new sign-in clear?" --about "The new sign-in has 2 steps." --media explainer.mp4 --chapter 0:00=Start
46
- sign-off-axi report init .sign-off/reports/sign-in-flow
57
+ sign-off-axi report init .sign-off/reports/sign-in-flow --purpose research
47
58
  sign-off-axi post --kind report --from .sign-off/reports/sign-in-flow/report.json
48
59
  sign-off-axi report init .sign-off/plans/sign-in --plan
49
60
  sign-off-axi post --kind plan --from .sign-off/plans/sign-in/plan.json --filer my-session-id
@@ -106,8 +117,8 @@ examples:
106
117
  `,
107
118
  validate: `usage: sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid]
108
119
  Checks message files against the communication contract without posting them: ${CONTRACT}
109
- A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, evidence, gate, finding and design at schema 1, report at schema 3 (schema 2 still passes) and plan at schema 2 (schema 1 still passes).
110
- A report 3 and a plan 2 are guided pages: fields the board draws as one scroll, no page of your own (see report init).
120
+ A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, evidence, gate, finding and design at schema 1, report at schema 5 (schemas 2 to 4 still pass) and plan at schema 3 (schemas 1 and 2 still pass).
121
+ 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".
111
122
  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.
112
123
  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).
113
124
  A plan's "view" (.html) is checked for its shape here; the file itself is checked at post, from the board file's folder.
@@ -278,15 +289,17 @@ Run it when you see the sign-in worked. A card posted with --start also closes b
278
289
  examples:
279
290
  sign-off-axi signin done --key npm-login
280
291
  `,
281
- dismiss: `usage: sign-off-axi dismiss <key> [board.json]
292
+ dismiss: `usage: sign-off-axi dismiss <key> [board.json] [--reason not-relevant|replaced|done-elsewhere]
282
293
  sign-off-axi dismiss --filer <id> [board.json]
283
- Hides a card from the board, like its Dismiss button. The agent is not told, and an ask it waits on keeps waiting.
294
+ Hides a card from the board. The agent is not told, and an ask it waits on keeps waiting.
295
+ --reason discards it instead, like the board's Discard: the card moves to Archived and its agent's wait hands the reason over (Not relevant, Replaced by newer or Done elsewhere) once Undo's 10 seconds are over.
284
296
  --filer <id> closes a finished task: every card that id still has waiting leaves the board. Run it when your work ends, so nothing of yours is left on Decide.
285
297
  Evidence and reports leave the main view by themselves a day after they were filed; they stay under Archived on their screen, and restore brings one back for good.
286
298
  restore <key> [board.json] brings it back. Find the key with \`sign-off-axi list --full\`.
287
299
  examples:
288
300
  sign-off-axi dismiss ship-the-new-sign-in-screen-this-week
289
301
  sign-off-axi dismiss --filer agent-41
302
+ sign-off-axi dismiss fleet-dashboard-first-look --reason replaced
290
303
  sign-off-axi restore ship-the-new-sign-in-screen-this-week
291
304
  `,
292
305
  refile: `usage: sign-off-axi refile <key> [board.json] --media <file>... [--link <commit or URL>]
@@ -299,7 +312,7 @@ Brings back a card that dismiss hid. See \`sign-off-axi dismiss --help\`.
299
312
  `,
300
313
  ticket: `usage: sign-off-axi ticket [board.json] [--json]
301
314
  sign-off-axi ticket [board.json] --id <id> [--json]
302
- sign-off-axi ticket [board.json] --id <id> [--title <text>] [--progress <0-100>] [--parent <id>] [--priority P0-P3] [--project <name>] [--hold yes|no] [--asked yes|no]
315
+ sign-off-axi ticket [board.json] --id <id> [--title <text>] [--progress <0-100>] [--parent <id>] [--priority P0-P3] [--project <name>] [--hold yes|no] [--asked yes|no] [--release <text>] [--state <state>] [--pr <url>]...
303
316
  sign-off-axi ticket [board.json] --id <id> --comment '<json>'
304
317
  A ticket is one piece of work: its id, title, percent done and, optionally, the ticket it sits under, its priority, its project (whose icon its rows show),
305
318
  whether it is on hold and whether you asked for it (the board's Tickets tab then lists it under Your asks). It stands alone: nothing else needs it.
@@ -309,6 +322,9 @@ With any other flag, writes it: a new ticket needs --title (progress starts at 0
309
322
  A card names its ticket with --ticket <id> on raise and post (every --type and --kind), or "ticket" in a message or --from item:
310
323
  its screen then shows a Ticket row, the title and progress, that opens the ticket, which lists its asks, designs, proof and reports.
311
324
  A card whose ticket is not on the board shows no row; a ticket with no cards shows an empty page.
325
+ --release labels the release (up to 40 characters). --state is queued, "in progress", "on hold" or done; it takes precedence over progress/hold for grouping.
326
+ --pr takes full HTTP(S) URLs, repeatable or comma separated; it replaces the linked PR list. Empty --release, --state or --pr clears that field.
327
+ Without --state, grouping stays Done at 100%, else On hold with --hold yes, else In progress above 0%, else To do.
312
328
  It writes one line to <board>.tickets.jsonl beside the board (${FORMAT}).
313
329
  --comment hands over a comment the person made on the ticket elsewhere (the fleet dashboard), in that dashboard's thread record: {"id","at","text","error","picked","replies":[{"at","text","by"}]},
314
330
  times in whole seconds since 1970. Send it again as it moves: the board shows it Sent, Delivered (no error), Read (picked) and Replied (the last reply) on the ticket, and a reply in Today.
@@ -354,8 +370,13 @@ examples:
354
370
  sign-off-axi feedback fleet-dashboard-full --reply "The colours now come from the new tokens." --link https://github.com/owner/repo/pull/245
355
371
  sign-off-axi feedback fleet-dashboard-full --acted --link https://github.com/owner/repo/pull/245
356
372
  `,
357
- report: `usage: sign-off-axi report init <folder> [--plan] [--page]
373
+ report: `usage: sign-off-axi report init <folder> [--purpose research|plan|decision|audit|incident|status|other] [--plan] [--page]
358
374
  Writes the guided template to fill: <folder>/report.json, or <folder>/plan.json with --plan. A file already there is kept.
375
+ Every report names what it is for in "purpose", its kind: research (research and discovery), plan (implementation plan), decision (decision and recommendation),
376
+ audit, incident (incident and retro), status, or other. --purpose fills it in and writes that kind's parts to fill, one section each, which post then requires
377
+ (research: question, scope, method, sources with links and a user report, options, findings with their confidence, not-checked, recommendation, open-questions;
378
+ plan: goal, current, design with a diagram, slices with their size, tests, risks, rollout, estimate, owner-calls; see the skill for the others).
379
+ The board shows the kind on the report's row and head, its parts as a strip, and filters by it.
359
380
  The board draws it as one scroll: what this is (the answer and one picture), what I understood, what I found and recommend, what you decide (plans), and the next step.
360
381
  Numbers sit behind a Details tap; every part has a Go deeper button that asks you (see \`sign-off-axi deeper --help\`) and a read receipt that shows when the person read it.
361
382
  Replace every FILL: line, then post it: post --kind report --from <folder>/report.json, or post --kind plan --from <folder>/plan.json --filer <your id>.
@@ -653,8 +674,9 @@ function board(file, say = warn) {
653
674
  for (const key of Object.keys(answers))
654
675
  if (!decisions.some((d) => d.key === key))
655
676
  say(`left out the answer for ${key}: that question is no longer in ${basename(file)}`);
656
- const promoted = new Set(readPromoted(file, say));
657
- return decisions.map((d) => ({ ...d, answer: Object.hasOwn(answers, d.key) ? answers[d.key] : undefined, dismissed: hidden.has(d.key), ...(promoted.has(d.key) && { promoted: true }) }));
677
+ // A discard holds while its card is hidden and nothing came after it: no restore, no plain dismiss, no answer or round.
678
+ const promoted = new Set(readPromoted(file, say)), discards = new Map(readDiscards(file).filter((x) => x.last && hidden.has(x.key)).map(({ key, reason, at }) => [key, { reason, at }]));
679
+ return decisions.map((d) => ({ ...d, answer: Object.hasOwn(answers, d.key) ? answers[d.key] : undefined, dismissed: hidden.has(d.key), ...(discards.has(d.key) && { discarded: discards.get(d.key) }), ...(promoted.has(d.key) && { promoted: true }) }));
658
680
  }
659
681
  function ready(file, on, root) {
660
682
  if (!existsSync(file))
@@ -684,7 +706,7 @@ function list(args) {
684
706
  ];
685
707
  const where = tilde(file) + (existsSync(file) ? '' : ' (no file yet; raise creates it)');
686
708
  if (values.full) {
687
- return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: awaitsVerdict(d.type, d.answer) ? (d.dismissed ? 'dismissed' : d.type === 'evidence' ? 'shown' : d.kind === 'design' && !d.promoted ? 'in review' : 'waiting') : d.answer.answer, title: d.title, about: d.about ?? '', detail: d.detail ?? '',
709
+ return toon({ board: where, decisions: rows.map((d) => ({ key: d.key, status: awaitsVerdict(d.type, d.answer) ? (d.discarded ? `discarded: ${DISCARD_REASONS[d.discarded.reason]}` : d.dismissed ? 'dismissed' : d.type === 'evidence' ? 'shown' : d.kind === 'design' && !d.promoted ? 'in review' : 'waiting') : d.answer.answer, title: d.title, about: d.about ?? '', detail: d.detail ?? '',
688
710
  recommend: d.recommend_value ?? '', default_if_silent: d.default_if_silent ?? '', close: d.close })) }, help);
689
711
  }
690
712
  return toon({
@@ -697,12 +719,16 @@ function list(args) {
697
719
  function printAnswers(file, rows, waiting, values, help) {
698
720
  const done = rows.flatMap((d) => (d.answer ? [{ ...d.answer, title: d.title }] : []));
699
721
  const rounds = readRounds(file).filter((r) => rows.some((d) => d.key === r.key));
722
+ // A card the person discarded, with their reason: it left the board and nothing more will come on it.
723
+ 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 }] : []));
700
724
  if (values.json) {
701
725
  const asked = (key) => rounds.filter((r) => r.key === key).map(({ note, at, comments }) => ({ note, at, comments }));
702
726
  const out = Object.fromEntries(done.map(({ key, answer, label, close, at, note, comments, notes, decisions, pick, replaces }) => [key, { answer, label, close, at, note, comments, notes, decisions, pick, replaces, ...(asked(key).length && { rounds: asked(key) }) }]));
703
727
  for (const r of rounds)
704
728
  if (!out[r.key])
705
729
  out[r.key] = { rounds: asked(r.key) };
730
+ for (const { key, reason, label, at } of gone)
731
+ out[key] = { ...out[key], discarded: { reason, label, at } };
706
732
  return JSON.stringify(out);
707
733
  }
708
734
  const notes = done.some((a) => a.note), changed = done.some((a) => a.replaces), sent = [...done, ...rounds].flatMap(({ key, comments = [] }) => comments.map(({ mock, ref, tag, words, text }) => ({ key, mock, ref, tag, words, text })));
@@ -719,6 +745,7 @@ function printAnswers(file, rows, waiting, values, help) {
719
745
  ...(sent.length && { comments: sent }),
720
746
  // A plan's send-back: each section's verdict and note, and each decision's pick.
721
747
  ...(plan.length && { plan }),
748
+ ...(gone.length && { discarded: gone.map(({ key, label, at }) => ({ key, reason: label, at })) }),
722
749
  waiting,
723
750
  }, help);
724
751
  }
@@ -747,6 +774,17 @@ function eventLines(file, rows, answers, rounds) {
747
774
  ...rounds.map(({ key, note, at, comments }) => ({ at, line: { ...head('round', key), note, at, ...tail(key, comments) } }))]
748
775
  .sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)).map(({ line }) => JSON.stringify(line)).join('\n');
749
776
  }
777
+ /** Discarded cards as the agent reads them: each key, the person's reason and when; nothing more will come on them. */
778
+ function discardOut(file, gone, on, values) {
779
+ const rows = gone.map((d) => ({ key: d.key, title: d.title, reason: d.discarded.reason, label: DISCARD_REASONS[d.discarded.reason], at: d.discarded.at }));
780
+ if (values.events) {
781
+ const kind = kindOf(file);
782
+ return rows.map(({ key, title, reason, label, at }) => JSON.stringify({ event: 'discarded', ...kind(key), key, title, reason, label, at, ...(gone.find((d) => d.key === key).filer && { filer: gone.find((d) => d.key === key).filer }) })).join('\n');
783
+ }
784
+ if (values.json)
785
+ return JSON.stringify(Object.fromEntries(rows.map(({ key, reason, label, at }) => [key, { discarded: { reason, label, at } }])));
786
+ return toon({ board: tilde(file), discarded: rows.map(({ key, title, label, at }) => ({ key, reason: label, at, title })) }, ['The person discarded these cards: stop the work they asked for', `Run \`sign-off-axi wait${on}\` for the next answer`]);
787
+ }
750
788
  /** Go deeper asks as the agent reads them: what part, what it is about, the question if the person typed one, and how to answer. */
751
789
  function deeperOut(file, asks, on, events) {
752
790
  if (events) {
@@ -919,6 +957,18 @@ async function wait(args) {
919
957
  if (found.length) {
920
958
  return printAnswers(file, found, waiting, values, [waiting ? `Run \`sign-off-axi wait${on}\` for the next of the ${waiting} waiting` : filer !== undefined && rows.some((d) => d.guided) ? `Run \`sign-off-axi wait${on}\` for the next Go deeper ask` : `Run \`sign-off-axi stop${onBoard}\` to close the board`]);
921
959
  }
960
+ // A card the person discarded wakes the wait once its Undo window is over, so an undone discard never reaches the agent:
961
+ // the filer's own wait takes it (read), any other passes it on (delivered); a keyed wait reads it each time, as it reads an answer.
962
+ const trail = readTrail(file, loadDecisions(file)), sent = (d) => d.discarded && trail.find((t) => t.kind === 'discard' && t.key === d.key && t.at === d.discarded.at);
963
+ const untaken = (d) => { const t = sent(d); return !!t && (values.key !== undefined ? d.key === values.key : !(filer !== undefined ? t.read : t.delivered)); };
964
+ const gone = await locked(`${trailPath(file)}.lock`, () => {
965
+ const got = rows.filter((d) => untaken(d) && Date.now() - Date.parse(d.discarded.at) >= UNDO_MS);
966
+ for (const d of got)
967
+ appendMark(file, { op: filer !== undefined ? 'read' : 'delivered', key: d.key });
968
+ return got;
969
+ }, 'another wait is still taking discards');
970
+ if (gone.length)
971
+ return discardOut(file, gone, on, values);
922
972
  // A Go deeper ask on this filer's guided page wakes the wait once, and is marked read when it is handed over.
923
973
  if (filer !== undefined) {
924
974
  const asks = await locked(`${deeperPath(file)}.lock`, () => {
@@ -965,8 +1015,8 @@ async function wait(args) {
965
1015
  if (event)
966
1016
  return vaultEvent(file, [{ key: values.key, at: event.at, status: event.status }], values);
967
1017
  }
968
- // A dismissed ask is hidden from the board, not withdrawn: it is not counted as waiting, but the agent still waits on it.
969
- if (values.key === undefined && !rows.some((d) => waits({ ...d, dismissed: false }) || (filer !== undefined && d.guided)) && !live && !unknown) {
1018
+ // A dismissed ask is hidden from the board, not withdrawn: it is not counted as waiting, but the agent still waits on it. A discarded one is withdrawn once handed over.
1019
+ if (values.key === undefined && !rows.some((d) => waits({ ...d, dismissed: !!d.discarded && !untaken(d) }) || (filer !== undefined && d.guided)) && !live && !unknown) {
970
1020
  throw fail(`nothing${filer ? ` posted by ${filer}` : ''} is waiting on ${tilde(file)}`, 'NOT_FOUND', `Run \`sign-off-axi answers${onBoard}\` to read the saved answers`, `Run \`sign-off-axi raise${on} --title "<question>"\` to ask a new one`);
971
1021
  }
972
1022
  const left = deadline - Date.now();
@@ -1261,9 +1311,12 @@ function dismiss(restoring) {
1261
1311
  const cmd = restoring ? 'restore' : 'dismiss';
1262
1312
  return (args) => {
1263
1313
  // dismiss --filer <id> closes a finished task: every card that filer still has waiting leaves the board.
1264
- const f = restoring ? -1 : args.findIndex((a) => a === '--filer'), filer = f < 0 ? undefined : args[f + 1], rest = f < 0 ? args : args.filter((_, i) => i !== f && i !== f + 1);
1314
+ const flag = (name) => (restoring ? -1 : args.findIndex((a) => a === name)), f = flag('--filer'), r = flag('--reason');
1315
+ const filer = f < 0 ? undefined : args[f + 1], reason = r < 0 ? undefined : args[r + 1], drop = [f, r].filter((x) => x >= 0).flatMap((x) => [x, x + 1]), rest = args.filter((_, i) => !drop.includes(i));
1265
1316
  if (f >= 0 && !isKey(filer))
1266
1317
  throw fail('--filer needs an id of 1-128 of A-Z a-z 0-9 . _ -', 'VALIDATION_ERROR', 'Pass the id you posted with');
1318
+ if (r >= 0 && (f >= 0 || !Object.hasOwn(DISCARD_REASONS, reason ?? '')))
1319
+ throw fail(f >= 0 ? '--reason discards one card, so it does not take --filer' : `--reason must be one of ${Object.keys(DISCARD_REASONS).join(', ')}`, 'VALIDATION_ERROR', 'sign-off-axi dismiss <key> --reason replaced');
1267
1320
  const at = rest.findIndex((a) => !a.startsWith('-') && !existsSync(a) && !a.endsWith('.json')), key = rest[at];
1268
1321
  if (at < 0 && f < 0)
1269
1322
  throw fail(`${cmd} needs a card key`, 'VALIDATION_ERROR', `sign-off-axi ${cmd} <key> [board.json]`);
@@ -1277,8 +1330,8 @@ function dismiss(restoring) {
1277
1330
  }
1278
1331
  if (!isKey(key) || !board(file).some((x) => x.key === key))
1279
1332
  throw fail(`no card with key ${key} on ${tilde(file)}`, 'NOT_FOUND', `Run \`sign-off-axi list${on} --full\` to see the keys`);
1280
- appendDismissed(file, [key], !restoring);
1281
- return toon({ board: tilde(file), [restoring ? 'restored' : 'dismissed']: [key] }, [`Run \`sign-off-axi ${restoring ? 'dismiss' : 'restore'}${on} ${key}\` to undo`]);
1333
+ appendDismissed(file, [key], !restoring, reason);
1334
+ return toon({ board: tilde(file), [restoring ? 'restored' : reason ? 'discarded' : 'dismissed']: [key], ...(reason && { reason: DISCARD_REASONS[reason] }) }, [`Run \`sign-off-axi ${restoring ? 'dismiss' : 'restore'}${on} ${key}\` to undo`]);
1282
1335
  });
1283
1336
  };
1284
1337
  }
@@ -1334,14 +1387,24 @@ function feedback(args) {
1334
1387
  * What it prints carries the roll-up: a ticket with tickets under it is as far along as they are on average. */
1335
1388
  function ticket(args) {
1336
1389
  const { file, values, on } = parse('ticket', args, { id: { type: 'string' }, title: { type: 'string' }, progress: { type: 'string' }, parent: { type: 'string' }, priority: { type: 'string' },
1337
- project: { type: 'string' }, hold: { type: 'string' }, asked: { type: 'string' }, comment: { type: 'string' }, json: { type: 'boolean' } });
1390
+ project: { type: 'string' }, hold: { type: 'string' }, asked: { type: 'string' }, release: { type: 'string' }, state: { type: 'string' }, pr: { type: 'string', multiple: true }, comment: { type: 'string' }, json: { type: 'boolean' } });
1338
1391
  if (values.comment !== undefined)
1339
1392
  return ticketComment(file, values, on);
1340
- const { id, title, parent } = values, write = [title, values.progress, parent, values.priority, values.project, values.hold, values.asked].some((v) => v !== undefined);
1393
+ const { id, title, parent } = values, write = [title, values.progress, parent, values.priority, values.project, values.hold, values.asked, values.release, values.state, values.pr].some((v) => v !== undefined);
1341
1394
  if (id === undefined && write)
1342
- throw fail('--title, --progress, --parent, --priority, --project, --hold and --asked need --id <ticket id>', 'VALIDATION_ERROR', 'sign-off-axi ticket --id T-29 --title "Ship the new settings page" --progress 40');
1343
- const cardsOf = (t) => board(file).filter((d) => d.ticket === t.id && !d.dismissed).reverse().sort((x, y) => (Date.parse(y.filed ?? '') || 0) - (Date.parse(x.filed ?? '') || 0));
1344
- const row = (t) => ({ id: t.id, title: t.title, progress: t.progress, parent: t.parent ?? '', priority: t.priority ?? '', project: t.project ?? '', hold: t.hold ? 'yes' : 'no', asked: t.asked ? 'yes' : 'no' });
1395
+ throw fail('--title, --progress, --parent, --priority, --project, --hold, --asked, --release, --state and --pr need --id <ticket id>', 'VALIDATION_ERROR', 'sign-off-axi ticket --id T-29 --title "Ship the new settings page" --progress 40');
1396
+ // The board is read once per command, and its warnings print once, however many tickets name cards.
1397
+ let byTicket;
1398
+ const cardsOf = (t) => {
1399
+ if (!byTicket) {
1400
+ byTicket = new Map();
1401
+ for (const d of board(file))
1402
+ if (d.ticket !== undefined && !d.dismissed)
1403
+ byTicket.set(d.ticket, [...(byTicket.get(d.ticket) ?? []), d]);
1404
+ }
1405
+ return [...(byTicket.get(t.id) ?? [])].reverse().sort((x, y) => (Date.parse(y.filed ?? '') || 0) - (Date.parse(x.filed ?? '') || 0));
1406
+ };
1407
+ const row = (t) => ({ id: t.id, title: t.title, progress: t.progress, parent: t.parent ?? '', priority: t.priority ?? '', project: t.project ?? '', hold: t.hold ? 'yes' : 'no', asked: t.asked ? 'yes' : 'no', release: t.release ?? '', state: t.state ?? '', prs: t.prs ?? [] });
1345
1408
  if (id === undefined) {
1346
1409
  const all = rollUp(readTickets(file, warn));
1347
1410
  if (values.json)
@@ -1371,14 +1434,14 @@ function ticket(args) {
1371
1434
  throw fail(`no ticket ${id} on ${tilde(file)} yet: a new ticket needs --title`, 'VALIDATION_ERROR', `sign-off-axi ticket${on} --id ${id} --title "<what the work is>" --progress 0`);
1372
1435
  // A later write changes only what it gives; an empty --parent, --priority, --project, --hold or --asked takes that field off.
1373
1436
  const next = { ...had, id, title: title ?? had.title, progress: progress ?? had?.progress ?? 0 };
1374
- for (const [k, v] of Object.entries({ parent, priority: values.priority, project: values.project, hold: yesNo(values.hold), asked: yesNo(values.asked) }))
1437
+ for (const [k, v] of Object.entries({ parent, priority: values.priority, project: values.project, hold: yesNo(values.hold), asked: yesNo(values.asked), release: values.release, state: values.state, prs: values.pr === undefined ? undefined : values.pr.every((v) => v === '') ? '' : [...new Set(values.pr.flatMap((v) => v.split(',').map((u) => u.trim())))] }))
1375
1438
  if (v === '')
1376
1439
  delete next[k];
1377
1440
  else if (v !== undefined)
1378
1441
  next[k] = v;
1379
1442
  const fault = ticketFault(next);
1380
1443
  if (fault)
1381
- throw fail(`ticket ${id}: ${fault.replace(/^(title|progress|parent|priority|project|hold|asked)/, '--$1').replace('must be true or false', 'must be yes or no')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi ticket --help`');
1444
+ throw fail(`ticket ${id}: ${fault.replace(/^(title|progress|parent|priority|project|hold|asked|release|state|prs)/, '--$1').replace(/^--prs\b/, '--pr').replace('must be true or false', 'must be yes or no')}`, 'VALIDATION_ERROR', 'Run `sign-off-axi ticket --help`');
1382
1445
  const same = had && JSON.stringify(row(had)) === JSON.stringify(row(next));
1383
1446
  if (!same)
1384
1447
  appendTicket(file, next);
@@ -1391,7 +1454,7 @@ function ticketComment(file, values, on) {
1391
1454
  const { id } = values, EXAMPLE = `sign-off-axi ticket${on} --id T-29 --comment '{"id":"3f9a1c2e7b6d4a10","at":1792820000,"text":"Is the export in this?","error":null,"picked":1792820300,"replies":[]}'`;
1392
1455
  if (id === undefined || !isKey(id))
1393
1456
  throw fail('--comment needs --id <ticket id>, 1-128 of A-Z a-z 0-9 . _ -', 'VALIDATION_ERROR', EXAMPLE);
1394
- if (['title', 'progress', 'parent', 'priority', 'project', 'hold', 'asked'].some((k) => values[k] !== undefined))
1457
+ if (['title', 'progress', 'parent', 'priority', 'project', 'hold', 'asked', 'release', 'state', 'pr'].some((k) => values[k] !== undefined))
1395
1458
  throw fail('--comment is written on its own, with no other ticket field', 'VALIDATION_ERROR', EXAMPLE);
1396
1459
  let c;
1397
1460
  try {
@@ -2434,16 +2497,52 @@ function init(args) {
2434
2497
  `Run \`sign-off-axi doctor ${shellPath(dir)}\` to check the setup`, `Run \`sign-off-axi open ${shellPath(board)}\` to show the board to the user`
2435
2498
  ]);
2436
2499
  }
2500
+ /** What each part of a report 5 asks for, as its template's FILL lines, and whether it reads as what was found or what is recommended; the items a part needs come with it. */
2501
+ const PART_FILL = {
2502
+ question: ['the question, and why it matters now', 'found'], scope: ['what is in and what is out', 'found'], method: ['how you looked: what you read, ran and asked', 'found'],
2503
+ sources: ['where the answers come from', 'found', [
2504
+ { title: 'FILL: a primary source (docs, code, a maintainer)', text: 'FILL: what it says, in one line', link: 'FILL: https link', source: 'primary' },
2505
+ { title: 'FILL: another primary source', text: 'FILL: what it says', link: 'FILL: https link', source: 'primary' },
2506
+ { title: 'FILL: what users report (an issue, a forum, Reddit, Hacker News)', text: 'FILL: what they say, with how many', link: 'FILL: https link', source: 'user' }
2507
+ ]],
2508
+ options: ['the options, on the same axes', 'found', [
2509
+ { title: 'FILL: option A', text: 'FILL: its pros and cons in one line', more: 'FILL: Cost: value.\nFILL: Setup: value.' },
2510
+ { title: 'FILL: option B', text: 'FILL: its pros and cons in one line', more: 'FILL: Cost: value.\nFILL: Setup: value.' }
2511
+ ]],
2512
+ findings: ['what you found', 'found', [{ title: 'FILL: one finding', text: 'FILL: one line, with its source', confidence: 'medium' }]],
2513
+ 'not-checked': ['what you did not check, and what you could not read', 'found'],
2514
+ recommendation: ['what you recommend, and the runner-up', 'recommend'], 'open-questions': ['what is still open', 'recommend'],
2515
+ goal: ['the goal, and what is not a goal', 'found'], current: ['how it works today', 'found'],
2516
+ design: ['the design, with its diagram', 'recommend'], slices: ['the slices, in order, each with its size', 'recommend', [{ title: 'FILL: slice 1', text: 'FILL: what ships in it', size: 'FILL: S, half a day' }]],
2517
+ tests: ['how each slice is tested and proven', 'recommend', [{ title: 'FILL: slice 1', text: 'FILL: the test, and the proof that it works' }]],
2518
+ risks: ['each risk, and how you roll back', 'recommend'], rollout: ['how it reaches people', 'recommend'], estimate: ['how long it takes, as a range', 'recommend'],
2519
+ 'owner-calls': ['what you need the user to decide', 'recommend'], context: ['the forces at play, said plainly', 'found'],
2520
+ consequences: ['what follows from the choice, good and bad', 'recommend'], standard: ['the standard checked against, with its link', 'found'],
2521
+ fixes: ['what to fix, most urgent first', 'recommend'], impact: ['who was hurt, how much and for how long', 'found'],
2522
+ timeline: ['what happened, in order, with times', 'found', [{ title: 'FILL: 09:12', text: 'FILL: what happened' }]], 'root-cause': ['why it happened (the system, not a person)', 'found'],
2523
+ actions: ['what changes so it does not happen again', 'recommend'], done: ['what is done', 'found'], 'in-progress': ['what is in progress', 'found'],
2524
+ blocked: ['what is held up, and on what', 'found'], 'up-next': ['what comes next', 'recommend'],
2525
+ };
2526
+ /** A report 5's sections for its kind: one per part, each a FILL line naming what it asks for; the plan's design carries a diagram to draw. */
2527
+ const kindSections = (purpose) => REPORT_PARTS[purpose].map(([part, name]) => {
2528
+ const [ask, stage, items] = PART_FILL[part];
2529
+ return { id: part, part, stage, heading: `FILL: ${name}: ${ask}`, body: `FILL: ${ask}, in a line or two`, ...(items && { items }),
2530
+ ...(purpose === 'plan' && part === 'design' && { diagram: [{ label: 'Flow', src: 'FILL: diagrams/flow.html, inline SVG in light and dark' }] }) };
2531
+ });
2437
2532
  /** report init <folder>: the guided template (report.json, or plan.json with --plan) to fill in, or with --page the older scaffold page and its picture. */
2438
2533
  function report(args) {
2439
2534
  let parsed;
2440
2535
  try {
2441
- parsed = parseArgs({ args, allowPositionals: true, strict: true, options: { plan: { type: 'boolean' }, page: { type: 'boolean' } } });
2536
+ parsed = parseArgs({ args, allowPositionals: true, strict: true, options: { plan: { type: 'boolean' }, page: { type: 'boolean' }, purpose: { type: 'string' } } });
2442
2537
  }
2443
2538
  catch (e) {
2444
2539
  throw fail(e.message, 'VALIDATION_ERROR', 'Run `sign-off-axi report --help`');
2445
2540
  }
2446
- const [sub, dir, extra] = parsed.positionals, { plan, page } = parsed.values;
2541
+ const [sub, dir, extra] = parsed.positionals, { plan, page, purpose } = parsed.values;
2542
+ if (purpose !== undefined && (plan || page))
2543
+ throw fail(`--purpose does not go with --${plan ? 'plan' : 'page'}: ${plan ? 'a plan is always an implementation plan' : 'a page report has no purpose field'}`, 'VALIDATION_ERROR', 'sign-off-axi report init <folder> --purpose research');
2544
+ if (purpose !== undefined && !REPORT_PURPOSES.includes(purpose))
2545
+ throw fail(`--purpose ${purpose} must be one of ${REPORT_PURPOSES.join(', ')}`, 'VALIDATION_ERROR', 'sign-off-axi report init <folder> --purpose research');
2447
2546
  if (sub !== 'init' || dir === undefined || extra !== undefined || (plan && page)) {
2448
2547
  throw fail(sub !== 'init' ? `unknown report command ${sub ?? '(none)'}` : extra !== undefined ? `unexpected argument ${extra}: report init takes one folder` : plan && page ? '--plan and --page do not go together' : 'report init needs the folder to write the template in', 'VALIDATION_ERROR', 'sign-off-axi report init <folder> [--plan]');
2449
2548
  }
@@ -2457,10 +2556,14 @@ function report(args) {
2457
2556
  kept.push(shellPath(path));
2458
2557
  else {
2459
2558
  const filed = new Date().toISOString().replace(/\.\d+Z$/, 'Z');
2460
- writeFileSync(path, readFileSync(new URL(`../web/${kind}-template.json`, import.meta.url), 'utf8').replace('"FILL: UTC time"', JSON.stringify(filed)), { flag: 'wx' });
2559
+ const template = readFileSync(new URL(`../web/${kind}-template.json`, import.meta.url), 'utf8').replace('"FILL: UTC time"', JSON.stringify(filed));
2560
+ // With --purpose the template holds that kind's parts, one section each; with none, the general shape and purpose to fill.
2561
+ const kinded = purpose && { ...JSON.parse(template), purpose, ...(REPORT_PARTS[purpose].length && { sections: kindSections(purpose) }) };
2562
+ writeFileSync(path, kinded ? `${JSON.stringify(kinded, null, 2)}\n` : template, { flag: 'wx' });
2461
2563
  created.push(shellPath(path));
2462
2564
  }
2463
2565
  return toon({ folder: shellPath(out), created: created.length ? created : 'nothing new (re-run is a no-op)', kept }, [
2566
+ ...(plan || purpose ? [] : [`Set "purpose" to what the report is for: ${REPORT_PURPOSES.join(', ')} (or run report init again with --purpose <kind> in a new folder)`]),
2464
2567
  `Replace every FILL: line in ${kind}.json with your own words: the answer first, one picture (bars, steps or tiles), what you understood, then the sections${plan ? ' and the decisions' : ''}`,
2465
2568
  `${plan ? 'A plan' : 'A design or architecture report'} needs one real diagram: a flow or architecture drawing as inline SVG, in light and dark, as "diagram" on the visual (see the skill)`,
2466
2569
  'Keep each line short: the board shows it in one scroll, the numbers behind a Details tap and the long answer behind Go deeper',
@@ -2975,6 +3078,7 @@ const state = () => {
2975
3078
  readonly: extra.readonly === true,
2976
3079
  answers_only: extra.answers_only === true,
2977
3080
  ...profile(),
3081
+ version: VERSION.label,
2978
3082
  answers,
2979
3083
  seen: Object.values(answers).filter(taken).map((a) => a.key),
2980
3084
  seen_at: Object.fromEntries(Object.values(answers).flatMap((a) => { const t = seenAt(file)(a); return t ? [[a.key, t]] : []; })),
@@ -2987,6 +3091,7 @@ const state = () => {
2987
3091
  annotations: fed ? [] : ((sent) => readAnnotations(file, warn).map((a) => (sent.has(a.id) ? { ...a, sent: true } : a)))(sentIds(file)),
2988
3092
  comments: extra.readonly === true ? [] : readPartComments(file),
2989
3093
  dismissed: readDismissed(file, answers, rounds, warn, fed ? [] : items),
3094
+ discarded: Object.fromEntries(readDiscards(file).filter((x) => x.last).map(({ key, reason, at }) => [key, { reason, at }])),
2990
3095
  promoted: readPromoted(file, warn),
2991
3096
  tickets: rollUp(readTickets(file, warn)),
2992
3097
  projects: readProjects(file, warn),
@@ -3343,13 +3448,15 @@ async function route(req) {
3343
3448
  return [200, state()];
3344
3449
  }
3345
3450
  if (req.url === '/api/dismiss') {
3346
- const { keys, dismissed } = JSON.parse(body) ?? {};
3451
+ const { keys, dismissed, reason } = JSON.parse(body) ?? {};
3347
3452
  if (!Array.isArray(keys) || !keys.length || keys.length > 200 || typeof dismissed !== 'boolean')
3348
3453
  return [400, { error: 'Send 1 to 200 keys and dismissed true or false' }];
3454
+ if (reason !== undefined && (!dismissed || !Object.hasOwn(DISCARD_REASONS, reason)))
3455
+ return [400, { error: `A discard's reason is one of ${Object.keys(DISCARD_REASONS).join(', ')}` }];
3349
3456
  const have = new Map(board(file).map((x) => [x.key, x]));
3350
3457
  if (!keys.every((k) => isKey(k) && have.has(k)))
3351
3458
  return [404, { error: 'This item is no longer in the file' }];
3352
- appendDismissed(file, [...new Set(keys)], dismissed);
3459
+ appendDismissed(file, [...new Set(keys)], dismissed, reason);
3353
3460
  return [200, state()];
3354
3461
  }
3355
3462
  if (req.url === '/api/engagement' || req.url === '/api/engagement/seen') {
@@ -3641,7 +3748,7 @@ if (argv[0]?.startsWith('-') && !['--help', '-v', '-V', '--version'].includes(ar
3641
3748
  argv.unshift('list');
3642
3749
  await runAxiCli({
3643
3750
  description: 'Ask the user yes/no or pick-one questions on one local board and read their answers back',
3644
- version: JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version,
3751
+ version: VERSION.version,
3645
3752
  argv,
3646
3753
  topLevelHelp: TOP_HELP,
3647
3754
  getCommandHelp: (cmd) => HELP[cmd],
package/dist/contract.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { createHash } from 'node:crypto';
3
3
  import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
4
4
  import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
5
- import { isKey, MEDIA_MAX, PRIORITIES, REPORT_FORMS } from './store.js';
5
+ import { isKey, MEDIA_MAX, PRIORITIES, REPORT_FORMS, REPORT_PARTS, REPORT_PURPOSES } from './store.js';
6
6
  /** A message that does not match: what is wrong, and the one line that says how to fix it. */
7
7
  export class ContractError extends Error {
8
8
  help;
@@ -12,11 +12,13 @@ export class ContractError extends Error {
12
12
  }
13
13
  }
14
14
  /** The kinds, with the schema number of each. */
15
- export const SCHEMAS = { ask: 1, report: 4, evidence: 1, gate: 1, finding: 1, plan: 3, design: 1 };
16
- /** Older schemas this sign-off-axi still takes, so a board that holds them loads and re-posts: the page-based report 2 and plan 1. */
17
- const OLDER = { report: [2, 3], plan: [1, 2] };
15
+ export const SCHEMAS = { ask: 1, report: 5, evidence: 1, gate: 1, finding: 1, plan: 3, design: 1 };
16
+ /** Older schemas this sign-off-axi still takes, so a board that holds them loads and re-posts: the page-based report 2, the guided report 3 and 4 (4 with no purpose), and plan 1 and 2. */
17
+ const OLDER = { report: [2, 3, 4], plan: [1, 2] };
18
18
  /** The guided schemas: a report 3 and a plan 2 are fields the board draws as one guided page, not a page the agent draws. */
19
- export const guided = (m) => (m?.kind === 'report' && [3, 4].includes(m.schema)) || (m?.kind === 'plan' && [2, 3].includes(m.schema));
19
+ export const guided = (m) => (m?.kind === 'report' && [3, 4, 5].includes(m.schema)) || (m?.kind === 'plan' && [2, 3].includes(m.schema));
20
+ /** The schemas with versioned engagement, whose owning filer is required: report 4 and 5, plan 3. */
21
+ export const engaged = (m) => (m?.kind === 'report' && [4, 5].includes(m.schema)) || (m?.kind === 'plan' && m.schema === 3);
20
22
  export const KINDS = Object.keys(SCHEMAS);
21
23
  const COMMON = ['contract', 'kind', 'schema', 'key', 'title', 'filer', 'ticket', 'priority', 'link', 'about'];
22
24
  const FIELDS = {
@@ -30,7 +32,7 @@ const FIELDS = {
30
32
  };
31
33
  const GUIDE = ['understood', 'visual', 'sections', 'next'];
32
34
  const GUIDE_FIELDS = {
33
- report: [...COMMON, 'filed', 'group', 'decided', 'asked_by', ...GUIDE],
35
+ report: [...COMMON, 'filed', 'group', 'decided', 'asked_by', 'purpose', ...GUIDE],
34
36
  plan: [...COMMON, 'project', 'decisions', ...GUIDE],
35
37
  };
36
38
  /** A done-done run: its id, full commit and the four Checks lines. */
@@ -223,6 +225,32 @@ export function checkMessage(m, want, flags = false) {
223
225
  if (/^([\\/]|[A-Za-z]:)/.test(o.src) || o.src.split(/[\\/]/).includes('..') || /[?#\\]/.test(o.src) || !/\.html?$/i.test(o.src))
224
226
  throw bad(`${field}[${n}].src`, `${show(o.src)} must be a relative .html path with no .., ?, # or \\`, 'Draw the diagram as inline SVG in an .html page (by hand or with the archify skill), then set "src" to that file next to the message');
225
227
  });
228
+ /** A report 5's parts: each part of its kind once, in a section naming it, with what that part needs (a source's link, a finding's confidence, a slice's size, a design's diagram). */
229
+ const kindParts = (parts) => {
230
+ const init = `Run \`${REPORT_INIT.replace('<folder>', '<new folder>')} --purpose ${m.purpose}\` for a template with every part, or add the section`;
231
+ const of = (part) => m.sections.findIndex((o) => o.part === part), items = (part) => m.sections[of(part)].items ?? [];
232
+ for (const [part, name] of parts) {
233
+ const at = of(part), twice = m.sections.findIndex((o, n) => o.part === part && n > at);
234
+ if (at < 0)
235
+ throw bad('sections', `has no ${show(part)} part (${name}): a ${m.purpose} report needs ${parts.map(([p]) => p).join(', ')}`, `Add a section with "part": ${show(part)}, ${name.toLowerCase()}. ${init}`);
236
+ if (twice >= 0)
237
+ throw bad(`sections[${twice}].part`, `repeats ${part}`, 'Give each part one section');
238
+ }
239
+ const need = (part, ok, rule, help) => { if (!ok)
240
+ throw bad(`sections[${of(part)}]`, `(${part}) ${rule}`, help); };
241
+ if (m.purpose === 'research') {
242
+ need('sources', items('sources').length >= 3 && items('sources').every((it) => it.link && it.source), 'must list at least 3 sources, each with its "link" and "source"', 'List each source as an item with "link" (where to read it) and "source": "primary" (docs, code, a maintainer) or "user" (what users report)');
243
+ need('sources', items('sources').some((it) => it.source === 'user'), 'must hold at least one user report ("source": "user")', 'Add what users report, with its link: a GitHub issue, a forum thread, Reddit, Hacker News or X; if a site blocked you, say so in "not-checked"');
244
+ need('options', items('options').length >= 2, 'must compare at least 2 options, one item each', 'Give each option an item with the same axes in "more", one "Axis: value." per line, so the board draws one table');
245
+ need('findings', items('findings').length >= 1 && items('findings').every((it) => it.confidence), 'must list each finding with its "confidence"', 'Give each finding an item with "confidence": "high", "medium" or "low"');
246
+ }
247
+ if (m.purpose === 'plan') {
248
+ const d = m.sections[of('design')];
249
+ need('design', !!(d.diagram || (d.items ?? []).some((it) => it.diagram) || m.visual?.diagram), 'needs a diagram (on the section, an item or the visual)', 'Draw the design as inline SVG in an .html page and add "diagram": [{"label": "Flow", "src": "diagrams/flow.html"}] (see the skill)');
250
+ need('slices', items('slices').length >= 1 && items('slices').every((it) => it.size), 'must list each slice with its "size"', 'Give each slice an item with "size", such as "S, half a day"');
251
+ need('tests', items('tests').length >= 1, 'must list how each slice is tested and proven', 'Add an item per slice: the test and the proof that it works');
252
+ }
253
+ };
226
254
  /** The guided page every plan 2 and report 3 is: what I understood, one picture, sections with expandable items, and the next step. Returns the section ids. */
227
255
  const guide = (minSections) => {
228
256
  list('understood', 1, 6, ['label', 'text'], '{"label":"You asked","text":"Make the first start smooth."}').forEach((o, n) => {
@@ -267,7 +295,9 @@ export function checkMessage(m, want, flags = false) {
267
295
  details('visual.details', v.details);
268
296
  if (Object.hasOwn(v, 'diagram'))
269
297
  diagrams('visual.diagram', v.diagram);
270
- const ids = list('sections', minSections, 6, ['id', 'stage', 'heading', 'body', 'items', 'details', 'diagram'], '{"id":"steps","stage":"recommend","heading":"Eight steps, 22 to 31 Oct","body":"..."}').map((o, n) => {
298
+ // A report 5 is built of its kind's parts: up to 10 sections, each naming the part it is, and items that carry a source's link, a finding's confidence or a slice's size.
299
+ const v5 = m.kind === 'report' && m.schema >= 5, parts = v5 ? REPORT_PARTS[m.purpose] ?? [] : [];
300
+ const ids = list('sections', minSections, v5 ? 10 : 6, ['id', 'stage', 'heading', 'body', 'items', 'details', 'diagram', ...(v5 ? ['part'] : [])], '{"id":"steps","stage":"recommend","heading":"Eight steps, 22 to 31 Oct","body":"..."}').map((o, n) => {
271
301
  const at = `sections[${n}]`;
272
302
  if (!(typeof o.id === 'string' && /^[a-z0-9-]{1,40}$/.test(o.id)))
273
303
  throw bad(`${at}.id`, 'must be 1-40 of a-z 0-9 -', 'Set "id" to a short slug such as "steps"');
@@ -275,8 +305,11 @@ export function checkMessage(m, want, flags = false) {
275
305
  throw bad(`${at}.stage`, `${show(o.stage)} is not found or recommend`, 'Set "stage" to "found" (what I found) or "recommend" (what I recommend)');
276
306
  text(`${at}.heading`, o.heading, 80, 'the section heading, in 80 characters or fewer');
277
307
  text(`${at}.body`, o.body, 300, 'the section\'s lead, in 300 characters or fewer');
308
+ if (Object.hasOwn(o, 'part') && !parts.some(([p]) => p === o.part)) {
309
+ throw bad(`${at}.part`, parts.length ? `${show(o.part)} is not a part of a ${m.purpose} report: ${parts.map(([p]) => p).join(', ')}` : `is not used by a report whose purpose is ${m.purpose}`, parts.length ? `Set "part" to the part this section is, one of ${parts.map(([p]) => p).join(', ')}` : 'Remove "part"');
310
+ }
278
311
  if (Object.hasOwn(o, 'items'))
279
- list(`${at}.items`, 1, 8, ['title', 'text', 'more', 'tone', 'diagram'], '{"title":"Ship 0.2.5","text":"You test it.","more":"..."}', o.items).forEach((it, k) => {
312
+ list(`${at}.items`, 1, 8, ['title', 'text', 'more', 'tone', 'diagram', ...(v5 ? ['link', 'source', 'confidence', 'size'] : [])], '{"title":"Ship 0.2.5","text":"You test it.","more":"..."}', o.items).forEach((it, k) => {
280
313
  text(`${at}.items[${k}].title`, it.title, 80, 'the item, in 80 characters or fewer');
281
314
  text(`${at}.items[${k}].text`, it.text, 160, 'one line under it, in 160 characters or fewer');
282
315
  if (Object.hasOwn(it, 'more'))
@@ -284,6 +317,14 @@ export function checkMessage(m, want, flags = false) {
284
317
  tone(`${at}.items[${k}]`, it);
285
318
  if (Object.hasOwn(it, 'diagram'))
286
319
  diagrams(`${at}.items[${k}].diagram`, it.diagram);
320
+ if (Object.hasOwn(it, 'link') && !(typeof it.link === 'string' && /^https?:\/\/[^\s\p{Cc}\p{Cs}]{1,500}$/u.test(it.link)))
321
+ throw bad(`${at}.items[${k}].link`, 'must be an http(s) link with no spaces', 'Set "link" to where the source can be read, such as "https://github.com/owner/repo/issues/12"');
322
+ if (Object.hasOwn(it, 'source') && !['user', 'primary'].includes(it.source))
323
+ throw bad(`${at}.items[${k}].source`, `${show(it.source)} is not user or primary`, 'Set "source" to "user" (what users report: an issue, a forum, Reddit, Hacker News) or "primary" (docs, code, a maintainer\'s answer, your own check)');
324
+ if (Object.hasOwn(it, 'confidence') && !['high', 'medium', 'low'].includes(it.confidence))
325
+ throw bad(`${at}.items[${k}].confidence`, `${show(it.confidence)} is not high, medium or low`, 'Set "confidence" to how sure you are of the finding: "high", "medium" or "low"');
326
+ if (Object.hasOwn(it, 'size'))
327
+ text(`${at}.items[${k}].size`, it.size, 24, 'how big the slice is, such as "S, half a day", in 24 characters or fewer');
287
328
  });
288
329
  if (Object.hasOwn(o, 'details'))
289
330
  details(`${at}.details`, o.details);
@@ -294,6 +335,8 @@ export function checkMessage(m, want, flags = false) {
294
335
  const twice = ids.findIndex((id, n) => ids.indexOf(id) < n);
295
336
  if (twice >= 0)
296
337
  throw bad(`sections[${twice}].id`, `repeats ${ids[twice]}`, 'Give every section its own id');
338
+ if (parts.length)
339
+ kindParts(parts);
297
340
  if (!Object.hasOwn(m, 'next'))
298
341
  throw bad('next', 'is required', 'Add "next": {"heading":"Go ahead, and I start on 22 Oct","lines":["..."]}');
299
342
  one('next', m.next, ['heading', 'lines'], '{"heading":"...","lines":["..."]}');
@@ -320,7 +363,8 @@ export function checkMessage(m, want, flags = false) {
320
363
  throw bad('schema', `${m.schema} of ${m.kind} is not in this sign-off-axi`, `Upgrade sign-off-axi, or post ${m.kind} schema ${SCHEMAS[m.kind]}`);
321
364
  if (g && /FILL:/.test(JSON.stringify(m)))
322
365
  throw bad('message', 'still has FILL: lines from the template', 'Replace every FILL: line with your own words, then post again');
323
- const fields = guided(m) ? GUIDE_FIELDS[m.kind] : FIELDS[m.kind], extra = Object.keys(m).find((f) => !fields.includes(f));
366
+ // A guided report 5 names what it is for; report 4 and older have no purpose field.
367
+ const fields = (guided(m) ? GUIDE_FIELDS[m.kind] : FIELDS[m.kind]).filter((f) => f !== 'purpose' || (m.kind === 'report' && m.schema >= 5)), extra = Object.keys(m).find((f) => !fields.includes(f));
324
368
  if (extra === 'options' && m.kind === 'report')
325
369
  throw bad('options', 'is not a field of report: a report takes no answer', 'Remove "options", or post an ask to get an answer');
326
370
  if (extra === 'options' && m.kind === 'plan')
@@ -332,7 +376,7 @@ export function checkMessage(m, want, flags = false) {
332
376
  if (!Object.hasOwn(m, 'title'))
333
377
  throw bad('title', 'is required', 'Add "title": the question, in 160 characters or fewer');
334
378
  text('title', m.title, 160, 'the question, in 160 characters or fewer');
335
- if (g && m.schema === SCHEMAS[m.kind] && !isKey(m.filer))
379
+ if (engaged(m) && !isKey(m.filer))
336
380
  throw bad('filer', 'is required', 'Set "filer" to the owning agent id');
337
381
  if (Object.hasOwn(m, 'filer') && !isKey(m.filer))
338
382
  throw bad('filer', 'must be 1-128 of A-Z a-z 0-9 . _ -', 'Set "filer" to your agent session id or tool name, or leave it out');
@@ -383,6 +427,13 @@ export function checkMessage(m, want, flags = false) {
383
427
  text('video', m.video, 10, 'the video length such as "0:28", in 10 characters or fewer, or leave it out');
384
428
  if (Object.hasOwn(m, 'asked_by'))
385
429
  text('asked_by', m.asked_by, 40, 'who asked for it, such as "You asked" or "Docs agent", in 40 characters or fewer, or leave it out');
430
+ if (m.schema >= 5) {
431
+ const set = `Set "purpose" to what this report is for, one of ${REPORT_PURPOSES.join(', ')}`;
432
+ if (!Object.hasOwn(m, 'purpose'))
433
+ throw bad('purpose', 'is required: the kind of report, which the board shows on its row and head', set);
434
+ if (!REPORT_PURPOSES.includes(m.purpose))
435
+ throw bad('purpose', `${show(m.purpose)} is not one of ${REPORT_PURPOSES.join(', ')}`, set);
436
+ }
386
437
  // A report is a page to tap through and a picture of it for the card: text alone is a status note, not a report.
387
438
  if (g) {
388
439
  guide(1);