@davesheffer/hunch 1.39.0 → 1.39.2

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 +193 -65
  2. package/dist/cli/integrations.js +10 -0
  3. package/dist/client/state.d.ts +2 -1
  4. package/dist/client/state.js +1 -0
  5. package/dist/core/agenthook.d.ts +14 -0
  6. package/dist/core/agenthook.js +55 -8
  7. package/dist/core/capturetoken.d.ts +30 -3
  8. package/dist/core/capturetoken.js +29 -3
  9. package/dist/core/changeProof.js +5 -1
  10. package/dist/core/checkreport.d.ts +7 -0
  11. package/dist/core/checkreport.js +20 -3
  12. package/dist/core/compare.js +3 -2
  13. package/dist/core/correction.d.ts +10 -4
  14. package/dist/core/correction.js +7 -4
  15. package/dist/core/countersign.d.ts +28 -0
  16. package/dist/core/countersign.js +50 -0
  17. package/dist/core/reviewqueue.js +6 -1
  18. package/dist/core/spawnCommand.js +41 -9
  19. package/dist/core/stateHttp.d.ts +1 -1
  20. package/dist/core/stateHttp.js +3 -1
  21. package/dist/core/taskReportEvidence.js +31 -11
  22. package/dist/core/topics.js +1 -1
  23. package/dist/core/types.d.ts +1 -0
  24. package/dist/core/workspace.d.ts +23 -1
  25. package/dist/core/workspace.js +31 -7
  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/workspaces.d.ts +10 -0
  31. package/dist/extractors/workspaces.js +92 -15
  32. package/dist/integrations/gitignore.d.ts +27 -2
  33. package/dist/integrations/gitignore.js +103 -17
  34. package/dist/integrations/hooks.d.ts +61 -7
  35. package/dist/integrations/hooks.js +330 -43
  36. package/dist/integrations/scaffold.js +1 -1
  37. package/dist/integrations/workspaceLedger.d.ts +23 -3
  38. package/dist/integrations/workspaceLedger.js +114 -8
  39. package/dist/mcp/server.js +115 -56
  40. package/dist/serve/app.d.ts +4 -0
  41. package/dist/serve/app.js +56 -36
  42. package/dist/store/hunchStore.d.ts +4 -2
  43. package/dist/store/hunchStore.js +23 -6
  44. package/dist/store/stateBinding.js +111 -42
  45. package/dist/wiki/wiki.d.ts +7 -0
  46. package/dist/wiki/wiki.js +19 -8
  47. package/package.json +1 -1
  48. package/server.json +2 -2
@@ -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
@@ -38,21 +52,243 @@ function block(invocation, opts = {}) {
38
52
  function escapeRe(s) {
39
53
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
40
54
  }
41
- /** Shared idempotent create/append/update-in-place logic for every hunch git
42
- * hook: write a fresh hook file, replace our own managed block in place if the
43
- * invocation changed, or append after any pre-existing (non-hunch) hook body
44
- * without clobbering it. Used by all three hook installers below — the three
45
- * copies had already drifted (installPreCommitHook was missing the chmodSync
46
- * on its "updated" path) before this was unified. */
47
- 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) {
48
151
  const dir = hooksDir(root);
49
152
  // `git rev-parse --git-path hooks` returns a path relative to the repo in a
50
153
  // normal checkout, but an ABSOLUTE one inside a linked worktree (the shared
51
154
  // hooks dir). isAbsolute() handles both POSIX (/…) and Windows (C:\… / C:/…);
52
155
  // a bare startsWith("/") misfired on Windows worktrees → a doubled junk path.
53
156
  const abs = isAbsolute(dir) ? dir : join(root, dir);
54
- mkdirSync(abs, { recursive: true });
55
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 });
56
292
  if (!existsSync(hookPath)) {
57
293
  writeFileSync(hookPath, `#!/bin/sh\n${blk}\n`);
58
294
  chmodSync(hookPath, 0o755);
@@ -73,7 +309,7 @@ function installManagedBlock(root, hookName, mark, end, blk) {
73
309
  return { path: hookPath, action: "appended" };
74
310
  }
75
311
  export function installPostCommitHook(root, invocation, opts = {}) {
76
- return installManagedBlock(root, "post-commit", MARK, ENDMARK, block(invocation, opts));
312
+ return installManagedBlock(root, "post-commit", MARK, ENDMARK, (inv) => block(inv, opts), invocation);
77
313
  }
78
314
  const PRE_MARK = "# >>> hunch pre-commit (constraint guard) >>>";
79
315
  const PRE_END = "# <<< hunch pre-commit <<<";
@@ -83,9 +319,11 @@ const PRE_END = "# <<< hunch pre-commit <<<";
83
319
  * blocking invariant (see strictgate.ts), so it's safe on a shared repo.
84
320
  * Preserves any existing pre-commit hook. */
85
321
  export function installPreCommitHook(root, invocation, strict = false) {
86
- const cmd = `${invocation} check --staged${strict ? " --strict" : ""}`;
87
- const blk = [PRE_MARK, strict ? cmd : `${cmd} || true`, PRE_END].join("\n");
88
- 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);
89
327
  }
