@sabaiway/agent-workflow-kit 10.3.0 → 10.5.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +5 -5
  3. package/SKILL.md +1 -1
  4. package/bridges/antigravity-cli-bridge/SKILL.md +7 -1
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +69 -17
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +73 -2
  7. package/bridges/antigravity-cli-bridge/capability.json +2 -2
  8. package/bridges/antigravity-cli-bridge/references/review-prompt.md +3 -0
  9. package/bridges/codex-cli-bridge/SKILL.md +8 -1
  10. package/bridges/codex-cli-bridge/bin/codex-exec.sh +1 -1
  11. package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +1 -1
  12. package/bridges/codex-cli-bridge/bin/codex-review.sh +89 -18
  13. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +55 -2
  14. package/bridges/codex-cli-bridge/capability.json +2 -2
  15. package/capability.json +1 -1
  16. package/package.json +1 -1
  17. package/references/agents/executor.md +40 -0
  18. package/references/agents/review-lens.md +5 -3
  19. package/references/modes/agents.md +9 -4
  20. package/references/modes/procedures.md +21 -8
  21. package/references/modes/recipes.md +7 -4
  22. package/references/modes/recommendations.md +3 -1
  23. package/references/modes/set-recipe.md +23 -6
  24. package/references/modes/status.md +2 -2
  25. package/references/modes/upgrade.md +1 -1
  26. package/references/modes/velocity.md +1 -0
  27. package/references/shared/composition-handoff.md +1 -1
  28. package/references/shared/deploy-tail.md +1 -1
  29. package/references/templates/orchestration.json +1 -1
  30. package/tools/autonomy-config.mjs +1 -1
  31. package/tools/bridge-posture.mjs +48 -0
  32. package/tools/carriers.mjs +152 -0
  33. package/tools/cheap-agents-read.mjs +234 -0
  34. package/tools/cheap-agents.mjs +101 -109
  35. package/tools/commands.mjs +3 -3
  36. package/tools/detect-backends.mjs +2 -2
  37. package/tools/direct-run.mjs +9 -0
  38. package/tools/family-registry.mjs +38 -18
  39. package/tools/flow-check.mjs +2 -7
  40. package/tools/fold-scope.mjs +5 -60
  41. package/tools/grounding.mjs +2 -2
  42. package/tools/inject-methodology.mjs +4 -0
  43. package/tools/orchestration-config.mjs +23 -61
  44. package/tools/orchestration-readme.mjs +70 -0
  45. package/tools/plan-shape-cli.mjs +112 -0
  46. package/tools/plan-shape-facts.mjs +204 -0
  47. package/tools/plan-shape.mjs +348 -0
  48. package/tools/procedures.mjs +197 -83
  49. package/tools/recipes.mjs +183 -230
  50. package/tools/recommendations.mjs +77 -11
  51. package/tools/renderers.mjs +27 -7
  52. package/tools/repo-lex.mjs +40 -0
  53. package/tools/review-roster-resolve.mjs +104 -0
  54. package/tools/review-roster.mjs +128 -0
  55. package/tools/review-rounds-cli.mjs +92 -0
  56. package/tools/review-rounds.mjs +115 -0
  57. package/tools/review-state.mjs +10 -11
  58. package/tools/set-recipe-roster.mjs +167 -0
  59. package/tools/set-recipe.mjs +138 -42
  60. package/tools/velocity-profile.mjs +8 -22
  61. package/tools/view-model.mjs +17 -3
@@ -0,0 +1,204 @@
1
+ import { lstatSync, readdirSync, realpathSync } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
+ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
4
+ import { segmentPrefixOf, validateSourceSizeConfig } from './source-size-config.mjs';
5
+ import { getLineCount, isSweep, resolveAnchorCandidates, unique } from './plan-shape.mjs';
6
+
7
+ const SOURCE_SIZE_REL = 'docs/ai/source-size.json';
8
+ const PACKAGE_FILE = 'package.json';
9
+ const PIN_FILE = 'package-content.test.mjs';
10
+ const EXCLUDED_DIRECTORIES = new Set(['.git', 'node_modules']);
11
+ const NEGATED_CLASS = /\[[!^]/;
12
+ const UNSUPPORTED_GLOB = /[()\\\u0000-\u001f]/;
13
+
14
+ const usageError = (message, scope = 'repository') => Object.assign(new Error(message), { exitCode: 2, scope });
15
+ const planError = (message) => usageError(message, 'plan');
16
+ const toPosix = (path) => path.split(sep).join('/');
17
+ const isInside = (root, path) => path === root || path.startsWith(`${root}${sep}`);
18
+ const isLexicallySafe = (path) => typeof path === 'string' && path.length > 0 && !isAbsolute(path) &&
19
+ !path.startsWith('/') && !path.includes('\\') && !path.split('/').includes('..');
20
+
21
+ const getLstat = (path, fail = usageError) => {
22
+ try {
23
+ return lstatSync(path);
24
+ } catch (error) {
25
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null;
26
+ throw fail(`cannot inspect ${path} (${error.message})`);
27
+ }
28
+ };
29
+
30
+ const walkRegularFiles = (root, directory = root) => readdirSync(directory, { withFileTypes: true })
31
+ .sort((left, right) => left.name.localeCompare(right.name))
32
+ .flatMap((entry) => {
33
+ const path = join(directory, entry.name);
34
+ const rel = toPosix(relative(root, path));
35
+ if (entry.isDirectory() && !EXCLUDED_DIRECTORIES.has(entry.name)) return walkRegularFiles(root, path);
36
+ return entry.isFile() ? [rel] : [];
37
+ });
38
+
39
+ const getRealpath = (path) => {
40
+ try {
41
+ return realpathSync(path);
42
+ } catch {
43
+ return null;
44
+ }
45
+ };
46
+
47
+ const resolveAbsentLeaf = (root, path) => {
48
+ const rootReal = realpathSync(root);
49
+ const lexical = resolve(root, path);
50
+ if (!isInside(resolve(root), lexical)) return { contained: false, resolved: lexical };
51
+ const findExisting = (candidate, missing) => {
52
+ const real = getLstat(candidate, planError) ? getRealpath(candidate) : null;
53
+ if (real) return join(real, ...missing);
54
+ const parent = dirname(candidate);
55
+ if (parent === candidate) return candidate;
56
+ return findExisting(parent, [basename(candidate), ...missing]);
57
+ };
58
+ const resolved = findExisting(lexical, []);
59
+ return { contained: isInside(rootReal, resolved), resolved };
60
+ };
61
+
62
+ const readJsonNoFollow = (path, label) => {
63
+ const result = readRegularFileNoFollow(path);
64
+ if (result.outcome === 'absent') return null;
65
+ if (result.outcome !== 'ok') throw usageError(`${label} must be a readable regular file (${result.className ?? result.code ?? result.outcome})`);
66
+ try {
67
+ const parsed = JSON.parse(result.content);
68
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('the root is not an object');
69
+ return parsed;
70
+ } catch (error) {
71
+ throw usageError(`${label} is not valid JSON (${error.message})`);
72
+ }
73
+ };
74
+
75
+ const loadPractice = (root) => {
76
+ const parsed = readJsonNoFollow(join(root, SOURCE_SIZE_REL), SOURCE_SIZE_REL);
77
+ if (parsed === null) return { capDeclared: false, cap: null, config: null };
78
+ try {
79
+ const config = validateSourceSizeConfig(parsed);
80
+ return { capDeclared: true, cap: config.defaults.maxLines, config };
81
+ } catch (error) {
82
+ throw usageError(`${SOURCE_SIZE_REL} is malformed (${error.message})`);
83
+ }
84
+ };
85
+
86
+ const isInScope = (path, config) => Boolean(config &&
87
+ config.roots.some((root) => segmentPrefixOf(root, path)) &&
88
+ !config.exclude.some((excluded) => segmentPrefixOf(excluded, path)) &&
89
+ config.extensions.some((extension) => path.endsWith(extension)));
90
+
91
+ const expandBraces = (pattern) => {
92
+ const match = /\{([^{}]+)\}/.exec(pattern);
93
+ if (!match) return [pattern];
94
+ return match[1].split(',').flatMap((part) => expandBraces(`${pattern.slice(0, match.index)}${part}${pattern.slice(match.index + match[0].length)}`));
95
+ };
96
+
97
+ const compileGlob = (pattern) => {
98
+ if (UNSUPPORTED_GLOB.test(pattern) || NEGATED_CLASS.test(pattern) || /\{[^}]*$|^[^{]*\}/.test(pattern)) return null;
99
+ try {
100
+ const alternatives = expandBraces(pattern).map((entry) => entry
101
+ .replace(/[.+^$|]/g, '\\$&')
102
+ .replace(/\*\*\//g, '\u0002')
103
+ .replace(/\*\*/g, '\u0001')
104
+ .replace(/\*/g, '[^/]*')
105
+ .replace(/\?/g, '[^/]')
106
+ .replace(/\u0002/g, '(?:.*/)?')
107
+ .replace(/\u0001/g, '.*'));
108
+ return new RegExp(`^(?:${alternatives.join('|')})$`);
109
+ } catch {
110
+ return null;
111
+ }
112
+ };
113
+
114
+ const matchFilesEntry = (entry, ownedPath) => {
115
+ const normalized = entry.replace(/^\.\//, '').replace(/\/$/, '');
116
+ if (normalized === '' || normalized === '.') return { known: true, matches: true };
117
+ if (!isSweep(normalized)) return { known: true, matches: ownedPath === normalized || ownedPath.startsWith(`${normalized}/`) };
118
+ const compiled = compileGlob(normalized);
119
+ return compiled ? { known: true, matches: compiled.test(ownedPath) } : { known: false, matches: false };
120
+ };
121
+
122
+ const getShipped = (packageJson, ownedPath) => {
123
+ if (packageJson.private === true) return false;
124
+ if (!Object.hasOwn(packageJson, 'files')) return true;
125
+ if (!Array.isArray(packageJson.files) || packageJson.files.some((entry) => typeof entry !== 'string')) return 'unknown';
126
+ return packageJson.files.reduce((state, rawEntry) => {
127
+ const excluded = rawEntry.startsWith('!');
128
+ const entry = excluded ? rawEntry.slice(1) : rawEntry;
129
+ const result = matchFilesEntry(entry, ownedPath);
130
+ if (!result.known) return 'unknown';
131
+ return result.matches ? !excluded : state;
132
+ }, false);
133
+ };
134
+
135
+ const findPackage = (root, path, cache) => {
136
+ const start = resolve(root, dirname(path));
137
+ const visit = (directory) => {
138
+ if (!isInside(resolve(root), directory)) return null;
139
+ const packagePath = join(directory, PACKAGE_FILE);
140
+ if (getLstat(packagePath)) {
141
+ const relRoot = toPosix(relative(root, directory)) || '.';
142
+ if (!cache.has(relRoot)) cache.set(relRoot, readJsonNoFollow(packagePath, toPosix(relative(root, packagePath))));
143
+ return { root: relRoot, json: cache.get(relRoot) };
144
+ }
145
+ return directory === resolve(root) ? null : visit(dirname(directory));
146
+ };
147
+ return visit(start);
148
+ };
149
+
150
+ const getPin = (root, owner, repoFiles, cache) => {
151
+ const prefix = owner.root === '.' ? '' : `${owner.root}/`;
152
+ const pins = repoFiles.filter((path) => path.startsWith(prefix) && basename(path) === PIN_FILE &&
153
+ findPackage(root, path, cache)?.root === owner.root);
154
+ if (pins.length > 1) throw usageError(`package ${owner.root} has several ${PIN_FILE} files: ${pins.join(', ')}`);
155
+ return pins[0] ?? null;
156
+ };
157
+
158
+ const describePinSkip = (owner, shipped, pinTest) => {
159
+ if (owner === null) return 'package ownership is unknown';
160
+ if (owner.json.private === true) return `private package ${owner.root}`;
161
+ if (shipped === 'unknown') return `shipping state is unknown for package ${owner.root}`;
162
+ return shipped === true && pinTest === null ? `no ${PIN_FILE} under package ${owner.root}` : null;
163
+ };
164
+
165
+ const describePath = (root, path, practice, repoFiles, packageCache) => {
166
+ const safe = isLexicallySafe(path);
167
+ const containment = safe ? resolveAbsentLeaf(root, path) : { contained: false, resolved: resolve(root, path) };
168
+ const stat = safe ? getLstat(resolve(root, path), planError) : null;
169
+ const read = stat?.isFile() ? readRegularFileNoFollow(resolve(root, path)) : null;
170
+ if (read && read.outcome !== 'ok') throw planError(`${path} cannot be read without following it (${read.className ?? read.code ?? read.outcome})`);
171
+ const kind = stat === null ? 'absent' : stat.isFile() ? 'regular' : 'other';
172
+ const owner = safe && containment.contained && !isSweep(path) ? findPackage(root, path, packageCache) : null;
173
+ const ownedPath = owner ? toPosix(relative(owner.root === '.' ? root : join(root, owner.root), resolve(root, path))) : null;
174
+ const shipped = owner ? getShipped(owner.json, ownedPath) : 'unknown';
175
+ const pinTest = owner && shipped === true ? getPin(root, owner, repoFiles, packageCache) : null;
176
+ return {
177
+ kind,
178
+ lines: read?.outcome === 'ok' ? getLineCount(read.content) : 0,
179
+ recordedLines: practice.config?.baseline?.[path]?.lines ?? null,
180
+ inScope: isInScope(path, practice.config),
181
+ shipped,
182
+ pinTest,
183
+ pinSkip: describePinSkip(owner, shipped, pinTest),
184
+ contained: containment.contained,
185
+ };
186
+ };
187
+
188
+ export const openRepo = (root) => {
189
+ const repoRoot = realpathSync(root);
190
+ return { repoRoot, repoFiles: walkRegularFiles(repoRoot), practice: loadPractice(repoRoot), packageCache: new Map() };
191
+ };
192
+
193
+ export const buildFacts = (root, { paths = [], repo = openRepo(root) } = {}) => {
194
+ const { repoRoot, repoFiles, practice, packageCache } = repo;
195
+ const expansions = Object.fromEntries(paths.filter(isSweep).map((pattern) => {
196
+ const compiled = compileGlob(pattern);
197
+ if (!compiled) throw planError(`unsupported glob in plan path: ${pattern}`);
198
+ return [pattern, repoFiles.filter((path) => compiled.test(path))];
199
+ }));
200
+ const allPaths = unique([...paths, ...Object.values(expansions).flat()]);
201
+ const pathFacts = Object.fromEntries(allPaths.map((path) => [path, describePath(repoRoot, path, practice, repoFiles, packageCache)]));
202
+ const candidates = (suffix, precedingPaths = []) => resolveAnchorCandidates(repoFiles, suffix, precedingPaths);
203
+ return { capDeclared: practice.capDeclared, cap: practice.cap, pathFacts, expansions, repoFiles, candidates };
204
+ };
@@ -0,0 +1,348 @@
1
+ import { tokenizeMarkdown } from '../references/scripts/markdown-blocks.mjs';
2
+
3
+ export const PLAN_TITLE_PREFIX = '# Plan: ';
4
+ export const PLAN_HEADINGS = Object.freeze([
5
+ '## Goal and boundary',
6
+ '## Module ledger',
7
+ '## Verification',
8
+ '## Phase: Cleanup',
9
+ '## Next steps',
10
+ ]);
11
+
12
+ const MAX_PLAN_LINES = 100;
13
+ const MAX_LEDGER_ROWS = 25;
14
+ const MAX_ROW_BYTES = 200;
15
+ const SECTION_CAPS = Object.freeze({
16
+ '## Goal and boundary': 10,
17
+ '## Module ledger': 60,
18
+ '## Verification': 20,
19
+ });
20
+ const CLEANUP_NEXT_CAP = 10;
21
+ const ROW_ID = /^[A-Za-z0-9._-]+$/;
22
+ const VERBS = new Set(['create', 'modify', 'delete']);
23
+ const GLOB_BYTE = /[*?[{]/;
24
+ const TOTAL_LINE = /^total:\s+(~?\d+)\s+→\s+(~?\d+)\s+lines(?:\s+.+)?$/;
25
+ const SPEC_LINE = /docs\/ai\/specs\/|(?:^|\W)(?:not adopted|adopting|nothing spec-covered touched)(?:\W|$)/;
26
+ const TEST_PATH = /(?:^|\/)\S+\.test\.[^/]+$/;
27
+ const EXTENSION_PHASE = /^## Phase: (?!Cleanup\s*$)\S(?:.*\S)?$/;
28
+ const BULLET = /^-\s+\S/;
29
+
30
+ export const getLineCount = (text) => text === '' ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
31
+ export const isSweep = (path) => GLOB_BYTE.test(path);
32
+ const endsWithPath = (path, suffix) => path === suffix || path.endsWith(`/${suffix}`);
33
+ export const unique = (items) => [...new Set(items)];
34
+ const getFindingLine = (document, index) => document.frontLines + index + 1;
35
+ const makeFinding = (line, code, message, rowId = null) => ({ line, code, message, rowId });
36
+ const getHeading = (document, text) => document.headings.find((heading) => heading.text === text);
37
+ const getNextSection = (document, heading) => document.headings.find((item) => item.index > heading.index && item.level <= 2);
38
+ const getBodyEnd = (document) => document.lines.length - (document.lines.at(-1) === '' ? 1 : 0);
39
+ const getSectionEnd = (document, heading) => getNextSection(document, heading)?.index ?? getBodyEnd(document);
40
+ const getSectionLines = (document, headingText) => {
41
+ const heading = getHeading(document, headingText);
42
+ return heading ? document.lines.slice(heading.index + 1, getSectionEnd(document, heading)) : [];
43
+ };
44
+ const getSectionSpan = (document, headingText) => {
45
+ const heading = getHeading(document, headingText);
46
+ return heading ? getSectionEnd(document, heading) - heading.index : 0;
47
+ };
48
+ const getBudget = (value) => {
49
+ const parsed = /^\d+$/.test(value) ? Number(value) : null;
50
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
51
+ };
52
+ const getFigure = (value) => Number(String(value).replace(/^~/, ''));
53
+ const getConcretePaths = (row, facts) => isSweep(row.path) ? facts.expansions?.[row.path] ?? [] : [row.path];
54
+ const getPathFact = (facts, path) => facts.pathFacts?.[path] ?? null;
55
+ const getAnchorPath = (anchor) => anchor.replace(/:[1-9]\d*$/, '');
56
+ const hasPathDefect = (path) => path.startsWith('/') || path.includes('\\') || path.split('/').includes('..');
57
+ const hasRaiseBullet = (verificationBullets, path) => verificationBullets.some((bullet) =>
58
+ bullet.includes('--write-baseline') && bullet.includes('--reason') && bullet.includes(path));
59
+
60
+ // fenceContinues (queue-audit's reader): only a NESTED fence continues an open bullet — a column-0
61
+ // fence is a document block and closes it; `gaps` records where an absorbed run sat, so no reader
62
+ // joins text from both sides of a code block into one claim.
63
+ export const bulletBlocks = (lines, fencedLines, from, to, { fenceContinues = false } = {}) => {
64
+ const indexes = Array.from({ length: Math.max(0, to - from) }, (_, offset) => from + offset);
65
+ const reduced = indexes.reduce((state, index) => {
66
+ if (fencedLines.has(index)) {
67
+ const absorbing = state.absorbing ?? Boolean(fenceContinues && state.current && /^\s+\S/.test(lines[index]));
68
+ if (!absorbing) return { blocks: state.current ? [...state.blocks, state.current] : state.blocks, current: null, absorbing };
69
+ const gaps = new Set([...state.current.gaps, state.current.lines.length - 1]);
70
+ return { blocks: state.blocks, current: { ...state.current, span: state.current.span + 1, gaps }, absorbing };
71
+ }
72
+ const line = lines[index];
73
+ if (BULLET.test(line)) {
74
+ return {
75
+ blocks: state.current ? [...state.blocks, state.current] : state.blocks,
76
+ current: { start: index, lines: [line], span: 1, gaps: new Set() },
77
+ absorbing: null,
78
+ };
79
+ }
80
+ if (state.current && (line.trim() === '' || /^\s+\S/.test(line))) {
81
+ return { blocks: state.blocks, current: { ...state.current, lines: [...state.current.lines, line], span: state.current.span + 1 }, absorbing: null };
82
+ }
83
+ return { blocks: state.current ? [...state.blocks, state.current] : state.blocks, current: null, absorbing: null };
84
+ }, { blocks: [], current: null, absorbing: null });
85
+ return reduced.current ? [...reduced.blocks, reduced.current] : reduced.blocks;
86
+ };
87
+
88
+ const parseRow = (raw, line) => {
89
+ const parts = raw.split(' | ').map((part) => part.trim());
90
+ if (parts.length !== 6) return { raw, line, valid: false, id: parts[0] || null };
91
+ const [id, verb, path, responsibility, budget, anchor] = parts;
92
+ const budgetValid = budget === 'n/a' || budget === '—' || getBudget(budget) !== null;
93
+ const deleteValid = verb === 'delete'
94
+ ? budget === '—' && anchor === '—'
95
+ : budget !== '—' && anchor !== '—' && anchor.length > 0;
96
+ const valid = ROW_ID.test(id) && VERBS.has(verb) && path.length > 0 && responsibility.length > 0 && budgetValid && deleteValid;
97
+ return {
98
+ raw,
99
+ line,
100
+ valid,
101
+ id,
102
+ verb,
103
+ path,
104
+ responsibility,
105
+ budget,
106
+ budgetLines: getBudget(budget),
107
+ anchor,
108
+ anchorPath: anchor === '—' ? null : getAnchorPath(anchor),
109
+ };
110
+ };
111
+
112
+ export const parseLedger = (text, suppliedDocument = null) => {
113
+ const document = suppliedDocument ?? tokenizeMarkdown(String(text ?? ''), 'the plan');
114
+ const start = getHeading(document, PLAN_HEADINGS[1]);
115
+ const end = getHeading(document, PLAN_HEADINGS[2]);
116
+ if (!start || !end || end.index <= start.index) return { rows: [], total: null, entries: [], document };
117
+ const entries = document.lines.slice(start.index + 1, end.index).map((raw, offset) => ({
118
+ raw,
119
+ index: start.index + 1 + offset,
120
+ line: getFindingLine(document, start.index + 1 + offset),
121
+ }));
122
+ const rows = entries.filter((entry) => entry.raw.includes(' | ')).map((entry) => parseRow(entry.raw, entry.line));
123
+ const totals = entries.filter((entry) => entry.raw.trim().startsWith('total:'));
124
+ const totalMatch = totals.length === 1 ? TOTAL_LINE.exec(totals[0].raw.trim()) : null;
125
+ const total = totalMatch ? {
126
+ line: totals[0].line,
127
+ beforeRaw: totalMatch[1],
128
+ afterRaw: totalMatch[2],
129
+ before: getFigure(totalMatch[1]),
130
+ after: getFigure(totalMatch[2]),
131
+ } : null;
132
+ return { rows, total, totals, entries, document };
133
+ };
134
+
135
+ const getVerificationBullets = (document) => {
136
+ const heading = getHeading(document, PLAN_HEADINGS[2]);
137
+ if (!heading) return [];
138
+ return bulletBlocks(document.lines, document.fencedLines, heading.index + 1, getSectionEnd(document, heading))
139
+ .map((block) => block.lines.join('\n').replace(/^\-\s+/, '').replace(/\s+/g, ' ').trim());
140
+ };
141
+
142
+ const checkHeadings = (text, document) => {
143
+ const findings = [];
144
+ const first = document.headings[0];
145
+ if (!first || first.level !== 1 || !first.text.startsWith(PLAN_TITLE_PREFIX) || first.text.slice(PLAN_TITLE_PREFIX.length).trim() === '') {
146
+ findings.push(makeFinding(first ? getFindingLine(document, first.index) : 1, 'title', `the first heading must open with "${PLAN_TITLE_PREFIX}" and a non-empty title`));
147
+ }
148
+ const secondLevel = document.headings.filter((heading) => heading.level === 2);
149
+ for (const literal of PLAN_HEADINGS) {
150
+ const matches = secondLevel.filter((heading) => heading.text === literal);
151
+ if (matches.length !== 1) findings.push(makeFinding(matches[0] ? getFindingLine(document, matches[0].index) : 1, 'headings', `${literal} must appear exactly once`));
152
+ }
153
+ const positions = PLAN_HEADINGS.map((literal) => secondLevel.findIndex((heading) => heading.text === literal));
154
+ if (positions.every((position) => position >= 0) && positions.some((position, index) => index > 0 && position <= positions[index - 1])) {
155
+ findings.push(makeFinding(1, 'headings', 'the five plan sections must keep their literal order'));
156
+ }
157
+ for (const heading of secondLevel) {
158
+ if (PLAN_HEADINGS.includes(heading.text)) continue;
159
+ const verification = positions[2];
160
+ const cleanup = positions[3];
161
+ const position = secondLevel.indexOf(heading);
162
+ if (!EXTENSION_PHASE.test(heading.text) || verification < 0 || cleanup < 0 || position <= verification || position >= cleanup) {
163
+ findings.push(makeFinding(getFindingLine(document, heading.index), 'headings', `${heading.text} is not an admitted extension phase between Verification and Cleanup`));
164
+ }
165
+ }
166
+ if (getLineCount(text) > MAX_PLAN_LINES) findings.push(makeFinding(1, 'line-cap', `the plan has ${getLineCount(text)} lines; the cap is ${MAX_PLAN_LINES}`));
167
+ for (const [heading, cap] of Object.entries(SECTION_CAPS)) {
168
+ const span = getSectionSpan(document, heading);
169
+ if (span > cap) findings.push(makeFinding(getFindingLine(document, getHeading(document, heading).index), 'section-cap', `${heading} spans ${span} lines; the cap is ${cap}`));
170
+ }
171
+ const cleanupNext = getSectionSpan(document, PLAN_HEADINGS[3]) + getSectionSpan(document, PLAN_HEADINGS[4]);
172
+ if (cleanupNext > CLEANUP_NEXT_CAP) findings.push(makeFinding(1, 'section-cap', `Cleanup and Next steps span ${cleanupNext} lines; their shared cap is ${CLEANUP_NEXT_CAP}`));
173
+ return findings;
174
+ };
175
+
176
+ const checkLedgerStructure = (parsed, facts) => {
177
+ const findings = [];
178
+ const nonBlank = parsed.entries.filter((entry) => entry.raw.trim() !== '');
179
+ const rowLines = new Set(parsed.rows.map((row) => row.line));
180
+ const totalLines = new Set((parsed.totals ?? []).map((total) => total.line));
181
+ for (const entry of nonBlank) {
182
+ if (!rowLines.has(entry.line) && !totalLines.has(entry.line)) findings.push(makeFinding(entry.line, 'ledger-line', 'a ledger line must be a six-field row or the total line'));
183
+ }
184
+ for (const row of parsed.rows) {
185
+ if (!row.valid) findings.push(makeFinding(row.line, 'row-grammar', 'the row must carry six valid fields and delete rows alone use — for budget and anchor', row.id));
186
+ }
187
+ if (parsed.rows.length > MAX_LEDGER_ROWS) findings.push(makeFinding(parsed.rows[MAX_LEDGER_ROWS].line, 'row-cap', `the ledger has ${parsed.rows.length} rows; the cap is ${MAX_LEDGER_ROWS}`));
188
+ const ids = new Map();
189
+ for (const row of parsed.rows.filter((item) => item.id)) {
190
+ const prior = ids.get(row.id);
191
+ if (prior) findings.push(makeFinding(row.line, 'duplicate-id', `${row.id} duplicates the row on line ${prior}`, row.id));
192
+ else ids.set(row.id, row.line);
193
+ }
194
+ for (const row of parsed.rows.filter((item) => item.valid)) {
195
+ const countedBytes = Buffer.byteLength([row.id, row.verb, row.responsibility, row.budget].join(' | '), 'utf8');
196
+ if (countedBytes > MAX_ROW_BYTES) findings.push(makeFinding(row.line, 'row-bytes', `${row.id} has ${countedBytes} counted bytes; the cap is ${MAX_ROW_BYTES}`, row.id));
197
+ const contained = !hasPathDefect(row.path) && getPathFact(facts, row.path)?.contained !== false;
198
+ if (!contained) findings.push(makeFinding(row.line, 'containment', `${row.id} path must be a contained repo-relative POSIX path`, row.id));
199
+ const anchorContained = row.anchorPath === null || (!hasPathDefect(row.anchorPath) && getPathFact(facts, row.anchorPath)?.contained !== false);
200
+ if (!anchorContained) findings.push(makeFinding(row.line, 'containment', `${row.id} anchor must be a contained repo-relative POSIX path`, row.id));
201
+ if (isSweep(row.path) && (row.verb !== 'modify' || !/\([1-9]\d* files\)/.test(row.responsibility))) {
202
+ findings.push(makeFinding(row.line, 'sweep', `${row.id} sweep must use modify and assert its count as (N files)`, row.id));
203
+ }
204
+ }
205
+ const owners = new Map();
206
+ for (const row of parsed.rows.filter((item) => item.valid)) {
207
+ for (const path of getConcretePaths(row, facts)) {
208
+ const prior = owners.get(path);
209
+ if (prior) findings.push(makeFinding(row.line, 'duplicate-path', `${path} is owned by both ${prior} and ${row.id}`, row.id));
210
+ else owners.set(path, row.id);
211
+ }
212
+ }
213
+ const last = nonBlank.at(-1);
214
+ if (!parsed.total || (parsed.totals ?? []).length !== 1 || last?.line !== parsed.total?.line) {
215
+ findings.push(makeFinding(last?.line ?? 1, 'total', 'the exact total line must be the last non-blank ledger line'));
216
+ }
217
+ const bullets = getVerificationBullets(parsed.document);
218
+ if (bullets.length === 0) findings.push(makeFinding(getHeading(parsed.document, PLAN_HEADINGS[2]) ? getFindingLine(parsed.document, getHeading(parsed.document, PLAN_HEADINGS[2]).index) : 1, 'acceptance', 'Verification must carry at least one top-level - bullet'));
219
+ if (!getSectionLines(parsed.document, PLAN_HEADINGS[0]).some((line) => SPEC_LINE.test(line))) {
220
+ findings.push(makeFinding(getHeading(parsed.document, PLAN_HEADINGS[0]) ? getFindingLine(parsed.document, getHeading(parsed.document, PLAN_HEADINGS[0]).index) : 1, 'governing-spec', 'Goal and boundary must name a governing spec path or the adopted state'));
221
+ }
222
+ return findings;
223
+ };
224
+
225
+ export const resolveAnchorCandidates = (repoFiles, suffix, precedingPaths) => {
226
+ if (repoFiles.includes(suffix)) return [suffix];
227
+ const above = unique(precedingPaths.filter((path) => endsWithPath(path, suffix)));
228
+ return above.length > 0 ? above : unique(repoFiles.filter((path) => endsWithPath(path, suffix)));
229
+ };
230
+
231
+ const getAnchorCandidates = (row, preceding, facts) => {
232
+ const suffix = row.anchorPath;
233
+ if (!suffix || hasPathDefect(suffix)) return [];
234
+ const precedingPaths = preceding.filter((item) => item.valid).flatMap((item) => getConcretePaths(item, facts));
235
+ return facts.candidates(suffix, precedingPaths);
236
+ };
237
+
238
+ const getCurrentLines = (rows, facts, verbs) => rows
239
+ .filter((row) => row.valid && verbs.has(row.verb) && row.budget !== 'n/a')
240
+ .flatMap((row) => getConcretePaths(row, facts))
241
+ .reduce((sum, path) => sum + (getPathFact(facts, path)?.lines ?? 0), 0);
242
+
243
+ const checkAuthoring = (parsed, facts) => {
244
+ const findings = [];
245
+ const bullets = getVerificationBullets(parsed.document);
246
+ for (const [index, row] of parsed.rows.entries()) {
247
+ if (!row.valid) continue;
248
+ const paths = getConcretePaths(row, facts);
249
+ const expectedKind = row.verb === 'create' ? 'absent' : 'regular';
250
+ if (paths.length === 0 && isSweep(row.path)) findings.push(makeFinding(row.line, 'kind', `${row.id} sweep resolves to no regular path`, row.id));
251
+ for (const path of paths) {
252
+ const observed = getPathFact(facts, path)?.kind ?? 'unknown';
253
+ if (observed !== expectedKind) findings.push(makeFinding(row.line, 'kind', `${row.id} expects ${expectedKind}, observed ${observed} at ${path}`, row.id));
254
+ }
255
+ const anchorCandidates = row.anchorPath ? getAnchorCandidates(row, parsed.rows.slice(0, index), facts) : null;
256
+ if (anchorCandidates && anchorCandidates.length !== 1) {
257
+ const count = anchorCandidates.length;
258
+ findings.push(makeFinding(row.line, 'anchor', `${row.id} anchor resolves to ${count} candidates; make it unique`, row.id));
259
+ }
260
+ if (row.verb !== 'delete' && row.budgetLines !== null && !facts.capDeclared) {
261
+ findings.push(makeFinding(row.line, 'cap-declaration', `${row.id} uses an integer budget but no source-size cap is declared; use n/a`, row.id));
262
+ }
263
+ for (const path of paths) {
264
+ const pathFact = getPathFact(facts, path);
265
+ if (!pathFact?.inScope) continue;
266
+ if (row.budget === 'n/a') findings.push(makeFinding(row.line, 'source-budget', `${row.id} is in source scope and cannot use n/a`, row.id));
267
+ if (row.verb === 'create' && !TEST_PATH.test(path)) {
268
+ const hasEarlierTest = parsed.rows.slice(0, index).some((prior) => prior.valid && ['create', 'modify'].includes(prior.verb) && TEST_PATH.test(prior.path));
269
+ if (!hasEarlierTest) findings.push(makeFinding(row.line, 'red-first', `${row.id} needs a create or modify test row above it`, row.id));
270
+ }
271
+ const ceiling = row.verb === 'modify' && pathFact.recordedLines != null ? pathFact.recordedLines : facts.cap;
272
+ if (row.budgetLines !== null && ceiling != null && row.budgetLines > ceiling && !hasRaiseBullet(bullets, row.path)) {
273
+ findings.push(makeFinding(row.line, 'budget-cap', `${row.id} budget ${row.budgetLines} exceeds ${ceiling}; name a reasoned baseline raise for ${row.path}`, row.id));
274
+ }
275
+ }
276
+ if (row.verb === 'create') {
277
+ const pathFact = getPathFact(facts, row.path);
278
+ if (pathFact?.shipped === true && pathFact.pinTest && !parsed.rows.some((candidate) => candidate.valid && candidate.verb === 'modify' && candidate.path === pathFact.pinTest)) {
279
+ findings.push(makeFinding(row.line, 'package-pin', `${row.id} ships but no modify row owns ${pathFact.pinTest}`, row.id));
280
+ }
281
+ }
282
+ }
283
+ if (parsed.total) {
284
+ const current = getCurrentLines(parsed.rows, facts, new Set(['modify', 'delete']));
285
+ if (parsed.total.before !== current) findings.push(makeFinding(parsed.total.line, 'before-total', `before is ${parsed.total.before}; current counted paths sum to ${current}`));
286
+ }
287
+ return { findings, skips: parsed.rows.filter((row) => row.verb === 'create').flatMap((row) =>
288
+ getPathFact(facts, row.path)?.pinSkip ? [`${row.id}: package pin skipped — ${getPathFact(facts, row.path).pinSkip}`] : []) };
289
+ };
290
+
291
+ const checkPostState = (parsed, facts) => {
292
+ const findings = [];
293
+ for (const row of parsed.rows.filter((item) => item.valid)) {
294
+ const paths = getConcretePaths(row, facts);
295
+ if (isSweep(row.path)) {
296
+ const expected = Number(/\(([1-9]\d*) files\)/.exec(row.responsibility)?.[1]);
297
+ if (paths.length === 0 || paths.length !== expected) findings.push(makeFinding(row.line, 'sweep-count', `${row.id} expected ${expected} files and resolves to ${paths.length}`, row.id));
298
+ }
299
+ for (const path of paths) {
300
+ const fact = getPathFact(facts, path);
301
+ const expectedKind = row.verb === 'delete' ? 'absent' : 'regular';
302
+ if (fact?.kind !== expectedKind) findings.push(makeFinding(row.line, 'post-kind', `${row.id} expects ${expectedKind}, observed ${fact?.kind ?? 'unknown'} at ${path}`, row.id));
303
+ if (row.verb !== 'delete' && row.budgetLines !== null && fact?.kind === 'regular' && fact.lines > row.budgetLines) {
304
+ findings.push(makeFinding(row.line, 'post-budget', `${path} has ${fact.lines} lines, above ${row.id} budget ${row.budgetLines}`, row.id));
305
+ }
306
+ }
307
+ }
308
+ if (parsed.total) {
309
+ const current = getCurrentLines(parsed.rows, facts, new Set(['create', 'modify']));
310
+ if (parsed.total.after < current) findings.push(makeFinding(parsed.total.line, 'after-total', `after is ${parsed.total.after}; counted paths sum to ${current}`));
311
+ }
312
+ return { findings, skips: [] };
313
+ };
314
+
315
+ const parseForJudge = (text) => {
316
+ try {
317
+ return { parsed: parseLedger(text), error: null };
318
+ } catch (error) {
319
+ return { parsed: null, error };
320
+ }
321
+ };
322
+
323
+ const judge = (text, facts, stateRules) => {
324
+ const parsedResult = parseForJudge(text);
325
+ if (parsedResult.error) {
326
+ const line = Number(/:(\d+):/.exec(parsedResult.error.message)?.[1] ?? 1);
327
+ return { findings: [makeFinding(line, 'block-model', parsedResult.error.message)], skips: [], parsed: null };
328
+ }
329
+ const parsed = parsedResult.parsed;
330
+ const structural = [...checkHeadings(String(text ?? ''), parsed.document), ...checkLedgerStructure(parsed, facts)];
331
+ const state = stateRules ? stateRules(parsed, facts) : { findings: [], skips: [] };
332
+ const findings = [...structural, ...state.findings].map((finding, index) => ({ ...finding, order: index }))
333
+ .sort((left, right) => left.line - right.line || left.order - right.order)
334
+ .map(({ order, ...finding }) => finding);
335
+ return { findings, skips: state.skips, parsed };
336
+ };
337
+
338
+ export const checkPlan = (text, facts) => judge(text, facts, checkAuthoring);
339
+ export const verifyPlan = (text, facts) => judge(text, facts, checkPostState);
340
+ export const checkPlanStructure = (text, facts) => judge(text, facts, null);
341
+
342
+ export const formatFindings = (result, label = 'plan') => {
343
+ const lines = result.findings.length === 0
344
+ ? [`plan-shape: ACCEPT — ${label}`]
345
+ : [`plan-shape: REFUSE — ${label}`, ...result.findings.map((finding) =>
346
+ `${label}:${finding.line}: ${finding.rowId ? `${finding.rowId}: ` : ''}${finding.code}: ${finding.message}`)];
347
+ return [...lines, ...result.skips.map((skip) => `plan-shape: SKIP — ${skip}`)].join('\n');
348
+ };