Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cli/.gitignore
Original file line number Diff line number Diff line change
@@ -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/
25 changes: 25 additions & 0 deletions cli/internal/cmd/mem.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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 <path> and a
// trailing newline.
func newMemProjectKeyCmd() *cobra.Command {
return &cobra.Command{
Use: "project-key <path>",
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.
Expand Down
23 changes: 23 additions & 0 deletions cli/internal/cmd/mem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"io"
"os"
"path/filepath"
"strings"
"testing"
)

Expand Down Expand Up @@ -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 <path>`: 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)
}
}
}
10 changes: 10 additions & 0 deletions docs/lessons.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <run> --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.
38 changes: 20 additions & 18 deletions scripts/knowledge-crystallize.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ============================================================================
Expand All @@ -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 '<drive>--' 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}"
Expand All @@ -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"
}

Expand Down Expand Up @@ -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."
Expand Down
29 changes: 29 additions & 0 deletions scripts/utils.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
8 changes: 5 additions & 3 deletions scripts/vault-maintenance-weekly.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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: $_"
Expand Down
5 changes: 4 additions & 1 deletion setup-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
116 changes: 116 additions & 0 deletions specs/BUG-031-windows-project-key/proposal.md
Original file line number Diff line number Diff line change
@@ -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 <path>` 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.
Comment on lines +49 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== proposal excerpt ==\n'
sed -n '40,70p' specs/BUG-031-windows-project-key/proposal.md

printf '\n== find ClaudeProjectKey ==\n'
rg -n "ClaudeProjectKey|project-key|Replace\\(" -S .

Repository: mlorentedev/dotfiles

Length of output: 18247


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the encoder and the Windows scripts mentioned in the proposal.
fd -a "ClaudeProjectKey" .
fd -a "setup-windows.ps1" .
fd -a "knowledge-crystallize.ps1" .
fd -a "proposal.md" specs/BUG-031-windows-project-key

printf '\n== encoder references ==\n'
rg -n "ClaudeProjectKey|Replace\\('/'|Replace\\('\\\\'|Replace\\(':'" -S . --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**'

printf '\n== proposal lines ==\n'
sed -n '45,60p' specs/BUG-031-windows-project-key/proposal.md

Repository: mlorentedev/dotfiles

Length of output: 10442


Make the fallback example include / too. ClaudeProjectKey replaces /, \, and :; the proposal’s fallback snippet only shows \ and :. Either add the / replacement or point this line at Get-ClaudeProjectKeyEncoded so it matches the single source of truth.

🧰 Tools
🪛 LanguageTool

[uncategorized] ~54-~54: Possible missing comma found.
Context: ...ndows.ps1:605/619/645). When dotf` is absent the inline fallback uses the **correc...

(AI_HYDRA_LEO_MISSING_COMMA)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specs/BUG-031-windows-project-key/proposal.md` around lines 49 - 56, The
fallback encoding described in the proposal omits the slash replacement used by
ClaudeProjectKey. Update the fallback example to replace /, \, and :
consistently, or reference Get-ClaudeProjectKeyEncoded as the single source of
truth; keep the dotf-based path unchanged.

- **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`
Loading
Loading