From 766e5d931e71fd191773df0f2321aab895d945fa Mon Sep 17 00:00:00 2001 From: Manu Lorente Date: Tue, 14 Jul 2026 10:25:40 -0600 Subject: [PATCH 1/3] fix(mem): own the Claude project-key encoding in Go so the Windows twins can't drift Two PowerShell sites re-implemented the auto-memory junction key and deleted the drive ':' instead of mapping it to '-', producing C-Users-... where Claude Code and memlink.ClaudeProjectKey use C--Users-.... setup-windows.ps1 deposited junctions Claude never reads; knowledge-crystallize.ps1 warned "No MEMORY.md found" for every project. vault-maintenance-weekly.ps1 also passed the POSIX --all, which bound positionally to $ProjectDir and left [switch]$All false, so the weekly task never fanned out. - add `dotf mem project-key ` exposing memlink.ClaudeProjectKey as the one encoding source - setup-windows.ps1 + knowledge-crystallize.ps1 resolve the key via a shared utils.ps1 helper (dotf-first, corrected pure fallback); fix the decoder regex - vault-maintenance-weekly.ps1: --all -> -All - Pester guard binds the PS encoder to Go's known-good keys Closes #689 --- cli/.gitignore | 3 + cli/internal/cmd/mem.go | 25 ++++ cli/internal/cmd/mem_test.go | 23 ++++ scripts/knowledge-crystallize.ps1 | 38 +++--- scripts/utils.ps1 | 29 +++++ scripts/vault-maintenance-weekly.ps1 | 8 +- setup-windows.ps1 | 5 +- specs/BUG-031-windows-project-key/proposal.md | 116 ++++++++++++++++++ specs/BUG-031-windows-project-key/tasks.md | 46 +++++++ .../verification.md | 76 ++++++++++++ tests/claude-project-key.Tests.ps1 | 52 ++++++++ 11 files changed, 399 insertions(+), 22 deletions(-) create mode 100644 specs/BUG-031-windows-project-key/proposal.md create mode 100644 specs/BUG-031-windows-project-key/tasks.md create mode 100644 specs/BUG-031-windows-project-key/verification.md create mode 100644 tests/claude-project-key.Tests.ps1 diff --git a/cli/.gitignore b/cli/.gitignore index 849ddff3..5e7c2239 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -1 +1,4 @@ dist/ +# Local `go build -o bin/...` output — goreleaser publishes from dist/; a manual +# build (e.g. to exercise a PR binary) must never be committed. +bin/ diff --git a/cli/internal/cmd/mem.go b/cli/internal/cmd/mem.go index 12f67616..c8f2e05e 100644 --- a/cli/internal/cmd/mem.go +++ b/cli/internal/cmd/mem.go @@ -12,6 +12,7 @@ import ( "github.com/mlorentedev/dotfiles/cli/internal/doctor" "github.com/mlorentedev/dotfiles/cli/internal/env" "github.com/mlorentedev/dotfiles/cli/internal/mem" + "github.com/mlorentedev/dotfiles/cli/internal/memlink" "github.com/mlorentedev/dotfiles/cli/internal/vault" "github.com/spf13/cobra" ) @@ -34,9 +35,33 @@ func newMemCmd() *cobra.Command { } cmd.AddCommand(newMemSessionEndCmd()) cmd.AddCommand(newMemSessionStartCmd()) + cmd.AddCommand(newMemProjectKeyCmd()) return cmd } +// newMemProjectKeyCmd exposes memlink.ClaudeProjectKey as a CLI so the Windows +// PowerShell twins (setup-windows.ps1, knowledge-crystallize.ps1) obtain the +// Claude auto-memory key from the one Go implementation instead of re-deriving it +// — the datum-duplication that drifted and mis-encoded the junction on Windows +// (BUG-031/#689; #551 fixed only the Go side). Prints the key for and a +// trailing newline. +func newMemProjectKeyCmd() *cobra.Command { + return &cobra.Command{ + Use: "project-key ", + Short: "Print Claude Code's per-project auto-memory key for a working directory", + Long: "project-key encodes a working directory into Claude Code's per-project key\n" + + "(the directory name under ~/.claude/projects) — every '/', '\\' and drive ':'\n" + + "maps to '-'. It is the single source the PowerShell setup/crystallize twins\n" + + "call so their junction target can never drift from the Go layer again.", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + _, err := fmt.Fprintln(cmd.OutOrStdout(), memlink.ClaudeProjectKey(args[0])) + return err + }, + } +} + // newMemSessionEndCmd wires the SessionEnd hook. Per the resilience contract a // session-end hook must NEVER crash a session, so it reads the payload, persists // the handoff record best-effort, and ALWAYS exits 0 — every error is swallowed. diff --git a/cli/internal/cmd/mem_test.go b/cli/internal/cmd/mem_test.go index 43cf2faf..af1fbd77 100644 --- a/cli/internal/cmd/mem_test.go +++ b/cli/internal/cmd/mem_test.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "strings" "testing" ) @@ -53,3 +54,25 @@ func TestMemSessionEnd_MalformedInputExitsZero(t *testing.T) { t.Fatalf("malformed input must still exit 0, got %v", err) } } + +// TestMemProjectKey exercises `dotf mem project-key `: the single source of +// the Claude auto-memory encoding that the PowerShell twins call instead of +// re-implementing it (BUG-031/#689). Output must equal memlink.ClaudeProjectKey. +func TestMemProjectKey(t *testing.T) { + for in, want := range map[string]string{ + `C:\Users\me\p`: "C--Users-me-p", + "/home/me/p": "-home-me-p", + } { + cmd := newMemCmd() + var out bytes.Buffer + cmd.SetArgs([]string{"project-key", in}) + cmd.SetOut(&out) + cmd.SetErr(io.Discard) + if err := cmd.Execute(); err != nil { + t.Fatalf("project-key %q: unexpected error %v", in, err) + } + if got := strings.TrimSpace(out.String()); got != want { + t.Errorf("project-key %q = %q, want %q", in, got, want) + } + } +} diff --git a/scripts/knowledge-crystallize.ps1 b/scripts/knowledge-crystallize.ps1 index 1bb45309..0de14a32 100644 --- a/scripts/knowledge-crystallize.ps1 +++ b/scripts/knowledge-crystallize.ps1 @@ -41,6 +41,12 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +# Shared PowerShell helpers. Get-ClaudeProjectKey / Get-ClaudeProjectKeyEncoded +# are the single source of the Claude auto-memory key encoding (they defer to the +# Go layer via `dotf mem project-key`), so this script cannot re-drift from Claude +# Code the way it did in #689 (deleting ':' -> the wrong single-dash key). +. (Join-Path $PSScriptRoot 'utils.ps1') + # ============================================================================ # HELPERS # ============================================================================ @@ -49,22 +55,17 @@ function Write-Info { param([string]$m) Write-Host "[INFO] $m" -Foreground function Write-Success { param([string]$m) Write-Host "[SUCCESS] $m" -ForegroundColor Green } function Write-Warn { param([string]$m) Write-Host "[WARNING] $m" -ForegroundColor Yellow } -# Encode a path the same way Claude Code does on Windows: -# Replace all '\' with '-' and strip the ':' after drive letters -# e.g. C:\Users\manu\Projects\dotfiles -> C-Users-manu-Projects-dotfiles -function Get-EncodedPath { - param([string]$Path) - return $Path.Replace('\', '-').Replace(':', '') -} - -# Decode a Claude Code encoded path back to a real filesystem path. -# Two-stage: drive-letter heuristic first, then filesystem scan. +# Decode a Claude Code encoded key back to a real filesystem path. +# Two-stage: drive-letter heuristic first, then filesystem scan. The key format is +# the double-dash drive encoding (C:\... -> C--...), matching Get-ClaudeProjectKey. function Get-DecodedPath { param([string]$Encoded) - # Stage 1: Drive-letter heuristic - # Pattern: single uppercase letter + '-Users-' or '-' -> C:\... - if ($Encoded -match '^([A-Za-z])-(.+)$') { + # Stage 1: Drive-letter heuristic. + # A key from a Windows path starts with '--' because the drive ':' and + # its trailing '\' both map to '-' (C:\Users -> C--Users). Capture the drive, + # then turn the remaining '-' back into path separators. + if ($Encoded -match '^([A-Za-z])--(.+)$') { $drive = $Matches[1].ToUpper() $rest = $Matches[2].Replace('-', '\') $candidate = "${drive}:\${rest}" @@ -73,20 +74,21 @@ function Get-DecodedPath { } } - # Stage 2: Filesystem scan under USERPROFILE (handles dashes in dir names) + # Stage 2: Filesystem scan under USERPROFILE (handles dashes in dir names). + # Uses the pure encoder (no per-directory subprocess) for the hot comparison. $found = Get-ChildItem -Path $env:USERPROFILE -Recurse -Directory -Depth 4 ` -ErrorAction SilentlyContinue | - Where-Object { (Get-EncodedPath $_.FullName) -eq $Encoded } | + Where-Object { (Get-ClaudeProjectKeyEncoded $_.FullName) -eq $Encoded } | Select-Object -First 1 if ($found) { return $found.FullName } return $null } -# Find the MEMORY.md path for a given project directory +# Find the MEMORY.md path for a given project directory. function Get-MemoryFilePath { param([string]$ProjectPath) - $encoded = Get-EncodedPath $ProjectPath + $encoded = Get-ClaudeProjectKey $ProjectPath return Join-Path $env:USERPROFILE ".claude\projects\$encoded\memory\MEMORY.md" } @@ -251,7 +253,7 @@ if ($All) { $memoryFile = Get-MemoryFilePath -ProjectPath $ProjectDir if (-not (Test-Path $memoryFile)) { - $encoded = Get-EncodedPath $ProjectDir + $encoded = Get-ClaudeProjectKey $ProjectDir Write-Warn "No MEMORY.md found for $ProjectDir" Write-Warn "Expected: $env:USERPROFILE\.claude\projects\$encoded\memory\MEMORY.md" Write-Warn "Run Claude Code in this project first to initialize the memory directory." diff --git a/scripts/utils.ps1 b/scripts/utils.ps1 index 5a8f3b12..5748be24 100644 --- a/scripts/utils.ps1 +++ b/scripts/utils.ps1 @@ -104,3 +104,32 @@ function Test-FileDrift { Write-Host "[FAIL] $DisplayName has drifted from $Source (edit in repo + re-run setup)" -ForegroundColor Red return $false } + +# Get-ClaudeProjectKeyEncoded: the pure encoding of a working directory into +# Claude Code's per-project key (the directory name under ~/.claude/projects) -- +# every '/', '\' and drive ':' maps to '-' (C:\Users\me\p -> C--Users-me-p). This +# MUST stay byte-for-byte equal to memlink.ClaudeProjectKey (Go); a Pester guard +# asserts that parity. It is the fast, no-subprocess path for hot loops (e.g. the +# filesystem scan in knowledge-crystallize.ps1's decoder) and the offline fallback +# for Get-ClaudeProjectKey. +function Get-ClaudeProjectKeyEncoded { + param([Parameter(Mandatory)][string]$Path) + return $Path.Replace('/', '-').Replace('\', '-').Replace(':', '-') +} + +# Get-ClaudeProjectKey: the authoritative single-shot key for a working directory. +# The single source of the encoding is the Go layer (memlink.ClaudeProjectKey), so +# this calls `dotf mem project-key` when dotf is on PATH and only falls back to the +# pure encoder above when it is not. Centralized here so the Windows twins cannot +# re-drift from Go the way they did in #689 (which deleted ':' instead of mapping +# it, producing the wrong single-dash key Claude never reads). Use this for +# per-project resolution; use Get-ClaudeProjectKeyEncoded inside hot loops. +function Get-ClaudeProjectKey { + param([Parameter(Mandatory)][string]$Path) + + if (Get-Command dotf -ErrorAction SilentlyContinue) { + $key = (& dotf mem project-key $Path 2>$null | Select-Object -First 1) + if ($LASTEXITCODE -eq 0 -and $key) { return $key.Trim() } + } + return Get-ClaudeProjectKeyEncoded $Path +} diff --git a/scripts/vault-maintenance-weekly.ps1 b/scripts/vault-maintenance-weekly.ps1 index cd8acd27..d56e70cf 100644 --- a/scripts/vault-maintenance-weekly.ps1 +++ b/scripts/vault-maintenance-weekly.ps1 @@ -17,10 +17,12 @@ $output = @() $output += "=== Vault Maintenance: $(Get-Date) ===" $output += "" -# Step 1: MEMORY.md maintenance across all projects -$output += "--- knowledge-crystallize --all ---" +# Step 1: MEMORY.md maintenance across all projects. +# -All is a PowerShell [switch]; the POSIX-style "--all" bound positionally to +# $ProjectDir and left $All false, so this task never fanned out (#689 / C5). +$output += "--- knowledge-crystallize -All ---" try { - $crystOutput = & "$ScriptDir\knowledge-crystallize.ps1" --all 2>&1 + $crystOutput = & "$ScriptDir\knowledge-crystallize.ps1" -All 2>&1 $output += $crystOutput } catch { $output += "Error: $_" diff --git a/setup-windows.ps1 b/setup-windows.ps1 index e7c3b0d2..4f077147 100644 --- a/setup-windows.ps1 +++ b/setup-windows.ps1 @@ -868,7 +868,10 @@ if (Test-Path $VaultRoot) { $cwdPath = $projectDir } - $encodedPath = $cwdPath.Replace('\', '-').Replace(':', '') + # Key MUST match Claude Code / memlink.ClaudeProjectKey (':' maps to '-', + # not deleted). Get-ClaudeProjectKey (utils.ps1) sources it from `dotf` + # so this junction target can never drift from the Go layer again (#689). + $encodedPath = Get-ClaudeProjectKey $cwdPath $targetDir = Join-Path $env:USERPROFILE ".claude\projects\$encodedPath\memory" $parentDir = Split-Path $targetDir -Parent diff --git a/specs/BUG-031-windows-project-key/proposal.md b/specs/BUG-031-windows-project-key/proposal.md new file mode 100644 index 00000000..b96e9c68 --- /dev/null +++ b/specs/BUG-031-windows-project-key/proposal.md @@ -0,0 +1,116 @@ +--- +id: "BUG-031-windows-project-key" +type: spec +status: implementing # draft | implementing | verifying | archived +created: "2026-07-14" +issue: "mlorentedev/dotfiles#689" # repo#NNN — GitHub issue / Project item that tracks this spec +tags: [spec, proposal, memlink, project-key, powershell, adr-020, fresh-machine] +template_version: "1.0" +--- + +# BUG-031-windows-project-key + +Make Go the single source of the Claude auto-memory project-key encoding, have +the Windows PowerShell twins call it (with a corrected fallback), and fix the +`--all` binding so the weekly vault-maintenance task actually runs. + +## Why + +The Claude auto-memory junction encoding **drifted** between the Go layer and its +PowerShell twins. `memlink.ClaudeProjectKey` (`cli/internal/memlink/memlink.go:105`, +the #551/HARNESS-040 fix) maps `/`, `\` **and** the drive `:` each to `-`, +producing Claude Code's real key — `C:\Users\me\p` -> `C--Users-me-p`. Two +PowerShell sites still map only `\`->`-` and **delete** the `:` +(`.Replace('\','-').Replace(':','')`), producing `C-Users-me-p` — a key Claude +never reads. On Windows this means: + +- `setup-windows.ps1:871` builds the junction at the wrong path and prints a + misleading `Linked auto-memory` — junk junctions Claude ignores (self-healed + later by `dotf mem session-start`, which uses the correct key). +- `knowledge-crystallize.ps1:57` (`Get-EncodedPath`) resolves a nonexistent path + in single-project mode and warns `No MEMORY.md found` for every project. + +Separately, `vault-maintenance-weekly.ps1:23` invokes the crystallizer with +`--all` (a POSIX double-dash). PowerShell binds the literal `--all` positionally +to `$ProjectDir`; the declared `[switch]$All` stays `$false`, so the Sunday +`DotfilesVaultMaintenance` scheduled task has **never** processed a project — +it single-project-resolves `--all` and throws. + +Root cause of the encoding half: **one datum (the project-key encoding) duplicated +across Go + 2 PowerShell sites**, which drifted when #551 fixed only Go. Re-syncing +three copies would reintroduce the same drift class; per ADR-020 (Go owns tooling +logic) and Standing Order #2 (SSOT), the fix is to let Go own the encoding and have +the twins call it. This is the #551 fix the setup twin never received. + +Source: issue #689 (audit codebase-audit-2026-07-06 findings C16, C11, C5). + +## What + +- **Go owns the datum.** New `dotf mem project-key ` subcommand prints + `memlink.ClaudeProjectKey(path)`. The encoding now has one implementation. +- **PowerShell calls Go, with a corrected fallback.** `setup-windows.ps1` and + `knowledge-crystallize.ps1` obtain the key via `dotf mem project-key`, guarded on + `dotf` being on PATH (the existing `if (Get-Command dotf ...)` pattern at + `setup-windows.ps1:605/619/645`). When `dotf` is absent the inline fallback uses + the **corrected** encoding `.Replace('\','-').Replace(':','-')` (matches Go), so + bootstrap never hard-depends on the CLI yet never re-emits the buggy key. +- **Decoder follows the encoder.** `knowledge-crystallize.ps1`'s `Get-DecodedPath` + is updated from the single-dash assumption (`^([A-Za-z])-`) to the real + double-dash key (`^([A-Za-z])--`), so `--all` discovery maps real + `~/.claude/projects/*` dirs back to filesystem paths. The wrong `Get-EncodedPath` + comment ("strip the ':'") is corrected. +- **`--all` -> `-All`.** `vault-maintenance-weekly.ps1` invokes the crystallizer + with the PowerShell switch `-All`, so `$All` is true and the weekly task fans out + over every project. +- **Anti-drift guard (incident -> guard).** A Pester test asserts the PS fallback + encoder and `dotf mem project-key` both yield `C--Users-me-p` for `C:\Users\me\p` + (== Go `ClaudeProjectKey`), so the twin can never silently re-drift. + +## Out of scope + +- **Porting the scripts wholesale to Go.** Bootstrap stays shell (ADR-020 C7); the + `knowledge-crystallize` port is tracked separately (CLI-021 #490). This PR ports + only the *encoding datum*, not the scripts. +- **The other fresh-machine bugs** (#691, #690, #688) — their own issues. +- **Cleaning junk junctions already deposited** by prior buggy runs on this machine. + `dotf mem session-start` self-heals the correct key on next session; a one-time + sweep of stale `C-Users-*` dirs is a possible follow-up, not this diff. + +## Risks / open questions + +- **`dotf` on PATH at the setup memory-loop.** Verified: `dotf` is installed at + `setup-windows.ps1:533`, before the memory loop at :857. The `Get-Command dotf` + guard degrades a missing CLI to the corrected inline encoder rather than failing — + same tolerance the adjacent env/secrets steps already use. +- **Standalone scheduled runs.** `vault-maintenance-weekly` -> `knowledge-crystallize` + rely on `dotf` on the Task Scheduler PATH; the fallback covers absence. +- **Atomicity.** The decoder change is only correct if the encoder change ships in + the same PR — both live in this diff. +- **#734.** The Linux integration container can't exercise a new `dotf` subcommand + until release (and there is no Windows integration container). Coverage here is Go + unit tests for the subcommand + the Pester guard for the fallback. + +## Acceptance criteria + +- [ ] `dotf mem project-key 'C:\Users\me\p'` prints `C--Users-me-p`; a POSIX path + `/home/me/p` prints `-home-me-p` (identical to `ClaudeProjectKey`). +- [ ] `setup-windows.ps1` and `knowledge-crystallize.ps1` compute the key via + `dotf mem project-key`, with a corrected fallback when `dotf` is absent. +- [ ] `knowledge-crystallize.ps1 -All` discovers projects via the double-dash + decoder (a known key round-trips to its path). +- [ ] `vault-maintenance-weekly.ps1` invokes the crystallizer with `-All` + (`$All` is `$true`; the scheduled task fans out). +- [ ] Pester guard: PS fallback key == `dotf mem project-key` output == expected Go + key for `C:\Users\me\p`. +- [ ] `go build`/`vet`/`test ./...` + golangci-lint clean; PSScriptAnalyzer clean + (ASCII-only `.ps1`); Pester green. + +## References + +- GH issue: [#689](https://github.com/mlorentedev/dotfiles/issues/689) +- Prior encoding fix: #551 / HARNESS-040 (`ClaudeProjectKey`, Go side only) +- ADR-020 (Go owns user-facing tooling logic; bootstrap stays shell — C7) +- Related port: CLI-021 [#490](https://github.com/mlorentedev/dotfiles/issues/490) + (`dotf vault` crystallize — the eventual home of `knowledge-crystallize`) +- Sibling fresh-machine bugs: #691, #690, #688 +- Pattern: `00_meta/patterns/pattern-powershell-ascii-only.md` diff --git a/specs/BUG-031-windows-project-key/tasks.md b/specs/BUG-031-windows-project-key/tasks.md new file mode 100644 index 00000000..a685b76e --- /dev/null +++ b/specs/BUG-031-windows-project-key/tasks.md @@ -0,0 +1,46 @@ +--- +tags: [spec, tasks, templates] +created: "2026-07-14" +--- + +# Tasks - BUG-031-windows-project-key + +> TDD order. One task = one focused commit. Tick as you go. + +## Setup + +- [x] Branch created from main: `fix/windows-twin-memory-parity` +- [x] `proposal.md` is complete and acceptance criteria are testable +- [x] No open questions left in `proposal.md` "Risks / open questions" + +## Implementation + +- [x] Go: failing test — `dotf mem project-key 'C:\Users\me\p'` prints `C--Users-me-p` + and `/home/me/p` prints `-home-me-p` (`TestMemProjectKey`, cli/internal/cmd/mem_test.go) +- [x] Go: implement `mem project-key ` subcommand -> `memlink.ClaudeProjectKey`, make it pass +- [x] `setup-windows.ps1`: replace the inline encoder with `Get-ClaudeProjectKey` + (dotf-first + corrected fallback, in utils.ps1) +- [x] `knowledge-crystallize.ps1`: source utils.ps1; delete local buggy `Get-EncodedPath`; + `Get-MemoryFilePath` uses `Get-ClaudeProjectKey`; fix the misleading comment +- [x] `knowledge-crystallize.ps1`: `Get-DecodedPath` regex `^([A-Za-z])-` -> `^([A-Za-z])--`; + Stage-2 scan uses pure `Get-ClaudeProjectKeyEncoded` (no per-dir subprocess) +- [x] `vault-maintenance-weekly.ps1`: `--all` -> `-All` +- [x] Pester guard: pure encoder == `dotf mem project-key` == expected Go key + (`tests/claude-project-key.Tests.ps1`, 10/10) + +## Closing + +- [x] Every acceptance criterion covered by >=1 test +- [x] `go build`/`vet`/`test ./...` clean (golangci-lint: run in CI) +- [x] PSScriptAnalyzer clean on changed files; ASCII-only `.ps1`; Pester green +- [x] No unrelated changes in the diff (no scope creep; pre-existing #692 gaps ticketed, not inlined) +- [x] `verification.md` filled in +- [ ] PR opened referencing this spec folder + +> `features.json` omitted, matching the current precedent (BUG-029, BUG-030 ship +> proposal/tasks/verification only). Acceptance criteria map to Go/Pester tests +> named in `verification.md`. + +## Machine-readable features + +See sibling `features.json`. Pass-state is harness-gated (agent may not set `passing`). diff --git a/specs/BUG-031-windows-project-key/verification.md b/specs/BUG-031-windows-project-key/verification.md new file mode 100644 index 00000000..38360be9 --- /dev/null +++ b/specs/BUG-031-windows-project-key/verification.md @@ -0,0 +1,76 @@ +--- +tags: [spec, verification, memlink, project-key, powershell, fresh-machine] +created: "2026-07-14" +--- + +# Verification - BUG-031-windows-project-key + +## Evidence + +- [x] `dotf mem project-key 'C:\Users\me\p'` prints `C--Users-me-p`; `/home/me/p` + prints `-home-me-p` -> `TestMemProjectKey` (cli/internal/cmd/mem_test.go), + verified end-to-end via `go run ./cmd/dotf mem project-key` (win path -> + `C--Users-me-p`). +- [x] `setup-windows.ps1` and `knowledge-crystallize.ps1` compute the key via + `dotf mem project-key` with a corrected fallback -> `Get-ClaudeProjectKey` + (scripts/utils.ps1) called at setup-windows.ps1:874 and + knowledge-crystallize.ps1 `Get-MemoryFilePath` / single-project branch. +- [x] `knowledge-crystallize.ps1 -All` discovery uses the double-dash decoder -> + `Get-DecodedPath` regex `^([A-Za-z])--(.+)$`; Stage 2 uses the pure + `Get-ClaudeProjectKeyEncoded` (no per-directory subprocess). +- [x] `vault-maintenance-weekly.ps1` invokes the crystallizer with `-All` (was the + POSIX `--all` that bound positionally to `$ProjectDir`). +- [x] Pester guard: PS fallback key == `dotf mem project-key` == expected Go key -> + `tests/claude-project-key.Tests.ps1`, 10/10 passing (incl. the dotf + cross-check against the PR-built binary). +- [x] `go build`/`vet`/`test ./...` clean; PSScriptAnalyzer clean on the two + CI-linted changed files; every changed `.ps1` ASCII-only. + +## Test status + +- Go: `go build ./... && go vet ./... && go test ./...` -> all packages `ok` + (cli/internal/cmd, memlink, doctor, env, mem, ... green). `TestMemProjectKey` + passes. +- Pester (Windows, this box): `Invoke-Pester tests/claude-project-key.Tests.ps1` + -> `Tests Passed: 10, Failed: 0`. The dotf cross-check ran green against a + PR-built `dotf` on PATH. +- PSScriptAnalyzer (repo settings, Error+Warning): PASS on `setup-windows.ps1`, + `scripts/knowledge-crystallize.ps1`, `scripts/utils.ps1`, + `scripts/vault-maintenance-weekly.ps1`, `tests/claude-project-key.Tests.ps1`. +- ASCII-only: all changed `.ps1` clean. (Pre-existing non-ASCII in + `setup-windows.ps1` comments is untouched and out of scope -> tracked in #692.) +- No regressions in the existing suite. + +## Decisions made during implementation + +- **Approach B (SSOT via Go) over re-syncing three encoders** (user decision): the + duplicated encoding datum was the root cause, so Go (`memlink.ClaudeProjectKey`) + becomes the single source and the PowerShell twins call `dotf mem project-key`. +- **Split the PS helper in two** (`Get-ClaudeProjectKeyEncoded` pure + + `Get-ClaudeProjectKey` dotf-first): the decoder's Stage-2 scan encodes every + directory under `USERPROFILE`; shelling `dotf` per directory there would be a + perf regression, so hot loops use the pure encoder and single-shot resolutions + use the dotf-first wrapper. A Pester guard binds pure == dotf == Go. +- **Fallback is load-bearing, not defensive.** The Windows CI Pester runner + installs the *released* dotf (no `mem project-key` yet, #734), so + `Get-ClaudeProjectKey` degrades to the corrected pure encoder there. The guard's + dotf cross-check probes support at discovery time and SKIPs (never fails) when + absent (WIN-004 -Skip-at-discovery lesson). +- **Centralized the helper in `utils.ps1`** (already the cross-OS parity home and + already sourced by setup-windows.ps1); `knowledge-crystallize.ps1` now sources it + too, deleting its local buggy `Get-EncodedPath`. + +## Promotion candidates + +- [ ] Lesson for `docs/lessons.md`? yes - "a shared datum re-implemented per-OS + drifts silently; own it in the Go layer and have shells call it, guard-test + the fallback for parity." Capture on merge. +- [ ] ADR-worthy? no - it applies ADR-020 (Go owns tooling logic), does not change it. +- [ ] New vault pattern? no - repo-local; the lesson above suffices. + +## Archive checklist + +- [ ] `proposal.md` frontmatter set to `status: archived` +- [ ] Folder moved: `specs/BUG-031-windows-project-key/` -> `specs/archive/BUG-031-windows-project-key/` +- [ ] Bitácora board ticket (#689) moved to Done / closed with PR link (ADR-018) +- [ ] Promotions above executed (the `docs/lessons.md` entry) diff --git a/tests/claude-project-key.Tests.ps1 b/tests/claude-project-key.Tests.ps1 new file mode 100644 index 00000000..041d3a17 --- /dev/null +++ b/tests/claude-project-key.Tests.ps1 @@ -0,0 +1,52 @@ +# Pester 5 anti-drift guard for the Claude auto-memory project-key encoding +# (BUG-031 / #689). The PowerShell encoder MUST stay byte-for-byte equal to the +# Go layer (memlink.ClaudeProjectKey). This locks the pure PS encoder to the same +# known-good keys the Go TestClaudeProjectKey asserts, so the #689 drift (deleting +# ':' -> the wrong single-dash key Claude never reads) can never silently return. +# +# WIN-004 lesson: -Skip conditions are computed at DISCOVERY time (top-level), +# NOT inside BeforeAll. + +# Known-good keys -- identical to cli/internal/memlink/memlink_test.go. Any change +# that makes the PS encoder diverge from these (== Go) fails the build. +$script:Cases = @( + @{ Path = 'C:\Users\me\p'; Key = 'C--Users-me-p' } + @{ Path = 'C:\Users\mlorente\Projects\Workspace\dotfiles'; Key = 'C--Users-mlorente-Projects-Workspace-dotfiles' } + @{ Path = '/home/me/Projects/dotfiles'; Key = '-home-me-Projects-dotfiles' } +) + +# Probe (discovery time): does the installed dotf support `mem project-key`? A +# released dotf predating this PR does not (#734), so the runtime cross-check is +# SKIPPED, not failed, in that environment. +$script:DotfKeySupported = $false +if (Get-Command dotf -ErrorAction SilentlyContinue) { + $probe = (& dotf mem project-key 'C:\x' 2>$null) + if ($LASTEXITCODE -eq 0 -and $probe) { $script:DotfKeySupported = $true } +} + +BeforeAll { + . (Join-Path $PSScriptRoot '..\scripts\utils.ps1') +} + +Describe 'Get-ClaudeProjectKeyEncoded (pure encoder locked to Go)' { + It "encodes '' as '' (== memlink.ClaudeProjectKey)" -ForEach $script:Cases { + Get-ClaudeProjectKeyEncoded $Path | Should -BeExactly $Key + } + + It "never deletes the drive ':' (the #689 regression)" { + # The old bug produced 'C-Users-...' (single dash). Guard the exact class. + Get-ClaudeProjectKeyEncoded 'C:\Users\me\p' | Should -Not -BeExactly 'C-Users-me-p' + } +} + +Describe 'Get-ClaudeProjectKey (dotf-first wrapper)' { + It "resolves '' to '' via dotf or the fallback" -ForEach $script:Cases { + Get-ClaudeProjectKey $Path | Should -BeExactly $Key + } + + It "agrees with 'dotf mem project-key' when the subcommand exists" -Skip:(-not $script:DotfKeySupported) -ForEach $script:Cases { + $fromCli = (& dotf mem project-key $Path | Select-Object -First 1).Trim() + $fromCli | Should -BeExactly $Key + $fromCli | Should -BeExactly (Get-ClaudeProjectKeyEncoded $Path) + } +} From 9070b7cb6e8a195ed2708c0919c106dbe686ddbe Mon Sep 17 00:00:00 2001 From: Manu Lorente Date: Tue, 14 Jul 2026 10:36:05 -0600 Subject: [PATCH 2/3] test(mem): update knowledge-crystallize bats to the corrected key encoding Two characterization tests in knowledge-crystallize-ps1.bats asserted the buggy behavior this PR removes: one required a local `Get-EncodedPath` function (deleted in favor of the shared utils.ps1 helper), the other grepped for the colon-deleting `Replace ':' ''` pattern that produced the wrong single-dash key. They locked the #689 bug in place, so the fix flipped them red. Re-point both at the corrected reality: the script sources utils.ps1 and resolves the key via Get-ClaudeProjectKey (no local encoder), and the drive colon is mapped to '-' (double-dash key), never deleted. --- tests/knowledge-crystallize-ps1.bats | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/knowledge-crystallize-ps1.bats b/tests/knowledge-crystallize-ps1.bats index 049d33c4..cb94f93d 100644 --- a/tests/knowledge-crystallize-ps1.bats +++ b/tests/knowledge-crystallize-ps1.bats @@ -29,8 +29,13 @@ setup() { grep -q '\[switch\]\$All' "$PS1_SCRIPT" } -@test "knowledge-crystallize.ps1 has Get-EncodedPath function" { - grep -q 'function Get-EncodedPath' "$PS1_SCRIPT" +@test "knowledge-crystallize.ps1 sources the shared project-key helper (no local encoder)" { + # #689: the local Get-EncodedPath re-implemented the key encoding and drifted + # (deleting ':' -> the wrong single-dash key). It now sources utils.ps1 and + # resolves the key via Get-ClaudeProjectKey (dotf-backed single source). + grep -q "utils.ps1" "$PS1_SCRIPT" + grep -q 'Get-ClaudeProjectKey' "$PS1_SCRIPT" + ! grep -q 'function Get-EncodedPath' "$PS1_SCRIPT" } @test "knowledge-crystallize.ps1 has Get-DecodedPath function" { @@ -57,8 +62,12 @@ setup() { grep -q "ErrorActionPreference = 'Stop'" "$PS1_SCRIPT" } -@test "knowledge-crystallize.ps1 path encoding strips colon (Windows convention)" { - grep -q "Replace.*':'.*''" "$PS1_SCRIPT" +@test "knowledge-crystallize.ps1 does not strip the drive colon (the #689 regression)" { + # The bug mapped ':' to '' (delete), producing C-Users-... which Claude never + # reads. The correct key maps ':' to '-' (C--Users-...). Guard both: the buggy + # delete pattern is gone, and the decoder expects the double-dash drive key. + ! grep -q "Replace.*':'.*''" "$PS1_SCRIPT" + grep -q "A-Za-z\].*--" "$PS1_SCRIPT" } @test "knowledge-crystallize.ps1 handles filesystem scan for dashes in dir names" { From 16e76fb13e4f4d7676b400c4b3e2acfe5b8c25f9 Mon Sep 17 00:00:00 2001 From: Manu Lorente Date: Tue, 14 Jul 2026 10:40:37 -0600 Subject: [PATCH 3/3] docs(lessons): capture the characterization-test-pins-the-bug near-miss BUG-031's correct fix flipped two bats characterization tests red because they asserted the old buggy behavior (a Get-EncodedPath function + the colon-deleting Replace pattern). A *.ps1-only grep missed them. Record the rule: grep every test extension for a removed symbol/pattern, and invert any test that fingerprints a bug when you kill it. --- docs/lessons.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/lessons.md b/docs/lessons.md index 61e5d49a..d0799689 100644 --- a/docs/lessons.md +++ b/docs/lessons.md @@ -1540,3 +1540,13 @@ Note: the body's `---` is safe because it's inside the `|` scalar. **Solution**: Write the discards explicitly, matching the pattern the repo already uses (`internal/doctor/checks_secrets_tooling.go`): `_ = tmp.Close()` and `defer func() { _ = os.Remove(tmpName) }()`. The explicit-discard form satisfies errcheck on every version and documents that ignoring the error is deliberate. Confirmed by reading the failed job log (`gh run view --log-failed` → "errcheck: 2") rather than re-guessing. **Rule**: A clean local `golangci-lint run` certifies nothing when the version differs from CI — `golangci-lint-action@v8` runs golangci-lint v2, which enables errcheck for `Close`/`Remove` that v1 excluded by default. Either pin the CI version locally (check the action's major version → linter major) or, better, write `_ = x.Close()` / `defer func() { _ = os.Remove(...) }()` explicitly so the code is linter-version-agnostic. When a Go PR's `lint` job is red but local is green, read the failed job log for the exact `(linter)` tag before touching code — the fix is usually mechanical once the specific linter is known. + +### [2026-07-14] A characterization test can pin the bug you are removing — grep every test extension, not just the source + +**Context**: BUG-031 (#689) fixed the Windows Claude project-key encoding by deleting a local `Get-EncodedPath` (which mapped the drive `:` to `''`, the bug) and routing through a shared `dotf`-backed helper. Before pushing I grepped the repo for `Get-EncodedPath` — but only across `*.ps1`. Local Go tests and the Pester guard were green, so the PR looked done. + +**Problem**: CI's `test` and `test-windows` jobs failed on two bats cases in `tests/knowledge-crystallize-ps1.bats`: one asserted `grep -q 'function Get-EncodedPath'` (the function I had just deleted), the other asserted `grep -q "Replace.*':'.*''"` (the exact colon-deleting pattern that WAS the bug). These were characterization tests written against the original behavior, so a correct fix flipped them red. My `*.ps1`-only grep never saw them because the stale assertions lived in a `.bats` file, and the local Go + Pester suites did not include that bats file. + +**Solution**: Re-point both bats cases at the corrected reality — assert the script sources `utils.ps1` and uses `Get-ClaudeProjectKey` with no local encoder, and invert the colon test to assert the buggy `Replace ':' ''` is **absent** and the decoder expects the double-dash key. Confirmed by running `bats tests/knowledge-crystallize-ps1.bats` locally (16/16) before pushing the follow-up commit; CI then green. + +**Rule**: When a fix removes or renames a symbol, or changes an observable string, grep the **whole test tree across every extension** (`*.bats`, `*.Tests.ps1`, `*_test.go`) for the old name/pattern before pushing — a green local run only covers the suites you actually ran, and a characterization test that encoded the old behavior will fail precisely *because* the fix is correct. Treat a test that asserts a bug's fingerprint (a specific buggy pattern) as a liability: when you kill the bug, invert the test to guard against its return in the same change.