@sabaiway/agent-workflow-kit 11.2.0 → 12.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.
@@ -23,6 +23,8 @@
23
23
  //
24
24
  // Pure string functions. No IO, no argv, no side effects on import. Dependency-free, Node >= 22.
25
25
 
26
+ import { nameOf } from './queue-row-name.mjs';
27
+
26
28
  // The closed list the queue actually uses. `DONE` and `CLOSED` are the two fold-scope already knows
27
29
  // (its CLOSED_MARKERS); the rest are the states this corpus grew on its own. The check mark is a
28
30
  // marker in its own right because the file's DONE-entry convention leads with it.
@@ -37,7 +39,9 @@ export const FROZEN_MARKERS = ['PARKED', 'STOPPED'];
37
39
  // writes deliberately, and a wider list would turn ordinary words into status.
38
40
  export const LIVE_MARKERS = ['QUEUED', 'PENDING'];
39
41
 
40
- // Not work: a counter and an ordering note. Matched at the START of the title only.
42
+ // Not work: a counter and an ordering note. Read by POSITION like every other status — the head of
43
+ // the title, or the head of a ` — `-separated segment, and everywhere but the title head the marker
44
+ // owes a DATE adjacent to it, the form the counter is always written in.
41
45
  export const RECORD_PREFIXES = ['TALLY', 'SEQUENCING'];
42
46
 
43
47
  export const CLASSES = ['live', 'terminal', 'parked', 'record', 'ambiguous'];
