Skip to content

feat(repo): reach --scenarios shadow adds a CLAUDE.local.md - #367

Merged
antonwolfy merged 15 commits into
intel:mainfrom
antonwolfy:feat/reach-shadow-scenario
Nov 3, 2026
Merged

antonwolfy merged 15 commits into
intel:mainfrom
antonwolfy:feat/reach-shadow-scenario

Conversation

@antonwolfy

@antonwolfy antonwolfy commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

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 reach probes the tree as it is. On a tree with AGENTS.md files and no CLAUDE* file, like dpnp's, it cannot show that a CLAUDE* file turns AGENTS.md off. Yet the off bullet of repos/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 a CLAUDE.md.

On main today, scenarios() (repos/reach.py) builds only root, cwd and read scenarios over the tree's own files. --scenarios shadow is refused as an unknown kind (commands/repo.py), and the --scenarios help says "(default: all)".

Measured on main 93ff3a4 (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 (L loaded, T truncated, i.e. a small model under-reporting, . not loaded):

tree file cwd=. cwd=sub read=sub
AGENTS.md, sub/AGENTS.md AGENTS.md L L L
sub/AGENTS.md . L L
+ CLAUDE.local.md AGENTS.md . . .
CLAUDE.local.md L L T
sub/AGENTS.md . . .
+ sub/CLAUDE.local.md AGENTS.md L . L
sub/AGENTS.md . . .
sub/CLAUDE.local.md . L L

A root CLAUDE.local.md turns every AGENTS.md off. A directory's own CLAUDE.local.md turns that directory's AGENTS.md off, also when the agent reaches the directory lazily from the root. The new scenarios make repo reach measure 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 as HarnessProfile.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-in claude-code profile sets it to CLAUDE.local.md; no other built-in profile sets it.
  • A new --scenarios kind, shadow (repos/reach.py, _shadow_scenarios). It is not in the default, which stays root,cwd,read, so a saved reach.json, repo compare and the receipt see the same scenarios unless the user asks:
    • shadow=. writes the shadow file at the workspace root and runs like cwd=. (started at the root, tools off).
    • shadow=<dir> writes it in each guidance directory that holds a file not named CLAUDE*, and runs like read=<dir> (started at the root, read tool on, told to read <dir>/REACH_PROBE.txt).
    • A directory gets no shadow scenario when the tree already has, at the shadow path, a file or a directory (an empty one too), or a file where the path needs a directory (AGENTS.md for a shadow_file of AGENTS.md/local.md). Shadow.free compares the tree's files casefolded and asks RepoTree.is_dir once 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_dir raising SourceError, RuntimeError or OSError).
  • The shadow file exists only during its own scenario (_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>", …), and distinct_salt now keeps those apart from every other token too. A try/finally removes 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_path refuses a shadow path that would replace a workspace file, or that would go under one that is not a directory (a REACH_PROBE.txt, a seeded file, a link). run_reach runs 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 bad shadow_file costs no harness call; _shadowed checks again before it writes. An OSError while creating becomes a ReachError naming the path, after cleanup. repo reach reports either as ✗ … with exit 1. A shadow_file that is not a relative path inside the directory is refused too.
  • The shadow file's result goes on its scenario row as "shadow": {"path", "tokens", "head", "tail", "read_by_tool", "class"}, with the classes classify gives. It does not become a files entry or a record, so the matrix keeps one row per tree file. guidance_reach.v1 is widened in place with this optional scenario property. format_matrix adds one line per shadow scenario, e.g. shadow file of shadow=sub: sub/CLAUDE.local.md (written for this scenario only) loaded.
  • --format receipt leaves the shadow scenarios out (build_receipt in repos/receipt.py skips a row with a shadow record), for a live run and for --from alike. A shadow scenario runs with a file the tree does not hold, and the receipt describes the tree as committed; before, its receipt said AGENTS.md: expected true, observed not_loaded with 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 --only check and the scenario count now plan from the files a run probes, for every kind: followable() (repos/reach.py) drops the links resolve_links cannot follow, and run_reach and the command both use it. Before, sub/AGENTS.md -> nowhere.md made the dry run list cwd=sub, read=sub and shadow=sub, which a run never executes (review). The cwd=/read= part of this was already on main.
  • followable() also holds run_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 --only check and the scenario count, and exits 1 on it as a run does. Before, with AGENTS.md -> nowhere.md as the only guidance, the dry run listed shadow=. (with --scenarios shadow --only shadow=.) or cwd=. (the default scenarios) and exited 0, while a run exited 1 (review). The cwd=. part of this was already on main.
  • --dry-run now makes every check a run makes before its first harness call. _reach_plan counts the selected scenarios in both modes, the command checks --format receipt against the probe before the dry run, and run_reach takes dry_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, with AGENTS.md, CLAUDE.local.md, sub/AGENTS.md and sub/CLAUDE.local.md and --scenarios shadow, the dry run printed "runs": [] and exited 0, while a run exited 2 with no 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 receipt with effective_instructions.json a link to reach.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 a shadow_file under a probe file (REACH_PROBE.txt/x.md). Each of these but the shadow one was already on main, and so was the count gap (--scenarios cwd on a tree with a root AGENTS.md only). The dry run now gives the run's ✗ line and exit code in each.
  • A --work-dir that 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_reach makes its directory in _make_base, which turns tempfile.mkdtemp's OSError into a ReachError, and the command's existing handler prints it. A write the workspace refuses while _prepare builds it (a full disk) is ✗ cannot build the probe workspace <dir>: <reason>, exit 1, and the workspace is still removed; _prepare picks the salt, which reads the tree only, before it builds. Before, each ended in a FileNotFoundError, NotADirectoryError, PermissionError or OSError traceback, in a run as on main and, since the dry run builds the workspace, in a dry run too (review). The out-dir check already caught OSError, and the workspace's removal ignores failures.
  • When a shadow scenario is planned, the check for guidance files above the workspace (check_ancestors) also looks for the shadow file: _guarded (repos/reach.py) adds the profile's guidance.shadow_file as given, a relative path shadow_for has already checked, to ANCESTOR_GUARD and the profile's guidance.names. Before, a custom profile with guidance.names: [AGENTS.md] and shadow_file: MEMORY.md passed that check with a MEMORY.md in --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 with 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, and with shadow_file: .claude/MEM.md a plain MEM.md in --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.md was already in ANCESTOR_GUARD. Without a shadow scenario the guarded paths are as before.
  • A tree with no guidance file is refused before the scenarios are selected: check_files (repos/reach.py) holds run_reach's no guidance files to probe: … check, and _reach_plan calls it right after probe_files, with exit 1 as a run gives. Before, --scenarios cwd on such a tree (no subdirectory to start in) stopped a run and a dry run with no scenario selected (check --scenarios and --only) and exit 2, naming the wrong cause; the run did so on main too. 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: main reworded the prose item (a short lead-in line ending in : is not counted), this branch added the shadow item after it; both are kept. docs/reference/cli-interface.json merged cleanly, and a fresh scripts/cli_surface.py dump matches 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 key guidance.claude_displaces_agents at the same places where this branch adds guidance.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 fresh scripts/cli_surface.py dump matches docs/reference/cli-interface.json.
  • If the profile has no shadow_file, --scenarios shadow is a usage error: exit 2, before any clone, with --scenarios shadow needs guidance.shadow_file in the harness profile; <name> sets none. The --scenarios help now names the default and the new kind.
  • Docs: contract §6 (the profile key), §7 (the scenario list in step 3 with what the skip covers, how paths are compared and when a bad shadow path is refused, the shadow record, 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-run checks, that it builds and removes the workspace, and that a --work-dir it cannot use or a refused workspace write stops both modes with exit 1), §7.1 (no receipt for a shadow scenario). The --dry-run help says it calls no harness and builds the workspace.
  • Changelog fragments: Added (the shadow kind) and Fixed (the dry run's plan, its refusals, a --work-dir it cannot use, and a tree with no guidance file).

Risk

  • Without --scenarios shadow the default scenario set, reach.json, the matrix and the receipt are as before. --dry-run output only gains a shadow key on shadow scenarios.
  • On a tree with a link the probe cannot follow, --dry-run now leaves out the scenarios of a directory that only that link put there, as a run does; its files key still lists every probed path. --only naming such a scenario (--only read=sub above) was accepted before and ran nothing; it is now a usage error, exit 2.
  • On a tree whose every guidance file is a link the probe cannot follow, --dry-run now exits 1 with nothing on stdout instead of printing a plan, and --only naming 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-run now 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-dir when given, runs git init, add and commit there, and removes it, with --keep too. It still runs no harness, its version command included, so a --bin that cannot run is found by a run only. A run's output and exit codes are unchanged, except that a --work-dir it cannot use, or a workspace write the disk refuses, now ends it with exit 1 and a ✗ line instead of a traceback.
  • With a shadow scenario planned, a profile whose shadow_file is a path that is not already guarded (MEMORY.md or .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-in claude-code profile's CLAUDE.local.md was already guarded, so it behaves as before.
  • On a tree with no guidance file, a selection that leaves no scenario (--scenarios cwd) now exits 1 with no guidance files to probe, not 2 with no scenario selected, in a run and a dry run; so does an --only naming a scenario that is not there, which exited 2 with --only … matches no scenario.
  • schema_compat warns, as expected: /scenarios[]/shadow was unconstrained and is now constrained (a new property on an open object; no writer wrote it before).
  • A shadow path refused by the workspace check stops the run with exit 1 and no reach.json, like any other ReachError in run_reach, now before any scenario runs. Tree paths are skipped before that, so only a shadow_file that goes through REACH_PROBE.txt (REACH_PROBE.txt/x.md), or a filesystem error while writing, can hit it. CLAUDE.local.md cannot.
  • On a case-sensitive disk, an empty directory whose name differs from the shadow file only in case is not seen, so it does not skip its scenario. The probe workspace never holds that directory, and the tree itself is never written, so nothing is replaced.
  • An ignored link at the shadow path that loops is taken on Python 3.11 and 3.12, where Path.resolve() raises on the loop. On 3.13 and later a non-strict resolve() returns the link, which is not a directory, so shadow=. 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 (on main c17f630):

  • make ci-local: 28 passed, 2 failed, 7 skipped. The two failures are the host's: Install gitleaks got HTTP 504 on each of its four download attempts (curl: (22) The requested URL returned error: 504), in two runs, so Scan commits had no gitleaks binary (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 own CLAUDE* files, or its AGENTS.md when there is none, and logs the files each run's workspace held. The tests cover:
    • the scenario list with and without the profile key;
    • the skip for a tree path at the shadow path (exact, other case, a directory with files, an empty directory at the root and at sub/ of a real WorkTree), for a tree file above it (AGENTS.md for AGENTS.md/local.md), and for a path the tree cannot resolve (is_dir raising SourceArgumentError, RuntimeError or OSError);
    • a real git working tree with an ignored CLAUDE.local.md -> CLAUDE.local.md and an AGENTS.md: shadow_for / scenarios and repo reach --scenarios shadow --dry-run (exit 0), each with this Python's Path.resolve and with one that raises RuntimeError on the loop as before 3.13 (no shadow=. there); skipped where the OS refuses a symlink;
    • the shadow file being present in its own scenario's workspace only, removed afterwards with the directory made for it (CLAUDE.local.md and .claude/CLAUDE.md), and never in the workspace's git;
    • its removal after a failed harness and after a scenario that raises;
    • a shadow path at or under a probe file (REACH_PROBE.txt, REACH_PROBE.txt/x.md): a ReachError and 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 directory and exit 1; with --scenarios root,cwd,read,shadow, the ReachError after 0 harness calls;
    • a write that fails part way, at the root and two levels down: a ReachError naming the path, and no file or directory left; a mkdir that loses a race to a file: a ReachError, and that file left in place;
    • the row's shadow record and class, and the matrix line;
    • the receipt of a root,shadow run: the cwd=. entry only;
    • the dry run over sub/AGENTS.md -> nowhere.md with all four kinds: cwd=. and shadow=. only, and --only read=sub exit 2;
    • a tree whose only guidance is 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;
    • the reviewer's tree (every shadow path a tree file) with --scenarios shadow, in both modes: exit 2, ✗ no scenario selected (check --scenarios and --only), empty stdout, no harness call;
    • the receipt over a link to 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, and REACH_PROBE.txt/x.md, in both modes: the same exit code and ✗ line, empty stdout, no harness call, and --work-dir as it was before;
    • a missing --work-dir, one that is a file and an unwritable one (mkdtemp patched to raise PermissionError, 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 the OSError this OS raises for that mkdtemp (Windows words a missing directory differently), empty stdout, no harness call, nothing left in --work-dir;
    • a custom profile with guidance.names: [AGENTS.md] and a shadow_file of MEMORY.md, .memory/MEMORY.md, CLAUDE.local.md or .claude/CLAUDE.local.md, with a file at that path in --work-dir and --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-dir but that file; with the default scenarios the dry run plans as before; with shadow_file: .claude/MEM.md and a plain MEM.md in --work-dir, the dry run plans cwd=., shadow=. and shadow=sub;
    • run_reach(dry_run=True) with --keep: the files and the planned runs, the version command never called, no workspace left and no --out made;
    • the usage error (exit 2) and the dry run;
    • the profile schema (string accepted, 3 and "" refused) and the reach schema rejecting a bad class;
    • distinct_salt re-salting when a guidance file already holds a shadow token.
  • The 9 test cases added for the ancestor and empty-directory fixes fail on the previous head 00892e7 and pass here. There, AGENTS.md/local.md and an empty CLAUDE.local.md/ still schedule shadow=. and shadow=sub, and REACH_PROBE.txt/x.md ends in a NotADirectoryError traceback that hides the FileExistsError from mkdir.
  • At bea3086, the looping-link tests fail in the "before 3.13" mode: the scenario list and repo reach --scenarios shadow --dry-run end in RuntimeError: 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.
  • The 3 tests added for the receipt, the early check and the dry run fail at fa9d2e4 and pass here. There, the root,shadow receipt holds s:cwd=., s:shadow=. and s:shadow=sub; REACH_PROBE.txt/x.md runs cwd=., cwd=sub and read=sub before the ReachError; and the dry run over the dangling link lists cwd=., cwd=sub, read=sub, shadow=. and shadow=sub.
  • The 3 test cases added for the tree of unfollowable links fail at 4f3a31a and pass here: both dry runs exit 0 (DID NOT RAISE SystemExit), and followable() does not raise.
  • The 7 test cases added for the dry run's refusals fail at f91ad4b and pass here: there each dry run exits 0 (DID NOT RAISE SystemExit), and run_reach takes no dry_run.
  • The 4 test cases added for an unusable --work-dir and a refused workspace write fail at 05788a8 and pass here: there each ends in FileNotFoundError, NotADirectoryError, PermissionError or OSError ([Errno 28] No space left on device) instead of exit 1.
  • The 2 MEMORY.md test cases added for the shadow file's name above the workspace fail at c4abed2 and pass here: there the dry run exits 0 (DID NOT RAISE SystemExit). The CLAUDE.local.md case and the default-scenarios case pass at both: they pin what did not change.
  • The 3 test cases added for the shadow file's path and the tree with no guidance file fail at 3869f52 and pass here: there .memory/MEMORY.md and .claude/CLAUDE.local.md above the work dir are not found (the dry run exits 0), a plain MEM.md stops .claude/MEM.md with exit 1, and --scenarios cwd on a tree with no guidance file exits 2 with no scenario selected. The reworked --work-dir test passes at 3869f52 too on this host: it changes only the expected reason, which differs on Windows.
  • On the fake harness the matrix reads as Claude Code measured: shadow=. gives both AGENTS.md files not_loaded and the shadow file loaded; shadow=sub gives sub/AGENTS.md not_loaded and the root AGENTS.md loaded.
  • Mutants, each run on a copy of the committed tree; every one makes its test fail (63 of 63):
    • the skip: always free; casefold dropped; directory-prefix check dropped; directory filter dropped; the check of tree files above the path dropped; is_dir not asked; a path the tree cannot resolve counted as free; the except narrowed back to SourceError (the RuntimeError and OSError cases and both "before 3.13" looping-link cases fail);
    • the workspace check: the probe-file guard off; the check of workspace entries above the path off (two tests); the early check's call dropped from run_reach; the early check skipping every scenario;
    • the cleanup: unlink dropped; unlink moved out of 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;
    • an OSError while creating not turned into a ReachError (two tests); the CLI not catching a run-time ReachError;
    • the class: forced to loaded; classified without the transcript;
    • the shadow tokens left out of the known set;
    • distinct_salt without the shadow paths;
    • the usage check off; scenarios() silently empty without a Shadow;
    • the unsafe-path check off;
    • the matrix line dropped; the dry-run key dropped;
    • the receipt keeping shadow rows;
    • the dry run planning from probe_files() again; followable() keeping the unresolved links; the --only check against probe_files();
    • the guard in 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 calling followable();
    • the dry run skipping the scenario count; skipping the receipt check; skipping run_reach's no-guidance guard; skipping _prepare (three tests); keeping the workspace with --keep; returning after the version command;
    • mkdtemp's OSError left untranslated (the missing, file and unwritable cases fail, rerun with the reworked test); the workspace build's OSError left untranslated (the full-disk case fails);
    • the shadow file left out of the guarded paths; guarded without a shadow scenario; its last part guarded instead of the path as given;
    • the command's no-guidance check dropped from _reach_plan (the --scenarios cwd case fails);
    • shadow in DEFAULT_KINDS; shadow in the CLI default;
    • the old kind set in the CLI check;
    • the profile property returning None; the key removed from claude-code.yaml;
    • minLength dropped from the profile schema; the class enum dropped from the reach schema.
  • Live probe with this branch at 00892e7, Claude Code 2.1.316 and Haiku 4.5 ($0.057), on a tree with AGENTS.md, sub/CLAUDE.md and sub/deep/AGENTS.md, --scenarios root,read,shadow: shadow=. gave every tree file not_loaded and CLAUDE.local.md loaded; shadow=sub/deep gave sub/deep/AGENTS.md not_loaded and sub/deep/CLAUDE.local.md loaded; no workspace was left behind. With --harness codex, --scenarios shadow stops with "needs guidance.shadow_file in the harness profile; codex sets none". Not rerun at bea3086, fa9d2e4, 4f3a31a, f91ad4b, 05788a8, c4abed2, 3869f52, f5bda12 or daf61c4, which change which paths are skipped or refused, when the refusal comes, the receipt, the dry run, the errors for an unusable --work-dir and for a tree with no guidance file, the paths guarded above the workspace, and bring in main, not the scenarios this tree gets.
  • Merged together with fix(repo): budget turns off an AGENTS.md beside its own CLAUDE* file #366 (repo budget and a nested CLAUDE* file) on main 93ff3a4, with this branch at bea3086: pytest -n auto 12584 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 on main c17f630, which is merged into this branch at daf61c4, so the make ci-local run 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-dir it cannot use. A reach.json written with --scenarios shadow still validates against the reverted guidance_reach.v1, because scenario items are open objects there. A profile YAML that sets guidance.shadow_file would be refused after a revert (guidance has additionalProperties: false).

Checklist

  • Tests added/updated and pytest passes
  • ruff, mypy, and schema checks pass
  • Docs updated (or N/A) — see the docs-drift checklist in CONTRIBUTING.md
  • Self-reviewed per "Before opening a PR" in AGENTS.md
  • No ad-hoc run artifacts committed
  • New files carry the SPDX license header (or are exempt in .github/.licenserc.yaml)

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>
Copilot AI balanced review requested due to automatic review settings November 2, 2026 13:40
@antonwolfy
antonwolfy requested a review from a team as a code owner November 2, 2026 13:40
@exai-bot

exai-bot commented Nov 2, 2026 •

Copy link
Copy Markdown

OpenCodeReview: Commit daf61c4 was not reviewed. This PR has reached its review limit (6 reviews).

To review this commit: re-request review from exai-bot in the Reviewers list.

Last review (f5bda12): no findings

Open threads from the bot: 0

Comment thread dokimi/dokimi/repos/reach.py Resolved

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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_file to 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

Comment thread dokimi/dokimi/repos/reach.py Resolved
@antonwolfy antonwolfy self-assigned this Nov 2, 2026
@codecov-commenter

codecov-commenter commented Nov 2, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.44%. Comparing base (c17f630) to head (daf61c4).
⚠️ Report is 1 commits behind head on main.
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

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     
Files with missing lines Coverage Δ
dokimi/dokimi/commands/repo.py 97.30% <100.00%> (+1.08%) ⬆️
dokimi/dokimi/repos/harness_profile.py 96.38% <100.00%> (+0.08%) ⬆️
dokimi/dokimi/repos/reach.py 98.51% <100.00%> (+1.64%) ⬆️
dokimi/dokimi/repos/receipt.py 98.79% <100.00%> (+0.01%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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>
Comment thread dokimi/dokimi/repos/reach.py Resolved
Comment thread dokimi/dokimi/repos/reach.py Resolved
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>
@antonwolfy

Copy link
Copy Markdown
Contributor Author

Review rounds so far:

  • bea3086: a shadow path is now also taken when a tree file sits above it (AGENTS.md for AGENTS.md/local.md) or the tree holds a directory there, an empty one included (RepoTree.is_dir). In the workspace, a path under a probe file or link is refused with a ReachError before anything is written, and cleanup removes only what the call made (exai-bot 4443116509, Copilot 4443144819).
  • fa9d2e4: a looping ignored link at the shadow path (RuntimeError from Path.resolve before Python 3.13) counts as taken instead of ending in a traceback (exai-bot 4443412079). No code change for the case-sensitive empty-directory point (4443412060); the reason is in its thread, and contract §7's skip line now says what is compared.

All four threads are answered and resolved. CI is green at fa9d2e4.

@vlad-perevezentsev vlad-perevezentsev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, then build_receipt: the shadow=. receipt has root AGENTS.md expected true, observed not_loaded, with no precedence entry and no unknowns.
  • A profile with shadow_file: REACH_PROBE.txt/x.md and --scenarios root,cwd,read,shadow: three harness runs, then the ReachError, and no reach.json.
  • On Windows (Server 2025, Python 3.13): pytest tests/test_repo_reach_shadow.py: 36 passed, 0 skipped.

Checked, no issues

  • Tokens: distinct_salt covers the shadow tokens, and _score_row keeps the shadow file's own tokens out of unknown.
  • Cleanup: only what _shadowed made is removed; the failed-write and lost-race tests cover it.
  • Schemas and defaults: guidance_reach.v1 and harness_profile.v1 are only widened (an optional property each); shadow is not in the default kinds, so published numbers do not change.
  • Docs and changelog: contract §6 and §7 and the --scenarios help match the code; the fragment is under Added.

Comment thread dokimi/dokimi/repos/reach.py Resolved
Comment thread dokimi/dokimi/repos/reach.py Resolved

@napetrov napetrov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread dokimi/dokimi/repos/reach.py Resolved
Comment thread dokimi/dokimi/commands/repo.py Resolved
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>
Comment thread dokimi/dokimi/commands/repo.py Resolved
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>
Comment thread dokimi/dokimi/commands/repo.py Resolved
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>
Comment thread dokimi/dokimi/commands/repo.py Resolved
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>
Comment thread dokimi/dokimi/repos/reach.py Resolved
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 napetrov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread dokimi/dokimi/repos/reach.py Resolved

@vlad-perevezentsev vlad-perevezentsev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 is schema_compat against current main: headroom.v1.json and repo_environment.v1 structure came in with #279 and #349 after this branch's base. This PR adds only a warning there, for the optional shadow property.
  • A dry run with shadow_file: .claude/MEM.md: a .claude/MEM.md above --work-dir passes the guard, and a plain MEM.md there is refused.
  • A tree with no guidance file and --scenarios cwd: a run and a dry run both exit 2 with no 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-dir test, which expects the POSIX error text.

Checked, no issues

  • Dry run parity: _reach_plan and run_reach plan from followable(), and the dry run stops in run_reach after _prepare, before the version command. The workspace is removed with --keep too.
  • Fix tests: test_the_receipt_leaves_the_shadow_scenarios_out and test_a_bad_shadow_path_is_refused_before_any_harness_call fail with the fix taken out (a mutation run at 3869f52).
  • Changelog: the new Fixed fragment covers the dry-run and --work-dir changes.

Comment thread dokimi/tests/test_repo_reach_shadow.py Resolved
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>
@antonwolfy
antonwolfy requested a review from exai-bot November 3, 2026 13:58
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>
@antonwolfy

Copy link
Copy Markdown
Contributor Author

Rounds on 3869f52, head now daf61c4:

  • a7a2240: merge of main (fix(repo): reach prose count skips a short lead-in line before the tokens #368's reworded prose item and this branch's shadow item, both kept in contract §7).
  • 470aba9 (napetrov, with @vlad-perevezentsev's reverse case): the ancestor guard checks guidance.shadow_file as given, not its last part. With .claude/CLAUDE.local.md above --work-dir the run is now refused. A plain MEM.md there no longer stops a profile whose shadow_file is .claude/MEM.md. Contract §7 says "the path as given".
  • ca06d45 (@vlad-perevezentsev, Windows): the --work-dir test builds the expected reason from the OSError the OS raises, not a POSIX text.
  • f5bda12 (napetrov's review-body nit): a tree with no guidance file is refused before scenario selection. --scenarios cwd on such a tree now exits 1 with no guidance files to probe in a run and a dry run, not 2 with no scenario selected.
  • daf61c4: merge of main again, bringing fix(repo): budget turns off an AGENTS.md beside its own CLAUDE* file #366. Its guidance.claude_displaces_agents and this branch's guidance.shadow_file sit side by side in harness_profile.py, claude-code.yaml, harness_profile.v1.json and contract §6.

make ci-local at f5bda12: 30 passed, 0 failed (13000 tests). At daf61c4, 13022 tests passed. Its only failures were the host's gitleaks download (HTTP 504), and CI's scan passes. CI is green. OpenCodeReview found 0 findings on f5bda12.

@vlad-perevezentsev vlad-perevezentsev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_name and no-guidance-no-scenario fail.
  • 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_file and claude_displaces_agents sit side by side in the profile, its schema and claude-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.

@antonwolfy
antonwolfy merged commit 39dbd2a into intel:main Nov 3, 2026
21 checks passed
@antonwolfy
antonwolfy deleted the feat/reach-shadow-scenario branch November 3, 2026 18:54
antonwolfy added a commit to antonwolfy/dokimi that referenced this pull request Nov 3, 2026
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>
antonwolfy added a commit to antonwolfy/dokimi that referenced this pull request Nov 3, 2026
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>
napetrov pushed a commit to napetrov/dokimi that referenced this pull request Nov 4, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants