@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.
@@ -0,0 +1,129 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { join, posix } from 'node:path';
3
+ import { computeWorkingState, coverageQualifierFor, describeMissingReviewAttestation } from './core-evidence.mjs';
4
+ import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
5
+ import { GIT_MAX_BUFFER, stripGitLocationEnv, withGitPath } from './git-env.mjs';
6
+ import { PLANS_REL, plansInFlight } from './plan-files.mjs';
7
+ import { isSweep, parseLedger, unique } from './plan-shape.mjs';
8
+ import { buildFacts, expandPatterns } from './plan-shape-facts.mjs';
9
+
10
+ const VERIFICATION = '## Verification';
11
+ const NUL = String.fromCharCode(0);
12
+ const STAGED_ARGV = Object.freeze(['diff', '--cached', '--name-only', '-z', '--no-renames']);
13
+ const IGNORE_ARGV = Object.freeze(['check-ignore', '-z', '--stdin']);
14
+
15
+ const detailOf = (result) => {
16
+ const stderr = String(result.stderr ?? '').trim();
17
+ return stderr ? ` (${stderr})` : '';
18
+ };
19
+
20
+ // Every spawn outcome is named — an error, a signal, an unaccepted status, a throw — never an empty success read as "nothing is staged".
21
+ export const readGitPaths = (argv, { cwd, env, spawn = spawnSync, input, accept = [0] }) => {
22
+ const label = `git ${argv[0]}`;
23
+ const runEnv = withGitPath({ ...stripGitLocationEnv(env), LC_ALL: 'C', GIT_OPTIONAL_LOCKS: '0' });
24
+ const result = (() => {
25
+ try {
26
+ return spawn('git', argv, { cwd, env: runEnv, input, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
27
+ } catch (error) {
28
+ throw new Error(`${label} threw synchronously (${error?.message ?? error})`);
29
+ }
30
+ })();
31
+ if (result === null || typeof result !== 'object') throw new Error(`${label} returned no result`);
32
+ if (result.error) throw new Error(`${label} could not run (${result.error.code ?? result.error.message ?? result.error})`);
33
+ if (result.signal) throw new Error(`${label} was killed by ${result.signal}`);
34
+ if (!accept.includes(result.status)) throw new Error(`${label} exited ${result.status}${detailOf(result)}`);
35
+ return String(result.stdout ?? '').split(NUL).filter(Boolean);
36
+ };
37
+
38
+ const refused = (path, parseError) => ({ path, parsed: null, parseError, rows: [] });
39
+
40
+ const readPlanEntry = (cwd, name) => {
41
+ const path = `${PLANS_REL}/${name}`;
42
+ const read = readRegularFileNoFollow(join(cwd, PLANS_REL, name));
43
+ if (read.outcome !== 'ok') return refused(path, `${path} is not a readable regular file (${read.className ?? read.code ?? read.outcome})`);
44
+ try {
45
+ const parsed = parseLedger(read.content);
46
+ const verification = parsed.document.headings.find(({ text }) => text === VERIFICATION);
47
+ return verification
48
+ ? { path, parsed, parseError: null, rows: [] }
49
+ : refused(path, `${path} carries no "${VERIFICATION}" heading, so its ledger cannot be located`);
50
+ } catch (error) {
51
+ return refused(path, `${path} cannot be parsed (${error?.message ?? error})`);
52
+ }
53
+ };
54
+
55
+ const describeCurrency = ({ state, detail }) => detail ? `${state} (${detail})` : state;
56
+ const toVerdict = ({ backend, summary }) => {
57
+ const missing = describeMissingReviewAttestation(summary);
58
+ return { backend, line: `${backend}: ${missing ?? `${summary.receipt.verdict} (attesting, ${summary.receipt.timestamp})`}` };
59
+ };
60
+
61
+ const describeFinalRun = (record) => {
62
+ if (!record) return null;
63
+ const qualifier = coverageQualifierFor(record);
64
+ return {
65
+ status: String(record.status).toUpperCase(),
66
+ gates: Array.isArray(record.results) ? `${record.results.filter(({ ok }) => ok === true).length}/${record.results.length}` : 'unknown',
67
+ fingerprintBefore: record.fingerprintBefore,
68
+ timestamp: record.timestamp,
69
+ coverage: qualifier ? qualifier.replace(/^ \u2014 /, '') : `coverage=${record.coverage ?? 'unknown'}`,
70
+ };
71
+ };
72
+
73
+ // The summary's review half, mapped ONCE: the tree block and the per-row red-proof annotation read the same records.
74
+ export const readSummaryFacts = (summary) => ({
75
+ redProofs: summary.redProofs.map((record) => ({ path: record.file, currency: describeCurrency(record.currency) })),
76
+ receipts: { path: summary.receiptsPath, verdicts: summary.verdicts.map(toVerdict), finalRun: describeFinalRun(summary.finalRun) },
77
+ });
78
+
79
+ // A sweep row is judged as a SET: presence from the expansion on disk, each changed-set flag from the glob over that FULL git set (a deleted member still counts), exclusion only when every member is excluded.
80
+ // git names a path in one spelling; a ledger may write `./src/a.mjs` or `src//a.mjs`, so every lookup uses the canonical form.
81
+ const canonical = (path) => posix.normalize(path).replace(/\/$/, '');
82
+ const matchesAny = (pattern, paths) => expandPatterns(paths, [pattern])[pattern].length > 0;
83
+ const sweepEvidenceFor = (pattern, expansions, sets) => {
84
+ const members = expansions[pattern] ?? [];
85
+ return {
86
+ pathFact: members.length > 0 ? { kind: 'sweep', members: members.length } : { kind: 'absent' },
87
+ staged: matchesAny(pattern, sets.staged),
88
+ unstaged: matchesAny(pattern, sets.unstaged),
89
+ untracked: matchesAny(pattern, sets.untracked),
90
+ excluded: members.length > 0 && members.every((member) => sets.excluded.includes(member)),
91
+ };
92
+ };
93
+ // `path` is the canonical form every reader of the row's evidence keys on; a row the ledger grammar refused carries none.
94
+ const hasPath = (row) => typeof row.path === 'string' && row.path.length > 0;
95
+ const evidenceFor = (row, pathFacts, expansions, sets) => {
96
+ if (!hasPath(row)) return { path: null, pathFact: null, staged: false, unstaged: false, untracked: false, excluded: false };
97
+ const path = canonical(row.path);
98
+ if (isSweep(path)) return { path, ...sweepEvidenceFor(path, expansions, sets) };
99
+ return {
100
+ path,
101
+ pathFact: pathFacts[path] ?? null,
102
+ staged: sets.staged.includes(path),
103
+ unstaged: sets.unstaged.includes(path),
104
+ untracked: sets.untracked.includes(path),
105
+ excluded: sets.excluded.includes(path),
106
+ };
107
+ };
108
+
109
+ export const gatherPlanFacts = ({ cwd, env = process.env, deps = {} }) => {
110
+ const entries = plansInFlight(cwd, deps.readdir).map((name) => readPlanEntry(cwd, name));
111
+ const ledgerPaths = unique(entries.flatMap(({ parsed }) => (parsed ? parsed.rows : []))
112
+ .filter(hasPath)
113
+ .map(({ path }) => canonical(path)));
114
+ const { pathFacts, expansions } = buildFacts(cwd, { paths: ledgerPaths });
115
+ const concretePaths = unique([...ledgerPaths.filter((path) => !isSweep(path)), ...Object.values(expansions).flat()]);
116
+ const working = computeWorkingState(cwd, { env });
117
+ if (working === null) throw new Error(`the working state of ${cwd} is not decidable`);
118
+ const staged = readGitPaths(STAGED_ARGV, { cwd, env, spawn: deps.spawn });
119
+ const excluded = concretePaths.length === 0
120
+ ? []
121
+ : readGitPaths(IGNORE_ARGV, { cwd, env, spawn: deps.spawn, accept: [0, 1], input: `${concretePaths.join(NUL)}${NUL}` });
122
+ const changed = { staged, unstaged: working.unstagedPaths, untracked: working.untrackedPaths };
123
+ const sets = { staged, unstaged: changed.unstaged, untracked: changed.untracked, excluded };
124
+ const plans = entries.map((entry) => ({
125
+ ...entry,
126
+ rows: (entry.parsed?.rows ?? []).map((row) => ({ row, raw: row.raw, evidence: evidenceFor(row, pathFacts, expansions, sets) })),
127
+ }));
128
+ return { plans, pathFacts, changed, excluded };
129
+ };
@@ -0,0 +1,162 @@
1
+ import { glyphsFor } from './presentation.mjs';
2
+ import { visibleLength } from './renderers.mjs';
3
+ import { foldQueue, isWithheld, judgeRow, derivePhase } from './now-status.mjs';
4
+
5
+ const BLOCK_TITLE = Object.freeze({ now: 'NOW', steps: 'STEPS', queue: 'QUEUE', campaign: 'CAMPAIGN' });
6
+ const STATUS_GLYPH = Object.freeze({
7
+ landed: 'present',
8
+ 'in progress': 'note',
9
+ pending: 'missing',
10
+ unjudged: 'unknown',
11
+ });
12
+ const SGR = Object.freeze({ bold: '\x1b[1m', reset: '\x1b[0m' });
13
+
14
+ const getHeading = (title, color) => color ? `${SGR.bold}${title}${SGR.reset}` : title;
15
+ const getStepName = (step) => step?.row ? `${step.row.id} · ${step.row.path}` : step?.name ?? 'none';
16
+ const getRedProof = (tree, path) => isWithheld(tree) ? null : tree.redProofs.find((proof) => proof.path === path) ?? null;
17
+
18
+ const getPlanView = (plan, tree) => {
19
+ if (!plan.parsed) return { path: plan.path, parseError: plan.parseError, phase: null, rows: [] };
20
+ const rows = plan.rows.map(({ row, raw, evidence }) => judgeRow({ ...row, raw }, {
21
+ ...evidence,
22
+ redProof: getRedProof(tree, evidence.path),
23
+ }));
24
+ return { path: plan.path, parseError: plan.parseError, phase: derivePhase(plan.parsed, rows), rows };
25
+ };
26
+
27
+ const getPhaseMarker = (step) => step?.row
28
+ ? { row: { id: step.row.id, path: step.row.path } }
29
+ : step ? { name: step.name } : null;
30
+ const getPhaseView = (phase) => ({
31
+ name: phase.name,
32
+ current: getPhaseMarker(phase.current),
33
+ next: getPhaseMarker(phase.next),
34
+ inProgress: phase.inProgress,
35
+ });
36
+
37
+ const getPlanBlocks = (plans, tree) => {
38
+ if (isWithheld(plans)) return { plans: plans, steps: plans };
39
+ const entries = plans.entries.map((plan) => getPlanView(plan, tree));
40
+ return {
41
+ plans: { entries: entries.map(({ path, parseError, phase }) => ({ path, parseError, phase: phase ? getPhaseView(phase) : null })) },
42
+ steps: { entries: entries.map(({ path, parseError, rows }) => ({ path, parseError, rows })) },
43
+ };
44
+ };
45
+
46
+ export const toNowViewModel = (facts) => {
47
+ const planBlocks = getPlanBlocks(facts.plans, facts.tree);
48
+ const plans = isWithheld(planBlocks.plans) ? planBlocks.plans : planBlocks.plans.entries;
49
+ const tree = isWithheld(facts.tree) ? facts.tree : { ...facts.tree };
50
+ const now = isWithheld(plans) && isWithheld(tree)
51
+ ? { withheld: [...new Set([plans.withheld, tree.withheld])].join(' \u00b7 ') }
52
+ : { plans, tree };
53
+ const queue = isWithheld(facts.queue)
54
+ ? facts.queue
55
+ : { path: facts.queue.path, ...foldQueue(facts.queue.rows) };
56
+ return { now, steps: planBlocks.steps, queue, campaign: isWithheld(facts.campaign) ? facts.campaign : { ...facts.campaign } };
57
+ };
58
+
59
+ const getWithheldLines = (title, block, color) => [getHeading(title, color), `${title} — WITHHELD: ${block.withheld}`];
60
+ const getAnnotationText = (annotation) => annotation.kind === 'red-proof'
61
+ ? `red-proof ${annotation.text}`
62
+ : annotation.text;
63
+ const getStatusTail = (step) => {
64
+ const detail = step.detail ? ` (${step.detail})` : '';
65
+ const annotations = step.annotations.map((annotation) => ` (${getAnnotationText(annotation)})`).join('');
66
+ return ` [${step.evidenceSource}]${detail}${annotations}`;
67
+ };
68
+
69
+ const renderNowBlock = (block, color, glyph) => {
70
+ if (isWithheld(block)) return getWithheldLines(BLOCK_TITLE.now, block, color);
71
+ const lines = [getHeading(BLOCK_TITLE.now, color)];
72
+ if (isWithheld(block.plans)) lines.push(` plans \u2014 WITHHELD: ${block.plans.withheld}`);
73
+ else if (block.plans.length === 0) lines.push(' no plan in flight');
74
+ for (const plan of isWithheld(block.plans) ? [] : block.plans) {
75
+ lines.push(` plan: ${plan.path}`);
76
+ if (plan.parseError) {
77
+ lines.push(` ${glyph.unknown} could not parse: ${plan.parseError}`);
78
+ continue;
79
+ }
80
+ lines.push(` phase: ${plan.phase.name}`);
81
+ lines.push(` current: ${getStepName(plan.phase.current)}`);
82
+ lines.push(` next: ${getStepName(plan.phase.next)}`);
83
+ for (const step of plan.phase.inProgress) lines.push(` ${glyph.note} in progress: ${getStepName(step)} — ${step.status}${getStatusTail(step)}`);
84
+ }
85
+ const tree = block.tree;
86
+ if (isWithheld(tree)) {
87
+ lines.push(` tree \u2014 WITHHELD: ${tree.withheld}`);
88
+ return lines;
89
+ }
90
+ const clean = tree.clean === null ? 'unknown' : tree.clean ? 'clean' : 'changed';
91
+ lines.push(` tree: ${tree.fingerprint} · ${clean}`);
92
+ for (const verdict of tree.verdicts) lines.push(` ${glyph.note} ${verdict.line}`);
93
+ lines.push(tree.finalRun
94
+ ? ` final run: ${tree.finalRun.status} ${tree.finalRun.gates} gates · ${tree.finalRun.fingerprintBefore} · ${tree.finalRun.timestamp} · ${tree.finalRun.coverage}`
95
+ : ' final run: none');
96
+ for (const proof of tree.redProofs) lines.push(` red-proof: ${proof.path} · ${proof.currency}`);
97
+ return lines;
98
+ };
99
+
100
+ const renderStepsBlock = (block, color, glyph) => {
101
+ if (isWithheld(block)) return getWithheldLines(BLOCK_TITLE.steps, block, color);
102
+ const lines = [getHeading(BLOCK_TITLE.steps, color)];
103
+ if (block.entries.length === 0) lines.push(' no steps — no plan in flight');
104
+ for (const plan of block.entries) {
105
+ lines.push(` plan: ${plan.path}`);
106
+ if (plan.parseError) {
107
+ lines.push(` WITHHELD: ${plan.parseError}`);
108
+ continue;
109
+ }
110
+ for (const step of plan.rows) {
111
+ lines.push(` ${glyph[STATUS_GLYPH[step.status]]} ${getStepName(step)} — ${step.status}${getStatusTail(step)}`);
112
+ }
113
+ }
114
+ return lines;
115
+ };
116
+
117
+ const renderQueueBlock = (block, color) => {
118
+ if (isWithheld(block)) return getWithheldLines(BLOCK_TITLE.queue, block, color);
119
+ const counts = Object.entries(block.counts).map(([klass, count]) => `${klass} ${count}`).join(' · ');
120
+ const lines = [getHeading(BLOCK_TITLE.queue, color), ` ${block.path} · ${block.total} rows · ${counts}`];
121
+ for (const bucket of block.buckets) {
122
+ const domain = bucket.order === 'priority' ? 'priority' : 'archive';
123
+ const bucketCounts = Object.entries(bucket.counts).map(([klass, count]) => `${klass} ${count}`).join(' · ');
124
+ lines.push(` ${domain} · ${bucket.label} · ${bucketCounts}`);
125
+ for (const row of bucket.rows) lines.push(` ${domain} · ${bucket.label} · ${bucket.order} ${row.position}: ${row.name} — ${row.klass}`);
126
+ }
127
+ return lines;
128
+ };
129
+
130
+ const getFigure = (value) => value === null || value === undefined ? 'not recorded' : String(value);
131
+ const renderCampaignBlock = (block, color) => {
132
+ if (isWithheld(block)) return getWithheldLines(BLOCK_TITLE.campaign, block, color);
133
+ const lines = [getHeading(BLOCK_TITLE.campaign, color), ` state: ${block.state}`];
134
+ if (block.state !== 'absent') {
135
+ lines.push(` caps: ${block.maxLines} lines · ${block.maxLineBytes} bytes per line`);
136
+ lines.push(` roots: ${block.roots} · recorded files: ${getFigure(block.recordedFiles)} · aggregate lines: ${getFigure(block.aggregateLines)}`);
137
+ }
138
+ return lines;
139
+ };
140
+
141
+ const padLine = (line, width) => {
142
+ const visible = visibleLength(line);
143
+ return line === '' || visible >= width ? line : line + ' '.repeat(width - visible);
144
+ };
145
+
146
+ export const renderNow = (vm, surface = {}) => {
147
+ if (surface.mode === 'json') return '';
148
+ const color = surface.mode === 'ansi' && Boolean(surface.color);
149
+ const glyph = glyphsFor(Boolean(surface.ascii));
150
+ const lines = [
151
+ ...renderNowBlock(vm.now, color, glyph),
152
+ '',
153
+ ...renderStepsBlock(vm.steps, color, glyph),
154
+ '',
155
+ ...renderQueueBlock(vm.queue, color),
156
+ '',
157
+ ...renderCampaignBlock(vm.campaign, color),
158
+ ];
159
+ return surface.mode === 'ansi'
160
+ ? lines.map((line) => padLine(line, surface.width ?? 80)).join('\n')
161
+ : lines.join('\n');
162
+ };
@@ -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))];
@@ -232,7 +232,8 @@ const backendSetLabel = (backends) =>
232
232
  // review slot can resolve or request reviewed|council (execute floors at solo|delegated), so the gate
