fix(harness): anchor extractPlanFilePath path captures on /Users|~|/home|/var|/tmp

Adversarial-tightened gate sweep surfaced a real bug in the path
extraction: stripAnsi collapses whitespace via cursor-positioning escape
removal, so "yet at /Users/..." in the visible buffer becomes
"yetat/Users/..." with no space between. The previous fallback pattern
`(~?\/?\S*\.claude\/plans\/[\w-]+\.md)` greedily matched non-whitespace
characters BEFORE the path, producing `yetat/Users/garrytan/.claude/...`
which then fails fs.readFileSync.

Fix: every regex now requires the path to START at a known path-anchor:
`~/`, `/Users/`, `/home/`, `/var/`, `/tmp/`, or `./`. Earlier
non-whitespace runs can't be glommed in.

Verified against the failing fixture (`yetat/Users/...`) plus the four
canonical render forms ("Plan saved to:", "Plan file:", `·`-decorated
ctrl-g hint, and the bare fallback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-01 07:36:59 -07:00
parent 9ef34603df
commit bdc4818bf5
+12 -9
View File
@@ -183,21 +183,24 @@ export function isAutoDecidedVisible(visible: string): boolean {
*/
export function extractPlanFilePath(visible: string): string | null {
// Patterns checked in order of specificity. Each captures the .md path.
// The visible buffer may have stripAnsi-collapsed whitespace, so we
// accept space-or-not separators in the prompts and accept paths
// without intervening whitespace (e.g. `editinVSCode·~/.claude/...`).
// The visible buffer may have stripAnsi-collapsed whitespace ("yet at" can
// become "yetat"), so the captured path MUST start at a clear path-anchor
// character: `~/`, `/Users/`, `/home/`, `/var/`, or `/tmp/`. Anchoring on
// these prefixes prevents earlier non-whitespace characters from being
// glommed into the path (real bug seen in the wild: `yetat/Users/...`).
const PATH_ANCHOR = '(~\\/|\\/Users\\/|\\/home\\/|\\/var\\/|\\/tmp\\/|\\.\\/)';
const patterns: RegExp[] = [
/Plan\s*saved\s*to\s*:?\s*(\S+\.md)/i,
/Plan\s*file\s*:?\s*(\S+\.md)/i,
/·\s*(\S+\.claude\/plans\/\S+\.md)/i,
// Fallback: any reference to a .claude/plans path in the buffer.
/(~?\/?\S*\.claude\/plans\/[\w-]+\.md)/i,
new RegExp(`Plan\\s*saved\\s*to\\s*:?\\s*(${PATH_ANCHOR}\\S+\\.md)`, 'i'),
new RegExp(`Plan\\s*file\\s*:?\\s*(${PATH_ANCHOR}\\S+\\.md)`, 'i'),
new RegExp(`·\\s*(${PATH_ANCHOR}\\S*\\.claude\\/plans\\/\\S+\\.md)`, 'i'),
// Fallback: any path-anchored reference to a .claude/plans .md file.
new RegExp(`(${PATH_ANCHOR}\\S*\\.claude\\/plans\\/[\\w-]+\\.md)`, 'i'),
];
for (const p of patterns) {
const m = visible.match(p);
if (m && m[1]) {
let raw = m[1];
// Some patterns capture trailing punctuation; strip a trailing dot.
// Strip trailing punctuation that some patterns may capture.
raw = raw.replace(/\.+$/, '.md').replace(/\.md\.+$/, '.md');
// Tilde expansion to absolute path.
if (raw.startsWith('~')) {