@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.
- package/README.md +2 -2
- package/artifacts/skills/thatch-plan-refinement.md +80 -0
- package/bin/thatch +17 -8
- package/docs/dev/README.md +3 -3
- package/docs/dev/features/cli.md +1 -1
- package/docs/dev/features/database.md +25 -0
- package/docs/dev/features/multi-host.md +3 -1
- package/docs/dev/features/qa-system.md +1 -1
- package/docs/dev/features/repo-identity.md +110 -0
- package/docs/dev/features/watchers.md +8 -0
- package/docs/dev/skills.md +1 -0
- package/docs/plans/worktree-deletion-recovery.md +246 -0
- package/docs/user/README.md +2 -1
- package/docs/user/skills.md +1 -0
- package/docs/user/watchers.md +7 -2
- package/package.json +1 -1
- package/src/db.ts +57 -0
- package/src/git.ts +198 -17
- package/src/hygiene.ts +6 -2
- package/src/index.ts +27 -9
- package/src/mcp.ts +4 -2
- package/src/prompts.ts +2 -0
- package/src/skills.ts +1 -0
- package/src/tool-defs.ts +11 -4
- package/src/watchers.ts +26 -0
|
@@ -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.
|
package/docs/user/README.md
CHANGED
|
@@ -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
|
|
package/docs/user/skills.md
CHANGED
|
@@ -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
|
|
package/docs/user/watchers.md
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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)
|
|
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
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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(
|
|
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);
|