sign-off-axi 0.6.5 → 0.6.7

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
@@ -78,7 +78,18 @@ What you send on a card (an answer, a note, a new round, a comment) shows its tr
78
78
 
79
79
  ### Plans and reports, on their own pages
80
80
 
81
- <!-- MARKER: report-writing and the report gallery for design output are not described here yet; they land with the report kit PRs. -->
81
+ An agent writes a report as data and the board draws it: `sign-off-axi report init <folder> --purpose <kind>` writes `report.json`, and each section takes `blocks` to show a point rather than tell it.
82
+
83
+ | Block | What it draws |
84
+ | --- | --- |
85
+ | `callout` | a note, warning or question set apart |
86
+ | `beats` | a stepper of pictures, each with a time, title and words |
87
+ | `video` | a short film with its poster |
88
+ | `table` | the same measures for each option |
89
+ | `compare` | before and after |
90
+ | `evidence` | captures, each marked Fixed, In progress or Waiting on you |
91
+
92
+ Design output (screens a model made) goes in a `gallery` on the section: `report init --gallery`. Every picture and film is a file beside `report.json`, and post pins it. A page an agent writes itself (`report init --page`) is refused when it brings its own colours, three or more, or a card inside a card; reports already on the board stay as filed.
82
93
 
83
94
  <table>
84
95
  <tr>
@@ -150,8 +161,9 @@ One line on what the agent runs per kind; the full reference is [docs/question-k
150
161
  | Proof update | `post --type evidence --media shot.png` | the capture, its result, Pass / Fail / Needs work |
151
162
  | Proof with a file to install | `post --type evidence --media shot.png --attach app.apk` | the capture, and the file with its size and a Download button (any type, up to 1 GB, `SIGN_OFF_ATTACH_MAX_MB` to change) |
152
163
  | Explainer video | `post --type explainer --media clip.mp4 --chapter 0:00=Start` | a player with chapter buttons, then Yes or No |
153
- | Report | `report init <folder>`, fill `report.json`, then `post --kind report --from <folder>/report.json` | one scroll: the answer and a picture, what I understood, what I found, the next step; numbers under their part, one bar (Agree, Disagree, Comment, Ask) that acts on the part in view, and one Activity of every note, ask, reply and read |
154
- | Design output report | `report init <folder> --gallery`, put the screenshots under the board's folder, fill the gallery (label, model, run time, a caption and device per frame), then `post --kind report` | a swipeable strip of options on a phone, a row on a desk: each option's phone and desktop frame side by side, the caption with the model and run time under each, a tap to open full size with Previous and Next |
164
+ | Report | `report init <folder> --purpose <kind>` (always: research, plan, decision, audit, incident, status or other), fill `report.json`, then `post --kind report --from <folder>/report.json` | one scroll: the answer and a picture, what I understood, what I found, the next step; numbers under their part, one bar (Agree, Disagree, Comment, Ask) that acts on the part in view, and one Activity of every note, ask, reply and read |
165
+ | Design output report | `report init <folder> --purpose <kind> --gallery`, put the screenshots under the board's folder, fill the gallery (label, model, run time, a caption and device per frame), then `post --kind report` | a swipeable strip of options on a phone, a row on a desk: each option's phone and desktop frame side by side, the caption with the model and run time under each, a tap to open full size with Previous and Next |
166
+ | Report kit blocks | add `blocks` to a section of `report.json`: `callout`, `beats` (a stepper), `video`, `table`, `compare` (before and after), `evidence` rows, with files under the board's folder | the board draws each in its own type and colours, light and dark: full width, one gutter, no card inside a card; a gallery and a video may run wider than the reading column on a desk |
155
167
  | Diagram, interactive page | `post --type diagram --view flow.svg` | the picture on the card; interactive pages run sandboxed, and open full screen at phone or desktop width |
156
168
  | Plan | `report init <folder> --plan`, fill `plan.json`, then `post --kind plan --from plan.json` | the same scroll, plus each decision with my pick and a Go ahead |
157
169
  | Sign-in link | `post --type signin --service npm --link <url> --unlocks ...` | an Open button and the time left |
package/dist/cli.js CHANGED
@@ -14,8 +14,8 @@ 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, guardOf, plainAsk, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendPartComment, annotationsPath, markCommentsRead, readPartComments, replyComment, asks, appendAnswer, appendDismissed, DISCARD_REASONS, readDiscards, UNDO_MS, appendPromoted, appendSendBack, appendTicket, readTickets, rollUp, appendTicketComment, ticketCommentFault, ticketFault, appendProject, readProjects, projectFault, iconPathFault, iconContentFault, ICON_BYTES, ICON_TYPES, PRIORITIES, REPORT_PARTS, REPORT_PURPOSES, appendDesignAnswer, answerLines, appendMark, readTrail, trailPath, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, appendOpened, readOpened, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, screenViews, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, reading, readDismissed, readItems, readPromoted, sameDecision, readMessages, readRounds, roundsPath, sameRound, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
18
- import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, diagramPages, diagramViews, galleryFrames, guided, mediaFiles, parseMessage, reportPage } from './contract.js';
17
+ import { ATTACH_DEFAULT_MB, ATTACH_MAX, ATTACH_NAME, guardOf, plainAsk, CHAPTERS_MAX, SIGNED_IN, CLEARED, InputError, MEDIA_MAX, NOTE_MAX, REPLY, answersPath, appendAnnotation, appendPartComment, annotationsPath, markCommentsRead, readPartComments, replyComment, asks, appendAnswer, appendDismissed, DISCARD_REASONS, readDiscards, UNDO_MS, appendPromoted, appendKind, readKinds, appendSendBack, appendTicket, readTickets, rollUp, appendTicketComment, ticketCommentFault, ticketFault, appendProject, readProjects, projectFault, iconPathFault, iconContentFault, ICON_BYTES, ICON_TYPES, PRIORITIES, REPORT_PARTS, REPORT_PURPOSES, appendDesignAnswer, answerLines, appendMark, readTrail, trailPath, appendMessage, appendRound, awaitsVerdict, lapsed, askEngagement, checkEngagement, markEngagementSeen, appendRead, appendOpened, readOpened, answerDeeper, askDeeper, deeperPath, guidePart, markDeeperRead, readDeeper, readReads, screenViews, seenAt, isKey, loadDecisions, markDelivered, markSeen, messagesPath, readAnnotations, readAnswers, reading, readDismissed, readItems, readPromoted, sameDecision, readMessages, readRounds, roundsPath, sameRound, seenBy, seenPath, sentIds, unreadable as reasonOf, writeItems } from './store.js';
18
+ import { ContractError, KINDS, MEDIA_BYTES, REPORT_INIT, checkMessage, diagramPages, diagramViews, galleryFrames, guided, kitWarnings, mediaFiles, parseMessage, reportPage } from './contract.js';
19
19
  import { faults, isStill } from './evidence-text.js';
