@jeffober/thatch 0.1.46 → 0.1.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,246 @@
1
+ # Plan: survive worktree deletion (repo path cache + cwd fallback)
2
+
3
+ Status: CONSENSUS (round 5, five fresh-context review rounds). Ready to
4
+ implement.
5
+
6
+ ## Problem
7
+
8
+ opencode sessions run inside git worktrees (`~/dev/wt-<repo>/<branch>`).
9
+ When the worktree is deleted after its branch merges:
10
+
11
+ - **Store identity corruption.** `detectRepo` (src/git.ts:78) runs
12
+ `git remote get-url origin` and `git rev-parse --git-common-dir` with
13
+ `cwd` set to the session directory. When that directory is deleted, Bun
14
+ rejects the spawn (ENOENT at chdir; verified empirically), both probes
15
+ throw, and the final fallback (src/git.ts:105) returns the directory
16
+ basename -- for a wt worktree, the branch name. That plausible-but-wrong
17
+ name becomes the project store, and memories silently target a store no
18
+ future session reads. The `"unknown"` sentinel (which degrades to the
19
+ global store, src/tool-defs.ts:38) is not reachable on this path: a real
20
+ deleted-worktree path is never empty, so the `|| "unknown"` at
21
+ src/git.ts:105 never fires for it. (A degenerate input like `/` does
22
+ return `"unknown"` today; that case is unaffected by this plan.)
23
+ - **Watcher cwd pinning.** `watch_command_create` captures the project dir
24
+ at registration (src/tool-defs.ts:1460-1464) and the poll runner spawns
25
+ `bash -c` there every cycle (src/watchers.ts:440). A deleted cwd makes
26
+ every poll reject; the watcher spins errors forever.
27
+
28
+ Only four surfaces pin a cwd for spawning: src/git.ts:82, src/git.ts:92,
29
+ src/git.ts:119 (`listBranches`), and src/watchers.ts:440. The `gh api`
30
+ spawn (src/watchers.ts:318) uses absolute REST paths and no cwd -- it is
31
+ unaffected. Watchers are in-memory only by documented contract
32
+ (src/watchers.ts:19-28).
33
+
34
+ **Host behavior when the directory is gone (verified in the opencode
35
+ monorepo, packages/opencode/src/project + packages/core/src/project.ts and
36
+ packages/core/src/fs-util.ts):** opencode does not refuse to resume. It
37
+ walks up from the missing directory, finds no `.git`, and boots the global
38
+ project with `worktree = "/"` while still passing the session's original
39
+ (deleted) path as `directory` (packages/opencode/src/plugin/index.ts:153-157).
40
+ Separately, Bun cannot launch at all from a deleted cwd, so per-prompt hook
41
+ processes in that case never run -- they are not a recovery surface. The
42
+ recovery surfaces are: resumed opencode sessions (via `ctx.directory`) and
43
+ any host that spawns from a healthy cwd while passing the deleted path via
44
+ `CLAUDE_PROJECT_DIR` / `CURSOR_PROJECT_DIR` (env has priority at
45
+ src/git.ts:79).
46
+
47
+ ## Design
48
+
49
+ ### 1. `repo_paths` cache table
50
+
51
+ ```sql
52
+ CREATE TABLE IF NOT EXISTS repo_paths (
53
+ worktree_path TEXT PRIMARY KEY,
54
+ main_path TEXT NOT NULL,
55
+ repo_slug TEXT NOT NULL,
56
+ resolved_at INTEGER NOT NULL
57
+ );
58
+ ```
59
+
60
+ - Follows the existing `CREATE TABLE IF NOT EXISTS` + ad-hoc migration
61
+ pattern (src/db.ts:89+). Writes use `ON CONFLICT DO UPDATE` upsert
62
+ (existing idiom, src/db.ts:544).
63
+ - **Key normalization.** The key is `realpathSync(dir)` at write time (the
64
+ directory exists then). On macOS, `/var/...` and `/private/var/...` are
65
+ the same directory with different path forms. Reads try the raw path
66
+ first, then `realpathSync(dirname(dir)) + basename(dir)` for the
67
+ deleted-directory case.
68
+ - **Cache access is injected, not imported.** src/git.ts stays DB-free.
69
+ `detectRepo` and the new helpers take an optional cache accessor
70
+ (`{ get(dir): Row | null; put(row): void }`); callers with a DB in scope
71
+ pass an adapter over the `repo_paths` table. Where the DB does not exist
72
+ yet, creation is reordered ahead of detection: src/index.ts:168 vs :171
73
+ and src/mcp.ts:124 vs :131. Callers without a cache behave as today plus
74
+ the `"unknown"`-on-deleted-dir fix.
75
+ - **Write trigger.** Only on successful resolution via the remote or
76
+ common-dir probes -- never from the basename fallback (caching that
77
+ output would store the exact garbage the cache exists to fix). Writes
78
+ are read-then-maybe-write: skip when the row already matches.
79
+ Frequency note: in the opencode plugin, detection runs once at init
80
+ (src/index.ts:168); the flush-tools hook runs per prompt but only on the
81
+ Claude Code / Cursor hosts (bin/thatch:973-974, :1006).
82
+
83
+ ### 2. main_path derivation
84
+
85
+ New helper in src/git.ts, run unconditionally at cache-write time:
86
+
87
+ ```ts
88
+ // resolveMainCheckout(dir): Promise<string | null>
89
+ // Runs `git rev-parse --path-format=absolute --git-common-dir` with cwd=dir.
90
+ // Returns null when git fails or is not a repo.
91
+ ```
92
+
93
+ `--path-format=absolute` makes the output absolute in every live case
94
+ (verified empirically): a linked worktree reports the main checkout's
95
+ absolute `.git`, a root checkout reports its own absolute `.git`. So
96
+ `main_path = dirname(output)` uniformly. A bare repo reports
97
+ `/path/repo.git`, which fails the `endsWith("/.git")` check; the resulting
98
+ nonsense `main_path` fails validation and is handled by the eviction rules
99
+ below -- documented at the write site, not special-cased.
100
+
101
+ detectRepo's public signature stays `Promise<string>`; a sibling helper
102
+ `resolveMainPath(dir, cache?): Promise<string | null>` reads and validates
103
+ a cache row for consumers that need the path.
104
+
105
+ ### 3. detectRepo recovery for a deleted directory
106
+
107
+ When both git probes throw:
108
+
109
+ 1. `statSync(dir)` fails (directory gone): consult the cache.
110
+ - Cache hit, and the cached `main_path` validates (below): return the
111
+ cached `repo_slug`.
112
+ - Cache hit with failed validation: return `"unknown"`.
113
+ - Cache miss: return `"unknown"`, never the basename. This preserves
114
+ the documented contract -- unidentifiable contexts must be `"unknown"`,
115
+ never a plausible wrong identity (src/tool-defs.ts:33-41).
116
+ 2. Directory exists but is not a git repo: basename fallback unchanged
117
+ (its legitimate use).
118
+
119
+ **Validation and eviction rules (definitive-invalidation only).**
120
+ Validation of a cache hit runs, with cwd = cached `main_path`:
121
+
122
+ 1. `statSync(main_path)` -- if it throws (main checkout also gone), the
123
+ row is KEPT (the checkout may be re-cloned) and this call degrades to
124
+ `"unknown"`. Transient spawn errors also keep the row. Never evict on
125
+ an error that might be transient -- a wrongly evicted row permanently
126
+ downgrades identity, which is worse than the status quo it replaces.
127
+ 2. `git rev-parse --git-common-dir` -- a throw here (not a nonzero exit
128
+ from a live dir) is likewise treated as transient; row kept.
129
+ 3. Identity check: `git remote get-url origin` in `main_path` must parse
130
+ to the cached `repo_slug`. A nonzero exit or unparseable remote on a
131
+ live directory is DEFINITIVE (the path exists but is not the recorded
132
+ repo -- re-cloned, replaced, or no longer a repo): evict the row,
133
+ return `"unknown"`. A thrown spawn error is transient and keeps the
134
+ row.
135
+
136
+ **Plugin identity recovery on resume.** The opencode plugin detects
137
+ identity from `ctx.directory` (falling back to `ctx.worktree` when
138
+ absent) instead of `ctx.worktree` alone. This touches the destructure and
139
+ type at src/index.ts:165-168, not just the call. For a resumed session in
140
+ a deleted worktree, the framework hands the plugin the original deleted
141
+ path as `directory` and `"/"` as `worktree`; keying on `directory` lets
142
+ the cache return the true store identity. Tool execution dirs
143
+ (`ctx.projectDir`) continue to use the framework's worktree value.
144
+
145
+ **CLI consistency.** `bin/thatch` `defaultStore()` (bin/thatch:76-82) lacks
146
+ the `unknown -> global` mapping that the tool layer applies. The mapping is
147
+ added there so all surfaces agree. `detectRepo` consumers that benefit from
148
+ the `"unknown"` change are enumerated for tests: `chatHookLine`
149
+ (bin/thatch:100), `flush-predictions` (bin/thatch:1217), `reminder`
150
+ (bin/thatch:729), `hygiene` (bin/thatch:769).
151
+
152
+ ### 4. Shared cwd fallback at the pinned spawn sites
153
+
154
+ A helper in src/git.ts:
155
+
156
+ ```ts
157
+ // resolveSpawnCwd(dir, cache?): Promise<string | null>
158
+ // dir if it exists; else the validated cached main_path; else null.
159
+ ```
160
+
161
+ - `listBranches` (src/git.ts:119) uses it directly: valid pinned cwd >
162
+ cached main checkout > current error behavior. `runWatchedCommand` gets
163
+ the same fallback via the plugin's runner wrapper (section 5) rather
164
+ than calling the helper itself. Branch visibility is unaffected by the
165
+ fallback (worktrees share `refs/heads` and config; only HEAD and the
166
+ index are per-worktree). For `listBranches` this upgrades a fail-safe
167
+ (`[]` -> hygiene skips the orphan check) to a correct answer.
168
+ - Inside `detectRepo` itself, the two probes use the same fallback so a
169
+ deleted worktree with a valid cache recovers without special casing.
170
+ - **Surfacing.** The watcher poll loop logs the fallback once per watcher
171
+ (a flag, not every cycle -- poll errors already log every cycle at
172
+ src/watchers.ts:1071-1073 and spam is the existing failure mode). There
173
+ is no LLM-facing surface at poll time; the notification text is not
174
+ modified.
175
+
176
+ ### 5. Watcher fallback resolved per poll via the injected runner
177
+
178
+ The plugin wires a `commandRunner` into the registry
179
+ (src/watchers.ts:234, option declared at :251, currently passed only by
180
+ tests; the plugin will start passing one at src/index.ts:247-279, where db
181
+ is in scope). The plugin's runner is a wrapper that resolves the cwd per
182
+ poll:
183
+
184
+ ```ts
185
+ // plugin scope, where db is available
186
+ const runner = async (command, cwd, timeoutMs) => {
187
+ const resolved = await resolveSpawnCwd(cwd, cacheAdapter);
188
+ if (resolved && resolved !== cwd && !loggedFallback.has(cwd)) {
189
+ console.error(`worktree deleted; watcher falling back to ${resolved}`);
190
+ loggedFallback.add(cwd); // key on the dead cwd: same once-per-watcher granularity
191
+ }
192
+ return runWatchedCommand(command, resolved ?? cwd, timeoutMs);
193
+ };
194
+ ```
195
+
196
+ - Resolution order: explicit `cd` > watcher cwd (if it still exists) >
197
+ cached main checkout > fail. This is the only mechanism that actually
198
+ recovers a watcher whose worktree dies mid-watch -- a fallback captured
199
+ at registration would be the same live path as the cwd and therefore
200
+ useless, which the previous plan revision got wrong.
201
+ - The registry keeps its no-DB contract with no new options: the cache
202
+ adapter is closed over in plugin scope, outside the registry.
203
+ - New optional param `cd` (z.string()) on `watch_command_create`,
204
+ validated at registration: the baseline run (src/watchers.ts:971-985)
205
+ spawns with it, so a dead path fails registration with the existing
206
+ friendly error (src/watchers.ts:441-443).
207
+ - `cd` is not persisted to SQLite; watchers are in-memory by contract
208
+ (src/watchers.ts:19-28). A watcher whose fallback path itself later
209
+ dies fails its polls -- acceptable under the same lifetime contract.
210
+ - `watch_create` and `watch_branch_create` get no `cd`: they run only gh
211
+ REST calls with absolute paths (src/watchers.ts:595-607); their only
212
+ directory-sensitive input is the init-time `ctx.defaultStore`, which
213
+ step 3 fixes at the source.
214
+
215
+ ## Tests
216
+
217
+ - `tests/git-integration.test.ts` (real tmp repos, existing scaffolding):
218
+ `git worktree add` + `rm -rf` the worktree; assert cache-backed slug and
219
+ main-path recovery; cache miss yields `"unknown"`, not the branch name;
220
+ existing non-git dir still yields the basename; realpath canonicalization
221
+ via a symlinked temp path; recycled main_path with a different origin
222
+ evicts (definitive) and yields `"unknown"`; missing main_path (stat
223
+ throws) keeps the row and yields `"unknown"`.
224
+ - `tests/watchers.test.ts` (injected `commandRunner` harness, lines 781+):
225
+ resolution ordering (cd > cwd > captured fallback), fallback logged once,
226
+ dead cd rejected at registration, watcher registered against a healthy
227
+ dir whose fallback was captured before deletion keeps polling.
228
+ - `tests/tool-defs.test.ts`: `cd` schema presence and validation; the
229
+ existing unknown-to-global tests (lines 326, 477) cover the tool side.
230
+
231
+ ## Accepted risks and deferred items
232
+
233
+ - Rows for dead worktrees accumulate until definitively invalidated;
234
+ bounded by worktrees ever seen. Optional prune deferred.
235
+ - Poll-time fallback is not surfaced to the LLM mid-poll (console.error
236
+ once per watcher). The eventual notification reports the condition
237
+ result, same as today.
238
+ - A fallback watcher runs its command from the main checkout root, so
239
+ commands using relative paths may observe different state -- potentially
240
+ a false exit 0 instead of a failure. Strictly better than today's
241
+ permanent error spin, but a real semantic difference worth knowing.
242
+ - Bare-repo `main_path` is nonsense until first validation evicts it;
243
+ documented at the write site rather than special-cased.
244
+ - `mkdtemp`/git path-form divergence claim verified in direction (Bun
245
+ normalizes cwd to the realpath form); the exact tmpdir form difference
246
+ is covered defensively by the dual read-path anyway.
@@ -180,7 +180,7 @@ search` - JSONL output designed for piping to `jq` or `grep`.
180
180
  |------|-------------|
181
181
  | `thatch_watch_create` | Watch a GitHub PR for events (comments, review comments and replies, thread resolutions, commits, status changes, description edits, CI completions). The plugin polls in the background and prompts the session when a watched event happens. |
182
182
  | `thatch_watch_branch_create` | Watch a GitHub branch (typically main) for commit landings, CI check-run completions, and workflow runs, with an optional workflow-name filter. |
183
- | `thatch_watch_command_create` | Wait on any local condition via a shell command that exits 0 when the wait is over. One-shot: one notification on the first exit 0, then it cancels itself. |
183
+ | `thatch_watch_command_create` | Wait on any local condition via a shell command that exits 0 when the wait is over. One-shot: one notification on the first exit 0, then it cancels itself. Accepts a `cd` path to run somewhere other than the project directory. |
184
184
  | `thatch_watch_list` | List this session's active watchers. |
185
185
  | `thatch_watch_cancel` | Cancel one of this session's watchers by id. |
186
186
 
@@ -325,6 +325,7 @@ response, walkthrough, and workflow skills that support the review pipeline:
325
325
  | `thatch-change-walkthrough` | Explains a change to the user as a teaching walkthrough: researches each affected workflow at the merge-base, teaches current behavior, then overlays the modifications with file:line citations. |
326
326
  | `thatch-code-walkthrough` | Explains a feature, module, or workflow to the user as a teaching walkthrough with file:line citations. Also used to draft high-level docs for new or undocumented features. |
327
327
  | `thatch-coding-workflow` | Plans and executes code changes with a task-list-driven workflow: complexity triage, milestone planning, research before coding, post-coding verification. Pairs with `thatch-code-archaeology` (research first, then plan). |
328
+ | `thatch-plan-refinement` | Refines a plan before it is implemented: a fresh-context reviewer subagent re-checks the plan each round until consensus; deep mode adds specialist lenses (reuse, alternatives, hidden problems, safe-to-modify, intent archaeology). |
328
329
 
329
330
  ### Writing skills
330
331
 
@@ -75,6 +75,7 @@ Skills are split into shared (all hosts) and opencode-only:
75
75
  | Skill | Purpose |
76
76
  |-------|---------|
77
77
  | `thatch-coding-workflow` | Plans and executes code changes with a task-list-driven workflow: complexity triage, milestone planning, research before coding, post-coding verification. Pairs with `thatch-code-archaeology` (research first, then plan). |
78
+ | `thatch-plan-refinement` | Refines a plan before it is implemented: a fresh-context reviewer subagent re-checks the plan each round until consensus; deep mode adds specialist lenses (reuse, alternatives, hidden problems, safe-to-modify, intent archaeology). |
78
79
 
79
80
  ## Stale skill cleanup
80
81
 
@@ -73,9 +73,14 @@ notification carries the exit code and duration only. The command's
73
73
  stdout and stderr are discarded - command output can be external
74
74
  content (a curl response, a build log), so it never rides a
75
75
  notification. The agent re-runs the command or reads logs itself when
76
- it acts. The command runs in the project directory as the local user;
76
+ it acts. The command runs in the project directory (or the `cd` path you
77
+ pass to `watch_command_create`) as the local user;
77
78
  each run is killed at a 30-second timeout
78
- (`THATCH_WATCH_COMMAND_TIMEOUT_SECONDS` overrides it).
79
+ (`THATCH_WATCH_COMMAND_TIMEOUT_SECONDS` overrides it). If the project
80
+ directory is a git worktree that gets deleted while a watch is active
81
+ (the branch merged and was cleaned up), the command keeps running from
82
+ the repo's main checkout instead of failing - branch lists and git state
83
+ are shared across worktrees, so the checks stay equivalent.
79
84
 
80
85
  ## How to use it
81
86
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeffober/thatch",
3
- "version": "0.1.46",
3
+ "version": "0.1.47",
4
4
  "description": "Persistent memory for AI coding agents — local embeddings, SQLite stores, zero config. Works with OpenCode (as a plugin), Claude Code (as an MCP server), and Cursor (as an MCP server).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/db.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  import { BehaviorEngine } from "./behavior";
9
9
  import { ChatStore, type ChatHostKind, type ChatWorktreeKind } from "./chat";
10
10
  import { PREDICTION_K, PREDICTION_P0, PREDICTION_W_SOFT } from "./scoring-engine";
11
+ import type { RepoPathCache, RepoPathRow } from "./git";
11
12
 
12
13
  export { cosineSimilarity } from "./vector-math";
13
14
  export type { PredictionNudgeItem, MatcherRow, PredictionRow, ScoredPrediction } from "./prediction";
@@ -93,6 +94,20 @@ export class ThatchDB {
93
94
  )
94
95
  `);
