@davesheffer/hunch 1.39.2 → 1.40.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 (69) hide show
  1. package/dist/cli/index.js +154 -64
  2. package/dist/cli/serve.d.ts +14 -0
  3. package/dist/cli/serve.js +18 -4
  4. package/dist/constitution/g2BehaviorCandidates.js +6 -1
  5. package/dist/constitution/g2Candidates.js +11 -1
  6. package/dist/constitution/structural.js +10 -0
  7. package/dist/core/correction.js +12 -7
  8. package/dist/core/delivery.d.ts +34 -0
  9. package/dist/core/delivery.js +60 -1
  10. package/dist/core/docanchors.js +195 -11
  11. package/dist/core/format.js +6 -1
  12. package/dist/core/glob.d.ts +25 -0
  13. package/dist/core/glob.js +43 -0
  14. package/dist/core/hookcache.d.ts +9 -2
  15. package/dist/core/hookcache.js +10 -3
  16. package/dist/core/paths.d.ts +48 -5
  17. package/dist/core/paths.js +89 -6
  18. package/dist/core/procstart.d.ts +94 -0
  19. package/dist/core/procstart.js +244 -0
  20. package/dist/core/served.d.ts +6 -3
  21. package/dist/core/served.js +10 -6
  22. package/dist/core/taskDelivery.js +27 -1
  23. package/dist/core/taskReport.d.ts +2 -0
  24. package/dist/core/taskReport.js +46 -11
  25. package/dist/core/taskReportEvidence.js +17 -1
  26. package/dist/core/taskReportHook.d.ts +19 -0
  27. package/dist/core/taskReportHook.js +54 -6
  28. package/dist/core/verifyLauncher.d.ts +21 -0
  29. package/dist/core/verifyLauncher.js +37 -0
  30. package/dist/core/workspace.d.ts +9 -0
  31. package/dist/core/workspace.js +15 -0
  32. package/dist/extractors/git.d.ts +5 -0
  33. package/dist/extractors/git.js +87 -10
  34. package/dist/extractors/indexer.d.ts +10 -0
  35. package/dist/extractors/indexer.js +56 -10
  36. package/dist/extractors/k8sManifest.d.ts +13 -0
  37. package/dist/extractors/k8sManifest.js +207 -23
  38. package/dist/extractors/landscapeDiscovery.js +9 -1
  39. package/dist/extractors/nativeTreeSitter.d.ts +24 -0
  40. package/dist/extractors/nativeTreeSitter.js +54 -1
  41. package/dist/extractors/parse.js +6 -1
  42. package/dist/integrations/claudemd.js +3 -3
  43. package/dist/integrations/hookmatch.d.ts +25 -0
  44. package/dist/integrations/hookmatch.js +58 -0
  45. package/dist/integrations/hooks.d.ts +79 -9
  46. package/dist/integrations/hooks.js +559 -46
  47. package/dist/integrations/providers.js +2 -17
  48. package/dist/integrations/scaffold.d.ts +1 -1
  49. package/dist/integrations/scaffold.js +24 -21
  50. package/dist/integrations/workspaceLedger.d.ts +10 -0
  51. package/dist/integrations/workspaceLedger.js +9 -1
  52. package/dist/mcp/server.d.ts +10 -0
  53. package/dist/mcp/server.js +32 -16
  54. package/dist/mcp/taskReportTools.d.ts +5 -9
  55. package/dist/mcp/taskReportTools.js +9 -20
  56. package/dist/serve/writelock.d.ts +21 -1
  57. package/dist/serve/writelock.js +157 -16
  58. package/dist/store/changeLedger.d.ts +9 -3
  59. package/dist/store/changeLedger.js +36 -10
  60. package/dist/store/hunchStore.d.ts +42 -2
  61. package/dist/store/hunchStore.js +105 -17
  62. package/dist/store/jsonStore.d.ts +6 -4
  63. package/dist/store/jsonStore.js +225 -32
  64. package/dist/store/merge.d.ts +7 -4
  65. package/dist/store/merge.js +65 -5
  66. package/dist/store/privateMigrate.js +10 -0
  67. package/dist/wiki/adopt.js +13 -2
  68. package/package.json +1 -1
  69. package/server.json +2 -2
@@ -16,20 +16,26 @@
16
16
  import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, realpathSync } from "node:fs";
17
17
  import { spawnSync } from "node:child_process";
18
18
  import { join, isAbsolute, dirname, basename, relative, resolve } from "node:path";
19
- import { hooksDir, gitCommonDir } from "../extractors/git.js";
19
+ import { homedir } from "node:os";
20
+ import { hooksDir, gitCommonDir, isLinkedWorktree } from "../extractors/git.js";
20
21
  import { initiatorChildEnv } from "../synthesis/initiator.js";
21
22
  import { HUNCH_NPX_PACKAGE_SPEC } from "../core/version.js";
22
23
  const MARK = "# >>> hunch post-commit >>>";
23
24
  const ENDMARK = "# <<< hunch post-commit <<<";
24
25
  const GIT_CONTEXT = { checkoutType: "$3" };
25
26
  const PRE_COMMIT_CONTEXT = { checkoutType: "$PRE_COMMIT_CHECKOUT_TYPE" };
