@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.
- package/CHANGELOG.md +76 -0
- package/README.md +2 -1
- package/SKILL.md +5 -1
- package/bridges/codex-cli-bridge/SKILL.md +14 -12
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +16 -15
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +9 -9
- package/bridges/codex-cli-bridge/bin/codex-review.sh +14 -13
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +8 -8
- package/bridges/codex-cli-bridge/capability.json +4 -4
- package/bridges/codex-cli-bridge/references/driving-codex.md +2 -2
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +6 -6
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/agents/review-lens.md +1 -0
- package/references/modes/bridge-settings.md +2 -2
- package/references/modes/now.md +30 -0
- package/references/scripts/spec-schema.mjs +54 -30
- package/references/scripts/spec-schema.test.mjs +65 -0
- package/tools/bridge-settings.mjs +1 -1
- package/tools/commands.mjs +7 -0
- package/tools/feedback-record.mjs +6 -2
- package/tools/now-cli.mjs +104 -0
- package/tools/now-facts.mjs +67 -0
- package/tools/now-plan-facts.mjs +129 -0
- package/tools/now-render.mjs +162 -0
- package/tools/now-status.mjs +145 -0
- package/tools/plan-shape-facts.mjs +1 -1
- package/tools/procedures.mjs +2 -1
- package/tools/queue-audit-cli.mjs +17 -10
- package/tools/queue-audit-rows.mjs +98 -18
- package/tools/queue-audit.mjs +58 -12
- package/tools/queue-row-name.mjs +149 -0
- package/tools/script-priors.mjs +2 -0
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
|
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
|
|
237
|
-
//
|
|
238
|
-
//
|
|
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
|
|
244
|
-
|
|
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
|
|
254
|
-
|
|
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
|
package/tools/queue-audit.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
};
|
package/tools/script-priors.mjs
CHANGED
|
@@ -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');
|