@@ -97,7 +101,9 @@ export const titleOf = (blockLines, gaps = new Set()) => {
97
101
  // mistaken for a row that declares a state.
98
102
  const display = span((line) => String(line ?? '').replace(/\r/g, ''));
99
103
  const judged = span(quoteFree);
100
- return { text: flatten(display.raw).replace(/`/g, ''), judged: flatten(judged.raw), endLine: display.endLine };
104
+ const text = flatten(display.raw).replace(/`/g, '');
105
+ const { name, id } = nameOf(nameSource(text));
106
+ return { text, name, id, judged: flatten(judged.raw), endLine: display.endLine };
101
107
  };
102
108
 
103
109
  // A word-boundary match that survives punctuation the corpus writes (`— CLOSED 2026-08-21 ·`), and
@@ -126,9 +132,11 @@ export const titleOf = (blockLines, gaps = new Set()) => {
126
132
  export const UNREAD_ITEM = /^(?:[*+](?:\s+\S|\s*$)|-\s*$)/;
127
133
 
128
134
  const IDENT = '[A-Za-z0-9_-]';
135
+ const wordMatches = (text, marker, { anyCase = false } = {}) =>
136
+ Array.from(String(text).matchAll(new RegExp(`(?<!${IDENT})${marker}(?!${IDENT})`, anyCase ? 'giu' : 'gu')));
129
137
  const carries = (text, marker, { anyCase = false } = {}) =>
130
138
  /^[A-Za-z]+$/.test(marker)
131
- ? new RegExp(`(?<!${IDENT})${marker}(?!${IDENT})`, anyCase ? 'i' : '').test(text)
139
+ ? wordMatches(text, marker, { anyCase }).length > 0
132
140
  : text.includes(marker);
133
141
 
134
142
  const markersIn = (text, list, options) => list.filter((marker) => carries(text, marker, options));
@@ -189,14 +197,17 @@ const DATED_STATUS = /^(?:\s*\+\s*[A-Z][A-Z-]*)*\s*\d{4}-\d{2}-\d{2}/;
189
197
  // `requireDate: false` answers the WEAKER question — does this span OPEN with a status word at all —
190
198
  // which is what separates "no status here" from "a status word with no date beside it". The second
191
199
  // is not silence: it is a row a human has to settle.
192
- export const leadMarkers = (span, list = TERMINAL_MARKERS, { requireDate = true } = {}) => {
200
+ export const leadMarkers = (span, list = TERMINAL_MARKERS, { requireDate = true, anyCase = false } = {}) => {
193
201
  let rest = String(span).replace(/\r/g, '').trimStart();
194
202
  const tick = rest.startsWith('✅');
195
203
  if (tick) rest = rest.slice(1).trimStart();
196
204
  const word = LEAD_WORD.exec(rest);
197
- const named = word ? list.filter((m) => m === word[1]) : [];
205
+ // The tick joins the answer only when the LIST being asked about carries it. Prepending it to a
206
+ // frozen, live or RECORD answer named the wrong marker in the evidence a deletion is read from.
207
+ const named = word ? list.filter((m) => anyCase ? m.toLowerCase() === word[1].toLowerCase() : m === word[1]) : [];
208
+ const withTick = (found) => (tick && list.includes('✅') ? ['✅', ...found] : found);
198
209
  if (named.length && (!requireDate || DATED_STATUS.test(rest.slice(word[1].length)))) {
199
- return tick ? ['✅', ...named] : named;
210
+ return withTick(named);
200
211
  }
201
212
  // A BARE check mark declares nothing on its own — the corpus writes `**✅ ENTRY GATE OPEN …**` for
202
213
  // a LIVE gate — so it counts only in its DATED form, and the weaker question never takes it.
@@ -213,14 +224,54 @@ export const leadMarkers = (span, list = TERMINAL_MARKERS, { requireDate = true
213
224
  // pieces; a bracket or a colon would make `(CLOSED is an input)` and `note: DONE is a token` into
214
225
  // status heads, which is a row ABOUT status words being deleted for containing them.
215
226
  const SEGMENT_SPLIT = /\s+[—–]\s+/;
216
- const statusMarkersIn = (title, list) => [
227
+ const statusMarkersIn = (title, list, options) => [
217
228
  ...new Set(
218
229
  String(title)
219
230
  .split(SEGMENT_SPLIT)
220
- .flatMap((segment) => leadMarkers(segment, list)),
231
+ .flatMap((segment) => leadMarkers(segment, list, options)),
221
232
  ),
222
233
  ];
223
234
 
235
+ // The NAME is what remains after the row's own STATUS declarations, and this is where the two
236
+ // grammars meet: the readability judge owns the sentence, this module owns the status vocabulary, so
237
+ // the drop happens HERE rather than by restating four marker lists in a second home. Every LEADING
238
+ // ` — `-segment that OPENS with a status word is removed before the name is cut — measured on the
239
+ // live corpus, `PARKED 2026-08-21 by AD-105 (…) — QUEUED 2026-08-20 — B — A-PLAN-REVIEW-…` was
240
+ // passing the readability bar with its own PARKED declaration as the row's "name", which is the
241
+ // exact class of unreadable row the bar exists for. The date is not required here: the question is
242
+ // only whether a human reading the head of that segment meets a status word instead of a name.
243
+ // Stated residual: the segments are judged on the DISPLAY text, so a QUOTED status word opening a
244
+ // segment drops it too — a stricter name, never a missed one.
245
+ // The CASE asymmetry is the classifier's own and it holds here too: terminal, frozen and record
246
+ // words are SHOUTED, live words are not. Reading all four case-insensitively deleted the name of a
247
+ // row that opens with an ordinary verb — `Tally the remaining broken gates — ROW-A` lost its whole
248
+ // name to a `TALLY` that was never there. And the remainder is returned as a SLICE of the display
249
+ // title, never re-joined from the split parts: re-joining rewrote every en dash the row wrote into
250
+ // an em dash, so the "name" stopped being the bytes a human is shown.
251
+ const SHOUTED_STATUS = [...TERMINAL_MARKERS, ...FROZEN_MARKERS, ...RECORD_PREFIXES];
252
+ const opensWithStatus = (segment) =>
253
+ leadMarkers(segment, SHOUTED_STATUS, { requireDate: false }).length > 0
254
+ // The DATED bare tick is a status the weaker question cannot see — that arm is guarded by
255
+ // `requireDate` — and it was the one form of status this drop still left inside a name:
256
+ // `✅ 2026-08-20 — PARKED 2026-08-21 by AD-105 — A-ROW` was named by its own status text and
257
+ // passed the bar with no finding at all, which is the blocker this drop exists to close.
258
+ || leadMarkers(segment, TERMINAL_MARKERS).length > 0
259
+ || leadMarkers(segment, LIVE_MARKERS, { requireDate: false, anyCase: true }).length > 0;
260
+ const nameSource = (title) => {
261
+ const text = String(title);
262
+ let at = 0;
263
+ for (const segment of text.split(SEGMENT_SPLIT)) {
264
+ const start = text.indexOf(segment, at);
265
+ if (!opensWithStatus(segment)) return text.slice(start);
266
+ at = start + segment.length;
267
+ }
268
+ return '';
269
+ };
270
+
271
+ const datedMarkersIn = (title, list, options) => list.filter((marker) =>
272
+ wordMatches(title, marker, options).some((match) => DATED_STATUS.test(String(title).slice(match.index + match[0].length))),
273
+ );
274
+
224
275
  const boldStatusMarkers = (blockLines, from, { list = TERMINAL_MARKERS, gaps, ...options } = {}) => {
225
276
  for (let offset = from + 1; offset < blockLines.length; offset += 1) {
226
277
  const span = boldSpanAt(blockLines, offset, gaps);
@@ -233,15 +284,20 @@ const boldStatusMarkers = (blockLines, from, { list = TERMINAL_MARKERS, gaps, ..
233
284
 
234
285
  const evidenceOf = (parts) => parts.filter(Boolean).join(' + ');
235
286
 
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.
287
+ // classifyRow(blockLines) -> { klass, evidence }. The order of the arms IS the rule: a
288
+ // contradiction is judged before the state it contradicts, and `live` is what survives when nothing
289
+ // else decided. A RECORD is a state like any other and is reduced with the rest: returning it early
290
+ // made `QUEUED 2026-09-04 — TALLY 2026-09-04 — A-ROW` a deletable `record` while the row said it was
291
+ // open, and hid a `DONE …` beside a `TALLY …` behind a single verdict.
239
292
  export const classifyRow = (blockLines, gaps) => {
240
293
  const { judged: title, endLine } = titleOf(blockLines, gaps);
241
294
  // The prefix must be a whole token: `TALLYING-FAILURES-HAS-NO-RUNG` and `SEQUENCING-BUG-IN-THE-
242
295
  // 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}` };
296
+ const records = String(title)
297
+ .split(SEGMENT_SPLIT)
298
+ .flatMap((segment, index) => leadMarkers(segment, RECORD_PREFIXES, { requireDate: index !== 0 })
299
+ .map((prefix) => ({ prefix, index, tick: segment.trimStart().startsWith('✅') })));
300
+ const [record] = records;
245
301
 
246
302
  // EVERY status of every class is gathered BEFORE anything is decided. Deciding as they were found
247
303
  // made two states unreachable: a frozen title returned before the contradiction arms could see a
@@ -250,8 +306,20 @@ export const classifyRow = (blockLines, gaps) => {
250
306
  // state at all.
251
307
  const titleFrozen = statusMarkersIn(title, FROZEN_MARKERS);
252
308
  const titleTerminal = statusMarkersIn(title, TERMINAL_MARKERS);
253
- const titleLive = markersIn(title, LIVE_MARKERS, { anyCase: true });
254
- const bodyTerminal = boldStatusMarkers(blockLines, endLine, { gaps });
309
+ const bodyTerminalAt = boldStatusMarkers(blockLines, endLine, { gaps });
310
+ // Narrowing the live reading to a status POSITION may never move a row INTO a deletable class.
311
+ // The narrow reading is what lets `Rows still pending a rename — A-ROW — PARKED 2026-09-04` be
312
+ // parked instead of ambiguous; applied to the same name beside a dated `DONE`, it deleted the
313
+ // contradiction and handed the row to a purge. So wherever the row already declares a TERMINAL or
314
+ // a RECORD state, the wide reading — a live word anywhere in the title, in any case — is added
315
+ // back: there it can only ever ADD ambiguity, and this grammar errs toward keeping a row.
316
+ const deletable = Boolean(titleTerminal.length || record || bodyTerminalAt);
317
+ const titleLive = [...new Set([
318
+ ...statusMarkersIn(title, LIVE_MARKERS, { anyCase: true, requireDate: false }),
319
+ ...datedMarkersIn(title, LIVE_MARKERS, { anyCase: true }),
320
+ ...(deletable ? markersIn(title, LIVE_MARKERS, { anyCase: true }) : []),
321
+ ])];
322
+ const bodyTerminal = bodyTerminalAt;
255
323
  const bodyFrozen = boldStatusMarkers(blockLines, endLine, { gaps, list: FROZEN_MARKERS });
256
324
  // The body declares LIVE too, and leaving it out of the table was the dangerous half: a row whose
257
325
  // body said `**QUEUED 2026-08-20:**` and then `**CLOSED 2026-01-01:**` was read as simply closed —
@@ -264,8 +332,18 @@ export const classifyRow = (blockLines, gaps) => {
264
332
  // `✅ Plan 3 / 3 — …` as a done marker (ten such rows), so dropping it turned ten reported rows
265
333
  // silent; in a BOLD BODY span it writes `**✅ ENTRY GATE OPEN …**` for a gate that OPENED. Same
266
334
  // glyph, two positions, two meanings — and the position is what this module already reads.
335
+ // A tick OPENING the record's own segment is consumed by that status exactly as a terminal status
336
+ // consumes its own: without this, `A-ROW — ✅ TALLY 2026-09-04` took the mention arm and became
337
+ // `ambiguous`, dropping the record refusal the row had always earned. It consumes THAT tick and no
338
+ // other, counted rather than assumed: suppressing every tick in the title made
339
+ // `✅ Plan 3 / 3 — a readable name — ✅ TALLY 2026-09-04` a DELETABLE record while one of its two
340
+ // done markers had nothing to do with the counter. Counting the ticks EVERY record segment
341
+ // consumes is what keeps `✅ TALLY 2026-09-04 — ✅ SEQUENCING 2026-09-04` the record it is.
342
+ const ticks = (String(title).match(/✅/gu) ?? []).length;
343
+ const consumed = records.filter((sighting) => sighting.tick).length;
267
344
  const mentioned = markersIn(title, [...TERMINAL_MARKERS, ...FROZEN_MARKERS]).filter(
268
- (marker) => !titleTerminal.includes(marker) && !titleFrozen.includes(marker),
345
+ (marker) => !titleTerminal.includes(marker) && !titleFrozen.includes(marker)
346
+ && !(marker === '✅' && consumed > 0 && ticks <= consumed),
269
347
  );
270
348
  if (!titleTerminal.length && !titleFrozen.length && mentioned.length) {
271
349
  return { klass: 'ambiguous', evidence: `title mentions ${mentioned.join(', ')} outside a status position` };
@@ -276,6 +354,10 @@ export const classifyRow = (blockLines, gaps) => {
276
354
  // stayed terminal and kept authorising a deletion while the row declared two things. Two sightings
277
355
  // of the SAME class are one state (a title and a body that agree do not contradict).
278
356
  const sightings = [
357
+ record && {
358
+ klass: 'record',
359
+ evidence: `${record.index === 0 ? 'title' : `title segment ${record.index + 1}`} opens with ${record.prefix}`,
360
+ },
279
361
  titleTerminal.length && { klass: 'terminal', evidence: `title: ${titleTerminal.join(', ')}` },
280
362
  titleFrozen.length && { klass: 'parked', evidence: `title: ${titleFrozen.join(', ')}` },
281
363
  titleLive.length && { klass: 'live', evidence: `title: ${titleLive.join(', ')}` },
@@ -306,5 +388,3 @@ export const classifyRow = (blockLines, gaps) => {
306
388
 
307
389
  return { klass: 'live', evidence: 'no status marker' };
308
390
  };
309
-
310
- // The `[from, to)` body-line window a `--section` names: it opens after that heading and closes at
@@ -8,7 +8,7 @@
8
8
  // This module is the half prose cannot do — it says, per row and with the literal evidence, whether
9
9
  // the row is still WORK.
10
10
  //
11
- // Four classes, and the boundaries between them are deliberately conservative, because the consumer
11
+ // Five classes, and the boundaries between them are deliberately conservative, because the consumer
12
12
  // of a `terminal` verdict is a DELETION:
13
13
  //
14
14
  // live no status marker decides otherwise — the default, and what a queue should hold.
@@ -37,10 +37,12 @@
37
37
  import { tokenizeMarkdown, fail } from '../references/scripts/markdown-blocks.mjs';
38
38
  import { bulletBlocks } from './fold-scope.mjs';
39
39
  import { CARRY_WORK, CLASSES, DEFAULTS, UNREAD_ITEM, classifyRow, titleOf } from './queue-audit-rows.mjs';
40
+ import { nameFindings } from './queue-row-name.mjs';
40
41
 
41
42
  // The row grammar is re-exported so one import names the whole reader: the CLI, the tests and any
42
43
  // 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
+ export { CLASSES, DEFAULTS, TERMINAL_MARKERS, FROZEN_MARKERS, LIVE_MARKERS, RECORD_PREFIXES, classifyRow, leadMarkers, titleOf } from './queue-audit-rows.mjs';
45
+ export { nameOf, nameFindings } from './queue-row-name.mjs';
44
46
 
45
47
  // The `[from, to)` body-line window a `--section` names: it opens after that heading and closes at
46
48
  // the next heading of the SAME level or higher, so a level-3 subheading stays inside. An absent
@@ -52,7 +54,7 @@ export { CLASSES, DEFAULTS, TERMINAL_MARKERS, FROZEN_MARKERS, LIVE_MARKERS, RECO
52
54
  // ATX allows an optional CLOSING run of `#`, so `## Pending ##` and `## Pending` are the SAME
53
55
  // heading. Comparing raw text made them two: the audit took one, and every row under the other left
54
56
  // the domain silently — a section full of dead rows reported as zero rows and exit 0.
55
- const canonicalHeading = (text) =>
57
+ export const canonicalHeading = (text) =>
56
58
  String(text ?? '')
57
59
  .replace(/\r/g, '')
58
60
  .replace(/\s+#+\s*$/, '')
@@ -77,10 +79,34 @@ const sectionWindow = (headings, lines, section, frontLines = 0) => {
77
79
  };
78
80
 
79
81
  // 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.
82
+ // (frontmatter included), the title as written, its class and the literal evidence for that class —
83
+ // and beside them the fields a RENDERER reads: the row's plain `name`, the `id` the one parse cut
84
+ // at, the `nameFindings` that name is judged by, and the line-numbered heading `buckets` it sits
85
+ // under. They are computed here, once, so no consumer becomes a second grammar over this document.
81
86
  export const auditQueue = (text, { section = null, label = 'the queue' } = {}) => {
82
87
  const { lines, headings, fencedLines, frontLines } = tokenizeMarkdown(String(text ?? ''), label);
83
- const { from, to } = sectionWindow(headings, lines, section, frontLines);
88
+ // ONE heading set answers BOTH questions this module asks of a heading — where a `--section` ends,
89
+ // and which bucket a row sits in — so the rows of the whole document are scanned FIRST and a
90
+ // heading a row SWALLOWS is refused. The tokenizer reads a 1-3-space indented ATX line as a
91
+ // heading (deliberately: a consumer's unit grammar is refused rather than guessed at) while the
92
+ // bullet scan reads the same line as a row CONTINUATION, and DECIDING between them silently was
93
+ // tried twice and was wrong twice: read raw, a ` ## Details` in a row's body ended the section
94
+ // early and every row after it went unjudged, and a ` ### Details` became an ancestor bucket of
95
+ // every following row; dropped, a `## Heading` one space under a `- ` row — where the document
96
+ // ends the list item and this scan does not — let the section run past a real heading into the
97
+ // next one. The column that would tell them apart is not knowable here either: a TAB after the
98
+ // bullet puts the row's body at a tab stop this hand-written reader does not compute.
99
+ // So the arithmetic is gone and the disposition is the one this file already gives an unread list
100
+ // item: a token this document reads two ways is corrected by hand. The corpus writes none — zero
101
+ // indented ATX headings in the 850 KB queue and in every plan and reference doc — and the
102
+ // correction is one keystroke in either direction, which the refusal names.
103
+ const documentBlocks = bulletBlocks(lines, fencedLines, 0, lines.length, { fenceContinues: true });
104
+ const insideARow = (index) => documentBlocks.some((block) => index > block.start && index < block.start + block.span);
105
+ const documentHeadings = headings.filter(({ index }) => {
106
+ if (!insideARow(index)) return true;
107
+ throw fail(2, `line ${frontLines + index + 1} is a heading this document and its row scan read differently ("${lines[index].trim().slice(0, 40)}") — the row above swallows it as body while the document opens a section there, and judging around it would report a domain that was never looked at. Indent it past three spaces to make it body, or to column 0 to make it a heading.`);
108
+ });
109
+ const { from, to } = sectionWindow(documentHeadings, lines, section, frontLines);
84
110
  // A fence CONTINUES a queue row rather than ending it, and the cap judges the row's PHYSICAL span.
85
111
  // Both halves are the same defect: a row carrying a code block reported one line and hid whatever
86
112
  // followed the fence — its length from the cap, and a closure from the classifier.
@@ -92,14 +118,28 @@ export const auditQueue = (text, { section = null, label = 'the queue' } = {}) =
92
118
  if (fencedLines.has(index) || !UNREAD_ITEM.test(lines[index])) continue;
93
119
  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
120
  }
95
- const rows = bulletBlocks(lines, fencedLines, from, to, { fenceContinues: true }).map((block) => {
121
+ const blocks = bulletBlocks(lines, fencedLines, from, to, { fenceContinues: true });
122
+ const bucketsByStart = new Map();
123
+ const positionedHeadings = documentHeadings.map(({ index, level, text: headingText }) => ({
124
+ index, level, text: headingText, line: frontLines + index + 1,
125
+ }));
126
+ const events = [
127
+ ...positionedHeadings.map((heading) => ({ kind: 'heading', index: heading.index, heading })),
128
+ ...blocks.map((block) => ({ kind: 'row', index: block.start, block })),
129
+ ].sort((left, right) => left.index - right.index);
130
+ events.reduce((stack, event) => {
131
+ if (event.kind === 'heading') return [...stack.filter(({ level }) => level < event.heading.level), event.heading];
132
+ bucketsByStart.set(event.block.start, stack.map(({ level, text: headingText, line }) => ({ level, text: headingText, line })));
133
+ return stack;
134
+ }, []);
135
+ const rows = blocks.map((block) => {
96
136
  const { klass, evidence } = classifyRow(block.lines, block.gaps);
137
+ const { text: title, name, id } = titleOf(block.lines, block.gaps);
97
138
  return {
98
- line: frontLines + block.start + 1,
99
- lines: block.span,
100
- title: titleOf(block.lines, block.gaps).text,
101
- klass,
102
- evidence,
139
+ title, name, id,
140
+ nameFindings: CARRY_WORK.has(klass) ? nameFindings(name, id) : [],
141
+ klass, line: frontLines + block.start + 1, lines: block.span,
142
+ buckets: bucketsByStart.get(block.start), evidence,
103
143
  };
104
144
  });
105
145
  const counts = Object.fromEntries(CLASSES.map((klass) => [klass, rows.filter((row) => row.klass === klass).length]));
@@ -109,7 +149,9 @@ export const auditQueue = (text, { section = null, label = 'the queue' } = {}) =
109
149
  // checkQueue(text, options) -> { ok, problems, notes }. A problem is a REFUSAL and every one of them
110
150
  // names a location: the family's bar is locations, never counts. An ambiguous row is a NOTE — it is
111
151
  // 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.
152
+ // who did not already know the answer. A NAME finding is the same shape with an opt-in: a note by
153
+ // default, so a gate line written before this contract keeps its verdict byte for byte, and a
154
+ // problem under `requireNames`, which a project adds once its rows are named.
113
155
  export const checkQueue = (text, options = {}) => {
114
156
  const { maxRows = DEFAULTS.maxRows, maxRowLines = DEFAULTS.maxRowLines, label = 'the queue' } = options;
115
157
  const { rows, counts, total } = auditQueue(text, { section: options.section ?? null, label });
@@ -129,6 +171,10 @@ export const checkQueue = (text, options = {}) => {
129
171
  if (row.klass === 'parked') {
130
172
  notes.push(`${label}:${row.line}: parked (${row.evidence}) — frozen, not dead: ${row.title.slice(0, 80)}`);
131
173
  }
174
+ const nameResults = options.requireNames ? problems : notes;
175
+ for (const finding of row.nameFindings) {
176
+ nameResults.push(`${label}:${row.line}: name ${JSON.stringify(row.name)} (${finding.cause}) — ${finding.message}`);
177
+ }
132
178
  if (CARRY_WORK.has(row.klass) && row.lines > maxRowLines) {
133
179
  problems.push(
134
180
  `${label}:${row.line}: the row is ${row.lines} lines, over the ${maxRowLines}-line cap — a row names the ` +
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ // The READABILITY judge of a backlog row: can the person who owns the queue read what the work is?
3
+ // The sibling question — is this row still WORK? — is queue-audit-rows.mjs, and the two never mix:
4
+ // this module classifies nothing and writes nothing. Governing contract:
5
+ // docs/ai/specs/kit/queue-row-name.md.
6
+ //
7
+ // Measured 2026-09-04 on this repo's own `## Pending / backlog`: 165 of 247 work-carrying rows
8
+ // failed this bar and 121 of those opened with the id itself, so every status answer named the work
9
+ // by machine ids the maintainer could not read. The fix is a rung, never a rule an agent remembers
10
+ // — the remembered rule is the discipline that already failed.
11
+ //
12
+ // Pure string functions. No IO, no argv, no side effects on import. Dependency-free, Node >= 22.
13
+
14
+ import { ROW_ID } from './feedback-record.mjs';
15
+
16
+ const IDENT = '[A-Za-z0-9_-]';
17
+ const PART = '[A-Z0-9]+';
18
+ const LETTERED = '(?=[A-Z0-9]*[A-Z])[A-Z0-9]+';
19
+
20
+ // AN ID IS A RUN THE ROW PRESENTS AS ONE — that is the whole rule, and it replaced "the first
21
+ // shouted kebab run anywhere", which was walked around from both sides at once: it cut a SENTENCE at
22
+ // its first shouted compound adjective (`… never a HAND-APPLY \`rm\` …` reported HAND-APPLY as the
23
+ // row's cross-reference key), and it matched NOTHING on the real ids this corpus writes, because one
24
+ // all-digit part killed the run — `UPGRADE-RUN-FEEDBACK-2026-08-18`, `FLOW-CHECK-64-HAS-NO-VALVE-…`
25
+ // and `AMEND-REACHES-FOR---NO-VERIFY` each handed the WHOLE title back as its "name", id included.
26
+ //
27
+ // TWO grammars, and the difference is who VOUCHES for the id:
28
+ //
29
+ // MARKED `id <ID>` — the row says so, so the id is exactly what a checked record may hand over:
30
+ // feedback-record.mjs's ROW_ID, imported rather than restated. `FOO`, `TASK-123` and
31
+ // `1-TASK` are ids here, and the kit's own row writer marks its ids for this reason.
32
+ // UNMARKED a heuristic over a display title nobody validated, so it stays narrow: parts of
33
+ // upper-case letters and digits joined by one or more hyphens (the corpus writes `---`),
34
+ // the FIRST TWO parts each carrying a letter, the rest unconstrained. That is what keeps
35
+ // `AD-133` a cross-reference inside a name rather than an id. A run SPANNING its whole
36
+ // segment needs only its first part lettered — nothing precedes it that it could eat.
37
+ //
38
+ // And the POSITION is where a row PRESENTS an id: the head of a ` — `-separated segment, or before a
39
+ // colon with nothing but WHITESPACE AND PUNCTUATION ahead of it in that segment. Both guards are
40
+ // measured: an id read mid-sentence took a claim's own words (`Preserve id TASK-123 in output — id
41
+ // ROW-A` answered TASK-123 rather than the row's id), and a prefix that admitted digits cut
42
+ // `123 ROW-A: the claim remains readable` down to the name `123`. What survives is why
43
+ // `See also FIRST-TIME-CORRECTNESS: …` keeps its name while `[ ] ROW-A: the claim` still names `[ ]`.
44
+ const RUN = `${LETTERED}(?:-+${LETTERED})(?:-+${PART})*`;
45
+ const SPANNING = `${LETTERED}(?:-+${PART})+`;
46
+ const MARKED = `id(?![A-Za-z0-9_])\\s+(${ROW_ID})(?!${IDENT})`;
47
+ const SEGMENT_SPLIT = /\s+[—–]\s+/u;
48
+
49
+ // Each form says where the NAME is cut: at the segment's own start for a presented head, at the
50
+ // `id` word for a marked id mid-sentence, and at the RUN itself for the label form — a cut at the
51
+ // match start there would swallow the `[ ]` the checkbox row is named by.
52
+ const FORMS = [
53
+ { re: new RegExp(`^(?:${MARKED}|(${SPANNING}))$`, 'du'), cut: () => 0 },
54
+ { re: new RegExp(`^(?:${MARKED}|(${RUN})(?!${IDENT}))`, 'du'), cut: () => 0 },
55
+ { re: new RegExp(`^[\\s\\p{P}\\p{S}]*?(?<!${IDENT})(${RUN})(?=:)`, 'du'), cut: (match) => match.indices[1][0] },
56
+ ];
57
+
58
+ const CASED = /\p{Cased}/u;
59
+ const LOWERCASE = /\p{Lowercase}/u;
60
+ const LETTER = /\p{L}/u;
61
+ const HYPHENS = /^-+$/u;
62
+ const normalize = (value) => String(value).replace(/-/gu, ' ').replace(/\s+/gu, ' ').trim().toLowerCase();
63
+
64
+ // nameOf(title) -> { name, id }. The title handed in is the DISPLAY text queue-audit already
65
+ // renders — every word the row wrote, its backticks stripped — with its leading STATUS segments
66
+ // already removed by the parser that owns the status vocabulary. Never the judged text, whose
67
+ // elided quotations would leave a HOLE where a quoted word stood and report an absent name for a
68
+ // row that reads perfectly. The boundary is stated POSITIVELY, by the id: ` — ` is the STATUS
69
+ // grammar's segment separator, not a name boundary, so a name that carries one keeps it whole, and
70
+ // a title that puts its id outside the bold span is named by that span entire.
71
+ export const nameOf = (title) => {
72
+ const display = String(title ?? '').trim();
73
+ let at = 0;
74
+ for (const text of display.split(SEGMENT_SPLIT)) {
75
+ const start = display.indexOf(text, at);
76
+ at = start + text.length;
77
+ for (const form of FORMS) {
78
+ const match = form.re.exec(text);
79
+ if (!match) continue;
80
+ const name = display.slice(0, start + form.cut(match)).trimEnd().replace(/(?:\s*[—–-]+)+$/u, '').trimEnd();
81
+ return { name, id: match[1] ?? match[2] };
82
+ }
83
+ }
84
+ return { name: display, id: null };
85
+ };
86
+
87
+ // ONE word list, and every clause below reads it. A hyphen-joined RUN is one word — `row-is-unreadable`
88
+ // is a slug, `fix---the---bug` is the same slug, and `2026-08-21` is one date rather than three
89
+ // numbers — while punctuation is no word at all, so the `[ ]` the kit's own row writer renders no
90
+ // longer supplies two of the three words the bar asks for. Word segmentation is the platform's
91
+ // (`Intl.Segmenter`, a JS built-in — this module stays dependency-free).
92
+ const WORDS = new Intl.Segmenter(undefined, { granularity: 'word' });
93
+ export const wordsOf = (text) => {
94
+ const parts = [...WORDS.segment(String(text))];
95
+ const words = [];
96
+ for (let index = 0; index < parts.length; index += 1) {
97
+ if (!parts[index].isWordLike) continue;
98
+ let back = index - 1;
99
+ while (back >= 0 && HYPHENS.test(parts[back].segment)) back -= 1;
100
+ if (back >= 0 && back < index - 1 && parts[back].isWordLike && words.length) {
101
+ words[words.length - 1] += parts.slice(back + 1, index + 1).map((part) => part.segment).join('');
102
+ continue;
103
+ }
104
+ words.push(parts[index].segment);
105
+ }
106
+ return words;
107
+ };
108
+
109
+ // A name is a SENTENCE, and that ONE invariant is the whole bar — never a table of shapes, because
110
+ // a table is walked around, and each earlier draft was: the slug written back in lower case walked
111
+ // around all four shapes the first draft enumerated; a dated parenthetical walked around a clause
112
+ // that asked the whole name for one lower-case letter anywhere; and "no cased character" as the
113
+ // signal for a caseless SCRIPT let a name of pure punctuation, digits or emoji (`[ ]`, `123`,
114
+ // `2026-09-04`) through as a sentence. So:
115
+ //
116
+ // a name CARRIES A LETTER — asked of every name, and the one clause a caseless script also meets;
117
+ // a name that carries CASE is three or more words, one of its first three CASED words carrying a
118
+ // lower-case letter.
119
+ //
120
+ // Binding the two counting clauses to case is what makes the verdict DETERMINISTIC rather than a
121
+ // function of the host's word-segmentation data: a caseless writing system writes no spaces to
122
+ // count and has no case to shout with, so neither is asked of it. Reading the SHOUTED clause off the
123
+ // first three CASED words (not the first three words) is what lets a Japanese sentence carry an
124
+ // English acronym without being called shouted.
125
+ export const nameFindings = (name, id) => {
126
+ const text = String(name ?? '').trim();
127
+ if (text === '') return [{ cause: 'absent', message: 'The row has no name before its id.' }];
128
+ const findings = [];
129
+ const words = wordsOf(text);
130
+ if (!words.some((word) => LETTER.test(word))) {
131
+ findings.push({ cause: 'no-letter', message: 'The name carries no letter, so it names nothing.' });
132
+ }
133
+ if (CASED.test(text) && words.length < 3) {
134
+ findings.push({ cause: 'too-few-words', message: 'The name is fewer than three words.' });
135
+ }
136
+ // The acronym exemption is for a MIXED-SCRIPT name — a caseless sentence carrying an English
137
+ // acronym — so it is spent only where caseless letter words actually exist. Skipping the clause
138
+ // on "fewer than three cased words" alone let `API 123 456` and `API GATE 2` through as sentences.
139
+ const cased = words.filter((word) => CASED.test(word));
140
+ const caseless = words.filter((word) => LETTER.test(word) && !CASED.test(word));
141
+ const exempt = caseless.length > 0 && cased.length < 3;
142
+ if (cased.length > 0 && !exempt && !cased.slice(0, 3).some((word) => LOWERCASE.test(word))) {
143
+ findings.push({ cause: 'shouted', message: 'None of the name\'s leading cased words carries a lower-case letter.' });
144
+ }
145
+ if (id !== null && normalize(text) === normalize(id)) {
146
+ findings.push({ cause: 'restates-the-id', message: 'The name only restates its id.' });
147
+ }
148
+ return findings;
149
+ };
@@ -34,6 +34,8 @@ export const SCRIPT_PRIORS = Object.freeze([
34
34
  prior('spec-schema.mjs', '4.7.0', '4.7.0', '40b5b038d5ec5ed53c327c6d269d22fe5fa2bed99ae711fbf84306ad047be452'),
35
35
  prior('spec-schema.test.mjs', '4.7.0', '4.7.0', 'fde896419924223e54cfcabfdb1ef5807df5386fac700ed7c1463b6e7f81501b'),
36
36
  prior('check-docs-size.mjs', '4.6.0', '6.0.0', '22d020c3668cdbfb4cbc1f67a8a85b2baa2d0c4956808a50626d37409e81ab38'),
37
+ prior('spec-schema.mjs', '5.0.0', '7.2.1', '6a7d1e836c158baab5a412a1ad76d34891934dc28c88486c264a4b5d46f5892a'),
38
+ prior('spec-schema.test.mjs', '5.0.0', '7.2.1', '698564d7283281be1826019d214df475bb876687915de086818d8f5ece0e0336'),
37
39
  ]);
38
40
 
39
41
  export const digestOf = (bytes) => createHash('sha256').update(bytes).digest('hex');