90
328
  // Original marker, kept byte-for-byte for backward compat: an existing install's
91
329
  // grounding-refresh block must still be found and updated in place by its own
@@ -126,8 +364,10 @@ function repairProvenanceMergeBlock(invocation) {
126
364
  /** How significant a combined install result is, for picking one HookInstall
127
365
  * action out of two independent sub-installs into the same file — "created"
128
366
  * (the file itself is new) outranks "appended"/"updated" (an existing file
129
- * changed), which outrank "unchanged". */
130
- 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";
131
371
  /** Install a post-merge hook carrying TWO independently-managed blocks:
132
372
  * re-sync the committed grounding docs when a merge brought memory in behind
133
373
  * them (fnd_c402046ac7, HUNCH_SYNC-guarded, foreground — it rewrites five
@@ -143,50 +383,97 @@ const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1 };
143
383
  * a repo carrying only one half (an older install, or a hand-edited hook)
144
384
  * gets the other appended rather than clobbered. */
145
385
  export function installPostMergeHook(root, invocation) {
146
- const grounding = installManagedBlock(root, "post-merge", GROUNDING_MERGE_MARK, GROUNDING_MERGE_END, groundingMergeBlock(invocation));
147
- 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;
148
395
  return ACTION_RANK[repair.action] >= ACTION_RANK[grounding.action] ? repair : grounding;
149
396
  }
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
+ }
150
404
  const CHECKOUT_MARK = "# >>> hunch post-checkout (workspace ledger) >>>";
151
405
  const CHECKOUT_END = "# <<< hunch post-checkout (workspace ledger) <<<";
152
406
  /** post-checkout is where branches and worktrees actually change (`git checkout`,
153
407
  * `git switch`, `git worktree add`). git passes `$3 = 1` for a branch checkout and `0`
154
408
  * for a file checkout; only the former can change the ledger. Constant argv (nothing
155
409
  * from repository content), HUNCH_SYNC-guarded, backgrounded, offline. */
156
- function checkoutBlock(invocation) {
410
+ function checkoutBlock(invocation, ctx = GIT_CONTEXT) {
157
411
  return [
158
412
  CHECKOUT_MARK,
159
- 'if [ -z "$HUNCH_SYNC" ] && [ "$3" = "1" ]; then',
413
+ `if [ -z "$HUNCH_SYNC" ] && [ "${ctx.checkoutType}" = "1" ]; then`,
160
414
  ` ( HUNCH_SYNC=1 ${invocation} workspaces snapshot --quiet >/dev/null 2>&1 || true ) &`,
161
415
  "fi",
162
416
  CHECKOUT_END,
163
417
  ].join("\n");
164
418
  }
165
419
  export function installPostCheckoutHook(root, invocation) {
166
- return installManagedBlock(root, "post-checkout", CHECKOUT_MARK, CHECKOUT_END, checkoutBlock(invocation));
167
- }
168
- /** Read-only diagnostic (used by `hunch doctor`): which of the three managed
169
- * hooks are currently present. Never writes anything — a hook counts as
170
- * installed if its managed marker is present, regardless of whether the
171
- * invocation inside it happens to be stale. postMerge requires BOTH halves
172
- * (grounding-refresh and repair-provenance) present — a repo carrying only
173
- * one is a partial install, same as `installPostMergeHook` self-healing it. */
174
- export function hookStatus(root) {
175
- const dir = hooksDir(root);
176
- const abs = isAbsolute(dir) ? dir : join(root, dir);
177
- const has = (name, mark) => {
178
- try {
179
- return readFileSync(join(abs, name), "utf8").includes(mark);
180
- }
181
- catch {
182
- return false;
183
- }
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 } : {}),
184
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) {
185
446
  return {
186
- postCommit: has("post-commit", MARK),
187
- preCommit: has("pre-commit", PRE_MARK),
188
- postMerge: has("post-merge", GROUNDING_MERGE_MARK) && has("post-merge", REPAIR_MERGE_MARK),
189
- postCheckout: has("post-checkout", CHECKOUT_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]),
190
451
  };
