diff --git a/changelog.d/capy-autoplan-mid-sentence-phase-report-gst-1916.md b/changelog.d/capy-autoplan-mid-sentence-phase-report-gst-1916.md new file mode 100644 index 000000000..3c8611d6b --- /dev/null +++ b/changelog.d/capy-autoplan-mid-sentence-phase-report-gst-1916.md @@ -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. diff --git a/lib/autoplan-phase-publication.ts b/lib/autoplan-phase-publication.ts index d08cc3952..ffc60de3b 100644 --- a/lib/autoplan-phase-publication.ts +++ b/lib/autoplan-phase-publication.ts @@ -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]+(?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. diff --git a/test/autoplan-publication-mid-sentence.test.ts b/test/autoplan-publication-mid-sentence.test.ts new file mode 100644 index 000000000..17225f2c6 --- /dev/null +++ b/test/autoplan-publication-mid-sentence.test.ts @@ -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'); + }); +});