Skip to content

🐛 Map pane write matcher counts a heredoc body line as a tracker write #369

Closed

Description

@ToRvaLDz

Parent: trailhead mod: Claude Code function hooks for on-screen state, context and in-process guards

Surfaced from: Map pane does not refresh after a multi-line Bash tracker write

Question

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.

Blocked by

nothing (on the frontier)

Activity

  1. self-assigned this
    on Nov 5, 2026
  2. ToRvaLDz commented on Nov 5, 2026

    @ToRvaLDz
    OwnerAuthor

    REPRO

    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.

  3. ToRvaLDz commented on Nov 5, 2026

    @ToRvaLDz
    OwnerAuthor

    DIAGNOSIS

    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).

  4. ToRvaLDz commented on Nov 5, 2026

    @ToRvaLDz
    OwnerAuthor

    status: clean
    critical: 0 · warning: 4 · info: 2

    Ledger

    • 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: accepted: exotic shape, not emulated per the shell-parsing stop criterion in the conventions (Threat model and stop criterion for the shell-parsing guards #226); a lost write leaves the pane stale until the turn-end read.
    • I2: accepted: exotic shape, not emulated per the shell-parsing stop criterion in the conventions (Threat model and stop criterion for the shell-parsing guards #226); a lost write leaves the pane stale until the turn-end read.

    Warning

    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.

  5. ToRvaLDz commented on Nov 5, 2026

    @ToRvaLDz
    OwnerAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions