Repository navigation
fix(repo): Claude Code Bash reads count as reads; bash= reach scenario - #369
Conversation
Settles two points of an audit of a `dokimi repo run` replay on IntelPython/dpnp#3099. Item 6: under Claude Code, guidance_read counted the Read tool only. In the replay 17 of 78 guidance cells printed an AGENTS.md with Bash (`cd benchmarks; cat AGENTS.md`) and all had guidance_read []. The claude-stream-json converter now runs cli_traces.shell_reads on each Bash command, as the Codex and Copilot readers do since intel#116, and emits a read event per printed file before the command event (same t, the subagent's agent id). Claude Code keeps the shell's directory between calls, so after an agent's call that ran a cd or pushd (shell_reads_moved), its later relative reads are skipped and absolute ones kept. On the tool_result, a hook or permission denial marks the reads denied, an is_error result of a one-file, one-part read drops it (cli_traces._failed_read), and otherwise the reads get rc 0. The new logic sits in helpers, so events._apply drops from complexity 17 to 14. process_metrics is unchanged; the contract (4.1, 6, 7, 7.1) and the module docstrings say which shell reads count, now for all three harnesses. Item 7: no reach scenario measured whether a Bash read of a nested file loads its directory's guidance. `repo reach --scenarios bash` adds bash=<dir>, started at the root with the profile's new `bash` tool mode (claude-code: --tools Bash), telling the agent to `cat <dir>/REACH_PROBE.txt`. The default kinds stay root,cwd,read. A profile without a bash mode (codex, copilot) is a usage error, exit 2. harness_profile.v1 accepts the optional `tools.bash` key in place; it is left out of delegation.none, whose propertyNames schema_compat holds fixed. The receipt maps bash=<dir> as it maps read=<dir>. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
|
✅ OpenCodeReview: Review complete: 0 finding(s) across 31 selected item(s). This PR has reached its review limit, so only a re-request starts a review. Review details
|
There was a problem hiding this comment.
🟡 Changes recommended
Unquoted Bash paths can execute unintended commands, while unresolved shell-state tracking, denied-read filtering, and Windows path handling cause missed reads, false usage attribution, and test failures.
4 open issues
What changed in this PR
4 new findings.
This PR aligns Claude Code’s Bash file-read tracking with Codex and Copilot and adds an opt-in reach scenario to measure whether shell reads load nested guidance.
Changes:
- Derives read events from supported Bash commands, accounting for failures, denials, and directory changes.
- Adds
bash=<dir>reach scenarios, profile support, and early argument validation without changing defaults. - Adds regression tests and updates documentation, schema descriptions, and changelog fragments.
| File | Description |
|---|---|
dokimi/tests/test_repo_reach_bash.py |
Tests Bash scenarios, validation, classification, and receipts. |
dokimi/tests/test_repo_events_bash.py |
Tests Bash read conversion and directory tracking. |
dokimi/tests/test_batch_ledger_golden.py |
Pins the Bash-mode execution identifier. |
dokimi/scripts/growth_baseline.json |
Lowers the converter’s complexity baseline. |
dokimi/dokimi/schemas/harness_profile.v1.json |
Allows the optional Bash tool mode. |
dokimi/dokimi/schemas/effective_instructions.v1.json |
Clarifies the receipt target description. |
dokimi/dokimi/repos/receipt.py |
Updates the accessed-state docstring only. |
dokimi/dokimi/repos/reach.py |
Adds Bash scenarios, prompts, and profile validation. |
dokimi/dokimi/repos/profiles/claude-code.yaml |
Defines the Bash-only tool mode. |
dokimi/dokimi/repos/process_metrics.py |
Documents shell-read counting; logic unchanged. |
dokimi/dokimi/repos/events.py |
Derives and settles Claude Code Bash read events. |
dokimi/dokimi/repos/cli_traces.py |
Exposes shell directory-movement tracking. |
dokimi/dokimi/commands/repo.py |
Validates Bash scenarios and updates CLI help. |
dokimi/docs/reviewing.md |
Adds Bash reach-probe usage guidance. |
dokimi/docs/repo-evaluation-contract.md |
Documents shell reads and Bash scenario behavior. |
dokimi/changelog.d/20261102_120100_antonwolfy_reach-bash-scenario.md |
Announces the optional Bash reach scenario. |
dokimi/changelog.d/20261102_120000_antonwolfy_claude-bash-reads.md |
Records changed Claude Code read metrics. |
Review effort: Balanced
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #369 +/- ##
==========================================
+ Coverage 94.69% 94.71% +0.02%
==========================================
Files 332 334 +2
Lines 43409 43622 +213
Branches 7380 7414 +34
==========================================
+ Hits 41104 41318 +214
+ Misses 1468 1467 -1
Partials 837 837
🚀 New features to boost your workflow:
|
Review of intel#369 (comments 4443237154, 4443248498): shell_reads_moved() returned the walker's chain flag `moved`, which says a cd was seen, not that the shell the script runs in may have moved. `cd sub &`, `{ cd sub; } | cat`, `{ cd sub; } &` and `cd sub && make &` all came back as moved, so a later Bash call's `cat AGENTS.md` of the same agent lost its root read. _Walk now keeps `shell_moved` apart from `moved`: a cd, pushd or popd sets it only outside a subshell, and it is saved with the directory at the start of an && / || list and at a brace group's opening, and restored wherever the directory is (a backgrounded list, a piped or backgrounded group, and a list backgrounded at the end of the script). `moved` and the reads of Codex and Copilot commands are unchanged. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Review of intel#369 (comments 4443237165, 4443237182). Two orders of the stream-json trace gave wrong reads: - A permission refusal can come back as an `is_error` tool_result (`Permission to use Bash has been denied`) and only later as a `permission_denials` entry in the final result. The error result dropped the read of a one-file `cat AGENTS.md`, so the denial marked a detached event: the trace lost the denied read and `tool_calls` was one short. - A call's `cd` marked the agent's shell as moved at tool_use time, so a `cd sub` that a PreToolUse hook or the permissions refused still cost the next call's `cat AGENTS.md` its read. The converter now records each Bash call (its agent, order, named paths, whether it moves the shell, and ok / error / denied) and settles them all after the last line: a denied call moves nothing and keeps its reads, denied; a failed one-file read drops its read; after a call that was not denied and may have moved the shell, only absolute reads are kept; otherwise reads get rc 0. Read events are still inserted before their command event at tool_use time, and only the dropped ones are removed at the end, so every read keeps its place. events._apply stays at complexity 14. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Review of intel#369 (comment 4443248578): Transcript.reads(), which reach's classify and so the receipt's `read_by_tool` use, and arms.skill_usage took every read event, including denied ones and failed ones (rc 1). A refused `cat sub/AGENTS.md` made an already-loaded file `fetched` and its receipt `states.accessed` true, and a refused read of an installed SKILL.md counted the skill as used. The Read tool had the same bug before the Bash reads of this PR. Both now take only reads that showed the file (transcript.shown_read: not denied, rc 0 or no result), as process_metrics' guidance_read already did. The events stay in events.jsonl for audit. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Review of intel#369 (comments 4443237195, 4443248319): the bash= prompt put the probe path into the Bash command as it was. Repository directory names keep spaces and shell metacharacters, so guidance in `my package/AGENTS.md` asked for `cat my package/REACH_PROBE.txt`, which reads `my` and `package/REACH_PROBE.txt`, and a `$` or `;` in the name changed what the command does. The operand is now reach.shell_word(path): shlex.quote of the path, with `./` in front of a path that starts with `-`. Scenario.read keeps the raw path, and the Read-tool prompt is unchanged. The fake harness of test_repo_reach_bash.py parses the quoted operand with shlex.split and runs the command as the prompt names it. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Review of intel#369 (comment 4443248422): the fake harness of test_repo_reach_bash.py built its Bash commands from str(Path). On Windows that gives `cat sub\AGENTS.md`, which the POSIX shell parser reads as `subAGENTS.md`, so the read-event and fetched assertions would fail there. The probe command is the prompt's own operand (since the quoting fix), and the command that cats the directory's AGENTS.md is built with as_posix() and shell-quoted. Test-only change. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
|
Review round 1 (Copilot and OpenCodeReview, 8 comments, 6 points), one commit per point:
CI is green at 261b310. The live Haiku probe was re-run at 261b310 with a |
`cd sub && { echo a; echo b; } &` backgrounds the whole && list, so the
shell does not move, yet shell_reads_moved returned moved=True: the `;`
inside the group overwrote the saved start of the enclosing list
(`list_start` / `listed`), and the final `&` restored the state after
`cd sub` instead of the state before it. A following Claude Code Bash
`cat AGENTS.md` then lost its root read.
A brace group now saves the enclosing list's state when it opens and
restores it when it closes, so the group is one command of that list.
`cd sub && { cd x; cat a; } &` moves nothing either, while
`cd sub && { echo a; }` (not backgrounded) still moves. The same fix
makes `cd x && { cat f; cat h; } & cat g` read `g`, not `x/g`.
Settles review comment 4443584029 on intel#369.
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
`cd "$X" &` and `cd sub | cat` run their `cd` in a subshell, yet shell_reads_moved returned moved=True: a computed part (an expansion, a pipe or a redirect) is not split into words, and `_Walk.words` called `lose()` for its `cd` without knowing the part was backgrounded or piped. A following Claude Code Bash `cat AGENTS.md` then lost its read. `words` now takes the part's operator after it; for a backgrounded part or a pipeline (an unquoted, unescaped `|`), `lose()` still drops the directory for the rest of the script but does not mark the shell moved. `cd "$X" &`, `cd "$X" | cat`, `cd sub | cat` and `echo | cd sub` move nothing; `cd "$X"`, `cd "$X" && make`, `cd $(pwd)` and `cd a\|b` still move. Settles review comment 4443584037 on intel#369. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
|
Review round 2 (OpenCodeReview, 2 comments):
CI is green at 444aec9. The description is updated. |
vlad-perevezentsev
left a comment
There was a problem hiding this comment.
Review at 444aec9. The Bash reads, their settlement at the end of the trace and the bash= scenario look right, and every earlier review thread is fixed. One open point: the moved-shell state is kept per agent, while Claude Code's subagents likely share the parent's Bash directory; two smaller gaps are in the line comments.
What I ran, at 444aec9:
make ci-local --offline: 0 failed, 19 passed, 13 skipped (network and Docker steps).shell_reads_movedon about 20 forms:cd sub | cat,pushd sub &,popd | cat,(cd sub) && cat AGENTS.mdandcd sub & cat AGENTS.mdmove no shell;cd sub || exit 1,if true; then cd sub; fi,cd -,command cd subdo.time cd suband a function that runscdare not seen as moves; they are rare in agent traces.converton hand-written traces: a parentcd subfollowed by a subagentcat AGENTS.mdrecords rootAGENTS.md(see the line comment onevents.py).- On Windows (Server 2025, Python 3.13):
pytest tests/test_repo_reach_bash.py tests/test_repo_events_bash.py: 73 passed, 0 skipped, so the POSIX-path fix in 261b310 holds there.
Checked, no issues
- Earlier threads: the backgrounded and piped
cdcases, the brace group inside a list, the computedcd, the latepermission_denialsentry, the deniedcd, the quoted probe path and the denied reads in reach and skill use are all fixed as the replies say. - Schemas:
harness_profile.v1only gains an optionalbashkey andeffective_instructions.v1only a description, so both are widening edits. - Docs and changelog: the contract rows, §7 and the reach section match the code; both new test files and the fragments have the SPDX header.
napetrov
left a comment
There was a problem hiding this comment.
Two blocking, one question, inline. Already on main, not from this PR (follow-ups, not blockers): a denied Skill call still counts as used (arms.skill_usage), and a failed (rc 1) read of a patch: arm file still counts in uptake.reads. Still open from earlier: discussion_r4445790 (subagent shell sharing).
Coverage: plain + edge passes, probes under the head tree; the full suite and context/consistency.py consumers were not checked.
The Claude Code converter kept the "shell may have moved" state per agent: after the parent ran `cd sub`, a subagent's `cat AGENTS.md` still gave a read of the root AGENTS.md (and the reverse). Claude Code keeps the Bash directory as process state, and a Task subagent may well share it, so such a read may name a file the agent was never shown. `_settle_bash` now keeps one flag for the whole trace: once any non-denied call may have moved the shell, every agent's later relative reads are dropped, absolute ones kept. This only drops reads, so it is safe whether subagents share the parent's shell or not. The per-agent field of `_BashCall` goes. The test that pinned the per-agent model is replaced by one test per direction (parent cd, then a subagent's read; subagent cd, then the parent's). The contract's guidance_read row, the `fetched` bullet and the "cannot show" list say so. Settles review comment 4445790393 on intel#369. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
arms.skill_usage skipped a refused or failed SKILL.md read, but its `Skill` branch had no such check: a Skill tool call that a hook or the permissions refused (`denied`) still put the skill in `skills_used` and made `skill_invoked` true, though the skill never ran. The loop now skips a denied or failed event (`rc` not 0 or None) at the top, before either branch. The same class of bug was in uptake.arm_uptake: it skipped denied events, but a read of a file a `patch:` arm added that failed (`rc` 1, no such file) still counted in `uptake.reads`; it now takes only reads that showed the file (transcript.shown_read). Tests: a hook-denied and a permission-denied Skill call, and a failed read of an added file. The contract's skills_used and uptake text say so. Settles review comment 4445790411 on intel#369. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
`_failed_read` dropped the read of a failed command only when the command was one part. `cd sub && cat AGENTS.md` with no sub/AGENTS.md exits 1, yet its read of sub/AGENTS.md stayed with `rc` 0, so guidance_read, skills_used and reach's `fetched` counted a file the agent never saw. A one-file read after `cd` parts now counts as one read part: a non-zero exit means the read did not run (`cd sub && cat f`, the cd failed) or ran and failed (the exit after `;`, `&` or `||` is the read's), and either way the file was not shown. A list whose other part is not a `cd` (`make && cat f`, `cat f && cd sub`) keeps its read, as the exit does not say which part failed. The rule is shared, so a Codex or Copilot command gets the same fix. Tests: the reviewer's probe and five more cd forms, three lists that keep their read, and a Copilot row. The contract's guidance_read row and `fetched` bullet say so. Settles review comment 4446227568 on intel#369. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
After a `cd sub` that ran, a hook-refused `cat AGENTS.md` lost its read event: `_settle_bash` checked "the shell may have moved, so a relative read is not known" before it checked the denial, and dropped the read. Only the command event was left, so `tool_calls` was one short, and the module docstring's promise that a refused call's reads stay, `denied`, did not hold. The denial is now checked first. A refused call ran nothing, so its reads stay, `denied`, after a move too; a relative path is then taken from the start directory, and guidance_read, reach and skill use skip a denied read anyway. Test: a `cd sub`, then a `cat AGENTS.md` refused by a hook or by `permission_denials`, keeps the denied read, counts 3 tool calls and gives no guidance_read. Settles review comment 4446227577 on intel#369. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
`shell_reads_moved` did not set `moved` for `eval "cd sub"`, `source env.sh` or `time cd sub`, so after such a Claude Code Bash call a later relative `cat AGENTS.md` was still recorded as a read of the root AGENTS.md, though the shell may be elsewhere. `eval`, `source` and `.` now lose the directory as `pushd` / `popd` do: they may run a `cd` that is not seen, so the shell may have moved (and within one call, a relative read after them is not recorded). `time` before a directory builtin is skipped like `builtin` and `command`, so `time cd sub` is a `cd` (`command cd sub` already was). The parser is shared, so a Codex or Copilot command follows the same rule within the command. A `cd` in a shell function defined and then called is still not seen; the contract's "cannot show" list says so, and its guidance_read row and `fetched` bullet name the new forms. Tests: each form's `moved`, a later call's relative read, and reads within the call. Settles review comment 4446227585 on intel#369. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
|
Round for @vlad-perevezentsev's and @napetrov's reviews, at ff48bb1 (CI green), one commit per point:
|
Review point 4458839235 on intel#369: result.json's process block has carried unknown_reads since 226a04c, but process_counts.PROCESS_KEYS did not list it, so process_values({"process": {"guidance_read": [], "unknown_reads": 3}}) gave only process:guidance_read, and a batch whose every cell sourced a venv before cat AGENTS.md reported a guidance_read mean of 0 and nothing else. reviewing.md already named process:unknown_reads, which neither command printed. PROCESS_KEYS now lists unknown_reads right after guidance_read, so repo report's process line and repo compare's descriptive measures carry it in the contract's §4.1 order. The tests pin process_values on the reviewer's input, the report line and the compare row order. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Review point 4458839251 on intel#369: three forms of the round-9 case and time handling kept a read at a wrong path or a hidden one. - time X=1 cd sub; cat AGENTS.md gave (['AGENTS.md'], False, 0), but bash's time keyword times the whole simple command, so the cd runs in this shell. A time prefix may now be followed by NAME=value words before the directory builtin: shell_words._prefix_runs_dir where _command_words skips the prefix, and _command_at past time's options (so time -p X=1 cd sub counts as a move). It now gives (['sub/AGENTS.md'], True, 0); command X=1 cd and builtin X=1 cd, which run no cd, are unchanged. - case $x in a) case $y in b) :;; esac; cd sub;; esac; cat AGENTS.md read sub/AGENTS.md: the inner esac ended the outer case. Inside a case each case word now opens one more level and only a leading esac closes one, so a miscount stays inside (reads dropped). It now gives ([], True, 1). - { cat AGENTS.md; { case $x in a) :;; esac; }; } > /dev/null kept AGENTS.md: in_case returned before track_groups, so the brace opened on the case part was not tracked and the outer group's redirect closed nothing. A case part now tracks its braces first (before the lose() its builtin causes), giving ([], False, 0). The contract's limits and the changelog fragment name the assignments after time. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Settles review comment 4459651043 on intel#369. A shell command that names one file at a directory the walker cannot follow (`cd -; cat missing.md`) added 1 to `process.unknown_reads` when it started, and its failure never took it back, although the same failure drops a known read's event. `_failed_read` now decides from the printer's own operand count also when no path was resolved, and each converter settles a command's unknown count with its result: Codex keeps it with the started command's derived reads and subtracts it on a failed one-file completion (a completion without a start adds it only when it did not fail), Copilot keeps it per call id and subtracts it on such a result, and the Claude Code Bash settlement adds it only for calls that neither were refused nor failed as one read. The contract's §4.1 `unknown_reads` row says that a failed one-file read counts none. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Settles review comment 4459651056 on intel#369. In `> /tmp/cd.log cd sub` the `cd` runs in the shell, but the command-word lookup took `>` as the command, so the move was missed and a later Bash `cat AGENTS.md` was recorded as the root file's read. `_command_words`, `_command_at` and `_prefix_runs_dir` now skip leading redirections (`>`, `>>`, `<`, `&>`, `2>err`, `{fd}>x`; the target attached or the next word), alongside the `NAME=value` words they already skip, and also after `builtin`, `command` and `time`. Such a part is still not followed (as `cd sub > log` is not), so the shell moves to a directory not known: within the call the next relative read is dropped and counted in `unknown_reads`, and so is the next call's. A redirected `cd` in a job or a pipeline still moves nothing, and `> log time cd sub` stays /usr/bin/time's child. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Settles review comment 4459886479 on intel#369. `_command_at` skipped a `builtin`, `command` or `time` prefix's options only before the redirections that follow it, so in `command > /tmp/cd.log -p cd sub` the command word came out as `-p`: the cd was missed, and a later relative Bash read was recorded at the start directory. `_past_prefix` now skips the prefix's options, redirections and, past `time`, `NAME=value` words in one loop, in any order, until the command word. Such a cd still loses the directory and moves the shell, as a redirected cd does since the previous commit; `command > log -v cd` still runs nothing. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
|
Rounds 12-14 on 8b08665, head now e8c06f0:
Two drop-only forms from round 12 are added to #374: a
|
napetrov
left a comment
There was a problem hiding this comment.
Only findings not already raised on this PR. Found by automated haiku review passes, deduped against all existing threads; the one inline item was reproduced by a probe on head and on the merge base.
Other candidates were dropped as already raised (case-word over-count, now tracked in #374) or as documented behaviour (a failed read followed by a passing part keeps rc 0).
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
…step A derived read event is one more event, so a run that prints a file through Bash before its first useful result shows one step more. The changelog says so and a test pins it. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
repo reach runs the shell on the host, so a probed CLAUDE.md could drive any command. claude-code's bash mode adds a PreToolUse hook that blocks all but DOKIMI_REACH_COMMAND, and fails closed. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
… caps The reach flag check moves to reach.check_flags; the Bash first_useful test joins test_repo_events_bash.py. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
|
Security fix for
The problem. The bash scenario starts Claude Code on the host with The fix. The Options considered:
Tests (red with the profile as at 2f5dfd3, green now):
Three mutations of the profile each turn the gate tests red: the 2f5dfd3 profile, the profile without End to end, Run on the head (5aab99f, Python 3.13):
Other changes:
Still open:
|
|
@xaleryb thanks, this closes a real hole. I checked 21e0690..5aab99f:
On the open points: |
`! cat AGENTS.md` of a missing file exits 0, so it was read with rc 0; its exit no longer says whether the file was printed, so it gives no read in any harness. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
! cat AGENTS.md at a directory not known gave no read but counted one unknown read, as a pipe does not. Skip the printer before it is counted, and name the negated read in the contract and reviewing.md as the changelog does. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
intel#369) * fix(repo): Claude Code Bash reads count as reads; bash= reach scenario Settles two points of an audit of a `dokimi repo run` replay on IntelPython/dpnp#3099. Item 6: under Claude Code, guidance_read counted the Read tool only. In the replay 17 of 78 guidance cells printed an AGENTS.md with Bash (`cd benchmarks; cat AGENTS.md`) and all had guidance_read []. The claude-stream-json converter now runs cli_traces.shell_reads on each Bash command, as the Codex and Copilot readers do since intel#116, and emits a read event per printed file before the command event (same t, the subagent's agent id). Claude Code keeps the shell's directory between calls, so after an agent's call that ran a cd or pushd (shell_reads_moved), its later relative reads are skipped and absolute ones kept. On the tool_result, a hook or permission denial marks the reads denied, an is_error result of a one-file, one-part read drops it (cli_traces._failed_read), and otherwise the reads get rc 0. The new logic sits in helpers, so events._apply drops from complexity 17 to 14. process_metrics is unchanged; the contract (4.1, 6, 7, 7.1) and the module docstrings say which shell reads count, now for all three harnesses. Item 7: no reach scenario measured whether a Bash read of a nested file loads its directory's guidance. `repo reach --scenarios bash` adds bash=<dir>, started at the root with the profile's new `bash` tool mode (claude-code: --tools Bash), telling the agent to `cat <dir>/REACH_PROBE.txt`. The default kinds stay root,cwd,read. A profile without a bash mode (codex, copilot) is a usage error, exit 2. harness_profile.v1 accepts the optional `tools.bash` key in place; it is left out of delegation.none, whose propertyNames schema_compat holds fixed. The receipt maps bash=<dir> as it maps read=<dir>. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a cd in a job or pipeline does not move the Bash shell Review of intel#369 (comments 4443237154, 4443248498): shell_reads_moved() returned the walker's chain flag `moved`, which says a cd was seen, not that the shell the script runs in may have moved. `cd sub &`, `{ cd sub; } | cat`, `{ cd sub; } &` and `cd sub && make &` all came back as moved, so a later Bash call's `cat AGENTS.md` of the same agent lost its root read. _Walk now keeps `shell_moved` apart from `moved`: a cd, pushd or popd sets it only outside a subshell, and it is saved with the directory at the start of an && / || list and at a brace group's opening, and restored wherever the directory is (a backgrounded list, a piped or backgrounded group, and a list backgrounded at the end of the script). `moved` and the reads of Codex and Copilot commands are unchanged. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): settle Claude Code Bash reads once the trace ends Review of intel#369 (comments 4443237165, 4443237182). Two orders of the stream-json trace gave wrong reads: - A permission refusal can come back as an `is_error` tool_result (`Permission to use Bash has been denied`) and only later as a `permission_denials` entry in the final result. The error result dropped the read of a one-file `cat AGENTS.md`, so the denial marked a detached event: the trace lost the denied read and `tool_calls` was one short. - A call's `cd` marked the agent's shell as moved at tool_use time, so a `cd sub` that a PreToolUse hook or the permissions refused still cost the next call's `cat AGENTS.md` its read. The converter now records each Bash call (its agent, order, named paths, whether it moves the shell, and ok / error / denied) and settles them all after the last line: a denied call moves nothing and keeps its reads, denied; a failed one-file read drops its read; after a call that was not denied and may have moved the shell, only absolute reads are kept; otherwise reads get rc 0. Read events are still inserted before their command event at tool_use time, and only the dropped ones are removed at the end, so every read keeps its place. events._apply stays at complexity 14. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a refused or failed read is no read in reach or skill use Review of intel#369 (comment 4443248578): Transcript.reads(), which reach's classify and so the receipt's `read_by_tool` use, and arms.skill_usage took every read event, including denied ones and failed ones (rc 1). A refused `cat sub/AGENTS.md` made an already-loaded file `fetched` and its receipt `states.accessed` true, and a refused read of an installed SKILL.md counted the skill as used. The Read tool had the same bug before the Bash reads of this PR. Both now take only reads that showed the file (transcript.shown_read: not denied, rc 0 or no result), as process_metrics' guidance_read already did. The events stay in events.jsonl for audit. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): shell-quote the probe path in the bash= reach prompt Review of intel#369 (comments 4443237195, 4443248319): the bash= prompt put the probe path into the Bash command as it was. Repository directory names keep spaces and shell metacharacters, so guidance in `my package/AGENTS.md` asked for `cat my package/REACH_PROBE.txt`, which reads `my` and `package/REACH_PROBE.txt`, and a `$` or `;` in the name changed what the command does. The operand is now reach.shell_word(path): shlex.quote of the path, with `./` in front of a path that starts with `-`. Scenario.read keeps the raw path, and the Read-tool prompt is unchanged. The fake harness of test_repo_reach_bash.py parses the quoted operand with shlex.split and runs the command as the prompt names it. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * test(repo): POSIX paths in the fake Bash commands of the bash= tests Review of intel#369 (comment 4443248422): the fake harness of test_repo_reach_bash.py built its Bash commands from str(Path). On Windows that gives `cat sub\AGENTS.md`, which the POSIX shell parser reads as `subAGENTS.md`, so the read-event and fetched assertions would fail there. The probe command is the prompt's own operand (since the quoting fix), and the command that cats the directory's AGENTS.md is built with as_posix() and shell-quoted. Test-only change. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a brace group's separators do not end the list around it `cd sub && { echo a; echo b; } &` backgrounds the whole && list, so the shell does not move, yet shell_reads_moved returned moved=True: the `;` inside the group overwrote the saved start of the enclosing list (`list_start` / `listed`), and the final `&` restored the state after `cd sub` instead of the state before it. A following Claude Code Bash `cat AGENTS.md` then lost its root read. A brace group now saves the enclosing list's state when it opens and restores it when it closes, so the group is one command of that list. `cd sub && { cd x; cat a; } &` moves nothing either, while `cd sub && { echo a; }` (not backgrounded) still moves. The same fix makes `cd x && { cat f; cat h; } & cat g` read `g`, not `x/g`. Settles review comment 4443584029 on intel#369. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a computed cd in a job or a pipeline moves no Bash shell `cd "$X" &` and `cd sub | cat` run their `cd` in a subshell, yet shell_reads_moved returned moved=True: a computed part (an expansion, a pipe or a redirect) is not split into words, and `_Walk.words` called `lose()` for its `cd` without knowing the part was backgrounded or piped. A following Claude Code Bash `cat AGENTS.md` then lost its read. `words` now takes the part's operator after it; for a backgrounded part or a pipeline (an unquoted, unescaped `|`), `lose()` still drops the directory for the rest of the script but does not mark the shell moved. `cd "$X" &`, `cd "$X" | cat`, `cd sub | cat` and `echo | cd sub` move nothing; `cd "$X"`, `cd "$X" && make`, `cd $(pwd)` and `cd a\|b` still move. Settles review comment 4443584037 on intel#369. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): one moved Bash shell for the parent and its subagents The Claude Code converter kept the "shell may have moved" state per agent: after the parent ran `cd sub`, a subagent's `cat AGENTS.md` still gave a read of the root AGENTS.md (and the reverse). Claude Code keeps the Bash directory as process state, and a Task subagent may well share it, so such a read may name a file the agent was never shown. `_settle_bash` now keeps one flag for the whole trace: once any non-denied call may have moved the shell, every agent's later relative reads are dropped, absolute ones kept. This only drops reads, so it is safe whether subagents share the parent's shell or not. The per-agent field of `_BashCall` goes. The test that pinned the per-agent model is replaced by one test per direction (parent cd, then a subagent's read; subagent cd, then the parent's). The contract's guidance_read row, the `fetched` bullet and the "cannot show" list say so. Settles review comment 4445790393 on intel#369. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a refused Skill call is no use of the skill arms.skill_usage skipped a refused or failed SKILL.md read, but its `Skill` branch had no such check: a Skill tool call that a hook or the permissions refused (`denied`) still put the skill in `skills_used` and made `skill_invoked` true, though the skill never ran. The loop now skips a denied or failed event (`rc` not 0 or None) at the top, before either branch. The same class of bug was in uptake.arm_uptake: it skipped denied events, but a read of a file a `patch:` arm added that failed (`rc` 1, no such file) still counted in `uptake.reads`; it now takes only reads that showed the file (transcript.shown_read). Tests: a hook-denied and a permission-denied Skill call, and a failed read of an added file. The contract's skills_used and uptake text say so. Settles review comment 4445790411 on intel#369. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a failed cd-then-read command is no read `_failed_read` dropped the read of a failed command only when the command was one part. `cd sub && cat AGENTS.md` with no sub/AGENTS.md exits 1, yet its read of sub/AGENTS.md stayed with `rc` 0, so guidance_read, skills_used and reach's `fetched` counted a file the agent never saw. A one-file read after `cd` parts now counts as one read part: a non-zero exit means the read did not run (`cd sub && cat f`, the cd failed) or ran and failed (the exit after `;`, `&` or `||` is the read's), and either way the file was not shown. A list whose other part is not a `cd` (`make && cat f`, `cat f && cd sub`) keeps its read, as the exit does not say which part failed. The rule is shared, so a Codex or Copilot command gets the same fix. Tests: the reviewer's probe and five more cd forms, three lists that keep their read, and a Copilot row. The contract's guidance_read row and `fetched` bullet say so. Settles review comment 4446227568 on intel#369. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a refused Bash read after a cd stays, denied After a `cd sub` that ran, a hook-refused `cat AGENTS.md` lost its read event: `_settle_bash` checked "the shell may have moved, so a relative read is not known" before it checked the denial, and dropped the read. Only the command event was left, so `tool_calls` was one short, and the module docstring's promise that a refused call's reads stay, `denied`, did not hold. The denial is now checked first. A refused call ran nothing, so its reads stay, `denied`, after a move too; a relative path is then taken from the start directory, and guidance_read, reach and skill use skip a denied read anyway. Test: a `cd sub`, then a `cat AGENTS.md` refused by a hook or by `permission_denials`, keeps the denied read, counts 3 tool calls and gives no guidance_read. Settles review comment 4446227577 on intel#369. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): eval, source and time cd move the Bash shell too `shell_reads_moved` did not set `moved` for `eval "cd sub"`, `source env.sh` or `time cd sub`, so after such a Claude Code Bash call a later relative `cat AGENTS.md` was still recorded as a read of the root AGENTS.md, though the shell may be elsewhere. `eval`, `source` and `.` now lose the directory as `pushd` / `popd` do: they may run a `cd` that is not seen, so the shell may have moved (and within one call, a relative read after them is not recorded). `time` before a directory builtin is skipped like `builtin` and `command`, so `time cd sub` is a `cd` (`command cd sub` already was). The parser is shared, so a Codex or Copilot command follows the same rule within the command. A `cd` in a shell function defined and then called is still not seen; the contract's "cannot show" list says so, and its guidance_read row and `fetched` bullet name the new forms. Tests: each form's `moved`, a later call's relative read, and reads within the call. Settles review comment 4446227585 on intel#369. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * docs(repo): tool_calls counts kept shell reads, denied ones too The contract's tool_calls row and the process_metrics docstring said it counts the read event of each file a shell command printed. It counts every event: a refused `cat AGENTS.md` keeps a denied read, and a failed `cat AGENTS.md missing.md` keeps a read for both paths, since the exit code does not say which file failed. Say so, so the count is not taken for evidence that a file was shown. Settles review comment 4446703136 on intel#369. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a failed Skill call is no use of the skill skill_usage skips an event with a non-zero rc, but the Claude Code converter set rc on read and command events only. A Skill call whose tool_result is an error ("Skill lint could not be loaded") with no hook refusal or permission denial stayed a plain tool event, so the skill counted as used, invoked and correctly selected. _apply_result now sets rc (0, or 1 on an error) on a Skill tool event as it does on a read. No other consumer reads rc on a tool event (uptake's mcp_calls, tool_calls, reach and the consistency check do not), so only skill usage changes. A permission-denied Skill call now carries rc 1 next to denied, as a permission-denied command does. Settles review comment 4446703155 on intel#369. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a cd $(...) in a pipeline moves no Bash shell A part holding a command substitution goes down the walker's subshell branch, which marked the shell moved unless the part was backgrounded. So `cd $(pwd) | cat` set shell_moved although the cd ran in a pipeline stage, and the next Claude Code Bash call's `cat AGENTS.md` lost its relative read. Both branches that see a directory builtin with a computed operand now ask one helper, _in_own_shell, whether the part runs in the script's shell: not backgrounded, and holding no pipe outside quotes, escapes, $( ... ) and backticks (so `cd $(echo a | tr a b)` still moves). The computed-word branch of _Walk.words reuses _runs_dir_before_parens for its builtin test, which is the same test on a part without parentheses. Settles review comment 4446703160 on intel#369. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a failed time cd-then-read command is no read _failed_read drops the read of a failed command that is one read of one file after cd parts, but took a part for a cd only when its first word was `cd`. The shell walker also runs a cd after `time`, `command`, `builtin` or a NAME=value word, so a failing `time cd sub && cat AGENTS.md` kept its sub/AGENTS.md read at rc 0, and guidance, skill use, reach access and uptake counted a file that was never shown. The test now goes through _command_words, the walker's own helper, so the two agree: `time cd`, `command cd`, `builtin cd` and `X=1 cd` are a cd, and `env cd` (no builtin runs) is not, as before. The helper is shared by the Codex and Copilot readers too. Settles review comment 4446703173 on intel#369. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): follow a brace group past the $( ) and groups in it Settles the review point that `{ cd $(pwd); } | cat` and `{ cd $(pwd); } &` moved the Bash shell: the part holding `$( )` was skipped as a subshell before its `{` was seen, so the group's pipe or `&` had nothing to restore, and a later relative `cat AGENTS.md` was dropped. The splitter now gives each part its outer text, the text outside any `( ... )` or backticks. The walker tracks brace groups on that text before it skips a subshell part, and tells a `cd $(...)` by it. So: - a group whose first command holds `$( )` or backticks, or whose closing part does (`{ cd sub; } | grep $(x)`), opens and closes as any group; - a `cd` inside `$( )`, backticks or `( ... )` moves nothing here: `(true; cd sub)` and `echo $(true; cd sub; pwd)` took the inner `cd` as this shell's (the row for the backtick form is changed to the read it now keeps); - every brace of a part counts, in order: `{ { cd sub; }; }` moves the shell, `{ { cat a; }; }` reads `a`, and `{ cat a; } | { cat b; }` drops `a`, whose output feeds the pipe. The splitter moves to repos/shell_split.py, as cli_traces.py was at its line cap. The parser is shared, so Codex and Copilot commands change in the same way. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a directory builtin in a job or a pipe keeps the directory Settles the review point that `source env.sh & cat AGENTS.md` gave no read: a backgrounded `source` cannot change the foreground shell's directory, yet the walker forgot it, and the same held for a backgrounded or piped `eval`, `.`, `pushd`, `popd` and computed `cd` (`cd "$X" &`, `cd $(pwd) | cat`). `_Walk.lose()` had two meanings: "a directory builtin went somewhere unknown" and "the next chain starts where a gated cd may have left it". It now means the first. A builtin backgrounded by `&` or run as a pipeline stage ran in a subshell, so it leaves this shell's directory and its movement flag alone, and the reads after it in the same command are kept. The gated case sets the directory unknown itself, as before. A builtin in this shell (`source env.sh; cat AGENTS.md`, `pushd sub > /dev/null`) still loses the directory. The rule is the shared parser's, so it holds for Codex and Copilot commands too. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a failed read naming two files keeps the one it knows Settles the review point that `_failed_read` decided "one file" from the number of derived reads. The walker leaves out a relative operand when its directory is not known, so in `/ws` a failed `cd /ws/sub; cd -; cat missing.md /ws/AGENTS.md` gave one read, `/ws/AGENTS.md`, and dropped it, though cat printed the guidance and exited 1 for the missing file. The walker now records how many files the last printing command named (its operands, past the options and the prefix words the walker skips), and `_failed_read` drops the reads only when that count is one. A failed `cd -; cat /ws/AGENTS.md` still gives no read. The rule is shared: the new rows cover a Claude Code Bash call, a Codex command and a Copilot bash command. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a builtin in a job or a pipe leaves a later || its directory Settles the review point that `source env.sh & false || cat AGENTS.md` gave no read: the backgrounded `source` kept the foreground directory but still set the walker's chain flag `moved`, so the `||` branch after it dropped the directory. A piped `source env.sh | false || ...`, a backgrounded `cd`, `pushd`, `eval` or `.`, a computed `cd` in a job or a pipe, and a backgrounded or piped brace group or list did the same. A directory builtin now sets `moved` only when it runs in this shell, as `shell_moved` was already set. The flag is saved and restored with the directory and `shell_moved` (one `_Spot`) at a backgrounded list and at a piped or backgrounded brace group. A builtin in this shell (`source env.sh; false || cat AGENTS.md`) still loses the directory for the `||` branch. The parser is shared, so this holds for Codex and Copilot commands too. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a quoted or escaped directory builtin name is the builtin Settles the review point that `"source" "$ENV"` moved nothing: a part with an expansion is not split by shlex, and the walker matched its blank-split first word, `"source"` with its quotes, against the builtins. A quoted or escaped `cd`, `pushd`, `popd`, `eval` or `.` with a computed operand (`'cd' "$D"`, `\eval "$X"`, `c"d" "$D"`), and a quoted `builtin`, `command`, `time` or `env` before one, were missed the same way, so a later relative read in the same command or the next Claude Code Bash call was taken at the old directory. The failed-read rule's `cd` test had the same gap: a failed `"cd" sub && cat AGENTS.md` kept its read. A new `_unquoted` helper removes each blank-split word's quotes and backslash escapes, as shlex does, without expanding anything (`"$D"` stays `$D`). `_runs_dir` and `_is_cd` pass their words through it before `_command_words` skips the prefixes; the words of a part shlex does split are already unquoted, so they are not unquoted twice (`'\cd'` names a program `\cd`, not the builtin). Keywords are still matched before unquoting: a quoted `"if"` is no keyword. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a directory builtin the parser does not follow moves it Settles the review points that `X="a b" cd "$D"` and `time -p cd sub` moved nothing. The walker matched the builtins on blank-split words for a part with an expansion, so an assignment holding a blank hid the `cd`, and it skipped `time`, `command` or `builtin` only right before a builtin's name, so an option between them (`time -p`, `command -p`, `builtin --`) hid it too. A later relative read in the same command or in the next Claude Code Bash call was then taken at the old directory. Rather than one more special case per shape, one conservative fallback: `_runs_dir` now reads a part's words with shlex where it can split them (so an assignment with a blank is skipped like any other), and a new `_may_run_dir` counts a part as moving the shell when a `builtin`, `command` or `time` the walker did not skip is followed by a directory builtin's name anywhere in the part. Text shlex cannot split is not understood at all, so any builtin's name in it counts. The forms the walker does follow are unchanged: `cd sub`, a backgrounded or piped one, a subshell. The failed-read rule is left as it was: a part before the read that is not surely a `cd` keeps the read on a non-zero exit, as its exit may be the one that failed. Its docstring says so. The contract's limits list states the rule and points at the drop-only forms tracked in intel#374. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): only the command word can name a directory builtin Settles the review point that the fallback for parts the parser does not fully follow fired on a builtin's name anywhere in the part. `time find . -name x`, `time git add .`, `command -v cd` or `builtin printf '%s' .` marked the shell as moved, so every later relative Bash read of the trace was dropped. `_may_run_dir` and `_runs_dir` now look at the command word only: past `builtin`, `command` and `time` with their options (`time -p`, `command -p`, `--`), the next word moves the shell only if it is a directory builtin. `command -v` / `-V` describes a command and runs nothing. For text shlex cannot split, the words are unquoted one by one and the same command word counts; when a quote up to it does not close (`X="a b c" cd "$D`), any builtin's name in the part still counts. `time -p cd sub`, `command -p cd sub` and `builtin -- cd sub` still move. The contract's limits list and the changelog fragment state the narrowed rule. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * refactor(repo): move a shell command's word helpers to shell_words cli_traces.py is at its 800-line cap, and the next fixes to how the walker finds a part's command word need room. The helpers that find the command word past `NAME=value`, `builtin`, `command`, `time` and `env`, and that tell whether it runs a directory builtin (`_command_words`, `_command_at`, `_runs_dir`, `_may_run_dir`, `_is_cd`, `_run_name`, `_cd`, `_unquoted` and their constants), move as they are to a new module beside it, `repos/shell_words.py`. No code changes; architecture.md names the module. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): time is the keyword only as the first unquoted word Settles the review point that `X=1 time cd sub; cat a` and `\time cd sub; cat a` read `sub/a` and moved the shell. Bash takes `time` as its keyword only as the first word of a command, unquoted; after an assignment, or quoted or escaped, it is /usr/bin/time, which runs the `cd` in a child process, so the shell stays and `cat a` reads `a`. The walker now skips `time` before a directory builtin, and as a prefix with options, only when the part's raw text starts with the keyword (`_time_keyword`); `_is_cd` follows it, so a failed `X=1 time cd sub && cat AGENTS.md` keeps its read like any part that is not surely a `cd`. The same point found the reverse in `case x in a) cd sub;; esac`, which moved nothing. A `case ... esac` is a compound the walker does not follow: from its `case` part to its `esac`, a part naming a directory builtin counts as moving the shell, and the reads in it are not kept, as an arm may or may not run. Later arms and arms on their own lines count too. The contract's limits list and the changelog fragment say both. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a brace group holding one read is that read Settles the review point that a failing `{ cat AGENTS.md; }`, alone or after `cd sub &&`, kept its read at rc 0, so `guidance_read`, reach and uptake counted a file the agent never saw. `_failed_read` took the group's `{` and `}` as parts of their own: the `}` was the last part, and the read before it was not a `cd`, so the failure could have been another part's. The braces are no longer parts there: a `}` part is skipped and the `{` that opens a group is stripped, so a group holding one read is that read, after any `cd` (`cd sub && { cat AGENTS.md; }`, nested groups too). A group holding another command (`{ make; cat AGENTS.md; }`) keeps its read, as before. The rule is shared, so Codex and Copilot commands get it too; a test each. §4.1, §7 and the changelog fragment say so. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a refused relative read after a move has no path Settles the review question about `cd sub` followed by a refused `cat AGENTS.md`: the denied read was kept with `AGENTS.md`, the path from the start directory, although the shell was then in `sub` or somewhere not known. The counts skip a denied read, but `events.jsonl`, `tool_calls` and the `Read` steps of `compare` / `consistency` carried a wrong path. The read stays, as a call the agent made, but with no `path`: `{"kind": "read", "denied": true}`. An absolute path in the same call keeps its own. Every consumer of read events already reads `path` with `.get` or skips a denied event first (`Transcript.reads()`, `guidance_read`, `skill_usage`, uptake, `run_from_events`, which gives a plain `Read` with no `file_path`); a test pins each. No schema describes the events. §4.1 says a read may have no path. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * feat(repo): count the shell reads at a directory not known Settles the review point that one `source .venv/bin/activate` drops every later relative Bash read of the run with nothing in `result.json` to say so: `guidance_read: []` then reads as "no guidance read", not "not known". `result.json` `process` gains `unknown_reads`, an int, `0` when there are none: the relative reads a trace made at a directory the converter could not follow, which give no read event. The walker counts the relative files a printing command names once its directory is lost in the command (`cd -; cat a`, `source env.sh && cat a`), and `shell_walk` returns that count with the reads and the move. The Codex and Copilot converters add it per command; the Claude Code converter adds it per Bash call that was not refused, plus each relative read it drops after an earlier call moved the shell (a failed one-file read is not counted: it failed). `Transcript.unknown_reads` carries it to `process_metrics`. No schema describes `process`; §4.1 documents the key next to `guidance_read`, and the changelog fragment gains an "Added" entry. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * test(repo): pin a failed cd continued over a line as no read Answers the review point that `_failed_read` splits the raw script and so would keep a failed `cd \<newline>sub && cat AGENTS.md`. It does not: `split_script` keeps a line continuation inside its part, so the first part is still the `cd`, and the failed read gives no read. No code change; the failed cd-then-cat test gains that form and `cd sub \<newline>&& cat AGENTS.md`, which no test covered. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a hidden group's reads are not counted as unknown Settles the review point that `process.unknown_reads` counted prints whose output never reached the agent. In `{ source env.sh; cat AGENTS.md; } > /dev/null`, or a group feeding a pipe, the walker drops the group's read events, as its output is not shown, but kept the count `printed()` had added for the group's relative files at a directory not known: `shell_walk` gave no read and `unknown=1`, which the three converters carry into `result.json`, so an empty `guidance_read` looked uncertain. An open brace group now saves the unknown count with its read mark, and the close that drops the group's reads rolls the count back to it. A nested hidden group rolls back only its own count, and a group whose output is shown keeps its count. Tests through `shell_walk` and through the Claude Code converter to `process_metrics`. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * docs(repo): reviewing.md says which shell reads guidance_read counts The "Delivery first" step that came in with intel#365 said that process:guidance_read counts Read-tool reads only. On this branch a Claude Code Bash call, Codex command or Copilot bash command that only prints files counts too, and process:unknown_reads counts the relative reads dropped after the shell moved. Say that, and keep the pointer to events.jsonl, agent.stdout or a process_metrics counter for the rest. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): repo report and compare show process:unknown_reads Review point 4458839235 on intel#369: result.json's process block has carried unknown_reads since 226a04c, but process_counts.PROCESS_KEYS did not list it, so process_values({"process": {"guidance_read": [], "unknown_reads": 3}}) gave only process:guidance_read, and a batch whose every cell sourced a venv before cat AGENTS.md reported a guidance_read mean of 0 and nothing else. reviewing.md already named process:unknown_reads, which neither command printed. PROCESS_KEYS now lists unknown_reads right after guidance_read, so repo report's process line and repo compare's descriptive measures carry it in the contract's §4.1 order. The tests pin process_values on the reviewer's input, the report line and the compare row order. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): time with assignments, nested case, braces on a case part Review point 4458839251 on intel#369: three forms of the round-9 case and time handling kept a read at a wrong path or a hidden one. - time X=1 cd sub; cat AGENTS.md gave (['AGENTS.md'], False, 0), but bash's time keyword times the whole simple command, so the cd runs in this shell. A time prefix may now be followed by NAME=value words before the directory builtin: shell_words._prefix_runs_dir where _command_words skips the prefix, and _command_at past time's options (so time -p X=1 cd sub counts as a move). It now gives (['sub/AGENTS.md'], True, 0); command X=1 cd and builtin X=1 cd, which run no cd, are unchanged. - case $x in a) case $y in b) :;; esac; cd sub;; esac; cat AGENTS.md read sub/AGENTS.md: the inner esac ended the outer case. Inside a case each case word now opens one more level and only a leading esac closes one, so a miscount stays inside (reads dropped). It now gives ([], True, 1). - { cat AGENTS.md; { case $x in a) :;; esac; }; } > /dev/null kept AGENTS.md: in_case returned before track_groups, so the brace opened on the case part was not tracked and the outer group's redirect closed nothing. A case part now tracks its braces first (before the lose() its builtin causes), giving ([], False, 0). The contract's limits and the changelog fragment name the assignments after time. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a failed one-file read adds nothing to unknown_reads Settles review comment 4459651043 on intel#369. A shell command that names one file at a directory the walker cannot follow (`cd -; cat missing.md`) added 1 to `process.unknown_reads` when it started, and its failure never took it back, although the same failure drops a known read's event. `_failed_read` now decides from the printer's own operand count also when no path was resolved, and each converter settles a command's unknown count with its result: Codex keeps it with the started command's derived reads and subtracts it on a failed one-file completion (a completion without a start adds it only when it did not fail), Copilot keeps it per call id and subtracts it on such a result, and the Claude Code Bash settlement adds it only for calls that neither were refused nor failed as one read. The contract's §4.1 `unknown_reads` row says that a failed one-file read counts none. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a leading redirection is not a cd's command word Settles review comment 4459651056 on intel#369. In `> /tmp/cd.log cd sub` the `cd` runs in the shell, but the command-word lookup took `>` as the command, so the move was missed and a later Bash `cat AGENTS.md` was recorded as the root file's read. `_command_words`, `_command_at` and `_prefix_runs_dir` now skip leading redirections (`>`, `>>`, `<`, `&>`, `2>err`, `{fd}>x`; the target attached or the next word), alongside the `NAME=value` words they already skip, and also after `builtin`, `command` and `time`. Such a part is still not followed (as `cd sub > log` is not), so the shell moves to a directory not known: within the call the next relative read is dropped and counted in `unknown_reads`, and so is the next call's. A redirected `cd` in a job or a pipeline still moves nothing, and `> log time cd sub` stays /usr/bin/time's child. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): a redirection before a prefix's options hides no cd Settles review comment 4459886479 on intel#369. `_command_at` skipped a `builtin`, `command` or `time` prefix's options only before the redirections that follow it, so in `command > /tmp/cd.log -p cd sub` the command word came out as `-p`: the cd was missed, and a later relative Bash read was recorded at the start directory. `_past_prefix` now skips the prefix's options, redirections and, past `time`, `NAME=value` words in one loop, in any order, until the command word. Such a cd still loses the directory and moves the shell, as a redirected cd does since the previous commit; `command > log -v cd` still runs nothing. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * docs(repo): first_useful.steps counts a Claude Bash read as one more step A derived read event is one more event, so a run that prints a file through Bash before its first useful result shows one step more. The changelog says so and a test pins it. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): the bash= probe may run only the cat it names repo reach runs the shell on the host, so a probed CLAUDE.md could drive any command. claude-code's bash mode adds a PreToolUse hook that blocks all but DOKIMI_REACH_COMMAND, and fails closed. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com> * refactor(repo): keep repo.py and test_repo_useful.py under the growth caps The reach flag check moves to reach.check_flags; the Bash first_useful test joins test_repo_events_bash.py. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com> * fix(repo): a negated printer is no read `! cat AGENTS.md` of a missing file exits 0, so it was read with rc 0; its exit no longer says whether the file was printed, so it gives no read in any harness. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com> * fix(repo): a negated read adds nothing to unknown_reads ! cat AGENTS.md at a directory not known gave no read but counted one unknown read, as a pipe does not. Skip the printer before it is counted, and name the negated read in the contract and reviewing.md as the changelog does. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> --------- Signed-off-by: Anton Volkov <antonwolfy@gmail.com> Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com> Co-authored-by: Alexander Rybkin <alexander.rybkin@intel.com>


Summary
Follow-up to an audit of a
dokimi repo runreplay on IntelPython/dpnp#3099 (items 6 and 7; no review thread).Item 6:
guidance_readmissed guidance printed through Bash under Claude Code. The Codex and Copilot trace readers have turned a shell command that only prints files intoreadevents since #116 (cli_traces.shell_reads). Theclaude-stream-jsonconverter did not: aBashcall was acommandevent and nothing else, and only the Read tool made areadevent. In the replay, 17 of 78 guidance cells printed anAGENTS.mdwith Bash and all hadguidance_read: []. Onmain:Bashcallmainguidance_readonmaincd benchmarks; cat AGENTS.md(ok)commandonly[]["benchmarks/AGENTS.md"]cat AGENTS.md(is_error: no such file, the none arm)commandonly[][](a failed one-file read is no read)cat AGENTS.md missing.md(is_error)commandonly[]rc0 (the exit does not say which failed)cat AGENTS.md, refused by the PreToolUse hookcommand, denied[][]cat AGENTS.md | head,grep rule AGENTS.mdcommandonly[][]Item 7: no reach scenario measured whether a Bash read of a nested file loads that directory's guidance.
read=<dir>measures it for the Read tool only. The replay's agents used only Bash, and their cells' constant +3,065 start tokens fit the root file alone, so that was the open question.The second round of commits answers the review of this PR: a
cdin a background job or a pipeline no longer counts as moving the shell, refused calls are settled correctly, refused or failed reads are no reads for reach and skill use, and thebash=prompt quotes its path. The third round fixes two more cases where acdthat cannot move the shell still counted as moving it: acdbefore a brace group in a backgrounded list, and a computedcd(cd "$X") or anycdin a pipeline, when backgrounded or piped. The fourth round:Skillcall as a use, and no failed arm-file read inuptake.reads;cd sub && cat AGENTS.md;cdasdenied;eval,source,.andtime cdas moving the shell.The fifth round:
tool_callscounts denied reads and the reads of a failed multi-file command;Skillcall as a use;cd $(...)in a pipeline (cd $(pwd) | cat) as moving the shell;time cd sub && cat AGENTS.md, and of thecommand cd,builtin cdandX=1 cdforms.The sixth round fixes three edge cases of the shared shell parser at their roots:
$( )({ cd $(pwd); } | cat) is tracked, so its pipe or&moves nothing;source,eval,.,pushdor computedcd(source env.sh & cat AGENTS.md) keeps the foreground directory;cd -; cat missing.md /ws/AGENTS.md).The seventh round fixes two more cases in the same parser:
||branch its directory (source env.sh & false || cat AGENTS.mdreadsAGENTS.md);"source" "$ENV",\cd "$D") is that builtin.The eighth round stops adding special cases to the parser. It adds one conservative fallback instead: a command the parser does not fully follow that names a directory builtin (
X="a b" cd "$D",time -p cd sub,command -p cd sub,builtin -- cd sub) counts as moving the shell. Forms that only drop a read (cd $(printf sub; true) | cat) are documented limits, tracked in #374.The ninth round narrows that rule, which fired on a builtin's name anywhere in the command, and settles four more points:
builtin,commandortimeand their options, the next word must be a directory builtin. Sotime git add .,time find . -name x,command -v cdandbuiltin printf '%s' .no longer drop every later relative read of the trace;timeis bash's keyword only as the first word, unquoted:X=1 time cd suband\time cd subrun /usr/bin/time and move nothing. A directory builtin in acasearm counts as a move;{ cat AGENTS.md; }) gives no read;process.unknown_readscounts the relative shell reads made at a directory the converter could not follow, soguidance_read: []aftersource .venv/bin/activateis not taken as "none read".The line-continuation point (
cd \<newline>sub && cat AGENTS.md) is declined: that command already gives no read, and a test now pins it.The tenth round stops
process.unknown_readsfrom counting a print whose output never reached the agent: a brace group whose output is hidden ({ source env.sh; cat AGENTS.md; } > /dev/null, or the group piped intohead) gave no read but counted 1.The twelfth round merges
mainagain and settles two more points:repo reportandrepo comparenow showprocess:unknown_reads. Before, a batch whose every cell ransource .venv/bin/activateand thencat AGENTS.mdshowed only aprocess:guidance_readmean of 0;caseandtimehandling kept a read at a wrong path or a hidden one:time X=1 cd sub, a nestedcase, and a brace opened on acasepart.The thirteenth round settles two more points:
cd -; cat missing.md, exit 1) no longer adds 1 toprocess.unknown_reads, under Codex, Copilot or Claude Code;> /tmp/cd.log cd sub) is no longer taken as the command word, so thatcdmoves the shell, and a later relative Bash read is no longer recorded as the root file's.The fourteenth round finds the command word also when a redirection comes between
command,builtinortimeand its options (command > /tmp/cd.log -p cd sub), so thatcdmoves the shell too.Changes
repos/events.py: for aBashtool_use, areadevent per path ofcli_traces.shell_reads(command, cwd), before thecommandevent. The read has the samet, the subagent'sagentid, and its path goes throughrelative_path. Claude Code keeps the shell's directory between calls. So once a call may have moved its shell (acd,pushd,popd,eval,sourceor.; newcli_traces.shell_reads_moved, which reuses the same walker), later relative reads are skipped and absolute ones are kept. The shell starts at the harness's start directory (the root inrepo runand inbash=). A(cd x && make)subshell,make -C xorenv -C xmoves nothing.One moved-shell state for the whole trace, not one per agent. After the parent's
cd, a subagent's relative reads are dropped too, and the reverse. ATasksubagent may share the parent's Bash directory: the alternative, a read of the rootAGENTS.mdwhile the shell is insub, would count guidance the agent was never shown. Dropping is safe under either model. No real trace with a subagentcdwas checked. (review: 4445790393)cli_traces._Walkkeeps ashell_movedflag, separate from the chain flagmoved, which loses the directory for a later||branch (seventh round: a builtin in a subshell no longer setsmovedeither, below). A directory builtin sets it only outside a subshell. It is saved and restored with the directory at a backgrounded&&/||list, a piped or backgrounded brace group, and a list backgrounded at the end of the script. Socd sub &,{ cd sub; } | cat,{ cd sub; } &,cd sub && make &and(cd sub)move nothing.cd sub,cd sub && make,cd "$X",pushd subandpopddo. (review: 4443237154, 4443248498)cli_traces._Walk.track_groups: a brace group saves the enclosing&&/||list's state when it opens and restores it when it closes, so its own separators do not end that list. Socd sub && { echo a; echo b; } &andcd sub && { cd x; cat a; } &move nothing, whilecd sub && { echo a; }still moves. This also fixes a read for Codex and Copilot:cd x && { cat f; cat h; } & cat greadx/g, and now readsg. (review: 4443584029)cli_traces._Walk.wordsgets the part's operator after it. A computedcdin a backgrounded part or in a pipeline (an unquoted, unescaped|) no longer marks the shell as moved, and (sixth round) no longer loses the directory either. Socd "$X" &,cd "$X" | cat,cd sub | catandecho | cd submove nothing, whilecd "$X",cd "$X" && make,cd $(pwd)andcd a\|bdo. (review: 4443584037)cli_traces._in_own_shell: a part holding a command substitution (cd $(pwd)) takes the walker's subshell branch, which saw&but not a pipe. Both branches now call this one helper: the part is not backgrounded, and has no pipe outside quotes, escapes,$( ... )and backticks. Socd $(pwd) | cat,cd `pwd` | catandcd $(pwd) &move nothing, whilecd $(echo a | tr a b)still moves, because its pipe is inside the substitution._Walk.wordsreuses the same builtin test, now_runs_dir. (review: 4446703160)repos/shell_split.py(new, moved out ofcli_traces.py, which was at its 800-line cap): the splitter now gives each part its outer text as well, the text outside any( ... )or backticks. Its subshell flag meant both "inside( ... )" and "holds a substitution". The walker tracks brace groups on the outer text before it skips a subshell part, and tells acd $(...)by that text (_runs_dir,_in_own_shell). Every brace of a part counts, in order (_Walk.open_group,_Walk.close_group). So:{ cd $(pwd); } | cat,{ cd $(pwd); } &,{ cd `pwd`; } | cat,{ source $(x); } &,{ { cd $(pwd); }; } | catand{ cd sub; } | grep $(echo x)move nothing;(true; cd sub)andecho $(true; cd sub; pwd)move nothing: the innercdwas taken as this shell's;{ cd $(pwd); }still moves, and{ { cd sub; }; }, missed before, now moves too;{ { cat a; }; }readsa, and{ cat a; } | { cat b; }dropsa, whose output feeds the pipe.(review: 4447109156)
cli_traces._Walk.lose: a directory builtin in a background job or a pipeline stage ran in a subshell, so it now leaves the foreground directory known, not only the moved-shell flag.source env.sh & cat AGENTS.md,eval "$CMD" | cat; cat AGENTS.md,pushd sub & cat AGENTS.mdandcd "$X" & cat AGENTS.mdreadAGENTS.md, and the next call's relative reads are kept. The&&/||gate that loses the directory for the next chain sets it unknown itself. (review: 4447109164)cli_traces._Walk: a directory builtin in a job or a pipeline stage no longer sets the chain flagmovedeither, and neither does a backgroundedcd. The flag is saved and restored with the directory andshell_moved(one_Spot,_Walk.spot/_Walk.back) at a backgrounded list and at a piped or backgrounded brace group. Sosource env.sh & false || cat AGENTS.md,source env.sh | false || cat AGENTS.md,cd x & false || cat AGENTS.md,{ cd x; } & false || cat AGENTS.mdandcd x && make & false || cat AGENTS.mdreadAGENTS.md. A builtin in this shell (source env.sh; false || cat AGENTS.md) still loses it. (review: 4447391791)cli_traces._unquoted(nowshell_words._unquoted): a part with an expansion is not split by shlex, so the walker matched its blank-split words, quotes included. They now pass through this helper, which removes quotes and backslash escapes and expands nothing, before_command_words(in_runs_dir, and in_is_cdfor the failed-read rule). So"source" "$ENV",'cd' "$D",\eval "$X",c"d" "$D"andbuiltin "cd" "$D"lose the directory and move the shell for the next call, and a failed"cd" sub && cat AGENTS.mdgives no read. The words of a part shlex splits are already unquoted and are not unquoted twice ('\cd'names a program\cd). Keywords are matched before unquoting. (review: 4447391806)cli_traces._runs_dir/_may_run_dir(eighth round; narrowed in the ninth, below): one fallback rule._runs_dirtakes a part's words fromshlex.splitwhere it can split them, so an assignment holding a blank is skipped like any other (X="a b" cd "$D"). Where shlex cannot split the text, any word that names a directory builtin counts._may_run_dircounts a part as moving the shell when it has abuiltin,commandortimethat_command_wordsdid not skip, because an option comes first (time -p cd sub,command -p cd sub,builtin -- cd sub), followed by a word that names a directory builtin. The walker calls it for parts shlex splits and for parts with an expansion, so the shell is unknown for the rest of the command and for later Bash calls. In a job or a pipe such a part still moves nothing (time -p cd sub & cat AGENTS.mdreadsAGENTS.md). The forms the walker does follow are unchanged. The failed-read rule (_is_cd) is unchanged, on purpose: a failedX="a b" cd sub && cat AGENTS.mdkeeps its read, because a part before the read that is not surely acdmay be the one that failed.cd $(printf sub; true) | catis left as it is: it only drops a read, and it is listed in repo: Bash reads dropped where the shell could not have moved (conservative limits of #369) #374. (review: 4447632234, 4447632250, 4447632218)cli_traces:eval,sourceand.lose the directory aspushd/popddo (_DIR_LOST), because they may run acdthat is not seen. Soeval "cd sub",eval "$CMD",source env.shand. env.shmove the shell, and a later relative read in the same command is not recorded.timebefore a directory builtin is skipped likebuiltinandcommand(ninth round: only as the first word, unquoted), sotime cd subis acd(time cd sub && cat AGENTS.mdreadssub/AGENTS.md).command cd subalready was acd.eval "cd sub" &andenv source env.shmove nothing, and (sixth round) a backgrounded or pipedeval,sourceor.keeps the directory for the rest of its command. Acdinside a shell function that the agent defines and then calls is still not seen. (review: 4446227585)cli_traces._failed_read: a command that is one read of one file aftercdparts (cd sub && cat AGENTS.md) is one read part, so a non-zero exit drops its read. After&&, the cd or the read failed; after;,&or||, the exit is the read's own. Either way the file was not shown. A list whose other part is not acd(make && cat f,cat f && cd sub) keeps its read, as before. The rule is shared by the three readers. (review: 4446227568) A part counts as acdafter the words the walker skips (_is_cd, through the walker's_command_words):time cd,command cd,builtin cd,X=1 cdand (seventh round) a quoted or escaped"cd"or\cdare acd, andenv cd, which runs no builtin, is not. (review: 4446703173) "One file" is the printer's own operand count, which the walker records (_Walk.operands), not the number of reads it kept. So a failedcd /ws/sub; cd -; cat missing.md /ws/AGENTS.md, whosemissing.mdis at a directory not known, keeps its/ws/AGENTS.mdread. (review: 4447109167)The Bash reads are settled once the trace ends, because a
permission_denialsentry comes only in the final result, and it can follow anis_errortool_result (Permission to use Bash has been denied) for the same call. Each Bash call records its order, named paths, whether it moves the shell, and its result (ok, error or denied: a hook's result orpermission_denials). Then, in the trace's order:denied, after a move too, a relative one with nopath(ninth round) (review: 4446227577; before, a refused relative read after acdthat ran was dropped, andtool_callswas one short);_failed_read, the rule the Codex and Copilot readers use);rc0.Read events are placed before their command event at tool_use time, and only the dropped ones are removed, so every read keeps its position. PowerShell commands are not parsed. The logic sits in helpers (
_tool_use,_bash_call,_settle_bash,_deny, a_Streamstate), soevents._applygoes from complexity 17 to 14 (baseline lowered; since the twelfth round's merge ofmain, which splits it further, it is under the cap and has no baseline entry).process_metrics.py'sguidance_readrule is unchanged. No new event field is added; a Claude CodeSkillevent now carries the existingrc(next bullets). (review: 4443237165, 4443237182)Transcript.reads()(reach'sclassify, and so the receipt'sread_by_tool) andarms.skill_usagetake only reads that showed the file (transcript.shown_read: notdenied,rc0 or no result), asguidance_readalready did. The events stay inevents.jsonl. The Read tool had this bug before this PR too. (review: 4443248578)arms.skill_usageskips a denied or failed event at the top of its loop, so aSkilltool call that a hook or the permissions refused no longer counts the skill as used.uptake.arm_uptakeskipped denied events already. It now also skips a failed read (rc1) of a file apatch:arm added (shown_read). (review: 4445790411)events._apply_resultsetsrcon a Claude CodeSkilltool event from its tool_result (0, or 1 on an error), as on a read. So a failedSkillcall (Skill lint could not be loaded, with no hook refusal or permission denial) no longer counts the skill as used. No other tool event getsrc, and no consumer other thanskill_usagereads it on a tool event (uptake'smcp_calls,tool_calls, reach, the consistency check). A permission-deniedSkillcall now hasrc1 next todenied, as a permission-denied command has. (review: 4446703155)repos/shell_words.py(new, ninth round, move only): the helpers that find a part's command word and tell whether it runs a directory builtin (_command_words,_runs_dir,_may_run_dir,_is_cd,_run_name,_cd,_unquotedand their constants) moved unchanged out ofcli_traces.py, which was at 793 of its 800 lines.docs/architecture.mdnames it.shell_words._command_at(ninth round): the index of the command word pastbuiltin,commandandtimewith their options (time -p,command -p,--), or none forcommand -v/-V, which only describes the command._may_run_dirand_runs_dirnow count a part as moving the shell only when that word is a directory builtin, not when a builtin's name is any later word. For text shlex cannot split,_runs_dirunquotes the words one by one (_unquote) and looks at the same command word. Only when a quote up to that word does not close (X="a b c" cd "$D) does any builtin's name in the part still count. Sotime find . -name x; cat AGENTS.mdreadsAGENTS.md.time git add .,time make -C .,command -v cd,builtin printf '%s' .andgrep $'it\'s' .move nothing.time -p cd sub,command -p cd subandbuiltin -- cd substill move.time cat AGENTS.md .still records no read of its own: the walker skipstimeonly before a directory builtin. (review: 4449500725)shell_words._time_keyword/_prefix(ninth round):timeis skipped (in_command_words,_command_atand_is_cd) only when the part's raw text starts with it as bash's keyword, unquoted. After an assignment, or quoted or escaped, it runs /usr/bin/time, a child process. SoX=1 time cd sub; cat a,\time cd sub; cat aand"time" cd sub; cat areadaand move nothing, and a failedX=1 time cd sub && cat AGENTS.mdkeeps its read like any part that is not surely acd.cli_traces._Walk.in_case: the walker does not follow acase ... esac. From itscasepart to itsesac, a part that names a directory builtin as any word counts as a move, and the reads in it are not kept, as an arm may or may not run. Socase x in a) cd sub;; esac, a later arm and an arm on its own line move the shell. (review: 4453764969)cli_traces._failed_read/_ungrouped(ninth round): a brace group's braces are not parts. A}part is skipped and an opening{is stripped, so a group that holds one read is that read. A failing{ cat AGENTS.md; },cd sub && { cat AGENTS.md; }or{ { cat AGENTS.md; }; }gives no read, while{ make; cat AGENTS.md; }keeps it. Shared by the three readers. (review: 4449500729)events._settle_bash/_deny_read(ninth round): a refused relative read after a move keeps its event with nopath({"kind": "read", "denied": true}), as the file it named is not known. Before, it carried the start directory's path. Every consumer readspathwith.getor skips a denied event first (Transcript.reads(),guidance_read,skill_usage, uptake,consistency.run_from_events, which gives a plainReadwith nofile_path). No schema describes the events. (review: 4449500733)result.jsonprocess.unknown_reads(ninth round): an int,0when there are none. The walker counts the relative files a printing command names once its directory is lost in the command (_Walk.unknown; the newcli_traces.shell_walkreturns it with the reads and the move). The Codex and Copilot readers add that per command (_counted_reads) and (thirteenth round) take it back when the command's result is a failed one-file read. The Claude Code converter adds it per Bash call that was neither refused nor failed as one read, plus each relative read it drops after an earlier call moved the shell, but not a failed one-file read.Transcript.unknown_readscarries it toprocess_metricsthroughrunner._process_or_null. No schema coversprocess. (review: 4453764950)cli_traces._Walk.open_group/close_group(tenth round): a brace group saves theunknowncount with its read mark. The close that drops the group's reads because its output is hidden (a pipe after it, its stdout to a file or /dev/null) also puts the count back. So{ source env.sh; cat AGENTS.md; } > /dev/nulland{ source env.sh; cat AGENTS.md; } | headgiveunknown_reads0, not 1, and a nested hidden group takes back only its own count ({ source env.sh; { cat a; } > /dev/null; cat b; }gives 1, not 2). A group whose output is shown keeps its count. Shared by the three readers. (review: 4455672089)tests/test_repo_events_bash.py(ninth round): the failedcd-then-cattest gainscd \<newline>sub && cat AGENTS.mdandcd sub \<newline>&& cat AGENTS.md, which already gave no read:split_scriptkeeps a line continuation inside its part. No code change. (review: 4447855726)repos/reach.py: a new kindbash, with scenario idbash=<dir>per guidance directory. It starts at the root in tool modebash, and the prompt isASK_AFTER_BASH(First use the Bash tool to run `cat <dir>/REACH_PROBE.txt`, and run no other command. Then:) +ASK. The operand isshell_word(path):shlex.quote, with./in front of a path that starts with-, so a space,$or;in a directory name stays one operand.Scenario.readand the Read-tool prompt keep the raw path. The default kinds stayroot,cwd,read.check_kindsrefusesbashfor a profile without that tool mode. The CLI calls it with the other argument checks (exit 2, naming the profile, before any clone), and so doesrun_reach.--scenarioshelp, the known-kinds message,--dry-runand--only bash=<dir>all cover it. (review: 4443237195, 4443248319)profiles/claude-code.yaml:tools.bash: ["--tools", "Bash"].harness_profile.v1.jsontakes the optionaltools.bashkey, widened in place.delegation.nonegets nobashmode:schema_compatholdspropertyNamesfixed, so wideningdelegation.none's mode enum fails it as a breaking change. Reach never passes a delegation, and a Bash-only run cannot start a subagent. Sorepo run --tools bash --delegation noneis refused as the allow-list refuses any unlisted mode, and a test pins that.Receipt: no code change. A
bash=<dir>row carries the probe file inread, so_workdiris<dir>and alazynested file isexpected: true, as forread=<dir>.states.accessedistruefor a file with a read event that was neither denied nor failed, elseunknown(tools on). Only the_statesdocstring and thetargetdescription ineffective_instructions.v1.jsonchanged.Docs: contract §4.1 (
guidance_readlists which shell reads count, for all three harnesses, and when a cross-callcdmakes a relative read unknown;tool_callscounts the derived reads that are kept, a denied one or one of a failed multi-file read too (review: 4446703136);rcon a Claude CodeSkillevent;skills_usedtakes calls and reads neither denied nor failed;uptake.readstakes reads that did not fail), §6 (trace converters, profile example), §7 (bash=<dir>in step 3, with the quoted path; thefetchedbullet covers Claude Code, says a denied or failed read is none, and lists thecdforms before a failed read), §7.1 (states.accessed; the "cannot show" list:rg,grepor a pipe stay unseen for all three, the shared-shell assumption for subagents, acdinside a called shell function, and (eighth round) the fallback rule for a command the parser does not fully follow, with a link to repo: Bash reads dropped where the shell could not have moved (conservative limits of #369) #374 for the drop-only forms; (ninth round) narrowed to the command word, with thetimekeyword andcasearms). Ninth round, §4.1 also: areadevent may have nopath, theunknown_readsrow, and the brace-group form inguidance_readand §7'sfetched. Alsodocs/reviewing.mdstep 2 and theprocess_metrics.py/events.py/reach.py/uptake.pydocstrings. I found no other doc ordata/skills/dokimi-design-evals/sentence that said Bash reads are not counted.Tests:
tests/test_repo_events_bash.py(205) andtests/test_repo_reach_bash.py(17, with its own fake Bash harness:test_repo_reach.pyis at 997 of its 1000 lines). The fake harness parses the prompt's quoted operand withshlex.splitand builds its other command withas_posix(), notstr(Path)(review: 4443248422).tests/test_repo_skill_arms.pyadds six tests: refused and failedSKILL.mdreads, refusedSkillcalls, and a failedSkillcall converted end to end.tests/test_repo_uptake.pyadds a failed-read case.tests/test_repo_cli_traces.pygains thecd x && { cat f; cat h; } & cat grow, a Copilotcd sub && cat AGENTS.mdfailure row, and (sixth round) a group-tracking test, a directory-builtin-in-a-job test, and a two-operand failure row for Codex and one for Copilot, and (seventh round) a||-after-a-job test and two quoted-builtin tests, and (eighth round) a test that the forms the walker does not follow lose the directory and one that they keep it in a job or a pipe, and (ninth round) rows where a builtin's name is an argument, a Codex brace-group failure test, a Copilot brace-group row, and a Codex/Copilotunknown_readstest, and (tenth round) ashell_walktest of theunknowncount for hidden and shown groups. The tenth round also adds three hidden/shown-group rows to the Claude Codeunknown_readstest.tests/test_repo_run.pyandtests/test_repo_process_metrics.pypinunknown_readsinresult.jsonand inprocess_metrics. Its backtick rowecho `true; cd sub; pwd`; cat AGENTS.mdchanges from[]to["AGENTS.md"]: its own comment says thecdis not this shell's.tests/test_batch_ledger_golden.pypins the execution id of the new("bash", "native")mode. The existing ids are unchanged.Merge of
mainb084e47 (eleventh round, 75d06d4): no conflict, no change to this PR's code.docs/reviewing.md"Delivery first" (eleventh round):process:guidance_readcounts a read tool's reads and a Claude Code Bash call, Codex command or Copilotbashcommand that only prints files (§7's list). A pipe,greporrgdoes not count, nor does a relative read after the shell moved.process:unknown_readscounts those dropped reads, soguidance_read: []withunknown_readsabove 0 is not "never read".Merge of
main39dbd2a (twelfth round, 4597bc3; feat(repo): time to first useful result, with a rate in repo compare #332, feat(repo): judged recall over the credited replay hits #357, test(subjects): pin the evidence baseline fallback, ci nulls and warnings #381, feat(repo): reach --scenarios shadow adds a CLAUDE.local.md #367). Five conflicts, each resolved by keeping both sides:bash=<dir>and thenmain'sshadow=scenarios;commands/repo.py:cmd_repo_reachcallscheck_kindsand thenmain'scheck_shadow, and the--scenarioshelp namesroot, cwd, read, bash, shadow;repos/reach.py: oneKINDS(root,cwd,read,bash,shadow),Scenariowith both the bash prompt andshadow, andscenarios()givingread=, thenbash=, thenshadow=;repos/events.py:mainsplit_applyinto_apply_init,_apply_assistantand_apply_finaland counts malformedtool_use/tool_resultblocks as dropped lines. The split now carries this branch's_Stream:_apply_assistanthands each well-formed block to_tool_use, and_apply_finalmarks permission denials through_deny;scripts/growth_baseline.json:main's side, with noevents.py::_applyentry.main'stest_a_failed_edit_changed_nothing_so_the_next_successful_one_is_the_first_useful_resultcalled_apply_result(block, by_id)and now passes the branch's(out, block, stream).docs/reference/cli-interface.jsonwas regenerated withscripts/cli_surface.py dump; it matches the merge.first_useful.stepscounts every event, asprocess.tool_callsdoes, so under Claude Code it now counts the Bash-derived reads too.process_counts.PROCESS_KEYS(twelfth round):unknown_readscomes right afterguidance_read. Soprocess_values({"process": {"guidance_read": [], "unknown_reads": 3}})givesprocess:unknown_reads3.0 next toprocess:guidance_read0.0,repo report's process line shows it, andrepo comparecompares it as a descriptive measure, in §4.1's order. No golden output had the key. (review: 4458839235)shell_words._prefix_runs_dir/_command_atandcli_traces._Walk.in_case(twelfth round). Each case is shared by the three readers:timekeyword and its options,NAME=valuewords are skipped before the directory builtin.time X=1 cd sub; cat AGENTS.mdgives(['sub/AGENTS.md'], True, 0), andtime -p X=1 cd subcounts as a move.command X=1 cdandbuiltin X=1 cd, which run nocd, are unchanged;case, eachcaseword opens one more level and only a leadingesaccloses one, so a miscount stays inside.case $x in a) case $y in b) :;; esac; cd sub;; esac; cat AGENTS.mdgives([], True, 1), no longer a read ofsub/AGENTS.md;casepart tracks its braces before it is skipped, and before thelose()a builtin in it causes.{ cat AGENTS.md; { case $x in a) :;; esac; }; } > /dev/nullgives([], False, 0), and a backgrounded or piped{ case x in a) cd sub;; esac; }now moves nothing.(review: 4458839251)
cli_traces._failed_readand the three converters (thirteenth round): "one file" no longer also needs a resolved path. So a failedcd -; cat missing.mdis a failed one-file read, though its file is at a directory not known. Each command's unknown count is settled with its result, as its known reads are:_Derived) and subtracts it on a failed one-file completion; a completion with no start adds it only when the command did not fail;_CopilotRun.by_idand subtracts it in_copilot_result;events._settle_bashadds a call's count only when the call was neither refused nor failed as one read.cd -; cat missing.mdwith exit 1 now givesunknown_reads0 in all four paths (it gave 1), and with exit 0 still gives 1. The contract's §4.1unknown_readsrow says so. (review: 4459651043)shell_words._command_words,_command_atand_prefix_runs_dir(thirteenth round): leading redirections are skipped when finding the command word, with theNAME=valuewords (_past_redirections, formerly_past_assignments), and also afterbuiltin,commandandtimewith their options. This covers>,>>,<,&>,2>err,</dev/nulland{fd}>x, with the target attached or as the next word. The part is still not followed, just ascd sub > logis not. So> /tmp/cd.log cd sub; cat AGENTS.mdand>/tmp/cd.log cd sub; cat AGENTS.mdgive([], True, 1)(they gave(['AGENTS.md'], False, 0)), and a Bash> /tmp/cd.log cd submakes the next call'scat AGENTS.mdunknown.2>err cd sub, whose stderr redirect the walker already strips, still readssub/AGENTS.md. A redirectedcdin a job or a pipeline still moves nothing.> log time cd subis still /usr/bin/time, a child. The failed-read rule sees the same command word: a failed> log cd sub && cat AGENTS.mdis a failed one-file read. Shared by the three readers. (review: 4459651056)shell_words._command_at/_past_prefix(fourteenth round): afterbuiltin,commandortime, the prefix's options, redirections and (aftertime)NAME=valuewords are skipped together in one loop, in any order, until the command word. Before, the options were skipped only before the redirections. Socommand > /tmp/cd.log -p cd sub; cat AGENTS.md,time > log -p cd sub; cat AGENTS.mdandbuiltin > log -- cd sub; cat AGENTS.mdgive([], True, 1)(they gave(['AGENTS.md'], False, 0)), ascommand -p > log cd subalready did. A Bashcommand > /tmp/cd.log -p cd submakes the next call'scat AGENTS.mdunknown. A word after--is no option (builtin > log -- -p cd subruns-p).command > log -v cdstill runs nothing. Shared by the three readers. (review: 4459886479)Changelog fragments: Changed (A, and the refused/failed read fix), Added (B, and
process.unknown_reads, now with itsrepo report/repo comparemeasure).Risk
process.guidance_readnow also lists guidance printed by a barecat/head/tail/nl/sed -nBash call.tool_callsgrows by one per printed file,skills.skills_usedcounts acatof an installedSKILL.md, anduptake.readscounts acatof a file apatch:arm added. Codex and Copilot cells already counted all of these. A batch that resumes cells converted before this change mixes the two meanings: the converter is not part of the execution id.repo reachwith Claude Code: a guidance file the agentcats in Bash is nowfetched, notloaded, andstates.accessedistrue. A read that was denied or failed, with any tool, no longer makes a filefetchedorstates.accessedtrue, and no longer counts a skill as used. Neither does a refused or failedSkillcall. Before, a refused Read-tool read of a guidance file could turnloadedintofetched. A Claude CodeSkillevent inevents.jsonlnow hasrc.&&/||list holding a brace group is now joined to the directory from before the list (cd x && { cat f; } & cat greadsg, notx/g);cd sub && cat AGENTS.mdis no longer a read, nor a failedtime cd,command cd,builtin cdorX=1 cdform of it;eval,sourceor.in the same command is no longer recorded, unless that builtin is backgrounded or piped;cdinside$( ), backticks or( ... )((true; cd sub); cat AGENTS.md) no longer loses the directory, and a backgrounded or pipedcd,pushd,sourceor computedcdno longer does either;{ { cat a; }; }) now gives its reads and itscd, and a group whose output feeds a following group ({ cat a; } | { cat b; }) no longer gives a read;cd -; cat missing.md /ws/AGENTS.md); before, it was dropped;||branch after a backgrounded or piped directory builtin keeps the directory (source env.sh & false || cat AGENTS.mdreadsAGENTS.md);"source" "$ENV"; cat AGENTS.md) is no longer recorded, and a failed"cd" sub && cat AGENTS.mdis no longer a read;X="a b" cd "$D",time -p cd sub,command -p cd sub,builtin -- cd sub,time -p source env.sh) in the same command is no longer recorded. (ninth round) Only when that builtin is the command word:time -p ls .,time git add .andcommand -v cdmove nothing;cdafterX=1 timeor a quoted or escapedtimemoves nothing (X=1 time cd sub; cat areadsub/a, and readsa), and its failed read is kept;casearm that names a directory builtin, as any word, loses the directory (case x in a) find . ;; esactoo), and the reads inside acaseare not kept;{ cat a; }, also with2>/dev/nullafter the}) is no read;cd -; cat missing.md) no longer counts inunknown_reads, and a relative read after acd,pushd,source,evalor.behind a leading redirection (> log cd sub; cat a) is no longer recorded at the start directory. (fourteenth round) Nor is one behind a redirection between a prefix and its options (command > log -p cd sub; cat a);time X=1 cd sub; cat areadssub/a, nota. A nestedcasekeeps the walker inside the outer one until its ownesac. A backgrounded or piped brace group around acasearm with acdkeeps the directory. Acasearm holding the wordcaseas an argument (echo case) keeps the walker inside to the end of the command, which only drops reads.source .venv/bin/activate, which in practice moves nothing. Acdinside a later-called shell function is not followed (as inshell_reads). (eighth round) Parts the parser does not fully follow whose command word is a directory builtin count as moves too (ninth round: not a builtin's name as an argument), as does acasearm that names one, and some forms ofcdinside a substitution followed by a pipe or&still count as moves (cd $(printf sub; true) | cat). Both only drop reads. repo: Bash reads dropped where the shell could not have moved (conservative limits of #369) #374 lists the drop-only forms.repo reportandrepo comparegainprocess:unknown_reads. Comparing a batch from before this PR with a new one warns that only one arm reports it, as for any new count.result.jsonprocesshas a new key,unknown_reads; a consumer that compares wholeprocessobjects sees it. (tenth round) It does not count the reads of a brace group whose output is hidden, as no read event is made for them either.events.jsonlcan hold areadevent with nopath(a refused relative Bash read after a move).repo run --tools bashnow exists forclaude-code(the Bash tool only). No default changes, and existing execution ids are unchanged.Testing
On a Linux review host, at
e8c06f0(onmain39dbd2a):make ci-local: 28 passed, 2 failed, 7 skipped (external-catalog-smoke: needs docker; test-latest, docs deploy and pip-audit report: job conditions false on a PR; the cli-surface packaging step: an expression the local runner does not evaluate; conda-forge-deps: no micromamba; semgrep secrets: offline). The two failures are the host's network: "Install gitleaks" could not download it (curl: (22) The requested URL returned error: 504, four times), so "Scan commits" had nogitleaksto run (exit 127). Both passed at92c790d, and this round's commit changes one module and one test file. 13538 tests passed, 5 skipped; changed-line coverage 99% of 432 lines (the two missed lines,shell_words._cd's-L/--handling, moved unchanged in the ninth round).package-smokeandwindows-smokepass. Not covered locally: the 3.11, 3.12 and 3.14 legs, and CI's merge commit.On
main's code, 14 of the first round's 20 event tests fail; the 6 that pass are the "gives no read" cases (failed one-file cat, pipe/grep/redirect, PowerShell,pushd).test_repo_reach_bash.pyfails at import (ASK_AFTER_BASH).The second round's new tests on the code of
bff1c3f(this PR before the review fixes): 21 fail, one or more per review point. The cases that pass there are the ones that were already right ((cd sub), the "moves" rows, acd subthat ran).The third round's new tests on the code of
261b310(the head before it): 11 fail, every newmoved=Falserow and its cross-call read, plus thecd x && { cat f; cat h; } & cat gread. The rows that pass there are the ones that still move (cd sub && { echo a; },cd "$X" && make,cd $(pwd),cd a\|b) andecho | cd sub, which was already right.The fourth round's new tests on the code of
444aec9(the head before it): 27 cases fail, at least one per review point:Skillcalls, and the failed arm-file read;cd-then-catforms and the Copilot row;cd;eval/source/./time cdrow.The cases that pass there were already right: the three lists that keep their read,
command cd sub,eval "cd sub" &andenv source env.sh.The fifth round's new tests on the code of
ff48bb1(the head before it): 9 cases fail, at least one per code point:Skilltest;cd $(pwd) | catandcd `pwd` | catrows, and thecd $(pwd) | catcross-call read;time cd,command cd,builtin cdandX=1 cdreads.The cases that pass there were already right:
cd $(pwd) &,cd $(echo a | tr a b)(moves), its cross-call read, andenv cd sub && cat AGENTS.mdkeeping its read. Thetool_callsrow is a doc change, with no test.The sixth round's new tests on the code of
13f858b(the head before it): 35 cases fail, at least one per review point:{ cd $(pwd); } | catand&, the backtick andsourceforms,{ { cd $(pwd); }; } | cat,(true; cd sub),{ { cd sub; }; }) and both reported forms' cross-call reads;source/eval/./pushd/ computedcd, and the three Claude Code calls that read after one;The changed backtick row fails there too. The cases that pass there were already right:
{ cd $(pwd); }and{ cd $(pwd); }; cat AGENTS.md, which move, and the next-call reads after a backgrounded or pipedsource,eval,.orpushd, which the fifth round's code already kept.The seventh round's new tests on the code of
e60774d(the head before it): 35 cases fail, at least one per review point:||-after-a-job-or-a-pipe rows and both Claude Code next-call rows;movedrows, and the 3 failed quoted-cdrows.The cases that pass there were already right: the four foreground
||rows,"." env.shand\pushd sub(shlex splits those parts), and the rows that keep the directory or move nothing.The ninth round's new or changed tests on the code of
e61e812(the head before it): 42 cases fail, at least one per code point:time find . -name x,time git add .,command -v cd,builtin printf '%s' ., ...) and thegrep $'it\'s' .row, each within a call and in the next call, plus four Codex-level rows;timerows and the two failedX=1 time cd/\time cdrows;caserows;cdcases and the consumer test;unknown_readscase, the Codex/Copilot test, the two changedresult.jsontests, the newresult.jsontest and theprocess_metricszero test.The cases that pass there were already right: the existing rows of the changed tests,
{ make; cat AGENTS.md; }keeping its read, acasewith no directory builtin, and the two continued-cdrows, which pin what the code already did.The eleventh round is a merge and a doc change. It adds no test and no mutant.
tests/test_docs_code_refs.pypasses.The tenth round's new tests on the code of
c51f68a(the head before it): 6 cases fail: the four hidden-groupshell_walkrows (> /dev/null,| head, a hidden group aftersource, a nested hidden group) and the two hidden-group Claude Code rows. The cases that pass there were already right: a group whose output is shown keeps its count.The twelfth round's new or changed tests on the code of
4597bc3(the merge, before its two fixes): 5 cases fail, at least one per point: the newunknown_readsprocess_values/report test, the compare test that now hasprocess:unknown_reads, and the threecase/timetests.Mutants, first round: 22 breaks of the code, each one failing its tests (31 of 31 runs). Mutants, second round: 15 breaks, each one failing its tests:
shell_movednot restored at a backgrounded list, at a piped or backgrounded group, or for a list backgrounded at the end;shell_reads_movedreturningmoved;cdsettingshell_moved;Transcript.reads()orskill_usagewithout the filter;shown_readwithout itsrcor itsdeniedcheck;./;The
as_posix()change in the fake harness is test-only and has no mutant. I could not run it on Windows.Mutants, third round: 5 breaks, each one failing its tests:
cdmarking the shell moved whatever its context (6 fail);cd "$X" &);\|taken as a pipe (1 fails:cd a\|b).Mutants, fourth round: 11 breaks, each one failing its tests:
cdnot moving the shell (1);skill_usagewithout its top filter (3 tests, 5 cases);arm_uptakecounting a failed read (1);_failed_readback to one part only (7 cases);_failed_readtaking any earlier part, not only acd(5 cases, including existing Codex and Copilot tests);eval,sourceor.each left out of_DIR_LOST(4, 3 and 3 cases);timenot skipped beforecd(3).Mutants, fifth round: 6 breaks, each one failing its tests:
rcon aSkillevent (2 cases);&, as before (3 cases);cd $(pwd) &);$( ... )taken as the part's (1 case:cd $(echo a | tr a b));_failed_readtaking only a first-wordcdagain (4 cases);env cdtaken as acd(1 case).Mutants, sixth round: 6 breaks, each one failing its tests:
{stripped (3 cases);{ cat a; } | { cat b; });lose()ignoring the job / pipeline context (12 cases);Mutants, seventh round: 7 breaks, each one failing its tests:
lose()settingmovedfor a builtin in a job or a pipe, as in the sixth round (7 and 2 cases);back()not restoringmoved(3 cases: the group and list rows);cdsettingmoved(1 case);_unquotedreturning its words as they are (22 cases);_runs_dirnot unquoting (19 cases),_is_cdnot unquoting (3 cases);_runs_dirunquoting twice (2 cases:'\cd' "$D").Mutants, ninth round: 18 breaks, each one failing its tests:
_may_run_dircounting any word after the prefix again;command -v/-Vtaken as running the command;timetaken as the keyword wherever it is; the raw-text keyword check always true;case ... esacnot tracked; acasearm losing no directory;Mutants, tenth round: 2 breaks, each one failing its tests:
The thirteenth round's new tests on the code of
b6ce65f(the head before it): 12 cases fail, at least one per point:> /tmp/cd.log cd sub,>/tmp/cd.log cd sub,</dev/null cd sub,&>log cd sub,X=1 > log cd sub,command > log cd sub,command -p > log cd sub,> log source env.sh).The cases that pass there were already right: the successful Claude Code row (count 1), and a redirected
cdin a job or a pipeline, which moved nothing.Mutants, twelfth round: 4 breaks, each one failing its tests (5 of 5 runs):
unknown_readsdropped fromPROCESS_KEYS(2 tests);time(1);caseword opening a case, as before (1);casepart not tracking its braces (1).Mutants, thirteenth round: 8 breaks, each one failing its tests:
_failed_readrequiring a resolved path again (4 cases: all three readers);_command_words(6 cases);builtin/commandand their options in_command_at(1 case:command -p > log cd sub). This mutant first survived, because_prefix_runs_diralready coverscommand > log cd sub; thecommand -prow was added for it.The fourteenth round's new tests on the code of
92c790d(the head before it): 3 cases fail,command > /tmp/cd.log -p cd sub,time > log -p cd subandbuiltin > log -- cd sub, each within a call and in the next Bash call. The cases that pass there were already right:time -p > log X=1 cd sub, whose options come first, andcommand > log -v cd, which runs nothing.Mutant, fourteenth round: the two separate loops put back (options, then redirections once) in
_past_prefix. The same 3 cases fail.A fuzz of 20,000 random Bash commands through the converter raised nothing and logged no trace error (first round; repeated at
e60774dwith the walker's new words: braces,$( ), backticks,source,time,env -C; and at7ba6c6awith quoted and escaped builtin names,&,|,&&and||).Live, Claude Code 2.1.316 with Haiku 4.5, on a synthetic tree (root
AGENTS.md,sub/AGENTS.md,deep/x/AGENTS.md),repo reach --scenarios bash,read($0.05):read=subandread=deep/xloaded their directory'sAGENTS.md;bash=subandbash=deep/xdid not (root file only). Eachbash=trace holds thecat <dir>/REACH_PROBE.txtcommand and its derivedreadevent,rc0. So in this version a Bash read of a nested file does not load that directory's guidance, which fits the replay's root-only start tokens. Repeated at261b310with a fourth directory,my docs/AGENTS.md($0.08): the same result, and thebash=my docstrace holdscat 'my docs/REACH_PROBE.txt'with its read ofmy docs/REACH_PROBE.txt. Not repeated after261b310: the later rounds change the cross-call rules, the failed-read rule, the pipeline rule and the skill and uptake counts. Abash=run (one successfulcat, no subagent, noSkillcall) reaches none of them.Merged together with fix(repo): reach prose count skips a short lead-in line before the tokens #368 on
main93ff3a4, at444aec9:pytest -n auto12572 passed, 59 skipped, 6 xfailed;mypyandruff checkclean. Not yet run merged together with the other follow-up branches ate8c06f0.Revert
git revertthe commits. Nothing persisted changes shape:harness_profile.v1only gained an optional key, andevents.jsonl/result.jsonwritten in between keep the extrareadevents (andrcon a Claude CodeSkillevent, a refused read with nopath, andprocess.unknown_reads).Checklist
pytestpassesruff,mypy, and schema checks passAGENTS.md.github/.licenserc.yaml)