@sabaiway/agent-workflow-kit 10.0.0 → 10.1.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 10.1.0 — no work without a specification, and a ratchet that does not trust its own file (AD-121)
8
+
9
+ A shipped tool that no LIVE contract claims is now a REFUSAL, not a preference. `tools/spec-coverage.mjs`
10
+ (the rule) and `tools/spec-coverage-cli.mjs` (argv, fs and the one write) read each contract's own
11
+ `## Module` list as the coverage map — coverage is never declared twice — and a project declares the
12
+ check as a gate. Measured at adoption in this repo: 122 tool modules, 14 governed by a contract.
13
+
14
+ The other 108 are owed, and what is owed is never STORED — it is a subtraction. `adopted` is the set
15
+ measured once at adoption and never rewritten; `settled` names the adopted paths whose contract has
16
+ since been written; the debt is the difference. So there is no list a hand can edit into a lie: the
17
+ only editable claim is "this one was paid", and every run checks that claim against the contracts
18
+ themselves — a settled path no live contract covers refuses, and refuses twice, because the tool is
19
+ uncovered again. A stored debt list was the first design and a review killed it: `--write-debt`
20
+ refusing to add a path guaranteed nothing while nobody was obliged to use it.
21
+
22
+ The scope file is validated before it is used: a wrong schema, an empty `roots` or `extensions`, a
23
+ non-string entry, or a census of ZERO files all refuse — a gate that passes because it looked at
24
+ nothing is not a pass. Exclusions are path-component boundaries, so excluding `x/fixtures` never also
25
+ hides `x/fixtures-escape.mjs`. `draft` and `retired` contracts cover nothing.
26
+
27
+ Also in this release: `tools/spec-check.mjs` counts a scenario marker as a WHOLE ordinal, so a store
28
+ reaching ten scenarios no longer refuses a correct binding while naming the wrong scenario
29
+ (`spec:x/S1` used to occur twice the moment `spec:x/S11` was written beside it).
30
+
7
31
  ## 10.0.0 — a symlinked docs file gets named instead of skipped (AD-119)
8
32
 
9
33
  Mirrors the memory canon: `references/scripts/check-docs-size.mjs` no longer lets a symlinked docs
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '10.0.0'
6
+ version: '10.1.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "10.0.0",
6
+ "version": "10.1.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "10.0.0",
3
+ "version": "10.1.0",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -44,25 +44,52 @@ const contains = (haystack, needle) => normalize(haystack).toLowerCase().include
44
44
  // AND a boundary: it closes the block it interrupts, so text past a fence can never join the bullet
45
45
  // before it (which would let a far-side literal satisfy a near-side claim). A `-` plus any whitespace
46
46
  // run opens a block; a blank or indented line continues it; any other unindented line closes it.
