@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.
- package/CHANGELOG.md +59 -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/modes/bridge-settings.md +2 -2
- package/references/modes/now.md +30 -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/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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { bulletBlocks, isSweep } from './plan-shape.mjs';
|
|
2
|
+
import { canonicalHeading } from './queue-audit.mjs';
|
|
3
|
+
import { CARRY_WORK, CLASSES, leadMarkers, titleOf } from './queue-audit-rows.mjs';
|
|
4
|
+
|
|
5
|
+
export const NOW_STATUS = Object.freeze({
|
|
6
|
+
LANDED: 'landed',
|
|
7
|
+
IN_PROGRESS: 'in progress',
|
|
8
|
+
PENDING: 'pending',
|
|
9
|
+
UNJUDGED: 'unjudged',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const NOW_ANNOTATION = Object.freeze({
|
|
13
|
+
CLAIM: 'claim',
|
|
14
|
+
ANOMALY: 'anomaly',
|
|
15
|
+
RED_PROOF: 'red-proof',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const EVIDENCE_SOURCE = Object.freeze({
|
|
19
|
+
PATH: 'path fact',
|
|
20
|
+
CHANGED: 'changed set',
|
|
21
|
+
EXCLUSION: 'exclusion fact',
|
|
22
|
+
});
|
|
23
|
+
const PHASE_PREFIX = '## Phase: ';
|
|
24
|
+
const PRIORITY_HEADING = canonicalHeading('## Pending / backlog');
|
|
25
|
+
|
|
26
|
+
export const isWithheld = (block) => typeof block?.withheld === 'string';
|
|
27
|
+
const CHECKED_TASK = /(?:^|\s)\[[xX]\](?:\s|$)/;
|
|
28
|
+
|
|
29
|
+
const getResponsibility = (raw) => String(raw ?? '').split(' | ')[3] ?? '';
|
|
30
|
+
const getJudgedProse = (raw) => titleOf([getResponsibility(raw)]).judged;
|
|
31
|
+
|
|
32
|
+
export const readProseClaim = (raw) => {
|
|
33
|
+
const prose = getJudgedProse(raw);
|
|
34
|
+
return CHECKED_TASK.test(prose) || leadMarkers(prose).length > 0 ? 'done' : null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const getChangedDetail = ({ staged, unstaged, untracked }) => {
|
|
38
|
+
const parts = [staged ? 'staged' : null, unstaged ? 'unstaged' : null, untracked ? 'untracked' : null].filter(Boolean);
|
|
39
|
+
return parts.join(', ');
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const getPathDecision = (row, evidence) => {
|
|
43
|
+
const pathFact = evidence.pathFact;
|
|
44
|
+
if (!pathFact) return { status: NOW_STATUS.UNJUDGED, detail: 'path fact unavailable', evidenceSource: EVIDENCE_SOURCE.PATH };
|
|
45
|
+
const present = pathFact.kind !== 'absent';
|
|
46
|
+
if (row.verb === 'create') {
|
|
47
|
+
return { status: present ? NOW_STATUS.LANDED : NOW_STATUS.PENDING, detail: null, evidenceSource: EVIDENCE_SOURCE.PATH };
|
|
48
|
+
}
|
|
49
|
+
if (row.verb === 'delete') {
|
|
50
|
+
return { status: present ? NOW_STATUS.PENDING : NOW_STATUS.LANDED, detail: null, evidenceSource: EVIDENCE_SOURCE.PATH };
|
|
51
|
+
}
|
|
52
|
+
if (!present) return { status: NOW_STATUS.PENDING, detail: 'post-state unmet', evidenceSource: EVIDENCE_SOURCE.PATH };
|
|
53
|
+
return {
|
|
54
|
+
status: NOW_STATUS.UNJUDGED,
|
|
55
|
+
detail: evidence.excluded === true ? 'excluded from git here, no changed-set evidence' : 'no base-motion reader',
|
|
56
|
+
evidenceSource: EVIDENCE_SOURCE.EXCLUSION,
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const getAnnotations = (row, evidence, status) => {
|
|
61
|
+
const claim = readProseClaim(row.raw) && status !== NOW_STATUS.LANDED
|
|
62
|
+
? [{ kind: NOW_ANNOTATION.CLAIM, text: 'claimed done' }]
|
|
63
|
+
: [];
|
|
64
|
+
const overBudget = row.budgetLines && evidence.pathFact?.kind === 'regular' && evidence.pathFact.lines > row.budgetLines
|
|
65
|
+
? [{ kind: NOW_ANNOTATION.ANOMALY, text: `over budget ${evidence.pathFact.lines}/${row.budgetLines}` }]
|
|
66
|
+
: [];
|
|
67
|
+
const redProof = evidence.redProof
|
|
68
|
+
? [{ kind: NOW_ANNOTATION.RED_PROOF, text: evidence.redProof.currency ?? String(evidence.redProof) }]
|
|
69
|
+
: [];
|
|
70
|
+
return [...claim, ...overBudget, ...redProof];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// A set has no single post-state, so a sweep row is reduced only under modify: the verb refuses it before any evidence arm.
|
|
74
|
+
const SWEEP_VERB_REFUSAL = Object.freeze({ status: NOW_STATUS.UNJUDGED, detail: 'a sweep row is judged as a set only under modify', evidenceSource: EVIDENCE_SOURCE.PATH });
|
|
75
|
+
|
|
76
|
+
export const judgeRow = (row, evidence = {}) => {
|
|
77
|
+
const changed = Boolean(evidence.staged || evidence.unstaged || evidence.untracked);
|
|
78
|
+
const decision = isSweep(row.path) && row.verb !== 'modify'
|
|
79
|
+
? SWEEP_VERB_REFUSAL
|
|
80
|
+
: changed
|
|
81
|
+
? { status: NOW_STATUS.IN_PROGRESS, detail: getChangedDetail(evidence), evidenceSource: EVIDENCE_SOURCE.CHANGED }
|
|
82
|
+
: getPathDecision(row, evidence);
|
|
83
|
+
return { row, ...decision, annotations: getAnnotations(row, evidence, decision.status) };
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const getPhaseSteps = (parsed, heading) => {
|
|
87
|
+
const document = parsed.document;
|
|
88
|
+
const next = document.headings.find((candidate) => candidate.index > heading.index && candidate.level <= heading.level);
|
|
89
|
+
const end = next?.index ?? document.lines.length;
|
|
90
|
+
return bulletBlocks(document.lines, document.fencedLines, heading.index + 1, end).map((block) => ({
|
|
91
|
+
name: block.lines.join(' ').replace(/^-\s+/, '').replace(/\s+/g, ' ').trim(),
|
|
92
|
+
status: NOW_STATUS.UNJUDGED,
|
|
93
|
+
detail: 'no evidence reader',
|
|
94
|
+
evidenceSource: null,
|
|
95
|
+
annotations: [],
|
|
96
|
+
}));
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const getMarkers = (name, steps) => {
|
|
100
|
+
const currentIndex = steps.findIndex(({ status }) => status !== NOW_STATUS.LANDED && status !== NOW_STATUS.IN_PROGRESS);
|
|
101
|
+
return {
|
|
102
|
+
name,
|
|
103
|
+
steps,
|
|
104
|
+
inProgress: steps.filter(({ status }) => status === NOW_STATUS.IN_PROGRESS),
|
|
105
|
+
current: currentIndex < 0 ? null : steps[currentIndex],
|
|
106
|
+
next: currentIndex < 0 ? null : steps[currentIndex + 1] ?? null,
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const derivePhase = (parsed, judged) => {
|
|
111
|
+
const complete = judged.length > 0 && judged.every(({ status }) => status === NOW_STATUS.LANDED);
|
|
112
|
+
if (!complete) return getMarkers('ledger', judged);
|
|
113
|
+
const verification = parsed.document.headings.find(({ text }) => text === '## Verification');
|
|
114
|
+
const heading = parsed.document.headings.find(({ index, text }) => index > verification.index && text.startsWith(PHASE_PREFIX));
|
|
115
|
+
const steps = heading ? getPhaseSteps(parsed, heading) : [];
|
|
116
|
+
return getMarkers(heading ? heading.text.slice(PHASE_PREFIX.length) : 'Cleanup', steps);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const getBucketKey = (buckets) => JSON.stringify(buckets ?? []);
|
|
120
|
+
const getBucketLabel = (buckets) => buckets.length === 0
|
|
121
|
+
? 'document'
|
|
122
|
+
: buckets.map(({ text, line }) => `${text} (line ${line})`).join(' > ');
|
|
123
|
+
const isPriorityBucket = (buckets) => buckets.some(({ level, text }) => level === 2 && canonicalHeading(text) === PRIORITY_HEADING);
|
|
124
|
+
const getCounts = (rows) => Object.fromEntries(CLASSES.map((klass) => [klass, rows.filter((row) => row.klass === klass).length]));
|
|
125
|
+
|
|
126
|
+
export const foldQueue = (rows) => {
|
|
127
|
+
const folded = rows.reduce((buckets, row) => {
|
|
128
|
+
const path = row.buckets ?? [];
|
|
129
|
+
const key = getBucketKey(path);
|
|
130
|
+
const found = buckets.find((bucket) => bucket.key === key);
|
|
131
|
+
const entry = {
|
|
132
|
+
name: row.name || `unnamed (line ${row.line})`,
|
|
133
|
+
line: row.line,
|
|
134
|
+
klass: row.klass,
|
|
135
|
+
evidence: row.evidence,
|
|
136
|
+
position: (found?.rows.length ?? 0) + 1,
|
|
137
|
+
};
|
|
138
|
+
const entries = CARRY_WORK.has(row.klass) ? [...(found?.rows ?? []), entry] : found?.rows ?? [];
|
|
139
|
+
return found
|
|
140
|
+
? buckets.map((bucket) => bucket.key === key ? { ...bucket, sourceRows: [...bucket.sourceRows, row], rows: entries } : bucket)
|
|
141
|
+
: [...buckets, { key, path, label: getBucketLabel(path), order: isPriorityBucket(path) ? 'priority' : 'position', sourceRows: [row], rows: entries }];
|
|
142
|
+
}, []);
|
|
143
|
+
const buckets = folded.map(({ key, sourceRows, ...bucket }) => ({ ...bucket, counts: getCounts(sourceRows) }));
|
|
144
|
+
return { buckets, counts: getCounts(rows), total: rows.length };
|
|
145
|
+
};
|
|
@@ -120,7 +120,7 @@ const compileGlob = (pattern) => {
|
|
|
120
120
|
}
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
-
const expandPatterns = (repoFiles, patterns) => Object.fromEntries(patterns.map((pattern) => {
|
|
123
|
+
export const expandPatterns = (repoFiles, patterns) => Object.fromEntries(patterns.map((pattern) => {
|
|
124
124
|
const compiled = compileGlob(pattern);
|
|
125
125
|
if (!compiled) throw planError(`unsupported glob in plan path: ${pattern}`);
|
|
126
126
|
return [pattern, repoFiles.filter((path) => compiled.test(path))];
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
// and the rules file had reached the source-size cap. Read-only: it reads the file it is pointed at
|
|
6
6
|
// and writes nothing. Dependency-free, Node >= 22.
|
|
7
7
|
//
|
|
8
|
-
// Exit codes: 0 accept; 1 refuse (a terminal/record row still listed,
|
|
9
|
-
//
|
|
8
|
+
// Exit codes: 0 accept; 1 refuse (a terminal/record row still listed, a cap breach, or — when and
|
|
9
|
+
// only when `--require-names` is named — a row whose name is not a sentence); 2 usage — a
|
|
10
|
+
// missing/unknown flag, a flag with no value, an unreadable path, or a section that is not there.
|
|
10
11
|
|
|
11
12
|
import { readFileSync } from 'node:fs';
|
|
12
13
|
import { fail } from '../references/scripts/markdown-blocks.mjs';
|
|
@@ -17,7 +18,7 @@ const HELP = `queue-audit — classify the backlog queue's rows (agent-workflow
|
|
|
17
18
|
|
|
18
19
|
Usage:
|
|
19
20
|
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
|
+
node queue-audit-cli.mjs --check <queue-file> [--section "…"] [--max-rows N] [--max-row-lines N] [--require-names]
|
|
21
22
|
|
|
22
23
|
--report one tab-separated line per row: file line, class, row length, title, the literal evidence.
|
|
23
24
|
Deterministic — this is the manifest a deletion is driven by, never a regex guess.
|
|
@@ -25,6 +26,10 @@ Usage:
|
|
|
25
26
|
carries work (live, parked and ambiguous alike), or when more rows than the row cap carry
|
|
26
27
|
work. Ambiguous rows are reported and never refuse on their own: a row that contradicts
|
|
27
28
|
itself, or names a status word outside a status position, is settled by a human.
|
|
29
|
+
--require-names the opt-in a project adds once its rows are named: a row whose name is not a plain
|
|
30
|
+
sentence refuses with exit 1 instead of printing a note. Without it every name finding is
|
|
31
|
+
a NOTE and the verdict is byte for byte what it was, so a queue that has not migrated is
|
|
32
|
+
never bricked by an upgrade it did not ask for.
|
|
28
33
|
|
|
29
34
|
Classes: ${CLASSES.join(' · ')}. Defaults: --max-rows ${DEFAULTS.maxRows}, --max-row-lines ${DEFAULTS.maxRowLines}.
|
|
30
35
|
|
|
@@ -44,7 +49,7 @@ const parseArgv = (argv) => {
|
|
|
44
49
|
// makes `--check <dirty-file> --help` exit 0 — the gate's refusal replaced by a help page, which
|
|
45
50
|
// is the same bypass a second mode flag would be, reached by a flag nobody reads as dangerous.
|
|
46
51
|
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) return { mode: 'help' };
|
|
47
|
-
const options = { mode: null, path: null, section: null };
|
|
52
|
+
const options = { mode: null, path: null, section: null, requireNames: false };
|
|
48
53
|
// Every option here is a SINGLETON. A repeat used to win silently: a second `--section` moved the
|
|
49
54
|
// domain the report covers and a softer `--max-rows` moved the ratchet, both without a word — and
|
|
50
55
|
// both in the direction that lets a queue keep rows a check would have refused.
|
|
@@ -69,6 +74,9 @@ const parseArgv = (argv) => {
|
|
|
69
74
|
once(arg);
|
|
70
75
|
options.section = valueOf(argv, index, arg);
|
|
71
76
|
index += 1;
|
|
77
|
+
} else if (arg === '--require-names') {
|
|
78
|
+
once(arg);
|
|
79
|
+
options.requireNames = true;
|
|
72
80
|
} else if (arg === '--max-rows' || arg === '--max-row-lines') {
|
|
73
81
|
once(arg);
|
|
74
82
|
const value = Number(valueOf(argv, index, arg));
|
|
@@ -79,12 +87,11 @@ const parseArgv = (argv) => {
|
|
|
79
87
|
}
|
|
80
88
|
if (!options.mode) throw fail(2, 'one of --report or --check is required — run with --help');
|
|
81
89
|
if (!options.path) throw fail(2, `${`--${options.mode}`} takes a queue file path`);
|
|
82
|
-
// A
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
throw fail(2, `${capsInReport.join(' and ')} ${capsInReport.length > 1 ? 'are' : 'is'} a --check option — --report lists every row and judges no cap`);
|
|
90
|
+
// A check-only option named beside `--report` used to be accepted and then ignored, and the run
|
|
91
|
+
// still exited 0. Those options belong to `--check`; naming one here is usage, not a no-op.
|
|
92
|
+
const checkOnlyInReport = ['--max-rows', '--max-row-lines', '--require-names'].filter((flag) => seen.has(flag));
|
|
93
|
+
if (options.mode === 'report' && checkOnlyInReport.length) {
|
|
94
|
+
throw fail(2, `${checkOnlyInReport.join(' and ')} ${checkOnlyInReport.length > 1 ? 'are' : 'is'} a --check option — --report lists every row and judges no check-only rule`);
|
|
88
95
|
}
|
|
89
96
|
return options;
|
|
90
97
|
};
|
|
@@ -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 ` +
|