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
57 changes: 57 additions & 0 deletions cli/internal/doctor/checks_automemory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package doctor

import (
"path/filepath"

"github.com/mlorentedev/dotfiles/cli/internal/memlink"
)

// checkAutoMemoryLink verifies the Claude auto-memory directory is a link
// (junction on Windows, symlink on POSIX) into the knowledge vault, so /handoff
// lands the session continuity block in the single sink — the vault — and not in
// ~/.claude or the code repo. That link is otherwise created only by the silent
// session-start hook; on a machine where it is missing, /handoff orphans the
// block (knowledge#120, observed 2026-06-19). Surfacing + repairing it from the
// one command users actually run for setup drift is the #551 acceptance.
//
// Mirrors checkVaultHooks: verify always, repair only under --fix, idempotent.
// Crucially it shares memlink with the session-start adapter, so doctor and the
// hook compute an identical target on every OS — and, like memlink.Ensure, it
// never overwrites a real data directory (the divergence case is a WARN to
// reconcile by hand, not a destructive fix).
func checkAutoMemoryLink(sys *System, start string, rep *Report, fix bool) {
rep.Section("Auto-memory vault link")

// Same ADR-025 seam + legacy default as checkVault / checkVaultHooks, so a
// path-default fix is inherited here too.
vault := sys.env("VAULT_PATH", filepath.Join(sys.home(), "Projects", "knowledge"))
target := memlink.ClaudeMemoryTarget(sys.home(), start)

switch memlink.Status(start, target, "", vault) {
case memlink.StateLinked:
rep.Pass("auto-memory linked to the vault: " + target)

case memlink.StateNoSource:
// No vault memory source for this project — a valid state (not every repo
// has a vault project), so SKIP, not FAIL.
rep.Skip("no vault memory source for this project — nothing to link")

case memlink.StateRealDir:
// The agent's own data lives here, diverged from the vault. Repairing would
// mean destroying it, which memlink refuses by contract — so do NOT --fix;
// flag it for a manual reconcile (knowledge#120).
rep.Warn("auto-memory is a real directory, not a vault link: " + target +
" — /handoff writes here, not the vault. Reconcile by hand (merge into the vault source, then relink); --fix will not overwrite real data.")

case memlink.StateRepairable:
if !fix {
rep.Fail("auto-memory not linked to the vault — run `dotf doctor --fix` so /handoff reaches the vault")
return
}
if msg, _ := memlink.Ensure(start, target, "", vault); msg != "" {
rep.Fix("linked auto-memory to the vault: " + target)
} else {
rep.Fail("could not create the auto-memory link at " + target + " (check permissions)")
}
}
}
87 changes: 87 additions & 0 deletions cli/internal/doctor/checks_automemory_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package doctor

import (
"bytes"
"path/filepath"
"strings"
"testing"

"github.com/mlorentedev/dotfiles/cli/internal/memlink"
)

// autoMemEnv builds a HOME+VAULT_PATH env and a vault holding a memory source for
// project "myproj", returning the System and the start dir whose base is myproj.
func autoMemEnv(t *testing.T) (sys *System, start, vault string) {
t.Helper()
home := t.TempDir()
vault = t.TempDir()
mkdirAll(t, filepath.Join(vault, "10_projects", "myproj", "memory"))
start = "/work/myproj" // filepath.Base → "myproj"; resolves convention 1
sys = newSys(map[string]string{"HOME": home, "VAULT_PATH": vault}, nil, nil)
return sys, start, vault
}

