Repository navigation
feat(repo): reach --scenarios shadow adds a CLAUDE.local.md - #367
Conversation
Follow-up to the dokimi review of IntelPython/dpnp#3099 (item 4). repo reach probed the tree as it is, so on a tree with AGENTS.md files and no CLAUDE* file it could not show that a CLAUDE* file turns them off, although budget.py's `off` bullet said the reach probe re-measures that rule. - Harness profiles take an optional guidance.shadow_file (harness_profile.v1 widened in place); the claude-code profile sets it to CLAUDE.local.md. - A new --scenarios kind, shadow, outside the default root,cwd,read: shadow=. writes the shadow file at the root and runs as cwd=.; shadow=<dir> writes it in each guidance directory holding a file not named CLAUDE* and runs as read=<dir>. A directory where the tree already has a file at that path is skipped. The file is seeded with its own tokens (distinct_salt counts them), never committed, and removed when its scenario ends, after an error too. - Its result is a `shadow` record on the scenario row (guidance_reach.v1 widened in place) and one line under the matrix; the matrix keeps one row per tree file. --dry-run lists the shadow scenarios with their file. - A profile without guidance.shadow_file refuses the kind (exit 2). The --scenarios help names the default instead of "all". - Contract sections 6 and 7 say so. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
There was a problem hiding this comment.
🟡 Changes recommended
The shadow occupancy check misses empty CLAUDE.local.md directories, causing invalid scenario scheduling and synthetic-file creation at directory paths; the check needs correction and regression coverage.
1 open issue
What changed in this PR
1 new must-fix finding.
This PR adds opt-in shadow scenarios to repo reach, allowing it to measure how temporary guidance files suppress existing guidance without editing the source tree.
Changes:
- Adds
guidance.shadow_fileto harness profiles and enables it for Claude Code. - Implements shadow scenario planning, temporary-file cleanup, result reporting, and CLI support while preserving default scenarios.
- Extends schemas, documentation, and regression tests.
| File | Description |
|---|---|
dokimi/tests/test_repo_reach_shadow.py |
Tests planning, cleanup, results, validation, and CLI behavior. |
dokimi/dokimi/schemas/harness_profile.v1.json |
Allows the optional shadow-file profile setting. |
dokimi/dokimi/schemas/guidance_reach.v1.json |
Defines optional shadow results on scenario rows. |
dokimi/dokimi/repos/reach.py |
Implements shadow scenarios and their lifecycle and reporting. |
dokimi/dokimi/repos/profiles/claude-code.yaml |
Sets CLAUDE.local.md as Claude Code’s shadow file. |
dokimi/dokimi/repos/harness_profile.py |
Exposes the shadow-file setting. |
dokimi/dokimi/commands/repo.py |
Adds CLI validation, dry-run support, and help text. |
dokimi/docs/repo-evaluation-contract.md |
Documents configuration, scenarios, and result fields. |
dokimi/changelog.d/20261102_120000_antonwolfy_reach-shadow.md |
Announces the opt-in shadow capability. |
Review effort: Balanced
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #367 +/- ##
==========================================
+ Coverage 94.39% 94.44% +0.04%
==========================================
Files 313 313
Lines 40240 40380 +140
Branches 6828 6851 +23
==========================================
+ Hits 37986 38138 +152
+ Misses 1438 1431 -7
+ Partials 816 811 -5
🚀 New features to boost your workflow:
|
Settles two review points on the reach shadow scenarios. A shadow path was free when no tree file sat at it or under it. It is now also taken when a tree file sits above it (AGENTS.md for a shadow file AGENTS.md/local.md) and when the tree holds a directory at it, which the file list leaves out when the directory is empty: Shadow asks the tree (RepoTree.is_dir) once per candidate path, and a path the tree cannot resolve counts as taken. In the workspace, a shadow path that would go under one of the probe's own entries that is not a directory (a REACH_PROBE.txt, a seeded file, a link) is refused with a ReachError before anything is written, as a path that would replace one already was. The cleanup removes only what the call made: the file once its write has begun, and the outermost directory it created. An OSError while creating becomes a ReachError naming the path, after that cleanup, so `repo reach` reports it as `✗ ...` with exit 1 instead of a traceback. The contract's §7 and the changelog fragment say what the skip covers. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Settles review comment 4443412079 on intel#367. A git working tree can hold an ignored CLAUDE.local.md that is a link to itself: it is in no file list, so Shadow.free asks WorkTree.is_dir, and Path.resolve raises RuntimeError on the loop before Python 3.13. That escaped as a traceback from `repo reach --scenarios shadow`, --dry-run included. Shadow.free now counts RuntimeError and OSError, the failures WorkTree.resolve already maps, as taken, as it does a SourceError. WorkTree.is_dir is unchanged for its other callers. On Python 3.13+ resolve does not raise for the loop, so the link reads as an ignored non-directory, which the skeleton workspace never holds. The tests use a real ignored looping link in a git working tree, for the scenario list and for the --dry-run command, each also with Path.resolve raising RuntimeError on the loop as before Python 3.13; and a tree whose is_dir raises each of the three errors. Comment 4443412060 needs no code change. The contract's skip line now says what the code compares: the tree's files ignoring case, an empty directory by the disk's own lookup. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
|
Review rounds so far:
All four threads are answered and resolved. CI is green at fa9d2e4. |
vlad-perevezentsev
left a comment
There was a problem hiding this comment.
Review at fa9d2e4. The shadow scenarios work as described: the file is written for its own scenario only and removed after an error too, and the default scenarios do not change. One gap remains: --format receipt reads a shadow scenario as a run of the real tree. Details are in the line comments.
What I ran, at fa9d2e4:
make ci-local --offline: 0 failed, 19 passed, 13 skipped (network and Docker jobs).- A shadow run with the test's fake harness, relabelled
claude-code, thenbuild_receipt: theshadow=.receipt has rootAGENTS.mdexpectedtrue, observednot_loaded, with no precedence entry and no unknowns. - A profile with
shadow_file: REACH_PROBE.txt/x.mdand--scenarios root,cwd,read,shadow: three harness runs, then theReachError, and noreach.json. - On Windows (Server 2025, Python 3.13):
pytest tests/test_repo_reach_shadow.py: 36 passed, 0 skipped.
Checked, no issues
- Tokens:
distinct_saltcovers the shadow tokens, and_score_rowkeeps the shadow file's own tokens out ofunknown. - Cleanup: only what
_shadowedmade is removed; the failed-write and lost-race tests cover it. - Schemas and defaults:
guidance_reach.v1andharness_profile.v1are only widened (an optional property each);shadowis not in the default kinds, so published numbers do not change. - Docs and changelog: contract §6 and §7 and the
--scenarioshelp match the code; the fragment is under Added.
napetrov
left a comment
There was a problem hiding this comment.
One blocking (late path refusal) and two nits, inline. Expect conflicts with #368 (contract doc) and #369 (contract doc, commands/repo.py, reach.py); merging after those means one rebase.
Coverage: plain + edge passes, planning/dry-run probes; reach tests 145 passed on head; no live harness runs.
Findings outside the diff
dokimi/dokimi/repos/receipt.py:55
nit: with --scenarios shadow --format receipt, shadow=. reports AGENTS.md as expected and not_loaded, a false failure in a shareable artifact. Disclosed in Risk; consider skipping receipts for shadow rows until expected knows about the shadow file.
Review of intel#367 (comment 4445805701): `repo reach --format receipt` built one receipt per scenario row and did not know about a row's `shadow` record. A `shadow=.` receipt therefore described the tree as committed while the run had a CLAUDE.local.md the tree lacks, and said `AGENTS.md: expected true, observed not_loaded` with the shadow file in none of its sources. build_receipt now skips a row that carries a `shadow` record, for a live run and for `--from` alike: the receipt describes the tree as committed, and the shadow results stay in reach.json and the matrix. Contract §7.1 and the changelog fragment say so; a test runs `root,shadow` and gets a receipt with the `cwd=.` row only. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Review of intel#367 (comments 4445805709 and 4446227108): a shadow path that went under or over a probe file (`shadow_file: REACH_PROBE.txt/x.md`) was refused only when its own scenario started. With `--scenarios root,cwd,read,shadow` the harness ran three times, and was billed for them, before the ReachError, and no reach.json was saved. The workspace is the same in every scenario, so run_reach now checks every planned shadow path once, right after build_workspace and before the harness's version command or any scenario (_check_shadow_paths). _shadowed keeps its own check. Contract §7 and the changelog fragment say when the refusal comes; a test with a harness that counts its calls gets the ReachError after none. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Review of intel#367 (comment 4446227118): `repo reach --dry-run` and the `--only` check planned from probe_files(), before run_reach drops the links it cannot follow. With `sub/AGENTS.md -> nowhere.md`, the dry run listed `cwd=sub`, `read=sub` and `shadow=sub`, which a run never executes: it lists the link under `unresolved` and probes nothing in `sub/`. followable() in repos/reach.py returns the probed files without those links, with resolve_links's groups and unresolved list. run_reach and the command both plan from it, so the dry run, the `--only` check and the scenario count match the run for every kind. The dry run's `files` key still lists every probed path. A test plants the dangling link: the dry run lists `cwd=.` and `shadow=.` only, and `--only read=sub` is a usage error. Changelog fragment, category Fixed. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Settles a review point on the reach shadow kind: with only `AGENTS.md -> nowhere.md` as guidance, `repo reach --dry-run` (with `--scenarios shadow --only shadow=.`, or the default `cwd=.`) listed a scenario and exited 0, while a run stopped at run_reach's "no guidance file to probe" guard with exit 1. followable() now holds that guard, so run_reach and the command share it. The command's planning moves into _reach_plan, which calls followable() before the dry run, the --only check and the scenario count, and exits 1 on that refusal as a run does. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Settles review comment 4447141458 on intel#367: with every shadow path already in the tree (AGENTS.md, CLAUDE.local.md, sub/AGENTS.md, sub/CLAUDE.local.md and --scenarios shadow), `repo reach --dry-run` printed `"runs": []` and exited 0, while the run exited 2 with "no scenario selected". _reach_plan returned from its --dry-run branch before the count check. The dry run now goes through the same path as a run up to its first harness call. _reach_plan makes the count check in both modes, the command makes its receipt-over-probe check before the dry run, and run_reach takes dry_run: it builds the workspace (now in _prepare, shared with the run), makes the checks of the build, removes the workspace and returns the files and the planned runs before the harness's version command. That also closes the other gaps a look over the run's refusals found, each of which the dry run printed a plan for and exited 0: a receipt that would replace the probe (2), a tree with no guidance file, a guidance file above the workspace, a guidance file that is not UTF-8, a tree path the workspace cannot hold, and a shadow path under a probe file (1). Contract §7, the --dry-run help and the dry-run changelog fragment say so. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Settles review comment 4447340550 on the reach shadow follow-up: a --work-dir that is missing, not a directory or not writable made tempfile.mkdtemp raise FileNotFoundError, NotADirectoryError or PermissionError, which neither run_reach nor the command caught, so a dry run and a run alike ended in a traceback. run_reach now makes its directory in _make_base, which turns that OSError into a ReachError naming the directory; the command's existing handler prints it as a single error line with exit 1. The same applies to a write the workspace refuses while _prepare builds it (a full disk): "cannot build the probe workspace <dir>: <reason>". _prepare picks the salt before it builds, since that step reads only the tree, so a tree read is not reported as a workspace failure. The out-dir check already caught OSError, and removing the workspace ignores failures, so neither needed a change. Tests cover a missing, file-valued and unwritable (mkdtemp patched to raise PermissionError) --work-dir and a full disk while seeding, each in both modes; the contract's dry-run paragraph and the changelog fragment say so. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Settles review comment 4447531771 on the reach shadow follow-up: the check for guidance files above the workspace looked for the built-in names and the profile's guidance.names only. A custom profile with guidance.names [AGENTS.md] and guidance.shadow_file MEMORY.md, run with a --work-dir that holds a MEMORY.md, passed that check, though a harness that loads MEMORY.md by convention would read the one above the workspace in every scenario. _prepare now takes the names from _guarded: when a shadow scenario is planned, the file name (last path part) of each planned shadow path, which shadow_for has already checked, joins ANCESTOR_GUARD and the profile's guidance names. The default CLAUDE.local.md was in ANCESTOR_GUARD already. Without a shadow scenario the names are unchanged. A run and a dry run share _prepare, so both refuse alike. Tests: MEMORY.md, .memory/MEMORY.md and CLAUDE.local.md as shadow files, each with its name in the --work-dir, give the same refusal and exit 1 in a dry run and a run, with no harness call; without a shadow scenario the dry run plans as before. The contract's ancestor-guard line and the changelog fragment say so. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
napetrov
left a comment
There was a problem hiding this comment.
Re-review of fa9d2e4..3869f52: the late refusal is fixed. One new blocking issue in the ancestor guard (inline). Nit: on a tree with no guidance file, a dry run with --scenarios cwd now exits 2 with no scenario selected instead of no guidance files to probe, so the message names the wrong cause.
Coverage: round diff, plain + edge passes.
vlad-perevezentsev
left a comment
There was a problem hiding this comment.
Review at 3869f52. Both points from my last round are fixed: the receipt leaves the shadow scenarios out (5aea368), and a bad shadow path is refused before any harness call (a708c98). The dry run now makes the same checks as a run. One gap in a new test on Windows is in the line comments.
Agreed with @napetrov on the ancestor guard that keeps only the last part of shadow_file (I added the reverse case in its thread), and on the "no scenario selected" message for a tree with no guidance file.
What I ran, at 3869f52:
make ci-local --offline: 1 failed, 18 passed, 13 skipped. The failure isschema_compatagainst currentmain:headroom.v1.jsonandrepo_environment.v1structurecame in with #279 and #349 after this branch's base. This PR adds only a warning there, for the optionalshadowproperty.- A dry run with
shadow_file: .claude/MEM.md: a.claude/MEM.mdabove--work-dirpasses the guard, and a plainMEM.mdthere is refused. - A tree with no guidance file and
--scenarios cwd: a run and a dry run both exit 2 withno scenario selected. - On Windows (Server 2025, Python 3.13):
pytest tests/test_repo_reach_shadow.py tests/test_repo_reach.py: 146 passed, 2 failed, 1 skipped (POSIX signals). Both failures are the new--work-dirtest, which expects the POSIX error text.
Checked, no issues
- Dry run parity:
_reach_planandrun_reachplan fromfollowable(), and the dry run stops inrun_reachafter_prepare, before the version command. The workspace is removed with--keeptoo. - Fix tests:
test_the_receipt_leaves_the_shadow_scenarios_outandtest_a_bad_shadow_path_is_refused_before_any_harness_callfail with the fix taken out (a mutation run at 3869f52). - Changelog: the new Fixed fragment covers the dry-run and
--work-dirchanges.
Conflict: - dokimi/docs/repo-evaluation-contract.md, §7's per-scenario record list: main reworded the `prose` bullet (a lead-in line of at most 12 words ending in `:` is not counted); this branch added the `shadow` bullet after it. Kept main's `prose` bullet and this branch's `shadow` bullet. docs/reference/cli-interface.json merged without a conflict; a fresh `scripts/cli_surface.py dump` of the merged tree matches it. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Settles the review point that the ancestor guard kept only the last part of guidance.shadow_file. For `shadow_file: .claude/CLAUDE.local.md` it looked for `<ancestor>/CLAUDE.local.md` and missed `<ancestor>/.claude/CLAUDE.local.md`, which the harness then loads in every scenario; for `shadow_file: .claude/MEM.md` a plain `MEM.md` above --work-dir, which that profile never loads, stopped the run. _guarded now adds profile.shadow_file as given, a relative path that shadow_for has already checked, when a shadow scenario is planned; check_ancestors already takes such paths (`.claude/CLAUDE.md`). Without a shadow scenario the guard is unchanged. Contract §7 says "the path as given" instead of "last path part", and the changelog fragment says so. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Settles the review point that test_repo_reach_with_a_work_dir_it_cannot_use_fails_cleanly_in_both_modes failed on Windows for a missing --work-dir and for one that is a file: it expected the POSIX texts "No such file or directory" and "Not a directory", where Windows says "The system cannot find the path specified". The code already printed a clean line there. The test now builds the reason as the code does, from the OSError that mkdtemp raises in that directory on this OS (strerror, else the exception), and still asserts the whole line, the "cannot make the probe workspace in --work-dir <dir>: " prefix included, and exit 1, in a dry run and a run alike. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Settles the review-body point that, on a tree with no guidance file, a run and a dry run with `--scenarios cwd` exited 2 with `no scenario selected`: the selection check in the command ran before run_reach's own check for a file to probe, so the message named the wrong cause. The check moves to reach.check_files, which run_reach and the command's _reach_plan both call; _reach_plan calls it right after probe_files, before the scenarios are selected, beside the followable guard and with its exit code. Such a tree now stops with `no guidance files to probe` and exit 1 in both modes, as a run with the default scenarios does on main. Changelog fragment: one entry. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Brings in intel#366 (budget turns off an AGENTS.md beside its own CLAUDE* file), which adds the profile key guidance.claude_displaces_agents in the same places where this branch adds guidance.shadow_file. Each conflict was two additions at the same spot; both are kept: - dokimi/dokimi/repos/harness_profile.py: both properties, HarnessProfile.shadow_file and then claude_displaces_agents. - dokimi/dokimi/repos/profiles/claude-code.yaml: both keys under guidance, each with its comment (shadow_file: CLAUDE.local.md, then claude_displaces_agents: true), and main's reworded notes. - dokimi/dokimi/schemas/harness_profile.v1.json: both optional properties under guidance. - dokimi/docs/repo-evaluation-contract.md: both lines in the §6 example profile (shadow_file, then claude_displaces_agents); main's other contract changes merged without conflict. docs/reference/cli-interface.json, regenerated with scripts/cli_surface.py dump, is unchanged. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
|
Rounds on 3869f52, head now daf61c4:
|
vlad-perevezentsev
left a comment
There was a problem hiding this comment.
LGTM
I checked daf61c4. My three points from the last round are fixed: the ancestor guard checks guidance.shadow_file as given (470aba9), the --work-dir test takes the reason from the OS (ca06d45), and a tree with no guidance file is refused before the scenario selection (f5bda12).
Merge: safe now: it merges cleanly into current main, 2 commits behind, and its reach and receipt tests pass on the merge. Once it is in, #369, #379 and #338 conflict with it (contract §6/§7, reach.py, repo.py, claude-code.yaml, harness_profile.v1.json) and need a rebase.
What I ran, at daf61c4:
make ci-local --offline: 0 failed, 19 passed, 13 skipped; 12,969 tests passed.- A mutation run with the guard fix and the no-guidance check taken out: the new cases of
test_repo_reach_guards_the_shadow_file_path_above_the_workspace,test_repo_reach_guards_no_shadow_file_name_its_path_does_not_nameandno-guidance-no-scenariofail. - On the merge with
main:test_repo_reach_shadow.py,test_repo_reach.py,test_repo_receipt.py,test_docs_code_refs.py: 282 passed. - On Windows (Server 2025, Python 3.13):
pytest tests/test_repo_reach_shadow.py tests/test_repo_reach.py: 151 passed, 1 skipped (POSIX signals).
Checked, no issues
- Merge with #366:
shadow_fileandclaude_displaces_agentssit side by side in the profile, its schema andclaude-code.yaml; the receipt still leaves the shadow rows out. - Docs and changelog: contract §7 says "the path as given", and the Fixed fragment has the no-guidance entry.
main (intel#367) added guidance.shadow_file next to claude_displaces_agents, and this branch rewords that flag's description. The three conflicts keep both: - docs/repo-evaluation-contract.md: the claude-code profile example keeps main's shadow_file line and this branch's claude_displaces_agents comment. - dokimi/repos/profiles/claude-code.yaml: main's shadow_file key and its comment, then this branch's comment on claude_displaces_agents. - dokimi/schemas/harness_profile.v1.json: main's shadow_file property, and this branch's description of claude_displaces_agents. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
Brings in main up to 39dbd2a (intel#332 first useful result, intel#357 judged recall, intel#381, intel#367 reach --scenarios shadow). Conflicts, each resolved by keeping both sides: - docs/repo-evaluation-contract.md: §7 step 3 lists the bash=<dir> scenario and then main's shadow=. / shadow=<dir> scenarios. - dokimi/commands/repo.py: cmd_repo_reach imports main's short list plus check_kinds, and runs check_kinds then check_shadow; the --scenarios help names root, cwd, read, bash, shadow and says what bash and shadow need from the profile. - dokimi/repos/reach.py: one KINDS ("root", "cwd", "read", "bash", "shadow") in place of the two each side added; Scenario keeps the bash prompt and main's shadow field; scenarios() builds read= and bash= and then main's shadow ones; run_reach keeps main's dry-run docstring and calls check_kinds first. - dokimi/repos/events.py: main split _apply into _apply_init, _apply_assistant and _apply_final and counts malformed tool_use and tool_result blocks as dropped lines. The split now carries this branch's _Stream: _apply_assistant checks the block and hands it to _tool_use (the Bash reads), and _apply_final marks permission denials through _deny. - scripts/growth_baseline.json: main's side, without the events.py::_apply entry; the split leaves _apply under the cap. tests/test_repo_useful.py (main's, from intel#332) called _apply_result with main's (block, by_id); it now passes the branch's (out, block, stream). docs/reference/cli-interface.json was regenerated with scripts/cli_surface.py dump and matches the merge. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
* feat(repo): reach --scenarios shadow adds a CLAUDE.local.md Follow-up to the dokimi review of IntelPython/dpnp#3099 (item 4). repo reach probed the tree as it is, so on a tree with AGENTS.md files and no CLAUDE* file it could not show that a CLAUDE* file turns them off, although budget.py's `off` bullet said the reach probe re-measures that rule. - Harness profiles take an optional guidance.shadow_file (harness_profile.v1 widened in place); the claude-code profile sets it to CLAUDE.local.md. - A new --scenarios kind, shadow, outside the default root,cwd,read: shadow=. writes the shadow file at the root and runs as cwd=.; shadow=<dir> writes it in each guidance directory holding a file not named CLAUDE* and runs as read=<dir>. A directory where the tree already has a file at that path is skipped. The file is seeded with its own tokens (distinct_salt counts them), never committed, and removed when its scenario ends, after an error too. - Its result is a `shadow` record on the scenario row (guidance_reach.v1 widened in place) and one line under the matrix; the matrix keeps one row per tree file. --dry-run lists the shadow scenarios with their file. - A profile without guidance.shadow_file refuses the kind (exit 2). The --scenarios help names the default instead of "all". - Contract sections 6 and 7 say so. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): reach shadow skips blocked paths, refuses probe-file parents Settles two review points on the reach shadow scenarios. A shadow path was free when no tree file sat at it or under it. It is now also taken when a tree file sits above it (AGENTS.md for a shadow file AGENTS.md/local.md) and when the tree holds a directory at it, which the file list leaves out when the directory is empty: Shadow asks the tree (RepoTree.is_dir) once per candidate path, and a path the tree cannot resolve counts as taken. In the workspace, a shadow path that would go under one of the probe's own entries that is not a directory (a REACH_PROBE.txt, a seeded file, a link) is refused with a ReachError before anything is written, as a path that would replace one already was. The cleanup removes only what the call made: the file once its write has begun, and the outermost directory it created. An OSError while creating becomes a ReachError naming the path, after that cleanup, so `repo reach` reports it as `✗ ...` with exit 1 instead of a traceback. The contract's §7 and the changelog fragment say what the skip covers. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): reach shadow treats a looping ignored link as taken Settles review comment 4443412079 on intel#367. A git working tree can hold an ignored CLAUDE.local.md that is a link to itself: it is in no file list, so Shadow.free asks WorkTree.is_dir, and Path.resolve raises RuntimeError on the loop before Python 3.13. That escaped as a traceback from `repo reach --scenarios shadow`, --dry-run included. Shadow.free now counts RuntimeError and OSError, the failures WorkTree.resolve already maps, as taken, as it does a SourceError. WorkTree.is_dir is unchanged for its other callers. On Python 3.13+ resolve does not raise for the loop, so the link reads as an ignored non-directory, which the skeleton workspace never holds. The tests use a real ignored looping link in a git working tree, for the scenario list and for the --dry-run command, each also with Path.resolve raising RuntimeError on the loop as before Python 3.13; and a tree whose is_dir raises each of the three errors. Comment 4443412060 needs no code change. The contract's skip line now says what the code compares: the tree's files ignoring case, an empty directory by the disk's own lookup. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): reach receipt leaves the shadow scenarios out Review of intel#367 (comment 4445805701): `repo reach --format receipt` built one receipt per scenario row and did not know about a row's `shadow` record. A `shadow=.` receipt therefore described the tree as committed while the run had a CLAUDE.local.md the tree lacks, and said `AGENTS.md: expected true, observed not_loaded` with the shadow file in none of its sources. build_receipt now skips a row that carries a `shadow` record, for a live run and for `--from` alike: the receipt describes the tree as committed, and the shadow results stay in reach.json and the matrix. Contract §7.1 and the changelog fragment say so; a test runs `root,shadow` and gets a receipt with the `cwd=.` row only. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): reach checks shadow paths before any scenario runs Review of intel#367 (comments 4445805709 and 4446227108): a shadow path that went under or over a probe file (`shadow_file: REACH_PROBE.txt/x.md`) was refused only when its own scenario started. With `--scenarios root,cwd,read,shadow` the harness ran three times, and was billed for them, before the ReachError, and no reach.json was saved. The workspace is the same in every scenario, so run_reach now checks every planned shadow path once, right after build_workspace and before the harness's version command or any scenario (_check_shadow_paths). _shadowed keeps its own check. Contract §7 and the changelog fragment say when the refusal comes; a test with a harness that counts its calls gets the ReachError after none. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): reach --dry-run plans from the files a run probes Review of intel#367 (comment 4446227118): `repo reach --dry-run` and the `--only` check planned from probe_files(), before run_reach drops the links it cannot follow. With `sub/AGENTS.md -> nowhere.md`, the dry run listed `cwd=sub`, `read=sub` and `shadow=sub`, which a run never executes: it lists the link under `unresolved` and probes nothing in `sub/`. followable() in repos/reach.py returns the probed files without those links, with resolve_links's groups and unresolved list. run_reach and the command both plan from it, so the dry run, the `--only` check and the scenario count match the run for every kind. The dry run's `files` key still lists every probed path. A test plants the dangling link: the dry run lists `cwd=.` and `shadow=.` only, and `--only read=sub` is a usage error. Changelog fragment, category Fixed. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): reach --dry-run refuses a tree of unfollowable links Settles a review point on the reach shadow kind: with only `AGENTS.md -> nowhere.md` as guidance, `repo reach --dry-run` (with `--scenarios shadow --only shadow=.`, or the default `cwd=.`) listed a scenario and exited 0, while a run stopped at run_reach's "no guidance file to probe" guard with exit 1. followable() now holds that guard, so run_reach and the command share it. The command's planning moves into _reach_plan, which calls followable() before the dry run, the --only check and the scenario count, and exits 1 on that refusal as a run does. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): reach --dry-run refuses what a run refuses Settles review comment 4447141458 on intel#367: with every shadow path already in the tree (AGENTS.md, CLAUDE.local.md, sub/AGENTS.md, sub/CLAUDE.local.md and --scenarios shadow), `repo reach --dry-run` printed `"runs": []` and exited 0, while the run exited 2 with "no scenario selected". _reach_plan returned from its --dry-run branch before the count check. The dry run now goes through the same path as a run up to its first harness call. _reach_plan makes the count check in both modes, the command makes its receipt-over-probe check before the dry run, and run_reach takes dry_run: it builds the workspace (now in _prepare, shared with the run), makes the checks of the build, removes the workspace and returns the files and the planned runs before the harness's version command. That also closes the other gaps a look over the run's refusals found, each of which the dry run printed a plan for and exited 0: a receipt that would replace the probe (2), a tree with no guidance file, a guidance file above the workspace, a guidance file that is not UTF-8, a tree path the workspace cannot hold, and a shadow path under a probe file (1). Contract §7, the --dry-run help and the dry-run changelog fragment say so. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): reach reports a --work-dir it cannot use, not a traceback Settles review comment 4447340550 on the reach shadow follow-up: a --work-dir that is missing, not a directory or not writable made tempfile.mkdtemp raise FileNotFoundError, NotADirectoryError or PermissionError, which neither run_reach nor the command caught, so a dry run and a run alike ended in a traceback. run_reach now makes its directory in _make_base, which turns that OSError into a ReachError naming the directory; the command's existing handler prints it as a single error line with exit 1. The same applies to a write the workspace refuses while _prepare builds it (a full disk): "cannot build the probe workspace <dir>: <reason>". _prepare picks the salt before it builds, since that step reads only the tree, so a tree read is not reported as a workspace failure. The out-dir check already caught OSError, and removing the workspace ignores failures, so neither needed a change. Tests cover a missing, file-valued and unwritable (mkdtemp patched to raise PermissionError) --work-dir and a full disk while seeding, each in both modes; the contract's dry-run paragraph and the changelog fragment say so. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): reach guards the shadow file's name above the workspace Settles review comment 4447531771 on the reach shadow follow-up: the check for guidance files above the workspace looked for the built-in names and the profile's guidance.names only. A custom profile with guidance.names [AGENTS.md] and guidance.shadow_file MEMORY.md, run with a --work-dir that holds a MEMORY.md, passed that check, though a harness that loads MEMORY.md by convention would read the one above the workspace in every scenario. _prepare now takes the names from _guarded: when a shadow scenario is planned, the file name (last path part) of each planned shadow path, which shadow_for has already checked, joins ANCESTOR_GUARD and the profile's guidance names. The default CLAUDE.local.md was in ANCESTOR_GUARD already. Without a shadow scenario the names are unchanged. A run and a dry run share _prepare, so both refuse alike. Tests: MEMORY.md, .memory/MEMORY.md and CLAUDE.local.md as shadow files, each with its name in the --work-dir, give the same refusal and exit 1 in a dry run and a run, with no harness call; without a shadow scenario the dry run plans as before. The contract's ancestor-guard line and the changelog fragment say so. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): reach guards the shadow file's path above the workspace Settles the review point that the ancestor guard kept only the last part of guidance.shadow_file. For `shadow_file: .claude/CLAUDE.local.md` it looked for `<ancestor>/CLAUDE.local.md` and missed `<ancestor>/.claude/CLAUDE.local.md`, which the harness then loads in every scenario; for `shadow_file: .claude/MEM.md` a plain `MEM.md` above --work-dir, which that profile never loads, stopped the run. _guarded now adds profile.shadow_file as given, a relative path that shadow_for has already checked, when a shadow scenario is planned; check_ancestors already takes such paths (`.claude/CLAUDE.md`). Without a shadow scenario the guard is unchanged. Contract §7 says "the path as given" instead of "last path part", and the changelog fragment says so. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * test(repo): reach work-dir refusal takes the OS's own reason Settles the review point that test_repo_reach_with_a_work_dir_it_cannot_use_fails_cleanly_in_both_modes failed on Windows for a missing --work-dir and for one that is a file: it expected the POSIX texts "No such file or directory" and "Not a directory", where Windows says "The system cannot find the path specified". The code already printed a clean line there. The test now builds the reason as the code does, from the OSError that mkdtemp raises in that directory on this OS (strerror, else the exception), and still asserts the whole line, the "cannot make the probe workspace in --work-dir <dir>: " prefix included, and exit 1, in a dry run and a run alike. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> * fix(repo): reach names a tree with no guidance file before selection Settles the review-body point that, on a tree with no guidance file, a run and a dry run with `--scenarios cwd` exited 2 with `no scenario selected`: the selection check in the command ran before run_reach's own check for a file to probe, so the message named the wrong cause. The check moves to reach.check_files, which run_reach and the command's _reach_plan both call; _reach_plan calls it right after probe_files, before the scenarios are selected, beside the followable guard and with its exit code. Such a tree now stops with `no guidance files to probe` and exit 1 in both modes, as a run with the default scenarios does on main. Changelog fragment: one entry. Signed-off-by: Anton Volkov <antonwolfy@gmail.com> --------- Signed-off-by: Anton Volkov <antonwolfy@gmail.com>

Summary
Follow-up to the dokimi review of IntelPython/dpnp#3099 (item 4 of its follow-ups), which waited on #116 (the Codex and Copilot CLI harness profiles).
repo reachprobes the tree as it is. On a tree withAGENTS.mdfiles and noCLAUDE*file, like dpnp's, it cannot show that aCLAUDE*file turnsAGENTS.mdoff. Yet theoffbullet ofrepos/budget.py's docstring said "the reach probe re-measures" that rule, and the dpnp review accepted a PR's account of the rule on the strength of a reach run that never had aCLAUDE.md.On
maintoday,scenarios()(repos/reach.py) builds onlyroot,cwdandreadscenarios over the tree's own files.--scenarios shadowis refused as an unknown kind (commands/repo.py), and the--scenarioshelp says "(default: all)".Measured on
main93ff3a4 (Claude Code 2.1.316, Haiku 4.5 on Bedrock) by writing the files by hand into synthetic trees, one heading and one line per guidance file (Lloaded,Ttruncated, i.e. a small model under-reporting,.not loaded):cwd=.cwd=subread=subAGENTS.md,sub/AGENTS.mdAGENTS.mdsub/AGENTS.mdCLAUDE.local.mdAGENTS.mdCLAUDE.local.mdsub/AGENTS.mdsub/CLAUDE.local.mdAGENTS.mdsub/AGENTS.mdsub/CLAUDE.local.mdA root
CLAUDE.local.mdturns everyAGENTS.mdoff. A directory's ownCLAUDE.local.mdturns that directory'sAGENTS.mdoff, also when the agent reaches the directory lazily from the root. The new scenarios makerepo reachmeasure exactly this, without anyone editing the tree.Changes
guidance.shadow_file, a new optional key of a harness profile (harness_profile.v1, widened in place; read asHarnessProfile.shadow_file). It names a file the harness loads by convention that, put in a directory, should turn the other guidance there off. The built-inclaude-codeprofile sets it toCLAUDE.local.md; no other built-in profile sets it.--scenarioskind,shadow(repos/reach.py,_shadow_scenarios). It is not in the default, which staysroot,cwd,read, so a savedreach.json,repo compareand the receipt see the same scenarios unless the user asks:shadow=.writes the shadow file at the workspace root and runs likecwd=.(started at the root, tools off).shadow=<dir>writes it in each guidance directory that holds a file not namedCLAUDE*, and runs likeread=<dir>(started at the root, read tool on, told to read<dir>/REACH_PROBE.txt).AGENTS.mdfor ashadow_fileofAGENTS.md/local.md).Shadow.freecompares the tree's files casefolded and asksRepoTree.is_dironce per candidate path, because the file list leaves out an empty directory (so an empty directory is found by the disk's own lookup). A path the tree cannot resolve also counts as taken: a link out of the tree, or an ignored link that loops (is_dirraisingSourceError,RuntimeErrororOSError)._shadowed). It is written after the workspace's commit, so it is never committed. It is seeded with its own head and tail tokens,token(salt, "<dir>/<shadow_file>", …), anddistinct_saltnow keeps those apart from every other token too. Atry/finallyremoves it after an error as well, and with it the outermost directory created for it; nothing the call did not make is removed._check_shadow_pathrefuses a shadow path that would replace a workspace file, or that would go under one that is not a directory (aREACH_PROBE.txt, a seeded file, a link).run_reachruns that check for every planned shadow path once the workspace is built, before the harness's version command and any scenario (_check_shadow_paths), so a badshadow_filecosts no harness call;_shadowedchecks again before it writes. AnOSErrorwhile creating becomes aReachErrornaming the path, after cleanup.repo reachreports either as✗ …with exit 1. Ashadow_filethat is not a relative path inside the directory is refused too."shadow": {"path", "tokens", "head", "tail", "read_by_tool", "class"}, with the classesclassifygives. It does not become afilesentry or a record, so the matrix keeps one row per tree file.guidance_reach.v1is widened in place with this optional scenario property.format_matrixadds one line per shadow scenario, e.g.shadow file of shadow=sub: sub/CLAUDE.local.md (written for this scenario only) loaded.--format receiptleaves the shadow scenarios out (build_receiptinrepos/receipt.pyskips a row with ashadowrecord), for a live run and for--fromalike. A shadow scenario runs with a file the tree does not hold, and the receipt describes the tree as committed; before, its receipt saidAGENTS.md: expected true, observed not_loadedwith the shadow file in none of its sources (review).--dry-run(planned) lists the shadow scenarios, each with the file it writes.--dry-run, the--onlycheck and the scenario count now plan from the files a run probes, for every kind:followable()(repos/reach.py) drops the linksresolve_linkscannot follow, andrun_reachand the command both use it. Before,sub/AGENTS.md -> nowhere.mdmade the dry run listcwd=sub,read=subandshadow=sub, which a run never executes (review). Thecwd=/read=part of this was already onmain.followable()also holdsrun_reach's guard against a tree whose every guidance file is such a link (no guidance file to probe: each is a link that cannot be followed: …), so the run and the command share one check. The command's planning moved into_reach_plan(commands/repo.py), which runs that check before the dry run, the--onlycheck and the scenario count, and exits 1 on it as a run does. Before, withAGENTS.md -> nowhere.mdas the only guidance, the dry run listedshadow=.(with--scenarios shadow --only shadow=.) orcwd=.(the default scenarios) and exited 0, while a run exited 1 (review). Thecwd=.part of this was already onmain.--dry-runnow makes every check a run makes before its first harness call._reach_plancounts the selected scenarios in both modes, the command checks--format receiptagainst the probe before the dry run, andrun_reachtakesdry_run: it builds the workspace (_prepare, which the run uses too), makes the checks of the build, removes the workspace and returns the files and the planned runs before the harness's version command. Before, withAGENTS.md,CLAUDE.local.md,sub/AGENTS.mdandsub/CLAUDE.local.mdand--scenarios shadow, the dry run printed"runs": []and exited 0, while a run exited 2 withno scenario selected(review). A look over the run's other refusals found six more where the dry run printed a plan and exited 0:--format receiptwitheffective_instructions.jsona link toreach.json(the run exits 2), and, each exit 1 in the run, a tree with no guidance file, a guidance file in--work-dir, a guidance file that is not UTF-8, a link path the workspace cannot hold (a:b/AGENTS.md -> ../AGENTS.md), and ashadow_fileunder a probe file (REACH_PROBE.txt/x.md). Each of these but the shadow one was already onmain, and so was the count gap (--scenarios cwdon a tree with a rootAGENTS.mdonly). The dry run now gives the run's✗line and exit code in each.--work-dirthat is missing, not a directory or not writable now stops a dry run and a run alike with✗ cannot make the probe workspace in --work-dir <dir>: <reason>and exit 1:run_reachmakes its directory in_make_base, which turnstempfile.mkdtemp'sOSErrorinto aReachError, and the command's existing handler prints it. A write the workspace refuses while_preparebuilds it (a full disk) is✗ cannot build the probe workspace <dir>: <reason>, exit 1, and the workspace is still removed;_preparepicks the salt, which reads the tree only, before it builds. Before, each ended in aFileNotFoundError,NotADirectoryError,PermissionErrororOSErrortraceback, in a run as onmainand, since the dry run builds the workspace, in a dry run too (review). The out-dir check already caughtOSError, and the workspace's removal ignores failures.check_ancestors) also looks for the shadow file:_guarded(repos/reach.py) adds the profile'sguidance.shadow_fileas given, a relative pathshadow_forhas already checked, toANCESTOR_GUARDand the profile'sguidance.names. Before, a custom profile withguidance.names: [AGENTS.md]andshadow_file: MEMORY.mdpassed that check with aMEMORY.mdin--work-dir, though a harness that loads that file by convention would read it in every scenario. A run and a dry run now both stop with✗ guidance files above the workspace would leak into every scenario: <work-dir>/MEMORY.md (use --work-dir outside that tree)and exit 1 (review). The guard first took only the last part of the path, so withshadow_file: .claude/CLAUDE.local.mdit looked for<ancestor>/CLAUDE.local.mdand missed<ancestor>/.claude/CLAUDE.local.md, which the harness then loads in every scenario, and withshadow_file: .claude/MEM.mda plainMEM.mdin--work-dir, which that profile never loads, stopped the run (review). Now the first stops a run and a dry run with exit 1 and the second plans as usual.CLAUDE.local.mdwas already inANCESTOR_GUARD. Without a shadow scenario the guarded paths are as before.check_files(repos/reach.py) holdsrun_reach'sno guidance files to probe: …check, and_reach_plancalls it right afterprobe_files, with exit 1 as a run gives. Before,--scenarios cwdon such a tree (no subdirectory to start in) stopped a run and a dry run withno scenario selected (check --scenarios and --only)and exit 2, naming the wrong cause; the run did so onmaintoo. With the default scenarios such a tree already stopped with exit 1.main(3258d6c) is merged in. The one conflict was contract §7's list of what each scenario records:mainreworded theproseitem (a short lead-in line ending in:is not counted), this branch added theshadowitem after it; both are kept.docs/reference/cli-interface.jsonmerged cleanly, and a freshscripts/cli_surface.py dumpmatches it.main(c17f630) is merged in again, bringing fix(repo): budget turns off an AGENTS.md beside its own CLAUDE* file #366. fix(repo): budget turns off an AGENTS.md beside its own CLAUDE* file #366 added the profile keyguidance.claude_displaces_agentsat the same places where this branch addsguidance.shadow_file(harness_profile.py,claude-code.yaml,harness_profile.v1.json, the contract's §6 example profile); each conflict kept both keys, both properties and both lines. A freshscripts/cli_surface.py dumpmatchesdocs/reference/cli-interface.json.shadow_file,--scenarios shadowis a usage error: exit 2, before any clone, with--scenarios shadow needs guidance.shadow_file in the harness profile; <name> sets none. The--scenarioshelp now names the default and the new kind.shadowrecord, the synopsis line, and in step 1 that a tree of links that cannot be followed stops a run and a dry run alike, and that the ancestor guard covers the shadow file's path, as given, when a shadow scenario is planned; a new paragraph on what--dry-runchecks, that it builds and removes the workspace, and that a--work-dirit cannot use or a refused workspace write stops both modes with exit 1), §7.1 (no receipt for a shadow scenario). The--dry-runhelp says it calls no harness and builds the workspace.--work-dirit cannot use, and a tree with no guidance file).Risk
--scenarios shadowthe default scenario set,reach.json, the matrix and the receipt are as before.--dry-runoutput only gains ashadowkey on shadow scenarios.--dry-runnow leaves out the scenarios of a directory that only that link put there, as a run does; itsfileskey still lists every probed path.--onlynaming such a scenario (--only read=subabove) was accepted before and ran nothing; it is now a usage error, exit 2.--dry-runnow exits 1 with nothing on stdout instead of printing a plan, and--onlynaming a scenario that is not there exits 1 with the run's message, not 2 with--only … matches no scenario: the tree is refused before the selection is checked, as a run refuses it.--dry-runnow exits 2 or 1 with the run's✗line, and nothing on stdout, in the seven cases listed under Changes, where it printed a plan and exited 0. To check them it reads every guidance file (on a blob-less clone that fetches those blobs, as a run does), writes the workspace in a temporary directory,--work-dirwhen given, runsgit init,addandcommitthere, and removes it, with--keeptoo. It still runs no harness, its version command included, so a--binthat cannot run is found by a run only. A run's output and exit codes are unchanged, except that a--work-dirit cannot use, or a workspace write the disk refuses, now ends it with exit 1 and a✗line instead of a traceback.shadow_fileis a path that is not already guarded (MEMORY.mdor.claude/CLAUDE.local.md, say) now makes a run and a dry run exit 1 when an ancestor of the workspace holds a file at that path. The built-inclaude-codeprofile'sCLAUDE.local.mdwas already guarded, so it behaves as before.--scenarios cwd) now exits 1 withno guidance files to probe, not 2 withno scenario selected, in a run and a dry run; so does an--onlynaming a scenario that is not there, which exited 2 with--only … matches no scenario.schema_compatwarns, as expected:/scenarios[]/shadowwas unconstrained and is now constrained (a new property on an open object; no writer wrote it before).reach.json, like any otherReachErrorinrun_reach, now before any scenario runs. Tree paths are skipped before that, so only ashadow_filethat goes throughREACH_PROBE.txt(REACH_PROBE.txt/x.md), or a filesystem error while writing, can hit it.CLAUDE.local.mdcannot.Path.resolve()raises on the loop. On 3.13 and later a non-strictresolve()returns the link, which is not a directory, soshadow=.is scheduled there, as for any other ignored file at that path; the workspace never holds that link.Testing
On a Linux review host, at
daf61c4(onmainc17f630):make ci-local: 28 passed, 2 failed, 7 skipped. The two failures are the host's:Install gitleaksgot HTTP 504 on each of its four download attempts (curl: (22) The requested URL returned error: 504), in two runs, soScan commitshad nogitleaksbinary (exit 127). The skipped steps: the external-catalog smoke (needs docker and network),test-latest, docs deploy and the pip-audit report (job conditions false on a PR),cli-surface / Package the result(an expression ci-local does not evaluate), conda-forge deps (micromamba not installed) and semgrep secrets (runs on the PR). 13022 tests passed (5 skipped); changed-line coverage 100% (184 lines). Not covered locally: the other Python matrix legs (3.11, 3.12, 3.14).tests/test_repo_reach_shadow.py, 60 tests. They use a fake harness that loads each directory's ownCLAUDE*files, or itsAGENTS.mdwhen there is none, and logs the files each run's workspace held. The tests cover:sub/of a realWorkTree), for a tree file above it (AGENTS.mdforAGENTS.md/local.md), and for a path the tree cannot resolve (is_dirraisingSourceArgumentError,RuntimeErrororOSError);CLAUDE.local.md -> CLAUDE.local.mdand anAGENTS.md:shadow_for/scenariosandrepo reach --scenarios shadow --dry-run(exit 0), each with this Python'sPath.resolveand with one that raisesRuntimeErroron the loop as before 3.13 (noshadow=.there); skipped where the OS refuses a symlink;CLAUDE.local.mdand.claude/CLAUDE.md), and never in the workspace's git;REACH_PROBE.txt,REACH_PROBE.txt/x.md): aReachErrorand the workspace unchanged; from the CLI,✗ the shadow file REACH_PROBE.txt/x.md needs REACH_PROBE.txt, a file of the probe workspace, as a directoryand exit 1; with--scenarios root,cwd,read,shadow, theReachErrorafter 0 harness calls;ReachErrornaming the path, and no file or directory left; amkdirthat loses a race to a file: aReachError, and that file left in place;shadowrecord and class, and the matrix line;root,shadowrun: thecwd=.entry only;sub/AGENTS.md -> nowhere.mdwith all four kinds:cwd=.andshadow=.only, and--only read=subexit 2;AGENTS.md -> nowhere.md, with--scenarios shadow --only shadow=.and with the default scenarios: the dry run and the real run give the same exit code (1), the same✗ no guidance file to probe: …line and an empty stdout, with no harness call;followable()refusing that list but not an empty one or a list with a file it can follow;--scenarios shadow, in both modes: exit 2,✗ no scenario selected (check --scenarios and --only), empty stdout, no harness call;reach.json, no guidance file (with the default scenarios and with--scenarios cwd), a guidance file in--work-dir, a guidance file that is not UTF-8, andREACH_PROBE.txt/x.md, in both modes: the same exit code and✗line, empty stdout, no harness call, and--work-diras it was before;--work-dir, one that is a file and an unwritable one (mkdtemppatched to raisePermissionError, so it holds as root and on Windows), and a full disk while the workspace is seeded, in both modes: exit 1, the same✗line naming the directory, with the reason taken from theOSErrorthis OS raises for thatmkdtemp(Windows words a missing directory differently), empty stdout, no harness call, nothing left in--work-dir;guidance.names: [AGENTS.md]and ashadow_fileofMEMORY.md,.memory/MEMORY.md,CLAUDE.local.mdor.claude/CLAUDE.local.md, with a file at that path in--work-dirand--scenarios root,shadow, in both modes: exit 1, the same✗ guidance files above the workspace would leak into every scenario: …line, empty stdout, no harness call, nothing left in--work-dirbut that file; with the default scenarios the dry run plans as before; withshadow_file: .claude/MEM.mdand a plainMEM.mdin--work-dir, the dry run planscwd=.,shadow=.andshadow=sub;run_reach(dry_run=True)with--keep: the files and the planned runs, the version command never called, no workspace left and no--outmade;3and""refused) and the reach schema rejecting a bad class;distinct_saltre-salting when a guidance file already holds a shadow token.00892e7and pass here. There,AGENTS.md/local.mdand an emptyCLAUDE.local.md/still scheduleshadow=.andshadow=sub, andREACH_PROBE.txt/x.mdends in aNotADirectoryErrortraceback that hides theFileExistsErrorfrommkdir.bea3086, the looping-link tests fail in the "before 3.13" mode: the scenario list andrepo reach --scenarios shadow --dry-runend inRuntimeError: Symlink loop from '.../CLAUDE.local.md'. This host runs Python 3.13, where the real loop raises nothing, so that mode stands in for 3.11 and 3.12.fa9d2e4and pass here. There, theroot,shadowreceipt holdss:cwd=.,s:shadow=.ands:shadow=sub;REACH_PROBE.txt/x.mdrunscwd=.,cwd=subandread=subbefore theReachError; and the dry run over the dangling link listscwd=.,cwd=sub,read=sub,shadow=.andshadow=sub.4f3a31aand pass here: both dry runs exit 0 (DID NOT RAISE SystemExit), andfollowable()does not raise.f91ad4band pass here: there each dry run exits 0 (DID NOT RAISE SystemExit), andrun_reachtakes nodry_run.--work-dirand a refused workspace write fail at05788a8and pass here: there each ends inFileNotFoundError,NotADirectoryError,PermissionErrororOSError([Errno 28] No space left on device) instead of exit 1.MEMORY.mdtest cases added for the shadow file's name above the workspace fail atc4abed2and pass here: there the dry run exits 0 (DID NOT RAISE SystemExit). TheCLAUDE.local.mdcase and the default-scenarios case pass at both: they pin what did not change.3869f52and pass here: there.memory/MEMORY.mdand.claude/CLAUDE.local.mdabove the work dir are not found (the dry run exits 0), a plainMEM.mdstops.claude/MEM.mdwith exit 1, and--scenarios cwdon a tree with no guidance file exits 2 withno scenario selected. The reworked--work-dirtest passes at3869f52too on this host: it changes only the expected reason, which differs on Windows.shadow=.gives bothAGENTS.mdfilesnot_loadedand the shadow fileloaded;shadow=subgivessub/AGENTS.mdnot_loadedand the rootAGENTS.mdloaded.is_dirnot asked; a path the tree cannot resolve counted as free; theexceptnarrowed back toSourceError(theRuntimeErrorandOSErrorcases and both "before 3.13" looping-link cases fail);run_reach; the early check skipping every scenario;finally; created-directory removal dropped; the written file not unlinked after a failed write; the created directory not removed after a failed write; the target unlinked whether or not this call wrote it; the file recorded only after its write succeeds;OSErrorwhile creating not turned into aReachError(two tests); the CLI not catching a run-timeReachError;loaded; classified without the transcript;distinct_saltwithout the shadow paths;scenarios()silently empty without aShadow;probe_files()again;followable()keeping the unresolved links; the--onlycheck againstprobe_files();followable()dropped (two tests); the guard raising for an empty list too; the command swallowing the refusal and planning from nothing; the command exiting 2 on it; the command filtering the links itself instead of callingfollowable();run_reach's no-guidance guard; skipping_prepare(three tests); keeping the workspace with--keep; returning after the version command;mkdtemp'sOSErrorleft untranslated (the missing, file and unwritable cases fail, rerun with the reworked test); the workspace build'sOSErrorleft untranslated (the full-disk case fails);_reach_plan(the--scenarios cwdcase fails);shadowinDEFAULT_KINDS;shadowin the CLI default;None; the key removed fromclaude-code.yaml;minLengthdropped from the profile schema; the class enum dropped from the reach schema.00892e7, Claude Code 2.1.316 and Haiku 4.5 ($0.057), on a tree withAGENTS.md,sub/CLAUDE.mdandsub/deep/AGENTS.md,--scenarios root,read,shadow:shadow=.gave every tree filenot_loadedandCLAUDE.local.mdloaded;shadow=sub/deepgavesub/deep/AGENTS.mdnot_loadedandsub/deep/CLAUDE.local.mdloaded; no workspace was left behind. With--harness codex,--scenarios shadowstops with "needs guidance.shadow_file in the harness profile; codex sets none". Not rerun atbea3086,fa9d2e4,4f3a31a,f91ad4b,05788a8,c4abed2,3869f52,f5bda12ordaf61c4, which change which paths are skipped or refused, when the refusal comes, the receipt, the dry run, the errors for an unusable--work-dirand for a tree with no guidance file, the paths guarded above the workspace, and bring inmain, not the scenarios this tree gets.repo budgetand a nestedCLAUDE*file) onmain93ff3a4, with this branch atbea3086:pytest -n auto12584 passed, 5 skipped, 6 xfailed; mypy and ruff clean. fix(repo): budget turns off an AGENTS.md beside its own CLAUDE* file #366 is now onmainc17f630, which is merged into this branch atdaf61c4, so themake ci-localrun above runs the two together.Revert
A plain revert removes the kind, the profile key and the schema properties, and brings back the dry run's old plan over a link the probe cannot follow, its plan where a run refuses before calling the harness, and the traceback for a
--work-dirit cannot use. Areach.jsonwritten with--scenarios shadowstill validates against the revertedguidance_reach.v1, because scenario items are open objects there. A profile YAML that setsguidance.shadow_filewould be refused after a revert (guidancehasadditionalProperties: false).Checklist
pytestpassesruff,mypy, and schema checks passAGENTS.md.github/.licenserc.yaml)