diff --git a/CLAUDE.md b/CLAUDE.md index 31bbdb4..2b92d90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,7 +72,7 @@ which routes to the `memory_*.py` modules. ```bash okfmem status # store + per-project page/archive counts, sync + hook health okfmem backfill --dry-run # stamp decay frontmatter on existing pages -okfmem init --dry-run # pointers + registry wiring +okfmem init --dry-run # per-repo memory link + pointers + registry wiring okfmem consolidate --dry-run # decay + archive stale pages + push okfmem sync [-m ""] # commit + pull-rebase + push the store (prompts for the message if -m omitted) diff --git a/README.md b/README.md index bee8cd8..9b59bac 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,39 @@ The installer will: Make sure `~/.local/bin` is in your `$PATH`. (e.g., `export PATH="$HOME/.local/bin:$PATH"`). +### Per-repo setup (`okfmem init`) + +`install.sh` is a **once-per-machine** step. The per-project memory link, though, is +resolved from the **current repo** (the cwd's git root), so the installer only wires +the repo you ran it in — the engine clone. In **every other repo** you want memory +for, run `init` once from inside it: + +```bash +cd ~/my-project +okfmem init +``` + +That creates the `//memory -> ~/okfmem-store/projects/` +link, so your agent's `STATE.md` / `MEMORY.md` auto-load resolves to the store instead of an +empty directory. A repo you've never saved memory for has no store project dir yet — `init` +seeds one (with a starter `MEMORY.md` + `STATE.md`) and links it, so a brand-new repo is fully +wired in that single command. It's idempotent — safe to re-run, and worth re-running after an +engine update to repair skill links and pointers. + +Forgetting this step fails *silently* (the agent simply never remembers anything), so three +surfaces nag you about it: + +* **The installers** end with an unmissable "ONE MORE STEP — required in every repo" block. +* **The SessionStart hook** prints a one-line reminder when the session's repo is unlinked — + even under `--quiet`, since that's the exact case worth interrupting for. +* **`/okfmem` and `/okfmem-save`** probe the repo first and lead with the fix if it's unwired. + +All three share one read-only probe, which you can also run yourself: + +```bash +okfmem init --project-link-state # -> linked | unlinked | not-a-repo | no-claude +``` + ### Uninstalling ```bash @@ -194,7 +227,7 @@ okfmem init --wire-statusline It sets your Claude Code `statusLine` to the badge **only when you have none** — an existing/custom statusline is never clobbered; instead it prints a guarded compose snippet to paste in (mirrors how a caveman-style badge is delegated). The badge scripts (`okfmem-statusline.sh`, and `okfmem-statusline.ps1` for PowerShell) are keystroke-cheap (one small file read, no `git`/`python`) and refuse a symlinked flag. The hook also drops a git-ignored `.session-trail.md` in the store (cwd + files touched) so a *forgotten* save still leaves a same-machine trail. Opt the whole thing out with `OKFMEM_NO_STATUS=1`. ### 2. Initialization & Wiring (`okfmem init`) -Scans your system for supported harnesses (Claude Code, Antigravity) and writes a managed `` block into their global prompts so the AI knows where to find the memory. (The `install.sh` script runs this automatically). +Scans your system for supported harnesses (Claude Code, Antigravity) and writes a managed `` block into their global prompts so the AI knows where to find the memory. (The `install.sh` script runs this automatically — but only for the repo it runs in, so **run `okfmem init` once inside each new repo** you want project memory for; see [Per-repo setup](#per-repo-setup-okfmem-init).) ### 3. Backfill Metadata (`okfmem backfill`) An idempotent tool that stamps required YAML frontmatter (like `importance`, `pinned`, `created`) onto all durable pages. (The `install.sh` script runs this automatically). diff --git a/install.ps1 b/install.ps1 index faa7e0a..7479f94 100644 --- a/install.ps1 +++ b/install.ps1 @@ -316,15 +316,36 @@ Set-StatuslineBadge Write-Host "" Write-Host "okfmem installation complete!" Write-Host "" -Write-Host "Next steps:" +# The one step the installer CANNOT do for you: init resolves the project to +# wire from the process cwd (git rev-parse), so this run only wired the engine +# clone. Every other repo needs its own `okfmem init`. Make that impossible to +# scroll past -- a missed init is a silently memory-less repo, and the failure +# is invisible (the agent just never remembers anything). +$EngineName = Split-Path -Leaf $EngineDir +Write-Host "======================================================================" -ForegroundColor Yellow +Write-Host " ONE MORE STEP -- REQUIRED IN EVERY REPO YOU WANT MEMORY FOR" -ForegroundColor Yellow +Write-Host "" +Write-Host " cd C:\path\to\your-repo" +Write-Host " okfmem init" +Write-Host "" +Write-Host " This install wired only the repo it ran in ($EngineName)." +Write-Host " The memory link is PER-REPO -- repeat those two lines once in each" +Write-Host " project. Skip it and your agent silently remembers nothing there." +Write-Host "======================================================================" -ForegroundColor Yellow +Write-Host "" +Write-Host "Other next steps:" + +$Step = 1 $PathDirs = $env:Path -split ";" if ($PathDirs -notcontains $BinDir) { - Write-Host "1. Add $BinDir to your User PATH, e.g.:" + Write-Host "$Step. Add $BinDir to your User PATH, e.g.:" Write-Host " [Environment]::SetEnvironmentVariable('Path', `"`$env:Path;$BinDir`", 'User')" Write-Host " (open a new terminal afterward for PATH changes to take effect)" + $Step++ } -Write-Host "2. Check system status by running: okfmem status" -Write-Host "3. The consolidation Stop hook was wired into Claude Code automatically" +Write-Host "$Step. Check system status by running: okfmem status" +$Step++ +Write-Host "$Step. The consolidation Stop hook was wired into Claude Code automatically" Write-Host " (see the 'stop hook' line above -- nothing to paste). For OTHER" Write-Host " agents, the hook snippet is in README.md." diff --git a/install.sh b/install.sh index 150acce..1276846 100755 --- a/install.sh +++ b/install.sh @@ -258,12 +258,31 @@ offer_statusline_badge || true echo "" echo "✅ okfmem installation complete!" echo "" -echo "Next steps:" +# The one step the installer CANNOT do for you: init resolves the project to +# wire from the process cwd (git rev-parse), so this run only wired the engine +# clone. Every other repo needs its own `okfmem init`. Make that impossible to +# scroll past -- a missed init is a silently memory-less repo, and the failure +# is invisible (the agent just never remembers anything). +echo "======================================================================" +echo " ONE MORE STEP -- REQUIRED IN EVERY REPO YOU WANT MEMORY FOR" +echo "" +echo " cd /path/to/your-repo" +echo " okfmem init" +echo "" +echo " This install wired only the repo it ran in ($(basename "$ENGINE_DIR"))." +echo " The memory link is PER-REPO -- repeat those two lines once in each" +echo " project. Skip it and your agent silently remembers nothing there." +echo "======================================================================" +echo "" +echo "Other next steps:" +n=1 if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then - echo "1. Add ~/.local/bin to your PATH in ~/.bashrc or ~/.zshrc:" + echo "$n. Add ~/.local/bin to your PATH in ~/.bashrc or ~/.zshrc:" echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" + n=$((n + 1)) fi -echo "2. Check system status by running: okfmem status" -echo "3. The consolidation Stop hook was wired into Claude Code automatically" +echo "$n. Check system status by running: okfmem status" +n=$((n + 1)) +echo "$n. The consolidation Stop hook was wired into Claude Code automatically" echo " (see the 'stop hook' line above -- nothing to paste). For other" echo " agents, the hook snippet is in README.md." diff --git a/memory_init.py b/memory_init.py index 3b7c87f..295de50 100755 --- a/memory_init.py +++ b/memory_init.py @@ -1120,6 +1120,89 @@ def _adopt_and_link_orphan( return ("changed", ", ".join(bits) + f" -> {name}, then linked{tier_note}") +def _seed_store_project(target, name): + """Create an empty store project dir with the two files every harness + auto-loads (`MEMORY.md` index + `STATE.md` snapshot), so a repo that has + never been saved still links to something real. Idempotent: an existing + file is never overwritten.""" + os.makedirs(target, exist_ok=True) + memory_md = os.path.join(target, "MEMORY.md") + if not os.path.exists(memory_md): + with open(memory_md, "w", encoding="utf-8") as f: + f.write( + f"# MEMORY — {name}\n\n" + "\n" + ) + state_md = os.path.join(target, "STATE.md") + if not os.path.exists(state_md): + with open(state_md, "w", encoding="utf-8") as f: + f.write( + "---\n" + f"name: {name}-state\n" + f"description: Active-state snapshot for {name}\n" + "type: state\n" + "---\n\n" + f"# STATE — {name}\n\n" + "No session saved yet. Run `/okfmem-save` at the end of a " + "session to populate this.\n" + ) + + +def project_link_state(store, claude_projects=None): + """Read-only probe: is the CURRENT repo (cwd's git root) wired to the + store? Mutates nothing and never raises -- every reminder surface (the + SessionStart pull hook, the skills, `okfmem status`) shares this one + implementation instead of re-deriving the encoded path by hand (a + `sed 's|/|-|g'` re-derivation is wrong on Windows, where `encode_root` + also encodes the drive colon). + + Returns (state, name) where state is one word: + 'linked' — memory link resolves to this repo's store project + 'unlinked' — in a git repo with Claude Code present, but not linked; + `okfmem init` (run from the repo) is the fix + 'not-a-repo' — cwd isn't inside a git repo; nothing to link + 'no-claude' — no Claude Code harness detected + `name` is the resolved project name, or None when it can't be resolved. + """ + try: + if claude_projects is None: + claude_projects = os.path.join( + os.path.expanduser("~"), ".claude", "projects" + ) + if not detect_harnesses().get("claude_code"): + return ("no-claude", None) + root = _current_git_root() + if not root: + return ("not-a-repo", None) + reg = _load_registry(os.path.join(store, "registry.json")) + name = reg.get("overrides", {}).get(root, os.path.basename(root)) + link = os.path.join(claude_projects, encode_root(root), "memory") + target_real = os.path.realpath(os.path.join(store, "projects", name)) + if os.path.islink(link) or _is_junction(link): + if os.path.normcase(os.path.realpath(link)) == os.path.normcase( + target_real + ): + return ("linked", name) + return ("unlinked", name) + managed_copy = _managed_copy_target(link) + if managed_copy is not None and os.path.normcase( + os.path.realpath(managed_copy) + ) == os.path.normcase(target_real): + return ("linked", name) + return ("unlinked", name) + except OSError: + # Fail open: a probe that can't read the filesystem must never break + # the caller (a SessionStart hook, a skill's status line). + return ("no-claude", None) + + +def cmd_project_link_state(store): + """`okfmem init --project-link-state`: print ' ' and exit. + Read-only (rung-1) -- never prompts, never writes.""" + state, name = project_link_state(store) + print(state if name is None else f"{state} {name}") + + def link_project_memory( store, claude_projects, @@ -1170,8 +1253,18 @@ def link_project_memory( return ("skip", "not inside a git repo") name = reg.get("overrides", {}).get(root, os.path.basename(root)) target = os.path.join(store, "projects", name) + seeded = False if not os.path.isdir(target): - return ("skip", f"no store project dir for '{name}' yet") + # A repo you've never saved memory for has no store project dir yet. + # Returning "skip" here made the documented per-repo step (`cd + # && okfmem init`) a silent no-op -- exactly the case the step exists + # for. Seed the dir instead, then link it. Rung-1: additive, inside + # the user's OWN store, and only for the repo they explicitly ran + # `init` in. + if dry_run: + return ("changed", f"would seed store project '{name}' and link it") + _seed_store_project(target, name) + seeded = True target_real = os.path.realpath(target) proj_dir = os.path.join(claude_projects, encode_root(root)) link = os.path.join(proj_dir, "memory") @@ -1244,7 +1337,8 @@ def link_project_memory( elif os.path.isdir(link): os.rmdir(link) # confirmed empty above tier_note = _install_memory_link(proj_dir, link, target_real) - return ("changed", f"{verb}ed to {name}{tier_note}") + seed_note = " (store project seeded)" if seeded else "" + return ("changed", f"{verb}ed to {name}{tier_note}{seed_note}") def _write_settings_json(claude_dir, settings, data): @@ -2239,12 +2333,28 @@ def main(): "(none|okfmem|custom|no-claude|skip) and exit -- read-only, used by " "the installers to ask the right question before wiring.", ) + ap.add_argument( + "--project-link-state", + action="store_true", + help="print a one-word probe of whether the CURRENT repo is wired to " + "the store (linked|unlinked|not-a-repo|no-claude) plus the resolved " + "project name, and exit -- read-only, used by the hook and skills to " + "remind you to run `okfmem init` in a new repo.", + ) ap.add_argument( "--store", default=os.environ.get("OKFMEM_STORE", os.path.expanduser("~/okfmem-store")), ) args = ap.parse_args() + if args.project_link_state: + # Pure read; deliberately BEFORE the store-shape check so an + # unconfigured box still answers instead of exiting 2. + cmd_project_link_state( + os.path.abspath(os.path.expanduser(args.store)) + ) + return + if args.statusline_state: # Pure read; independent of a store (needs only ~/.claude). cmd_statusline_state() diff --git a/memory_pull.py b/memory_pull.py index ebf0ef2..fdc2d71 100644 --- a/memory_pull.py +++ b/memory_pull.py @@ -200,6 +200,36 @@ def pull_store(store, timeout=DEFAULT_TIMEOUT): ms._release_lock(store, lock) +def unlinked_repo_notice(store): + """Return a one-shot reminder when the session's repo has no memory link + yet, else None. + + This runs on the SessionStart path, which is the ONLY okfmem surface that + fires in a repo the user never ran `okfmem init` in -- and an unlinked repo + fails invisibly (the agent simply never remembers anything), so silence is + the wrong default. Deliberately printed even under `--quiet`: SessionStart + stdout reaches the agent as context, and this is the one line worth the + interruption. Never raises -- the fail-open contract covers this too. + """ + try: + engine = os.path.dirname(os.path.realpath(__file__)) + if engine not in sys.path: + sys.path.insert(0, engine) + import memory_init as mi + + state, name = mi.project_link_state(store) + if state != "unlinked": + return None + return ( + f"okfmem: this repo ('{name}') is NOT wired to the memory store, " + "so nothing said here will be remembered next session. Tell the " + "user to run `okfmem init` once from this repo -- then continue " + "with their request." + ) + except Exception: + return None + + def main(): ap = argparse.ArgumentParser( description="Fetch + integrate remote changes into the okfmem store " @@ -207,13 +237,19 @@ def main(): ap.add_argument("--store", default=os.environ.get("OKFMEM_STORE", os.path.expanduser("~/okfmem-store"))) ap.add_argument("--quiet", action="store_true", - help="suppress output (for hook/automation use)") + help="suppress output (for hook/automation use) -- the " + "unlinked-repo reminder still prints, since a silent " + "unlinked repo is the failure it exists to catch") args = ap.parse_args() res = pull_store(args.store) if not args.quiet: print(f"okfmem pull: {res['reason']}") + notice = unlinked_repo_notice(args.store) + if notice: + print(notice) + # Fail-open: non-zero ONLY on a rebase conflict a human must resolve. # Offline / no-upstream / already-up-to-date all exit 0 so an automated # caller (a SessionStart hook) never trips on this. diff --git a/skills/okfmem-save/SKILL.md b/skills/okfmem-save/SKILL.md index 339315d..1e4cd85 100644 --- a/skills/okfmem-save/SKILL.md +++ b/skills/okfmem-save/SKILL.md @@ -69,10 +69,25 @@ fi PROJECT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" PROJECT_NAME="$(basename "$PROJECT_ROOT")" -MEMORY_DIR="$HOME/.claude/projects/$(echo "$PROJECT_ROOT" | sed 's|:|-|g; s|/|-|g')/memory" + +# Ask the engine whether THIS repo is wired, instead of re-deriving the encoded +# path by hand: `encode_root` also encodes the drive colon on Windows, so a +# hand-rolled sed/replace resolves to the wrong directory there. +LINK_STATE="$(okfmem init --project-link-state)" # "linked " | "unlinked " | "not-a-repo" | "no-claude" + +# The probe resolves the project name through the registry (honouring renames), +# so take the name from it rather than assuming basename == project. +STORE="${OKFMEM_STORE:-$HOME/okfmem-store}" +MEMORY_DIR="$STORE/projects/$(echo "$LINK_STATE" | awk '{print $2}')" +``` + +**If the probe says `unlinked`, stop and fix that first** — this repo has no memory link, so anything you write would land in a directory the agent never auto-loads. Tell the user plainly, then run: + +```bash +okfmem init # from the repo root; seeds ~/okfmem-store/projects// and links it ``` -If `$MEMORY_DIR` does not exist or is not a symlink into `~/okfmem-store/projects/`, the project isn't set up yet — tell the user and offer to scaffold (create `~/okfmem-store/projects//` with a `MEMORY.md` + `STATE.md`, then symlink it into `$MEMORY_DIR`). +`init` creates the store project dir (with a seed `MEMORY.md` + `STATE.md`) when it doesn't exist yet, so a repo that has never been saved wires up in that one command. `not-a-repo` means `cd` to the project root first; `no-claude` means the harness isn't installed and there's nothing to link. **Pull latest before writing anything (#26).** The SessionStart pull hook (#16) only fires at session *start* — if another machine pushed to `~/okfmem-store` *during* this session, the store is stale by the time `/okfmem-save` runs. Bring it current before writing `STATE.md`/pages so this session's capture builds on the latest shared history instead of racing it during the Step 7 sync: diff --git a/skills/okfmem/SKILL.md b/skills/okfmem/SKILL.md index acaa491..9d44dff 100644 --- a/skills/okfmem/SKILL.md +++ b/skills/okfmem/SKILL.md @@ -55,6 +55,19 @@ Relay its output: detected harnesses + pointer state, registry roots/overrides, stale-reference count, and the skills-wiring line (`claude_code:N, codex:N, antigravity:N` — with "not linked — run `okfmem init`" if any are pending). +Then probe **this repo's** own memory link — the per-repo wiring is separate +from the machine-wide install, and an unlinked repo fails invisibly: + +```bash +python3 ~/okfmem/okfmem init --project-link-state # read-only +``` + +`linked ` is healthy. On **`unlinked `, lead the summary with it**: +this repo isn't wired, nothing said here will be remembered, and the fix is one +command run from the repo root — `okfmem init` (it seeds the store project dir +too, so a never-saved repo wires up in that single step). `not-a-repo` / +`no-claude` are informational, not problems. + ### Step 2: Store inventory ```bash @@ -142,7 +155,9 @@ Print this orientation instead of the dashboard: - `/okfmem-curate` (`/memory-curate`) — **rare**; judgment-driven purge/merge the automatic decay pass won't do. Routine hygiene is already automatic. - `okfmem sync -m "…"` — commit+push the store by hand (pull-rebase + lock). -- `okfmem init` — (re)wire skills + pointers into each harness after a clone. +- `okfmem init` — run once **in each repo** you want memory for (the link is + per-repo; the installer only wired the repo it ran in). Also (re)wires skills + + pointers into each harness after a clone. - `okfmem consolidate --dry-run` — preview what decay would archive. **What's automatic vs manual** diff --git a/tests/test_link_project_memory.py b/tests/test_link_project_memory.py index d8aac0e..b65bb06 100644 --- a/tests/test_link_project_memory.py +++ b/tests/test_link_project_memory.py @@ -126,9 +126,11 @@ def test_skip_when_not_a_git_repo(env, monkeypatch): assert "git repo" in msg -def test_skip_when_store_has_no_project_dir_yet(env, monkeypatch): +def test_seeds_store_project_when_none_exists_yet(env, monkeypatch): # A repo whose derived name ("unlinked") has no store/projects dir yet -- - # e.g. the very first `init` before any page has ever been authored. + # e.g. the very first `init` in a repo before any page has been authored. + # This used to be a silent "skip", which made the documented per-repo step + # (`cd && okfmem init`) a no-op exactly where it was needed. other_root = env["root"].parent / "unlinked" other_root.mkdir() monkeypatch.setattr(mi, "_current_git_root", lambda: str(other_root)) @@ -136,8 +138,56 @@ def test_skip_when_store_has_no_project_dir_yet(env, monkeypatch): status, msg = mi.link_project_memory( env["store"], env["claude_projects"], env["harnesses"], {"overrides": {}}, dry_run=False) - assert status == "skip" - assert "no store project dir" in msg + assert status == "changed" + assert "seeded" in msg + + target = os.path.join(env["store"], "projects", "unlinked") + assert os.path.isdir(target) + # Both files the harness auto-loads exist, so the link resolves to + # something real rather than an empty directory. + assert os.path.isfile(os.path.join(target, "MEMORY.md")) + assert os.path.isfile(os.path.join(target, "STATE.md")) + + link = os.path.join( + env["claude_projects"], mi.encode_root(str(other_root)), "memory") + assert os.path.realpath(link) == os.path.realpath(target) + + +def test_seed_writes_nothing_under_dry_run(env, monkeypatch): + other_root = env["root"].parent / "unlinked-dry" + other_root.mkdir() + monkeypatch.setattr(mi, "_current_git_root", lambda: str(other_root)) + + status, msg = mi.link_project_memory( + env["store"], env["claude_projects"], env["harnesses"], + {"overrides": {}}, dry_run=True) + assert status == "changed" + assert "would seed" in msg + assert not os.path.exists( + os.path.join(env["store"], "projects", "unlinked-dry")) + + +def test_seed_is_idempotent(env, monkeypatch): + # Re-running init in a seeded repo reports "ok" and never rewrites the + # seed files (a populated MEMORY.md must survive a second init). + other_root = env["root"].parent / "reinit" + other_root.mkdir() + monkeypatch.setattr(mi, "_current_git_root", lambda: str(other_root)) + kw = dict(dry_run=False) + mi.link_project_memory( + env["store"], env["claude_projects"], env["harnesses"], + {"overrides": {}}, **kw) + + memory_md = os.path.join(env["store"], "projects", "reinit", "MEMORY.md") + with open(memory_md, "w", encoding="utf-8") as f: + f.write("# MEMORY — reinit\n\n- [Real page](real.md) — hook\n") + + status, _ = mi.link_project_memory( + env["store"], env["claude_projects"], env["harnesses"], + {"overrides": {}}, **kw) + assert status == "ok" + with open(memory_md, encoding="utf-8") as f: + assert "Real page" in f.read() def test_repoints_a_broken_symlink(env): @@ -367,3 +417,54 @@ def test_honors_registry_override_for_project_name(tmp_path, monkeypatch): link = os.path.join(str(claude_projects), encoded, "memory") assert os.path.realpath(link) == os.path.realpath( os.path.join(str(store), "projects", "renamed")) + + +# --------------------------------------------------------------------------- +# project_link_state — the read-only probe every reminder surface shares +# --------------------------------------------------------------------------- + +def test_probe_reports_unlinked_before_init(env, monkeypatch): + monkeypatch.setattr(mi, "detect_harnesses", lambda: env["harnesses"]) + state, name = mi.project_link_state(env["store"], env["claude_projects"]) + assert (state, name) == ("unlinked", "myproj") + + +def test_probe_reports_linked_after_init(env, monkeypatch): + monkeypatch.setattr(mi, "detect_harnesses", lambda: env["harnesses"]) + mi.link_project_memory(env["store"], env["claude_projects"], + env["harnesses"], env["reg"], dry_run=False) + state, name = mi.project_link_state(env["store"], env["claude_projects"]) + assert (state, name) == ("linked", "myproj") + + +def test_probe_reports_unlinked_when_link_points_elsewhere(env, monkeypatch): + # A link that resolves to a DIFFERENT project is as broken as no link at + # all -- the reminder must fire rather than read it as wired. + monkeypatch.setattr(mi, "detect_harnesses", lambda: env["harnesses"]) + link = _link_path(env) + os.makedirs(os.path.dirname(link)) + os.symlink(str(env["root"].parent), link, target_is_directory=True) + state, _ = mi.project_link_state(env["store"], env["claude_projects"]) + assert state == "unlinked" + + +def test_probe_reports_not_a_repo_outside_git(env, monkeypatch): + monkeypatch.setattr(mi, "detect_harnesses", lambda: env["harnesses"]) + monkeypatch.setattr(mi, "_current_git_root", lambda: None) + assert mi.project_link_state(env["store"], env["claude_projects"]) == ( + "not-a-repo", None) + + +def test_probe_reports_no_claude_without_harness(env, monkeypatch): + monkeypatch.setattr(mi, "detect_harnesses", lambda: {"claude_code": None}) + assert mi.project_link_state(env["store"], env["claude_projects"]) == ( + "no-claude", None) + + +def test_probe_never_raises(env, monkeypatch): + # Fail-open contract: the SessionStart hook calls this, so an OSError from + # anywhere inside must collapse to a no-op answer, not a traceback. + monkeypatch.setattr(mi, "detect_harnesses", + lambda: (_ for _ in ()).throw(OSError("boom"))) + assert mi.project_link_state(env["store"], env["claude_projects"]) == ( + "no-claude", None) diff --git a/tests/test_pull.py b/tests/test_pull.py index 6c27353..531b3a6 100644 --- a/tests/test_pull.py +++ b/tests/test_pull.py @@ -208,3 +208,30 @@ def test_main_exit_one_only_on_conflict(monkeypatch): "conflict": True, "offline": False, "reason": "conflict"}) assert code == 1 + + +# -------------------------------------------------------------------------- +# unlinked_repo_notice — the SessionStart reminder to run `okfmem init` +# -------------------------------------------------------------------------- + +def test_notice_fires_only_when_unlinked(monkeypatch): + import memory_init as mi + + monkeypatch.setattr(mi, "project_link_state", lambda store: ("unlinked", "proj")) + msg = mp.unlinked_repo_notice("/any/store") + assert msg and "okfmem init" in msg and "proj" in msg + + for state in ("linked", "not-a-repo", "no-claude"): + monkeypatch.setattr(mi, "project_link_state", + lambda store, _s=state: (_s, "proj")) + assert mp.unlinked_repo_notice("/any/store") is None + + +def test_notice_never_raises(monkeypatch): + # Fail-open: this runs on the SessionStart path, so a broken probe must + # degrade to "no reminder", never a traceback that fails the hook. + import memory_init as mi + + monkeypatch.setattr(mi, "project_link_state", + lambda store: (_ for _ in ()).throw(RuntimeError("boom"))) + assert mp.unlinked_repo_notice("/any/store") is None