@sabaiway/agent-workflow-kit 12.0.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.
@@ -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
+ };