mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-09 18:28:15 +01:00
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:
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.
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user