@sabaiway/agent-workflow-kit 9.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 +69 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/scripts/check-docs-size-cli.test.mjs +259 -21
- package/references/scripts/check-docs-size.mjs +89 -19
- package/tools/fold-scope.mjs +34 -7
- package/tools/queue-audit-cli.mjs +135 -0
- package/tools/queue-audit-rows.mjs +310 -0
- package/tools/queue-audit.mjs +164 -0
- package/tools/script-priors.mjs +1 -0
- package/tools/spec-check.mjs +11 -1
- package/tools/spec-coverage-cli.mjs +211 -0
- package/tools/spec-coverage.mjs +88 -0
package/tools/fold-scope.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|