diff --git a/.claude/agent-memory/atomic-executor/MEMORY.md b/.claude/agent-memory/atomic-executor/MEMORY.md index 9dee9a7ec..e71c198c3 100644 --- a/.claude/agent-memory/atomic-executor/MEMORY.md +++ b/.claude/agent-memory/atomic-executor/MEMORY.md @@ -107,3 +107,23 @@ - [Self-test probe literal trips the NEXT sweep pass](project_selftest_probe_literal_trips_the_next_sweep_pass.md) — describe probes, never quote them - [TRX sanitisation must be case-insensitive](project_trx_sanitisation_must_be_case_insensitive.md) · [TRX/msbuild evidence needs a sanitisation micro-action](project_vstest_trx_evidence_needs_sanitisation_task.md) - [PowerShell budget hook blocks scratch .ps1 helpers](project_powershell_scratch_script_budget_hook_blocks_helpers.md) — cap counts others +- [Appending a 2nd pass must not qualify schema fields](project_appending_a_second_pass_must_not_qualify_schema_fields.md) — `Output Summary (pass 1):` deletes the field +- [Agent Bash resets cwd; use `env -C`](project_bash_cwd_resets_use_env_dash_c.md) — standalone `cd` does not persist; dotnet reads the wrong global.json +- [Changed-line coverage branch gate invalidated by the fix](project_changed_line_coverage_branch_gate_invalidated_by_the_fix.md) — the fix makes the line analyzable; baseline-keyed branch is unsatisfiable +- [CSharpier forces a blank line before a comment](project_csharpier_requires_blank_line_before_comment_breaking_numstat_bounds.md) — one comment line costs 2 insertions; breaks a "1 insertion" numstat bound +- [dotnet global.json cwd-search vs no-cd Bash discipline](project_dotnet_global_json_cwd_search_vs_bash_discipline.md) — invoke pinned SDK exe by absolute path + `--tool-manifest` +- [ExcludeFromCodeCoverage misses `this`-capturing lambdas](project_excludefromcodecoverage_misses_this_capturing_lambdas.md) — lifted beside the member, stay at hits=0; prefix is entity-encoded in the XML +- [FakeTimeProvider zero due time fires at creation](project_faketimeprovider_zero_duetime_fires_at_creation.md) — "not completed before Advance" is false at TimeSpan.Zero +- [Koverage -RepoRoot needs native separators](project_koverage_reporoot_needs_native_separators.md) — a forward-slash root strips nothing; the "processed" doc stays raw +- [MSBuild logs leak TWO absolute roots](project_msbuild_log_has_two_absolute_path_leak_classes.md) — `/analyzerconfig:` hits the ancestor checkout; sanitise worktree root FIRST +- [MSTest Deploy_ dir leaks tokens on FAILING runs only](project_mstest_deploy_dir_leaks_tokens_on_failing_runs.md) — breaks "exactly one TRX" + the name gate +- [Plan-mandated .ps1 helpers hit the budget cap + a frozen porcelain gate](project_plan_mandated_ps1_helpers_collide_with_budget_cap_and_frozen_porcelain_gate.md) — fix ONE gitignored path +- [pwsh -File starts in the SESSION root](project_pwsh_file_starts_in_session_root_needs_workingdirectory.md) — script's own `git rev-parse` hits the wrong worktree; pass `-WorkingDirectory` +- [pwsh stdin is a REPL; non-ASCII mangled](project_pwsh_stdin_repl_mode_and_nonascii_mangling.md) — multi-line blocks output NOTHING; em-dash searches read false 0 +- [Reflective property read escapes a member-expression grep](project_reflective_property_read_escapes_member_expression_grep.md) — `GetProperty("X")` is invisible to `git grep "Type.X"`; a new throwing guard broke 8 cleared tests +- [vstest leaves TWO .coverage files per run](project_vstest_emits_two_coverage_files_per_run.md) — an "exactly one attachment" gate is unsatisfiable by construction +- [ExpectedExitCode keyed off the baseline, not this run](project_expectedexitcode_declared_from_baseline_not_observed_run.md) — vacuous toward +- [Preparation mode flips anchored-diff membership](project_preparation_mode_flips_anchored_diff_gate_membership.md) — feature folder +- [FluentAssertions BeEmpty names only the first item](project_fluentassertions_beempty_names_only_first_item.md) — a gate demanding two type names in the BeEmpty failure text is unsatisfiable +- [vstest success run prints no Failed/Skipped line; TRX notExecuted is hard-coded 0](project_vstest_success_run_prints_no_failed_or_skipped_line.md) — derive Skipped as total minus executed; a gate that greps a Skipped: line on a green run is unsatisfiable +- [Worktree-isolation guard refuses pwsh launched from Bash in two shapes](project_worktree_isolation_guard_refuses_pwsh_from_bash.md) — a quoted absolute path as the command name is refused too; use a PATH= prefix and bootstrap the SDK from POSIX paths diff --git a/.claude/agent-memory/atomic-executor/powershell-bom-required.md b/.claude/agent-memory/atomic-executor/powershell-bom-required.md index 8f418f5fe..a1ce09cd8 100644 --- a/.claude/agent-memory/atomic-executor/powershell-bom-required.md +++ b/.claude/agent-memory/atomic-executor/powershell-bom-required.md @@ -9,6 +9,8 @@ When creating a new PowerShell file (e.g., `*.Tests.ps1`) in this repository, pr **Why:** PSScriptAnalyzer is configured to enforce `PSUseBOMForUnicodeEncodedFile`. This bit cycle 0 of issue #25 (one loop restart) and again cycle 1 (one loop restart). The Write tool produces UTF-8 without BOM by default on Windows. +**Scope correction (measured 2026-09-03, issue #752):** the rule fires only on a file that actually contains non-ASCII bytes. A **pure-ASCII** new `.ps1` needs no BOM. All ten pre-existing files in `tests/scripts/vscode/` are `BOM=False NONASCII=0`, and a new pure-ASCII test file there drew zero PSSA diagnostics (16 before, 16 after, identical set). So: if the plan mandates pure ASCII, write it BOM-free and match the siblings; add the BOM only when the file genuinely needs a non-ASCII character. Do match the siblings' **CRLF** line endings, and note that `[System.IO.File]::ReadAllText/WriteAllBytes` ignore `Set-Location` — pass an absolute path or you silently operate on a same-named file in another worktree. + **How to apply:** After Write-ing a new .ps1 file, run a one-shot BOM-prepend command before invoking `mcp__drm-copilot__run_poshqc_analyze`: ```powershell diff --git a/.claude/agent-memory/atomic-executor/project_appending_a_second_pass_must_not_qualify_schema_fields.md b/.claude/agent-memory/atomic-executor/project_appending_a_second_pass_must_not_qualify_schema_fields.md new file mode 100644 index 000000000..b06eaf573 --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_appending_a_second_pass_must_not_qualify_schema_fields.md @@ -0,0 +1,27 @@ +--- +name: appending-a-second-pass-must-not-qualify-schema-fields +description: Relabelling "Output Summary:" to "Output Summary (pass 1):" when appending a re-run section deletes the machine-checkable field; the completeness gate reports the artifact INCOMPLETE. +metadata: + type: project +--- + +When a contingency forces a gate to run twice and you append a "pass 2" section to the existing +artifact, do not qualify the required schema field names to disambiguate the passes. + +**Why:** `evidence-and-timestamp-conventions` requires the literal fields `Timestamp:`, `Command:`, +`EXIT_CODE:` and `Output Summary:`. Rewriting the original line to `Output Summary (pass 1):` and +adding `Output Summary (final, ...):` leaves the file with ZERO occurrences of the exact literal, so +a completeness audit reports the artifact incomplete even though it reads as complete to a human. +The parenthetical is the whole defect: an exact-literal scan does not tolerate it. + +**How to apply:** keep exactly one unqualified `Output Summary:` line that covers BOTH passes, and +label the narrative sections instead ("Pass 1 result:", "## Pass 2 — the re-run"). Same rule for the +other three fields. If a second `EXIT_CODE:` is genuinely needed, put the re-run in its own artifact +rather than qualifying the field in a shared one — the schema is per-file, so one artifact carries +one field set. + +Caught on issue #735 by the P5-T10 completeness gate, on the `file-line-counts` artifact after the +P4-T3 branch B extraction forced P4-T1 and P4-T2 to be re-run. The gate did its job; the point is +that the mistake is invisible on reading and only a literal scan finds it. + +Related: [[project_artifact_output_summary_breaks_its_own_exact_count_gate]]. diff --git a/.claude/agent-memory/atomic-executor/project_bash_cwd_resets_use_env_dash_c.md b/.claude/agent-memory/atomic-executor/project_bash_cwd_resets_use_env_dash_c.md new file mode 100644 index 000000000..b902c619e --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_bash_cwd_resets_use_env_dash_c.md @@ -0,0 +1,28 @@ +--- +name: bash-cwd-resets-use-env-dash-c +description: Agent-thread Bash resets cwd between calls so a standalone `cd` does not persist; `env -C ` runs a plan's relative-path command from the worktree root with no `cd` and no chaining +metadata: + type: project +--- + +In an agent thread the Bash tool resets its working directory to the session root between calls. A +standalone `cd ` followed by `pwd` in the next call reports the session root again, so the +"working directory persists between calls" note in the tool description does not hold here. + +Use `env -C "" ` as a uniform prefix instead. It is a single +unchained command, uses no `cd`, and satisfies a delegation's "never use `cd`" discipline literally +while still running the command from the worktree root. + +**Why:** Atomic plans are written with relative operands (`TaskMaster.sln`, +`UtilitiesCS.Test/bin/Debug/UtilitiesCS.Test.dll`, `> TestResults/x.diff`) and say "run from the +worktree root". Rewriting them all to absolute paths would violate execute-verbatim AND break +acceptance clauses that assert the recorded command line. `dotnet` is the sharpest case: it resolves +`global.json` from cwd, so a bare `dotnet --version` from the wrong worktree exits 155 with +`The repo-local .NET SDK is missing` even when the item worktree has a working `.dotnet-sdk/`. + +**How to apply:** Prefix every plan command block with `env -C `, place it BEFORE any +`MSYS_NO_PATHCONV=1` or `PATH=` assignment, and record the prefix in the evidence artifact's +`Command:` field. Well-written plans already word command-line gates as "contains +`msbuild.exe TaskMaster.sln`" rather than "begins with", which accommodates the prefix. Combine with +[[project_pwsh_command_quoting_from_bash]] and +[[project_msys_slash_switch_conversion_rule]]. diff --git a/.claude/agent-memory/atomic-executor/project_changed_line_coverage_branch_gate_invalidated_by_the_fix.md b/.claude/agent-memory/atomic-executor/project_changed_line_coverage_branch_gate_invalidated_by_the_fix.md new file mode 100644 index 000000000..e3b4f75d0 --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_changed_line_coverage_branch_gate_invalidated_by_the_fix.md @@ -0,0 +1,14 @@ +--- +name: changed-line-coverage-branch-gate-invalidated-by-the-fix +description: A plan gate that keys its expected changed-line coverage off the BASELINE measurement is unsatisfiable when the fix itself makes the line analyzable for the first time. +metadata: + type: project +--- + +A two-branch acceptance gate of the form "if the baseline recorded the changed line as COVERED, assert COVERED; if it recorded NOT REPORTED, assert NOT REPORTED; any other combination is a stop-and-report" can be driven into its own stop-and-report clause by a correct fix. + +**Why:** Pester's JaCoCo writer emits a `` counter only for lines that carry an *analyzable command*. In issue #752 the pre-fix line 301 held only an operand of an `-and` chain begun three lines earlier, so the baseline XML had a `sourcefile` node for the file and no node at all for line 301 (`LINE301 NODE COUNT=0`). The fix replaced that operand with a `[System.IO.Path]::GetRelativePath(...)` call, which *is* an analyzable command: analyzed commands went 802 -> 803, a line-301 node appeared, and it was covered. Baseline `false`, post-change `COVERED` — exactly the combination the plan classified as a stop-and-report, reached by the change working as designed. + +**How to apply:** When a plan's changed-line coverage branch keys off a baseline non-observation, expect the third combination and do not force the measurement into a branch. Writing `NOT REPORTED` would have required also writing the branch's mandated note asserting that no per-line counter exists — false against the post-change XML. Record the measurement truthfully, add a clearly-labelled divergence section giving the analyzed-command delta as the mechanism, leave that task's checkbox unchecked, and escalate at completion; the blocking condition (post-change >= baseline) is what actually matters and it held. Related: [[project_coverage_delta_reproduce_baseline_counting_method]], [[feedback_never_predict_an_observation_into_an_artifact]]. + +At authoring time, the fix is to make such a gate assert `post-change >= baseline` on the file percentage plus "the changed line is covered OR carried no counter at baseline", rather than demanding parity with the baseline's reporting state. diff --git a/.claude/agent-memory/atomic-executor/project_count_idiom_pitfalls_csharpier_and_measureobject.md b/.claude/agent-memory/atomic-executor/project_count_idiom_pitfalls_csharpier_and_measureobject.md index 84599e53c..27b762df1 100644 --- a/.claude/agent-memory/atomic-executor/project_count_idiom_pitfalls_csharpier_and_measureobject.md +++ b/.claude/agent-memory/atomic-executor/project_count_idiom_pitfalls_csharpier_and_measureobject.md @@ -1,6 +1,6 @@ --- name: count-idiom-pitfalls-csharpier-and-measureobject -description: Two measurement idioms that silently report the wrong number in plan gates - CSharpier's "Formatted N files" is a processed count not a rewrite count, and Measure-Object -Line omits blank lines +description: Three measurement idioms that silently report the wrong number in plan gates - CSharpier's "Formatted N files" is a processed count not a rewrite count, Measure-Object -Line omits blank lines, and -clike reads a markdown checkbox as a wildcard character class metadata: type: project --- @@ -25,6 +25,11 @@ Use `(Get-Content -LiteralPath $f).Count`, and cross-check with `wc -l`. The two Critically: baseline and final MUST use the same idiom, or the before/after comparison is incommensurable. The Uniform Count Idiom for `git grep` (`(git grep -n -F 'TOKEN' -- 'PATHSPEC' | Measure-Object -Line).Lines`) is fine because `git grep` never emits a blank line. -**Why:** Both idioms look authoritative and produce a plausible number, so neither failure announces itself. The CSharpier one caused a false restart signal; the Measure-Object one silently understated every file-size baseline. +**3. `-like`/`-clike` treats a markdown checkbox as a WILDCARD CHARACTER CLASS.** +Counting AC check-offs with `Where-Object { $_ -clike "- [x] *" }` returns **0** on a file where every line matches, because PowerShell wildcards give `[...]` character-class meaning: `- [x] *` means "dash, space, the single character `x`, space, anything". `- [ ] *` is worse — it raises `WildcardPatternException: The specified wildcard character pattern is not valid` once per pipeline item, and if the exception stream is not being read the surviving count still looks like a legitimate 0. +A zero here reads exactly like "no criteria are checked off", which is the answer a Phase 6 verification gate is looking for, so the wrong verdict is the plausible one. +Use a regex instead: `$_ -cmatch '^- \[x\] '`, or the Grep tool with `^- \[x\] ` (case-sensitive by default), which also gives line numbers to confirm the hits fall inside the AC section's line range rather than in an unrelated checkbox list elsewhere in the file (spec.md carries a severity picker at lines 24-27 that inflates a whole-file count by one). + +**Why:** All three idioms look authoritative and produce a plausible number, so none of the failures announces itself. The CSharpier one caused a false restart signal; the Measure-Object one silently understated every file-size baseline. **How to apply:** When a plan gate asks "how many files did the formatter change", hash before and after. When a plan gate asks for a file line count, use `(Get-Content).Count` and record the counting method in the baseline artifact so the final-QC task reproduces it. Related: [[project_csharpier_pipefiles_nonenforcing_gate]], [[feedback_verify_line_citations_with_numbered_output]]. diff --git a/.claude/agent-memory/atomic-executor/project_csharpier_requires_blank_line_before_comment_breaking_numstat_bounds.md b/.claude/agent-memory/atomic-executor/project_csharpier_requires_blank_line_before_comment_breaking_numstat_bounds.md new file mode 100644 index 000000000..47eefa3fc --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_csharpier_requires_blank_line_before_comment_breaking_numstat_bounds.md @@ -0,0 +1,14 @@ +--- +name: csharpier-blank-line-before-comment-breaks-numstat-bounds +description: CSharpier forces a blank line between a member declaration and a following comment, so a "one comment line" insertion costs 2 lines and breaks any numstat bound written as 1 insertion +metadata: + type: project +--- + +CSharpier (1.2.6, this repo) **requires a blank line between a member declaration and a comment that follows it**. Inserting one standalone comment line above a field declaration therefore costs **two** inserted lines after `csharpier format .`, not one. This holds for both `//` and `///` comment styles — verified by removing the blank line and running `dotnet tool run csharpier check `, which exits 1 and prints an `Expected: Around Line N` block containing the blank line, for each style. + +**Why:** it broke issue #731's `[P5-T10]` scope gate. `[P1-T1]`/`[P1-T3]` inserted exactly one comment line above an `IEmailMoveMonitor` field in `QfcCollectionController.cs` and `QfcQueue.cs`, and both files measured baseline+1 immediately afterwards, satisfying those tasks. `[P5-T1]`'s mandated repository-wide format then added the blank lines, and the anchored `git diff --numstat` came out `3 1` against a bound of "at most 2 insertions, at most 1 deletion", and `2 0` against "at most 1 insertion, 0 deletions". Both breaches were exactly one formatter-inserted blank line. The *substance* of the AC ("the diff is limited to one statement and one comment line") was met; the numeric proxy encoding it was not. + +**How to apply:** when a plan bounds a file with `git diff --numstat` and the change adds a standalone comment line, budget **2 insertions per comment**, not 1. Flag this at preflight: a bound of "1 insertion" for a comment-only change is unsatisfiable in any repo that runs CSharpier over the file. The two alternative placements do not help — a trailing comment on the declaration line, and deleting a nearby blank to compensate, are each 1 insertion **plus 1 deletion**, which breaks a `0 deletions` bound instead. There is no placement costing one net insertion. Also note the ordering trap: a task that verifies `line count == baseline + 1` *before* the format step passes, and the same file then fails a post-format diff bound, so the two checks disagree without either being wrong. + +Related: [[csharpier-formats-xml-print-width]], [[new-cs-files-guarantee-a-format-loop-restart]]. diff --git a/.claude/agent-memory/atomic-executor/project_dotnet_global_json_cwd_search_vs_bash_discipline.md b/.claude/agent-memory/atomic-executor/project_dotnet_global_json_cwd_search_vs_bash_discipline.md new file mode 100644 index 000000000..fcf638432 --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_dotnet_global_json_cwd_search_vs_bash_discipline.md @@ -0,0 +1,43 @@ +--- +name: dotnet-global-json-cwd-search-vs-bash-discipline +description: dotnet's global.json/tool-manifest resolution walks the process cwd, not an invoked exe's location, which breaks under a no-cd Bash-discipline delegation where the Bash tool's default cwd is a different worktree than the item worktree +metadata: + type: project +--- + +When a delegation prompt enforces "never use cd, address the item worktree via absolute paths" +(the standard TaskMaster item-worktree Bash-discipline block), a bare `dotnet` invocation for +SDK-pinned commands (`dotnet tool restore`, `dotnet tool run csharpier ...`) can fail with the +item worktree's own `global.json` errorMessage ("The repo-local .NET SDK is missing...") even +though the SDK was correctly installed into that worktree's `.dotnet-sdk/`. + +**Why:** the .NET muxer's `global.json` search walks the **process working directory** upward, +not the location of the `dotnet.exe` you invoke. The Bash tool's default cwd is the session +worktree (e.g. `TaskMaster-wt//`), not the item worktree named in the delegation +(`.claude/worktrees//`) — confirmed via `pwd`. Since `cd` is prohibited and every other +command must take absolute-path operands, there is no literal way to point `dotnet`'s cwd-based +resolution at the item worktree. + +**How to apply:** +- Invoke the item worktree's own pinned SDK executable by absolute path + (`/.dotnet-sdk/dotnet.exe ...`) — this self-resolves for simple commands + (`--version`) without needing cwd-based version negotiation. +- For `dotnet tool restore`, pass `--tool-manifest /dotnet-tools.json` explicitly + rather than relying on cwd-based manifest discovery. +- For `dotnet tool run ` (no `--tool-manifest` flag exists for this subcommand), the manifest + is resolved by cwd search regardless. If the session worktree is a sibling checkout of the SAME + repo and its own `dotnet-tools.json` is byte-identical to the item worktree's (verify with + `diff`/`cat` before relying on this), running from the default cwd resolves the same pinned tool + version. This is safe because CSharpier operates on the absolute file-path arguments you pass, + regardless of which cwd's manifest resolved the tool binary. Document the substitution in the + evidence artifact. +- MSBuild.exe and vstest.console.exe are standalone native executables (resolved via `vswhere`), + not the `dotnet` muxer — they are unaffected by this issue as long as the solution/project/DLL + paths passed to them are absolute. +- Record the deviation explicitly in the P0-T8/P0-T9-equivalent evidence artifact rather than + silently working around it, since it is a real mechanical consequence of the Bash-discipline + contract, not a plan defect. + +See also [[project_planner_and_executor_observe_different_worktrees]] and +[[project_relative_path_in_pwsh_dotnet_io_hits_wrong_worktree]] for the same class of +cwd/worktree-mismatch issue in other tools. diff --git a/.claude/agent-memory/atomic-executor/project_doubled_backslash_dedoubles_bash_to_native_exe.md b/.claude/agent-memory/atomic-executor/project_doubled_backslash_dedoubles_bash_to_native_exe.md index eba3376a1..a62a4b858 100644 --- a/.claude/agent-memory/atomic-executor/project_doubled_backslash_dedoubles_bash_to_native_exe.md +++ b/.claude/agent-memory/atomic-executor/project_doubled_backslash_dedoubles_bash_to_native_exe.md @@ -33,6 +33,22 @@ error. construction), contradicting the older blanket note in [[project_tool_layer_collapses_double_backslash_in_file_content]] for this case. +**Reliable counting idiom when every escaping route fails.** Verifying a "letter-anchored +absolute path" claim needed a count of drive-letter-plus-separator occurrences. Three routes +failed in a row: the Grep tool with a quadrupled class returned `across 0 files`; a bash +`grep -cE` with the same class reported `Trailing backslash`; and writing the pattern to a file +with `printf '%s\n'` and using `grep -f` ALSO reported `Trailing backslash`, because the +doubling was collapsed before bash ever saw the argument. What works is to remove backslashes +from the problem entirely by translating them first: +``` +tr '\\' '/' < "$f" | grep -cE '[A-Za-z]:/' # drive-anchored count +tr '\\' '~' < "$f" | grep -o ':~' | wc -l # bare drive-root count, no URL false hits +``` +`tr` receives a backslash whether or not the layer collapses `\\`, so the translation is +order-independent. Use `~` (not `/`) when the file is Markdown, or every `https://` counts as a +hit. This is the idiom to reach for whenever a preflight claim is stated in terms of Windows +path separators. + Related: [[project_unquoted_backslash_in_bash_arg_silently_redirects_output]], [[project_bash_heredoc_collapses_doubled_backslashes]], [[project_preflight_gate_literal_extract_from_plan_not_retype]] diff --git a/.claude/agent-memory/atomic-executor/project_excludefromcodecoverage_misses_this_capturing_lambdas.md b/.claude/agent-memory/atomic-executor/project_excludefromcodecoverage_misses_this_capturing_lambdas.md new file mode 100644 index 000000000..58e626033 --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_excludefromcodecoverage_misses_this_capturing_lambdas.md @@ -0,0 +1,35 @@ +--- +name: excludefromcodecoverage-misses-this-capturing-lambdas +description: '[ExcludeFromCodeCoverage] removes a member''s own lines but NOT lambdas that capture `this`; those are lifted into members of the declaring class and still appear in Cobertura with hits=0' +metadata: + type: project +--- + +`[ExcludeFromCodeCoverage]` on a method does **not** uniformly remove every line of that method's +source span from the Cobertura document. The rule is about where the compiler lifts each lambda: + +- A lambda that captures **a local** is lifted into a compiler-generated **display type**. The + attribute reaches it; the token never appears in the document at all. +- A lambda that captures **`this`** is lifted into a separate **instance member of the declaring + class**, named `b___`. The attribute on the declaring member does **not** + reach a member emitted beside it, so each such lambda appears as its own `` element + carrying one line with `hits="0"`. + +**Why:** measured on issue #736. `AppOlObjects.ResolveValidatedArchiveRootPath()` carried the +attribute and passed three `this`-capturing delegate literals as arguments; its body lines vanished +but lines 89/90/91 stayed at `hits=0`, which made a `>= 90%` new-file gate unpassable in every state +until the set was derived and removed explicitly. In the same run, +`EfcFormController.ShowModelessFaultNotice`'s `FormClosed += (s, a) => notice.Dispose();` captured a +local, was lifted into a display type, and disappeared completely. + +**How to apply:** before writing a coverage floor over a file containing an excluded COM/UI wrapper, +check the argument list for `this`-capturing lambdas and expect one uncovered line each. Derive the +set mechanically from the `b__` name prefix rather than by eye. **In the XML the +angle brackets are entity-encoded**, so a raw text search must use `<Name>b__`; reading the +attribute through the XML DOM (`$m.name`) decodes them and matches the plain prefix. The trailing +`74_0` ordinal is compiler-assigned from the member's position in the class and shifts when +unrelated members are added, so never assert it verbatim. + +Related: [[project_exempt_forward_extraction_leaves_call_site_uncovered]], +[[project_excludefromcodecoverage_partial_class_cs0579]], +[[project_cobertura_filename_maps_to_several_class_nodes]]. diff --git a/.claude/agent-memory/atomic-executor/project_expectedexitcode_declared_from_baseline_not_observed_run.md b/.claude/agent-memory/atomic-executor/project_expectedexitcode_declared_from_baseline_not_observed_run.md new file mode 100644 index 000000000..00df5a3d8 --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_expectedexitcode_declared_from_baseline_not_observed_run.md @@ -0,0 +1,42 @@ +--- +name: expectedexitcode-declared-from-baseline-not-observed-run +description: A carried-blocker ExpectedExitCode rule keyed off the recorded Phase 0 baseline (rather than off whether the blocker recurred in this run) is vacuous toward new regressions and unsatisfiable when the blocker does not recur +metadata: + type: project +--- + +When a plan authorizes a non-zero exit for a carried baseline blocker, the rule that selects +`ExpectedExitCode:` must key off **whether the blocker recurred in this run**, not off the +recorded Phase 0 baseline value. Keying off the baseline breaks in both directions at once: + +- **Vacuous direction.** "When `BASELINE_FAILURE_SET:` is a name list, declare `ExpectedExitCode: 1`" + admits *any* non-zero exit, including one caused by a brand-new failure this change introduced. + The gate cannot distinguish a carried blocker from a regression. +- **Unsatisfiable direction.** If the baseline failures were flaky and do not recur, the run exits 0 + while the artifact declares 1. An acceptance clause reading "the observed `EXIT_CODE:` equals that + declared expectation" then fails deterministically, and a Phase-6-style restart rule turns that + into an unbounded loop with no defined escape (each iteration is a full format + two `/t:Rebuild` + + two full-suite runs). + +**Why:** the `ExpectedExitCode:` schema field normalizes observed==expected to `pass`, so the +declaration is only truthful if it describes this run's cause. See +[[project_exact_count_gate_vs_remediation_loop]] for the same loop shape from a pinned count. + +**How to apply:** rewrite rule one as "when this run reports at least one Failed test and every +Failed test name it reports appears on `BASELINE_FAILURE_SET:`", and rule two as "when this run +reports no Failed test AND this run's own coverage figure is below the floor". Then add the Failed +name list to the artifact's recorded fields, because the rule now reads it. Do **not** "fix" it by +declaring the expectation to be whatever was observed — that makes the equality clause vacuous. + +**Sibling tell.** The defect is visible without running anything: every other carried-blocker gate +in the same plan says a non-zero exit "is authorized for that reason only" (permissive, accepts 0), +and only the broken one states an equality. An asymmetry between one gate and its siblings is the +cheapest signal that the strict one was written against the schema rather than against the run. + +**Companion arity defect in the same family.** A producing task that enumerates "every line whose +hit count is 0" (an upper bound, 0..N) paired with a consuming task asserting "all three of which +match the three lines the producer enumerated" strands the consumer whenever fewer than N come out +zero-hit. Whether a null-coalesced default lambda reports a zero-hit line at all depends on how the +formatter wraps it, so the count is not knowable at planning time. State the consumer as "the +zero-hit set this artifact enumerates is identical to the set the producer enumerated" plus a +separate named list of permitted lines. diff --git a/.claude/agent-memory/atomic-executor/project_faketimeprovider_zero_duetime_fires_at_creation.md b/.claude/agent-memory/atomic-executor/project_faketimeprovider_zero_duetime_fires_at_creation.md new file mode 100644 index 000000000..58acd0b2b --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_faketimeprovider_zero_duetime_fires_at_creation.md @@ -0,0 +1,31 @@ +--- +name: faketimeprovider-zero-duetime-fires-at-creation +description: FakeTimeProvider.CreateTimer with dueTime TimeSpan.Zero invokes the callback during creation, so a "not completed before Advance" pre-assertion on a zero-delay wait is false and no Advance is needed +metadata: + type: project +--- + +`Microsoft.Extensions.Time.Testing.FakeTimeProvider.CreateTimer(cb, state, TimeSpan.Zero, Timeout.InfiniteTimeSpan)` +invokes `cb` **during the CreateTimer call**, not on the next `Advance`. A helper that completes a +`TaskCompletionSource` from that callback therefore returns an **already-completed** task, and an +assertion of the shape `waitTask.IsCompleted.Should().BeFalse(...)` placed between creation and +`Advance` fails deterministically. A non-zero due time behaves as expected: the timer stays pending +until `Advance` reaches it. + +**Why:** #729 P2-T4. `spec.md` and research 1.4 had read the upstream `WakeWaiters` source and +concluded a due timer fires "on the next advance, not at creation", and pre-authorized only the +*opposite* correction — swapping `Advance(TimeSpan.Zero)` for `Advance(TimeSpan.FromTicks(1))` if the +comparison turned out strict. The observed behaviour was more eager than either branch anticipated, +so the plan's single authorized retry branch was scoped to a different assertion (`Status`, not +`IsCompleted`) and prescribed an edit to a line the failing test never reaches. `WaitAsync(30ms, fake)` +in the same file passed its identical pre-`Advance` assertion, which isolates the cause to the zero +due time rather than to the helper. + +**How to apply:** when a plan asserts non-completion before `Advance`, that assertion is only sound +for a **strictly positive** due time. For a zero-delay scenario, assert completion directly after +creation, or drop the pre-assertion for that one test. At preflight, treat any +"FakeTimeProvider fires on the next advance" premise about a `TimeSpan.Zero` due time as unverified +until a run confirms it — reading `WakeWaiters` is not sufficient, because the immediate-fire path is +in `Change`/creation rather than in the advance path. See +[[project_preflight_selfderived_gate_thresholds_are_blind]] for the sibling case of a premise that +only an executed run can falsify. diff --git a/.claude/agent-memory/atomic-executor/project_fluentassertions_beempty_names_only_first_item.md b/.claude/agent-memory/atomic-executor/project_fluentassertions_beempty_names_only_first_item.md new file mode 100644 index 000000000..3ef92b440 --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_fluentassertions_beempty_names_only_first_item.md @@ -0,0 +1,32 @@ +--- +name: fluentassertions-beempty-names-only-first-item +description: FluentAssertions BeEmpty renders "found at least one item {first}" and names ONE element, so a gate demanding two type names in the failure text is unsatisfiable +metadata: + type: project +--- + +`collection.Should().BeEmpty(because)` in FluentAssertions 8.10.0 renders a non-empty failure as +`but found at least one item {}`. It names one representative element, not the whole +collection. An acceptance condition that demands two or more distinct identifiers appear in that +failure text can never pass, however many elements the collection actually holds. + +Observed on issue #729 P3-T4: the `SVGControl.Test` no-live-Form guard failed as intended against +the pre-deletion assembly, but its acceptance required the pasted failure text to contain both +`SVGControl.Test.Form1` and `SVGControl.Test.Form2`. Whole-log counts were 1 and 0. Both types were +genuinely compiled in — both `.cs` files were on the `csc` command line and a metadata string scan +of the built DLL returned 2 occurrences of each name. Which single item gets named is deterministic +when the assertion sorts first: Block E's `OrderBy(name, StringComparer.Ordinal)` guarantees +`Form1` wins and `Form2` can never appear. + +**Why:** the failure is invisible to plan review. The plan's premise (two Form types in the +assembly) was correct, the guard was correct, and the red-before state was real; only the renderer's +message shape defeated the gate. This is the class the `atomic-plan-contract` covers with "observe a +command's success-case output before asserting over that output" — and here it is the *failure*-case +output that had to be observed first. + +**How to apply:** during preflight, treat any acceptance that counts N>1 distinct tokens inside one +assertion-library failure message as suspect. Prefer asserting the run-level `FailedCount`, or have +the test itself emit the full list (e.g. project the collection into the `because` argument or a +`Console.WriteLine`), rather than reading the library's summary line. Related: +[[project_preflight_recurring_csharp_plan_defect_classes]], +[[project_compile_red_needs_body_level_references]]. diff --git a/.claude/agent-memory/atomic-executor/project_koverage_reporoot_needs_native_separators.md b/.claude/agent-memory/atomic-executor/project_koverage_reporoot_needs_native_separators.md new file mode 100644 index 000000000..a09924105 --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_koverage_reporoot_needs_native_separators.md @@ -0,0 +1,14 @@ +--- +name: koverage-reporoot-needs-native-separators +description: ConvertTo-KoverageCoberturaXml -RepoRoot strips prefixes by literal StartsWith, so a forward-slash root silently leaves every Cobertura filename absolute and the document stays effectively raw +metadata: + type: project +--- + +`ConvertTo-KoverageCoberturaXml -RepoRoot ` delegates to `ConvertTo-KoverageRelativePath`, which strips a prefix by **literal `StartsWith`** against `"\"` and `"/"` (`scripts/vscode/Invoke-MSTestWithCoverage.Helpers.ps1:67-92`). Cobertura `class/@filename` values on Windows use backslashes. So passing `-RepoRoot` as `C:/Users/.../worktree` (forward slashes) matches nothing: **every filename stays host-absolute and the post-processed document is indistinguishable from a raw one**, while the call still succeeds and returns a document. + +**Why:** on issue #731 `[P5-T5]` this produced a processed document whose residual absolute-filename count was 563 of 563 classes. The plan's `Cobertura document state:` audit line — value `processed` when no selected `class/@filename` begins with a drive-letter prefix — is what caught it; without that audit line the run would have proceeded on a document that was silently unrelativised. Rates and per-line maps were unaffected (only the relativisation is a no-op; package filtering, closure exemption, merging and rate recomputation all still ran), but the state assertion would have read `raw` and blocked the plan. + +**How to apply:** always pass `-RepoRoot (Resolve-Path -LiteralPath '.').Path`, which returns the native backslash form, never a forward-slash spelling of the same path. Recovery is cheap and needs no test re-run: step 9 of the coverage procedure is a pure transformation of the on-disk **raw** document, so re-read the raw file, re-call `ConvertTo-KoverageCoberturaXml` with the native root, and overwrite only the processed path. Keep an audit line that counts drive-letter-prefixed filenames in the selected classes — it is the only cheap signal that distinguishes a genuinely processed document from this failure. + +Related: [[koverage-cobertura-postprocessing-shape]], [[csharp-canonical-coverage-artifact-conversion]]. diff --git a/.claude/agent-memory/atomic-executor/project_msbuild_log_has_two_absolute_path_leak_classes.md b/.claude/agent-memory/atomic-executor/project_msbuild_log_has_two_absolute_path_leak_classes.md new file mode 100644 index 000000000..2da5e16fe --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_msbuild_log_has_two_absolute_path_leak_classes.md @@ -0,0 +1,44 @@ +--- +name: msbuild-log-has-two-absolute-path-leak-classes +description: A raw MSBuild file-logger log carries TWO distinct absolute host paths — the worktree root AND the main-checkout root via csc.exe /analyzerconfig: — so a worktree-root-only sanitisation leaves ~36 leaks per log +metadata: + type: project +--- + +Before committing a raw MSBuild file-logger log as evidence, sanitise **two** absolute-path +prefixes, not one: + +1. **Worktree root** — printed on effectively every diagnostic line (path to the `.sln` and to + every project file). +2. **Main-checkout root** — embedded by every `csc.exe` compiler-invocation line via an + `/analyzerconfig:` argument pointing at the *ancestor* checkout's `.editorconfig`. MSBuild + emits two `/analyzerconfig:` arguments back to back for the same file: the main-checkout one + and the worktree-rooted one. + +**Why:** when running inside a linked worktree, the main-checkout root is an *ancestor* of the +worktree root, so it is not itself prefixed by the worktree-root string and a worktree-root-only +substitution never matches it. Measured on issue #730 (2026-09-02): 36 `csc.exe` invocations per +log x 4 logs = 144 residual account-token matches survived a sanitisation that reported the +worktree-root class fully clean. + +**How to apply:** +- Derive the main-checkout root as `Split-Path -Parent (git rev-parse --path-format=absolute + --git-common-dir)`. Do not ascend a hardcoded number of levels; the worktree nesting depth is + not a stable contract. +- Run the **worktree-root substitution first**. The main-checkout-root string is a *prefix* of the + worktree-root string, so applying it first truncate-matches and corrupts every worktree-root + occurrence. +- Use **distinct placeholders** (`` vs ``). Collapsing both makes + the log falsely assert the two roots are the same path and destroys its own record that MSBuild + emitted two separate `/analyzerconfig:` arguments. +- A single case-insensitive sweep for the account token (`Split-Path -Leaf $env:USERPROFILE`) + covers both classes, because it is agnostic to which root the token sat in. +- Both substitutions are pure in-line substring replacements, so line counts are preserved and + every existing line-number citation against the log stays valid. Assert that + (`(Get-Content).Count` before == after) rather than assuming it. + +Also relevant: the repo `.gitignore`'s blanket `*.log` rule silently excludes these artifacts from +a plain `git add`; they need `git add -f`. + +Related: [[_shared_no_absolute_host_paths]], [[project_vstest_trx_evidence_needs_sanitisation_task]], +[[project_msbuild_filelogger_double_counts_each_warning]] diff --git a/.claude/agent-memory/atomic-executor/project_mstest_deploy_dir_leaks_tokens_on_failing_runs.md b/.claude/agent-memory/atomic-executor/project_mstest_deploy_dir_leaks_tokens_on_failing_runs.md new file mode 100644 index 000000000..11f3a3407 --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_mstest_deploy_dir_leaks_tokens_on_failing_runs.md @@ -0,0 +1,39 @@ +--- +name: mstest-deploy-dir-leaks-tokens-on-failing-runs +description: A FAILING vstest run drops an empty Deploy_ \In\ tree into /ResultsDirectory; a passing run does not. It breaks both "exactly one TRX" and the name-based sanitisation gate. +metadata: + type: project +--- + +A vstest.console.exe run that FAILS leaves an empty MSTest deployment scratch directory inside +whatever path `/ResultsDirectory:` names. A run that PASSES leaves only the TRX. + +The directory tree is `Deploy_ \In\` plus a sibling `Out`. +It contains no files. + +**Why:** the deployment folder is created per test host and retained when the run does not complete +cleanly. Its generated names embed the local account name and the machine name. + +**How to apply:** when an `[expect-fail]` task writes its TRX into the feature evidence tree, delete +the deployment tree immediately after the run and before writing the artifact. Two separate gates +fail otherwise: + +- the task's own "that results directory holds exactly one TRX and no others" acceptance, and +- the Phase 5 sanitisation gate that requires ZERO file or directory NAMES under `evidence/` + containing the account or machine token — see [[project_evidence_sanitisation_capture_time_gate]] + and the sibling content-sweep note. + +Recursive delete idioms are blocked; use `[System.IO.Directory]::Delete($path, $true)`. Fold the +cleanup into the shared test-runner helper so every run is covered, not just the ones you remember: + +```powershell +$deploy = @(Get-ChildItem -LiteralPath $resultsDir -Directory -Force | + Where-Object { $_.Name -like 'Deploy_*' }) +foreach ($d in $deploy) { [System.IO.Directory]::Delete($d.FullName, $true) } +``` + +Describe the removal by role in the evidence artifact. Do not quote the directory name — that would +reintroduce both tokens into a file the sweep has already cleared. + +Observed on issue #735, where P1-T2 and P3-T5 each produced one, and the P0-T8, P1-T7, P1-T8, P2-T8, +P3-T11, P3-T12 and P4-T3 passing runs produced none. diff --git a/.claude/agent-memory/atomic-executor/project_plan_mandated_ps1_helpers_collide_with_budget_cap_and_frozen_porcelain_gate.md b/.claude/agent-memory/atomic-executor/project_plan_mandated_ps1_helpers_collide_with_budget_cap_and_frozen_porcelain_gate.md new file mode 100644 index 000000000..37fc1683d --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_plan_mandated_ps1_helpers_collide_with_budget_cap_and_frozen_porcelain_gate.md @@ -0,0 +1,34 @@ +--- +name: plan-mandated-ps1-helpers-collide-with-budget-cap-and-frozen-porcelain-gate +description: A plan rule that mandates authoring snippets into .ps1 files (e.g. a backslash-literal authoring rule) is unexecutable unless it fixes ONE gitignored path — the budget hook caps distinct non-test .ps1 paths at 3, and an exclusive porcelain gate frozen at Phase 0 rejects any new untracked path +metadata: + type: project +--- + +When a plan adds a rule of the form "author every backslash-bearing snippet into a `.ps1` file and +invoke it with `pwsh -File`", check two things before clearing it. Both are silent in the plan text +and both are hard blocks at execution time. + +**Why (1) — the budget cap.** `.claude/hooks/enforce-powershell-batch-budget.ps1` is a PreToolUse +hook on `Write|Edit`. Default `ProdCap` is 3 (`:316`). A **new distinct** non-test `.ps1` path inside +the worktree root is DENIED once three are recorded (`:293`). An already-recorded path is allowed +unconditionally (`:289`), and a path outside the worktree root is discarded and spends nothing +(`:279-281`). So one reused helper is free forever; one helper per task exhausts the cap mid-plan. +The hook's three offered outs (raise the env var, delete the state file, split the batch) are all +governance bypasses an executor must not take. + +**Why (2) — the frozen porcelain gate.** A Phase 5 format gate that asserts +`git status --porcelain` "lists no path outside the union of , , +" has its exclusion set captured in Phase 0, *before* any helper exists. A helper written +to a non-ignored path therefore fails that gate, and the Phase 5 restart loop cannot clear it — +restarting re-runs the same porcelain capture against the same stray file. + +**How to apply:** require exactly one fixed helper path under an already-gitignored directory +(in TaskMaster, `coverage/` — `coverage/*` at `.gitignore:144`), rewritten in place, with the agent +scratchpad outside the worktree as the fallback if the cap is already spent. Never let the plan +leave the location unspecified. Do not name an absolute scratchpad path in a committed plan; it +embeds the account name — see [[../_shared_no_absolute_host_paths]]. + +Related: [[project_powershell_scratch_script_budget_hook_blocks_helpers]], +[[project_tool_layer_collapses_double_backslash_in_file_content]], +[[project_agent_memory_tracked_breaks_unscoped_git_gates]] diff --git a/.claude/agent-memory/atomic-executor/project_powershell_scratch_script_budget_hook_blocks_helpers.md b/.claude/agent-memory/atomic-executor/project_powershell_scratch_script_budget_hook_blocks_helpers.md index 81bb11136..271f006e0 100644 --- a/.claude/agent-memory/atomic-executor/project_powershell_scratch_script_budget_hook_blocks_helpers.md +++ b/.claude/agent-memory/atomic-executor/project_powershell_scratch_script_budget_hook_blocks_helpers.md @@ -29,4 +29,15 @@ control. Observed 2026-08-28 on #677; the three files filling the cap belonged to worktree `2026-08-23T22-51`, which this session never touched. +**CORRECTED 2026-09-03 (worktree `prep-752`): the cross-worktree spend is FIXED upstream.** +`enforce-powershell-batch-budget.ps1` now runs every persisted entry through +`Test-PowerShellBatchBudgetPathInRoot` at rehydration and DROPS any absolute path that does not +sit under the current worktree root, so the three scratchpad entries still sitting in the tracked +`.claude/state/powershell-batch-budget.default.json` no longer spend any other worktree's budget. +The state file is also per-session (`powershell-batch-budget..json`), and the +tracked `.default.json` is only read when the session id literally resolves to `default`. +Practical effect: do not report "the PowerShell budget is already full" from reading that JSON at +preflight — read the hook's containment filter first. A relative path in the state file is still +admitted by design, so a relative scratch entry can still spend a slot. + Related: [[project_pwsh_command_quoting_from_bash]], [[project_long_runs_need_detached_process]] diff --git a/.claude/agent-memory/atomic-executor/project_preflight_moving_base_two_dot_diff_inertness_test.md b/.claude/agent-memory/atomic-executor/project_preflight_moving_base_two_dot_diff_inertness_test.md index c17bb999f..9ae31cca8 100644 --- a/.claude/agent-memory/atomic-executor/project_preflight_moving_base_two_dot_diff_inertness_test.md +++ b/.claude/agent-memory/atomic-executor/project_preflight_moving_base_two_dot_diff_inertness_test.md @@ -27,6 +27,27 @@ merge base `9b6aff2e` vs `origin/main` `2b85134b` differed by 77 files including gates stayed correct. Relates to [[baseline-sha-diff-conflates-merged-base]] and [[preflight-mergebase-diff-gates-need-commit-cadence]]. +**Run the inertness test once per gate SCOPE, not once per plan.** The outcome is routinely mixed +within a single plan, so a single measurement generalised to the whole plan is wrong in one +direction or the other. Measured on #584, 2026-09-02, after an orchestrator merged `origin/main` +into the branch post-authoring: of five spans anchored to the plan's stale BASE `5ebaaf10`, the +three *per-file* spans (`-- UtilitiesCS/Threading/UiThread.cs`, +`-- UtilitiesCS/Threading/ProgressTrackerAsync.cs`) were inert because the merge never touched those +files; the *two-directory* span (`-- UtilitiesCS UtilitiesCS.Test`) picked up 18 merge-induced paths +but was still inert *for its own assertion*, because that gate greps added lines for seven timing +tokens and the merge delta contained none of them; and only the *unscoped* span +(`git diff --name-status ..HEAD`) was unsatisfiable, because its acceptance enumerates an +exact five-path set and the delta carried ~40 foreign source paths plus `.claude/agent-memory/**`, +which a second clause separately forbids. So the test has three tiers, not two: paths untouched, +paths touched but assertion-inert, and assertion-broken. Only the third blocks. + +**Corollary — an assertion-inert directory span still degrades.** That same `-- UtilitiesCS +UtilitiesCS.Test` gate also required its diff to be *non-empty*, justified in the plan as "an empty +diff means the gate had nothing to inspect". Post-merge that clause is satisfied by foreign content +regardless of what the executor writes, so it stops proving the change exists. Report it as a +degraded-but-passing gate and fold the re-anchor into the delta; do not silently re-anchor it +yourself, per [[baseline-sha-diff-conflates-merged-base]]. + ## Better remedy: make the inertness test a task the executor runs Do not settle for a preflight-time measurement plus a plan sentence asserting the result. The diff --git a/.claude/agent-memory/atomic-executor/project_preparation_mode_flips_anchored_diff_gate_membership.md b/.claude/agent-memory/atomic-executor/project_preparation_mode_flips_anchored_diff_gate_membership.md new file mode 100644 index 000000000..469fa3875 --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_preparation_mode_flips_anchored_diff_gate_membership.md @@ -0,0 +1,35 @@ +--- +name: preparation-mode-flips-anchored-diff-gate-membership +description: In preparation mode the feature folder is committed to the branch BEFORE execution, so every base-anchored git diff lists issue.md/spec.md/research/plan.md alongside the code paths — an equality gate over "the N footprint paths" becomes unsatisfiable. +metadata: + type: project +--- + +A plan authored in "preparation mode" is committed together with its whole feature +folder onto the branch, and a later run executes it from that pushed commit. Every +`git diff --name-only -- .` in the plan therefore lists the four feature-folder +paths (`issue.md`, `spec.md`, `research/*.md`, `plan.*.md`) **in addition to** the code +footprint, because the base commit predates the preparation commit. + +**Why:** an anchored footprint gate written as an equality over the N code paths is +unsatisfiable in that state, and a preflight round that does not know about preparation +mode will verify it as correct against the pre-commit tree. Observed on #644, where +`[P4-T8]`/`[P5-T20]` had to be rewritten between round 1 and round 2 for exactly this. + +**How to apply:** +- Write the repository-wide clause as a **membership test** over the non-code paths + ("contains all six code paths and no path other than those six and the four + feature-folder paths"), never as an equality over all ten. The membership form holds + in both states: if the preparation commit was not made, the four are untracked and + `git diff` cannot list them, and an unexpected formatter rewrite still fails. +- `git diff ` compares the commit tree to the **working tree** and ignores + untracked files, so evidence artifacts written under the feature folder during + execution are correctly absent until they are staged or committed. +- A path staged with `git add` becomes tracked, so `git diff --name-only` does + list a newly created file once staged — this is the G8b companion that makes the + name-listing diff complete. +- The terminal commit task must flip **its own** plan checkbox before staging, because + the plan file lives inside the folder the commit covers; flipping after re-dirties the + tree the clean-tree clause asserts. Same reason it must write no evidence artifact. +- Keep `':!.claude/agent-memory'` on every repository-wide span. See + [[agent-memory-tracked-breaks-unscoped-git-gates]]. diff --git a/.claude/agent-memory/atomic-executor/project_pwsh_file_starts_in_session_root_needs_workingdirectory.md b/.claude/agent-memory/atomic-executor/project_pwsh_file_starts_in_session_root_needs_workingdirectory.md new file mode 100644 index 000000000..6422fa45e --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_pwsh_file_starts_in_session_root_needs_workingdirectory.md @@ -0,0 +1,27 @@ +--- +name: pwsh-file-starts-in-session-root-needs-workingdirectory +description: pwsh -File from the Bash tool starts in the session-root worktree, so a script's own `git rev-parse --show-toplevel` silently resolves to the WRONG worktree; pass -WorkingDirectory +metadata: + type: project +--- + +`pwsh -NoProfile -File ` launched through the Bash tool inherits the Bash tool's default +working directory, which in a parallel/item run is the **session root worktree**, not the item worktree. + +**Why:** The plan convention "every shell invocation begins with `Set-Location (git rev-parse --show-toplevel)`" +is written assuming the process already sits in the item worktree. Under `-File` it does not, so +`git rev-parse --show-toplevel` returns the session root and the script `Set-Location`s to the wrong tree. +This fails **silently and plausibly**: in item #751 the plan's test-assembly discovery returned +`ASSEMBLY_COUNT=0` because the session root had no built `bin\Debug` output. A zero result reads like a +genuine finding (and the delegation prompt even named a known tooling defect that predicts zero), so it can +be mis-reported as a blocker instead of a harness error. + +**How to apply:** Always launch script files as +`pwsh -NoProfile -WorkingDirectory "" -File ""`. +`-WorkingDirectory` is a real pwsh parameter and is the only anchor available for `-File` (unlike +`-Command`, where you can prepend `Set-Location ""; ...`). Before treating any zero/empty discovery +result as a finding, print `(Get-Location).Path` from inside the script and confirm it is the item worktree. + +Related: [[project_bash_cwd_resets_use_env_dash_c]], +[[project_relative_path_in_pwsh_dotnet_io_hits_wrong_worktree]], +[[project_doubled_backslash_dedoubles_bash_to_native_exe]]. diff --git a/.claude/agent-memory/atomic-executor/project_pwsh_stdin_repl_mode_and_nonascii_mangling.md b/.claude/agent-memory/atomic-executor/project_pwsh_stdin_repl_mode_and_nonascii_mangling.md new file mode 100644 index 000000000..e9ac05430 --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_pwsh_stdin_repl_mode_and_nonascii_mangling.md @@ -0,0 +1,20 @@ +--- +name: pwsh-stdin-repl-mode-and-nonascii-mangling +description: pwsh -Command - over a bash heredoc executes line-by-line like a REPL, so multi-line blocks silently produce NO output; it also mangles non-ASCII in both patterns and rendered output +metadata: + type: project +--- + +When Bash allowlisting blocks `grep`/`cat`/`sed` and only `pwsh *` is available, `pwsh -NoProfile -Command - <<'EOF'` is the workaround. It has two traps. + +**1. Stdin mode is a REPL, not a script.** Each physical line is executed as its own statement. A multi-line `try { ... } catch { }`, `foreach (...) { ... }`, or `function Name { ... }` spanning lines produces **no output and no error** — the Bash tool reports "completed with no output". This looks identical to a genuine zero result. + +**Why:** the failure is silent and mimics a real negative finding, so a gate that "returns zero matches" can be a broken harness rather than a clean tree. + +**How to apply:** put every statement on ONE physical line, using `;` inside braces. Variables and functions persist across lines in the same session, so build the script as a sequence of single-line statements. Always run a positive control (search for a token you know is present) before accepting any zero result. + +**2. Non-ASCII is mangled in both directions.** A pattern containing an em dash or `§` passed through the heredoc arrives corrupted, so `Select-String -SimpleMatch` returns 0 for text that IS present. Rendered output is mangled too: an em dash in a `Get-Content` line prints as `-`, which can make a correct file look wrong. + +**How to apply:** evaluate any search whose literal contains non-ASCII with the Grep tool (ripgrep), which is UTF-8-correct. Reserve the pwsh harness for ASCII-only literals. Observed on #729 P7-T7/P8-T16, where three em-dash searches read 0 through pwsh and 1 through ripgrep. + +Related: [[pwsh-command-quoting-from-bash]], [[no-cd-or-non-allowlisted-bash-in-taskmaster]] diff --git a/.claude/agent-memory/atomic-executor/project_reflective_property_read_escapes_member_expression_grep.md b/.claude/agent-memory/atomic-executor/project_reflective_property_read_escapes_member_expression_grep.md new file mode 100644 index 000000000..2121022d7 --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_reflective_property_read_escapes_member_expression_grep.md @@ -0,0 +1,28 @@ +--- +name: reflective-property-read-escapes-member-expression-grep +description: A blast-radius grep for `Type.Member` cannot find a consumer that reads the same member reflectively by name string; adding a throwing guard to a previously-silent getter broke 8 tests a repo-wide grep had cleared +metadata: + type: project +--- + +A blast-radius analysis that enumerates consumers with `git grep -n "UiThread.Dispatcher\b"` misses +any call site that reads the member **reflectively by name**, e.g. +`typeof(UiThread).GetProperty("Dispatcher", ...)` then `.GetValue(null)`. The qualified member +expression never appears in that file, so the grep clears it. + +**Why:** On #584 the fix added an `InvalidOperationException` guard to a getter that had previously +returned `null` silently. `spec.md` asserted the repo-wide grep was "the complete set of production +reads". It was not: `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs` snapshots the property +reflectively in `[TestInitialize]`/`[TestCleanup]`, so `PropertyInfo.GetValue` began throwing and all +8 tests in the class failed with the opaque MSTest message `One or more errors occurred.` The plan +had run the complementary census for reflective reads of the private **field** +(`git grep -n -F '"_dispatcher"'`) and got it right — it simply never ran the same census for the +**property** name. + +**How to apply:** Before changing a getter, setter, or method from silent/lenient to throwing, run +BOTH censuses: the member-expression grep AND a literal search for the member name as a string +(`git grep -n -F '"Dispatcher"'`), filtering out `` doc hits. Also note that a class +comment asserting "these tests do not depend on X being initialized" is a statement about the OLD +contract and becomes false the moment the contract tightens — treat such comments as call sites, not +as reassurance. See [[compile_red_needs_body_level_references]] for the related "grep shape does not +match usage shape" failure. diff --git a/.claude/agent-memory/atomic-executor/project_sln_csproj_edit_crlf_preserve.md b/.claude/agent-memory/atomic-executor/project_sln_csproj_edit_crlf_preserve.md index 403488087..2b6cd0878 100644 --- a/.claude/agent-memory/atomic-executor/project_sln_csproj_edit_crlf_preserve.md +++ b/.claude/agent-memory/atomic-executor/project_sln_csproj_edit_crlf_preserve.md @@ -27,3 +27,14 @@ turns a 9-line change into whole-file churn. Cheapest reliable idiom, run immedi sed: `sed -i 's/$/\r/' .md`, then confirm with `file .md` (expect "with CRLF line terminators") and `git diff --stat` (expect insertions == deletions == number of boxes ticked). Doing this per check-off rather than once at the end keeps the diff readable if the run is interrupted. + +**Refinement observed 2026-09-03 (issue #584 worktree): for `.md` the churn claim above did not +reproduce.** `sed -i` on a CRLF plan `.md` did flatten the whole file to LF (`file` flipped from +"with CRLF line terminators" to none), but `git diff --numstat` still reported only the real content +delta, and `git add` printed `warning: ... LF will be replaced by CRLF the next time Git touches it`. +Cause: this repo's `.gitattributes` sets `* text=auto` with `core.autocrlf=true`, so the index stores +LF and a LF working copy is already normalized — there is nothing for git to see. Before spending a +re-CRLF pass, check `git diff --numstat ` after the first check-off: if it shows only the +intended delta, the flatten is cosmetic and the `sed -i 's/$/\r/'` repair is unnecessary. Keep using +the Edit tool for `.cs`/`.sln`, where an editor or MSBuild may care about the working-copy bytes even +when git does not. diff --git a/.claude/agent-memory/atomic-executor/project_vstest_emits_two_coverage_files_per_run.md b/.claude/agent-memory/atomic-executor/project_vstest_emits_two_coverage_files_per_run.md new file mode 100644 index 000000000..55e8f2657 --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_vstest_emits_two_coverage_files_per_run.md @@ -0,0 +1,32 @@ +--- +name: vstest-emits-two-coverage-files-per-run +description: A single vstest /EnableCodeCoverage run leaves TWO *.coverage files under its ResultsDirectory, so any plan gate demanding "exactly one" is unsatisfiable by construction +metadata: + type: project +--- + +One `vstest.console.exe ... /EnableCodeCoverage /ResultsDirectory:` run leaves **two** files matching +`Get-ChildItem -Path -Recurse -Filter '*.coverage'`: + +1. the published attachment, under `\\__.coverage` +2. an in-run copy, under `\__\In\\.coverage` + +Both have identical byte length. The vstest console `Attachments:` section prints only the first, so reading +the console output suggests there is one. + +**Why:** In item #751 the plan required the locate search to "return exactly one file" and routed any other +count to a `COVERAGE_CAPTURE_BLOCKED` rung that forbids converting an arbitrary member of the set. The count +was 2 on both the baseline run and the final-QC run, so the numeric coverage pair was unobtainable and the +coverage criterion came out remediation-required — even though `dotnet-coverage` was installed and working. +The gate could never have passed; it was not a property of the change under test. + +**How to apply:** At preflight, flag any acceptance condition asserting exactly one `*.coverage` file under a +vstest results directory. The correct predicate selects the published attachment specifically (exclude paths +containing `\In\\`, or take the one that is a direct descendant of a GUID-named subdirectory), or +merges both with `dotnet-coverage merge`. During execution do NOT self-repair a plan that says "exactly one" +— record the observed count and the blocked outcome, since the plan explicitly forbids picking one. + +Note the attachment filenames embed the account name and machine name, so never transcribe those paths into +an artifact. Related: [[_shared_no_absolute_host_paths]], +[[project_csharp_canonical_coverage_artifact_conversion]], +[[project_failed_coverage_run_leaves_raw_unprocessed_cobertura]]. diff --git a/.claude/agent-memory/atomic-executor/project_vstest_success_run_prints_no_failed_or_skipped_line.md b/.claude/agent-memory/atomic-executor/project_vstest_success_run_prints_no_failed_or_skipped_line.md new file mode 100644 index 000000000..b2286d94a --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_vstest_success_run_prints_no_failed_or_skipped_line.md @@ -0,0 +1,72 @@ +--- +name: vstest-success-run-prints-no-failed-or-skipped-line +description: vstest.console.exe prints only "Total tests:" and "Passed:" on an all-green run (no "Failed:"/"Skipped:" line), and the TRX Counters element supplies only failed - notExecuted is hard-coded 0 and is NOT the skipped count +metadata: + type: project +--- + +On a fully passing run, `vstest.console.exe` (VS18 / net481) prints exactly three summary lines: + +``` +Test Run Successful. +Total tests: 4783 + Passed: 4783 + Total time: 12.8452 Seconds +``` + +There is NO `Failed:` line and NO `Skipped:` line. Measured 2026-09-02 on #584 preflight round 5 +across four separate green runs (4783, 1312, 41, and 2 tests), with and without +`/Settings:...runsettings`, and under `dotnet-coverage collect`. `grep -c "Failed:\|Skipped:"` +returned 0 on every one. Both lines DO appear when the run has failures. + +**Why:** a plan acceptance of the form "record the numeric `Total tests`, `Passed`, `Failed`, and +`Skipped` counts printed by vstest ... as concrete numbers, not placeholders" names two values the +tool never emits on its success path, so the executor must either derive them or record a +placeholder and fail the task. This is the `atomic-plan-contract` mandatory rule "Observe a +command's success-case output before asserting over that output" — the same shape as demanding +separate line and branch percentages from a coverage run that prints one combined column. + +It survived four earlier preflight rounds on #584 because every vstest invocation was broken by +MSYS `/InIsolation` mangling (see [[project_msys_slash_switch_conversion_rule]]) and ran zero +tests, so no round could observe a SUCCESSFUL summary block until the prefix fix landed. A defect +hidden behind another defect only becomes visible after the first is fixed — budget a round for it. + +**How to apply:** when a plan asks for a Failed/Skipped count, point it at the TRX — but only +`Failed`. Every one of these commands already passes `/Logger:trx`, and the TRX carries the run +totals in one element: + +``` + +``` + +`failed` is the console's `Failed:`. **`notExecuted` is NOT the console's `Skipped:`.** The vstest +TrxLogger populates only `total`, `executed`, `passed`, and `failed`; every other counter attribute +(`notExecuted`, `error`, `timeout`, `aborted`, `inconclusive`, ...) is hard-coded to `0`. Measured +2026-09-02 on #584 preflight round 6 with a purpose-built 3-test probe assembly (1 pass, 1 fail, +1 `[Ignore]`): the console printed `Skipped: 1` while the same run's TRX reported `notExecuted="0"` +even though that test's own `` was present. + +Derive `Skipped` as `total` minus `executed` (3-2=1 on the red probe, 2-2=0 on a green run), or +count `outcome="NotExecuted"` results. Sourcing `Skipped` from `notExecuted` yields a constant `0` — +an acceptance value that cannot fail, which is the same class of defect as reading a count the +console never prints. + +Note also: MSTest 4.3 renders a method-level `[Ignore]` in this repo's suites as a PASSING test +named `Disabled_`, so the real UtilitiesCS.Test / QuickFiler.Test runs report zero skips +and the wrong derivation happens to agree there. Do not let that coincidence validate the mechanism. + +Re-measured independently 2026-09-02 on #584 preflight round 7 with the same probe: +`total=3 executed=2 notExecuted=0` on the mixed run (console `Skipped: 1`), `total=1 executed=1` on +a green run, so `total - executed` gives 1 and 0. Also measured, on a run with a failure and NO +skip: console prints `Failed: 1` and no `Skipped:` line at all — the two aggregate lines really are +independent per-counter, so an `[expect-fail]` task whose run has a non-zero failure count CAN read +`Failed:` from the console. + +Two operational facts from the same runs: +- The default TRX filename is `___net.trx`, and vstest also prints + a `Results File: \` console line on green AND red runs. Both + disclose the account and machine name; redact both from committed evidence. +- The timestamp is per-second and vstest never overwrites, so re-running the same command into the + same `/ResultsDirectory:` leaves TWO `.trx` files with distinct mtimes. A plan that reads "the TRX" + from a task's own directory needs an explicit tie-break (most recently modified) once any task can + be re-run; per-task directories bound collisions ACROSS tasks only, never across re-runs. diff --git a/.claude/agent-memory/atomic-executor/project_worktree_isolation_guard_refuses_pwsh_from_bash.md b/.claude/agent-memory/atomic-executor/project_worktree_isolation_guard_refuses_pwsh_from_bash.md new file mode 100644 index 000000000..374d788e6 --- /dev/null +++ b/.claude/agent-memory/atomic-executor/project_worktree_isolation_guard_refuses_pwsh_from_bash.md @@ -0,0 +1,60 @@ +--- +name: worktree-isolation-guard-refuses-pwsh-from-bash +description: In an isolated agent worktree the PreToolUse guard refuses every Bash invocation of pwsh (both -Command AND -File) and every command whose NAME is a quoted absolute path, so plan tasks whose only command is one of those shapes cannot run +metadata: + type: project +--- + +In a worktree-isolated agent session (`.claude/worktrees/agent-/`), the Bash guard refuses two +distinct command shapes. + +**Shape 1 — anything that invokes `pwsh`.** Refusal text: "this command runs pwsh in a plain command; +what it reads or is handed as shell text cannot be shown not to run git. Refusing to run it". +Observed 2026-09-02 in `agent-a18cc3bc53f9c1d8a` on FIVE formulations, all refused: +- `cd && echo ... && pwsh -NoProfile -Command '...'` +- `pwsh -NoProfile -Command 'Set-Location ""; ...'` +- `cd && pwsh -NoProfile -Command '...'` +- `pwsh -NoProfile -ExecutionPolicy Bypass -File .ps1` +- `cd && pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/vscode/