233
233
  // reads the recipe, the requested recipe and the roster.
234
234
  const REVIEW_RECIPES = new Set(['reviewed', 'council']);
235
- const CONSULT_LINE = ' • Before every fold of a finding raised by a review member (a bridge backend or a placed lens): ASK that member whether the proposed fold solves it and adds no new problem; WAIT for its answer, READ it, then edit only as accepted or corrected — agy: agy-review --continue --decided @f --focus "Finding: <finding>. Proposed fold: <exact fold>. Does this proposed fold solve the finding and add no new problem? Reply accept, or correct with exact replacement text."; codex: fresh codex-review plan <consult-brief> written before the working tree changes; a placed lens: re-dispatch the same lens vehicle with the finding and the proposed fold. A self-review finding, or any finding when no review member ran, is folded directly — the exemption is the finding\'s ORIGIN, never the recipe word on the slot line.';
235
+ const CONSULT_LINE = ' • Before every fold of a finding raised by a review member (a bridge backend or a placed lens): ASK that member whether the proposed fold solves it and adds no new problem; WAIT for its answer, READ it, then edit only as accepted or corrected — agy: agy-review --continue --decided @f --focus "Finding: <finding>. Proposed fold: <exact fold>. Does this proposed fold solve the finding and add no new problem? If this finding is the second case against the same check, reply with the replacement invariant, not an added case. ' +
236
+ 'Reply accept, or correct with exact replacement text."; codex: fresh codex-review plan <consult-brief> with the same payload written before the working tree changes; a placed lens: re-dispatch the same lens vehicle with the same payload. A self-review finding, or any finding when no review member ran, is folded directly — the exemption is the finding\'s ORIGIN, never the recipe word on the slot line.';
236
237
  const ARMED_CONSULT_LINE = ' • ARMED pre-fold sequence for a bridge-raised finding: the round is open → dispatch that bridge\'s consult with a nonce → WAIT and READ → fold accepted or corrected → flow-writer consult-attestation <planId> --backend <id> --nonce <n> --proposed-fix-digest <the-sha256-of-the-fold-text> → then edit. A lens-raised finding re-dispatches the lens without a nonce, WAIT and READ, then edit as accepted or corrected — it mints no manifest and no attestation — only its per-round participation rides its internal-attestation.';
237
238
  // activity-aware (AD-046): the triage classification vocabulary rides EVERY review-backed activity;
238
239
  // the LEDGER pointer renders ONLY for plan-execution — the ledger is plan-execution-scoped (AD-045),
@@ -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, or a cap breach); 2 usage —
9
- // a missing/unknown flag, a flag with no value, an unreadable path, or a section that is not there.
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 cap named beside `--report` used to be accepted and then ignored, and the run still exited 0 —
83
- // so an operator who meant to ask a question about the caps was told nothing and read the silence
84
- // as an answer. The caps belong to `--check`; naming one here is a usage error, not a no-op.
85
- const capsInReport = ['--max-rows', '--max-row-lines'].filter((flag) => seen.has(flag));
86
- if (options.mode === 'report' && capsInReport.length) {
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
  };