@davesheffer/hunch 1.38.1 → 1.39.1

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 (54) hide show
  1. package/dist/cli/index.js +355 -55
  2. package/dist/cli/integrations.js +10 -0
  3. package/dist/cli/serve.js +1 -0
  4. package/dist/client/readOrCompute.d.ts +77 -0
  5. package/dist/client/readOrCompute.js +85 -0
  6. package/dist/client/state.d.ts +1 -0
  7. package/dist/client/state.js +1 -0
  8. package/dist/constitution/g2.d.ts +1 -0
  9. package/dist/constitution/service.js +8 -0
  10. package/dist/constitution/sourceMutation.js +23 -18
  11. package/dist/core/agenthook.d.ts +14 -0
  12. package/dist/core/agenthook.js +48 -5
  13. package/dist/core/changeProof.js +5 -1
  14. package/dist/core/checkreport.d.ts +7 -0
  15. package/dist/core/checkreport.js +20 -3
  16. package/dist/core/compare.js +3 -2
  17. package/dist/core/config.d.ts +16 -0
  18. package/dist/core/config.js +13 -0
  19. package/dist/core/machine.d.ts +20 -0
  20. package/dist/core/machine.js +101 -0
  21. package/dist/core/taskReportEvidence.js +6 -6
  22. package/dist/core/types.d.ts +67 -1
  23. package/dist/core/types.js +3 -0
  24. package/dist/core/workspace.d.ts +256 -0
  25. package/dist/core/workspace.js +359 -0
  26. package/dist/extractors/diff.d.ts +34 -0
  27. package/dist/extractors/diff.js +147 -5
  28. package/dist/extractors/git.d.ts +40 -11
  29. package/dist/extractors/git.js +147 -43
  30. package/dist/extractors/helm.d.ts +17 -28
  31. package/dist/extractors/helm.js +12 -12
  32. package/dist/extractors/indexer.js +171 -7
  33. package/dist/extractors/k8sManifest.d.ts +59 -0
  34. package/dist/extractors/k8sManifest.js +507 -0
  35. package/dist/extractors/workspaces.d.ts +28 -0
  36. package/dist/extractors/workspaces.js +427 -0
  37. package/dist/integrations/claudemd.js +1 -0
  38. package/dist/integrations/gitignore.d.ts +27 -2
  39. package/dist/integrations/gitignore.js +103 -17
  40. package/dist/integrations/hooks.d.ts +63 -7
  41. package/dist/integrations/hooks.js +350 -38
  42. package/dist/integrations/scaffold.js +11 -0
  43. package/dist/integrations/workspaceLedger.d.ts +93 -0
  44. package/dist/integrations/workspaceLedger.js +307 -0
  45. package/dist/mcp/server.js +59 -5
  46. package/dist/serve/app.d.ts +2 -0
  47. package/dist/serve/app.js +107 -92
  48. package/dist/serve/mcpHttp.d.ts +27 -0
  49. package/dist/serve/mcpHttp.js +95 -0
  50. package/dist/store/hunchStore.d.ts +4 -2
  51. package/dist/store/hunchStore.js +23 -6
  52. package/dist/store/stateBinding.js +83 -35
  53. package/package.json +1 -1
  54. package/server.json +2 -2
@@ -1,7 +1,36 @@
1
+ /** Which hook manager (if any) owns the hook file git would run.
2
+ * - `none`: a plain local hooks dir Hunch may write into.
3
+ * - `pre-commit`: the pre-commit framework's generated hook (ends in `exec`).
4
+ * - `husky`: husky v9 (`core.hooksPath=.husky/_`, stubs source `h`, which exits).
5
+ * - `husky-legacy`: husky ≤8 (`core.hooksPath=.husky`, a tracked directory).
6
+ * - `tracked-hooks-path`: any other `core.hooksPath` inside the work tree that git tracks.
7
+ * - `exec-exit`: no known manager, but the existing hook ends in exec/exit before our block. */
8
+ export type HookManagerKind = "none" | "pre-commit" | "husky" | "husky-legacy" | "tracked-hooks-path" | "exec-exit";
1
9
  export interface HookInstall {
10
+ /** The hook file written; for a non-writing result, the file the user should edit. */
2
11
  path: string;
3
- action: "created" | "appended" | "updated" | "unchanged";
12
+ /** created/appended/updated/unchanged: the block is (now) in a file git reaches.
13
+ * managed-elsewhere: a hook manager owns the hook — nothing was written.
14
+ * unreachable: the existing hook ends in exec/exit before our block — nothing was written. */
15
+ action: "created" | "appended" | "updated" | "unchanged" | "managed-elsewhere" | "unreachable";
16
+ manager?: HookManagerKind;
17
+ /** Why nothing was written (non-writing actions only). */
18
+ reason?: string;
19
+ /** What to add to `path` by hand (non-writing actions only). */
20
+ snippet?: string;
4
21
  }
