Count an autoplan phase report that starts after a preface sentence (GST-1916) (#4171)

* Count an autoplan phase report that starts after a preface sentence (GST-1916)

Claude Code can journal the prose between tool calls as a paraphrased
narration summary. A Phase 1 report that opened with a check sentence came
back as "I checked the packet... Phase 1 is complete with 9 native issues and
no outside review, so the work moves on to Phase 2.", and the publication
guard denied the next phase with publication_missing because it counted a
declaration only at the start of a line (#4155).

- A declaration that starts a sentence inside a plain prose line now counts.
  Sentences inside double, curly or single quotes or inline code, and list,
  heading, table and quote lines, still don't.
- A "with" result that mentions "no" after its counts is affirmative; a
  result that opens with "no" is still rejected.
- New test replays the captured summary as a narration-journaled report
  through the guard, with quoted, negated and other-phase controls.

Co-authored-by: Marius Storm-Olsen <1500+mstormo@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Reject an autoplan phase report that follows a same-line example introduction (GST-1916)

Review-Finding: review-p1/specialist-batched#1 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Mention same-line example introductions in the autoplan changelog entry (GST-1916)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: Marius Storm-Olsen <1500+mstormo@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-08 16:53:27 +00:00
1 parent 15a35ff1fd
commit 23f9d07ce9
3 files changed
+115 -3

No files matched your search

@@ -0,0 +1,4 @@
---
important: blocker
---
- **/autoplan no longer stops after a phase with `publication_missing` when Claude Code saves the phase report as a summary that opens with another sentence.** Claude Code can save the prose between tool calls as a paraphrased summary. When the report started with a check sentence, the summary read "I checked the packet... Phase 1 is complete with 9 native issues and no outside review, so the work moves on to Phase 2." The guard only counted a phase declaration at the start of a line, so the run died after Phase 1. It now also counts one that starts a sentence in plain prose. A declaration inside quotes or inline code, one after an example or template introduction on the same line, a negated or future one, a list item, a heading and another phase's number still don't count. A "with" result may now mention "no outside review" after its counts; one that opens with "no" still doesn't count. Thanks to @mstormo for finding the cause in #4155.
+24 -3
View File
@@ -5,6 +5,8 @@ export interface AutoplanPhaseHit {
ts: number;
}
const exampleIntroduction = /\b(?:example|sample|quote(?:d)?|source|template|instruction|marker|expected\s+(?:output|announcement))\b/i;
function phaseDeclaration(text: string): RegExpExecArray | null {
const declaration = String.raw`Phase[ \t]+(1|2(?:\.5)?|3)(?:[ \t]+\(([^()]*)\))?[ \t]+(?:is[ \t]+)?(?:complete(?:d)?|done|finished|wrapped[ \t]+up)`;
const plain = text.replace(new RegExp(String.raw`^\*\*(${declaration}[.:]?)\*\*`, 'i'), '$1');
@@ -16,7 +18,7 @@ function phaseDeclaration(text: string): RegExpExecArray | null {
// supply the missing phase declaration.
const recap = new RegExp(String.raw`^${declaration}(?:[ \t]*[—–][ \t]*|[ \t]+(?<withResult>with)[ \t]+)(.+)$`, 'i').exec(plain);
const tail = recap?.[4]?.trim();
if (tail && !/^["“'‘>]|\?|\b(?:if|unless|when|once|pending|maybe|perhaps|would|could|will|source|example|sample|quote(?:d)?|historical|earlier|previous(?:ly)?|template|not|no|never|superseded|provided|rejected|incomplete|unfinished|withdrawn|retracted|cancelled|canceled)\b/i.test(tail)) match = recap;
if (tail && !/^(?:["“'‘>]|no\b)|\?|\b(?:if|unless|when|once|pending|maybe|perhaps|would|could|will|source|example|sample|quote(?:d)?|historical|earlier|previous(?:ly)?|template|not|never|superseded|provided|rejected|incomplete|unfinished|withdrawn|retracted|cancelled|canceled)\b/i.test(tail)) match = recap;
if (match?.groups?.withResult && /\bhypothetic(?:al|ally)\b/i.test(tail!)) match = null;
}
// Optional phase names are metadata, and must agree with the phase number.
@@ -30,6 +32,25 @@ function phaseDeclaration(text: string): RegExpExecArray | null {
return match;
}
/**
* A narration summary can fold a preface sentence into the report, so the
* declaration starts a sentence inside the line (#4155). Only plain prose
* lines qualify, and a sentence inside quotes or inline code is quoted text.
*/
function sentenceDeclaration(line: string): RegExpExecArray | null {
if (/^(?:[#>|"“'‘`[+-]|\*(?!\*)|\d+[.)][ \t])/.test(line) || /\bEmit\s+phase-transition\s+summary\s*:/i.test(line)) return null;
for (const boundary of line.matchAll(/[.!?]["”’)]?[ \t]+(?=(?:\*\*)?Phase[ \t])/g)) {
const before = line.slice(0, boundary.index! + boundary[0].length);
if (exampleIntroduction.test(before)) return null;
const count = (pattern: RegExp) => before.match(pattern)?.length ?? 0;
if (count(/"/g) % 2 || count(/`/g) % 2 || count(/“/g) > count(/”/g) ||
count(/(?:^|[\s(])['‘]/g) > count(/['’](?=[\s.,;:!?)]|$)/g)) continue;
const match = phaseDeclaration(line.slice(before.length));
if (match) return match;
}
return null;
}
/** An explicit current withdrawal in the same announcement cancels a new with-result claim. */
function withResultWithdrawn(lines: string[], index: number, phase: string): boolean {
let source = false;
@@ -93,7 +114,7 @@ export function autoplanPhaseCompletions(
if (fence) continue;
// Accept a plain/bold declaration, never a heading, quoted source,
// table cell, checklist, or a sentence promising future completion.
let match = phaseDeclaration(text);
let match = phaseDeclaration(text) ?? sentenceDeclaration(text);
if (/^>\s/.test(text)) {
// The skill's transition summary itself is a blockquote. Accept a
// filled-in single-phase summary with concrete consensus counts;
@@ -107,7 +128,7 @@ export function autoplanPhaseCompletions(
!/\[[^\]]*\]|\{\{/.test(block.join('\n'));
if (concreteSummary) match = phaseDeclaration(text.replace(/^>\s?/, ''));
}
const introducedExample = /\b(?:example|sample|quote(?:d)?|source|template|instruction|marker|expected\s+(?:output|announcement))\b.*[::]\s*$/i.test(previousLine) ||
const introducedExample = new RegExp(String.raw`${exampleIntroduction.source}.*[::]\s*$`, 'i').test(previousLine) ||
(!!match?.groups?.withResult && /\b(?:example|sample|quote(?:d)?|source|historical|earlier|previous|archived|hypothetical|template)\b.*[::]\s*$/i.test(previousLine.replace(/\*\*/g, '')));
// Keep an example introduction across all its marker/quoted lines,
// rather than allowing its second marker to look like real completion.
@@ -0,0 +1,87 @@
/**
* #4155: Claude Code can journal a phase report between tool calls as a
* paraphrased narration summary. When the report opened with a preface
* sentence, the summary folds it in, so the declaration starts a sentence but
* not the line. It still counts; quoted, negated and foreign-phase mentions
* in the same position do not.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import { autoplanPhaseCompletions } from '../lib/autoplan-phase-publication';
import { guardFixture, section, denial, codeOf, type GuardFixture } from './helpers/autoplan-guard-fixture';
const at = Date.parse('2026-11-08T03:42:14.959Z');
const observe = (text: string) => autoplanPhaseCompletions({ status: 'ready', calls: [],
assistantMessages: [{ sessionId: 'autoplan-fixture', text, timestamp: new Date(at).toISOString() }] }, at - 1);
// The narration summary Claude Code 2.1.319 journaled for one real Phase 1 report (#4155).
const captured = "I checked the packet against the plan and all four edits are in place, with the native reviewer's hash matching. " +
'Phase 1 is complete with 9 native issues and no outside review, so the work moves on to Phase 2.';
describe('a declaration that starts a sentence inside the line', () => {
test('the captured narration summary counts as Phase 1 and nothing else', () => {
expect(observe(captured)).toEqual([{ phase: 1, ts: at }]);
});
test('other affirmative forms after a preface sentence count', () => {
expect(observe('Checked the packet. Phase 2.5 (DX) is done: 4 findings.')).toEqual([{ phase: 2.5, ts: at }]);
expect(observe('All edits verified! **Phase 3 complete.** Moving on.')).toEqual([{ phase: 3, ts: at }]);
expect(observe('The reviewer said "looks good." Phase 2 is finished.')).toEqual([{ phase: 2, ts: at }]);
});
const rejected = [
'The template says "Done. Phase 1 is complete. Moving on." for this step.',
'Codex wrote “Checked. Phase 1 complete. Next.” in its notes.',
'The marker is `Done. Phase 1 complete. Next.` in the template.',
"The log reads 'Done. Phase 1 complete. Next.' for the reviewer's run.",
'The log reads ‘Done. Phase 1 complete. Next.’ for this step.',
'Example: Done. Phase 1 complete.',
'The template example: Done. Phase 1 complete. Next.',
'The expected announcement reads as follows. Phase 1 complete.',
'I checked the packet. I did not declare Phase 1 complete yet.',
'I checked the packet. Phase 1 is not complete.',
'I checked the packet. Phase 1 is complete with no outside review.',
'I checked the packet. Phase 1 will be complete after the review.',
'Wait for the review before Phase 1 is complete with 9 issues.',
'I checked the packet. Phase 4 is complete.',
'I checked the packet. Phase 2.1 is complete.',
'- Checked the packet. Phase 1 complete.',
'1. Phase 1 complete.',
'# Checked. Phase 1 complete.',
'> Checked. Phase 1 complete.',
'Example:\nChecked the packet. Phase 1 complete.',
'```text\nChecked the packet. Phase 1 complete.\n```',
];
test.each(rejected)('%s does not count', text => expect(observe(text)).toEqual([]));
});
const NARRATION = 'Eg0KC0IJbmFycmF0aW9u'; // Synthetic signature tag path 2→1→8=narration, as in autoplan-public-narration-ad.json.
const fixtures: GuardFixture[] = [];
afterEach(() => { for (const f of fixtures.splice(0)) f.cleanup(); });
/** Phase-close step 6, journaled the way #4155 saw it: the report as a narration summary, then the no-op and its result. */
async function nextPhaseEntry(summary: string) {
const f = guardFixture(); fixtures.push(f);
const id = f.nextMessage();
f.say(summary, id); f.use(`noop-${id}`, 'Bash', { command: 'true autoplan-published ceo' }, id);
f.result(`noop-${id}`, { content: '' }); f.journal();
const rows = fs.readFileSync(f.transcript, 'utf8').trim().split('\n').map(line => JSON.parse(line));
const report = rows.find(r => r.message?.content?.[0]?.text === summary);
report.message.content = [{ type: 'thinking', thinking: summary, signature: NARRATION }];
fs.writeFileSync(f.transcript, rows.map(r => JSON.stringify(r) + '\n').join(''));
return f.hook(f.input('next', 'Read', { file_path: section('design-phase.md') }));
}
describe('the publication guard on a narration-journaled report', () => {
test('the captured summary lets Phase 2 start', async () => {
expect(denial(await nextPhaseEntry(captured))).toBeUndefined();
});
test('a quoted, negated or other-phase summary is still publication_missing', async () => {
for (const summary of [
'I checked the packet. The template says "Done. Phase 1 is complete. Next." for this step.',
'I checked the packet. I did not declare Phase 1 complete yet.',
'I checked the packet. Phase 2 is complete with 4 design issues.',
]) expect(codeOf(await nextPhaseEntry(summary)), summary).toBe('publication_missing');
});
});