@davesheffer/hunch 1.38.0 → 1.39.0

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.
Files changed (48) hide show
  1. package/dist/cli/index.js +255 -9
  2. package/dist/cli/serve.js +1 -0
  3. package/dist/client/readOrCompute.d.ts +77 -0
  4. package/dist/client/readOrCompute.js +85 -0
  5. package/dist/client/state.d.ts +1 -0
  6. package/dist/client/state.js +1 -0
  7. package/dist/constitution/g2.d.ts +1 -0
  8. package/dist/constitution/service.js +8 -0
  9. package/dist/constitution/sourceMutation.js +23 -18
  10. package/dist/core/config.d.ts +16 -0
  11. package/dist/core/config.js +13 -0
  12. package/dist/core/machine.d.ts +20 -0
  13. package/dist/core/machine.js +101 -0
  14. package/dist/core/taskRecord.js +6 -3
  15. package/dist/core/taskReport.d.ts +25 -2
  16. package/dist/core/taskReport.js +92 -18
  17. package/dist/core/taskReportEvidence.d.ts +4 -1
  18. package/dist/core/taskReportEvidence.js +5 -2
  19. package/dist/core/taskReportHook.d.ts +16 -3
  20. package/dist/core/taskReportHook.js +65 -13
  21. package/dist/core/taskTouched.d.ts +4 -0
  22. package/dist/core/taskTouched.js +30 -10
  23. package/dist/core/types.d.ts +66 -1
  24. package/dist/core/types.js +3 -0
  25. package/dist/core/workspace.d.ts +234 -0
  26. package/dist/core/workspace.js +335 -0
  27. package/dist/extractors/helm.d.ts +17 -28
  28. package/dist/extractors/helm.js +12 -12
  29. package/dist/extractors/indexer.js +171 -7
  30. package/dist/extractors/k8sManifest.d.ts +59 -0
  31. package/dist/extractors/k8sManifest.js +507 -0
  32. package/dist/extractors/workspaces.d.ts +18 -0
  33. package/dist/extractors/workspaces.js +350 -0
  34. package/dist/integrations/claudemd.js +1 -0
  35. package/dist/integrations/hooks.d.ts +2 -0
  36. package/dist/integrations/hooks.js +25 -0
  37. package/dist/integrations/scaffold.js +11 -0
  38. package/dist/integrations/workspaceLedger.d.ts +73 -0
  39. package/dist/integrations/workspaceLedger.js +201 -0
  40. package/dist/mcp/server.js +54 -0
  41. package/dist/mcp/taskReportTools.d.ts +9 -0
  42. package/dist/mcp/taskReportTools.js +13 -5
  43. package/dist/serve/app.d.ts +2 -0
  44. package/dist/serve/app.js +107 -92
  45. package/dist/serve/mcpHttp.d.ts +27 -0
  46. package/dist/serve/mcpHttp.js +95 -0
  47. package/package.json +1 -1
  48. package/server.json +2 -2
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Workspace snapshot — THIS machine's worktrees and local branches, read from git with a
3
+ * fixed set of commands (docs/workspace-ledger.md). Deterministic, no LLM, no network
4
+ * unless `fetch` is explicitly requested.
5
+ *
6
+ * Every git invocation here uses execFileSync with a literal argv (never a shell), passes
7
+ * refs after `--end-of-options` / `--`, runs under `foreignRepoEnv` (so a hook's GIT_DIR
8
+ * cannot redirect a per-worktree query), and has a timeout. Paths come from
9
+ * `git worktree list` on this machine only — never from a stored record.
10
+ */
11
+ import { execFileSync, spawnSync } from "node:child_process";
12
+ import { existsSync, realpathSync, statSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { foreignRepoEnv, gitCommonDir, mainWorktreeRoot, stableRepositoryName } from "./git.js";
15
+ import { extracted } from "../core/types.js";
16
+ import { MAX_BRANCHES, MAX_WORKTREES, WORKSPACE_SCHEMA_VERSION, WorkspaceSchema, isSafeBranchName, workspaceId, worktreeId, } from "../core/workspace.js";
17
+ const SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
18
+ const MAX_OUTPUT = 64 * 1024 * 1024;
19
+ /** How far back in the default branch a squash-merge is searched for. */
20
+ export const DEFAULT_SQUASH_SEARCH_COMMITS = 2000;
21
+ function env() {
22
+ return foreignRepoEnv(process.env);
23
+ }
24
+ function run(cwd, args, timeout = 10_000) {
25
+ try {
26
+ return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: MAX_OUTPUT, env: env(), timeout, stdio: ["ignore", "pipe", "ignore"] });
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /** Exit status of a git predicate: 0 → true, 1 → false, anything else (git missing,
33
+ * timeout, fatal) → null. Never collapse "no" and "error" (the CommitRepairStatus rule). */
34
+ function predicate(cwd, args) {
35
+ const r = spawnSync("git", args, { cwd, env: env(), timeout: 10_000, stdio: "ignore" });
36
+ if (r.error || r.status === null)
37
+ return null;
38
+ if (r.status === 0)
39
+ return true;
40
+ return r.status === 1 ? false : null;
41
+ }
42
+ function sha(value) {
43
+ const v = value?.trim() ?? "";
44
+ return SHA.test(v) ? v : null;
45
+ }
46
+ function iso(value) {
47
+ const v = value?.trim() ?? "";
48
+ if (!v || !Number.isFinite(Date.parse(v)))
49
+ return null;
50
+ return new Date(v).toISOString();
51
+ }
52
+ /** origin/HEAD → origin/main|master → local main|master. Null when none resolves: every
53
+ * verdict is then `unknown`, never `unmerged`. */
54
+ function defaultBranch(root) {
55
+ const candidates = [];
56
+ const symbolic = run(root, ["symbolic-ref", "-q", "refs/remotes/origin/HEAD"])?.trim();
57
+ if (symbolic?.startsWith("refs/remotes/origin/")) {
58
+ const name = symbolic.slice("refs/remotes/origin/".length);
59
+ candidates.push({ name, ref: `origin/${name}`, full: symbolic });
60
+ }
61
+ for (const name of ["main", "master"]) {
62
+ candidates.push({ name, ref: `origin/${name}`, full: `refs/remotes/origin/${name}` });
63
+ }
64
+ for (const name of ["main", "master"])
65
+ candidates.push({ name, ref: name, full: `refs/heads/${name}` });
66
+ for (const c of candidates) {
67
+ if (!isSafeBranchName(c.name))
68
+ continue;
69
+ const head = sha(run(root, ["rev-parse", "--verify", "-q", "--end-of-options", `${c.full}^{commit}`]));
70
+ if (head)
71
+ return { name: c.name, ref: c.ref, head };
72
+ }
73
+ return null;
74
+ }
75
+ function listWorktrees(root) {
76
+ const out = run(root, ["worktree", "list", "--porcelain"]) ?? "";
77
+ const items = [];
78
+ let cur = null;
79
+ const flush = () => {
80
+ if (cur?.path && cur.head)
81
+ items.push({ path: cur.path, head: cur.head, branch: cur.branch ?? null, locked: !!cur.locked, prunable: !!cur.prunable, bare: !!cur.bare });
82
+ cur = null;
83
+ };
84
+ for (const line of out.split("\n")) {
85
+ if (!line.trim()) {
86
+ flush();
87
+ continue;
88
+ }
89
+ if (line.startsWith("worktree ")) {
90
+ flush();
91
+ cur = { path: line.slice(9) };
92
+ continue;
93
+ }
94
+ if (!cur)
95
+ continue;
96
+ if (line.startsWith("HEAD "))
97
+ cur.head = sha(line.slice(5)) ?? undefined;
98
+ else if (line.startsWith("branch refs/heads/")) {
99
+ const b = line.slice("branch refs/heads/".length);
100
+ cur.branch = isSafeBranchName(b) ? b : null;
101
+ }
102
+ else if (line === "detached")
103
+ cur.branch = null;
104
+ else if (line === "locked" || line.startsWith("locked "))
105
+ cur.locked = true;
106
+ else if (line === "prunable" || line.startsWith("prunable "))
107
+ cur.prunable = true;
108
+ else if (line === "bare")
109
+ cur.bare = true;
110
+ }
111
+ flush();
112
+ return items.filter((w) => !w.bare);
113
+ }
114
+ /** `git status --porcelain` is non-empty → uncommitted or untracked work that
115
+ * `git worktree remove` would refuse to discard. null when the path is gone. */
116
+ function isDirty(path) {
117
+ if (!existsSync(path))
118
+ return null;
119
+ const out = run(path, ["status", "--porcelain", "--ignore-submodules"]);
120
+ return out === null ? null : out.trim().length > 0;
121
+ }
122
+ function listBranches(root) {
123
+ const format = ["%(refname)", "%(objectname)", "%(upstream)", "%(upstream:track,nobracket)", "%(committerdate:iso-strict)", "%(worktreepath)"].join("%00");
124
+ const out = run(root, ["for-each-ref", `--format=${format}`, "refs/heads/"]) ?? "";
125
+ const items = [];
126
+ let skipped = 0;
127
+ for (const line of out.split("\n")) {
128
+ if (!line)
129
+ continue;
130
+ const [refname = "", objectname = "", upstream = "", track = "", date = "", worktreePath = ""] = line.split("\0");
131
+ if (!refname.startsWith("refs/heads/"))
132
+ continue;
133
+ const name = refname.slice("refs/heads/".length);
134
+ const head = sha(objectname);
135
+ if (!head)
136
+ continue;
137
+ // git will create `refs/heads/-x` through update-ref even though check-ref-format
138
+ // --branch refuses it; such a name is never recorded, and the omission is stated.
139
+ if (!isSafeBranchName(name)) {
140
+ skipped++;
141
+ continue;
142
+ }
143
+ const up = upstream.startsWith("refs/remotes/") ? upstream.slice("refs/remotes/".length) : null;
144
+ items.push({ name, head, upstream: up, track, date: iso(date), worktreePath: worktreePath || null });
145
+ }
146
+ return { branches: items, skipped };
147
+ }
148
+ /** Newest first (by commit date, then name), so a truncated record keeps the branches
149
+ * someone is most likely to ask about. */
150
+ function newestFirst(items) {
151
+ const stamp = (x) => x.date ?? x.last_commit_at ?? "";
152
+ const key = (x) => x.name ?? x.path ?? "";
153
+ return [...items].sort((a, b) => stamp(b).localeCompare(stamp(a)) || key(a).localeCompare(key(b)));
154
+ }
155
+ function parseTrack(track) {
156
+ if (track.trim() === "gone")
157
+ return { gone: true, ahead: null, behind: null };
158
+ const ahead = /ahead (\d+)/.exec(track);
159
+ const behind = /behind (\d+)/.exec(track);
160
+ return { gone: false, ahead: ahead ? Number(ahead[1]) : 0, behind: behind ? Number(behind[1]) : 0 };
161
+ }
162
+ /** patch-id of the whole diff between two commits: what a squash-merge lands as one commit. */
163
+ function combinedPatchId(root, base, head) {
164
+ let diff;
165
+ try {
166
+ diff = execFileSync("git", ["diff", "--binary", "--full-index", "--no-renames", "--no-ext-diff", "--no-textconv", base, head, "--"], { cwd: root, env: env(), maxBuffer: MAX_OUTPUT, timeout: 15_000, stdio: ["ignore", "pipe", "ignore"] });
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ if (!diff.byteLength)
172
+ return null;
173
+ const r = spawnSync("git", ["patch-id", "--stable"], { cwd: root, env: env(), input: diff, encoding: "utf8", maxBuffer: MAX_OUTPUT, timeout: 15_000 });
174
+ if (r.error || r.status !== 0)
175
+ return null;
176
+ const id = r.stdout.trim().split(/\s+/)[0] ?? "";
177
+ return SHA.test(id) ? id : null;
178
+ }
179
+ /** patch-id → commit for a range, via one `git log -p | git patch-id` pipeline. `ok: false`
180
+ * means the pipeline failed or overflowed — the caller says so instead of pretending the
181
+ * search happened. */
182
+ function patchIdsOf(root, range, limit, timeout) {
183
+ const map = new Map();
184
+ let log;
185
+ try {
186
+ log = execFileSync("git", ["log", "--format=%H", "-p", "--no-merges", "--binary", "--full-index", "--no-renames", "--no-ext-diff", "--no-textconv", ...(limit ? [`-n${limit}`] : []), ...range, "--"], { cwd: root, env: env(), maxBuffer: MAX_OUTPUT, timeout, stdio: ["ignore", "pipe", "ignore"] });
187
+ }
188
+ catch {
189
+ return { map, ok: false };
190
+ }
191
+ if (!log.byteLength)
192
+ return { map, ok: true };
193
+ const r = spawnSync("git", ["patch-id", "--stable"], { cwd: root, env: env(), input: log, encoding: "utf8", maxBuffer: MAX_OUTPUT, timeout });
194
+ if (r.error || r.status !== 0)
195
+ return { map, ok: false };
196
+ for (const line of r.stdout.split("\n")) {
197
+ const [patchId = "", commit = ""] = line.trim().split(/\s+/);
198
+ if (SHA.test(patchId) && SHA.test(commit) && !map.has(patchId))
199
+ map.set(patchId, commit);
200
+ }
201
+ return { map, ok: true };
202
+ }
203
+ /** The pull request a MERGE COMMIT names for this branch, from the local commit subject
204
+ * GitHub/GitLab write ("Merge pull request #N from owner/branch"). Bounded scan, JS-side
205
+ * matching (no branch name reaches git), never a forge request. */
206
+ function prFromMergeCommits(root, branch, range) {
207
+ const out = run(root, ["log", "--merges", "--format=%s", "-n500", range, "--"]) ?? "";
208
+ for (const subject of out.split("\n")) {
209
+ const m = /^Merge pull request #(\d{1,9}) from [^/\s]+\/(\S+)$/.exec(subject.trim());
210
+ if (m && m[2] === branch)
211
+ return Number(m[1]);
212
+ }
213
+ return undefined;
214
+ }
215
+ /** The pull request a SQUASH COMMIT names ("Title (#N)"). */
216
+ function prFromSquashCommit(root, commit) {
217
+ const subject = run(root, ["log", "-1", "--format=%s", "--end-of-options", commit, "--"])?.trim() ?? "";
218
+ const m = /\(#(\d{1,9})\)$/.exec(subject);
219
+ return m ? Number(m[1]) : undefined;
220
+ }
221
+ function withPr(verdict, pr) {
222
+ return pr === undefined ? verdict : { ...verdict, pr, evidence: [...verdict.evidence, `pull request #${pr} (from the local commit subject)`] };
223
+ }
224
+ function mergedVerdict(root, name, head, def, patchIds, searched) {
225
+ if (!def)
226
+ return { status: "unknown", method: null, evidence: ["no default branch resolved (origin/HEAD, origin/main, origin/master, main, master)"] };
227
+ const ancestor = predicate(root, ["merge-base", "--is-ancestor", head, def.head]);
228
+ if (ancestor === null)
229
+ return { status: "unknown", method: null, evidence: ["git merge-base failed"] };
230
+ if (ancestor) {
231
+ const verdict = { status: "merged", method: "ancestry", evidence: [`${head.slice(0, 12)} is an ancestor of ${def.ref}@${def.head.slice(0, 12)}`] };
232
+ return withPr(verdict, prFromMergeCommits(root, name, `${head}..${def.head}`));
233
+ }
234
+ const base = sha(run(root, ["merge-base", head, def.head]));
235
+ if (!base)
236
+ return { status: "unknown", method: null, evidence: [`no merge base with ${def.ref}`] };
237
+ const known = patchIds();
238
+ if (!known.ok) {
239
+ return { status: "unmerged", method: null, evidence: [`not an ancestor of ${def.ref}@${def.head.slice(0, 12)}; squash/rebase search unavailable (default-branch history too large or git failed)`] };
240
+ }
241
+ const combined = combinedPatchId(root, base, head);
242
+ if (combined) {
243
+ const commit = known.map.get(combined);
244
+ if (commit) {
245
+ const verdict = { status: "merged", method: "squash", evidence: [`patch-id of ${base.slice(0, 12)}..${head.slice(0, 12)} equals ${def.ref} commit ${commit.slice(0, 12)}`] };
246
+ return withPr(verdict, prFromSquashCommit(root, commit));
247
+ }
248
+ }
249
+ // Rebase / cherry-pick: every commit of the branch has a patch-equivalent commit in the
250
+ // default branch. Uses the one-time map instead of `git cherry`, whose cost grows with
251
+ // the default branch's history for EVERY branch checked.
252
+ const own = patchIdsOf(root, [`${base}..${head}`], null, 30_000);
253
+ if (own.ok && own.map.size && [...own.map.keys()].every((id) => known.map.has(id))) {
254
+ return { status: "merged", method: "rebase", evidence: [`all ${own.map.size} commit(s) have a patch-equivalent commit in ${def.ref} (last ${searched} searched)`] };
255
+ }
256
+ return { status: "unmerged", method: null, evidence: [`not in ${def.ref}@${def.head.slice(0, 12)}; squash/rebase searched last ${searched} commits`] };
257
+ }
258
+ function realpath(path) {
259
+ try {
260
+ return realpathSync(path);
261
+ }
262
+ catch {
263
+ return path;
264
+ }
265
+ }
266
+ function fetchedAt(root) {
267
+ const common = gitCommonDir(root);
268
+ if (!common)
269
+ return null;
270
+ try {
271
+ return statSync(join(common, "FETCH_HEAD")).mtime.toISOString();
272
+ }
273
+ catch {
274
+ return null;
275
+ }
276
+ }
277
+ /** Snapshot this machine's workspace for the repository at `root`. Validated against the
278
+ * strict schema before it is returned, so the extractor can never emit a record the
279
+ * loader would refuse. */
280
+ export function snapshotWorkspace(root, opts) {
281
+ const now = opts.now ?? new Date();
282
+ const main = mainWorktreeRoot(root);
283
+ if (opts.fetch)
284
+ run(main, ["fetch", "--prune", "--quiet"], 120_000);
285
+ const def = defaultBranch(main);
286
+ const searched = opts.squashSearchCommits ?? DEFAULT_SQUASH_SEARCH_COMMITS;
287
+ let patchIds = null;
288
+ const lazyPatchIds = () => (patchIds ??= def ? patchIdsOf(main, [def.head], searched, 60_000) : { map: new Map(), ok: true });
289
+ const notes = [];
290
+ const allWorktrees = listWorktrees(main).map((w) => ({ ...w, date: iso(run(main, ["log", "-1", "--format=%cI", "--end-of-options", w.head, "--"])) }));
291
+ const rawWorktrees = allWorktrees.length > MAX_WORKTREES ? newestFirst(allWorktrees).slice(0, MAX_WORKTREES) : allWorktrees;
292
+ if (rawWorktrees.length < allWorktrees.length)
293
+ notes.push(`truncated: ${allWorktrees.length - rawWorktrees.length} older worktree(s) omitted (record holds ${MAX_WORKTREES})`);
294
+ const mainReal = realpath(main);
295
+ const worktrees = rawWorktrees.map((w) => ({
296
+ id: worktreeId(w.path),
297
+ path: opts.publish === "full" ? w.path : null,
298
+ branch: w.branch,
299
+ head: w.head,
300
+ is_main: realpath(w.path) === mainReal,
301
+ dirty: w.prunable ? null : isDirty(w.path),
302
+ locked: w.locked,
303
+ prunable: w.prunable,
304
+ last_commit_at: w.date,
305
+ }));
306
+ const worktreeByPath = new Map(rawWorktrees.map((w) => [w.path, worktreeId(w.path)]));
307
+ const listed = listBranches(main);
308
+ if (listed.skipped)
309
+ notes.push(`skipped: ${listed.skipped} branch name(s) git would refuse as a branch argument`);
310
+ const maxBranches = Math.min(MAX_BRANCHES, Math.max(1, opts.maxBranches ?? MAX_BRANCHES));
311
+ const rawBranches = listed.branches.length > maxBranches ? newestFirst(listed.branches).slice(0, maxBranches) : listed.branches;
312
+ if (rawBranches.length < listed.branches.length)
313
+ notes.push(`truncated: ${listed.branches.length - rawBranches.length} older branch(es) omitted (record holds ${maxBranches})`);
314
+ const branches = rawBranches.map((b) => {
315
+ const track = b.upstream ? parseTrack(b.track) : { gone: false, ahead: null, behind: null };
316
+ return {
317
+ name: b.name,
318
+ head: b.head,
319
+ is_default: def?.name === b.name,
320
+ upstream: b.upstream,
321
+ upstream_gone: track.gone,
322
+ ahead: track.ahead,
323
+ behind: track.behind,
324
+ last_commit_at: b.date,
325
+ worktree: (b.worktreePath && worktreeByPath.get(b.worktreePath)) || null,
326
+ merged: def?.name === b.name
327
+ ? { status: "unmerged", method: null, evidence: ["default branch"] }
328
+ : mergedVerdict(main, b.name, b.head, def, lazyPatchIds, searched),
329
+ };
330
+ });
331
+ const record = {
332
+ schema: WORKSPACE_SCHEMA_VERSION,
333
+ id: workspaceId(opts.machine.id),
334
+ machine: { id: opts.machine.id, label: opts.machine.label, platform: process.platform },
335
+ repository: stableRepositoryName(main),
336
+ publish: opts.publish,
337
+ observed_at: now.toISOString(),
338
+ fetched_at: fetchedAt(main),
339
+ default_branch: def,
340
+ worktrees,
341
+ branches,
342
+ provenance: extracted(1, [
343
+ "git worktree list --porcelain", "git for-each-ref refs/heads/", "git status --porcelain",
344
+ "git merge-base --is-ancestor", "git diff | git patch-id --stable", "git log -p | git patch-id --stable",
345
+ ...notes,
346
+ ]),
347
+ };
348
+ return WorkspaceSchema.parse(record);
349
+ }
350
+ //# sourceMappingURL=workspaces.js.map
@@ -51,6 +51,7 @@ export function renderHunchSection(store, root) {
51
51
  lines.push("- When the user asks to **update Hunch**, run `hunch update` from this repository root. It updates to the latest release and repairs all configured harness pins. Use `hunch update --global` to also update a global CLI alongside a repository dependency; reconnect active MCP sessions afterward.");
52
52
  lines.push("- `hunch_context(target, task_id)` — the minimal relevant slice for what you're about to do; a task phrase falls back to the closest graph matches. **Call FIRST** for memory. Include the current task ID on each context call so its contribution is inspectable.");
53
53
  lines.push("- `hunch_structure(target?)` — the indexed shape of the repo/dir/file/symbol — orient from the graph, not grep rounds.");
54
+ lines.push("- `hunch_workspaces(view?)` — which worktrees and branches are open on which machine, what is merged and deletable (read-only; this machine live, others from memory). Call it instead of `git branch` / `git worktree list`; never delete on its say-so.");
54
55
  lines.push("- `hunch_runbook(task)` — the proven steps for a recurring task, before re-deriving them.");
55
56
  lines.push("- `hunch_escalations()` — the decisions only the HUMAN can make (including one exact imported ADR at a time, topic conflicts, and policy calls). Normally empty; when it isn't, ASK the user inline — an entry is a question, silence is never approval. Apply an ADR answer only through `hunch_review_imported_adr` with its printed source and review hashes.");
56
57
  lines.push("- `hunch now` (CLI) — recent decisions + the live roadmap; `hunch log` — the memory-move timeline (every capture/adopt/supersede/prune/repair, each revertable).");
@@ -28,6 +28,7 @@ export declare function installPreCommitHook(root: string, invocation: string, s
28
28
  * a repo carrying only one half (an older install, or a hand-edited hook)
29
29
  * gets the other appended rather than clobbered. */
30
30
  export declare function installPostMergeHook(root: string, invocation: string): HookInstall;
31
+ export declare function installPostCheckoutHook(root: string, invocation: string): HookInstall;
31
32
  /** Read-only diagnostic (used by `hunch doctor`): which of the three managed
32
33
  * hooks are currently present. Never writes anything — a hook counts as
33
34
  * installed if its managed marker is present, regardless of whether the
@@ -38,4 +39,5 @@ export declare function hookStatus(root: string): {
38
39
  postCommit: boolean;
39
40
  preCommit: boolean;
40
41
  postMerge: boolean;
42
+ postCheckout: boolean;
41
43
  };
@@ -25,6 +25,12 @@ function block(invocation, opts = {}) {
25
25
  // team policy, so only the explicit local-only mode forces deterministic.
26
26
  ...(opts.localOnly ? [" export HUNCH_SYNTH_PROVIDER=deterministic"] : []),
27
27
  ` ( ${invocation} sync --from-hook --quiet${priv}${commit} >/dev/null 2>&1 || true ) &`,
28
+ // Deliberately NO workspace-ledger snapshot here (docs/workspace-ledger.md): a commit
29
+ // changes HEAD, not which branches and worktrees exist — post-checkout covers that, and
30
+ // a ledger read publishes a fresh observation when someone actually asks. Snapshotting
31
+ // per commit would add git work (up to a patch-id walk) to the most frequent operation
32
+ // there is, and its backgrounded child outliving `git commit` is what held a Windows
33
+ // clone directory open and broke team-matrix-e2e's teardown with EBUSY.
28
34
  "fi",
29
35
  ENDMARK,
30
36
  ].join("\n");
@@ -141,6 +147,24 @@ export function installPostMergeHook(root, invocation) {
141
147
  const repair = installManagedBlock(root, "post-merge", REPAIR_MERGE_MARK, REPAIR_MERGE_END, repairProvenanceMergeBlock(invocation));
142
148
  return ACTION_RANK[repair.action] >= ACTION_RANK[grounding.action] ? repair : grounding;
143
149
  }
150
+ const CHECKOUT_MARK = "# >>> hunch post-checkout (workspace ledger) >>>";
151
+ const CHECKOUT_END = "# <<< hunch post-checkout (workspace ledger) <<<";
152
+ /** post-checkout is where branches and worktrees actually change (`git checkout`,
153
+ * `git switch`, `git worktree add`). git passes `$3 = 1` for a branch checkout and `0`
154
+ * for a file checkout; only the former can change the ledger. Constant argv (nothing
155
+ * from repository content), HUNCH_SYNC-guarded, backgrounded, offline. */
156
+ function checkoutBlock(invocation) {
157
+ return [
158
+ CHECKOUT_MARK,
159
+ 'if [ -z "$HUNCH_SYNC" ] && [ "$3" = "1" ]; then',
160
+ ` ( HUNCH_SYNC=1 ${invocation} workspaces snapshot --quiet >/dev/null 2>&1 || true ) &`,
161
+ "fi",
162
+ CHECKOUT_END,
163
+ ].join("\n");
164
+ }
165
+ export function installPostCheckoutHook(root, invocation) {
166
+ return installManagedBlock(root, "post-checkout", CHECKOUT_MARK, CHECKOUT_END, checkoutBlock(invocation));
167
+ }
144
168
  /** Read-only diagnostic (used by `hunch doctor`): which of the three managed
145
169
  * hooks are currently present. Never writes anything — a hook counts as
146
170
  * installed if its managed marker is present, regardless of whether the
@@ -162,6 +186,7 @@ export function hookStatus(root) {
162
186
  postCommit: has("post-commit", MARK),
163
187
  preCommit: has("pre-commit", PRE_MARK),
164
188
  postMerge: has("post-merge", GROUNDING_MERGE_MARK) && has("post-merge", REPAIR_MERGE_MARK),
189
+ postCheckout: has("post-checkout", CHECKOUT_MARK),
165
190
  };
166
191
  }
167
192
  //# sourceMappingURL=hooks.js.map
@@ -83,6 +83,16 @@ Capture the decision for **$ARGUMENTS** into Hunch's graph.
83
83
  5. Commit with \`hunch_record_decision\`, passing \`capture_token\` (from step 1) and the confirmed \`topic\`. The artifact is the graph write, not prose.
84
84
  6. On CONFLICT for the topic, do NOT auto-supersede — Hunch refuses and presents both; let me choose supersede (link) / split the topic / discard.
85
85
  `;
86
+ const WORKTREES_CMD = `---
87
+ description: Which worktrees and branches are open on which machine, what is merged and deletable — from Hunch's workspace ledger, not from git spelunking
88
+ ---
89
+ Answer **$ARGUMENTS** (default: "what is open, and what can I delete?") from the workspace ledger.
90
+
91
+ 1. Call \`hunch_workspaces(view: "branches")\` (and \`view: "inventory"\` for the worktree list). Do NOT run \`git branch\`, \`git worktree list\` or \`git log\` yourself — the tool already read this machine live and every other machine from memory.
92
+ 2. Report the rows as they are: MACHINES, WORKTREE (dirty), UPSTREAM, MERGED (with its method) and the ACTION column. A verdict of \`unknown\` or a machine marked \`unverified\` is reported as such, never upgraded to a guess.
93
+ 3. Recommend only what the ACTION column says. You never delete a branch or remove a worktree from this command; the human runs the printed git commands (or \`hunch workspaces prune\` when it ships) on the machine that holds them.
94
+ 4. If a machine is missing or stale, say so: it has not run \`hunch workspaces snapshot\` (the post-checkout hook / MCP session start does this) or it is not sharing an overlay.
95
+ `;
86
96
  const AUDIT_CMD = `---
87
97
  description: Run an audit and record what it finds into Hunch as findings (observed gaps, no code change)
88
98
  ---
@@ -232,6 +242,7 @@ export function writeSlashCommands(root) {
232
242
  ["capture.md", CAPTURE_CMD],
233
243
  ["heal.md", HEAL_CMD],
234
244
  ["audit.md", AUDIT_CMD],
245
+ ["worktrees.md", WORKTREES_CMD],
235
246
  ];
236
247
  for (const [name, body] of files) {
237
248
  const p = join(dir, name);
@@ -0,0 +1,73 @@
1
+ import { type WorkspacesConfig } from "../core/config.js";
2
+ import { type MachineIdentity } from "../core/machine.js";
3
+ import { branchRows, worktreeRows, type BranchRow, type PrunePlan, type PruneStep, type Workspace, type WorktreeRow } from "../core/workspace.js";
4
+ import type { HunchStore } from "../store/hunchStore.js";
5
+ export interface LedgerView {
6
+ machine: MachineIdentity;
7
+ /** This machine, live (paths included; never written). */
8
+ live: Workspace;
9
+ /** live + every OTHER machine's stored record. */
10
+ records: Workspace[];
11
+ config: WorkspacesConfig;
12
+ }
13
+ export declare function workspaceLedgerView(store: HunchStore, root: string, opts?: {
14
+ fetch?: boolean;
15
+ }): LedgerView;
16
+ export type SnapshotOutcome = {
17
+ status: "off";
18
+ } | {
19
+ status: "dry-run";
20
+ record: Workspace;
21
+ } | {
22
+ status: "no-home";
23
+ record: Workspace;
24
+ } | {
25
+ status: "unchanged";
26
+ record: Workspace;
27
+ previous: Workspace;
28
+ }
29
+ /** The OTHER memory home already holds a record with this machine's id (an old public
30
+ * copy, or a record someone else wrote under this id): the store refuses a twin, and so
31
+ * do we — `hunch workspaces forget <id>` removes the stale copy. */
32
+ | {
33
+ status: "collision";
34
+ record: Workspace;
35
+ reason: string;
36
+ } | {
37
+ status: "written";
38
+ record: Workspace;
39
+ home: "private" | "public";
40
+ flushed: "pushed" | "committed" | null;
41
+ };
42
+ /** Record this machine's snapshot. Honors `workspaces.publish`, skips a write when the
43
+ * content is unchanged and the stored record is under a day old (an idle machine's hooks
44
+ * must not commit a record per checkout), and reports exactly what happened. */
45
+ export declare function recordWorkspaceSnapshot(store: HunchStore, root: string, opts?: {
46
+ fetch?: boolean;
47
+ dryRun?: boolean;
48
+ live?: Workspace;
49
+ }): SnapshotOutcome;
50
+ /** Whether a snapshot could land anywhere on this root — used to skip work that would
51
+ * write nothing. */
52
+ export declare function snapshotHasHome(store: HunchStore, root: string): boolean;
53
+ export declare function padTable(header: string[], rows: string[][]): string;
54
+ export declare function renderWorktreeTable(view: LedgerView, rows: WorktreeRow[], now?: Date): string;
55
+ export declare function describeUpstream(r: BranchRow): string;
56
+ export declare function renderBranchTable(view: LedgerView, rows: BranchRow[]): string;
57
+ /** One line for `hunch now` / `hunch_now`, from STORED records only (no git, so the hot
58
+ * view stays fast); null when memory holds no workspace record. */
59
+ export declare function workspaceSummaryLine(records: readonly Workspace[], config: WorkspacesConfig, now?: Date): string | null;
60
+ export { branchRows, worktreeRows };
61
+ export declare function prunePlanFor(view: LedgerView): PrunePlan;
62
+ export interface PruneResult {
63
+ step: PruneStep;
64
+ outcome: "deleted" | "failed";
65
+ detail: string;
66
+ }
67
+ /** Execute the local steps of a plan. Each command is a fixed argv; the branch name was
68
+ * validated by the record schema and is passed after `--`; the worktree path comes from
69
+ * `git worktree list` on this machine. A failure stops that step, never the others. */
70
+ export declare function applyPrune(root: string, steps: readonly PruneStep[]): PruneResult[];
71
+ /** Interactive yes/no; false when stdin is not a terminal (the caller then needs --yes). */
72
+ export declare function confirmPrune(question: string): Promise<boolean>;
73
+ export declare function renderPrunePlan(view: LedgerView, plan: PrunePlan): string;