95
96
 
97
+ // Cache mapping worktree directories to their main checkout and repo
98
+ // identity, recorded by detectRepo while the directory is alive. When the
99
+ // worktree is deleted (merged and cleaned up), identity recovery and the
100
+ // watcher cwd fallback read this instead of failing. Never delete the
101
+ // table on upgrade - the whole point is surviving restarts.
102
+ this.#db.run(`
103
+ CREATE TABLE IF NOT EXISTS repo_paths (
104
+ worktree_path TEXT PRIMARY KEY,
105
+ main_path TEXT NOT NULL,
106
+ repo_slug TEXT NOT NULL,
107
+ resolved_at INTEGER NOT NULL
108
+ )
109
+ `);
110
+
96
111
  this.#db.run(`
97
112
  CREATE TABLE IF NOT EXISTS entries (
98
113
  slug TEXT NOT NULL,
@@ -993,6 +1008,35 @@ export class ThatchDB {
993
1008
  return this.#chat.markDelivered(ids);
994
1009
  }
995
1010
 
1011
+ // ---------------------------------------------------------------------------
1012
+ // Repo path cache - worktree dir to main checkout + identity, written by
1013
+ // detectRepo while the directory is alive and read after it is deleted.
1014
+ // ---------------------------------------------------------------------------
1015
+
1016
+ repoPathGet(worktreePath: string): RepoPathRow | null {
1017
+ const row = this.#db
1018
+ .query("SELECT worktree_path, main_path, repo_slug FROM repo_paths WHERE worktree_path = ?")
1019
+ .get(worktreePath) as any;
1020
+ if (!row) return null;
1021
+ return { worktreePath: row.worktree_path, mainPath: row.main_path, repoSlug: row.repo_slug };
1022
+ }
1023
+
1024
+ repoPathPut(row: RepoPathRow): void {
1025
+ this.#db.run(
1026
+ `INSERT INTO repo_paths (worktree_path, main_path, repo_slug, resolved_at)
1027
+ VALUES (?, ?, ?, ?)
1028
+ ON CONFLICT(worktree_path) DO UPDATE SET
1029
+ main_path = excluded.main_path,
1030
+ repo_slug = excluded.repo_slug,
1031
+ resolved_at = excluded.resolved_at`,
1032
+ [row.worktreePath, row.mainPath, row.repoSlug, Date.now()],
1033
+ );
1034
+ }
1035
+
1036
+ repoPathEvict(worktreePath: string): void {
1037
+ this.#db.run("DELETE FROM repo_paths WHERE worktree_path = ?", [worktreePath]);
1038
+ }
1039
+
996
1040
  // ---------------------------------------------------------------------------