22
+ /** Portable invocation for text that lands in a TRACKED file (a husky script, a
23
+ * committed hooks dir, .pre-commit-config.yaml): the same exact-version npx
24
+ * package reference the committed MCP/provider configs use — never this
25
+ * machine's absolute node/CLI path. */
26
+ export declare const PORTABLE_HOOK_INVOCATION: string;
27
+ /** The line that ends the script unconditionally before anything appended after
28
+ * it could run, or null. Deliberately conservative (a heuristic, not a shell
29
+ * parser): an `exec`/`exit` statement outside any if/case/loop/function block,
30
+ * or a trailing top-level `if … else … fi` whose every branch ends in
31
+ * `exec`/`exit` — the shape of the pre-commit framework's generated hook. */
32
+ export declare function terminalExitLine(content: string): string | null;
33
+ type BlockState = "installed" | "unreachable" | "missing";
5
34
  export declare function installPostCommitHook(root: string, invocation: string, opts?: {
6
35
  private?: boolean;
7
36
  commit?: boolean;
@@ -28,14 +57,41 @@ export declare function installPreCommitHook(root: string, invocation: string, s
28
57
  * a repo carrying only one half (an older install, or a hand-edited hook)
29
58
  * gets the other appended rather than clobbered. */
30
59
  export declare function installPostMergeHook(root: string, invocation: string): HookInstall;
31
- /** Read-only diagnostic (used by `hunch doctor`): which of the three managed
32
- * hooks are currently present. Never writes anything — a hook counts as
33
- * installed if its managed marker is present, regardless of whether the
34
- * invocation inside it happens to be stale. postMerge requires BOTH halves
35
- * (grounding-refresh and repair-provenance) present — a repo carrying only
36
- * one is a partial install, same as `installPostMergeHook` self-healing it. */
60
+ export declare function installPostCheckoutHook(root: string, invocation: string): HookInstall;
61
+ export type HookState = BlockState;
62
+ export interface HookReportEntry {
63
+ state: HookState;
64
+ manager: HookManagerKind;
65
+ /** The file where the block lives (or should live). */
66
+ path: string;
67
+ /** Why an `unreachable` block never runs. */
68
+ reason?: string;
69
+ }
70
+ export interface HookReport {
71
+ postCommit: HookReportEntry;
72
+ preCommit: HookReportEntry;
73
+ postMerge: HookReportEntry;
74
+ postCheckout: HookReportEntry;
75
+ }
76
+ /** Read-only diagnostic (used by `hunch doctor`): each managed hook's state —
77
+ * `installed` (present where git will run it), `unreachable` (present, but
78
+ * after an exec/exit or in a manager-owned file git never reaches), or
79
+ * `missing`. postMerge requires BOTH halves (grounding-refresh and
80
+ * repair-provenance) — a repo carrying only one is a partial install, same as
81
+ * `installPostMergeHook` self-healing it. Never writes anything. */
82
+ export declare function hookReport(root: string): HookReport;
83
+ /** Boolean view of `hookReport`: a hook counts as installed only when its
84
+ * managed block is present where git will actually run it, regardless of
85
+ * whether the invocation inside it happens to be stale. An unreachable block
86
+ * (issue #311) is NOT installed. */
37
87
  export declare function hookStatus(root: string): {
38
88
  postCommit: boolean;
39
89
  preCommit: boolean;
40
90
  postMerge: boolean;
91
+ postCheckout: boolean;
41
92
  };
93
+ /** CLI lines for one install result: the usual ✓ line when the block is in a
94
+ * file git runs, otherwise a warning with the reason and the snippet to add to
95
+ * the manager's own file. */
96
+ export declare function formatHookInstall(root: string, label: string, h: HookInstall, detail?: string): string[];
97
+ export {};
@@ -3,12 +3,26 @@
3
3
  * learning loop after every commit. Loop-guarded via the HUNCH_SYNC env var, and
4
4
  * backgrounded so it never slows a commit down. Existing hooks are preserved —
5
5
  * we append a guarded block rather than clobbering.
6
+ *
7
+ * Hook managers (issue #311): an appended block is only worth writing when git
8
+ * will actually reach it and the file is this machine's own. The pre-commit
9
+ * framework's generated hook ends in `exec`, husky v9 routes every hook through
10
+ * `.husky/_/h` (which ends in `exit $c` and is regenerated on `npm install`), and
11
+ * husky ≤8 / any committed `core.hooksPath` would put this machine's absolute
12
+ * CLI path into tracked files. In those cases nothing is written: the installer
13
+ * returns a portable snippet for the manager's own configuration instead, and
14
+ * `hookReport` tells a present-but-dead block apart from a working one.
6
15
  */
7
- import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync } from "node:fs";
8
- import { join, isAbsolute } from "node:path";
9
- import { hooksDir } from "../extractors/git.js";
16
+ import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, realpathSync } from "node:fs";
17
+ import { spawnSync } from "node:child_process";
18
+ import { join, isAbsolute, dirname, basename, relative, resolve } from "node:path";
19
+ import { hooksDir, gitCommonDir } from "../extractors/git.js";
20
+ import { initiatorChildEnv } from "../synthesis/initiator.js";
21
+ import { HUNCH_NPX_PACKAGE_SPEC } from "../core/version.js";
10
22
  const MARK = "# >>> hunch post-commit >>>";
11
23
  const ENDMARK = "# <<< hunch post-commit <<<";
24
+ const GIT_CONTEXT = { checkoutType: "$3" };
25
+ const PRE_COMMIT_CONTEXT = { checkoutType: "$PRE_COMMIT_CHECKOUT_TYPE" };
12
26
  function block(invocation, opts = {}) {
13
27
  // --private routes the auto-synthesized decision into the HUNCH_PRIVATE_DIR overlay
14
28
  // instead of the public repo. --commit (opt-in) also commits & pushes the repo the
@@ -25,6 +39,12 @@ function block(invocation, opts = {}) {
25
39
  // team policy, so only the explicit local-only mode forces deterministic.
26
40
  ...(opts.localOnly ? [" export HUNCH_SYNTH_PROVIDER=deterministic"] : []),
27
41
  ` ( ${invocation} sync --from-hook --quiet${priv}${commit} >/dev/null 2>&1 || true ) &`,
42
+ // Deliberately NO workspace-ledger snapshot here (docs/workspace-ledger.md): a commit
43
+ // changes HEAD, not which branches and worktrees exist — post-checkout covers that, and
44
+ // a ledger read publishes a fresh observation when someone actually asks. Snapshotting
45
+ // per commit would add git work (up to a patch-id walk) to the most frequent operation
46
+ // there is, and its backgrounded child outliving `git commit` is what held a Windows
47
+ // clone directory open and broke team-matrix-e2e's teardown with EBUSY.
28
48
  "fi",
29
49
  ENDMARK,
30
50
  ].join("\n");
@@ -32,21 +52,243 @@ function block(invocation, opts = {}) {
32
52
  function escapeRe(s) {
33
53
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
34
54
  }
35
- /** Shared idempotent create/append/update-in-place logic for every hunch git
36
- * hook: write a fresh hook file, replace our own managed block in place if the
37
- * invocation changed, or append after any pre-existing (non-hunch) hook body
38
- * without clobbering it. Used by all three hook installers below — the three
39
- * copies had already drifted (installPreCommitHook was missing the chmodSync
40
- * on its "updated" path) before this was unified. */
41
- function installManagedBlock(root, hookName, mark, end, blk) {
55
+ /** Portable invocation for text that lands in a TRACKED file (a husky script, a
56
+ * committed hooks dir, .pre-commit-config.yaml): the same exact-version npx
57
+ * package reference the committed MCP/provider configs use — never this
58
+ * machine's absolute node/CLI path. */
59
+ export const PORTABLE_HOOK_INVOCATION = `npx -y --package=${HUNCH_NPX_PACKAGE_SPEC} hunch`;
60
+ const EXIT_LINE = /^(?:exec|exit)(?:\s|;|$)/;
61
+ const INDENTED_EXIT_LINE = /^\s*(?:exec|exit)(?:\s|;|$)/;
62
+ const BLOCK_OPEN = /^(?:if|case|for|while|until|select)\b|\{\s*$/;
63
+ const BLOCK_CLOSE = /^(?:fi|esac|done)\b|^\}|[;\s](?:fi|esac|done|\})\s*;?\s*$/;
64
+ /** The line that ends the script unconditionally before anything appended after
65
+ * it could run, or null. Deliberately conservative (a heuristic, not a shell
66
+ * parser): an `exec`/`exit` statement outside any if/case/loop/function block,
67
+ * or a trailing top-level `if … else … fi` whose every branch ends in
68
+ * `exec`/`exit` — the shape of the pre-commit framework's generated hook. */
69
+ export function terminalExitLine(content) {
70
+ const lines = content.replace(/\r/g, "").split("\n");
71
+ let depth = 0;
72
+ for (const raw of lines) {
73
+ const line = raw.trim();
74
+ if (line === "" || line.startsWith("#"))
75
+ continue;
76
+ if (depth === 0 && EXIT_LINE.test(line))
77
+ return line;
78
+ depth = Math.max(0, depth + (BLOCK_OPEN.test(line) ? 1 : 0) - (BLOCK_CLOSE.test(line) ? 1 : 0));
79
+ }
80
+ const meaningful = lines.filter((l) => l.trim() !== "" && !/^\s*#/.test(l));
81
+ const last = meaningful.at(-1);
82
+ if (last === undefined || !/^fi\s*(?:;|$)/.test(last))
83
+ return null;
84
+ const start = meaningful.slice(0, -1).findLastIndex((l) => /^if\s/.test(l));
85
+ if (start < 0)
86
+ return null;
87
+ const branches = [];
88
+ let current = [];
89
+ let hasElse = false;
90
+ for (const line of meaningful.slice(start + 1, -1)) {
91
+ if (/^elif\s/.test(line) || /^else\s*(?:;|$)/.test(line)) {
92
+ if (/^else/.test(line))
93
+ hasElse = true;
94
+ branches.push(current);
95
+ current = [];
96
+ continue;
97
+ }
98
+ current.push(line);
99
+ }
100
+ branches.push(current);
101
+ if (!hasElse)
102
+ return null;
103
+ const allExit = branches.every((b) => {
104
+ const tail = b.filter((l) => !/^\s*then\s*$/.test(l)).at(-1);
105
+ return tail !== undefined && INDENTED_EXIT_LINE.test(tail);
106
+ });
107
+ return allExit ? last.trim() : null;
108
+ }
109
+ function readText(path) {
110
+ try {
111
+ return readFileSync(path, "utf8");
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ /** Whether `mark` is present in `file`, and if so whether git would reach it. */
118
+ function markerState(file, mark) {
119
+ const text = readText(file);
120
+ if (text === null)
121
+ return "absent";
122
+ const at = text.indexOf(mark);
123
+ if (at < 0)
124
+ return "absent";
125
+ return terminalExitLine(text.slice(0, at)) ? "unreachable" : "reachable";
126
+ }
127
+ function realish(p) {
128
+ try {
129
+ return realpathSync.native(p);
130
+ }
131
+ catch {
132
+ return resolve(p);
133
+ }
134
+ }
135
+ function isInside(parent, child) {
136
+ const norm = (p) => (process.platform === "win32" ? realish(p).toLowerCase() : realish(p));
137
+ const rel = relative(norm(parent), norm(child));
138
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
139
+ }
140
+ function gitRun(args, cwd) {
141
+ const p = spawnSync("git", args, { cwd, encoding: "utf8", env: initiatorChildEnv(), stdio: ["ignore", "pipe", "ignore"] });
142
+ return { status: p.status, stdout: (p.stdout ?? "").trim() };
143
+ }
144
+ function isPreCommitFrameworkHook(text) {
145
+ return /File generated by pre-commit/i.test(text) || /-m\s*pre_commit\b/.test(text) || /^\s*exec\s+pre-commit\b/m.test(text);
146
+ }
147
+ /** Work out who owns `hookName` in `root` and whether an appended block would run.
148
+ * `mark` is our block's marker: when an existing block sits BEFORE a trailing
149
+ * exec/exit it is reachable, so only the text above it is inspected. Read-only. */
150
+ function resolveHookTarget(root, hookName, mark) {
42
151
  const dir = hooksDir(root);
43
152
  // `git rev-parse --git-path hooks` returns a path relative to the repo in a
44
153
  // normal checkout, but an ABSOLUTE one inside a linked worktree (the shared
45
154
  // hooks dir). isAbsolute() handles both POSIX (/…) and Windows (C:\… / C:/…);
46
155
  // a bare startsWith("/") misfired on Windows worktrees → a doubled junk path.
47
156
  const abs = isAbsolute(dir) ? dir : join(root, dir);
48
- mkdirSync(abs, { recursive: true });
49
157
  const hookPath = join(abs, hookName);
158
+ const slashed = abs.replace(/\\/g, "/").replace(/\/+$/, "");
159
+ const huskyH = readText(join(abs, "h"));
160
+ if (slashed.endsWith(".husky/_") || (huskyH !== null && /husky/.test(huskyH))) {
161
+ return {
162
+ hookName, hookPath, manager: "husky", ownerFile: join(dirname(abs), hookName),
163
+ reason: "husky v9 runs every hook through .husky/_/h, which exits before any appended line (and regenerates .husky/_ on install)",
164
+ };
165
+ }
166
+ const common = gitCommonDir(root);
167
+ const insideGitDir = common !== "" && isInside(common, abs);
168
+ if (!insideGitDir) {
169
+ const top = gitRun(["rev-parse", "--show-toplevel"], root).stdout;
170
+ if (top && isInside(top, abs)) {
171
+ const rel = relative(realish(top), realish(abs)).replace(/\\/g, "/") || ".";
172
+ const tracked = gitRun(["ls-files", "--", rel], top).stdout !== "";
173
+ // Not yet committed but not ignored either: the next `git add -A` would commit it.
174
+ const committable = tracked || gitRun(["check-ignore", "-q", "--", `${rel}/${hookName}`], top).status === 1;
175
+ if (committable) {
176
+ const legacy = basename(abs) === ".husky";
177
+ return {
178
+ hookName, hookPath, ownerFile: hookPath,
179
+ manager: legacy ? "husky-legacy" : "tracked-hooks-path",
180
+ reason: `${rel}/ is ${tracked ? "tracked by git" : "inside the work tree and not ignored"}${legacy ? " (husky ≤8)" : ""} — Hunch will not write this machine's CLI path into a committed hook`,
181
+ };
182
+ }
183
+ }
184
+ }
185
+ const text = readText(hookPath);
186
+ if (text !== null && isPreCommitFrameworkHook(text)) {
187
+ const config = /--config=(\S+?)["')\s]/.exec(text)?.[1] ?? ".pre-commit-config.yaml";
188
+ const top = gitRun(["rev-parse", "--show-toplevel"], root).stdout || root;
189
+ return {
190
+ hookName, hookPath, manager: "pre-commit", ownerFile: isAbsolute(config) ? config : join(top, config),
191
+ reason: `the pre-commit framework generated ${hookName}; it ends in exec, so an appended block never runs (and \`pre-commit install\` rewrites the file)`,
192
+ };
193
+ }
194
+ if (text !== null) {
195
+ const at = text.indexOf(mark);
196
+ const terminal = terminalExitLine(at >= 0 ? text.slice(0, at) : text);
197
+ if (terminal) {
198
+ return {
199
+ hookName, hookPath, manager: "exec-exit", ownerFile: hookPath,
200
+ reason: `${hookName} ends in \`${terminal}\`, so a block after it never runs`,
201
+ };
202
+ }
203
+ }
204
+ return { hookName, hookPath, manager: "none", ownerFile: hookPath };
205
+ }
206
+ /** Stable pre-commit framework hook id per managed block (detection + snippet). */
207
+ function preCommitId(mark) {
208
+ return {
209
+ [MARK]: "hunch-post-commit",
210
+ [PRE_MARK]: "hunch-pre-commit",
211
+ [GROUNDING_MERGE_MARK]: "hunch-post-merge-grounding",
212
+ [REPAIR_MERGE_MARK]: "hunch-post-merge-repair-provenance",
213
+ [CHECKOUT_MARK]: "hunch-post-checkout",
214
+ }[mark] ?? "hunch";
215
+ }
216
+ /** Whether our block for `mark` is present where git will actually run it. */
217
+ function blockState(t, mark) {
218
+ const inHook = markerState(t.hookPath, mark);
219
+ switch (t.manager) {
220
+ case "none":
221
+ return inHook === "reachable" ? "installed" : inHook === "unreachable" ? "unreachable" : "missing";
222
+ case "husky":
223
+ if (markerState(t.ownerFile, mark) === "reachable")
224
+ return "installed";
225
+ if (markerState(t.ownerFile, mark) === "unreachable" || inHook !== "absent")
226
+ return "unreachable";
227
+ return "missing";
228
+ case "pre-commit": {
229
+ const config = readText(t.ownerFile);
230
+ if (config !== null && new RegExp(`\\bid:\\s*["']?${escapeRe(preCommitId(mark))}["']?\\s*$`, "m").test(config))
231
+ return "installed";
232
+ // Migration mode: `pre-commit install` moved the previous hook to <hook>.legacy and runs it first.
233
+ if (markerState(`${t.hookPath}.legacy`, mark) === "reachable")
234
+ return "installed";
235
+ return inHook !== "absent" ? "unreachable" : "missing";
236
+ }
237
+ default: // husky-legacy, tracked-hooks-path, exec-exit: the hook file itself
238
+ return inHook === "reachable" ? "installed" : inHook === "unreachable" ? "unreachable" : "missing";
239
+ }
240
+ }
241
+ function stripMarkers(blk) {
242
+ return blk.split("\n").filter((l) => !/^# (?:>>>|<<<) hunch /.test(l)).join("\n");
243
+ }
244
+ /** The hand-applied equivalent of our block, in the manager's own format. */
245
+ function snippetFor(t, mark, build, localInvocation) {
246
+ if (t.manager === "pre-commit") {
247
+ const script = stripMarkers(build(PORTABLE_HOOK_INVOCATION, PRE_COMMIT_CONTEXT));
248
+ return [
249
+ `# under \`repos:\` in ${basename(t.ownerFile)} (and run \`pre-commit install --hook-type ${t.hookName}\`)`,
250
+ "- repo: local",
251
+ " hooks:",
252
+ ` - id: ${preCommitId(mark)}`,
253
+ ` name: hunch ${t.hookName}`,
254
+ " entry: sh",
255
+ ` args: ["-c", ${JSON.stringify(script)}]`,
256
+ " language: system",
257
+ ` stages: [${t.hookName}]`,
258
+ " always_run: true",
259
+ " pass_filenames: false",
260
+ ].join("\n");
261
+ }
262
+ if (t.manager === "exec-exit") {
263
+ // Untracked local hook: this machine's invocation is fine; placement is the fix.
264
+ return `# insert ABOVE the final exec/exit in ${t.hookName}\n${build(localInvocation, GIT_CONTEXT)}`;
265
+ }
266
+ return build(PORTABLE_HOOK_INVOCATION, GIT_CONTEXT);
267
+ }
268
+ /** Shared idempotent create/append/update-in-place logic for every hunch git
269
+ * hook: write a fresh hook file, replace our own managed block in place if the
270
+ * invocation changed, or append after any pre-existing (non-hunch) hook body
271
+ * without clobbering it. Used by all hook installers below — the copies had
272
+ * already drifted (installPreCommitHook was missing the chmodSync on its
273
+ * "updated" path) before this was unified. When a hook manager owns the file,
274
+ * or the block would sit after an exec/exit, nothing is written and the result
275
+ * carries the snippet to add by hand (issue #311). */
276
+ function installManagedBlock(root, hookName, mark, end, build, invocation) {
277
+ const t = resolveHookTarget(root, hookName, mark);
278
+ if (t.manager !== "none") {
279
+ if (blockState(t, mark) === "installed")
280
+ return { path: t.ownerFile, action: "unchanged", manager: t.manager };
281
+ return {
282
+ path: t.ownerFile,
283
+ action: t.manager === "exec-exit" ? "unreachable" : "managed-elsewhere",
284
+ manager: t.manager,
285
+ reason: t.reason,
286
+ snippet: snippetFor(t, mark, build, invocation),
287
+ };
288
+ }
289
+ const blk = build(invocation, GIT_CONTEXT);
290
+ const hookPath = t.hookPath;
291
+ mkdirSync(dirname(hookPath), { recursive: true });
50
292
  if (!existsSync(hookPath)) {
51
293
  writeFileSync(hookPath, `#!/bin/sh\n${blk}\n`);
52
294
  chmodSync(hookPath, 0o755);
@@ -67,7 +309,7 @@ function installManagedBlock(root, hookName, mark, end, blk) {
67
309
  return { path: hookPath, action: "appended" };
68
310
  }
69
311
  export function installPostCommitHook(root, invocation, opts = {}) {
70
- return installManagedBlock(root, "post-commit", MARK, ENDMARK, block(invocation, opts));
312
+ return installManagedBlock(root, "post-commit", MARK, ENDMARK, (inv) => block(inv, opts), invocation);
71
313
  }
72
314
  const PRE_MARK = "# >>> hunch pre-commit (constraint guard) >>>";
73
315
  const PRE_END = "# <<< hunch pre-commit <<<";
@@ -77,9 +319,11 @@ const PRE_END = "# <<< hunch pre-commit <<<";
77
319
  * blocking invariant (see strictgate.ts), so it's safe on a shared repo.
78
320
  * Preserves any existing pre-commit hook. */
79
321
  export function installPreCommitHook(root, invocation, strict = false) {
80
- const cmd = `${invocation} check --staged${strict ? " --strict" : ""}`;
81
- const blk = [PRE_MARK, strict ? cmd : `${cmd} || true`, PRE_END].join("\n");
82
- return installManagedBlock(root, "pre-commit", PRE_MARK, PRE_END, blk);
322
+ const build = (inv) => {
323
+ const cmd = `${inv} check --staged${strict ? " --strict" : ""}`;
324
+ return [PRE_MARK, strict ? cmd : `${cmd} || true`, PRE_END].join("\n");
325
+ };
326
+ return installManagedBlock(root, "pre-commit", PRE_MARK, PRE_END, build, invocation);
83
327
  }
84
328
  // Original marker, kept byte-for-byte for backward compat: an existing install's
85
329
  // grounding-refresh block must still be found and updated in place by its own
@@ -120,8 +364,10 @@ function repairProvenanceMergeBlock(invocation) {
120
364
  /** How significant a combined install result is, for picking one HookInstall
121
365
  * action out of two independent sub-installs into the same file — "created"
122
366
  * (the file itself is new) outranks "appended"/"updated" (an existing file
123
- * changed), which outrank "unchanged". */
124
- const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1 };
367
+ * changed), which outrank "unchanged". Non-writing results are handled before
368
+ * ranking (they must never be masked by a sibling's success). */
369
+ const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1, "managed-elsewhere": 0, unreachable: 0 };
370
+ const writes = (h) => h.action !== "managed-elsewhere" && h.action !== "unreachable";
125
371
  /** Install a post-merge hook carrying TWO independently-managed blocks:
126
372
  * re-sync the committed grounding docs when a merge brought memory in behind
127
373
  * them (fnd_c402046ac7, HUNCH_SYNC-guarded, foreground — it rewrites five
@@ -137,31 +383,97 @@ const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1 };
137
383
  * a repo carrying only one half (an older install, or a hand-edited hook)
138
384
  * gets the other appended rather than clobbered. */
139
385
  export function installPostMergeHook(root, invocation) {
140
- const grounding = installManagedBlock(root, "post-merge", GROUNDING_MERGE_MARK, GROUNDING_MERGE_END, groundingMergeBlock(invocation));
141
- const repair = installManagedBlock(root, "post-merge", REPAIR_MERGE_MARK, REPAIR_MERGE_END, repairProvenanceMergeBlock(invocation));
386
+ const grounding = installManagedBlock(root, "post-merge", GROUNDING_MERGE_MARK, GROUNDING_MERGE_END, groundingMergeBlock, invocation);
387
+ const repair = installManagedBlock(root, "post-merge", REPAIR_MERGE_MARK, REPAIR_MERGE_END, repairProvenanceMergeBlock, invocation);
388
+ if (!writes(grounding) && !writes(repair)) {
389
+ return { ...grounding, snippet: `${grounding.snippet}\n${stripComment(repair.snippet ?? "", grounding.manager)}` };
390
+ }
391
+ if (!writes(grounding))
392
+ return grounding;
393
+ if (!writes(repair))
394
+ return repair;
142
395
  return ACTION_RANK[repair.action] >= ACTION_RANK[grounding.action] ? repair : grounding;
143
396
  }
144
- /** Read-only diagnostic (used by `hunch doctor`): which of the three managed
145
- * hooks are currently present. Never writes anything — a hook counts as
146
- * installed if its managed marker is present, regardless of whether the
147
- * invocation inside it happens to be stale. postMerge requires BOTH halves
148
- * (grounding-refresh and repair-provenance) present — a repo carrying only
149
- * one is a partial install, same as `installPostMergeHook` self-healing it. */
150
- export function hookStatus(root) {
151
- const dir = hooksDir(root);
152
- const abs = isAbsolute(dir) ? dir : join(root, dir);
153
- const has = (name, mark) => {
154
- try {
155
- return readFileSync(join(abs, name), "utf8").includes(mark);
156
- }
157
- catch {
158
- return false;
159
- }
397
+ /** When two snippets for the same file are joined, drop the second's leading
398
+ * instruction comment (the first already says where it goes). */
399
+ function stripComment(snippet, manager) {
400
+ if (manager !== "pre-commit" && manager !== "exec-exit")
401
+ return snippet;
402
+ return snippet.split("\n").filter((l, i) => !(i === 0 && l.startsWith("# "))).join("\n");
403
+ }
404
+ const CHECKOUT_MARK = "# >>> hunch post-checkout (workspace ledger) >>>";
405
+ const CHECKOUT_END = "# <<< hunch post-checkout (workspace ledger) <<<";
406
+ /** post-checkout is where branches and worktrees actually change (`git checkout`,
407
+ * `git switch`, `git worktree add`). git passes `$3 = 1` for a branch checkout and `0`
408
+ * for a file checkout; only the former can change the ledger. Constant argv (nothing
409
+ * from repository content), HUNCH_SYNC-guarded, backgrounded, offline. */
410
+ function checkoutBlock(invocation, ctx = GIT_CONTEXT) {
411
+ return [
412
+ CHECKOUT_MARK,
413
+ `if [ -z "$HUNCH_SYNC" ] && [ "${ctx.checkoutType}" = "1" ]; then`,
414
+ ` ( HUNCH_SYNC=1 ${invocation} workspaces snapshot --quiet >/dev/null 2>&1 || true ) &`,
415
+ "fi",
416
+ CHECKOUT_END,
417
+ ].join("\n");
418
+ }
419
+ export function installPostCheckoutHook(root, invocation) {
420
+ return installManagedBlock(root, "post-checkout", CHECKOUT_MARK, CHECKOUT_END, checkoutBlock, invocation);
421
+ }
422
+ function reportEntry(root, hookName, marks) {
423
+ const states = marks.map((mark) => {
424
+ const t = resolveHookTarget(root, hookName, mark);
425
+ return { t, state: blockState(t, mark) };
426
+ });
427
+ const unreachable = states.find((s) => s.state === "unreachable");
428
+ const pick = unreachable ?? states.find((s) => s.state === "missing") ?? states[0];
429
+ if (!pick)
430
+ throw new Error("reportEntry needs at least one marker");
431
+ const state = unreachable ? "unreachable" : states.every((s) => s.state === "installed") ? "installed" : "missing";
432
+ return {
433
+ state,
434
+ manager: pick.t.manager,
435
+ path: state === "unreachable" && pick.t.manager !== "husky" ? pick.t.hookPath : pick.t.ownerFile,
436
+ ...(state === "unreachable" ? { reason: pick.t.reason } : {}),
160
437
  };
438
+ }
439
+ /** Read-only diagnostic (used by `hunch doctor`): each managed hook's state —
440
+ * `installed` (present where git will run it), `unreachable` (present, but
441
+ * after an exec/exit or in a manager-owned file git never reaches), or
442
+ * `missing`. postMerge requires BOTH halves (grounding-refresh and
443
+ * repair-provenance) — a repo carrying only one is a partial install, same as
444
+ * `installPostMergeHook` self-healing it. Never writes anything. */
445
+ export function hookReport(root) {
161
446
  return {
162
- postCommit: has("post-commit", MARK),
163
- preCommit: has("pre-commit", PRE_MARK),
164
- postMerge: has("post-merge", GROUNDING_MERGE_MARK) && has("post-merge", REPAIR_MERGE_MARK),
447
+ postCommit: reportEntry(root, "post-commit", [MARK]),
448
+ preCommit: reportEntry(root, "pre-commit", [PRE_MARK]),
449
+ postMerge: reportEntry(root, "post-merge", [GROUNDING_MERGE_MARK, REPAIR_MERGE_MARK]),
450
+ postCheckout: reportEntry(root, "post-checkout", [CHECKOUT_MARK]),
165
451
  };
166
452
  }
453
+ /** Boolean view of `hookReport`: a hook counts as installed only when its
454
+ * managed block is present where git will actually run it, regardless of
455
+ * whether the invocation inside it happens to be stale. An unreachable block
456
+ * (issue #311) is NOT installed. */
457
+ export function hookStatus(root) {
458
+ const r = hookReport(root);
459
+ return {
460
+ postCommit: r.postCommit.state === "installed",
461
+ preCommit: r.preCommit.state === "installed",
462
+ postMerge: r.postMerge.state === "installed",
463
+ postCheckout: r.postCheckout.state === "installed",
464
+ };
465
+ }
466
+ /** CLI lines for one install result: the usual ✓ line when the block is in a
467
+ * file git runs, otherwise a warning with the reason and the snippet to add to
468
+ * the manager's own file. */
469
+ export function formatHookInstall(root, label, h, detail = "") {
470
+ if (writes(h))
471
+ return [` ✓ ${label} ${h.action}${detail}`];
472
+ const shown = isInside(root, h.path) ? relative(realish(root), realish(h.path)).replace(/\\/g, "/") : h.path;
473
+ return [
474
+ ` ⚠ ${label} NOT installed — ${h.reason ?? "a hook manager owns this hook"}`,
475
+ ` add this to ${shown} yourself:`,
476
+ ...(h.snippet ?? "").split("\n").map((l) => ` ${l}`),
477
+ ];
478
+ }
167
479
  //# 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);