20
20
  import { copiesOf, differs } from './images.js';
21
21
  import { deliverVault, errors, readVaultExpiry, sweepVault, takeVaultExpiries, VaultError } from './vault.js';
@@ -124,7 +124,7 @@ examples:
124
124
  validate: `usage: sign-off-axi validate <message.json>... [--kind <kind>] [--expect-invalid]
125
125
  Checks message files against the communication contract without posting them: ${CONTRACT}
126
126
  A message is one JSON object with "contract": 1, its "kind" and its "schema". This build checks kinds ask, evidence, gate and finding at schema 1, design at schema 2 (schema 1 still passes), report at schema 5 (schemas 2 to 4 still pass) and plan at schema 3 (schemas 1 and 2 still pass).
127
- A report 3 to 5 and a plan 2 or 3 are guided pages: fields the board draws as one scroll, no page of your own (see report init). A report 5 names what it is for in "purpose".
127
+ A report 3 to 5 and a plan 2 or 3 are guided pages: fields the board draws as one scroll, no page of your own (see report init). Every report names what it is for in "purpose", required on a report 5 and on any new report, and so is its "project".
128
128
  An evidence message's media paths read from the message file's folder: each file must be there, inside it, and 50 MB or less.
129
129
  A report is a page to tap through: its "view" (.html) and "preview" (its picture, beside it) are checked for their shape here; the files themselves are checked at post, from the board file's folder, filled with no FILL: lines left (see report init).
130
130
  A plan's "view" (.html) is checked for its shape here; the file itself is checked at post, from the board file's folder.
@@ -380,8 +380,10 @@ examples:
380
380
  sign-off-axi feedback fleet-dashboard-full --reply "The colours now come from the new tokens." --link https://github.com/owner/repo/pull/245
381
381
  sign-off-axi feedback fleet-dashboard-full --acted --link https://github.com/owner/repo/pull/245
382
382
  `,
