@davesheffer/hunch 1.39.3 → 1.40.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 (43) hide show
  1. package/dist/cli/index.js +61 -17
  2. package/dist/constitution/g2BehaviorCandidates.js +6 -1
  3. package/dist/constitution/g2Candidates.js +11 -1
  4. package/dist/constitution/structural.js +10 -0
  5. package/dist/core/delivery.d.ts +34 -0
  6. package/dist/core/delivery.js +60 -1
  7. package/dist/core/docanchors.js +181 -11
  8. package/dist/core/format.js +6 -1
  9. package/dist/core/glob.d.ts +12 -6
  10. package/dist/core/glob.js +12 -6
  11. package/dist/core/hookcache.d.ts +9 -2
  12. package/dist/core/hookcache.js +10 -3
  13. package/dist/core/paths.d.ts +21 -0
  14. package/dist/core/paths.js +39 -1
  15. package/dist/core/taskDelivery.js +27 -1
  16. package/dist/core/taskReportHook.d.ts +19 -0
  17. package/dist/core/taskReportHook.js +40 -4
  18. package/dist/core/taskReportRender.d.ts +3 -0
  19. package/dist/core/taskReportRender.js +22 -12
  20. package/dist/core/verifyLauncher.d.ts +21 -0
  21. package/dist/core/verifyLauncher.js +37 -0
  22. package/dist/extractors/indexer.d.ts +5 -0
  23. package/dist/extractors/indexer.js +56 -11
  24. package/dist/extractors/k8sManifest.d.ts +13 -0
  25. package/dist/extractors/k8sManifest.js +103 -7
  26. package/dist/extractors/landscapeDiscovery.js +9 -1
  27. package/dist/extractors/nativeTreeSitter.d.ts +24 -0
  28. package/dist/extractors/nativeTreeSitter.js +54 -1
  29. package/dist/extractors/parse.d.ts +3 -1
  30. package/dist/extractors/parse.js +12 -3
  31. package/dist/integrations/claudemd.js +3 -3
  32. package/dist/integrations/hooks.d.ts +43 -4
  33. package/dist/integrations/hooks.js +309 -22
  34. package/dist/mcp/server.js +19 -11
  35. package/dist/mcp/taskReportTools.d.ts +5 -9
  36. package/dist/mcp/taskReportTools.js +9 -20
  37. package/dist/store/changeLedger.d.ts +9 -3
  38. package/dist/store/changeLedger.js +36 -10
  39. package/dist/store/hunchStore.d.ts +42 -2
  40. package/dist/store/hunchStore.js +74 -16
  41. package/dist/store/stateBinding.js +1 -1
  42. package/package.json +1 -1
  43. package/server.json +2 -2
@@ -17,20 +17,25 @@ import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, realpath
17
17
  import { spawnSync } from "node:child_process";
18
18
  import { join, isAbsolute, dirname, basename, relative, resolve } from "node:path";
19
19
  import { homedir } from "node:os";
20
- import { hooksDir, gitCommonDir } from "../extractors/git.js";
20
+ import { hooksDir, gitCommonDir, isLinkedWorktree } from "../extractors/git.js";
21
21
  import { initiatorChildEnv } from "../synthesis/initiator.js";
22
22
  import { HUNCH_NPX_PACKAGE_SPEC } from "../core/version.js";
23
23
  const MARK = "# >>> hunch post-commit >>>";
24
24
  const ENDMARK = "# <<< hunch post-commit <<<";
25
25
  const GIT_CONTEXT = { checkoutType: "$3" };
26
26
  const PRE_COMMIT_CONTEXT = { checkoutType: "$PRE_COMMIT_CHECKOUT_TYPE" };