47
- // Blocks are returned RAW (their own lines) — the queue reader needs the field lines inside them.
48
- const bulletBlocks = (lines, fencedLines, from, to) => {
47
+ // Blocks are returned RAW (their own lines) — the queue reader needs the field lines inside them
48
+ // each carrying the body index it OPENS at, because a second reader (queue-audit.mjs) reports rows by
49
+ // file line and a scan that dropped the index would have to re-derive it against a different grammar.
50
+ //
51
+ // `fenceContinues` is the SECOND reader's question, and it is a different one. A deferral row asks
52
+ // what a bullet CLAIMS, so a fence must cut it. A queue row asks what a bullet COSTS and whether it
53
+ // is still work, and there the fence-as-boundary is a hole: measured, a row carrying a code block
54
+ // reported ONE line and its `**DONE 2026-01-01:**` two lines further down was invisible, so the
55
+ // per-row cap could be walked straight past and a closure went unseen.
56
+ //
57
+ // Under the option only a NESTED fence continues an open block — one whose opening line is indented,
58
+ // which is what makes it part of the list item at all. A fence opening at column 0 is a
59
+ // DOCUMENT-level block and still closes the row, exactly as an unindented line does; absorbing it
60
+ // charged a one-line row for six. The run is decided ONCE, at its opening line, so a content line
61
+ // inside it cannot re-decide the question.
62
+ //
63
+ // The absorbed lines never enter `lines` — a marker inside a quotation is not a status — so the
64
+ // block records where they were: `span` is the row's PHYSICAL extent, and `gaps` holds the `lines`
65
+ // indices a fence run follows, so a reader assembling a multi-line span cannot join text from both
66
+ // sides of a code block into one claim.
67
+ export const bulletBlocks = (lines, fencedLines, from, to, { fenceContinues = false } = {}) => {
49
68
  const blocks = [];
50
69
  let current = null;
70
+ let absorbing = null;
51
71
  const close = () => {
52
72
  if (current) blocks.push(current);
53
73
  current = null;
54
74
  };
55
75
  for (let index = from; index < to; index += 1) {
56
76
  if (fencedLines.has(index)) {
57
- close();
77
+ if (absorbing === null) absorbing = Boolean(fenceContinues && current && /^\s+\S/.test(lines[index]));
78
+ if (!absorbing) close();
79
+ else {
80
+ current.span += 1;
81
+ current.gaps.add(current.lines.length - 1);
82
+ }
58
83
  continue;
59
84
  }
85
+ absorbing = null;
60
86
  const line = lines[index];
61
87
  if (BULLET.test(line)) {
62
88
  close();
63
- current = [line];
89
+ current = { start: index, lines: [line], span: 1, gaps: new Set() };
64
90
  } else if (current && (line.trim() === '' || /^\s+\S/.test(line))) {
65
- current.push(line);
91
+ current.lines.push(line);
92
+ current.span += 1;
66
93
  } else {
67
94
  close();
68
95
  }
@@ -83,7 +110,7 @@ export const extractAcceptance = (planText) => {
83
110
  if (!open) return [];
84
111
  const next = headings.find((heading) => heading.index > open.index && heading.level <= 2);
85
112
  return bulletBlocks(lines, fencedLines, open.index + 1, next ? next.index : lines.length)
86
- .map((block) => normalize(block.join('\n').replace(/^-\s+/, '')))
113
+ .map((block) => normalize(block.lines.join('\n').replace(/^-\s+/, '')))
87
114
  .filter(Boolean);
88
115
  };
89
116
 
@@ -134,7 +161,7 @@ const exposureOf = (value) => {
134
161
 
135
162
  const topLevelRows = (queueText) => {
136
163
  const { lines, fencedLines } = tokenizeMarkdown(String(queueText ?? ''), 'the queue');
137
- return bulletBlocks(lines, fencedLines, 0, lines.length).map((block) => block.join('\n'));
164
+ return bulletBlocks(lines, fencedLines, 0, lines.length).map((block) => block.lines.join('\n'));
138
165
  };
139
166
 
140
167
  const EMPTY_ROW = () => ({ found: false, matches: 0, fields: {}, missing: [...ROW_FIELDS], duplicates: [], exposure: null, closed: null, claimInInvariant: false });
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ // The CLI half of the queue auditor: argv and fs, no rule (the rule is queue-audit.mjs).
3
+ //
4
+ // Split for the same reason fold-scope is split — a module you can hold whole is the unit of review,
5
+ // and the rules file had reached the source-size cap. Read-only: it reads the file it is pointed at
6
+ // and writes nothing. Dependency-free, Node >= 22.
7
+ //
8
+ // Exit codes: 0 accept; 1 refuse (a terminal/record row still listed, or a cap breach); 2 usage —
9
+ // a missing/unknown flag, a flag with no value, an unreadable path, or a section that is not there.
10
+
11
+ import { readFileSync } from 'node:fs';
12
+ import { fail } from '../references/scripts/markdown-blocks.mjs';
13
+ import { isDirectRun } from './direct-run.mjs';
14
+ import { CLASSES, DEFAULTS, auditQueue, checkQueue, formatReport } from './queue-audit.mjs';
15
+
16
+ const HELP = `queue-audit — classify the backlog queue's rows (agent-workflow family).
17
+
18
+ Usage:
19
+ node queue-audit-cli.mjs --report <queue-file> [--section "## Pending / backlog (newest)"]
20
+ node queue-audit-cli.mjs --check <queue-file> [--section "…"] [--max-rows N] [--max-row-lines N]
21
+
22
+ --report one tab-separated line per row: file line, class, row length, title, the literal evidence.
23
+ Deterministic — this is the manifest a deletion is driven by, never a regex guess.
24
+ --check refuses when a terminal or record row is still listed, when a row over the per-row line cap
25
+ carries work (live, parked and ambiguous alike), or when more rows than the row cap carry
26
+ work. Ambiguous rows are reported and never refuse on their own: a row that contradicts
27
+ itself, or names a status word outside a status position, is settled by a human.
28
+
29
+ Classes: ${CLASSES.join(' · ')}. Defaults: --max-rows ${DEFAULTS.maxRows}, --max-row-lines ${DEFAULTS.maxRowLines}.
30
+
31
+ Exit codes: 0 accept; 1 refuse; 2 usage (missing/unknown flag, unreadable path, bad section).`;
32
+
33
+ // A flag whose value is MISSING refuses. `--section` with nothing after it used to fall through to
34
+ // `null`, which means "audit the whole document" — silently widening the domain of a report a
35
+ // deletion is driven by, in exactly the direction that costs live rows.
36
+ const valueOf = (argv, index, flag) => {
37
+ const value = argv[index + 1];
38
+ if (value === undefined || value === '' || value.startsWith('--')) throw fail(2, `${flag} takes a value`);
39
+ return value;
40
+ };
41
+
42
+ const parseArgv = (argv) => {
43
+ // `--help` is answered ONLY when it is the whole invocation. A help flag that wins from anywhere
44
+ // makes `--check <dirty-file> --help` exit 0 — the gate's refusal replaced by a help page, which
45
+ // is the same bypass a second mode flag would be, reached by a flag nobody reads as dangerous.
46
+ if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) return { mode: 'help' };
47
+ const options = { mode: null, path: null, section: null };
48
+ // Every option here is a SINGLETON. A repeat used to win silently: a second `--section` moved the
49
+ // domain the report covers and a softer `--max-rows` moved the ratchet, both without a word — and
50
+ // both in the direction that lets a queue keep rows a check would have refused.
51
+ const seen = new Set();
52
+ const once = (flag) => {
53
+ if (seen.has(flag)) throw fail(2, `${flag} was given twice — each option is named exactly once`);
54
+ seen.add(flag);
55
+ };
56
+ for (let index = 0; index < argv.length; index += 1) {
57
+ const arg = argv[index];
58
+ if (arg === '--help' || arg === '-h') throw fail(2, '--help is answered only when it is the whole invocation — it never rides another mode');
59
+ else if (arg === '--report' || arg === '--check') {
60
+ // The mode is set ONCE. A later flag overwriting an earlier one would let `--check <f>
61
+ // --report <f>` answer a refusal question with an exit-0 report — the gate's verdict replaced
62
+ // by a listing, silently. Repetition is as wrong as conflict: both mean the caller asked two
63
+ // questions and only one was answered.
64
+ if (options.mode) throw fail(2, `--${options.mode} was already given — name exactly one of --report or --check`);
65
+ options.mode = arg.slice(2);
66
+ options.path = valueOf(argv, index, arg);
67
+ index += 1;
68
+ } else if (arg === '--section') {
69
+ once(arg);
70
+ options.section = valueOf(argv, index, arg);
71
+ index += 1;
72
+ } else if (arg === '--max-rows' || arg === '--max-row-lines') {
73
+ once(arg);
74
+ const value = Number(valueOf(argv, index, arg));
75
+ if (!Number.isInteger(value) || value <= 0) throw fail(2, `${arg} takes a positive integer, got "${argv[index + 1]}"`);
76
+ options[arg === '--max-rows' ? 'maxRows' : 'maxRowLines'] = value;
77
+ index += 1;
78
+ } else throw fail(2, `unknown argument "${arg}" — run with --help`);
79
+ }
80
+ if (!options.mode) throw fail(2, 'one of --report or --check is required — run with --help');
81
+ if (!options.path) throw fail(2, `${`--${options.mode}`} takes a queue file path`);
82
+ // A cap named beside `--report` used to be accepted and then ignored, and the run still exited 0 —
83
+ // so an operator who meant to ask a question about the caps was told nothing and read the silence
84
+ // as an answer. The caps belong to `--check`; naming one here is a usage error, not a no-op.
85
+ const capsInReport = ['--max-rows', '--max-row-lines'].filter((flag) => seen.has(flag));
86
+ if (options.mode === 'report' && capsInReport.length) {
87
+ throw fail(2, `${capsInReport.join(' and ')} ${capsInReport.length > 1 ? 'are' : 'is'} a --check option — --report lists every row and judges no cap`);
88
+ }
89
+ return options;
90
+ };
91
+
92
+ export const main = (argv, { log = console.log, error = console.error } = {}) => {
93
+ let options;
94
+ try {
95
+ options = parseArgv(argv);
96
+ } catch (err) {
97
+ error(err.message);
98
+ return err.exitCode ?? 2;
99
+ }
100
+ if (options.mode === 'help') {
101
+ log(HELP);
102
+ return 0;
103
+ }
104
+
105
+ let text;
106
+ try {
107
+ text = readFileSync(options.path, 'utf8');
108
+ } catch (err) {
109
+ error(`cannot read ${options.path}: ${err.message}`);
110
+ return 2;
111
+ }
112
+
113
+ try {
114
+ if (options.mode === 'report') {
115
+ log(formatReport(auditQueue(text, { section: options.section, label: options.path }), { label: options.path }));
116
+ return 0;
117
+ }
118
+ const result = checkQueue(text, { ...options, label: options.path });
119
+ for (const note of result.notes) log(note);
120
+ for (const problem of result.problems) error(problem);
121
+ log(
122
+ `${options.path}: ${result.total} rows — ` +
123
+ CLASSES.map((klass) => `${result.counts[klass]} ${klass}`).join(' · '),
124
+ );
125
+ return result.ok ? 0 : 1;
126
+ } catch (err) {
127
+ error(err.message);
128
+ return err.exitCode ?? 1;
129
+ }
130
+ };
131
+
132
+ // `process.exitCode`, never `process.exit()`: stdout is a PIPE under a gate runner, and an immediate
133
+ // exit drops whatever of a large `--report` has not been flushed yet. A truncated manifest is worse
134
+ // than none — it is the document a deletion is driven by, and a short one reads as a complete one.
135
+ if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+ // The STATUS GRAMMAR of a queue row: what one row says about its own state, and the literal evidence
3
+ // for saying it. The DOCUMENT pass — which rows exist, which section they live in, what the caps say
4
+ // — is queue-audit.mjs, and the argv/fs half is queue-audit-cli.mjs. Split at the seam the source-size
5
+ // practice asks for: a module you can hold whole is the unit of review.
6
+ //
7
+ // Five classes, and the boundaries between them are deliberately conservative, because the consumer
8
+ // of a `terminal` verdict is a DELETION:
9
+ //
10
+ // live no status marker decides otherwise — the default, and what a queue should hold.
11
+ // terminal the row is DEAD — done, closed, superseded, moot, declined: a marker in the TITLE, or
12
+ // a marker OPENING a bold status line in the body (`**CLOSED 2026-07-20 …**`). The body
13
+ // decides when the title is silent — the real shape of a row that was closed in place.
14
+ // parked the row is FROZEN, not dead: `PARKED` / `STOPPED` carry a stated resume condition, so
15
+ // deleting one loses work that is only waiting. Reported, never deleted, never a refusal.
16
+ // record the row is not work at all: a TALLY counter or a SEQUENCING note.
17
+ // ambiguous the row declares two states, or names a status word outside a status position.
18
+ // REPORTED, never auto-deleted.
19
+ //
20
+ // A terminal WORD in ordinary prose is NOT a status: rows routinely cite a sibling that was CLOSED or
21
+ // explain why something was CUT, and reading that as the row's own state would delete live work. Only
22
+ // the title and a bold status line are status positions.
23
+ //
24
+ // Pure string functions. No IO, no argv, no side effects on import. Dependency-free, Node >= 22.
25
+
26
+ // The closed list the queue actually uses. `DONE` and `CLOSED` are the two fold-scope already knows
27
+ // (its CLOSED_MARKERS); the rest are the states this corpus grew on its own. The check mark is a
28
+ // marker in its own right because the file's DONE-entry convention leads with it.
29
+ export const TERMINAL_MARKERS = ['✅', 'DONE', 'CLOSED', 'RESOLVED', 'DECIDED', 'SUPERSEDED', 'MOOT', 'DECLINED'];
30
+
31
+ // FROZEN, not dead: each of these carries a stated condition under which the work resumes (measured:
32
+ // "costs nothing until one is adopted again", "do NOT open tranche 4", "do NOT schedule without
33
+ // recurring incidents"). They are classified apart precisely so a deletion pass cannot take them.
34
+ export const FROZEN_MARKERS = ['PARKED', 'STOPPED'];
35
+
36
+ // A title marker that declares the row still OPEN. Only these two: they are the ones the corpus
37
+ // writes deliberately, and a wider list would turn ordinary words into status.
38
+ export const LIVE_MARKERS = ['QUEUED', 'PENDING'];
39
+
40
+ // Not work: a counter and an ordering note. Matched at the START of the title only.
41
+ export const RECORD_PREFIXES = ['TALLY', 'SEQUENCING'];
42
+
43
+ export const CLASSES = ['live', 'terminal', 'parked', 'record', 'ambiguous'];
44
+
45
+ // The classes that still carry work, and therefore still cost a reader attention: both caps judge
46
+ // exactly these.
47
+ export const CARRY_WORK = new Set(['live', 'parked', 'ambiguous']);
48
+
49
+ export const DEFAULTS = { maxRows: 60, maxRowLines: 12 };
50
+
51
+ // A row is judged on the text it DECLARES, never on the text it QUOTES, and this is the ONE place
52
+ // that distinction is made. Every quotation this grammar can recognise is removed before any marker
53
+ // is looked for, and each is replaced by a SPACE so the tokens around it never become neighbours:
54
+ //
55
+ // inline code `` `DONE 2026-01-01` is parser input `` names a literal. Stripping the backticks and
56
+ // keeping the text turned a row ABOUT the parser into a closed row — a deletion.
57
+ // indented a body line indented past the row's own continuation (2 spaces here, plus Markdown's
58
+ // code block 4) is a code block, so ` **DONE 2026-02-01:** sample` is sample output, not this
59
+ // row's status.
60
+ //
61
+ // A fenced region is handled one level up, by the block scan, and reaches here as a `gaps` boundary.
62
+ // What remains is deliberately NOT exhaustive: this is a hand-written reader over a corpus, so the
63
+ // residue is stated in the contract rather than guessed at, and the classes it can still misread are
64
+ // reported (`ambiguous`), never deleted.
65
+ const INLINE_CODE = /`[^`\n]*`/g;
66
+ const CODE_INDENT = /^\s{6,}\S/;
67
+ const quoteFree = (line) => String(line ?? '').replace(/\r/g, '').replace(INLINE_CODE, ' ');
68
+ const judgeable = (line) => (CODE_INDENT.test(String(line ?? '')) ? '' : quoteFree(line));
69
+
70
+ // The TITLE is the row's first bold span — `- **… — queued 2026-08-26.** prose` — and it ENDS where
71
+ // that span closes, even when it wraps over several lines. Both halves matter: taking the whole first
72
+ // line would read a sibling named in the prose after the title (`Its sibling was CLOSED …`) as this
73
+ // row's own status, and taking only the first line would lose the marker of a title that wraps — the
74
+ // common shape here, since a named row puts its id and `queued <date>` on the second line. A row with
75
+ // no bold span falls back to its first line.
76
+ export const titleOf = (blockLines, gaps = new Set()) => {
77
+ // A title never closes ACROSS a code block: the fenced lines a row absorbs are elided from its
78
+ // lines, so without this bound an opener above a fence and a `:**` below it become adjacent and a
79
+ // title assembles itself out of two halves the document never joined.
80
+ const upTo = [...gaps].filter((at) => at >= 0).sort((a, b) => a - b)[0];
81
+ const reach = upTo === undefined ? blockLines.length : upTo + 1;
82
+ const span = (render) => {
83
+ const first = render(String(blockLines[0] ?? ''));
84
+ const joined = blockLines.slice(0, reach).map(render).join('\n');
85
+ const open = first.indexOf('**');
86
+ const close = open === -1 ? -1 : joined.indexOf('**', open + 2);
87
+ return {
88
+ raw: close === -1 ? first : joined.slice(open + 2, close),
89
+ endLine: close === -1 ? 0 : joined.slice(0, close).split('\n').length - 1,
90
+ };
91
+ };
92
+ const flatten = (raw) => raw.replace(/^\s*[-*]\s+/, '').replace(/[*_]/g, '').replace(/\s+/g, ' ').trim();
93
+ // TWO renderings of one title, and the split is the point. `text` is what a HUMAN reads in the
94
+ // manifest, so it keeps every word the row wrote — eliding quoted code there turned readable titles
95
+ // into gaps ("codex and") in the very document a deletion is driven by. `judged` is what the
96
+ // MARKERS are looked for in, with quotations removed, so a row that names a literal is never
97
+ // mistaken for a row that declares a state.
98
+ const display = span((line) => String(line ?? '').replace(/\r/g, ''));
99
+ const judged = span(quoteFree);
100
+ return { text: flatten(display.raw).replace(/`/g, ''), judged: flatten(judged.raw), endLine: display.endLine };
101
+ };
102
+
103
+ // A word-boundary match that survives punctuation the corpus writes (`— CLOSED 2026-08-21 ·`), and
104
+ // that never fires inside a longer word (`UNDECIDED`, `PARKED-ish`). The HYPHEN is a boundary that
105
+ // does NOT count: every row id here is a kebab slug, so `IS-A-CLOSED-LIST` carries the word CLOSED as
106
+ // part of a NAME, and reading that as the row's own status would delete live work (measured — it was
107
+ // the only false positive in the 272-row corpus). The check mark is not a word character, so it is
108
+ // matched literally.
109
+ //
110
+ // CASE is asymmetric, and the asymmetry is measured, not stylistic. A real status here is SHOUTED
111
+ // (`QUEUED` · `DONE` · `CLOSED` · `PARKED`), while the same words in lower case are ordinary prose:
112
+ // matching terminal markers case-insensitively flipped EIGHT live rows to terminal in one pass —
113
+ // "(decided 2026-07-22)", "…is DEFERRED until resolved", "the class gets asked for … done" — every
114
+ // one a false positive, every one a deletion. Live markers are the opposite: the corpus writes
115
+ // `queued 2026-08-26` in lower case, so a case-sensitive live marker never fires and the
116
+ // contradiction arm that PROTECTS a row goes dead. So: terminal and frozen are case-sensitive, live
117
+ // is not. The cost of the split is a non-standard lower-case dead row staying `live` — visible, and
118
+ // far cheaper than deleting work.
119
+ // ONE identifier-aware boundary, used by every marker test here: a marker glued to any identifier
120
+ // character — letter, DIGIT, underscore or hyphen — belongs to a NAME, not to a status. Measured
121
+ // misses when it was letters-and-hyphen only: `DONE2-STATE-IS-UNREACHABLE` and
122
+ // `CLOSED_LOOP-DESIGN-IS-UNDOCUMENTED` are live rows named after the thing they fix.
123
+ // A TOP-LEVEL list item the row grammar does not take: a `*` or `+` bullet — with content or EMPTY,
124
+ // since an empty one still opens a list whose indented content the audit would then never judge — or
125
+ // a `-` with nothing after it. Indented items are nested content of a row and are read as its body.
126
+ export const UNREAD_ITEM = /^(?:[*+](?:\s+\S|\s*$)|-\s*$)/;
127
+
128
+ const IDENT = '[A-Za-z0-9_-]';
129
+ const carries = (text, marker, { anyCase = false } = {}) =>
130
+ /^[A-Za-z]+$/.test(marker)
131
+ ? new RegExp(`(?<!${IDENT})${marker}(?!${IDENT})`, anyCase ? 'i' : '').test(text)
132
+ : text.includes(marker);
133
+
134
+ const markersIn = (text, list, options) => list.filter((marker) => carries(text, marker, options));
135
+
136
+ // A BOLD status line: `**CLOSED 2026-07-20 (…):** the story`. Anchored at the start of the line
137
+ // (indent allowed) so a bold phrase mid-sentence is never a status, and requiring the marker to open
138
+ // the bold span so `**Fix (small canon change):**` cannot become one.
139
+ // A bold status may WRAP: the opener sits on one line and the `:**` on the next — two rows of the
140
+ // live corpus are written that way, and a per-line matcher called both of them live, which is a
141
+ // false GREEN from the gate that authorises deletions. So the span is read across continuation
142
+ // lines, bounded so a `**` that never closes cannot swallow the rest of the row.
143
+ const BOLD_OPEN = /^\s*\*\*\s*(.*)$/;
144
+ const BOLD_SPAN_LINES = 4;
145
+
146
+ // A span never reaches ACROSS a code block. The fenced lines a row absorbs are elided from its
147
+ // lines, so without the gap set an opener above a fence and a `:**` below it become adjacent and
148
+ // assemble into one claim that the document never made.
149
+ const boldSpanAt = (blockLines, index, gaps = new Set()) => {
150
+ const first = BOLD_OPEN.exec(judgeable(blockLines[index]));
151
+ if (!first) return null;
152
+ let span = first[1];
153
+ for (let step = 0; step < BOLD_SPAN_LINES; step += 1) {
154
+ const close = span.indexOf('**');
155
+ if (close !== -1) return span.slice(0, close).replace(/:\s*$/, '');
156
+ const next = blockLines[index + step + 1];
157
+ if (next === undefined || gaps.has(index + step)) return null;
158
+ span += ` ${judgeable(next).trim()}`;
159
+ }
160
+ return null;
161
+ };
162
+ // The marker must OPEN the bold span, as a WHOLE token. A QUALIFIED closure closes a PART of the row,
163
+ // not the row: `**PART (2) IS CLOSED …**`, `**SECOND FACE CLOSED …**`, `**+ bare-lane DECIDED …**`,
164
+ // `**DISPOSITION DECIDED …**` all leave work behind, and five of the six body-decided rows in the live
165
+ // corpus were exactly that shape. The token boundary matters too — `**CLOSED-loop design:**` is a
166
+ // subheading about a loop, not a closure. A bare check mark with no word after it (`**✅ 2026-08-20:**`)
167
+ // IS a status: the corpus leads its done entries with it.
168
+ const LEAD_WORD = new RegExp(`^([A-Za-z]+)(?!${IDENT})`);
169
+ const DATE = /^\d{4}-\d{2}-\d{2}/;
170
+ // A real status carries its DATE, and that is measured, not stylistic: across the 272-row corpus the
171
+ // status forms are `DONE 2026-08-21 ·`, `CLOSED 2026-07-20 (…)`, `SUPERSEDED 2026-08-21 by the row
172
+ // above`, `PARKED 2026-08-21 by AD-105`, `RESOLVED 2026-08-25 —`; the same words in prose never do —
173
+ // `STOPPED. That is`, `PARKED rather than`, `RESOLVED; the npm-pack`, `SUPERSEDED by a`. Requiring
174
+ // the date is what keeps a row NAMED after the machinery it fixes (`CLOSED STATUS PARSER DROPS ROWS`,
175
+ // `**DONE criteria:**`) out of the deletion set. A marker without one is not ignored — it makes the
176
+ // row `ambiguous`, for a human.
177
+ // The date is ADJACENT to the marker. A 40-character window of arbitrary text between the two was
178
+ // measured wrong: `**DONE criteria due 2026-09-01:**` — a live row stating when its criteria are due
179
+ // — read as a closure, and this verdict authorises a DELETION. The only gap the corpus actually
180
+ // writes is a SECOND shouted status word introduced by `+`: `DONE + SHIPPED 2026-07-09`,
181
+ // `DONE + PUBLISHED 2026-08-25` (measured — those three rows and nothing else in 6900 lines). So the
182
+ // gap is exactly that, never prose: a `+` must introduce every extra word, which is what keeps
183
+ // `DONE criteria due <date>` and `DONE CRITERIA <date>` out of the deletion set.
184
+ const DATED_STATUS = /^(?:\s*\+\s*[A-Z][A-Z-]*)*\s*\d{4}-\d{2}-\d{2}/;
185
+
186
+ // The markers OPENING a span, as whole tokens. A leading check mark alone declares NOTHING: the
187
+ // corpus writes `**✅ ENTRY GATE OPEN:**` for a live gate, so the mark must be followed by a terminal
188
+ // word or by a date — the form its done entries actually use (`**✅ 2026-08-20 (AD-100):**`).
189
+ // `requireDate: false` answers the WEAKER question — does this span OPEN with a status word at all —
190
+ // which is what separates "no status here" from "a status word with no date beside it". The second
191
+ // is not silence: it is a row a human has to settle.
192
+ export const leadMarkers = (span, list = TERMINAL_MARKERS, { requireDate = true } = {}) => {
193
+ let rest = String(span).replace(/\r/g, '').trimStart();
194
+ const tick = rest.startsWith('✅');
195
+ if (tick) rest = rest.slice(1).trimStart();
196
+ const word = LEAD_WORD.exec(rest);
197
+ const named = word ? list.filter((m) => m === word[1]) : [];
198
+ if (named.length && (!requireDate || DATED_STATUS.test(rest.slice(word[1].length)))) {
199
+ return tick ? ['✅', ...named] : named;
200
+ }
201
+ // A BARE check mark declares nothing on its own — the corpus writes `**✅ ENTRY GATE OPEN …**` for
202
+ // a LIVE gate — so it counts only in its DATED form, and the weaker question never takes it.
203
+ // Measured: dropping that guard turned three live rows into ambiguous, this one among them.
204
+ if (!named.length && tick && list.includes('✅') && requireDate && DATE.test(rest)) return ['✅'];
205
+ return [];
206
+ };
207
+
208
+ // A STATUS sits at the head of the title or at the head of one of its segments — `✅ DONE 2026-…`,
209
+ // `A-ROW — ✅ CLOSED 2026-…`, `SUPERSEDED 2026-… by the row above`. A marker anywhere else is a
210
+ // MENTION: `THE CLOSED state drops live work` is a defect report about closed state, not a closed
211
+ // row, and deleting it would take live work. ONLY the documented separators split a segment — the
212
+ // spaced dash family the corpus writes its status after. A bare hyphen would cut every kebab id into
213
+ // pieces; a bracket or a colon would make `(CLOSED is an input)` and `note: DONE is a token` into
214
+ // status heads, which is a row ABOUT status words being deleted for containing them.
215
+ const SEGMENT_SPLIT = /\s+[—–]\s+/;
216
+ const statusMarkersIn = (title, list) => [
217
+ ...new Set(
218
+ String(title)
219
+ .split(SEGMENT_SPLIT)
220
+ .flatMap((segment) => leadMarkers(segment, list)),
221
+ ),
222
+ ];
223
+
224
+ const boldStatusMarkers = (blockLines, from, { list = TERMINAL_MARKERS, gaps, ...options } = {}) => {
225
+ for (let offset = from + 1; offset < blockLines.length; offset += 1) {
226
+ const span = boldSpanAt(blockLines, offset, gaps);
227
+ if (span === null) continue;
228
+ const found = leadMarkers(span, list, options);
229
+ if (found.length) return { markers: found, offset };
230
+ }
231
+ return null;
232
+ };
233
+
234
+ const evidenceOf = (parts) => parts.filter(Boolean).join(' + ');
235
+
236
+ // classifyRow(blockLines) -> { klass, evidence }. The order of the arms IS the rule: a record is
237
+ // judged before any status, a contradiction before the state it contradicts, and `live` is what
238
+ // survives when nothing else decided.
239
+ export const classifyRow = (blockLines, gaps) => {
240
+ const { judged: title, endLine } = titleOf(blockLines, gaps);
241
+ // The prefix must be a whole token: `TALLYING-FAILURES-HAS-NO-RUNG` and `SEQUENCING-BUG-IN-THE-
242
+ // DISPATCHER` are work, and a bare `startsWith` would have the checker demand their deletion.
243
+ const record = RECORD_PREFIXES.find((prefix) => new RegExp(`^${prefix}(?!${IDENT})`).test(title));
244
+ if (record) return { klass: 'record', evidence: `title opens with ${record}` };
245
+
246
+ // EVERY status of every class is gathered BEFORE anything is decided. Deciding as they were found
247
+ // made two states unreachable: a frozen title returned before the contradiction arms could see a
248
+ // live marker beside it, so `PARKED … — QUEUED …` read as simply parked, and the body was scanned
249
+ // for terminal markers only, so a dated `**PARKED 2026-08-21 …**` closing a row in place was not a
250
+ // state at all.
251
+ const titleFrozen = statusMarkersIn(title, FROZEN_MARKERS);
252
+ const titleTerminal = statusMarkersIn(title, TERMINAL_MARKERS);
253
+ const titleLive = markersIn(title, LIVE_MARKERS, { anyCase: true });
254
+ const bodyTerminal = boldStatusMarkers(blockLines, endLine, { gaps });
255
+ const bodyFrozen = boldStatusMarkers(blockLines, endLine, { gaps, list: FROZEN_MARKERS });
256
+ // The body declares LIVE too, and leaving it out of the table was the dangerous half: a row whose
257
+ // body said `**QUEUED 2026-08-20:**` and then `**CLOSED 2026-01-01:**` was read as simply closed —
258
+ // a contradiction handed to a deletion as a verdict. A live marker never needs its date here (the
259
+ // corpus writes `queued` in lower case, and the arm that reads it PROTECTS the row).
260
+ const bodyLive = boldStatusMarkers(blockLines, endLine, { gaps, list: LIVE_MARKERS, requireDate: false });
261
+ // A marker the title carries somewhere OTHER than a status head. It cannot decide the row, and it
262
+ // cannot be ignored either — a human settles it. The check mark STAYS in this path, and the
263
+ // asymmetry with the body side is measured, not an oversight: in a TITLE the corpus writes
264
+ // `✅ Plan 3 / 3 — …` as a done marker (ten such rows), so dropping it turned ten reported rows
265
+ // silent; in a BOLD BODY span it writes `**✅ ENTRY GATE OPEN …**` for a gate that OPENED. Same
266
+ // glyph, two positions, two meanings — and the position is what this module already reads.
267
+ const mentioned = markersIn(title, [...TERMINAL_MARKERS, ...FROZEN_MARKERS]).filter(
268
+ (marker) => !titleTerminal.includes(marker) && !titleFrozen.includes(marker),
269
+ );
270
+ if (!titleTerminal.length && !titleFrozen.length && mentioned.length) {
271
+ return { klass: 'ambiguous', evidence: `title mentions ${mentioned.join(', ')} outside a status position` };
272
+ }
273
+ // A row that declares two states declares none: it is REPORTED, and a human settles it. The title
274
+ // and the body are read into ONE table and reduced by CLASS, never by position — an earlier version
275
+ // let the title's own state hide the body's, so a terminal title above a `**PARKED <date>:**` body
276
+ // stayed terminal and kept authorising a deletion while the row declared two things. Two sightings
277
+ // of the SAME class are one state (a title and a body that agree do not contradict).
278
+ const sightings = [
279
+ titleTerminal.length && { klass: 'terminal', evidence: `title: ${titleTerminal.join(', ')}` },
280
+ titleFrozen.length && { klass: 'parked', evidence: `title: ${titleFrozen.join(', ')}` },
281
+ titleLive.length && { klass: 'live', evidence: `title: ${titleLive.join(', ')}` },
282
+ bodyTerminal && { klass: 'terminal', evidence: `body +${bodyTerminal.offset}: ${bodyTerminal.markers.join(', ')}` },
283
+ bodyFrozen && { klass: 'parked', evidence: `body +${bodyFrozen.offset}: ${bodyFrozen.markers.join(', ')}` },
284
+ bodyLive && { klass: 'live', evidence: `body +${bodyLive.offset}: ${bodyLive.markers.join(', ')}` },
285
+ ].filter(Boolean);
286
+ const declared = [...new Set(sightings.map((s) => s.klass))];
287
+ if (declared.length > 1) return { klass: 'ambiguous', evidence: evidenceOf(sightings.map((s) => s.evidence)) };
288
+ if (declared.length === 1 && declared[0] !== 'live') {
289
+ return { klass: declared[0], evidence: evidenceOf(sightings.map((s) => s.evidence)) };
290
+ }
291
+
292
+ // A bold body span that OPENS with a status word but carries no date beside it is the residue of
293
+ // the date rule, and silence is the wrong answer for it: `**DONE criteria due 2026-09-01:**` used
294
+ // to be read as a closure, and the fix must not turn it into "nothing to see". It is REPORTED as
295
+ // ambiguous — visible to a human, never deletable by a machine. Measured: with the bare-check-mark
296
+ // guard in place, zero rows in the live 314-row corpus and zero in the 62-row purge archive take
297
+ // this arm, so it closes a door without moving a single existing verdict.
298
+ const undated = boldStatusMarkers(blockLines, endLine, { gaps, requireDate: false })
299
+ ?? boldStatusMarkers(blockLines, endLine, { gaps, list: FROZEN_MARKERS, requireDate: false });
300
+ if (undated) {
301
+ return {
302
+ klass: 'ambiguous',
303
+ evidence: `body +${undated.offset}: ${undated.markers.join(', ')} with no date beside it`,
304
+ };
305
+ }
306
+
307
+ return { klass: 'live', evidence: 'no status marker' };
308
+ };
309
+
310
+ // The `[from, to)` body-line window a `--section` names: it opens after that heading and closes at
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ // The backlog queue as a CLASSIFIED corpus, not a prose pile.
3
+ //
4
+ // `docs/plans/queue.md` is the one long-lived surface in this family with no type, cap, rotation or
5
+ // exit event: rows accumulate, a closed row stays listed because its 26 lines of measurements have
6
+ // nowhere else to live, and a heading can say one thing while its body says another. The file's own
7
+ // header already asked for the discipline in prose ("a DONE entry is <=5 lines") and it did not hold.
8
+ // This module is the half prose cannot do — it says, per row and with the literal evidence, whether
9
+ // the row is still WORK.
10
+ //
11
+ // Four classes, and the boundaries between them are deliberately conservative, because the consumer
12
+ // of a `terminal` verdict is a DELETION:
13
+ //
14
+ // live no status marker decides otherwise — the default, and what a queue should hold.
15
+ // terminal the row is DEAD — done, closed, superseded, moot, declined: a marker in the TITLE, or
16
+ // a marker OPENING a bold status line in the body (`**CLOSED 2026-07-20 …**`). The body
17
+ // decides when the title is silent — the real shape of a row that was closed in place.
18
+ // parked the row is FROZEN, not dead: `PARKED` / `STOPPED` carry a stated resume condition, so
19
+ // deleting one loses work that is only waiting. Reported, never deleted, never a refusal.
20
+ // record the row is not work at all: a TALLY counter or a SEQUENCING note.
21
+ // ambiguous the row contradicts itself (a terminal AND a live marker in the title, or a bold
22
+ // terminal body under an explicitly QUEUED title). REPORTED, never auto-deleted.
23
+ //
24
+ // A terminal WORD in ordinary prose is NOT a status: rows routinely cite a sibling that was CLOSED or
25
+ // explain why something was CUT, and reading that as the row's own state would delete live work. Only
26
+ // the title and a bold status line are status positions.
27
+ //
28
+ // Markdown is read through the family's ONE block model (references/scripts/markdown-blocks.mjs) and
29
+ // the ONE bullet scan the other queue reader uses (fold-scope.mjs) — fences, CRLF, indented headings
30
+ // and the backtick-info-string rule are THEIR problem, never a second hand-rolled grammar here. A
31
+ // document either of them refuses is a loud refusal, never a silent empty read.
32
+ //
33
+ // Pure string functions plus a thin CLI. Read-only: it reads the file it is pointed at and writes
34
+ // nothing. Dependency-free, Node >= 22. No side effects on import.
35
+
36
+
37
+ import { tokenizeMarkdown, fail } from '../references/scripts/markdown-blocks.mjs';
38
+ import { bulletBlocks } from './fold-scope.mjs';
39
+ import { CARRY_WORK, CLASSES, DEFAULTS, UNREAD_ITEM, classifyRow, titleOf } from './queue-audit-rows.mjs';
40
+
41
+ // The row grammar is re-exported so one import names the whole reader: the CLI, the tests and any
42
+ // consumer ask this module, and the split into a rules half stays an implementation detail.
43
+ export { CLASSES, DEFAULTS, TERMINAL_MARKERS, FROZEN_MARKERS, LIVE_MARKERS, RECORD_PREFIXES, classifyRow, leadMarkers } from './queue-audit-rows.mjs';
44
+
45
+ // The `[from, to)` body-line window a `--section` names: it opens after that heading and closes at
46
+ // the next heading of the SAME level or higher, so a level-3 subheading stays inside. An absent
47
+ // section is a named refusal — auditing the whole file when the caller asked for one section would
48
+ // report rows the caller never meant to judge, and a deletion would follow.
49
+ // `frontLines` is not decoration: every line number this module reports is a FILE line, frontmatter
50
+ // included (the row manifest already adds it), so a refusal that named body-relative lines would send
51
+ // a reader to the wrong place in the very file it is refusing.
52
+ // ATX allows an optional CLOSING run of `#`, so `## Pending ##` and `## Pending` are the SAME
53
+ // heading. Comparing raw text made them two: the audit took one, and every row under the other left
54
+ // the domain silently — a section full of dead rows reported as zero rows and exit 0.
55
+ const canonicalHeading = (text) =>
56
+ String(text ?? '')
57
+ .replace(/\r/g, '')
58
+ .replace(/\s+#+\s*$/, '')
59
+ .replace(/\s+/g, ' ')
60
+ .trim();
61
+
62
+ const sectionWindow = (headings, lines, section, frontLines = 0) => {
63
+ if (!section) return { from: 0, to: lines.length };
64
+ const wanted = canonicalHeading(section);
65
+ const matches = headings.filter((heading) => canonicalHeading(heading.text) === wanted);
66
+ // A usage error, not a document refusal: what is wrong is the ARGUMENT, and the CLI contract
67
+ // promises 2 for that. AMBIGUITY refuses on the same footing as absence: taking the first of two
68
+ // same-named headings would leave every row under the second one outside the audit — invisible to
69
+ // the caps, absent from the report a deletion is driven by, and silently so.
70
+ if (matches.length === 0) throw fail(2, `no section heading "${wanted}" in the queue — the audit refuses to guess its domain.`);
71
+ if (matches.length > 1) {
72
+ throw fail(2, `${matches.length} section headings read "${wanted}" (lines ${matches.map((h) => frontLines + h.index + 1).join(', ')}) — the audit refuses to pick one and leave the rest unjudged.`);
73
+ }
74
+ const [open] = matches;
75
+ const next = headings.find((heading) => heading.index > open.index && heading.level <= open.level);
76
+ return { from: open.index + 1, to: next ? next.index : lines.length };
77
+ };
78
+
79
+ // auditQueue(text, { section, label }) -> { rows, counts }. Each row carries its 1-based FILE line
80
+ // (frontmatter included), the title as written, its class and the literal evidence for that class.
81
+ export const auditQueue = (text, { section = null, label = 'the queue' } = {}) => {
82
+ const { lines, headings, fencedLines, frontLines } = tokenizeMarkdown(String(text ?? ''), label);
83
+ const { from, to } = sectionWindow(headings, lines, section, frontLines);
84
+ // A fence CONTINUES a queue row rather than ending it, and the cap judges the row's PHYSICAL span.
85
+ // Both halves are the same defect: a row carrying a code block reported one line and hid whatever
86
+ // followed the fence — its length from the cap, and a closure from the classifier.
87
+ // A list item this grammar cannot read is a REFUSAL, never a silence. `*` and `+` open a list in
88
+ // every Markdown dialect and a bare `-` is an empty item; none of them is a row here, and dropping
89
+ // them made a section of dead work report "0 rows" and exit 0 — a gate answering about a domain it
90
+ // never looked at. The queue writes `-` rows; anything else is corrected by hand, not guessed at.
91
+ for (let index = from; index < to; index += 1) {
92
+ if (fencedLines.has(index) || !UNREAD_ITEM.test(lines[index])) continue;
93
+ throw fail(2, `line ${frontLines + index + 1} opens a list item this audit does not read ("${lines[index].trim().slice(0, 40)}") — a queue row is a "- " bullet, and judging around this one would report a domain that was never looked at.`);
94
+ }
95
+ const rows = bulletBlocks(lines, fencedLines, from, to, { fenceContinues: true }).map((block) => {
96
+ const { klass, evidence } = classifyRow(block.lines, block.gaps);
97
+ return {
98
+ line: frontLines + block.start + 1,
99
+ lines: block.span,
100
+ title: titleOf(block.lines, block.gaps).text,
101
+ klass,
102
+ evidence,
103
+ };
104
+ });
105
+ const counts = Object.fromEntries(CLASSES.map((klass) => [klass, rows.filter((row) => row.klass === klass).length]));
106
+ return { rows, counts, total: rows.length };
107
+ };
108
+
109
+ // checkQueue(text, options) -> { ok, problems, notes }. A problem is a REFUSAL and every one of them
110
+ // names a location: the family's bar is locations, never counts. An ambiguous row is a NOTE — it is
111
+ // exactly the case a human must settle, and failing on it would make the cap unpassable by anyone
112
+ // who did not already know the answer.
113
+ export const checkQueue = (text, options = {}) => {
114
+ const { maxRows = DEFAULTS.maxRows, maxRowLines = DEFAULTS.maxRowLines, label = 'the queue' } = options;
115
+ const { rows, counts, total } = auditQueue(text, { section: options.section ?? null, label });
116
+ const problems = [];
117
+ const notes = [];
118
+
119
+ for (const row of rows) {
120
+ if (row.klass === 'terminal' || row.klass === 'record') {
121
+ problems.push(
122
+ `${label}:${row.line}: a ${row.klass} row is still listed (${row.evidence}) — its story belongs to the ` +
123
+ `ADR or the changelog, and the row leaves the queue in the same commit: ${row.title.slice(0, 80)}`,
124
+ );
125
+ }
126
+ if (row.klass === 'ambiguous') {
127
+ notes.push(`${label}:${row.line}: ambiguous (${row.evidence}) — settle it by hand: ${row.title.slice(0, 80)}`);
128
+ }
129
+ if (row.klass === 'parked') {
130
+ notes.push(`${label}:${row.line}: parked (${row.evidence}) — frozen, not dead: ${row.title.slice(0, 80)}`);
131
+ }
132
+ if (CARRY_WORK.has(row.klass) && row.lines > maxRowLines) {
133
+ problems.push(
134
+ `${label}:${row.line}: the row is ${row.lines} lines, over the ${maxRowLines}-line cap — a row names the ` +
135
+ `work; the measurements belong to a record or an ADR: ${row.title.slice(0, 80)}`,
136
+ );
137
+ }
138
+ }
139
+
140
+ // Both caps count EVERY row that still carries work, frozen and ambiguous included. Counting only
141
+ // `live` would let the queue grow without limit through the Frozen bucket — moving a row there, or
142
+ // leaving it self-contradicting, would buy room the cap is there to deny.
143
+ const working = rows.filter((row) => CARRY_WORK.has(row.klass)).length;
144
+ if (working > maxRows) {
145
+ problems.push(
146
+ `${label}: ${working} rows carry work, over the ${maxRows}-row cap — a backlog nobody can read is a dump. ` +
147
+ 'Close, delete or fold rows before filing another.',
148
+ );
149
+ }
150
+
151
+ return { ok: problems.length === 0, problems, notes, counts, total };
152
+ };
153
+
154
+ // One tab-separated line per row: line, class, row-length, title. Deterministic and stable, so it can
155
+ // be diffed between runs and used as the manifest a deletion is driven by.
156
+ export const formatReport = (audit, { label = 'the queue' } = {}) => {
157
+ const head = [
158
+ `# queue-audit — ${label}`,
159
+ `# ${audit.total} rows: ${CLASSES.map((klass) => `${audit.counts[klass]} ${klass}`).join(' · ')}`,
160
+ '# line\tclass\tlines\ttitle\tevidence',
161
+ ];
162
+ const body = audit.rows.map((row) => [row.line, row.klass, row.lines, row.title, row.evidence].join('\t'));
163
+ return [...head, ...body].join('\n');
164
+ };
@@ -43,7 +43,17 @@ const dirOf = (rel) => rel.slice(0, rel.lastIndexOf('/'));
43
43
  const leafOf = (rel) => rel.slice(rel.lastIndexOf('/') + 1);
44
44
  const bare = (rel) => (rel.endsWith('/') ? rel.slice(0, -1) : rel);
45
45
  const lineCount = (text) => text.replace(/\n$/, '').split('\n').length;
46
- const occurrences = (text, needle) => text.split(needle).length - 1;
46
+ // A marker is counted as a WHOLE ordinal, never as a prefix of a longer one. A plain substring count
47
+ // makes `spec:…/S1` occur twice the moment `spec:…/S11` is written in the same file — so a store
48
+ // that reaches ten scenarios starts refusing bindings that are perfectly correct, and the refusal
49
+ // names the wrong scenario. Measured here at S11.
50
+ const occurrences = (text, needle) => {
51
+ let found = 0;
52
+ for (let at = text.indexOf(needle); at !== -1; at = text.indexOf(needle, at + needle.length)) {
53
+ if (!/[0-9]/.test(text[at + needle.length] ?? '')) found += 1;
54
+ }
55
+ return found;
56
+ };
47
57
  // Containment is a question about path COMPONENTS, and only the platform's own path model answers
48
58
  // it. A textual prefix test reads "/repo\outside" as a child of "/repo" on a POSIX host — where the
49
59
  // backslash is an ordinary filename character — and it mis-reads a filesystem root ("/" or "C:\")
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ // The CLI half of the coverage requirement: argv, fs and the debt record. No rule lives here (the
3
+ // rule is spec-coverage.mjs), and the ratchet is enforced HERE because it is the only write.
4
+ //
5
+ // Exit codes: 0 accept; 1 refuse (an uncovered tool, or a settled debt entry still recorded); 2
6
+ // usage — an unknown flag, a flag with no value, an unreadable scope or store, a reasonless write.
7
+
8
+ import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
9
+ import { join, relative, sep } from 'node:path';
10
+ import { isDirectRun } from './direct-run.mjs';
11
+ import { claimsOf, formatFindings, judgeCoverage, settleAfter } from './spec-coverage.mjs';
12
+
13
+ export const SCOPE_PATH = join('docs', 'ai', 'spec-coverage.json');
14
+ export const STORE_ROOT = join('docs', 'ai', 'specs');
15
+ const REASON_MAX_BYTES = 300;
16
+
17
+ const HELP = `spec-coverage — every shipped tool is governed by a contract, or the debt names it.
18
+
19
+ Usage:
20
+ node spec-coverage-cli.mjs --report [--root <dir>]
21
+ node spec-coverage-cli.mjs --check [--root <dir>]
22
+ node spec-coverage-cli.mjs --write-debt --reason "<what was paid, and by which contract>" [--root <dir>]
23
+
24
+ --report one line per in-scope tool: the contract that covers it, or that none does.
25
+ --check refuses an in-scope tool no contract claims and is not recorded as debt, and a
26
+ recorded entry that is already settled — the record must not overstate the debt.
27
+ --write-debt records what was PAID: every adopted path whose contract now exists moves into the
28
+ settled set, and nothing else changes. It never touches the adoption baseline, so a
29
+ path outside it cannot be invented — it is refused by name. Write the contract first.
30
+
31
+ Scope and debt: ${SCOPE_PATH}. Contracts: ${STORE_ROOT}. Exit codes: 0 accept; 1 refuse; 2 usage.`;
32
+
33
+ const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
34
+ const posix = (p) => p.split(sep).join('/');
35
+
36
+ // A scope this tool cannot trust is worse than no scope: `{}`, an empty `roots`, or an `exclude`
37
+ // carrying an empty string all yield a census of ZERO tools and a cheerful PASS — a gate answering
38
+ // about a domain it never looked at, which is the exact failure this whole rung exists to end.
39
+ const validateScope = (scope, path) => {
40
+ const bad = (why) => { throw fail(2, `the coverage scope ${path} is unusable: ${why}`); };
41
+ if (scope === null || typeof scope !== 'object' || Array.isArray(scope)) bad('it is not an object');
42
+ if (scope.schema !== 1) bad(`schema must be 1, got ${JSON.stringify(scope.schema)}`);
43
+ const list = (key, required) => {
44
+ const value = scope[key];
45
+ if (value === undefined && !required) return [];
46
+ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string' || v === '')) bad(`${key} must be an array of non-empty strings`);
47
+ if (required && value.length === 0) bad(`${key} is empty, so nothing would ever be judged`);
48
+ return value;
49
+ };
50
+ list('roots', true);
51
+ const extensions = list('extensions', true);
52
+ if (extensions.some((ext) => !ext.startsWith('.'))) bad('every extension starts with a dot');
53
+ list('exclude', false);
54
+ // Both recorded sets are PATHS. `Array.isArray` alone let `[42]` through, and a scope the tool
55
+ // cannot trust is the thing this validator exists to catch.
56
+ if (!Array.isArray(scope.adopted)) bad('adopted is the frozen set measured at adoption, and it must be an array');
57
+ list('adopted', false);
58
+ list('settled', false);
59
+ return scope;
60
+ };
61
+
62
+ const readJson = (path, what) => {
63
+ let raw;
64
+ try {
65
+ raw = readFileSync(path, 'utf8');
66
+ } catch (err) {
67
+ throw fail(2, `cannot read ${what} ${path}: ${err.message}`);
68
+ }
69
+ try {
70
+ return JSON.parse(raw);
71
+ } catch (err) {
72
+ throw fail(2, `${what} ${path} is not valid JSON: ${err.message}`);
73
+ }
74
+ };
75
+
76
+ // Deterministic order in both walks: a report a human compares between runs must not depend on the
77
+ // order a directory happens to be read in.
78
+ const sorted = (entries) => [...entries].sort((a, b) => (a.name < b.name ? -1 : 1));
79
+
80
+ export const specDocuments = (root, io = { readdirSync, readFileSync }) => {
81
+ const out = [];
82
+ const walk = (dir) => {
83
+ for (const entry of sorted(io.readdirSync(dir, { withFileTypes: true }))) {
84
+ const full = join(dir, entry.name);
85
+ if (entry.isDirectory()) walk(full);
86
+ else if (entry.name.endsWith('.md')) out.push({ rel: posix(relative(root, full)), text: io.readFileSync(full, 'utf8') });
87
+ }
88
+ };
89
+ walk(join(root, STORE_ROOT));
90
+ return out;
91
+ };
92
+
93
+ // A test file is never in scope: a contract governs the module, and its tests are the evidence FOR
94
+ // that contract, not a second thing to write one for. A `<name>.test/` directory is the same answer.
95
+ export const toolsIn = (root, scope, io = { readdirSync }) => {
96
+ const extensions = scope.extensions ?? ['.mjs'];
97
+ // A textual prefix is not a path. `.../fixtures` would also hide `.../fixtures-escape.mjs`, so a
98
+ // new tool could leave the scope by being named next to an excluded directory. The boundary is a
99
+ // path COMPONENT: the entry itself, or something under it.
100
+ const excluded = (rel) => (scope.exclude ?? []).some((prefix) => rel === prefix || rel.startsWith(`${prefix}/`));
101
+ const isTest = (name) => extensions.some((ext) => name.endsWith(`.test${ext}`));
102
+ const out = [];
103
+ const walk = (dir) => {
104
+ for (const entry of sorted(io.readdirSync(dir, { withFileTypes: true }))) {
105
+ const full = join(dir, entry.name);
106
+ const rel = posix(relative(root, full));
107
+ if (excluded(rel)) continue;
108
+ if (entry.isDirectory()) {
109
+ if (!entry.name.endsWith('.test')) walk(full);
110
+ } else if (extensions.some((ext) => entry.name.endsWith(ext)) && !isTest(entry.name)) out.push(rel);
111
+ }
112
+ };
113
+ for (const scopeRoot of scope.roots ?? []) walk(join(root, scopeRoot));
114
+ return out;
115
+ };
116
+
117
+ const parseArgv = (argv) => {
118
+ if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) return { mode: 'help' };
119
+ const options = { mode: null, root: process.cwd(), reason: null };
120
+ const seen = new Set();
121
+ const valueOf = (index, flag) => {
122
+ const value = argv[index + 1];
123
+ if (value === undefined || value === '' || value.startsWith('--')) throw fail(2, `${flag} takes a value`);
124
+ return value;
125
+ };
126
+ for (let index = 0; index < argv.length; index += 1) {
127
+ const arg = argv[index];
128
+ if (arg === '--report' || arg === '--check' || arg === '--write-debt') {
129
+ if (options.mode) throw fail(2, `--${options.mode} was already given — name exactly one mode`);
130
+ options.mode = arg.slice(2);
131
+ } else if (arg === '--root' || arg === '--reason') {
132
+ if (seen.has(arg)) throw fail(2, `${arg} was given twice — each option is named exactly once`);
133
+ seen.add(arg);
134
+ options[arg === '--root' ? 'root' : 'reason'] = valueOf(index, arg);
135
+ index += 1;
136
+ } else throw fail(2, `unknown argument "${arg}" — run with --help`);
137
+ }
138
+ if (!options.mode) throw fail(2, 'one of --report, --check or --write-debt is required — run with --help');
139
+ // A repayment with no stated reason is how a ratchet becomes a rubber stamp: the reason is recorded
140
+ // in the file it changes and is what the commit message and the changelog restate.
141
+ if (options.mode === 'write-debt' && !options.reason) throw fail(2, '--write-debt requires --reason "<what was paid, and by which contract>"');
142
+ if (options.reason && Buffer.byteLength(options.reason, 'utf8') > REASON_MAX_BYTES) {
143
+ throw fail(2, `a reason must be at most ${REASON_MAX_BYTES} UTF-8 bytes, got ${Buffer.byteLength(options.reason, 'utf8')}`);
144
+ }
145
+ return options;
146
+ };
147
+
148
+ export const main = (argv, { log = console.log, error = console.error, io } = {}) => {
149
+ let options;
150
+ try {
151
+ options = parseArgv(argv);
152
+ } catch (err) {
153
+ error(err.message);
154
+ return err.exitCode ?? 2;
155
+ }
156
+ if (options.mode === 'help') {
157
+ log(HELP);
158
+ return 0;
159
+ }
160
+
161
+ let scope;
162
+ let judged;
163
+ let unreadable;
164
+ try {
165
+ scope = validateScope(readJson(join(options.root, SCOPE_PATH), 'the coverage scope'), join(options.root, SCOPE_PATH));
166
+ const documents = specDocuments(options.root, io);
167
+ const found = claimsOf(documents);
168
+ unreadable = found.unreadable;
169
+ const tools = toolsIn(options.root, scope, io);
170
+ // A census of nothing is not a pass. Either the roots are wrong or the tree is not what the
171
+ // scope describes; both are refusals, never a green.
172
+ if (tools.length === 0) throw fail(2, `the declared roots (${(scope.roots ?? []).join(', ')}) hold no file this scope would judge — a census of zero is not a pass`);
173
+ judged = judgeCoverage({ tools, claims: found.claims, adopted: scope.adopted, settled: scope.settled ?? [] });
174
+ } catch (err) {
175
+ error(err.message);
176
+ return err.exitCode ?? 2;
177
+ }
178
+
179
+ if (options.mode === 'report') {
180
+ for (const { path, by } of judged.covered) log(`${path}\tcovered\t${by}`);
181
+ for (const path of judged.uncovered) log(`${path}\tuncovered\t-`);
182
+ for (const path of judged.debt) log(`${path}\tdebt\t-`);
183
+ return 0;
184
+ }
185
+
186
+ if (options.mode === 'write-debt') {
187
+ // What is PAYABLE is a subset of what was ADOPTED by construction — it is the owed set filtered,
188
+ // and the owed set is the baseline minus what is already settled. So this write cannot invent a
189
+ // path even in principle; `settleAfter` states that as a rule and refuses one directly, which is
190
+ // where it is asserted. A branch here would be unreachable, and an unreachable guard is not a
191
+ // guard: it is a claim nobody can check.
192
+ const next = settleAfter(scope.adopted, scope.settled ?? [], judged.payable);
193
+ // `adopted` is never rewritten here: it is the state this write is judged against.
194
+ writeFileSync(join(options.root, SCOPE_PATH), `${JSON.stringify({ ...scope, reason: options.reason, settled: next.settled }, null, 2)}\n`);
195
+ log(`spec-coverage: debt ${judged.debt.length} → ${judged.debt.length - next.added.length} (${next.added.length} paid and recorded)`);
196
+ log(`reason: ${options.reason}`);
197
+ return 0;
198
+ }
199
+
200
+ const findings = formatFindings({ ...judged, unreadable });
201
+ if (findings.length === 0) {
202
+ log(`spec-coverage: PASS — ${judged.covered.length} tool(s) governed by a contract, ${judged.debt.length} still owed`);
203
+ return 0;
204
+ }
205
+ error(`spec-coverage: FAIL — ${findings.length} finding(s) against ${join(options.root, SCOPE_PATH)}:`);
206
+ for (const line of findings) error(line);
207
+ error('spec-coverage: WHY — no work is done without a specification; a tool no contract governs promises nothing anyone can check.');
208
+ return 1;
209
+ };
210
+
211
+ if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ // NO WORK IS DONE WITHOUT A SPECIFICATION — the half prose cannot do.
3
+ //
4
+ // The spec store answers "what does this module promise?" only for the modules somebody chose to
5
+ // write a contract for, so a contract has been a suggestion: measured when this module was written,
6
+ // 122 tool modules under `agent-workflow-kit/tools/` and 14 of them covered. This module makes the
7
+ // contract a REQUIREMENT with a ratchet — a shipped tool no contract governs is a REFUSAL, and the
8
+ // debt of today's uncovered tools may only shrink.
9
+ //
10
+ // The contract this module is built to is `docs/ai/specs/kit/spec-coverage.md`, and it was written
11
+ // BEFORE this file. That order is the point: a contract amended after the code describes whatever
12
+ // the last review happened to find, which makes review an open question with no bounded answer.
13
+ //
14
+ // Pure functions. No filesystem, no argv, no side effects on import — the CLI half owns all of that.
15
+ // Dependency-free, Node >= 22.
16
+
17
+ import { readSpecDocument } from '../references/scripts/spec-schema.mjs';
18
+
19
+ // One `## Module` bullet, carried with the document that made the claim so a refusal can name the
20
+ // owner. The two forms are the ones the spec schema already validates and there is no third here:
21
+ // a `dir/` root covers by PREFIX, a file claim by EQUALITY. The trailing slash is what makes the
22
+ // prefix test safe — `tools/manifest/` can never swallow `tools/manifest-validate.mjs`.
23
+ // Only a LIVE contract claims shipped code. A `draft` is a proposal — it may name a module nobody
24
+ // has built and bind a scenario to nothing — and a `retired` one has stopped promising anything. If
25
+ // either counted, a tool could ship covered by a contract that was never in force.
26
+ const CLAIMING_KINDS = new Set(['spec']);
27
+ const CLAIMING_STATUS = 'live';
28
+
29
+ export const claimsOf = (documents) => {
30
+ const claims = [];
31
+ const unreadable = [];
32
+ for (const { rel, text } of documents) {
33
+ const verdict = readSpecDocument(String(text ?? ''), rel);
34
+ // Only a CONTRACT claims code. A navigator (`kind: index`) lists children and a part belongs to
35
+ // the module its parent already claims, so neither is asked for a `## Module` — skipping them by
36
+ // KIND, never by the absence of the section, is what keeps the next line honest.
37
+ if (!CLAIMING_KINDS.has(verdict.kind) || verdict.status !== CLAIMING_STATUS) continue;
38
+ const module = verdict.structure?.module;
39
+ // A CONTRACT whose module cannot be read is its OWN finding, never a silent skip: the tools it
40
+ // would have covered would look uncovered and the refusal would name the wrong defect.
41
+ if (!module) unreadable.push({ rel, why: verdict.errors?.[0]?.message ?? 'no readable ## Module declaration' });
42
+ else for (const path of module.paths) claims.push({ path, form: module.form, by: rel });
43
+ }
44
+ return { claims, unreadable };
45
+ };
46
+
47
+ export const coveredBy = (claims, path) =>
48
+ claims.find((claim) => (claim.path.endsWith('/') ? path.startsWith(claim.path) : claim.path === path)) ?? null;
49
+
50
+ // The verdict over one census. The debt is DERIVED — `adopted` minus `settled` — so there is no
51
+ // stored list a hand can edit into a lie. Three findings:
52
+ // uncovered a tool no contract covers and the derived debt does not owe — the refusal
53
+ // falselySettled a path recorded as PAID whose contract is not there — the record claims what the
54
+ // contracts do not say, and it is checked against them every run
55
+ // payable a path still owed whose contract now EXISTS — the debt shrank and the record did
56
+ // not, so run --write-debt; until then the record overstates what is owed
57
+ export const judgeCoverage = ({ tools, claims, adopted = [], settled = [] }) => {
58
+ const paid = new Set(settled);
59
+ const owed = adopted.filter((path) => !paid.has(path));
60
+ const stillOwed = new Set(owed);
61
+ const present = new Set(tools);
62
+ const covered = [];
63
+ const uncovered = [];
64
+ for (const path of tools) {
65
+ const claim = coveredBy(claims, path);
66
+ if (claim) covered.push({ path, by: claim.by });
67
+ else if (!stillOwed.has(path)) uncovered.push(path);
68
+ }
69
+ const falselySettled = settled.filter((path) => present.has(path) && coveredBy(claims, path) === null);
70
+ const payable = owed.filter((path) => !present.has(path) || coveredBy(claims, path) !== null);
71
+ return { covered, uncovered, falselySettled, payable, debt: owed };
72
+ };
73
+
74
+ // What a `--write-debt` run may record: every path whose contract now exists moves into `settled`,
75
+ // and nothing else changes. `adopted` is never touched, so a path that is not in it cannot be
76
+ // invented — the run names it and says to write the contract.
77
+ export const settleAfter = (adopted, settled, payable) => {
78
+ const unknown = payable.filter((path) => !adopted.includes(path));
79
+ if (unknown.length) return { ok: false, unknown };
80
+ return { ok: true, settled: [...new Set([...settled, ...payable])].sort(), added: payable };
81
+ };
82
+
83
+ export const formatFindings = ({ uncovered, falselySettled = [], payable = [], unreadable = [] }) => [
84
+ ...unreadable.map((u) => ` ${u.rel}: its ## Module cannot be read (${u.why}) — the tools it claims are unknown`),
85
+ ...uncovered.map((p) => ` ${p}: no contract under docs/ai/specs/ claims this module — write one, or it cannot ship`),
86
+ ...falselySettled.map((p) => ` ${p}: recorded as SETTLED, but no live contract claims it — the record asserts a contract that is not there`),
87
+ ...payable.map((p) => ` ${p}: still recorded as owed although it is covered now (or gone) — run --write-debt to record what was paid`),
88
+ ];