997
1041
  // Hygiene - signals for the session-start heartbeat. Staleness means
998
1042
  // neither written nor recalled since the cutoff; recall telemetry keeps
@@ -1048,3 +1092,16 @@ export class ThatchDB {
1048
1092
  this.#db.close();
1049
1093
  }
1050
1094
  }
1095
+
1096
+ /**
1097
+ * Adapts ThatchDB to the RepoPathCache interface src/git.ts consumes, so
1098
+ * git.ts never imports the database. Callers pass this into detectRepo /
1099
+ * resolveSpawnCwd / listBranches.
1100
+ */
1101
+ export function repoPathCache(db: ThatchDB): RepoPathCache {
1102
+ return {
1103
+ get: (p) => db.repoPathGet(p),
1104
+ put: (row) => db.repoPathPut(row),
1105
+ evict: (p) => db.repoPathEvict(p),
1106
+ };
1107
+ }
package/src/git.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { $ } from "bun";
2
- import { statSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { statSync, realpathSync } from "node:fs";
3
+ import { join, dirname, basename } from "node:path";
4
4
 
5
5
  /**
6
6
  * Classifies the checkout the given directory serves: "worktree" for a
@@ -67,41 +67,219 @@ const URL_PATTERNS: UrlPattern[] = [
67
67
  },
68
68
  ];
69
69
 
70
+ /**
71
+ * A cached mapping from a worktree directory to its main checkout and repo
72
+ * identity. Injected into detectRepo and friends so src/git.ts never touches
73
+ * the database directly (the MCP server resolves identity before it opens
74
+ * the DB; hooks and tests may have no DB at all).
75
+ */
76
+ export interface RepoPathRow {
77
+ worktreePath: string;
78
+ mainPath: string;
79
+ repoSlug: string;
80
+ }
81
+
82
+ export interface RepoPathCache {
83
+ get(worktreePath: string): RepoPathRow | null;
84
+ put(row: RepoPathRow): void;
85
+ evict(worktreePath: string): void;
86
+ }
87
+
88
+ /**
89
+ * Path-form candidates for a worktree directory, raw first. On macOS a path
90
+ * can arrive as `/var/...` while git and spawned children report the same
91
+ * directory as `/private/var/...`; when the directory itself is deleted (the
92
+ * recovery case) it cannot be realpathed, so the parent is resolved and the
93
+ * basename rejoined instead.
94
+ */
95
+ function canonicalWorktreeKeys(dir: string): string[] {
96
+ const keys = [dir];
97
+ try {
98
+ keys.push(join(realpathSync(dirname(dir)), basename(dir)));
99
+ } catch {
100
+ // Parent unresolvable; the raw path is all we have.
101
+ }
102
+ return [...new Set(keys)];
103
+ }
104
+
105
+ function pathExists(dir: string): boolean {
106
+ try {
107
+ statSync(dir);
108
+ return true;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Resolves the main checkout directory for the given live directory:
116
+ * the parent of the absolute git common dir. `--path-format=absolute` makes
117
+ * the output uniform - a linked worktree reports the MAIN checkout's .git,
118
+ * a root checkout reports its own. Returns null outside a git repo, for a
119
+ * bare repo (the common dir is the bare dir itself - no separate main
120
+ * checkout), or when the spawn fails (deleted dir).
121
+ */
122
+ export async function resolveMainCheckout(dir: string): Promise<string | null> {
123
+ try {
124
+ const out = await $`git rev-parse --path-format=absolute --git-common-dir`.cwd(dir).quiet();
125
+ if (out.exitCode !== 0) return null;
126
+ const d = out.stdout.toString().trim();
127
+ if (!d.endsWith("/.git")) return null;
128
+ return d.slice(0, -"/.git".length);
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /** Verdicts for cache-row validation. "kept" = transient failure, row stays. */
135
+ type RowVerdict = "valid" | "kept" | "evicted";
136
+
137
+ /**
138
+ * Validates a cached row against the CURRENT state of its main checkout.
139
+ * Eviction is definitive-only: a wrongly evicted row permanently downgrades
140
+ * identity to "unknown", so anything that might be transient (missing main
141
+ * checkout that could be re-cloned, spawn hiccups) keeps the row and degrades
142
+ * this one call. A live directory that is no longer the recorded repo
143
+ * (re-cloned, replaced, no longer a repo) is definitive and evicts - the
144
+ * identity check is what catches a recycled path returning the old slug.
145
+ */
146
+ async function validateMainPath(row: RepoPathRow, cache: RepoPathCache): Promise<RowVerdict> {
147
+ if (!pathExists(row.mainPath)) return "kept";
148
+
149
+ try {
150
+ const rd = await $`git rev-parse --git-common-dir`.cwd(row.mainPath).quiet();
151
+ if (rd.exitCode !== 0) {
152
+ cache.evict(row.worktreePath);
153
+ return "evicted";
154
+ }
155
+ } catch {
156
+ return "kept";
157
+ }
158
+
159
+ try {
160
+ const remote = await $`git remote get-url origin`.cwd(row.mainPath).quiet();
161
+ if (remote.exitCode === 0) {
162
+ const parsed = parseGitUrl(remote.stdout.toString());
163
+ if (parsed !== row.repoSlug) {
164
+ cache.evict(row.worktreePath);
165
+ return "evicted";
166
+ }
167
+ return "valid";
168
+ }
169
+ } catch {
170
+ return "kept";
171
+ }
172
+
173
+ // No origin remote: the recorded slug came from the common-dir fallback,
174
+ // so it must still be the main checkout's own basename.
175
+ if (basename(row.mainPath) !== row.repoSlug) {
176
+ cache.evict(row.worktreePath);
177
+ return "evicted";
178
+ }
179
+ return "valid";
180
+ }
181
+
182
+ /** First cache row for the directory that still validates against its main checkout. */
183
+ async function findValidRow(dir: string, cache: RepoPathCache): Promise<RepoPathRow | null> {
184
+ for (const key of canonicalWorktreeKeys(dir)) {
185
+ const row = cache.get(key);
186
+ if (!row) continue;
187
+ const verdict = await validateMainPath(row, cache);
188
+ if (verdict === "valid") return row;
189
+ }
190
+ return null;
191
+ }
192
+
193
+ /**
194
+ * Records a successful identity resolution. Keyed on the REALPATH of the
195
+ * directory (it exists now - this is the only moment canonicalization is
196
+ * possible); read-then-maybe-write skips the row-relevant git spawn when the
197
+ * cache already agrees. Never called for the basename fallback: caching that
198
+ * output would store the exact wrong identity this cache exists to fix.
199
+ */
200
+ async function recordRepoPath(dir: string, slug: string, cache?: RepoPathCache): Promise<void> {
201
+ if (!cache) return;
202
+ let canonical: string;
203
+ try {
204
+ canonical = realpathSync(dir);
205
+ } catch {
206
+ // Directory vanished between resolution and write; nothing to record.
207
+ return;
208
+ }
209
+ const existing = cache.get(canonical);
210
+ if (existing && existing.repoSlug === slug && pathExists(existing.mainPath)) return;
211
+ const mainPath = await resolveMainCheckout(dir);
212
+ // Bare repo (no /.git-suffixed common dir) has no meaningful main
213
+ // checkout - skip rather than record a nonsense path. Validation would
214
+ // evict it on first read anyway.
215
+ if (!mainPath) return;
216
+ cache.put({ worktreePath: canonical, mainPath, repoSlug: slug });
217
+ }
218
+
219
+ /**
220
+ * Resolves the directory commands should spawn in: the given directory when
221
+ * it exists, else the validated main checkout from the cache (the worktree
222
+ * was deleted - the merged-and-gone case), else null (caller keeps its
223
+ * current failure behavior). Worktrees share refs/heads and config with the
224
+ * main checkout, so git reads are equivalent from either; only HEAD and the
225
+ * index are per-worktree.
226
+ */
227
+ export async function resolveSpawnCwd(dir: string, cache?: RepoPathCache): Promise<string | null> {
228
+ if (pathExists(dir)) return dir;
229
+ const row = cache ? await findValidRow(dir, cache) : null;
230
+ return row?.mainPath ?? null;
231
+ }
232
+
70
233
  /**
71
234
  * Resolves the canonical repository identity from the given directory.
72
235
  *
73
236
  * Resolution chain:
74
237
  * 1. Parse `owner/repo` from `git remote get-url origin`
75
- * 2. Fall back to the basename of the git-common-dir (worktree-safe)
238
+ * 2. Fall back to the basename of the main checkout (resolved from the
239
+ * absolute git-common-dir, so this is worktree-safe: the common dir
240
+ * lives in the main checkout, not the worktree)
76
241
  * 3. Fall back to the directory basename
242
+ *
243
+ * When the directory no longer exists (deleted worktree), steps 1-2 spawn
244
+ * -reject and the cache is consulted for the identity it recorded while the
245
+ * directory was alive; a miss returns "unknown" rather than a plausible
246
+ * wrong name, because resolveStore degrades "unknown" to the global store
247
+ * but a branch-shaped store name silently mis-targets memories.
77
248
  */
78
- export async function detectRepo(cwd?: string): Promise<string> {
249
+ export async function detectRepo(cwd?: string, cache?: RepoPathCache): Promise<string> {
79
250
  const dir = cwd ?? process.env.CURSOR_PROJECT_DIR ?? process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
80
251
 
81
252
  try {
82
253
  const remote = await $`git remote get-url origin`.cwd(dir).quiet();
83
254
  if (remote.exitCode === 0) {
84
255
  const parsed = parseGitUrl(remote.stdout.toString());
85
- if (parsed) return parsed;
256
+ if (parsed) {
257
+ await recordRepoPath(dir, parsed, cache);
258
+ return parsed;
259
+ }
86
260
  }
87
261
  } catch {
88
- // no remote 'origin', or not a git repo
262
+ // no remote 'origin', spawn failure (deleted dir), or not a git repo
89
263
  }
90
264
 
91
265
  try {
92
- const gitDir = await $`git rev-parse --git-common-dir`.cwd(dir).quiet();
93
- if (gitDir.exitCode === 0) {
94
- // Handle both "/path/to/.git" (absolute) and ".git" (relative,
95
- // when cwd is the repo root itself).
96
- const d = gitDir.stdout.toString().trim();
97
- const parent = d.endsWith("/.git") ? d.slice(0, -5) : (d === ".git" ? "" : d);
98
- const name = parent.split("/").pop() || "unknown";
99
- if (name !== "unknown") return name;
266
+ const main = await resolveMainCheckout(dir);
267
+ if (main) {
268
+ const name = basename(main);
269
+ if (name !== "" && name !== "unknown") {
270
+ await recordRepoPath(dir, name, cache);
271
+ return name;
272
+ }
100
273
  }
101
274
  } catch {
102
275
  // not a git repo
103
276
  }
104
277
 
278
+ if (!pathExists(dir)) {
279
+ const row = cache ? await findValidRow(dir, cache) : null;
280
+ return row?.repoSlug ?? "unknown";
281
+ }
282
+
105
283
  return dir.split("/").pop() || "unknown";
106
284
  }
107
285
 
@@ -109,14 +287,17 @@ export async function detectRepo(cwd?: string): Promise<string> {
109
287
  * Local branch names in the given directory's repository. Used to detect
110
288
  * branch-scoped memories whose branch no longer exists. Returns [] outside
111
289
  * a git repo - callers must treat that as "unknown", not "no branches",
112
- * or every branch-scoped memory would look orphaned.
290
+ * or every branch-scoped memory would look orphaned. Falls back to the
291
+ * cached main checkout when the worktree was deleted: refs/heads is shared
292
+ * across worktrees, so the branch list is identical from either.
113
293
  */
114
- export async function listBranches(cwd: string): Promise<string[]> {
294
+ export async function listBranches(cwd: string, cache?: RepoPathCache): Promise<string[]> {
295
+ const dir = (await resolveSpawnCwd(cwd, cache)) ?? cwd;
115
296
  // The format string is interpolated because Bun's shell parser rejects
116
297
  // bare parentheses in template literals.
117
298
  const fmt = "%(refname:short)";
118
299
  try {
119
- const out = await $`git for-each-ref --format=${fmt} refs/heads`.cwd(cwd).quiet();
300
+ const out = await $`git for-each-ref --format=${fmt} refs/heads`.cwd(dir).quiet();
120
301
  if (out.exitCode !== 0) return [];
121
302
  return out.stdout.toString().split("\n").map((s) => s.trim()).filter(Boolean);
122
303
  } catch {
package/src/hygiene.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ThatchDB } from "./db";
2
- import { listBranches } from "./git";
2
+ import { listBranches, type RepoPathCache } from "./git";
3
3
 
4
4
  const STALE_DAYS = 90;
5
5
 
@@ -7,11 +7,15 @@ const STALE_DAYS = 90;
7
7
  * Counts that give the agent standing cause to tend the store. Only non-zero
8
8
  * signals are reported; a healthy store stays silent. Shared by the opencode
9
9
  * plugin's session-start hook and the CLI's `thatch reminder` subcommand.
10
+ * The optional cache lets listBranches fall back to the cached main checkout
11
+ * when the worktree was deleted (refs are shared, so the branch list is
12
+ * identical from either).
10
13
  */
11
14
  export async function hygieneReport(
12
15
  db: ThatchDB,
13
16
  repo: string,
14
17
  worktree: string,
18
+ repoCache?: RepoPathCache,
15
19
  ): Promise<string | null> {
16
20
  const parts: string[] = [];
17
21
 
@@ -30,7 +34,7 @@ export async function hygieneReport(
30
34
  // branch-scoped memory look orphaned - skip the check in that case.
31
35
  const scoped = db.branchesInStore(repo);
32
36
  if (scoped.length > 0) {
33
- const live = await listBranches(worktree);
37
+ const live = await listBranches(worktree, repoCache);
34
38
  if (live.length > 0) {
35
39
  const orphaned = scoped.filter((b) => !live.includes(b));
36
40
  const n = db.entryCountForBranches(repo, orphaned);