383
- report: `usage: sign-off-axi report init <folder> [--purpose research|plan|decision|audit|incident|status|other] [--plan] [--page] [--gallery]
384
- Writes the guided template to fill: <folder>/report.json, or <folder>/plan.json with --plan. A file already there is kept.
383
+ report: `usage: sign-off-axi report init <folder> --purpose research|plan|decision|audit|incident|status|other [--page] [--gallery]
384
+ sign-off-axi report init <folder> --plan
385
+ sign-off-axi report kind <key> <kind> --project <name> --by <who> [--board board.json] (or --from <file> of key<TAB>kind<TAB>project lines)
386
+ Writes the guided template to fill: <folder>/report.json (--purpose is required), or <folder>/plan.json with --plan. A file already there is kept.
385
387
  Every report names what it is for in "purpose", its kind: research (research and discovery), plan (implementation plan), decision (decision and recommendation),
386
388
  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
387
389
  (research: question, scope, method, sources with links and a user report, options, findings with their confidence, not-checked, recommendation, open-questions;
@@ -392,7 +394,7 @@ Numbers sit behind a Details tap; every part has a Go deeper button that asks yo
392
394
  Replace every FILL: line, then post it: post --kind report --from <folder>/report.json, or post --kind plan --from <folder>/plan.json --filer <your id>.
393
395
  A report is for an explanation the user asked for, or a concept, design or trade-off they must understand. Proof that something works is evidence.
394
396
  --gallery adds a gallery to the first section: a design output's options, each with its model, run time and a phone and a desktop frame (an image or page, as a path from the board file's folder), which the board draws as a swipeable strip with each frame opening full size.
395
- --page writes the older scaffold page instead, for a report that needs a page of its own (report schema 2). It is:
397
+ --page (with --purpose) writes the older scaffold page instead, for a demo to tap that no block of the kit can show (report schema 2); post it with --type report --purpose <kind>. The board owns the colours: post refuses a page that writes out three or more colours of its own, or draws a card inside a card, on its first post. It is:
396
398
  Writes a report page to fill: <folder>/index.html and its card picture <folder>/preview.svg. A file already there is kept.
397
399
  The page is one self-contained file, phone first, light and dark, in four parts, each one idea:
398
400
  1. the answer in one plain sentence and a picture of it 2. a diagram of how it works
@@ -400,8 +402,10 @@ The page is one self-contained file, phone first, light and dark, in four parts,
400
402
  Replace every FILL: line, open the page in a browser 390 px wide and look: mostly text means it is not ready.
401
403
  Then post it with the command it prints; the paths are from the board file's folder, so make the folder under it, such as .sign-off/mocks/<key>.
402
404
  A report is for an explanation the user asked for, or a concept, design or trade-off they must understand. Proof that something works is evidence.
405
+ report kind sets the kind and the project of a report posted before every report had them, and records --by, who set it; the board itself is not changed, only <board>.kinds.jsonl. All the lines of --from are checked before the first is written.
403
406
  examples:
404
- sign-off-axi report init .sign-off/mocks/sign-in-flow
407
+ sign-off-axi report init .sign-off/reports/sign-in-flow --purpose research
408
+ sign-off-axi report kind sign-in-flow research --project Beacon --by agent-41
405
409
  `,
406
410
  init: `usage: sign-off-axi init [dir] [--agent auto|<name>[,<name>]...]
407
411
  Writes a repo's sign-off setup under <dir>/.sign-off/ (config, an empty board, mocks/ and evidence/ folders, and a .gitignore keeping media and answer files out of git). A file that is already there is kept, never changed, except that a given --agent replaces the config's agent: line; a re-run adds what is missing.