27
- 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) {
28
33
  // --private routes the auto-synthesized decision into the HUNCH_PRIVATE_DIR overlay
29
34
  // instead of the public repo. --commit (opt-in) also commits & pushes the repo the
30
35
  // decision landed in (the private store under --private, else this repo). The hook
31
36
  // script is local (.git/hooks/), never committed.
32
- const priv = opts.private ? " --private" : "";
33
- 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" : "";
34
39
  return [
35
40
  MARK,
36
41
  'if [ -z "$HUNCH_SYNC" ]; then',
@@ -38,7 +43,7 @@ function block(invocation, opts = {}) {
38
43
  // A split-private capture must not make a storage-private promise and then
39
44
  // ship the commit diff to a subscription CLI. Shared overlays are a separate
40
45
  // team policy, so only the explicit local-only mode forces deterministic.
41
- ...(opts.localOnly ? [" export HUNCH_SYNTH_PROVIDER=deterministic"] : []),
46
+ ...(opts.localOnly || keep?.localOnly ? [LOCAL_ONLY_LINE] : []),
42
47
  ` ( ${invocation} sync --from-hook --quiet${priv}${commit} >/dev/null 2>&1 || true ) &`,
43
48
  // Deliberately NO workspace-ledger snapshot here (docs/workspace-ledger.md): a commit
44
49
  // changes HEAD, not which branches and worktrees exist — post-checkout covers that, and
@@ -370,6 +375,41 @@ const BLOCK_FLAGS = ["--private", "--commit", "--strict"];
370
375
  function blockFlags(line) {
371
376
  return BLOCK_FLAGS.filter((f) => new RegExp(`(?:^|\\s)${escapeRe(f)}(?=\\s|$)`).test(line));
372
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
+ }
373
413
  /** Inspect the command inside `mark`'s block in `file`: the block body is the
374
414
  * lines between the mark and the closing marker. A block may legitimately carry
375
415
  * several command lines (or a hand-added one); any working line makes it live. */
@@ -459,6 +499,107 @@ function snippetFor(t, mark, build, localInvocation) {
459
499
  }
460
500
  return build(PORTABLE_HOOK_INVOCATION, GIT_CONTEXT);
461
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
+ }
462
603
  /** Shared idempotent create/append/update-in-place logic for every hunch git
463
604
  * hook: write a fresh hook file, replace our own managed block in place if the
464
605
  * invocation changed, or append after any pre-existing (non-hunch) hook body
@@ -487,30 +628,91 @@ function installManagedBlock(root, hookName, mark, end, build, invocation) {
487
628
  ...(info.state === "stale" ? { stale: true } : {}),
488
629
  };
489
630
  }
490
- 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.
491
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 } : {}) } : {};
492
653
  mkdirSync(dirname(hookPath), { recursive: true });
493
- if (!existsSync(hookPath)) {
654
+ if (cur === null) {
494
655
  writeFileSync(hookPath, `#!/bin/sh\n${blk}\n`);
495
656
  chmodSync(hookPath, 0o755);
496
- return { path: hookPath, action: "created" };
657
+ return { path: hookPath, action: "created", ...sharedNote };
497
658
  }
498
- const cur = readFileSync(hookPath, "utf8");
499
659
  if (cur.includes(mark)) {
500
- 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);
501
703
  if (updated === cur)
502
- return { path: hookPath, action: "unchanged" };
704
+ return { path: hookPath, action: "unchanged", ...sharedNote };
503
705
  writeFileSync(hookPath, updated);
504
706
  chmodSync(hookPath, 0o755);
505
- return { path: hookPath, action: "updated" };
707
+ return { path: hookPath, action: "updated", ...note };
506
708
  }
507
709
  const appended = cur.endsWith("\n") ? `${cur}${blk}\n` : `${cur}\n${blk}\n`;
508
710
  writeFileSync(hookPath, appended);
509
711
  chmodSync(hookPath, 0o755);
510
- return { path: hookPath, action: "appended" };
712
+ return { path: hookPath, action: "appended", ...sharedNote };
511
713
  }
512
714
  export function installPostCommitHook(root, invocation, opts = {}) {
513
- 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);
514
716
  }
515
717
  const PRE_MARK = "# >>> hunch pre-commit (constraint guard) >>>";
516
718
  const PRE_END = "# <<< hunch pre-commit <<<";
@@ -520,9 +722,10 @@ const PRE_END = "# <<< hunch pre-commit <<<";
520
722
  * blocking invariant (see strictgate.ts), so it's safe on a shared repo.
521
723
  * Preserves any existing pre-commit hook. */
522
724
  export function installPreCommitHook(root, invocation, strict = false) {
523
- const build = (inv) => {
524
- const cmd = `${inv} check --staged${strict ? " --strict" : ""}`;
525
- 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");
526
729
  };
527
730
  return installManagedBlock(root, "pre-commit", PRE_MARK, PRE_END, build, invocation);
528
731
  }
@@ -567,7 +770,7 @@ function repairProvenanceMergeBlock(invocation) {
567
770
  * (the file itself is new) outranks "appended"/"updated" (an existing file
568
771
  * changed), which outrank "unchanged". Non-writing results are handled before
569
772
  * ranking (they must never be masked by a sibling's success). */
570
- 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 };
571
774
  const writes = (h) => h.action !== "managed-elsewhere" && h.action !== "unreachable";
572
775
  /** Install a post-merge hook carrying TWO independently-managed blocks:
573
776
  * re-sync the committed grounding docs when a merge brought memory in behind
@@ -593,7 +796,17 @@ export function installPostMergeHook(root, invocation) {
593
796
  return grounding;
594
797
  if (!writes(repair))
595
798
  return repair;
596
- 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
+ };
597
810
  }
598
811
  /** When two snippets for the same file are joined, drop the second's leading
599
812
  * instruction comment (the first already says where it goes). */
@@ -687,19 +900,93 @@ export function hookInvocationLines(report, running) {
687
900
  grouped.set(e.invocation, [...(grouped.get(e.invocation) ?? []), name]);
688
901
  return [...grouped].map(([inv, names]) => `${names.join(", ")} → ${inv}${running && inv !== running ? ` (differs from the running Hunch: ${running})` : ""}`);
689
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)";
690
904
  /** CLI lines for one install result: the usual ✓ line when the block is in a
691
905
  * file git runs, otherwise a warning with the reason and the snippet to add to
692
906
  * the manager's own file. */
693
907
  export function formatHookInstall(root, label, h, detail = "") {
694
- if (writes(h))
695
- 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
+ }
696
931
  const shown = isInside(root, h.path) ? relative(realish(root), realish(h.path)).replace(/\\/g, "/") : h.path;
697
932
  return [
698
- ` ⚠ ${label} NOT installed — ${h.reason ?? "a hook manager owns this hook"}`,
933
+ ` ⚠ ${label} NOT ${h.live ? "updated" : "installed"} — ${h.reason ?? "a hook manager owns this hook"}`,
699
934
  // A stale block is already there and the reason said to replace it; anything
700
935
  // else is missing and has to be added.
701
936
  ` ${h.stale ? "replace the Hunch block in" : "add this to"} ${shown} yourself:`,
702
937
  ...(h.snippet ?? "").split("\n").map((l) => ` ${l}`),
703
938
  ];
704
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
+ }
705
992
  //# sourceMappingURL=hooks.js.map
@@ -13,7 +13,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
13
13
  import { RootsListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
14
14
  import { z } from "zod";
15
15
  import { hunchPaths, findRoot, toPosixTarget, repoRelativeTarget } from "../core/paths.js";
16
- import { isIndexedPath, matchSymbolsTiered } from "../core/glob.js";
16
+ import { matchSymbolsTiered } from "../core/glob.js";
17
17
  import { resolveMcpToolset } from "./toolset.js";
18
18
  import { readConfig } from "../core/config.js";
19
19
  import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
@@ -465,13 +465,16 @@ export function resolveSymbols(store, target) {
465
465
  target = repoRelativeTarget(target, store.publicRoot);
466
466
  const syms = store.json.loadAll("symbols");
467
467
  const components = store.json.loadAll("components");
468
- // A target that IS a real indexed path (has a symbol, or is covered by an
469
- // indexed component even with zero symbols — README.md, package.json, ...)
470
- // never falls through to the suffix tier: bare `endsWith` matched unrelated
471
- // files that merely end in the same characters — "db.ts" matched "mongodb.ts"
472
- // (issue #300), and a same-basename file in another directory leaked its
473
- // rules onto a real indexed target with no symbols of its own (issue #299).
474
- const indexed = isIndexedPath(target, syms.map((s) => s.file), components.map((c) => c.paths));
468
+ // A target that IS a real path (has a symbol, is covered by an indexed
469
+ // component even with zero symbols — README.md, package.json, ... — or, last
470
+ // resort, is a real working-tree file the index cannot see at all, issue
471
+ // #334) never falls through to the suffix tier: bare `endsWith` matched
472
+ // unrelated files that merely end in the same characters — "db.ts" matched
473
+ // "mongodb.ts" (issue #300), and a same-basename file in another directory
474
+ // leaked its rules onto a real target with no symbols of its own (issue
475
+ // #299). `store.isKnownPath` is the one definition of that question; it is
476
+ // asked here over the PUBLIC graph only, matching the records resolved below.
477
+ const indexed = store.isKnownPath(target, { symbols: syms, components });
475
478
  return matchSymbolsTiered(target, syms, indexed);
476
479
  }
477
480
  /** Resolve a target to canonical indexed file path(s) (for file-granular blast
@@ -583,7 +586,7 @@ function prepareRoot(root, explicitOverlay, requireIndex) {
583
586
  * per-host prose (CLAUDE.md, AGENTS.md) and hooks add to it, never replace it. */
584
587
  export const MCP_INSTRUCTIONS = [
585
588
  "Hunch is this repository's engineering memory: decisions, bug history, invariants, components, with provenance.",
586
- "Per user task: (1) hunch_task(action:\"start\", title) once — unless the host's prompt hook already printed a task_id, then reuse it; (2) hunch_context(target, task_id) FIRST, before reading or editing, for the file, symbol, or task phrase; (3) hunch_check_constraints(scope) before editing shared code; (4) hunch_task(action:\"finish\", task_id) before the final response and show its contribution card.",
589
+ "Per user task: (1) hunch_task(action:\"start\", title) once — skip it when the host's prompt hook already opened the task and printed its verify command, and reuse that task_id; (2) hunch_context(target, task_id) FIRST, before reading or editing, for the file, symbol, or task phrase; (3) hunch_check_constraints(scope) before editing shared code; (4) hunch_task(action:\"finish\", task_id) before the final response, showing its contribution card — required, with one exception: the host's prompt hook opened the task AND its instruction said that host closes the task AND the task used no Hunch (no hunch_* call on this task_id, no task verify check, no hook context you acted on, nothing to claim). A task you started with hunch_task is always finished by you.",
587
590
  "Then by moment: hunch_why(target) for rationale and rejected alternatives, hunch_bug_lineage before fixing a failure, hunch_record_decision after a non-trivial choice, hunch_record_correction when a human corrects you.",
588
591
  "Hosts without lifecycle hooks (Windsurf, Cursor, or Codex before its hooks are trusted) receive no automatic grounding: call these tools yourself.",
589
592
  ].join("\n");
@@ -932,8 +935,13 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
932
935
  if (!all.has(d.id))
933
936
  all.set(d.id, d);
934
937
  const deps = [...all.values()].sort((a, b) => a.depth - b.depth);
935
- if (!deps.length)
936
- return ok(`Nothing depends on "${symbol}" (leaf node, or not indexed).`);
938
+ // A real path the index has no symbols for reaches here too (resolveSymbols
939
+ // correctly refuses to suffix-resolve it, #334) — don't tell the caller a
940
+ // path we can confirm is real "isn't indexed".
941
+ if (!deps.length) {
942
+ const known = store.isKnownPath(repoRelativeTarget(symbol, store.publicRoot));
943
+ return ok(`Nothing depends on "${symbol}" (${known ? "leaf node, or no indexed symbols for it" : "leaf node, or not indexed"}).`);
944
+ }
937
945
  // Nearest dependents first (sorted by depth); cap the tail so a high-fan-in
938
946
  // symbol can't flood the session context.
939
947
  const lines = deps.slice(0, DEP_CAP).map((d) => ` • [depth ${d.depth}] ${d.via} (${d.id})`);
@@ -1,4 +1,5 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { verificationLauncherFor } from "../core/verifyLauncher.js";
2
3
  import { readTaskReport } from "../core/taskReport.js";
3
4
  import type { HunchStore } from "../store/hunchStore.js";
4
5
  /** A tool result must fit the host's round-trip. The full report (every
@@ -193,13 +194,8 @@ export declare function boundedTaskReportForHost(report: ReturnType<typeof readT
193
194
  };
194
195
  full_report: string;
195
196
  };
196
- /** `metaUrl` is the module running (a `.ts` source checkout needs the tsx
197
- * loader; a published `.js` build needs nothing) and `resolve` is that
198
- * module's `import.meta.resolve`. The loader is resolved ONLY on the source
199
- * path: `import.meta.resolve` throws for a package that is not installed, and
200
- * `tsx` is a devDependency absent from every published install (#261). */
201
- export declare function verificationLauncherFor(metaUrl: string, resolve: (specifier: string) => string): {
202
- argv: string[];
203
- shell: string;
204
- };
197
+ /** Re-exported so existing imports keep working; the implementation lives in
198
+ * src/core/verifyLauncher.ts because the prompt hook needs it too and a hook
199
+ * must not pull in the MCP SDK. */
200
+ export { verificationLauncherFor };
205
201
  export declare function registerTaskReportTools(server: McpServer, getRoot: () => string, getStore: () => HunchStore): void;
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { fileURLToPath, pathToFileURL } from "node:url";
2
+ import { verificationLauncherFor } from "../core/verifyLauncher.js";
3
3
  import { TaskIdSchema, ReportClaimSchema, LessonReferenceSchema, finishReportTask, listReportTasks, readTaskReport, readLessonHistory, recordReportClaim, reportPresentationEnabled, startReportTask } from "../core/taskReport.js";
4
4
  import { persistTaskRecord } from "../core/taskRecord.js";
5
5
  import { reportSourceSnapshot, runReportConformance } from "../core/taskReportEvidence.js";
@@ -49,30 +49,19 @@ export function boundedTaskReportForHost(report) {
49
49
  return { ...minimal, deliveries: [], omitted: { ...minimal.omitted, deliveries: report.deliveries.length } };
50
50
  }
51
51
  /** Reuse the MCP server's installation, not a potentially stale global binary.
52
- * Structured argv is authoritative; the shell hint uses literal quoting. */
52
+ * `src/core/` and `src/mcp/` are siblings, so the relative entry URL is the
53
+ * same from either — but each caller passes ITS OWN import.meta. */
53
54
  function verificationLauncher() {
54
55
  return verificationLauncherFor(import.meta.url, (specifier) => import.meta.resolve(specifier));
55
56
  }
56
- /** `metaUrl` is the module running (a `.ts` source checkout needs the tsx
57
- * loader; a published `.js` build needs nothing) and `resolve` is that
58
- * module's `import.meta.resolve`. The loader is resolved ONLY on the source
59
- * path: `import.meta.resolve` throws for a package that is not installed, and
60
- * `tsx` is a devDependency absent from every published install (#261). */
61
- export function verificationLauncherFor(metaUrl, resolve) {
62
- const dev = metaUrl.endsWith(".ts");
63
- const entry = fileURLToPath(new URL(`../cli/index.${dev ? "ts" : "js"}`, metaUrl));
64
- // `--import` takes a URL. Converting the resolved loader to a path made Node on
65
- // Windows reject it ("Received protocol 'c:'"), so every verification launched
66
- // from a source checkout there failed before running and cards showed no check.
67
- const loader = dev ? resolve("tsx") : null;
68
- const argv = [process.execPath, ...(loader ? ["--import", loader.startsWith("file:") ? loader : pathToFileURL(loader).href] : []), entry];
69
- const quote = (s) => process.platform === "win32" ? `'${s.replace(/'/g, "''")}'` : `'${s.replace(/'/g, "'\\''")}'`;
70
- return { argv, shell: `${process.platform === "win32" ? "& " : ""}${argv.map(quote).join(" ")}` };
71
- }
57
+ /** Re-exported so existing imports keep working; the implementation lives in
58
+ * src/core/verifyLauncher.ts because the prompt hook needs it too and a hook
59
+ * must not pull in the MCP SDK. */
60
+ export { verificationLauncherFor };
72
61
  export function registerTaskReportTools(server, getRoot, getStore) {
73
62
  server.registerTool("hunch_task", {
74
63
  title: "Start or finish a task's contribution report",
75
- description: "Start once per user task; pass the returned task_id to hunch_context. Finish before your final response and include the returned concise contribution card, without asking the user. Applications are explicitly agent-reported and must name an exact delivered occurrence and record hash. Completion never implies successful verification. Not for storing decisions or claiming tests passed; use the CLI task verify wrapper for observed command results.",
64
+ description: "Start once per user task, unless the host's prompt hook already opened the task and printed its verify command — then reuse that task_id and do not start. Pass the task_id to hunch_context. Finish before your final response and include the returned concise contribution card, without asking the user; skip finish only when the prompt hook's own instruction said this host closes the task and the task used no Hunch (no hunch_* call on this task_id, no verified check, no hook context you acted on, nothing to claim), and a task you started with this tool must always be finished. Applications are explicitly agent-reported and must name an exact delivered occurrence and record hash. Completion never implies successful verification. Not for storing decisions or claiming tests passed; use the CLI task verify wrapper for observed command results.",
76
65
  inputSchema: {
77
66
  action: z.enum(["start", "finish"]), task_id: TaskIdSchema.optional(),
78
67
  title: z.string().min(1).max(200).optional(),
@@ -99,7 +88,7 @@ export function registerTaskReportTools(server, getRoot, getStore) {
99
88
  task = readTaskReport(root, task_id, reportSourceSnapshot(root).hash).task;
100
89
  }
101
90
  const launcher = verificationLauncher();
102
- return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to every hunch_context and decision/correction/finding capture call. Before the final response, finish with hunch_task and include its contribution card. For checks use this exact installation (the global hunch binary may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]. The default budget is 15 minutes; add --timeout <seconds> before -- for a longer suite.` }], structuredContent: { task, verification_argv: [...launcher.argv, "task", "verify", task.task_id, "--"] } };
91
+ return { content: [{ type: "text", text: `Task ${task.task_id} · ${task.state}. Pass task_id to every hunch_context and decision/correction/finding capture call. Before the final response, finish with hunch_task and include its contribution card. For checks use this exact installation (the global hunch binary may be stale): ${launcher.shell} task verify ${task.task_id} -- <command> [arguments]${launcher.note}. The default budget is 15 minutes; add --timeout <seconds> before -- for a longer suite.` }], structuredContent: { task, verification_argv: [...launcher.argv, "task", "verify", task.task_id, "--"] } };
103
92
  }
104
93
  if (!task_id)
105
94
  throw new Error("finish requires the exact task_id");
@@ -128,9 +128,15 @@ export declare function compactLedger(hunchDir: string, scope: Scope, opts?: {
128
128
  /** Three-way merge of one scope's ledger, for the git merge driver: two clones that both
129
129
  * appended to the same partition. The union of events is kept (identity = what changed, to
130
130
  * which hash, when, by whom), ordered by time then ours-before-theirs, and RE-SEQUENCED from
131
- * the higher floor; every subscriber's cursor is therefore invalid after a merge and the gap
132
- * rule makes it resynchronize. Idempotency entries are unioned; a key both sides used for
133
- * different records is a conflict the caller must surface (ours is kept). */
131
+ * the higher floor. When that renumbering moves an event either side had already published,
132
+ * no pre-merge cursor of EITHER side can be trusted — a cursor could otherwise sit above an
133
+ * event that now holds a lower seq and never see it (issue #285). The floor is then raised
134
+ * past BOTH pre-merge heads, so every such cursor satisfies subscribe's `after_seq <
135
+ * floor_seq` and resynchronizes from the floor; a cursor equal to the larger head is exactly
136
+ * the failing case, hence the `+ 1`. A merge that renumbers nothing keeps today's seqs (no
137
+ * cursor could have seen a different numbering) and the floor stays put. Idempotency entries
138
+ * are unioned; a key both sides used for different records is a conflict the caller must
139
+ * surface (ours is kept). */
134
140
  export declare function mergeLedgers(base: Ledger | null, ours: Ledger, theirs: Ledger): {
135
141
  ledger: Ledger;
136
142
  conflicts: string[];