func TestCheckAutoMemoryLink(t *testing.T) {
t.Run("no vault source → SKIP", func(t *testing.T) {
home := t.TempDir()
vault := t.TempDir()
sys := newSys(map[string]string{"HOME": home, "VAULT_PATH": vault}, nil, nil)
var buf bytes.Buffer
rep := capture(&buf)
checkAutoMemoryLink(sys, "/work/orphan", rep, false)
if rep.Failures() != 0 || !strings.Contains(buf.String(), "nothing to link") {
t.Errorf("expected SKIP, got\n%s", buf.String())
}
})

t.Run("source exists, link missing, no --fix → FAIL", func(t *testing.T) {
sys, start, _ := autoMemEnv(t)
var buf bytes.Buffer
rep := capture(&buf)
checkAutoMemoryLink(sys, start, rep, false)
if rep.Failures() != 1 || !strings.Contains(buf.String(), "dotf doctor --fix") {
t.Errorf("expected FAIL with a --fix hint, got\n%s", buf.String())
}
})

// NB: subtest names must avoid commas/parens — they leak into t.TempDir() paths,
// and a bare comma in a path breaks `cmd /c mklink` arg parsing on Windows.
t.Run("fix links it then a re-run is idempotent", func(t *testing.T) {
sys, start, _ := autoMemEnv(t)

var fixBuf bytes.Buffer
repFix := capture(&fixBuf)
checkAutoMemoryLink(sys, start, repFix, true)
if !strings.Contains(fixBuf.String(), "linked auto-memory to the vault") {
t.Fatalf("--fix should create the link, got\n%s", fixBuf.String())
}

var reBuf bytes.Buffer
repRe := capture(&reBuf)
checkAutoMemoryLink(sys, start, repRe, true)
if repRe.Failures() != 0 || !strings.Contains(reBuf.String(), "auto-memory linked to the vault") {
t.Errorf("re-run should be an idempotent PASS, got\n%s", reBuf.String())
}
})

t.Run("real non-empty dir → WARN, never destroyed by --fix", func(t *testing.T) {
sys, start, _ := autoMemEnv(t)
target := memlink.ClaudeMemoryTarget(sys.home(), start)
ownFile := filepath.Join(target, "MEMORY.md")
writeFile(t, ownFile, "local agent data")

var buf bytes.Buffer
rep := capture(&buf)
checkAutoMemoryLink(sys, start, rep, true) // even with --fix
out := buf.String()
if rep.Failures() != 0 {
t.Errorf("a real data dir must WARN, not FAIL\n%s", out)
}
if !strings.Contains(out, "real directory") || !strings.Contains(out, "Reconcile") {
t.Errorf("expected the manual-reconcile WARN, got\n%s", out)
}
if !pathExists(ownFile) {
t.Error("--fix must NOT destroy the agent's own data dir")
}
})
}
27 changes: 21 additions & 6 deletions cli/internal/doctor/checks_contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,19 @@ import (
// persist it, not mutating an ephemeral child environment.
func checkContractEnvVars(sys *System, c *Contract, rep *Report, fix bool) {
rep.Section("Environment variables (contract)")
osName := contractOS(sys)
for _, e := range c.EnvVars {
if e.RequiredOn == "windows" {
rep.Pass(e.Name + " (windows-scoped, skipped on Linux)")
// A var scoped to a different OS than this one does not apply here.
if e.RequiredOn != "" && e.RequiredOn != osName {
rep.Pass(fmt.Sprintf("%s (%s-scoped, skipped on %s)", e.Name, e.RequiredOn, osName))
continue
}

current := sys.Getenv(e.Name)
if current == "" {
def := expandHome(sys, e.Default["linux"])
def := expandHome(sys, e.Default[osName])
if def == "" {
if e.requiredOnLinux() {
if e.requiredOn(osName) {
rep.Fail(e.Name + " unset and no default available (required)")
} else {
rep.Pass(e.Name + " unset (optional, no default)")
Expand All @@ -32,7 +34,7 @@ func checkContractEnvVars(sys *System, c *Contract, rep *Report, fix bool) {
}
if fix {
rep.Fix(fmt.Sprintf("%s unset — add to your shell profile: export %s=%q", e.Name, e.Name, def))
} else if e.requiredOnLinux() {
} else if e.requiredOn(osName) {
rep.Warn(fmt.Sprintf("%s unset (required); default %q — run --fix or set in profile", e.Name, def))
} else {
rep.Warn(fmt.Sprintf("%s unset; default %q would be reported with --fix", e.Name, def))
Expand All @@ -59,7 +61,7 @@ func checkContractEnvVars(sys *System, c *Contract, rep *Report, fix bool) {
// will set it on next login), never a hard FAIL.
func checkContractPath(sys *System, c *Contract, rep *Report) {
rep.Section("PATH entries (contract)")
for _, entry := range c.RequiredPathEntries["linux"] {
for _, entry := range c.RequiredPathEntries[contractOS(sys)] {
expanded := expandHome(sys, entry)
if pathContains(sys, expanded) {
rep.Pass(expanded + " in PATH")
Expand Down Expand Up @@ -103,6 +105,19 @@ func checkRequiredBinaries(sys *System, c *Contract, rep *Report) {
}
}

// contractOS maps the runtime GOOS to the env-contract's OS dialect key. The
// contract declares only two dialects: "linux" (POSIX — $HOME paths, the shell
// profiles) and "windows" ($env:USERPROFILE paths). macOS ("darwin") and the
// "" test default share the POSIX/linux dialect, so only Windows branches. This
// replaces the formerly hardcoded "linux" key, which made the env-contract sweep
// report Linux paths on Windows — a false-positive drift every session (#551).
func contractOS(sys *System) string {
if sys.GOOS == "windows" {
return "windows"
}
return "linux"
}

// contractBinaryNames returns the set of binary names already version-checked by
// the contract, so the core-tools section can avoid double-reporting them.
func contractBinaryNames(c *Contract) map[string]bool {
Expand Down
101 changes: 101 additions & 0 deletions cli/internal/doctor/checks_contract_os_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package doctor

import (
"bytes"
"strings"
"testing"
)

// TestContractOS maps the runtime GOOS to the env-contract's two dialect keys.
// Only Windows branches; macOS ("darwin") and the "" test default share the
// POSIX/linux dialect.
func TestContractOS(t *testing.T) {
for goos, want := range map[string]string{
"windows": "windows",
"linux": "linux",
"darwin": "linux",
"": "linux",
} {
if got := contractOS(&System{GOOS: goos}); got != want {
t.Errorf("contractOS(GOOS=%q) = %q, want %q", goos, got, want)
}
}
}

// TestCheckContractPath_Dialects proves the PATH-entries check reads the OS
// dialect for the running platform, not a hardcoded "linux" — the root cause of
// the false-positive drift banner on Windows (Linux entries expanded with a
// Windows home).
func TestCheckContractPath_Dialects(t *testing.T) {
contract := &Contract{RequiredPathEntries: map[string][]string{
"linux": {"$HOME/.local/bin"},
"windows": {`$env:USERPROFILE\scripts`},
}}

t.Run("linux dialect", func(t *testing.T) {
home := t.TempDir()
// expandHome does literal token substitution, so the expanded entry keeps
// the contract's forward slash ("$HOME/.local/bin") — build the PATH entry
// the same way rather than with filepath.Join, whose separator is host-OS.
linuxEntry := home + "/.local/bin"
env := map[string]string{"HOME": home, "PATH": linuxEntry}
var buf bytes.Buffer
checkContractPath(newSys(env, nil, nil), contract, capture(&buf)) // GOOS "" → linux
out := buf.String()
if !strings.Contains(out, linuxEntry+" in PATH") {
t.Errorf("linux entry should be checked + pass\n%s", out)
}
if strings.Contains(out, "scripts") {
t.Errorf("windows entry must NOT be checked on linux\n%s", out)
}
})

t.Run("windows dialect", func(t *testing.T) {
home := t.TempDir() // POSIX temp dir → no drive colon to collide with the ':' list separator on the test host
winEntry := home + `\scripts`
env := map[string]string{"USERPROFILE": home, "PATH": winEntry}
s := newSys(env, nil, nil)
s.GOOS = "windows"
var buf bytes.Buffer
checkContractPath(s, contract, capture(&buf))
out := buf.String()
if !strings.Contains(out, winEntry+" in PATH") {
t.Errorf("windows entry should be checked + pass (and $env:USERPROFILE expanded)\n%s", out)
}
if strings.Contains(out, ".local/bin") {
t.Errorf("linux entry must NOT be checked on windows\n%s", out)
}
})
}

// TestCheckContractEnvVars_WindowsDialect proves env-var defaults and OS scoping
// follow the running platform: on Windows a linux-scoped var skips, the
// windows-scoped var validates, and a defaulted var resolves its WINDOWS default.
func TestCheckContractEnvVars_WindowsDialect(t *testing.T) {
home := t.TempDir()
contract := &Contract{EnvVars: []ContractEnvVar{
{Name: "HOME", RequiredOn: "linux", Validation: "path_exists"},
{Name: "USERPROFILE", RequiredOn: "windows", Validation: "path_exists"},
{Name: "DOTFILES_DIR", Default: map[string]string{
"linux": "$HOME/linuxonly",
"windows": `$env:USERPROFILE\winonly`,
}, Validation: "path_exists"},
}}
env := map[string]string{"USERPROFILE": home} // DOTFILES_DIR unset → falls back to the dialect default
s := newSys(env, nil, nil)
s.GOOS = "windows"

var buf bytes.Buffer
checkContractEnvVars(s, contract, capture(&buf), false)
out := buf.String()

if !strings.Contains(out, "HOME (linux-scoped, skipped on windows)") {
t.Errorf("a linux-scoped var must SKIP on windows\n%s", out)
}
if !strings.Contains(out, "USERPROFILE="+home) {
t.Errorf("the windows-scoped USERPROFILE must validate on windows\n%s", out)
}
if !strings.Contains(out, "winonly") || strings.Contains(out, "linuxonly") {
t.Errorf("DOTFILES_DIR must resolve the WINDOWS default, not the linux one\n%s", out)
}
}
8 changes: 4 additions & 4 deletions cli/internal/doctor/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ type ContractEnvVar struct {
Validation string `json:"validation"`
}

// requiredOnLinux reports whether this var must be present on Linux: either
// globally required, or scoped required_on: linux.
func (e ContractEnvVar) requiredOnLinux() bool {
return e.Required || e.RequiredOn == "linux"
// requiredOn reports whether this var must be present on the given OS dialect:
// either globally required, or scoped required_on to that same OS.
func (e ContractEnvVar) requiredOn(os string) bool {
return e.Required || e.RequiredOn == os
}

// ContractBinary is a required binary with an optional pinned minimum version
Expand Down
1 change: 1 addition & 0 deletions cli/internal/doctor/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ func Run(opts Options) (int, error) {
checkOptionalTools(sys, cfg, contract, rep)
checkVault(sys, rep)
checkVaultHooks(sys, rep, opts.Fix)
checkAutoMemoryLink(sys, start, rep, opts.Fix)
checkPathFiles(sys, cfg, rep)
checkSecrets(sys, cfg, rep)
checkPATExpiry(sys, cfg, rep)
Expand Down
8 changes: 6 additions & 2 deletions cli/internal/doctor/fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,16 @@ func fileContains(p, substr string) bool {
return strings.Contains(string(raw), substr)
}

// expandHome substitutes $HOME and ${HOME} with the resolved home dir, leaving
// other variables untouched — the exact scope of doctor.sh's expand_path.
// expandHome substitutes the home-dir tokens of both contract dialects with the
// resolved home dir, leaving other variables untouched — the POSIX $HOME/${HOME}
// (doctor.sh's expand_path) plus PowerShell's $env:USERPROFILE, so the windows
// dialect resolves even when the GOOS seam selects it on a POSIX test host.
func expandHome(sys *System, s string) string {
home := sys.home()
s = strings.ReplaceAll(s, "${HOME}", home)
s = strings.ReplaceAll(s, "$HOME", home)
s = strings.ReplaceAll(s, "${env:USERPROFILE}", home)
s = strings.ReplaceAll(s, "$env:USERPROFILE", home)
return s
}

Expand Down
5 changes: 3 additions & 2 deletions cli/internal/mem/session_start_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"os"
"path/filepath"
"time"

"github.com/mlorentedev/dotfiles/cli/internal/memlink"
)

// This file is the Claude session-start adapter (CLI-025 PR2b-2b): it composes the
Expand Down Expand Up @@ -65,8 +67,7 @@ func ClaudeContext(in ClaudeContextInput) string {
ctx += vaultHealth(vaultRoot, vaultName, in.ScriptsDir)
ctx += memorySymlink(in.Cwd, in.Vault, in.Home)

encoded := encodeProjectPath(in.Cwd)
memoryDir := filepath.Join(in.Home, ".claude", "projects", encoded, "memory")
memoryDir := memlink.ClaudeMemoryTarget(in.Home, in.Cwd)
ctx += knowledgeHealth(filepath.Join(memoryDir, "MEMORY.md"),
cfg.threshold("memory_md_max_lines", 150), cfg.threshold("crystallize_max_days", 14), in.Now)
ctx += vaultBaseline(vaultRoot)
Expand Down
4 changes: 1 addition & 3 deletions cli/internal/mem/session_start_detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,7 @@ func workSDKFamilyBlock(family string) string {
// and surfaces the "[auto-memory] Created …" line when it created one. Computes
// Claude's encoded target path and delegates the OS-agnostic link to memlink.
func memorySymlink(cwd, vault, home string) string {
encoded := encodeProjectPath(cwd)
target := filepath.Join(home, ".claude", "projects", encoded, "memory")
msg, _ := memlink.Ensure(cwd, target, "", vault)
msg, _ := memlink.Ensure(cwd, memlink.ClaudeMemoryTarget(home, cwd), "", vault)
if msg == "" {
return ""
}
Expand Down
6 changes: 0 additions & 6 deletions cli/internal/mem/session_start_injectors.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,6 @@ import (
// byte-equivalence is pinned by its own unit test. An injector with nothing to say
// returns "".

// encodeProjectPath maps a CWD to Claude Code's per-project key by replacing '/'
// with '-' (the shell's `tr '/' '-'`).
func encodeProjectPath(cwd string) string {
return strings.ReplaceAll(cwd, "/", "-")
}

// claudeJSONSize warns when ~/.claude/.claude.json has been truncated below
// threshold bytes (the upstream `claude plugin install` strip bug, SDD-021). Empty
// when the file is absent or a healthy size.
Expand Down
Loading
Loading