26
- function block(invocation, opts = {}) {
27
+ const LOCAL_ONLY_LINE = " export HUNCH_SYNTH_PROVIDER=deterministic";
28
+ // Tolerates hand-added quotes: missing the line would let a re-run delete it.
29
+ const LOCAL_ONLY_RE = /^\s*export HUNCH_SYNTH_PROVIDER=["']?deterministic["']?\s*$/m;
30
+ /** How the provider line is named among a result's kept/added options. */
31
+ const LOCAL_ONLY_LABEL = "HUNCH_SYNTH_PROVIDER=deterministic";
32
+ function block(invocation, opts = {}, keep) {
27
33
  // --private routes the auto-synthesized decision into the HUNCH_PRIVATE_DIR overlay
28
34
  // instead of the public repo. --commit (opt-in) also commits & pushes the repo the
29
35
  // decision landed in (the private store under --private, else this repo). The hook
30
36
  // script is local (.git/hooks/), never committed.
31
- const priv = opts.private ? " --private" : "";
32
- const commit = opts.commit ? " --commit" : "";
37
+ const priv = opts.private || keep?.flags.includes("--private") ? " --private" : "";
38
+ const commit = opts.commit || keep?.flags.includes("--commit") ? " --commit" : "";
33
39
  return [
34
40
  MARK,
35
41
  'if [ -z "$HUNCH_SYNC" ]; then',
@@ -37,7 +43,7 @@ function block(invocation, opts = {}) {
37
43
  // A split-private capture must not make a storage-private promise and then
38
44
  // ship the commit diff to a subscription CLI. Shared overlays are a separate
39
45
  // team policy, so only the explicit local-only mode forces deterministic.
40
- ...(opts.localOnly ? [" export HUNCH_SYNTH_PROVIDER=deterministic"] : []),
46
+ ...(opts.localOnly || keep?.localOnly ? [LOCAL_ONLY_LINE] : []),
41
47
  ` ( ${invocation} sync --from-hook --quiet${priv}${commit} >/dev/null 2>&1 || true ) &`,
42
48
  // Deliberately NO workspace-ledger snapshot here (docs/workspace-ledger.md): a commit
43
49
  // changes HEAD, not which branches and worktrees exist — post-checkout covers that, and
@@ -213,29 +219,257 @@ function preCommitId(mark) {
213
219
  [CHECKOUT_MARK]: "hunch-post-checkout",
214
220
  }[mark] ?? "hunch";
215
221
  }
216
- /** Whether our block for `mark` is present where git will actually run it. */
217
- function blockState(t, mark) {
222
+ const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=\S*$/;
223
+ const UNQUOTED_META = /[;|&$`<>()]/;
224
+ const LAUNCHER_EXT = /\.(?:exe|cmd|bat|ps1)$/;
225
+ const HUNCH_ENTRY = /\/cli\/index\.(?:js|ts)$/;
226
+ /** Split an invocation into simple shell words. Hunch writes its own paths with
227
+ * JSON.stringify, so a double-quoted token decodes with JSON.parse. Anything a
228
+ * shell would treat as more than a plain command (metacharacters, expansions,
229
+ * an unterminated quote) yields null — we refuse to guess. */
230
+ function shellWords(s) {
231
+ const out = [];
232
+ let i = 0;
233
+ while (i < s.length) {
234
+ const c = s[i];
235
+ if (/\s/.test(c)) {
236
+ i++;
237
+ continue;
238
+ }
239
+ if (c === '"' || c === "'") {
240
+ const end = s.indexOf(c, i + 1);
241
+ if (end < 0)
242
+ return null;
243
+ const raw = s.slice(i, end + 1);
244
+ if (c === '"') {
245
+ if (/[$`]/.test(raw))
246
+ return null;
247
+ try {
248
+ out.push(JSON.parse(raw));
249
+ }
250
+ catch {
251
+ return null;
252
+ }
253
+ }
254
+ else
255
+ out.push(raw.slice(1, -1));
256
+ i = end + 1;
257
+ continue;
258
+ }
259
+ let j = i;
260
+ while (j < s.length && !/\s/.test(s[j]))
261
+ j++;
262
+ const word = s.slice(i, j);
263
+ if (UNQUOTED_META.test(word))
264
+ return null;
265
+ out.push(word);
266
+ i = j;
267
+ }
268
+ return out;
269
+ }
270
+ /** The launcher's own name: last path segment, lower-cased, without a Windows
271
+ * executable extension — so `/usr/local/bin/hunch`, `hunch.cmd` and `hunch` all match. */
272
+ function base(tok) {
273
+ return (tok.split(/[\\/]/).at(-1) ?? "").toLowerCase().replace(LAUNCHER_EXT, "");
274
+ }
275
+ function isAbs(tok) {
276
+ return isAbsolute(tok) || /^[A-Za-z]:[\\/]/.test(tok);
277
+ }
278
+ function isPathish(tok) {
279
+ return tok.includes("/") || tok.includes("\\") || isAbs(tok);
280
+ }
281
+ function pathExists(tok, root) {
282
+ // A hand-written hook may spell this machine's install as `~/…`; the shell
283
+ // expands it, so we must too before asking the filesystem.
284
+ const path = tok === "~" ? homedir() : tok.startsWith("~/") ? join(homedir(), tok.slice(2)) : tok;
285
+ return existsSync(isAbs(path) ? path : resolve(root, path));
286
+ }
287
+ const NOT_OURS = "not a command Hunch writes";
288
+ /** Whether the command in front of a managed block's subcommand is a Hunch
289
+ * launcher that could actually run (issue #315): a block whose command was
290
+ * hand-edited into something else, or whose absolute node/CLI path no longer
291
+ * exists after a reinstall, is present but dead. `command` is the text BEFORE
292
+ * the subcommand; env assignments and a leading `(` belong to the block's own
293
+ * shape and are stripped. Never spawns anything, never touches the network. */
294
+ export function hookInvocationHealth(command, root) {
295
+ let rest = command.trim().replace(/^\(\s*/, "");
296
+ for (;;) {
297
+ const m = /^(\S+)\s+/.exec(rest);
298
+ if (!m || !ENV_ASSIGN.test(m[1]))
299
+ break;
300
+ rest = rest.slice(m[0].length);
301
+ }
302
+ const invocation = rest.trim();
303
+ const bad = (reason) => ({ ok: false, invocation, reason });
304
+ const toks = shellWords(invocation);
305
+ if (toks === null || toks.length === 0)
306
+ return bad(NOT_OURS);
307
+ const [head, ...rest2] = toks;
308
+ const missing = (tok) => `${tok} does not exist`;
309
+ // `<runtime> <script…>`: every non-flag token after the runtime is a script
310
+ // path (`<node> <cli>`, or tsx's `<node> <tsx> <cli>`) — a runtime flag like
311
+ // `--no-warnings` is not a path, but a runtime with no script at all is not ours.
312
+ const runtime = () => {
313
+ if (isPathish(head) && !pathExists(head, root))
314
+ return bad(missing(head));
315
+ const scripts = rest2.filter((t) => !t.startsWith("-"));
316
+ if (scripts.length === 0)
317
+ return bad(NOT_OURS);
318
+ const gone = scripts.find((t) => !pathExists(t, root));
319
+ return gone === undefined ? { ok: true, invocation } : bad(missing(gone));
320
+ };
321
+ switch (base(head)) {
322
+ case "hunch":
323
+ if (rest2.length > 0)
324
+ return bad(NOT_OURS);
325
+ if (isPathish(head) && !pathExists(head, root))
326
+ return bad(missing(head));
327
+ return { ok: true, invocation };
328
+ case "node":
329
+ return rest2.length === 0 ? bad(NOT_OURS) : runtime();
330
+ case "npx":
331
+ case "pnpm":
332
+ case "yarn":
333
+ case "bunx":
334
+ case "npm":
335
+ case "env": {
336
+ // A package runner in front of the launcher: `npx -y --package=…@x.y.z hunch`
337
+ // (PORTABLE_HOOK_INVOCATION), `npx hunch`, and the shapes users adapt the
338
+ // snippet into by hand (`pnpm exec hunch`, `yarn run hunch`, `npm exec --
339
+ // hunch`, `env X=1 hunch`). What the runner would resolve is never verified —
340
+ // doing so would mean a network install — so only the launcher it ends in is read.
341
+ const args = rest2.filter((t) => !t.startsWith("-"));
342
+ const last = args.at(-1);
343
+ if (last !== undefined && (base(last) === "hunch" || /^@davesheffer\/hunch(?:@\S+)?$/.test(last)))
344
+ return { ok: true, invocation };
345
+ const tsx = args.indexOf("tsx");
346
+ if (tsx >= 0 && args.length === tsx + 2) {
347
+ return pathExists(args[tsx + 1], root) ? { ok: true, invocation } : bad(missing(args[tsx + 1]));
348
+ }
349
+ return bad(NOT_OURS);
350
+ }
351
+ default:
352
+ // `resolveInvocation` writes process.execPath, which is not always named
353
+ // `node` (Debian's `nodejs`, a version-suffixed binary). Any other runtime
354
+ // is ours only as an absolute path running Hunch's own CLI entry — else
355
+ // `hunch init` would rewrite the same block and doctor would call it stale forever.
356
+ return isAbs(head) && rest2.length > 0 && HUNCH_ENTRY.test(rest2.at(-1).replace(/\\/g, "/")) ? runtime() : bad(NOT_OURS);
357
+ }
358
+ }
359
+ /** The command each managed block runs, keyed by mark. Resolved inside a
360
+ * function (like `preCommitId`) because the marks are declared further down. */
361
+ function blockSubcommand(mark) {
362
+ return {
363
+ [MARK]: { re: /(?:^|\s)sync\s+--from-hook\b/, words: "sync --from-hook" },
364
+ [PRE_MARK]: { re: /(?:^|\s)check\s+--staged\b/, words: "check --staged" },
365
+ [GROUNDING_MERGE_MARK]: { re: /(?:^|\s)grounding\s+--refresh\b/, words: "grounding --refresh" },
366
+ [REPAIR_MERGE_MARK]: { re: /(?:^|\s)repair-provenance\b/, words: "repair-provenance" },
367
+ [CHECKOUT_MARK]: { re: /(?:^|\s)workspaces\s+snapshot\b/, words: "workspaces snapshot" },
368
+ }[mark] ?? { re: /(?:^|\s)hunch\b/, words: "hunch" };
369
+ }
370
+ /** Hunch's OWN option flags a block's command line may carry after the
371
+ * subcommand — the ones `hunch init` decides from its options, so a regenerated
372
+ * block silently drops any the re-run does not ask for again. Fixed set, in a
373
+ * stable order; anything else on the line is not ours to report. */
374
+ const BLOCK_FLAGS = ["--private", "--commit", "--strict"];
375
+ function blockFlags(line) {
376
+ return BLOCK_FLAGS.filter((f) => new RegExp(`(?:^|\\s)${escapeRe(f)}(?=\\s|$)`).test(line));
377
+ }
378
+ /** The flags a freshly BUILT block carries, read the way `inspectBlock` reads an
379
+ * installed one: per command line, only from the text after the subcommand. A
380
+ * whole-block scan would count a flag that is merely part of the launcher path
381
+ * (a Hunch installed under `…/opt --strict x/…`), and so miss a real downgrade.
382
+ *
383
+ * With `liveRoot`, only lines whose launcher works are read — for an INSTALLED
384
+ * block's text, where a dead hand-kept line's `--commit` was never in effect
385
+ * and carrying it into a rebuild would switch it on. */
386
+ function builtFlags(blk, mark, liveRoot) {
387
+ const { re } = blockSubcommand(mark);
388
+ const found = new Set();
389
+ for (const line of blk.replace(/\r/g, "").split("\n")) {
390
+ if (line.trim().startsWith("#"))
391
+ continue;
392
+ const m = re.exec(line);
393
+ if (!m)
394
+ continue;
395
+ if (liveRoot !== undefined && !hookInvocationHealth(line.slice(0, m.index), liveRoot).ok)
396
+ continue;
397
+ for (const f of blockFlags(line.slice(m.index)))
398
+ found.add(f);
399
+ }
400
+ return BLOCK_FLAGS.filter((f) => found.has(f));
401
+ }
402
+ /** The text of `mark`'s block in `cur` (which must contain `mark`): through its
403
+ * closing marker, or — when that is missing — up to the next Hunch marker line,
404
+ * so another block's command further down is never read as this block's. */
405
+ function ownBlockText(cur, mark, blockRe) {
406
+ const m = blockRe.exec(cur);
407
+ if (m)
408
+ return m[0];
409
+ const lines = cur.slice(cur.indexOf(mark)).split("\n");
410
+ const stop = lines.findIndex((l, i) => i > 0 && /^# (?:<<<|>>>) hunch /.test(l));
411
+ return (stop < 0 ? lines : lines.slice(0, stop)).join("\n");
412
+ }
413
+ /** Inspect the command inside `mark`'s block in `file`: the block body is the
414
+ * lines between the mark and the closing marker. A block may legitimately carry
415
+ * several command lines (or a hand-added one); any working line makes it live. */
416
+ function inspectBlock(file, mark, root) {
417
+ const { re, words } = blockSubcommand(mark);
418
+ const text = readText(file) ?? "";
419
+ const at = text.indexOf(mark);
420
+ const body = at < 0 ? [] : text.slice(at).replace(/\r/g, "").split("\n").slice(1);
421
+ let first = null;
422
+ for (const line of body) {
423
+ if (/^# <<< hunch /.test(line))
424
+ break;
425
+ if (line.trim().startsWith("#"))
426
+ continue;
427
+ const m = re.exec(line);
428
+ if (!m)
429
+ continue;
430
+ // Flags belong to the SUBCOMMAND, so they are read from the text after it.
431
+ const health = { ...hookInvocationHealth(line.slice(0, m.index), root), flags: blockFlags(line.slice(m.index)) };
432
+ if (health.ok)
433
+ return health;
434
+ first ??= health;
435
+ }
436
+ return first ?? { ok: false, invocation: "", reason: `the block has no \`hunch ${words}\` command`, flags: [] };
437
+ }
438
+ /** Whether our block for `mark` is present where git will actually run it AND
439
+ * its command is a Hunch launcher that exists (issue #315 — a marker alone
440
+ * proves nothing about what the block does). */
441
+ function blockInfo(t, mark, root) {
218
442
  const inHook = markerState(t.hookPath, mark);
443
+ // A reachable marker only earns "installed" once its command checks out.
444
+ const live = (file) => {
445
+ const health = inspectBlock(file, mark, root);
446
+ return health.ok
447
+ ? { state: "installed", invocation: health.invocation, flags: health.flags, file }
448
+ : { state: "stale", invocation: health.invocation, staleReason: health.reason, flags: health.flags, file };
449
+ };
219
450
  switch (t.manager) {
220
451
  case "none":
221
- return inHook === "reachable" ? "installed" : inHook === "unreachable" ? "unreachable" : "missing";
452
+ return inHook === "reachable" ? live(t.hookPath) : { state: inHook === "unreachable" ? "unreachable" : "missing", file: t.hookPath };
222
453
  case "husky":
223
454
  if (markerState(t.ownerFile, mark) === "reachable")
224
- return "installed";
455
+ return live(t.ownerFile);
225
456
  if (markerState(t.ownerFile, mark) === "unreachable" || inHook !== "absent")
226
- return "unreachable";
227
- return "missing";
457
+ return { state: "unreachable", file: t.hookPath };
458
+ return { state: "missing", file: t.ownerFile };
228
459
  case "pre-commit": {
229
460
  const config = readText(t.ownerFile);
461
+ // The framework's own config carries the command in its own format; the
462
+ // `id:` is the contract, so it is taken at face value.
230
463
  if (config !== null && new RegExp(`\\bid:\\s*["']?${escapeRe(preCommitId(mark))}["']?\\s*$`, "m").test(config))
231
- return "installed";
464
+ return { state: "installed", file: t.ownerFile };
232
465
  // 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";
466
+ const legacy = `${t.hookPath}.legacy`;
467
+ if (markerState(legacy, mark) === "reachable")
468
+ return live(legacy);
469
+ return inHook !== "absent" ? { state: "unreachable", file: t.hookPath } : { state: "missing", file: t.ownerFile };
236
470
  }
237
471
  default: // husky-legacy, tracked-hooks-path, exec-exit: the hook file itself
238
- return inHook === "reachable" ? "installed" : inHook === "unreachable" ? "unreachable" : "missing";
472
+ return inHook === "reachable" ? live(t.hookPath) : { state: inHook === "unreachable" ? "unreachable" : "missing", file: t.hookPath };
239
473
  }
240
474
  }
241
475
  function stripMarkers(blk) {
@@ -265,6 +499,107 @@ function snippetFor(t, mark, build, localInvocation) {
265
499
  }
266
500
  return build(PORTABLE_HOOK_INVOCATION, GIT_CONTEXT);
267
501
  }
502
+ /** The repo's worktree tops as `git worktree list --porcelain` reports them:
503
+ * `linked` is every entry AFTER the first (the first is always the main
504
+ * worktree, or the bare repo) that is not the bare entry, and `count` is how
505
+ * many non-bare worktrees the repo has — the number the shared-hooks note
506
+ * quotes, which must not include a bare repo's phantom entry. Entries are
507
+ * separated by blank lines and each starts with a `worktree <path>` line.
508
+ * Read straight from git rather than the workspace ledger: this runs during
509
+ * `hunch init`, before any ledger exists. */
510
+ function worktreeTops(root) {
511
+ const out = gitRun(["worktree", "list", "--porcelain"], root).stdout;
512
+ if (!out)
513
+ return { linked: [], count: 0 };
514
+ const entries = out.replace(/\r/g, "").split(/\n\s*\n/).map((e) => e.split("\n"));
515
+ const parsed = entries
516
+ .map((lines) => ({
517
+ path: lines.find((l) => l.startsWith("worktree "))?.slice("worktree ".length) ?? "",
518
+ bare: lines.some((l) => l.trim() === "bare"),
519
+ }))
520
+ .filter((e) => e.path !== "");
521
+ return { linked: parsed.slice(1).filter((e) => !e.bare).map((e) => e.path), count: parsed.filter((e) => !e.bare).length };
522
+ }
523
+ /** The LINKED worktree an invocation is bound to — one it can only work from
524
+ * inside, because some token is an absolute path under it (that worktree's own
525
+ * node_modules install, its own source checkout) — or null.
526
+ *
527
+ * "Inside" counts textually OR physically: a node_modules symlinked out of a
528
+ * worktree still dies with `git worktree remove`, and a path spelled through
529
+ * the main checkout that realpaths into a worktree is just as bound. A
530
+ * RELATIVE path token is bound to nothing: the shell resolves it against the
531
+ * hook's own cwd at run time, which is whichever worktree git is running in,
532
+ * so it stays correct for all of them. */
533
+ function worktreeBound(invocation, tops) {
534
+ const toks = shellWords(invocation);
535
+ if (toks === null)
536
+ return null;
537
+ const norm = (p) => (process.platform === "win32" ? resolve(p).toLowerCase() : resolve(p));
538
+ const textuallyInside = (top, tok) => {
539
+ const rel = relative(norm(top), norm(tok));
540
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
541
+ };
542
+ for (const top of tops) {
543
+ if (toks.some((t) => isAbs(t) && (textuallyInside(top, t) || isInside(top, t))))
544
+ return top;
545
+ }
546
+ return null;
547
+ }
548
+ /** The (hookName, mark) pairs of every managed block Hunch writes — the sibling
549
+ * blocks that share one hooks dir, scanned for a working shared launcher.
550
+ * Resolved inside a function (like `preCommitId`) because the marks are
551
+ * declared further down. */
552
+ function managedBlocks() {
553
+ return [
554
+ ["post-commit", MARK],
555
+ ["pre-commit", PRE_MARK],
556
+ ["post-merge", GROUNDING_MERGE_MARK],
557
+ ["post-merge", REPAIR_MERGE_MARK],
558
+ ["post-checkout", CHECKOUT_MARK],
559
+ ];
560
+ }
561
+ /** The repo's existing SHARED invocation to install instead of `invocation`, or
562
+ * null to go on as normal (issue #316).
563
+ *
564
+ * Linked worktrees share ONE `<git-common-dir>/hooks` directory. Rebuilding a
565
+ * block from a path inside a linked worktree therefore re-points the main
566
+ * checkout's and every sibling worktree's hooks at code that disappears with
567
+ * `git worktree remove` — leaving all of them dead. So when the repo already
568
+ * runs a Hunch bound to NO linked worktree, that invocation wins. The gate is
569
+ * the caller's invocation, not `root`: an install run from the MAIN checkout
570
+ * with a worktree-bound launcher poisons the shared hooks exactly the same way.
571
+ *
572
+ * `own: true` means the block for THIS mark already carries it (the caller must
573
+ * also preserve the flags it carries). Otherwise the answer came from a sibling
574
+ * block: `hunch index` self-heals a missing post-merge/post-checkout hook and
575
+ * agents run it from worktrees constantly — a missing block must not become the
576
+ * way a worktree path gets into the shared hooks while the repo already has a
577
+ * working shared launcher. */
578
+ function sharedInvocationFor(root, mark, invocation, own) {
579
+ const tops = worktreeTops(root).linked;
580
+ if (tops.length === 0 || !worktreeBound(invocation, tops))
581
+ return null;
582
+ if (own.state === "installed") {
583
+ // A bound invocation always differs from an unbound one, so no separate
584
+ // "did it change?" test is needed to avoid a pointless rewrite.
585
+ return own.invocation && !worktreeBound(own.invocation, tops)
586
+ ? { invocation: own.invocation, own: true, flags: own.flags ?? [] }
587
+ : null;
588
+ }
589
+ for (const [hookName, m] of managedBlocks()) {
590
+ if (m === mark)
591
+ continue;
592
+ const info = blockInfo(resolveHookTarget(root, hookName, m), m, root);
593
+ if (info.state === "installed" && info.invocation && !worktreeBound(info.invocation, tops)) {
594
+ // A rebuilt block gets its options from the CALLER, so `own.flags` are
595
+ // the ones the old block carried that this run may be dropping — reported
596
+ // (droppedFlags), never restored: restoring one would fabricate a request
597
+ // nobody made.
598
+ return { invocation: info.invocation, own: false, flags: own.flags ?? [] };
599
+ }
600
+ }
601
+ return null;
602
+ }
268
603
  /** Shared idempotent create/append/update-in-place logic for every hunch git
269
604
  * hook: write a fresh hook file, replace our own managed block in place if the
270
605
  * invocation changed, or append after any pre-existing (non-hunch) hook body
@@ -276,40 +611,108 @@ function snippetFor(t, mark, build, localInvocation) {
276
611
  function installManagedBlock(root, hookName, mark, end, build, invocation) {
277
612
  const t = resolveHookTarget(root, hookName, mark);
278
613
  if (t.manager !== "none") {
279
- if (blockState(t, mark) === "installed")
614
+ const info = blockInfo(t, mark, root);
615
+ if (info.state === "installed")
280
616
  return { path: t.ownerFile, action: "unchanged", manager: t.manager };
281
617
  return {
282
618
  path: t.ownerFile,
283
619
  action: t.manager === "exec-exit" ? "unreachable" : "managed-elsewhere",
284
620
  manager: t.manager,
285
- reason: t.reason,
621
+ // A stale block IS reachable — the manager's file already carries one, it
622
+ // just cannot run. Saying "a hook manager owns this hook" would send the
623
+ // user looking for a block that is right there (issue #315).
624
+ reason: info.state === "stale"
625
+ ? `the Hunch block in ${basename(info.file)} is stale (${info.staleReason ?? "its command is not a Hunch launcher"}) — replace it with the snippet below`
626
+ : t.reason,
286
627
  snippet: snippetFor(t, mark, build, invocation),
628
+ ...(info.state === "stale" ? { stale: true } : {}),
287
629
  };
288
630
  }
289
- const blk = build(invocation, GIT_CONTEXT);
631
+ // Issue #316: the hooks dir is shared with every linked worktree, so prefer
632
+ // the repo's existing unbound command over a caller path that dies with a
633
+ // worktree. The invocation text comes from blockInfo, i.e. straight out of the block's
634
+ // command line with its quoting intact, and inspectBlock only calls a block
635
+ // healthy when shellWords parsed it into plain words with no shell
636
+ // metacharacters — so re-embedding that exact text in build() is safe.
637
+ const info = blockInfo(t, mark, root);
638
+ const shared = sharedInvocationFor(root, mark, invocation, info);
639
+ const blk = build(shared?.invocation ?? invocation, GIT_CONTEXT);
640
+ // A dead own block rebuilt from a SIBLING's launcher keeps neither its command
641
+ // nor its options; say which options went, so a downgraded strict guard is not
642
+ // the user's to discover later.
643
+ // The local-only provider line is such an option too: a privacy promise that
644
+ // goes must be named, exactly like a flag.
290
645
  const hookPath = t.hookPath;
646
+ const cur = existsSync(hookPath) ? readFileSync(hookPath, "utf8") : null;
647
+ const blockRe = new RegExp(`${escapeRe(mark)}[\\s\\S]*?${escapeRe(end)}`);
648
+ const own = cur?.includes(mark) ? ownBlockText(cur, mark, blockRe) : "";
649
+ const lost = shared && !shared.own
650
+ ? [...shared.flags.filter((f) => !builtFlags(blk, mark).includes(f)), ...(LOCAL_ONLY_RE.test(own) && !LOCAL_ONLY_RE.test(blk) ? [LOCAL_ONLY_LABEL] : [])]
651
+ : [];
652
+ const sharedNote = shared ? { sharedInvocation: shared.invocation, ...(lost.length > 0 ? { droppedFlags: lost } : {}) } : {};
291
653
  mkdirSync(dirname(hookPath), { recursive: true });
292
- if (!existsSync(hookPath)) {
654
+ if (cur === null) {
293
655
  writeFileSync(hookPath, `#!/bin/sh\n${blk}\n`);
294
656
  chmodSync(hookPath, 0o755);
295
- return { path: hookPath, action: "created" };
657
+ return { path: hookPath, action: "created", ...sharedNote };
296
658
  }
297
- const cur = readFileSync(hookPath, "utf8");
298
659
  if (cur.includes(mark)) {
299
- const updated = cur.replace(new RegExp(`${escapeRe(mark)}[\\s\\S]*?${escapeRe(end)}`), blk);
660
+ const closed = blockRe.test(cur);
661
+ let next = blk;
662
+ let note = sharedNote;
663
+ if (shared?.own) {
664
+ // A worktree-local re-run may ADD options to the shared block, never
665
+ // remove them or re-point it: an advisory `hunch init` from a worktree
666
+ // must not silently downgrade the repo's strict pre-commit guard. So the
667
+ // block is rebuilt as the UNION of what it carries and what this run asks
668
+ // for. Refusing the whole write over one omitted option would also discard
669
+ // the one the run DID ask for — a `hunch private` whose --private never
670
+ // lands keeps capturing into the public store behind a ✓ line.
671
+ //
672
+ // What the block carries is read from ALL its WORKING command lines (the
673
+ // options of a hand-added second line are merged in — the line itself is
674
+ // not kept: the block is rebuilt from the template), and the provider line
675
+ // is an option like any flag: a privacy promise is not a re-run's to withdraw.
676
+ const carried = builtFlags(own, mark, root);
677
+ const asked = builtFlags(blk, mark);
678
+ const keepsLocalOnly = LOCAL_ONLY_RE.test(own);
679
+ const kept = [...carried.filter((f) => !asked.includes(f)), ...(keepsLocalOnly && !LOCAL_ONLY_RE.test(blk) ? [LOCAL_ONLY_LABEL] : [])];
680
+ const added = [...asked.filter((f) => !carried.includes(f)), ...(!keepsLocalOnly && LOCAL_ONLY_RE.test(blk) ? [LOCAL_ONLY_LABEL] : [])];
681
+ const keptNote = { ...sharedNote, ...(kept.length > 0 ? { keptFlags: kept } : {}) };
682
+ // Nothing to add: the block stays byte-identical. Rebuilding it anyway would
683
+ // let a re-run that asked for nothing rewrite a line someone shaped by hand
684
+ // (a `--strict || true` softened guard would come back blocking).
685
+ if (added.length === 0)
686
+ return { path: hookPath, action: "kept-shared", ...keptNote };
687
+ next = build(shared.invocation, GIT_CONTEXT, { flags: carried, localOnly: keepsLocalOnly });
688
+ note = { ...keptNote, addedFlags: added };
689
+ }
690
+ // No closing marker: there is nothing to replace in place, and saying
691
+ // "unchanged" (or "kept") would hide that this run's block never landed.
692
+ if (!closed) {
693
+ return {
694
+ path: hookPath,
695
+ action: "unreachable",
696
+ reason: `the Hunch block in ${basename(hookPath)} has no closing \`${end}\` line, so it cannot be updated in place — replace it with the snippet below`,
697
+ snippet: next,
698
+ stale: true,
699
+ live: true,
700
+ };
701
+ }
702
+ const updated = cur.replace(blockRe, () => next);
300
703
  if (updated === cur)
301
- return { path: hookPath, action: "unchanged" };
704
+ return { path: hookPath, action: "unchanged", ...sharedNote };
302
705
  writeFileSync(hookPath, updated);
303
706
  chmodSync(hookPath, 0o755);
304
- return { path: hookPath, action: "updated" };
707
+ return { path: hookPath, action: "updated", ...note };
305
708
  }
306
709
  const appended = cur.endsWith("\n") ? `${cur}${blk}\n` : `${cur}\n${blk}\n`;
307
710
  writeFileSync(hookPath, appended);
308
711
  chmodSync(hookPath, 0o755);
309
- return { path: hookPath, action: "appended" };
712
+ return { path: hookPath, action: "appended", ...sharedNote };
310
713
  }
311
714
  export function installPostCommitHook(root, invocation, opts = {}) {
312
- return installManagedBlock(root, "post-commit", MARK, ENDMARK, (inv) => block(inv, opts), invocation);
715
+ return installManagedBlock(root, "post-commit", MARK, ENDMARK, (inv, _ctx, keep) => block(inv, opts, keep), invocation);
313
716
  }
314
717
  const PRE_MARK = "# >>> hunch pre-commit (constraint guard) >>>";
315
718
  const PRE_END = "# <<< hunch pre-commit <<<";
@@ -319,9 +722,10 @@ const PRE_END = "# <<< hunch pre-commit <<<";
319
722
  * blocking invariant (see strictgate.ts), so it's safe on a shared repo.
320
723
  * Preserves any existing pre-commit hook. */
321
724
  export function installPreCommitHook(root, invocation, strict = false) {
322
- const build = (inv) => {
323
- const cmd = `${inv} check --staged${strict ? " --strict" : ""}`;
324
- return [PRE_MARK, strict ? cmd : `${cmd} || true`, PRE_END].join("\n");
725
+ const build = (inv, _ctx, keep) => {
726
+ const on = strict || keep?.flags.includes("--strict");
727
+ const cmd = `${inv} check --staged${on ? " --strict" : ""}`;
728
+ return [PRE_MARK, on ? cmd : `${cmd} || true`, PRE_END].join("\n");
325
729
  };
326
730
  return installManagedBlock(root, "pre-commit", PRE_MARK, PRE_END, build, invocation);
327
731
  }
@@ -366,7 +770,7 @@ function repairProvenanceMergeBlock(invocation) {
366
770
  * (the file itself is new) outranks "appended"/"updated" (an existing file
367
771
  * changed), which outrank "unchanged". Non-writing results are handled before
368
772
  * 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 };
773
+ const ACTION_RANK = { created: 3, appended: 2, updated: 2, unchanged: 1, "kept-shared": 1, "managed-elsewhere": 0, unreachable: 0 };
370
774
  const writes = (h) => h.action !== "managed-elsewhere" && h.action !== "unreachable";
371
775
  /** Install a post-merge hook carrying TWO independently-managed blocks:
372
776
  * re-sync the committed grounding docs when a merge brought memory in behind
@@ -392,7 +796,17 @@ export function installPostMergeHook(root, invocation) {
392
796
  return grounding;
393
797
  if (!writes(repair))
394
798
  return repair;
395
- return ACTION_RANK[repair.action] >= ACTION_RANK[grounding.action] ? repair : grounding;
799
+ const picked = ACTION_RANK[repair.action] >= ACTION_RANK[grounding.action] ? repair : grounding;
800
+ const other = picked === repair ? grounding : repair;
801
+ // Either half may be the one that kept the repo's shared invocation (issue
802
+ // #316); the ranking above can pick the other one. Carry the shared-hook facts
803
+ // over so the CLI line stays true about what the file now runs.
804
+ return {
805
+ ...picked,
806
+ ...(picked.sharedInvocation ?? other.sharedInvocation ? { sharedInvocation: picked.sharedInvocation ?? other.sharedInvocation } : {}),
807
+ ...(picked.keptFlags ?? other.keptFlags ? { keptFlags: picked.keptFlags ?? other.keptFlags } : {}),
808
+ ...(picked.droppedFlags ?? other.droppedFlags ? { droppedFlags: picked.droppedFlags ?? other.droppedFlags } : {}),
809
+ };
396
810
  }
397
811
  /** When two snippets for the same file are joined, drop the second's leading
398
812
  * instruction comment (the first already says where it goes). */
@@ -422,23 +836,34 @@ export function installPostCheckoutHook(root, invocation) {
422
836
  function reportEntry(root, hookName, marks) {
423
837
  const states = marks.map((mark) => {
424
838
  const t = resolveHookTarget(root, hookName, mark);
425
- return { t, state: blockState(t, mark) };
839
+ return { t, ...blockInfo(t, mark, root) };
426
840
  });
427
841
  const unreachable = states.find((s) => s.state === "unreachable");
428
- const pick = unreachable ?? states.find((s) => s.state === "missing") ?? states[0];
842
+ const stale = states.find((s) => s.state === "stale");
843
+ // A dead block outranks a stale one, which outranks a missing one: each is a
844
+ // stronger statement about why this hook is not doing its job.
845
+ const pick = unreachable ?? stale ?? states.find((s) => s.state === "missing") ?? states[0];
429
846
  if (!pick)
430
847
  throw new Error("reportEntry needs at least one marker");
431
- const state = unreachable ? "unreachable" : states.every((s) => s.state === "installed") ? "installed" : "missing";
848
+ const state = unreachable ? "unreachable" : stale ? "stale" : states.every((s) => s.state === "installed") ? "installed" : "missing";
849
+ const invocation = [...new Set(states.map((s) => s.invocation).filter((i) => !!i))].join(" · ");
850
+ // Union across the hook's marks, in BLOCK_FLAGS order — a hook is re-installed
851
+ // as a whole, so a flag on any of its blocks is one a bare re-run would drop.
852
+ const flags = BLOCK_FLAGS.filter((f) => states.some((s) => s.flags?.includes(f)));
432
853
  return {
433
854
  state,
434
855
  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 } : {}),
856
+ path: state === "unreachable" && pick.t.manager !== "husky" ? pick.t.hookPath : state === "stale" ? pick.file : pick.t.ownerFile,
857
+ ...(state === "unreachable" ? { reason: pick.t.reason } : state === "stale" ? { reason: pick.staleReason } : {}),
858
+ ...(invocation ? { invocation } : {}),
859
+ ...(flags.length ? { flags } : {}),
437
860
  };
438
861
  }
439
862
  /** 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
863
+ * `installed` (present where git will run it, running a Hunch launcher that
864
+ * exists), `unreachable` (present, but after an exec/exit or in a
865
+ * manager-owned file git never reaches), `stale` (present where git runs it,
866
+ * but its command is not a working Hunch launcher — issue #315), or
442
867
  * `missing`. postMerge requires BOTH halves (grounding-refresh and
443
868
  * repair-provenance) — a repo carrying only one is a partial install, same as
444
869
  * `installPostMergeHook` self-healing it. Never writes anything. */
@@ -451,9 +876,9 @@ export function hookReport(root) {
451
876
  };
452
877
  }
453
878
  /** 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. */
879
+ * managed block is present where git will actually run it AND the command
880
+ * inside it is a Hunch launcher that exists. An unreachable block (issue #311)
881
+ * and a stale one (issue #315) are both NOT installed. */
457
882
  export function hookStatus(root) {
458
883
  const r = hookReport(root);
459
884
  return {
@@ -463,17 +888,105 @@ export function hookStatus(root) {
463
888
  postCheckout: r.postCheckout.state === "installed",
464
889
  };
465
890
  }
891
+ /** Doctor lines for where each installed/stale hook points (issue #315): hooks
892
+ * sharing one invocation share one line, and a line whose invocation is not the
893
+ * Hunch currently running says so. Informational — a hook may legitimately
894
+ * point at a different install. Pure; the caller adds indentation and dim(). */
895
+ export function hookInvocationLines(report, running) {
896
+ const named = [["post-commit", report.postCommit], ["post-merge", report.postMerge], ["pre-commit", report.preCommit], ["post-checkout", report.postCheckout]]
897
+ .filter(([, e]) => e.invocation && (e.state === "installed" || e.state === "stale"));
898
+ const grouped = new Map();
899
+ for (const [name, e] of named)
900
+ grouped.set(e.invocation, [...(grouped.get(e.invocation) ?? []), name]);
901
+ return [...grouped].map(([inv, names]) => `${names.join(", ")} → ${inv}${running && inv !== running ? ` (differs from the running Hunch: ${running})` : ""}`);
902
+ }
903
+ const KEPT_HOW = " — to change its options, re-run `hunch init` with a Hunch that is not inside a linked worktree (a global install, or the main checkout's)";
466
904
  /** CLI lines for one install result: the usual ✓ line when the block is in a
467
905
  * file git runs, otherwise a warning with the reason and the snippet to add to
468
906
  * the manager's own file. */
469
907
  export function formatHookInstall(root, label, h, detail = "") {
470
- if (writes(h))
471
- return [` ✓ ${label} ${h.action}${detail}`];
908
+ // kept-shared deliberately drops `detail`: it describes the options this run
909
+ // ASKED for, and a run that asked for FEWER than the shared block carries (an
910
+ // advisory re-run of a strict guard) would describe a block that is not there
911
+ // (issue #316).
912
+ if (h.action === "kept-shared") {
913
+ const flags = h.keptFlags?.length ? ` with ${h.keptFlags.join(" ")}` : "";
914
+ // The block kept options this run did not ask for, so the way to remove them
915
+ // is the one thing worth saying next.
916
+ const how = h.keptFlags?.length ? KEPT_HOW : "";
917
+ return [` ✓ ${label} kept — the shared hook keeps running ${h.sharedInvocation}${flags} (not re-pointed at this worktree)${how}`];
918
+ }
919
+ if (writes(h)) {
920
+ // A union write (issue #316). Once an option was KEPT, `detail` — which
921
+ // describes only what this run asked for — is no longer the truth about the
922
+ // block: an "advisory" detail beside a kept --strict contradicts itself.
923
+ const opts = [h.addedFlags?.length ? `added ${h.addedFlags.join(" ")}` : "", h.keptFlags?.length ? `kept its existing ${h.keptFlags.join(" ")}` : ""].filter(Boolean).join(", ");
924
+ const union = opts ? ` — ${opts} (a worktree re-run adds options, never removes them)${h.keptFlags?.length ? KEPT_HOW : ""}` : "";
925
+ const said = h.keptFlags?.length ? "" : detail;
926
+ const lost = h.droppedFlags?.length ? ` — the previous block's ${h.droppedFlags.join(" ")} was not carried over; re-run with the option to restore it` : "";
927
+ if (h.sharedInvocation)
928
+ return [` ✓ ${label} ${h.action}${said} — runs the repo's shared Hunch (${h.sharedInvocation}), not this worktree's${union}${lost}`];
929
+ return [` ✓ ${label} ${h.action}${detail}${lost}`];
930
+ }
472
931
  const shown = isInside(root, h.path) ? relative(realish(root), realish(h.path)).replace(/\\/g, "/") : h.path;
473
932
  return [
474
- ` ⚠ ${label} NOT installed — ${h.reason ?? "a hook manager owns this hook"}`,
475
- ` add this to ${shown} yourself:`,
933
+ ` ⚠ ${label} NOT ${h.live ? "updated" : "installed"} — ${h.reason ?? "a hook manager owns this hook"}`,
934
+ // A stale block is already there and the reason said to replace it; anything
935
+ // else is missing and has to be added.
936
+ ` ${h.stale ? "replace the Hunch block in" : "add this to"} ${shown} yourself:`,
476
937
  ...(h.snippet ?? "").split("\n").map((l) => ` ${l}`),
477
938
  ];
478
939
  }
940
+ /** The closing line `hunch init` prints about the SHARED hooks dir (issue #316).
941
+ * Setup used to claim "sharing the repo's hooks + memory" unconditionally —
942
+ * true of memory, but the hooks dir is genuinely shared, so a block pointing
943
+ * inside a linked worktree dies for every checkout when that worktree goes.
944
+ *
945
+ * The verdict is read from what the shared blocks ACTUALLY run, not from this
946
+ * run's actions: a second `hunch init` writes nothing yet the hooks are just as
947
+ * broken, and the main checkout — which never used to get a note at all — is
948
+ * where the user can most easily fix it. `installs` only decides between the
949
+ * healthy variants. `[]` when the repo has no linked worktree. Read-only. */
950
+ export function sharedHooksNote(root, installs) {
951
+ const { linked, count } = worktreeTops(root);
952
+ if (linked.length === 0)
953
+ return [];
954
+ const d = hooksDir(root);
955
+ const dir = isAbsolute(d) ? d : join(root, d);
956
+ // A hooks dir is shared when git resolves it inside the COMMON dir, or points
957
+ // outside this checkout entirely. A relative `core.hooksPath` (husky's
958
+ // `.husky/_`) resolves per checkout, so it is this worktree's alone.
959
+ const common = gitCommonDir(root);
960
+ const top = gitRun(["rev-parse", "--show-toplevel"], root).stdout;
961
+ const dirShared = (common !== "" && isInside(common, dir)) || (top !== "" && !isInside(top, dir));
962
+ if (dirShared) {
963
+ const bound = [];
964
+ const tops = new Set();
965
+ for (const [hookName, m] of managedBlocks()) {
966
+ const info = blockInfo(resolveHookTarget(root, hookName, m), m, root);
967
+ const at = info.invocation ? worktreeBound(info.invocation, linked) : null;
968
+ if (!at)
969
+ continue;
970
+ tops.add(at);
971
+ if (!bound.includes(hookName))
972
+ bound.push(hookName);
973
+ }
974
+ if (bound.length > 0) {
975
+ const one = bound.length === 1;
976
+ // Several blocks may be bound to different worktrees; naming the first one
977
+ // is enough to find the problem, and the plural says there are more.
978
+ const where = tops.size > 1 ? `linked worktrees (${[...tops][0]})` : `a linked worktree (${[...tops][0]})`;
979
+ return [` ⚠ shared hooks dir (${dir}) — ${bound.join(", ")} run${one ? "s" : ""} Hunch from inside ${where}; removing that worktree breaks ${one ? "it" : "them"} for every checkout of this repo — install Hunch globally or in the main checkout, then re-run \`hunch init\` with it to re-point ${one ? "it" : "them"}`];
980
+ }
981
+ }
982
+ if (!isLinkedWorktree(root))
983
+ return [];
984
+ const wrote = installs.some((h) => writes(h));
985
+ if (!dirShared || !wrote)
986
+ return [" ✓ linked worktree — sharing the repo's memory"];
987
+ if (installs.some((h) => h.sharedInvocation)) {
988
+ return [` ✓ linked worktree — sharing the repo's memory; the hooks dir is shared by ${count} worktrees (${dir}) and keeps running the repo's Hunch, not this worktree's`];
989
+ }
990
+ return [` ✓ linked worktree — sharing the repo's hooks + memory (hooks dir is shared by ${count} worktrees: ${dir})`];
991
+ }
479
992
  //# sourceMappingURL=hooks.js.map