You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
🐛 Map pane write matcher counts a heredoc body line as a tracker write #369
GH_ISSUE_WRITE in plugins/trailhead/hooks/mod/map-pane.tsx matches text inside a heredoc body, not only commands. How do we stop a line of heredoc text from counting as a tracker write, without losing a real write in a multi-line command?
Repro
Test the regex on a command that writes no issue, only a file whose text mentions a write: cat > /tmp/x.md <<'EOF' / gh issue close 5 / EOF
Expected: no match. Observed: match, so the pane starts one extra read and arms its trailing read. The engine hits this when a ticket or comment body it writes to a file quotes a gh issue command.
Impact is low: extra gh calls and no wrong data. The regex is checked one shell segment at a time and has no notion of a heredoc, so any fix must keep the multi-line write shapes covered by tests/map-pane-write.test.ts.
Run GH_ISSUE_WRITE from plugins/trailhead/hooks/mod/map-pane.tsx against a command that writes only a local file, whose heredoc body has a line reading as a tracker write:
cat > /tmp/x.md <<'EOF'
gh issue close 5
EOF
Expected: no match. Observed:true, on the current tree (a one-off script evaluating the exported regex).
Control: the same heredoc with a neutral body followed by gh issue view 5 gives false, so the match comes from the body line.
Root cause (falsifiable).GH_ISSUE_WRITE runs over the raw e.command and its segment bound stops only at line ends, so a heredoc body line is just another line. If this is the cause, the match starts on the body line, and removing only the body makes it vanish.
Falsification test, run, prediction held. On cat > /tmp/x.md <<'EOF'\ngh issue close 5\nEOF: the regex matches at index 24, the start of the body line; replacing only that line with neutral text gives no match; stripping the heredoc body first gives no match. Deterministic: a pure function of the string.
Constraints on the fix.
The host's command: matcher takes a regex, never a function (.claude-plugin/types/claude-code/index.d.ts), so heredoc awareness goes in the handler, with the regex kept as a cheap first filter.
No mod file imports from hooks/lib, and shell-scan.js is CommonJS. The repo's pattern for logic the mod shares is an import-free leaf held equal by a parity test (git-env.ts and hooks/lib/git-env-mod.test.js).
isWhiteboardCreate (whiteboard-follow.ts) has the same blindness. Its effect is smaller, since it also needs an issue URL in the output, so it misfires only when a real create runs in the same command.
Fix shape. A pure isTrackerWrite(command) that strips heredoc bodies and then tests GH_ISSUE_WRITE, called first in the tool.call handler; hintWhiteboard gets the stripped text too. The stripper is a small import-free mod leaf with a parity test against stripHeredocBodies. When the scanner cannot tell, it keeps the line: a lost write costs a stale pane, an extra read costs one gh call.
Weak point: the stripper exists in two places, held together only by the parity test. Still chosen because importing the CommonJS file from the mod is untried against the host loader, and the mod has no such import today.
Shapes to cover (expected verdict after the fix):
Command
Write?
<<EOF, <<'EOF', <<"EOF" with a write-looking body
no
<<-EOF with a write-looking body and a tab-indented terminator
no
two heredocs on one line, write-looking bodies
no
the same two with neutral bodies, then gh issue close 3
yes
gh issue create --body-file - <<EOF
yes
gh issue comment 5 -F - <<EOF with a write-looking body
yes
write after the terminator
yes
body lines that only resemble the terminator (EOF2, EOF), then a write-looking line before the real one
no
unterminated heredoc with a write-looking line
no
<<-EOF where a space-indented EOF is not the terminator
no
cat <<< 'x' then a write on the next line
yes
echo $((1<<2)) then a write on the next line
yes
echo $((1<<2)); gh issue view 5
no
trailhead-issue-body.js named in a heredoc body
no
Eliminated: regex flags (none set); the gh api alternative (the repro matches the gh issue one).
W1: fixed (53b6f26). A # that starts a comment now stops the scan. I probed a#b, $#, ${#x}, ${x#y}, \#, a tab before #, ;#, (#, { #, and an opener followed by a comment (with and without an apostrophe). The write stayed in every one, and the output matched shell-scan.js.
W2: fixed (53b6f26). The open quote now carries across lines, and an apostrophe in a comment no longer opens one. The output matched shell-scan.js on the multi-line quote, quote-closed-then-opener, and unbalanced-quote shapes. An unbalanced quote only suppresses stripping, so the failure is an extra read.
W3: fixed (53b6f26). The skip now needs ERR_UNKNOWN_FILE_EXTENSION or !process.features.typescript, and it prints a SKIP line. Any other load error fails the test. The corpus now has W1 and W2 cases, and the test passes here on Node v22.22.2.
I1: fixed (53b6f26). The $(cat <<EOF ...) difference is documented in the heredoc.ts header and asserted in KNOWN_DIFFS as keeping the lines. That is the safe direction. ${x:-<<EOF} is now skipped and matches shell-scan.js.
W4: $'..' containing \' loses a real write. plugins/trailhead/hooks/mod/heredoc.ts:24-25,35-36.
The quote branch treats every ' quote as a quote with no escapes, so it closes $'it\'s <<EOF' at the escaped quote.
Reproduced with echo $'it\'s <<EOF'\ngh issue close 5\nEOF. The leaf returns only the first line, so the write is lost. shell-scan.js keeps all three lines.
This is the worse failure direction. The shape is unlikely, because it needs both \' and << inside $'..'.
Fix, about 2 lines: when a ' follows $, record the quote as $' and let the backslash skip apply to it (quote === '"' || quote === "$'").
Without the fix, the flip side is a harmless extra read: with no opener in the string, a $'a\'b' leaves a stray open quote that carries over and suppresses later stripping.
Info
I2: A heredoc opener inside backticks is stripped here and kept by shell-scan.js. heredoc.ts:43.
Reproduced with echo `cat <<EOF` followed by gh issue close 5 on the next line. The leaf drops the write; shell-scan.js keeps it.
The shape is rare. If you want it covered, either track a backtick as a quote-like state, which is a small extra branch, or list it under the known differences. The $(..) difference is already listed there, but that one errs toward an extra read, so this one is the dangerous direction.
Checked, no finding
Unmatched ${ breaks out of the scan and keeps the lines, which errs toward an extra read.
${x:-'}'} and $((1<<2)) are safe.
A tag containing # (<<A#B) works. The leaf strips correctly there and shell-scan.js over-strips.
A heredoc with a write-looking body is still not counted in the plain shapes.
"$(cat <<'EOF' ...)" with a write-looking body is still counted. This is the known $(..) difference and an extra read only.
Size: heredoc.ts is 79 lines with one scan loop and no frame stack. It is not yet an emulation. Another round of branches would be.
Goal-backward verification
status: met
All claims were evaluated and hold. The ticket's commits, from the exact git log --grep command, are 171d94b and 53b6f26, and I read both.
Repro no longer a write (class: met): isTrackerWrite in /home/torvalds/Git/trailhead/plugins/trailhead/hooks/mod/map-pane.tsx (added in 171d94b) strips heredoc bodies before testing GH_ISSUE_WRITE. In map-pane-write.test.ts the case cat >f <<EOF\ngh issue close 5\nEOF is false, and so are the quoted and double-quoted variants. The ticket's /tmp/x.md repro is covered in map-pane-ui.test.ts.
Real writes in every existing multi-line shape (class: met): the WRITES and READS lists in map-pane-write.test.ts are now asserted against both GH_ISSUE_WRITE and isTrackerWrite. They include a heredoc followed by gh issue edit, create, comment and trailhead-issue-body.js, and lines on either side of the write.
Hook uses the check (class: met): the tool.call handler returns next(e) early unless isTrackerWrite(e.command), so no lastWriteAt, refresh or hint runs on a body-only match. hintWhiteboard receives stripHeredocBodies(e.command). The new UI test a heredoc body that names a tracker write starts no pane read passes on both surfaces. It shows the fetch count unchanged on the repro and up by exactly one on a heredoc followed by a real gh issue close.
DIAGNOSIS table, all 14 rows (class: met): each row is a HEREDOCS entry that passes. The <<EOF, <<'EOF' and <<"EOF" forms, <<-EOF with tab terminators, and two heredocs on one line (write-looking and neutral bodies) are covered. So are --body-file - <<EOF and -F - <<EOF as writes, a write after the terminator, EOF2 and EOF lookalikes, an unterminated heredoc, and a space-indented EOF under <<-. The last rows cover <<<, $((1<<2)) with a write or a read on the next line, and the trailhead-issue-body.js named in a heredoc body.
Edges beyond the table (class: met): 53b6f26 fixes comment <<, multi-line quotes, ${..} and apostrophes in bodies, each with a test. A << inside $(..) is not seen by the mod stripper, and that is documented. The lines are kept, so the cost is an extra read, not a lost write, as the ticket's rule allows.
Parity with shell-scan.js (class: met): node .../hooks/lib/heredoc-mod.test.js printed "2 passed". That covers equality with shell-scan.js over 18 commands and the known $(..) difference.
Tests run: with the scratch helper I ran only map-pane-write.test.ts and map-pane-ui.test.ts: 400 pass, 0 fail.
Not checked: isWhiteboardCreate in whiteboard-follow.ts was not changed. The ticket does not require a change there, since the stripped text is passed in.
Tests and acceptance
npm test on tree 53adbb7: 107 ok or skipped, 0 failed, including claude plugin validate and claude plugin test. The repro now gives no match.
Acceptance testing: not run. The defect had no visible effect (extra gh reads, no wrong data), and tests/map-pane-ui.test.ts covers the pane read count on both surfaces. Not exercised in a live session.
Resolved: a heredoc body line no longer counts as a tracker write in the map pane.
Cause.GH_ISSUE_WRITE ran over the raw command, and its segment bound stops only at line ends, so a heredoc body line read as a command.
Fix. The host's command: matcher takes only a regex, so GH_ISSUE_WRITE stays as the first filter, and the handler now checks isTrackerWrite first: heredoc bodies out, then the regex. On no write it passes through with no refresh and no hint. hintWhiteboard gets the stripped text too.
171d94b adds the import-free leaf plugins/trailhead/hooks/mod/heredoc.ts, isTrackerWrite in plugins/trailhead/hooks/mod/map-pane.tsx, the parity test plugins/trailhead/hooks/lib/heredoc-mod.test.js against shell-scan.js, the note in plugins/trailhead/hooks/mod/README.md, and cases in plugins/trailhead/tests/map-pane-write.test.ts and plugins/trailhead/tests/map-pane-ui.test.ts.
53b6f26 keeps a write after a << in a comment, in a multi-line quote or in ${..}, and makes the parity test fail on a load error instead of skipping.
Verified.npm test green on the pushed tree. Findings ledger: see the latest VERIFY comment.
Known limits. Two rare shapes can still lose a write (W4, I2), accepted in the ledger; the pane then stays stale until the turn-end read. A << inside $(..) is not stripped, which costs an extra read at worst.
Not covered. Not exercised in a live session; the read count is covered by the mod's UI tests only.
Activity
ToRvaLDz commentedon Nov 5, 2026
REPRO
Run
GH_ISSUE_WRITEfromplugins/trailhead/hooks/mod/map-pane.tsxagainst a command that writes only a local file, whose heredoc body has a line reading as a tracker write:Expected: no match. Observed:
true, on the current tree (a one-off script evaluating the exported regex).Control: the same heredoc with a neutral body followed by
gh issue view 5givesfalse, so the match comes from the body line.ToRvaLDz commentedon Nov 5, 2026
DIAGNOSIS
Root cause (falsifiable).
GH_ISSUE_WRITEruns over the rawe.commandand its segment bound stops only at line ends, so a heredoc body line is just another line. If this is the cause, the match starts on the body line, and removing only the body makes it vanish.Falsification test, run, prediction held. On
cat > /tmp/x.md <<'EOF'\ngh issue close 5\nEOF: the regex matches at index 24, the start of the body line; replacing only that line with neutral text gives no match; stripping the heredoc body first gives no match. Deterministic: a pure function of the string.Constraints on the fix.
command:matcher takes a regex, never a function (.claude-plugin/types/claude-code/index.d.ts), so heredoc awareness goes in the handler, with the regex kept as a cheap first filter.hooks/lib, andshell-scan.jsis CommonJS. The repo's pattern for logic the mod shares is an import-free leaf held equal by a parity test (git-env.tsandhooks/lib/git-env-mod.test.js).isWhiteboardCreate(whiteboard-follow.ts) has the same blindness. Its effect is smaller, since it also needs an issue URL in the output, so it misfires only when a real create runs in the same command.Fix shape. A pure
isTrackerWrite(command)that strips heredoc bodies and then testsGH_ISSUE_WRITE, called first in thetool.callhandler;hintWhiteboardgets the stripped text too. The stripper is a small import-free mod leaf with a parity test againststripHeredocBodies. When the scanner cannot tell, it keeps the line: a lost write costs a stale pane, an extra read costs oneghcall.Weak point: the stripper exists in two places, held together only by the parity test. Still chosen because importing the CommonJS file from the mod is untried against the host loader, and the mod has no such import today.
Shapes to cover (expected verdict after the fix):
<<EOF,<<'EOF',<<"EOF"with a write-looking body<<-EOFwith a write-looking body and a tab-indented terminatorgh issue close 3gh issue create --body-file - <<EOFgh issue comment 5 -F - <<EOFwith a write-looking bodyEOF2,EOF), then a write-looking line before the real one<<-EOFwhere a space-indentedEOFis not the terminatorcat <<< 'x'then a write on the next lineecho $((1<<2))then a write on the next lineecho $((1<<2)); gh issue view 5trailhead-issue-body.jsnamed in a heredoc bodyEliminated: regex flags (none set); the
gh apialternative (the repro matches thegh issueone).ToRvaLDz commentedon Nov 5, 2026
status: clean
critical: 0 · warning: 4 · info: 2
Ledger
#that starts a comment now stops the scan. I probeda#b,$#,${#x},${x#y},\#, a tab before#,;#,(#,{ #, and an opener followed by a comment (with and without an apostrophe). The write stayed in every one, and the output matchedshell-scan.js.shell-scan.json the multi-line quote, quote-closed-then-opener, and unbalanced-quote shapes. An unbalanced quote only suppresses stripping, so the failure is an extra read.ERR_UNKNOWN_FILE_EXTENSIONor!process.features.typescript, and it prints a SKIP line. Any other load error fails the test. The corpus now has W1 and W2 cases, and the test passes here on Node v22.22.2.$(cat <<EOF ...)difference is documented in theheredoc.tsheader and asserted inKNOWN_DIFFSas keeping the lines. That is the safe direction.${x:-<<EOF}is now skipped and matchesshell-scan.js.Warning
W4:
$'..'containing\'loses a real write.plugins/trailhead/hooks/mod/heredoc.ts:24-25,35-36.'quote as a quote with no escapes, so it closes$'it\'s <<EOF'at the escaped quote.echo $'it\'s <<EOF'\ngh issue close 5\nEOF. The leaf returns only the first line, so the write is lost.shell-scan.jskeeps all three lines.\'and<<inside$'..'.'follows$, record the quote as$'and let the backslash skip apply to it (quote === '"' || quote === "$'").$'a\'b'leaves a stray open quote that carries over and suppresses later stripping.Info
I2: A heredoc opener inside backticks is stripped here and kept by
shell-scan.js.heredoc.ts:43.echo `cat <<EOF`followed bygh issue close 5on the next line. The leaf drops the write;shell-scan.jskeeps it.$(..)difference is already listed there, but that one errs toward an extra read, so this one is the dangerous direction.Checked, no finding
${breaks out of the scan and keeps the lines, which errs toward an extra read.${x:-'}'}and$((1<<2))are safe.#(<<A#B) works. The leaf strips correctly there andshell-scan.jsover-strips."$(cat <<'EOF' ...)"with a write-looking body is still counted. This is the known$(..)difference and an extra read only.heredoc.tsis 79 lines with one scan loop and no frame stack. It is not yet an emulation. Another round of branches would be.Goal-backward verification
status: met
All claims were evaluated and hold. The ticket's commits, from the exact
git log --grepcommand, are 171d94b and 53b6f26, and I read both.isTrackerWritein/home/torvalds/Git/trailhead/plugins/trailhead/hooks/mod/map-pane.tsx(added in 171d94b) strips heredoc bodies before testingGH_ISSUE_WRITE. Inmap-pane-write.test.tsthe casecat >f <<EOF\ngh issue close 5\nEOFis false, and so are the quoted and double-quoted variants. The ticket's/tmp/x.mdrepro is covered inmap-pane-ui.test.ts.map-pane-write.test.tsare now asserted against bothGH_ISSUE_WRITEandisTrackerWrite. They include a heredoc followed bygh issue edit,create,commentandtrailhead-issue-body.js, and lines on either side of the write.tool.callhandler returnsnext(e)early unlessisTrackerWrite(e.command), so nolastWriteAt, refresh or hint runs on a body-only match.hintWhiteboardreceivesstripHeredocBodies(e.command). The new UI testa heredoc body that names a tracker write starts no pane readpasses on both surfaces. It shows the fetch count unchanged on the repro and up by exactly one on a heredoc followed by a realgh issue close.HEREDOCSentry that passes. The<<EOF,<<'EOF'and<<"EOF"forms,<<-EOFwith tab terminators, and two heredocs on one line (write-looking and neutral bodies) are covered. So are--body-file - <<EOFand-F - <<EOFas writes, a write after the terminator,EOF2andEOFlookalikes, an unterminated heredoc, and a space-indentedEOFunder<<-. The last rows cover<<<,$((1<<2))with a write or a read on the next line, and thetrailhead-issue-body.jsnamed in a heredoc body.<<, multi-line quotes,${..}and apostrophes in bodies, each with a test. A<<inside$(..)is not seen by the mod stripper, and that is documented. The lines are kept, so the cost is an extra read, not a lost write, as the ticket's rule allows.shell-scan.js(class: met):node .../hooks/lib/heredoc-mod.test.jsprinted "2 passed". That covers equality withshell-scan.jsover 18 commands and the known$(..)difference.map-pane-write.test.tsandmap-pane-ui.test.ts: 400 pass, 0 fail.Not checked:
isWhiteboardCreateinwhiteboard-follow.tswas not changed. The ticket does not require a change there, since the stripped text is passed in.Tests and acceptance
npm teston tree53adbb7: 107 ok or skipped, 0 failed, includingclaude plugin validateandclaude plugin test. The repro now gives no match.Acceptance testing: not run. The defect had no visible effect (extra
ghreads, no wrong data), andtests/map-pane-ui.test.tscovers the pane read count on both surfaces. Not exercised in a live session.fix: ignore heredoc bodies when the map pane spots a tracker write
fix: keep a write after a comment or quoted << in the map pane stripper
ToRvaLDz commentedon Nov 5, 2026
Resolved: a heredoc body line no longer counts as a tracker write in the map pane.
Cause.
GH_ISSUE_WRITEran over the raw command, and its segment bound stops only at line ends, so a heredoc body line read as a command.Fix. The host's
command:matcher takes only a regex, soGH_ISSUE_WRITEstays as the first filter, and the handler now checksisTrackerWritefirst: heredoc bodies out, then the regex. On no write it passes through with no refresh and no hint.hintWhiteboardgets the stripped text too.plugins/trailhead/hooks/mod/heredoc.ts,isTrackerWriteinplugins/trailhead/hooks/mod/map-pane.tsx, the parity testplugins/trailhead/hooks/lib/heredoc-mod.test.jsagainstshell-scan.js, the note inplugins/trailhead/hooks/mod/README.md, and cases inplugins/trailhead/tests/map-pane-write.test.tsandplugins/trailhead/tests/map-pane-ui.test.ts.<<in a comment, in a multi-line quote or in${..}, and makes the parity test fail on a load error instead of skipping.Verified.
npm testgreen on the pushed tree. Findings ledger: see the latest VERIFY comment.Known limits. Two rare shapes can still lose a write (W4, I2), accepted in the ledger; the pane then stays stale until the turn-end read. A
<<inside$(..)is not stripped, which costs an extra read at worst.Not covered. Not exercised in a live session; the read count is covered by the mod's UI tests only.