@@ -511,6 +515,18 @@ const CONTRACT_TYPES = ['decision', 'ask', 'evidence', 'report', 'plan', 'design
511
515
  const asSent = ({ filed: _filed, announced: _announced, ...rest }) => rest;
512
516
  /** A report names its own `filed` time, so only the notice record goes. */
513
517
  const asReport = ({ announced: _announced, ...rest }) => rest;
518
+ /** The projects the board knows, for a refusal that names them. */
519
+ const knownProjects = (file) => { const all = existsSync(file) ? readProjects(file).map((p) => p.name) : []; return all.length ? `known projects: ${all.join(', ')}` : 'no project is registered on this board yet (sign-off-axi project --name <project> --icon <picture>), so name the one the report is about'; };
520
+ /** A new report names its kind and its project, whatever its schema; one already on the board stays a re-post of its own key (an older one is named with report kind). */
521
+ function newReportFields(m, file) {
522
+ if (m?.kind !== 'report')
523
+ return;
524
+ if (!Object.hasOwn(m, 'purpose')) {
525
+ throw new ContractError(`report ${m.key} has no kind: "purpose" is required, what the report is for`, `Set "purpose" to one of ${REPORT_PURPOSES.join(', ')}; ${REPORT_INIT} --purpose <kind> writes a report with it filled in`);
526
+ }
527
+ if (!Object.hasOwn(m, 'project'))
528
+ throw new ContractError(`report ${m.key} has no project: "project" is required, so its row shows the project's icon`, `Set "project" to the project the report is about; ${knownProjects(file)}`);
529
+ }
514
530
  /** Items of a --from file that carry contract or kind are contract messages: each is checked against its kind's rules before anything is stored.
515
531
  * A new item of a contract kind must carry them (contract section 2). A --replace feed replaces the stored item, so an item under an existing key
516
532
  * that changed is checked again; an unchanged item, a board-format one and every item outside a --replace feed are not checked again. */
@@ -551,6 +567,8 @@ function contractItems(items, from, file, replace = false) {
551
567
  }
552
568
  try {
553
569
  checkMessage(check);
570
+ if (!held)
571
+ newReportFields(check, file);
554
572
  if ((check.kind === 'report' || check.kind === 'plan') && !guided(check))
555
573
  reportPage(check, dirname(file));
556
574
  if (guided(check))
@@ -1139,12 +1157,12 @@ async function locked(lock, fn, busy = 'another raise is still writing the board
1139
1157
  const mockFile = (path) => path.split('?')[0];
1140
1158
  /** Posts an item. `raise` is the alias for `post --type decision`: same flags as before, same output. */
1141
1159
  function post(args, cmd = 'post') {
1142
- const typed = cmd === 'post' ? { replace: { type: 'boolean' }, kind: { type: 'string' }, type: { type: 'string' }, tradeoff: { type: 'string' }, mock: { type: 'string', multiple: true },
1160
+ const typed = cmd === 'post' ? { replace: { type: 'boolean' }, kind: { type: 'string' }, purpose: { type: 'string' }, project: { type: 'string' }, type: { type: 'string' }, tradeoff: { type: 'string' }, mock: { type: 'string', multiple: true },
1143
1161
  media: { type: 'string', multiple: true }, attach: { type: 'string', multiple: true }, link: { type: 'string', multiple: true }, view: { type: 'string' }, preview: { type: 'string' }, chapter: { type: 'string', multiple: true },
1144
1162
  ...Object.fromEntries(SIGNIN_FIELDS.map((f) => [f, { type: 'string' }])) } : {};
1145
1163
  const { file, named, values, on } = parse(cmd, args, { from: { type: 'string' }, filer: { type: 'string' }, 'ask-ref': { type: 'string' }, ticket: { type: 'string' }, priority: { type: 'string' }, ...typed,
1146
1164
  ...Object.fromEntries(RAISE_FIELDS.map((f) => [f, { type: 'string', multiple: MANY.includes(f) }])) });
1147
- const given = [...RAISE_FIELDS, 'type', 'tradeoff', 'mock', 'media', 'attach', 'link', 'view', 'preview', 'chapter', ...SIGNIN_FIELDS].filter((f) => values[f] !== undefined);
1165
+ const given = [...RAISE_FIELDS, 'type', 'purpose', 'project', 'tradeoff', 'mock', 'media', 'attach', 'link', 'view', 'preview', 'chapter', ...SIGNIN_FIELDS].filter((f) => values[f] !== undefined);
1148
1166
  // Only a design takes --link more than once (one per option); any other type keeps its one item link, the last given.
1149
1167
  const optionLinks = values.link ?? [];
1150
1168
  if (values.type !== 'design' && optionLinks.length)
@@ -1850,15 +1868,21 @@ function postReport(file, named, on, values) {
1850
1868
  const odd = ['option', 'recommend', 'default', 'close', 'detail', 'mock', 'tradeoff', 'media', 'chapter'].find((f) => values[f] !== undefined);
1851
1869
  if (odd)
1852
1870
  throw fail(`--${odd} does not apply to --type report: a report takes no answer`, 'VALIDATION_ERROR', 'Run `sign-off-axi post --help`');
1871
+ if (values.purpose === undefined)
1872
+ throw fail(`a report needs --purpose <kind>, what it is for: ${REPORT_PURPOSES.join(', ')}`, 'VALIDATION_ERROR', 'sign-off-axi post --type report --purpose research --title "<the answer>" --about "<why it matters>" --view <page>.html --preview <picture>.svg');
1873
+ if (!REPORT_PURPOSES.includes(values.purpose))
1874
+ throw fail(`--purpose ${values.purpose} must be one of ${REPORT_PURPOSES.join(', ')}`, 'VALIDATION_ERROR', 'sign-off-axi post --type report --purpose research --title "<the answer>" ...');
1875
+ if (values.project === undefined)
1876
+ throw fail(`a report needs --project <name>, the project it is about; ${knownProjects(file)}`, 'VALIDATION_ERROR', 'sign-off-axi post --type report --purpose research --project Beacon --title "<the answer>" ...');
1853
1877
  const missing = ['title', 'about', 'view', 'preview'].filter((f) => values[f] === undefined).map((f) => `--${f}`);
1854
1878
  if (missing.length) {
1855
- throw fail(`a report is a page to tap through, not text: ${missing.join(' and ').replace(/ and (?=.* and )/g, ', ')} ${missing.length > 1 ? 'are' : 'is'} missing`, 'VALIDATION_ERROR', `Run \`${REPORT_INIT} --page\` to make the page and its picture, fill them, then post --type report --title "<the answer>" --about "<why it matters>" --view <folder>/index.html --preview <folder>/preview.svg`);
1879
+ throw fail(`a report is a page to tap through, not text: ${missing.join(' and ').replace(/ and (?=.* and )/g, ', ')} ${missing.length > 1 ? 'are' : 'is'} missing`, 'VALIDATION_ERROR', `Run \`${REPORT_INIT} --page\` to make the page and its picture, fill them, then post --type report --purpose <kind> --project <name> --title "<the answer>" --about "<why it matters>" --view <folder>/index.html --preview <folder>/preview.svg`);
1856
1880
  }
1857
1881
  const key = values.key ?? slug(values.title), had = existsSync(file) ? readItems(file).items.find((i) => i?.key === key) : undefined;
1858
1882
  // Posting the same report again is a no-op, so it keeps the time it was first filed.
1859
1883
  const filed = (had?.kind === 'report' && had.filed) || new Date().toISOString().replace(/\.\d+Z$/, 'Z');
1860
1884
  const { title, about, view, preview, link } = values;
1861
- return postKind(file, named, on, values, { contract: 1, kind: 'report', schema: 2, key, title, about, view, preview, ...(link !== undefined && { link }), filed });
1885
+ return postKind(file, named, on, values, { contract: 1, kind: 'report', schema: 2, key, title, about, view, preview, purpose: values.purpose, project: values.project, ...(link !== undefined && { link }), filed });
1862
1886
  }
1863
1887
  /** Posts one contract message (docs/contract.md), from a file or from stdin with --from -: checked against --kind, then stored like a raised item.
1864
1888
  * `built` is a message post --type report made from its flags. */
@@ -1890,14 +1914,19 @@ async function postKind(file, named, on, values, built) {
1890
1914
  // A new ask is held to the question standard, and so is a new design's title; a card already on the board still re-posts as it was.
1891
1915
  const existing = !built && ['ask', 'design'].includes(kind) && existsSync(file) && readItems(file).items.some((i) => i?.key === m.key);
1892
1916
  checkMessage(m, built ? 'report' : kind, !built && kind === 'ask' && !existing, !built && kind === 'design' && !existing);
1917
+ if (!(existsSync(file) && readItems(file).items.some((i) => i?.key === m.key)))
1918
+ newReportFields(m, file);
1893
1919
  // Media paths read from the message file's folder, or from the current folder for stdin.
1894
1920
  if (m.kind === 'evidence')
1895
1921
  sources = mediaFiles(m, from === '-' ? process.cwd() : dirname(resolve(from)));
1896
1922
  // A report's or plan's page is served from the board file's folder, as --view is.
1897
1923
  if ((m.kind === 'report' || m.kind === 'plan') && !guided(m))
1898
- reportPage(m, dirname(file));
1899
- if (guided(m))
1924
+ reportPage(m, dirname(file), !(existsSync(file) && readItems(file).items.some((i) => i?.key === m.key)));
1925
+ if (guided(m)) {
1900
1926
  diagramPages(m, dirname(file));
1927
+ for (const w of kitWarnings(m))
1928
+ process.stderr.write(`warning: ${name}: ${w}\n`);
1929
+ }
1901
1930
  if (m.kind === 'design')
1902
1931
  designFiles(m, dirname(file));
1903
1932
  }
@@ -2581,18 +2610,27 @@ const kindSections = (purpose) => REPORT_PARTS[purpose].map(([part, name]) => {
2581
2610
  function report(args) {
2582
2611
  let parsed;
2583
2612
  try {
2584
- parsed = parseArgs({ args, allowPositionals: true, strict: true, options: { plan: { type: 'boolean' }, page: { type: 'boolean' }, purpose: { type: 'string' }, gallery: { type: 'boolean' } } });
2613
+ parsed = parseArgs({ args, allowPositionals: true, strict: true, options: { plan: { type: 'boolean' }, page: { type: 'boolean' }, purpose: { type: 'string' }, gallery: { type: 'boolean' }, project: { type: 'string' }, board: { type: 'string' }, by: { type: 'string' }, from: { type: 'string' } } });
2585
2614
  }
2586
2615
  catch (e) {
2587
2616
  throw fail(e.message, 'VALIDATION_ERROR', 'Run `sign-off-axi report --help`');
2588
2617
  }
2589
2618
  const [sub, dir, extra] = parsed.positionals, { plan, page, purpose, gallery } = parsed.values;
2619
+ if (parsed.values.project !== undefined && sub !== 'kind')
2620
+ throw fail('--project belongs to report kind; a new report carries its project in report.json', 'VALIDATION_ERROR', 'sign-off-axi report kind <key> <kind> --project <name> --by <who>');
2621
+ if (sub === 'kind')
2622
+ return reportKind(parsed.positionals.slice(1), parsed.values);
2623
+ if (parsed.values.board !== undefined || parsed.values.by !== undefined || parsed.values.from !== undefined)
2624
+ throw fail('--board, --by and --from belong to report kind', 'VALIDATION_ERROR', 'sign-off-axi report kind <key> <kind> --by <who>');
2625
+ if (purpose !== undefined && plan)
2626
+ throw fail('--purpose does not go with --plan: a plan is always an implementation plan', 'VALIDATION_ERROR', 'sign-off-axi report init <folder> --plan');
2590
2627
  if (gallery && (plan || page))
2591
- throw fail(`--gallery does not go with --${plan ? 'plan' : 'page'}: a gallery is a section of a guided report`, 'VALIDATION_ERROR', 'sign-off-axi report init <folder> --gallery');
2592
- if (purpose !== undefined && (plan || page))
2593
- 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');
2628
+ throw fail(`--gallery does not go with --${plan ? 'plan' : 'page'}: a gallery is a section of a guided report`, 'VALIDATION_ERROR', 'sign-off-axi report init <folder> --purpose research --gallery');
2594
2629
  if (purpose !== undefined && !REPORT_PURPOSES.includes(purpose))
2595
2630
  throw fail(`--purpose ${purpose} must be one of ${REPORT_PURPOSES.join(', ')}`, 'VALIDATION_ERROR', 'sign-off-axi report init <folder> --purpose research');
2631
+ if (sub === 'init' && !plan && purpose === undefined) {
2632
+ throw fail(`report init needs --purpose <kind>, what the report is for: ${REPORT_PURPOSES.join(', ')}`, 'VALIDATION_ERROR', 'sign-off-axi report init <folder> --purpose research');
2633
+ }
2596
2634
  if (sub !== 'init' || dir === undefined || extra !== undefined || (plan && page)) {
2597
2635
  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]');
2598
2636
  }
@@ -2619,7 +2657,6 @@ function report(args) {
2619
2657
  created.push(shellPath(path));
2620
2658
  }
2621
2659
  return toon({ folder: shellPath(out), created: created.length ? created : 'nothing new (re-run is a no-op)', kept }, [
2622
- ...(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)`]),
2623
2660
  `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' : ''}`,
2624
2661
  `${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)`,
2625
2662
  ...(gallery ? ['Save each option\'s screenshots (or pages) under the board file\'s folder, a phone frame 390 wide and a desktop frame 1280 wide, and fill the gallery: its label, model, run time (UTC), and per frame its path from that folder and a caption'] : []),
@@ -2639,10 +2676,51 @@ function report(args) {
2639
2676
  return toon({ folder: shellPath(out), created: created.length ? created : 'nothing new (re-run is a no-op)', kept }, [
2640
2677
  'Replace every FILL: line: the answer and its picture first, then the diagram, the steps to tap, and the folded detail',
2641
2678
  'Open index.html in a browser 390 px wide and look before posting: mostly text means it is not ready',
2642
- served ? `Run \`sign-off-axi post ${shellPath(board)} --type report --title "<the answer>" --about "<why it matters>" --view ${rel}/index.html --preview ${rel}/preview.svg\``
2643
- : `Run \`sign-off-axi post <board.json> --type report --title "<the answer>" --about "<why it matters>" --view <path>/index.html --preview <path>/preview.svg\`, both paths from the board file's folder, which must hold this folder`,
2679
+ served ? `Run \`sign-off-axi post ${shellPath(board)} --type report --purpose ${purpose} --project <name> --title "<the answer>" --about "<why it matters>" --view ${rel}/index.html --preview ${rel}/preview.svg\``
2680
+ : `Run \`sign-off-axi post <board.json> --type report --purpose ${purpose} --project <name> --title "<the answer>" --about "<why it matters>" --view <path>/index.html --preview <path>/preview.svg\`, both paths from the board file's folder, which must hold this folder`,
2644
2681
  ]);
2645
2682
  }
2683
+ /** report kind <key> <kind> --by <who>, or --from <file>: one `key<TAB>kind<TAB>project` line each, with a note after if you like. Sets the kind and the project of reports posted before every report had one, and records who set it.
2684
+ * Every key is checked before the first is written, so a bad line changes nothing. */
2685
+ function reportKind(given, values) {
2686
+ const example = 'sign-off-axi report kind sign-in-flow research --project Beacon --by agent-41', { by, from, project } = values;
2687
+ if (by === undefined || !by.trim() || by.length > 80)
2688
+ throw fail('report kind needs --by <who set it>, in 80 characters or fewer', 'VALIDATION_ERROR', example);
2689
+ if (from !== undefined === given.length > 0)
2690
+ throw fail(from !== undefined ? '--from takes the place of <key> <kind>' : 'report kind needs <key> <kind> --project <name>, or --from <file> with one `key<TAB>kind<TAB>project` line each', 'VALIDATION_ERROR', example);
2691
+ let pairs = [[...given, project]];
2692
+ if (from !== undefined) {
2693
+ let text;
2694
+ try {
2695
+ text = readFileSync(from, 'utf8');
2696
+ }
2697
+ catch (e) {
2698
+ throw fail(`${from} ${reasonOf(e.code)}`, 'NOT_FOUND', 'Check the path names a readable file');
2699
+ }
2700
+ pairs = text.split('\n').filter((l) => l.trim() && !l.startsWith('#')).map((l) => l.split('\t').map((x) => x.trim()));
2701
+ }
2702
+ const file = values.board === undefined ? boardHere() : resolve(values.board), items = readItems(file).items, reports = new Map(items.filter((i) => i?.kind === 'report').map((i) => [i.key, i]));
2703
+ const set = readKinds(file, warn), rows = pairs.map(([key, kind, name, _why, ...rest], n) => {
2704
+ const at = from === undefined ? '' : `${from} line ${n + 1}: `;
2705
+ if (key === undefined || kind === undefined || !name || rest.length)
2706
+ throw fail(`${at}expected <key> <kind> <project>`, 'VALIDATION_ERROR', example);
2707
+ if (!REPORT_PURPOSES.includes(kind))
2708
+ throw fail(`${at}${kind} is not a report kind: use one of ${REPORT_PURPOSES.join(', ')}`, 'VALIDATION_ERROR', example);
2709
+ if (name.length > 40)
2710
+ throw fail(`${at}project ${name} is over 40 characters`, 'VALIDATION_ERROR', example);
2711
+ const d = reports.get(key);
2712
+ if (!d)
2713
+ throw fail(`${at}${key} is not a report on ${tilde(file)}`, 'NOT_FOUND', 'Run `sign-off-axi list` for the keys; a plan is always an implementation plan and needs no kind');
2714
+ for (const [what, has, now] of [['kind', d.purpose, kind], ['project', d.project, name]])
2715
+ if (has !== undefined && has !== now)
2716
+ throw fail(`${at}${key} already has its ${what} ${has} in the report itself`, 'VALIDATION_ERROR', 'Only a report posted without one is named with report kind');
2717
+ return { key, kind, project: name, same: (d.purpose ?? set.get(key)?.purpose) === kind && (d.project ?? set.get(key)?.project) === name };
2718
+ });
2719
+ const changed = rows.filter((r) => !r.same);
2720
+ for (const r of changed)
2721
+ appendKind(file, r.key, r.kind, r.project, by);
2722
+ return toon({ board: tilde(file), by, changed: changed.length, unchanged: rows.length - changed.length, reports: changed.map(({ key, kind, project }) => ({ key, kind, project })) }, [changed.length ? 'The board shows each kind and project on its row and head now' : 'Every report named already had that kind and project']);
2723
+ }
2646
2724
  /** `notify check|test`: whether device notifications can work from this setup, and one real test notice. Never prints the token. */
2647
2725
  async function notify(args) {
2648
2726
  const mode = ['check', 'test'].includes(args[0]) ? args.shift() : 'check';
@@ -3173,13 +3251,13 @@ const state = () => {
3173
3251
  const rounds = readRounds(file, warn).map(({ comments: _, ...r }) => r);
3174
3252
  // The board file is read once for the whole state: it can hold thousands of items.
3175
3253
  const { extra } = readItems(file), fed = extra.readonly === true || extra.answers_only === true, items = loadDecisions(file);
3176
- const lines = items.some((d) => d.type === 'design') ? answerLines(file) : [];
3254
+ const lines = items.some((d) => d.type === 'design') ? answerLines(file) : [], kinds = readKinds(file, warn);
3177
3255
  return {
3178
3256
  file,
3179
3257
  mockBase: `/mock/${loopback(bind) ? 'local' : mockPass(tokenOf(file))}/`,
3180
3258
  // Who filed an item stays in the file, except as `by`, the name a card's thread says posted it and took its answer: never a bare session id, which names nobody.
3181
3259
  // A sign-in's start command stays on this host too: the page only learns that a tap makes a new link, and how the last run went.
3182
- decisions: items.filter((d) => !fakeEvidence(d)).map(({ filer, start, ...d }) => ({ ...d, ...(filer && !SESSION_ID.test(filer) && { by: filer }), ...(plainAsk(d) && guardOf(d) && { guard: guardOf(d) }), ...(d.type === 'design' && answers[d.key]?.answer === 'approve' && { status: 'approved' }), ...(d.type === 'design' && designState(d, answers[d.key], lines)), ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
3260
+ decisions: items.filter((d) => !fakeEvidence(d)).map(({ filer, start, ...d }) => ({ ...d, ...(d.kind === 'report' && kinds.has(d.key) && { purpose: d.purpose ?? kinds.get(d.key).purpose, project: d.project ?? kinds.get(d.key).project, kind_by: kinds.get(d.key).by }), ...(filer && !SESSION_ID.test(filer) && { by: filer }), ...(plainAsk(d) && guardOf(d) && { guard: guardOf(d) }), ...(d.type === 'design' && answers[d.key]?.answer === 'approve' && { status: 'approved' }), ...(d.type === 'design' && designState(d, answers[d.key], lines)), ...(start && { start: true, run: signinState(d.key) }), evidenceCard: d.evidence ? evidenceCard(d.evidence) : null,
3183
3261
  ...(d.mocks && { mocks: d.mocks.map((m) => ({ ...m, hash: mockHash(m.path) })) }) })),
3184
3262
  readonly: extra.readonly === true,
3185
3263
  answers_only: extra.answers_only === true,
@@ -3369,7 +3447,7 @@ async function route(req) {
3369
3447
  if (!mock || !type)
3370
3448
  return [404, { error: 'Mock is unavailable' }];
3371
3449
  const actual = under(dirname(under(dirname(file), mockFile(mock))), path);
3372
- if (statSync(actual).size > MOCK_BYTES)
3450
+ if (statSync(actual).size > (type.startsWith('video/') && frame ? MEDIA_BYTES : MOCK_BYTES))
3373
3451
  return [404, { error: 'Mock is unavailable' }];
3374
3452
  const bytes = readFileSync(actual);
3375
3453
  // 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.