Files
gstack/test/helpers/eng-seeded-coverage.ts
T
garrytan 8bd53faa6c test(eng-batching): bind unsourced native briefs through the report's target
Run 36606688266 asked ten separate native review questions (D1-D9 bound
to ledger records R1-R9) and failed reviewCount=0 < FLOOR=3: its briefs
named the plan by title instead of citing PLAN.md, its report declared
'Review target (fixed): PLAN.md' under '# Engineering review: <plan>', and
it kept an unfenced copy of the plan's own H1. The named-source route now
accepts those spellings and non-inline ledger briefs. The same replay
rejects a foreign, mixed, duplicate or missing target, another plan's
title or copied H1, a brief naming another plan or file, a mismatched
saved brief, and re-asks. The run-36597762183 capture still counts 3.
2026-09-29 19:15:06 +00:00

452 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { NativePlanQuestionCall } from './plan-count-transcript';
import { type AskUserQuestionFingerprint } from './claude-pty-runner';
import { marked } from 'marked';
// Ignore displayed examples/code, while retaining inline code identifiers.
function prose(text: string, omitLiteralProse = false): string {
let fence: string | undefined;
const lines = text.split('\n').filter(line => {
const mark = line.match(/^ {0,3}(`{3,}|~{3,})/);
if (mark) {
if (!fence) fence = mark[1];
else if (mark[1][0] === fence[0] && mark[1].length >= fence.length) fence = undefined;
return false;
}
return !fence && !/^(?: {0,3}>| {4}|\t)/.test(line);
}).join('\n');
return (omitLiteralProse ? lines.replace(/`([^`]+)`/g, (span, body: string) => /\s/.test(body) ? '' : span) : lines).replace(/[`*]/g, '');
}
function completedDecision(call: NativePlanQuestionCall, startedAt: number, finishedAt: number): boolean {
const answeredAt = Date.parse(call.answeredAt ?? '');
if (!call.sessionId || !call.toolUseId || call.answered !== true || call.failed !== false ||
!Number.isFinite(answeredAt) || answeredAt < startedAt || answeredAt > finishedAt ||
call.questions.length < 1 || call.questions.length > 4 || !Array.isArray(call.unansweredQuestionIndices) ||
call.unansweredQuestionIndices.length !== 0) return false;
return new Set(call.questions.map(q => q.question)).size === call.questions.length &&
Object.keys(call.answers ?? {}).length === call.questions.length &&
call.questions.every(q => q.question.trim() && !q.multiSelect && q.options.length >= 2 && q.options.length <= 4 &&
q.options.every(o => o.label.trim()) && new Set(q.options.map(o => o.label)).size === q.options.length &&
q.options.some(o => call.answers?.[q.question] === o.label));
}
/** Structural eligibility for this distinct-issue counter, not seed quality. */
function batchingIssueNumber(call: NativePlanQuestionCall): string | undefined {
if (!completedDecision(call, 0, Date.now()) || call.questions.length !== 1) return;
const q = call.questions[0]!;
const title = q.question.split('\n')[0]!;
const legacy = /^(?:D[1-9]\d*\s*[—–:-]\s*)?Issue ([1-9]\d*)\s*:\s*\S[^\n]*$/i.exec(title)?.[1];
let issue = legacy, currentOwner = legacy ? `Issue ${legacy}` : '';
if (legacy) {
if (!new RegExp(`^(?:Arch(?:itecture)?|Code quality|Tests?|Testing|Performance|Security)(?: ${legacy})?$`, 'i').test(q.header.trim())) return;
const optionIds = q.options.map(o => /^([1-9]\d*)([A-D])[.):]\s+\S/i.exec(o.label));
if (optionIds.some(id => id?.[1] !== legacy) || new Set(optionIds.map(id => id![2]!.toUpperCase())).size !== q.options.length) return;
} else {
// The current ledger uses stable R IDs and a new D number for each ask.
// Earlier briefs may instead cite their current finding in task metadata.
// Bind only that owned identity, never D alone or a later recap of others.
const decision = /^D([1-9]\d*)\b/.exec(title);
// D owns this ask. R can be shared by the title/header or live in the
// header alone when this question names one current source document.
const recordIds = [...prose(title, true).replace(/"[^"\n]*"|“[^”\n]*”/g, '').matchAll(/\bR[1-9]\d*\b/g)].map(match => match[0]);
let recordId = recordIds.length === 1 ? recordIds[0] : undefined;
const lines = prose(q.question, true).split('\n').filter(line => line.trim());
const metadata = lines[1]?.replace(/"[^"\n]*"|“[^”\n]*”/g, '') ?? '';
const explanation = (lines[2] ?? '').replace(/"[^"\n]*"|“[^”\n]*”/g, '');
if (!decision || !/^Project\/branch\/task: \S/.test(metadata) || !/\bPLAN\.md\b/.test(metadata) ||
lines.filter(line => /^Project\/branch\/task:/.test(line)).length !== 1 ||
lines.filter(line => /^ELI10:/.test(line)).length !== 1 || !/^ELI10: \S/.test(explanation) ||
/^ELI10:\s*(?:".*"|“.*”)\s*$/.test(lines[2] ?? '') ||
/^ELI10: (?:source|quoted|historical|example|hypothetical)\b/i.test(explanation) ||
/\b(?:copied|quoted|historical)\s+(?:(?:source|quoted)\s+)?(?:example|excerpt|text|material)\b/i.test(metadata) ||
q.options.some(option => !prose(option.description ?? '', true).trim())) return;
const finding = /(?:^|[,;]\s*)finding (F[1-9]\d*)\s*\(PLAN\.md:[1-9]\d*(?:[-–][1-9]\d*)?\)/i.exec(metadata)?.[1];
if (recordIds.length > 1 || /\b(?:copied|quoted|historical|example|hypothetical)\b/i.test(title)) return;
if (!recordId && !finding) {
const header = q.header.trim();
const ids = [...header.matchAll(/\bR[1-9]\d*\b/g)].map(match => match[0]);
const sources = [...metadata.matchAll(/\b[\w./-]+\.md(?::[1-9]\d*(?:[-–][1-9]\d*)?)?\b/g)].map(match => match[0]);
// This native form carries the complete tradeoff in each option.
// Short aliases for a comparison in the question/report must keep using
// the saved-ledger path; the header cannot replace that authority.
const completeOptions = q.options.every(option => {
const description = prose(option.description ?? '', true).replace(/"[^"\n]*"|“[^”\n]*”/g, '').trim();
const blocks = [...description.matchAll(/([✅❌])\s*([^✅❌]+)/g)];
return description.startsWith('✅') && blocks.every(block => /[A-Za-z0-9]/.test(block[2]!)) &&
blocks.filter(block => block[1] === '✅').length >= 2 && blocks.some(block => block[1] === '❌');
});
if (!completeOptions || lines.some(line => /^(?:Options?:\s*)?[A-D][).:]\s+\S/.test(line))) return;
// A bare D number, a quoted/foreign R, or an unrelated source mention
// cannot supply identity. Native ACK/options and current-status checks
// below remain the same as the title-owned route.
if (ids.length !== 1 || !new RegExp(`^${ids[0]}(?:\\s+[A-Za-z]|\\s*[—–:-]\\s*[A-Za-z])`).test(header) ||
/\bR[1-9]\d*\b/.test(title) || /[\r\n`"“”]/.test(header) ||
[...title.matchAll(/\bD[1-9]\d*\b/g)].length !== 1 ||
/\b(?:copied|quoted|historical|history|example|hypothetical|withdrawn|cancelled|canceled|rejected|superseded|resolved|closed|not current|no longer current)\b/i.test(header) ||
sources.length !== 1 || !/^PLAN\.md(?::[1-9]\d*(?:[-–][1-9]\d*)?)?$/.test(sources[0]!)) return;
recordId = ids[0];
}
if (recordId) {
const headerIds = [...q.header.matchAll(/\bR[1-9]\d*\b/g)].map(match => match[0]);
if (!new RegExp(`^${recordId}\\b`).test(q.header.trim()) || headerIds.length !== 1 || headerIds[0] !== recordId) return;
issue = `record:${recordId}`;
} else if (finding) issue = `finding:${finding.toUpperCase()}`;
else return;
currentOwner = `${recordId ?? finding}|D${decision[1]}`;
}
// Owned scalar statuses remain current prose; a whole code example does not.
const owner = `(?:(?:this|the|that) (?:issue|finding|decision)|${currentOwner})`;
const statusPrefix = `(?:^|[.!?;]\\s+|\\n)(?:Correction:\\s*)?${owner} (?:is|was|has been) `;
const scalarOwner = new RegExp(`${statusPrefix}$`, 'i');
const text = q.question.replace(/`([^`\n]+)`/g, (span, body: string, at: number, source: string) =>
scalarOwner.test(source.slice(0, at)) ? body : span);
const inactive = new RegExp(`${statusPrefix}["“'‘]?(?:withdrawn|cancelled|canceled|rejected|superseded|resolved|closed|hypothetical|not current|no longer current)\\b`, 'i');
return inactive.test(prose(text, true)) ? undefined : issue;
}
/** Batching measures separate native issue decisions; seed quality is checked separately. */
export function isEngBatchingIssueAUQ(fp: AskUserQuestionFingerprint, priorCalls: readonly NativePlanQuestionCall[] = []): boolean {
const call = fp.nativeCall;
if (!call || fp.signature !== `${call.sessionId}:${call.toolUseId}` ||
(fp.nativeQuestionIndex !== undefined && fp.nativeQuestionIndex !== 0) ||
priorCalls.some(prior => prior.sessionId !== call.sessionId || prior.toolUseId === call.toolUseId)) return false;
const issue = batchingIssueNumber(call);
if (!issue) return false;
const q = call.questions[0]!;
if (fp.options.length !== q.options.length || !fp.options.every((o, i) => o.index === i + 1 && o.label === q.options[i]!.label)) return false;
// Re-asking an eligible issue cannot inflate the floor; setup and batches do not suppress later separate decisions.
return !priorCalls.some(prior => batchingIssueNumber(prior) === issue);
}
// The report's one target declaration, in the skill's own spellings.
const TARGET_FIELD = /^(?:Reviewed |Review )?[Tt]arget(?: \(fixed\))?:/;
/** A native brief can use its D number and topic while its stable R identity
* lives in the required saved ledger. Count that owned choice, not a title
* spelling. This does not approve the row or validate the implementation. */
function recordedBatchingIssue(call: NativePlanQuestionCall, savedPlan: string): string | undefined {
const q = call.questions[0]!;
const text = prose(q.question, true), lines = text.split('\n').filter(line => line.trim());
const title = lines[0] ?? '', decision = /^D([1-9]\d*(?:\.[1-9]\d*)?)\s*[—–:-]\s+\S/.exec(title);
const metadata = (lines[1] ?? '').replace(/"[^"\n]*"|“[^”\n]*”/g, ''), explanation = lines[2] ?? '';
const source = /\bPLAN\.md:([1-9]\d*(?:[-–][1-9]\d*)?)\b/.exec(metadata)?.[1];
if (!decision || !/^Project\/branch\/task: \S/.test(metadata) ||
!/^ELI10: \S/.test(explanation) || /^ELI10:\s*(?:".*"|“.*”)\s*$/.test(explanation) ||
/^(?:ELI10:\s*)?(?:source|quoted|historical|example|hypothetical)\b/i.test(explanation) ||
lines.filter(line => /^Project\/branch\/task:/.test(line)).length !== 1 ||
lines.filter(line => /^ELI10:/.test(line)).length !== 1 ||
/\b(?:copied|quoted|historical|hypothetical)\b/i.test(metadata)) return;
if (q.options.some(option => !prose(option.description ?? '', true).trim()) ||
/\b(?:this|the|that) (?:issue|finding|decision) (?:is|was|has been) ["“'‘]?(?:withdrawn|cancelled|canceled|rejected|superseded|resolved|closed|hypothetical|not current|no longer current)\b/i.test(text)) return;
const clean = (s: string) => s.replace(/[`*]/g, '').replace(/\s+/g, ' ').trim();
const tokens = marked.lexer(savedPlan);
const ledgers = tokens.flatMap((t, i) => t.type === 'heading' && /^Decision ledger$/i.test(clean(t.text)) ? [i] : []);
if (ledgers.length !== 1) return;
const start = ledgers[0]!, heading = tokens[start]!;
if (heading.type !== 'heading') return;
const currentHeading = (at: number) => {
const ancestors: Array<{ depth: number; text: string }> = [];
for (const token of tokens.slice(0, at + 1)) if (token.type === 'heading') {
while (ancestors.length && ancestors.at(-1)!.depth >= token.depth) ancestors.pop();
ancestors.push(token);
}
return !ancestors.some(owner => {
const previous = tokens.slice(0, tokens.findIndex(token => token.type === 'heading' && token === owner))
.filter(token => token.type !== 'space').at(-1);
return /\b(?:copied|quoted|historical|history|example|hypothetical|template|archived|withdrawn|superseded)\b/i.test(clean(owner.text)) ||
previous?.type === 'paragraph' && /\b(?:source|quoted|copied|historical|example|hypothetical|template|archived)\b[^\n]*[::]\s*$/i.test(previous.raw);
});
};
if (!currentHeading(start)) return;
// A named plan may inherit its file identity only from this report's one
// current target declaration and matching title, never from quoted examples.
const sourceNames = [...metadata.matchAll(/\b[\w./-]+\.md\b/g)].map(match => match[0]);
const rawSourceNames = [...(lines[1] ?? '').matchAll(/\b[\w./-]+\.md\b/g)];
const directSource = sourceNames.length > 0 && sourceNames.every(name => name === 'PLAN.md') &&
new Set([...metadata.matchAll(/\bPLAN\.md:([1-9]\d*(?:[-–][1-9]\d*)?)\b/g)].map(match => match[1])).size <= 1;
const targetName = (s: string) => clean(s).replace(/^Eng(?:ineering)? review\s*[:—–-]\s*/i, '')
.replace(/^Plan\s*[:—–-]\s*/i, '').toLowerCase();
const named = [...(lines[1] ?? '').matchAll(/"(Plan:\s*[^"\n]+)"|“(Plan:\s*[^”\n]+)”|\b[Pp]lan\s+"([^"\n]+)"|\b[Pp]lan\s+“([^”\n]+)”/g)]
.map(match => targetName(match[1] ?? match[2] ?? match[3] ?? match[4]!));
const titles = tokens.slice(0, start).filter(token => token.type === 'heading' && token.depth === 1);
const targetFields = tokens.slice(0, start).flatMap((token, at) => {
if (token.type !== 'paragraph' || !currentHeading(at)) return [];
const previous = tokens.slice(0, at).filter(t => t.type !== 'space').at(-1);
const quotedContext = /\b(?:quoted|copied|historical|example|hypothetical|archived)\b[^\n]*:\s*$/i;
if (previous?.type === 'paragraph' && quotedContext.test(previous.raw)) return [];
const parts = token.raw.split('\n');
return parts.filter((line, i) => TARGET_FIELD.test(line) &&
!parts.slice(0, i).some(part => quotedContext.test(part)));
});
// The report title owns the target; an unfenced copy of the reviewed plan
// may add its own H1 only when it names that same plan.
const namedSource = !rawSourceNames.length && named.length === 1 && titles.length >= 1 &&
titles[0]!.type === 'heading' && currentHeading(tokens.indexOf(titles[0]!)) &&
/^Eng(?:ineering)? review\s*[:—–-]\s*\S/i.test(clean(titles[0]!.text)) &&
titles.every(title => title.type === 'heading' && targetName(title.text) === named[0]) && targetFields.length === 1 &&
new RegExp(`${TARGET_FIELD.source}\\s*\`?PLAN\\.md\`?(?:\\s|$)`).test(targetFields[0]!) &&
[...targetFields[0]!.matchAll(/\b[\w./-]+\.md\b/g)].length === 1;
if (!directSource && !namedSource) return;
const withdrawn = (value: string, owners: string) => new RegExp(
`(?:^|[.!?;]\\s+|\\n)(?:Correction:\\s*)?(?:${owners}) (?:is|was|has been) ["“'‘]?(?:withdrawn|cancelled|canceled|rejected|superseded|resolved|closed|hypothetical|not current|no longer current)\\b`, 'i').test(prose(value, true));
const decisionOwner = `D${decision[1].replace('.', '\\.')}`;
if (withdrawn(q.question, decisionOwner)) return;
const previous = tokens.slice(0, start).filter(t => t.type !== 'space').at(-1);
if (previous && /\b(?:copied|quoted|historical|example|hypothetical|template)\b.*[::]\s*$/i.test(previous.raw)) return;
// Records may continue in the current Architecture/Code quality/Tests/Performance
// sections. Their typed fields own the choice; the ledger need not be contiguous.
const end = tokens.findIndex((token, at) => at > start && token.type === 'heading' && /^GSTACK REVIEW REPORT$/i.test(clean(token.text)));
const recordEnd = end < 0 ? tokens.length : end;
const recordSection = (at: number, depth: number) => {
const owner = tokens.slice(0, at).filter(token => token.type === 'heading' && token.depth < depth).at(-1);
if (!owner || owner.type !== 'heading') return false;
const name = clean(owner.text).replace(/^(?:Section\s+)?[1-9]\d*[.:]?\s*/i, '');
return /^Decision ledger$/i.test(name) || /^(?:Architecture|Code quality|Tests?|Testing|Performance) review(?:\s*[—–:-]\s+[A-Za-z0-9][A-Za-z0-9 ,/()&-]*)?$/i.test(name);
};
const words = (s: string) => (clean(s).toLowerCase().replace(/\(recommended\)/g, '').match(/[a-z][a-z0-9_]*/g) ?? [])
.filter(word => !['the', 'a', 'an', 'and', 'or', 'with', 'to', 'of', 'as', 'is', 'it', 'one', 'first', 'now', 'option', 'recommended', 'planned'].includes(word));
const captionWords = (s: string) => words(s.replace(/['’]s\b/g, '')).filter(word =>
!['by', 'on', 'at', 'per', 'then', 'only', 'what', 'how', 'should', 'does', 'each', 'every'].includes(word))
.map(word => word.length > 4 && /ies$/.test(word) ? word.slice(0, -3) + 'y'
: word.length > 3 && /s$/.test(word) && !/ss$/.test(word) ? word.slice(0, -1) : word);
const inlineLabelScore = (native: string, saved: string) => {
const modifiers = (s: string) => s.replace(/\b([a-z][a-z0-9_]*)-keyed\b/gi, 'keyed by $1');
const left = captionWords(modifiers(native)), right = captionWords(modifiers(saved));
const negated = (ws: string[]) => ws.some(word => ['no', 'not', 'never', 'without', 'dont'].includes(word));
// Normalize the keyed modifier, then preserve action/operand order. A
// caption cannot swap the source and destination of the same operation.
if (left.length < 2 || right.length < 2 || left[0] !== right[0] || negated(left) !== negated(right)) return 0;
let cursor = 0;
for (const word of left) {
const at = right.indexOf(word, cursor);
if (at < 0) return 0;
cursor = at + 1;
}
return left.length / right.length;
};
const labelScore = (native: string, saved: string) => {
const normalize = (s: string) => clean(s).replace(/^[A-D][).:]\s+/, '')
.replace(/\s*\(recommended\)/gi, '').toLowerCase()
// Both captions defer this choice. An appended implementation action
// is not part of the equivalence.
.replace(/^(?:decide at|leave to) implementation(?: time)?$/, 'defer to implementation');
if (normalize(native) === normalize(saved)) return 3;
const left = words(normalize(native)), right = words(normalize(saved));
const negated = (tokens: string[]) => tokens.some(word => ['no', 'not', 'never', 'without', 'dont'].includes(word));
if (Math.min(left.length, right.length) < 2 || negated(left) !== negated(right)) return 0;
if (normalize(saved).startsWith(normalize(native) + ' ')) return 2;
// Native captions may abbreviate the saved caption, but cannot introduce
// a different action. Every native content word must occur in order in
// the saved label; a short abbreviation must retain its first letter.
let cursor = 0, exact = 0;
for (const word of left) {
const found = right.findIndex((candidate, at) => at >= cursor && (candidate === word ||
word.length >= 2 && word.length <= 3 && candidate.length > word.length && candidate[0] === word[0] &&
new RegExp('^' + [...word].join('.*')).test(candidate)));
if (found < 0) return 0;
if (right[found] === word) exact++;
cursor = found + 1;
}
return exact >= 2 ? left.length / right.length : 0;
};
const matches: string[] = [];
for (let i = start + 1; i < recordEnd; i++) {
const record = tokens[i]!;
if (record.type !== 'heading') continue;
const id = /^(R[1-9]\d*(?:[a-z][a-z0-9]*)?):\s+\S/.exec(clean(record.text))?.[1];
if (!id || !currentHeading(i) || !recordSection(i, record.depth) || withdrawn(q.question, id)) continue;
let stop = i + 1;
while (stop < recordEnd && !(tokens[stop]!.type === 'heading' && (tokens[stop] as any).depth <= record.depth)) stop++;
const body = tokens.slice(i + 1, stop);
if (withdrawn(body.filter(t => t.type === 'paragraph').map(t => t.raw).join('\n'), `${id}|${decisionOwner}`)) continue;
const paragraphs = body.filter(t => t.type === 'paragraph').map(t => t.raw);
const fields = paragraphs.join('\n').split('\n').map(line => line.replace(/\*\*/g, '').trim());
const field = (name: string) => fields.filter(line => line.startsWith(name + ':')).map(line => line.slice(name.length + 1).trim());
const finding = field('Finding'), baseline = field('Plan baseline'), state = field('State');
if (finding.length !== 1 || baseline.length !== 1 || !baseline[0] || state.length !== 1 ||
!/^(?:pending|approved)$/i.test(state[0]!) ||
/\b(?:copied|quoted|historical|example|hypothetical|withdrawn|superseded)\b/i.test(finding[0]!)) continue;
const marker = `Question D${decision[1]}:`;
const questions = fields.flatMap((line, at) => line.startsWith(marker) ? [at] : []);
if (questions.length !== 1) continue;
const inlineBrief = fields[questions[0]!]!.slice(marker.length).trim();
const inline = Boolean(inlineBrief);
if (!inline && clean(fields[questions[0]! + 1] ?? '') !== clean(title)) continue;
const sources = [...finding[0]!.matchAll(/\b([\w./-]+\.md)(?::([1-9]\d*(?:[-–][1-9]\d*)?))?\b/g)];
if (sources.length !== 1 || sources[0]![1] !== 'PLAN.md' ||
!inline && !sources[0]![2] || source && sources[0]![2] !== source) continue;
if (inline) {
const topic = captionWords(`${record.text} ${inlineBrief.split('Options:')[0]}`);
const nativeTopic = new Set(captionWords(`${q.header} ${title}`));
const completeOptions = q.options.every(option => {
const description = prose(option.description ?? '', true).replace(/"[^"\n]*"|“[^”\n]*”|‘[^’\n]*’/g, '').trim();
const blocks = [...description.matchAll(/([✅❌])\s*([^✅❌]+)/g)];
return description.startsWith('✅') && blocks.every(block => /[A-Za-z0-9]/.test(block[2]!)) &&
blocks.filter(block => block[1] === '✅').length >= 2 && blocks.some(block => block[1] === '❌');
});
const sameId = tokens.slice(start + 1, recordEnd).filter(token => token.type === 'heading' &&
new RegExp(`^${id}:`).test(clean(token.text)));
if (!completeOptions || sameId.length !== 1 || new Set(topic.filter(word => nativeTopic.has(word))).size < 2 ||
[...title.matchAll(/\bD[1-9]\d*(?:\.[1-9]\d*)?\b/g)].length !== 1 ||
/["“'‘][^"”'’\n]*\b[\w./-]+\.md\b/.test(finding[0]!) ||
/^(?:["“'‘`]|quoted\b|copied\b|historical\b|example\b|hypothetical\b)/i.test(inlineBrief)) continue;
}
// The source requires the complete brief, not a literal Options field.
// Read option records only inside this Question block, before answer/history.
// Code, quotations and foreign blocks never contribute saved option labels.
const briefLines = body.flatMap(token => token.type === 'paragraph' ? token.raw.split('\n') :
token.type === 'list' ? token.items.flatMap(item => item.tokens.filter(child => child.type === 'text' || child.type === 'paragraph').flatMap(child => child.raw.split('\n'))) : [])
.map(line => line.replace(/\*\*/g, '').replace(/^\s*[-*+]\s+(?=[A-D][).:]\s)/, '').trim());
const questionAt = briefLines.findIndex(line => line.startsWith(marker));
if (questionAt < 0) continue;
const remaining = inline ? [inlineBrief.includes('Options:') ? inlineBrief.slice(inlineBrief.indexOf('Options:')) : '',
...briefLines.slice(questionAt + 1)] : briefLines.slice(questionAt + 2);
const boundary = remaining.findIndex(line => /^(?:Question D[1-9]\d*(?:\.[1-9]\d*)?|Finding|Plan baseline|Runtime evidence|State|Actual answer|Accepted scope|History):/.test(line));
let brief = remaining.slice(0, boundary < 0 ? remaining.length : boundary);
// A copied complete question may already deliberate its A-D choices.
// Explicit native fields own the offered options; question prose cannot
// supply a second set or lend another question's choices to this record.
const headers = brief.flatMap((line, at) => /^Header:/.test(line) ? [at] : []);
if (headers.length) {
const options = brief.flatMap((line, at) => /^Options:/.test(line) ? [at] : []);
// Selectors bind options independently of presentation order. Native
// labels may already own one; conflicting or repeated prefixes cannot
// manufacture another choice or borrow its full description.
const nativeOptions = q.options.map((option, at) => {
const label = clean(option.label), prefix = /^([A-D])[).:]\s+/.exec(label);
const selector = prefix?.[1];
const caption = prefix ? label.slice(prefix[0].length) : label;
return { selector, label: caption, description: option.description ?? '' };
});
const selectors = q.options.map((_, at) => String.fromCharCode(65 + at));
const explicitSelectors = nativeOptions.flatMap(option => option.selector ? [option.selector] : []);
if (new Set(explicitSelectors).size !== explicitSelectors.length ||
nativeOptions.some(option => option.selector && !selectors.includes(option.selector) || !option.label || /^[A-D][).:]\s+/.test(option.label))) continue;
const readOptions = (lines: string[]) => {
const records: Array<{ selector: string; label: string; description: string[] }> = [];
for (const line of lines) {
const label = /^([A-D])[).:]\s+(.+)$/.exec(line);
if (label) records.push({ selector: label[1]!, label: label[2]!, description: [] });
else if (records.length) records.at(-1)!.description.push(line);
else if (line.trim()) return undefined;
}
if (records.length !== nativeOptions.length || new Set(records.map(record => record.selector)).size !== records.length ||
records.some(record => !selectors.includes(record.selector))) return undefined;
const matches = records.map(record => nativeOptions.flatMap((native, at) =>
(!native.selector || native.selector === record.selector) && clean(record.label) === native.label &&
clean(record.description.join('\n')) === clean(native.description) ? [at] : []));
return matches.every(match => match.length === 1) && new Set(matches.flat()).size === records.length ? records : undefined;
};
// Explicit Options fields are authoritative. Matching question prose
// cannot repair an abbreviated description or contradictory choice.
const optionRecords = options.length === 1 && clean(brief[options[0]!]!) === 'Options:'
? readOptions(brief.slice(options[0]! + 1)) : undefined;
if (inline || headers.length !== 1 || options.length !== 1 || !optionRecords ||
field('Header').length !== 1 || field('Options').length !== 1 || field('Actual answer').length !== 1 ||
options[0]! <= headers[0]! || brief.slice(headers[0]! + 1, options[0]!).some(line => line.trim()) ||
clean(brief[headers[0]!]!.slice('Header:'.length)) !== clean(q.header) ||
!(() => {
const question = [title, ...brief.slice(0, headers[0])];
const native = clean(prose(q.question, true));
if (clean(prose(question.join('\n'), true)) === native) return true;
const deliberations = question.flatMap((line, at) => /^Pros\s*\/\s*cons:$/i.test(line) ? [at] : []);
if (deliberations.length !== 1) return false;
const at = deliberations[0]!;
// A copied deliberation block owns the same complete alternatives,
// even when the native UI presents its recommended choice first.
for (let end = at + 2; end <= question.length; end++) {
if (!readOptions(question.slice(at + 1, end))) continue;
return clean(prose([...question.slice(0, at), ...question.slice(end)].join('\n'), true)) === native;
}
return false;
})()) continue;
// The grid's A-D columns use selector order, not menu presentation order.
brief = [...optionRecords].sort((a, b) => a.selector.localeCompare(b.selector))
.flatMap(record => [`${record.selector}) ${record.label}`, ...record.description]);
}
if (brief.some(line => /^(?:quoted|copied|historical|example|hypothetical|template)(?:\s+[^:]*)?:/i.test(line))) continue;
const labels: Array<[string, string, string]> = [];
for (const line of brief) {
// Compact and expanded briefs use the same A-D records. Descriptions
// remain prose; their mentions of options cannot define another label.
const content = line.replace(/^Options:\s*/, '');
if (!/^[A-D][).:]\s+\S/.test(content)) continue;
for (const match of content.matchAll(/(?:^|\s)([A-D])[).:]\s+(.+?)(?=\s+[A-D][).:]\s+|$)/g))
labels.push([match[0], match[1]!, match[2]!]);
}
if (labels.length !== q.options.length || labels.some((label, index) => label[1] !== String.fromCharCode(65 + index))) continue;
const comparisons = body.filter(t => t.type === 'table').filter(table => {
if (table.type !== 'table') return false;
const headers = table.header.map(c => clean(c.text));
const columnIds = headers.slice(2).map(header => /^([A-D])(?:[).:]?\s+\S.*)?$/.exec(header)?.[1]);
// The role is Current; a baseline-context caption may qualify it. Do
// not strip arbitrary parenthetical prose: historical/proposed/negated
// values cannot masquerade as the current baseline column.
const currentColumn = /^Current(?:\s+\((?:(?:approved|original) )?(?:plan(?: baseline)?|baseline|proposal)\))?$/i.test(headers[1] ?? '');
if (headers[0] !== 'Choice' || !currentColumn ||
JSON.stringify(columnIds) !== JSON.stringify(q.options.map((_, at) => String.fromCharCode(65 + at)))) return false;
// R5a/R5b are dimensions of the one R5 decision, not extra asks. Keep
// the record boundary and unique row identities; R50 is another issue.
const rows = table.rows.filter(row => new RegExp(`^${id}(?:[a-z])?\\b`).test(clean(row[0]!.text)));
const rowIds = rows.map(row => {
const caption = clean(row[0]!.text);
const explicit = new RegExp(`^(${id}[a-z])\\b`).exec(caption)?.[1];
// Lettered dimensions have explicit identities. Bare R rows instead
// identify their distinct commitments by the complete caption.
return explicit ?? caption.toLowerCase();
});
if (!rows.length || new Set(rowIds).size !== rowIds.length || rows.some(row => !row.every(cell => clean(cell.text)))) return false;
const optionColumns = q.options.map(option => {
const scores = labels.map((label, at) => {
const direct = labelScore(option.label, label[2]!) || inline && inlineLabelScore(option.label, label[2]!);
const caption = headers[at + 2]!.replace(/^[A-D][).:]?\s*/, '');
const captionWords = words(caption), savedWords = words(label[2]!);
const negated = (tokens: string[]) => tokens.some(word => ['no', 'not', 'never', 'without', 'dont'].includes(word));
// A descriptive header belongs to its existing A/B/C option. It
// cannot relabel a contradictory saved choice or lend another
// column's action to a short native caption.
const boundCaption = captionWords.filter(word => savedWords.includes(word)).length >= 2 &&
negated(captionWords) === negated(savedWords);
if (inline && !boundCaption && !rows.some(row => labelScore(label[2]!, row[at + 2]!.text) ||
inlineLabelScore(label[2]!, row[at + 2]!.text))) return 0;
const captionScore = boundCaption ? labelScore(option.label, caption) || inline && inlineLabelScore(option.label, caption) : 0;
// Extra native detail must also exist in that option's saved grid
// column; a shared caption cannot authorize an added action.
const extendsCaption = clean(option.label).toLowerCase().startsWith(clean(label[2]!).toLowerCase() + ' ');
return direct || captionScore || extendsCaption && Math.max(...rows.map(row => labelScore(option.label, row[at + 2]!.text))) || 0;
});
const best = Math.max(...scores);
return best > 0 && scores.filter(score => score === best).length === 1 ? scores.indexOf(best) : -1;
});
return !optionColumns.includes(-1) && new Set(optionColumns).size === q.options.length;
});
if (comparisons.length === 1) matches.push(`record:${id}`);
}
return matches.length === 1 ? matches[0] : undefined;
}
/** Fixture-local identity memory prevents re-asks from inflating the floor,
* even after a reopened row replaces its earlier saved question. */
export function createEngBatchingIssueCounter(readPlan: () => string,
isSetup: (fp: AskUserQuestionFingerprint) => boolean) {
const seen = new Set<string>();
const trace: Array<{ signature: string; issue: string; source: 'native' | 'saved-ledger' }> = [];
return {
trace,
isReviewAUQ(fp: AskUserQuestionFingerprint, priorCalls: readonly NativePlanQuestionCall[] = []): boolean {
const call = fp.nativeCall;
if (!call || fp.signature !== `${call.sessionId}:${call.toolUseId}` ||
(fp.nativeQuestionIndex !== undefined && fp.nativeQuestionIndex !== 0) ||
priorCalls.some(prior => prior.sessionId !== call.sessionId || prior.toolUseId === call.toolUseId) ||
!completedDecision(call, 0, Date.now()) || call.questions.length !== 1 || isSetup(fp)) return false;
const q = call.questions[0]!;
if (fp.options.length !== q.options.length || !fp.options.every((o, i) => o.index === i + 1 && o.label === q.options[i]!.label)) return false;
const native = batchingIssueNumber(call);
if (native && !isEngBatchingIssueAUQ(fp, priorCalls)) return false;
const issue = native ?? recordedBatchingIssue(call, readPlan());
if (!issue || seen.has(issue)) return false;
seen.add(issue); trace.push({ signature: fp.signature, issue, source: native ? 'native' : 'saved-ledger' });
return true;
},
};
}