191
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
+ }
192
479
  //# sourceMappingURL=hooks.js.map
@@ -80,7 +80,7 @@ Capture the decision for **$ARGUMENTS** into Hunch's graph.
80
80
  2. Run the GRILLING LOOP: one focused question at a time. Push back on hand-wavy answers. Resolve every branch before committing — an unexamined decision poisons the graph.
81
81
  3. Confirm the TOPIC anchor with me before committing. One topic per decision; if it spans two, split into two captures.
82
82
  4. Capture REJECTED alternatives explicitly (what, and why not) — this is what makes the decision enforceable (Veto/drift check against it).
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.
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. The token is not my signature: confirm the record in the client prompt if one appears; otherwise it stays agent testimony until I run the \`hunch review --confirm <id>\` command the response prints.
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
86
  const WORKTREES_CMD = `---
@@ -58,16 +58,36 @@ export declare function renderBranchTable(view: LedgerView, rows: BranchRow[]):
58
58
  * view stays fast); null when memory holds no workspace record. */
59
59
  export declare function workspaceSummaryLine(records: readonly Workspace[], config: WorkspacesConfig, now?: Date): string | null;
60
60
  export { branchRows, worktreeRows };
61
- export declare function prunePlanFor(view: LedgerView): PrunePlan;
61
+ /** This machine's plan: the pure plan from the live record, then two live checks per step.
62
+ * A step `git branch -d` would refuse is moved to `skipped` BEFORE anything runs (removing
63
+ * the worktree and then failing the branch delete would half-apply the step), and a
64
+ * worktree's ignored files — which `git worktree remove` deletes without asking — are
65
+ * attached so the plan and the confirmation name them. */
66
+ export declare function prunePlanFor(view: LedgerView, root: string): PrunePlan;
67
+ /** Why `git branch -d -- <branch>` would refuse right now, or null when it would delete.
68
+ * Mirrors git's own rule (builtin/branch.c `branch_merged`): the branch head must be an
69
+ * ancestor of its upstream when one is configured AND resolves, otherwise of HEAD of the
70
+ * worktree the command runs in (the main worktree). Squash and rebase merges never pass it
71
+ * once the upstream is gone or unset, so such a step is reported, not half-applied. The
72
+ * branch name is matched in JS; only refs git printed and SHAs reach git as arguments. */
73
+ export declare function branchDeleteRefusal(root: string, step: Pick<PruneStep, "branch" | "head" | "method">): string | null;
62
74
  export interface PruneResult {
63
75
  step: PruneStep;
64
- outcome: "deleted" | "failed";
76
+ /** `skipped`: a live precondition failed before anything ran — the step is untouched. */
77
+ outcome: "deleted" | "skipped" | "failed";
65
78
  detail: string;
66
79
  }
67
80
  /** Execute the local steps of a plan. Each command is a fixed argv; the branch name was
68
81
  * 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. */
82
+ * `git worktree list` on this machine. `git branch -d`'s own precondition is re-checked
83
+ * BEFORE the worktree is removed, so a step either runs whole or not at all. A failure
84
+ * stops that step, never the others. */
70
85
  export declare function applyPrune(root: string, steps: readonly PruneStep[]): PruneResult[];
86
+ export declare function describeIgnored(ignored: NonNullable<PruneStep["ignored"]>): string;
87
+ /** The confirmation question for `prune --apply`: counts, and — because `git worktree
88
+ * remove` deletes ignored files (.env, build output) without asking — every worktree's
89
+ * ignored paths by name. */
90
+ export declare function pruneConfirmQuestion(view: LedgerView, plan: PrunePlan): string;
71
91
  /** Interactive yes/no; false when stdin is not a terminal (the caller then needs --yes). */
72
92
  export declare function confirmPrune(question: string): Promise<boolean>;
73
93
  export declare function renderPrunePlan(view: LedgerView, plan: PrunePlan): string;