@davesheffer/hunch 1.39.2 → 1.39.3

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 (46) hide show
  1. package/dist/cli/index.js +97 -50
  2. package/dist/cli/serve.d.ts +14 -0
  3. package/dist/cli/serve.js +18 -4
  4. package/dist/core/correction.js +12 -7
  5. package/dist/core/docanchors.js +16 -2
  6. package/dist/core/glob.d.ts +19 -0
  7. package/dist/core/glob.js +37 -0
  8. package/dist/core/paths.d.ts +27 -5
  9. package/dist/core/paths.js +51 -6
  10. package/dist/core/procstart.d.ts +94 -0
  11. package/dist/core/procstart.js +244 -0
  12. package/dist/core/served.d.ts +6 -3
  13. package/dist/core/served.js +10 -6
  14. package/dist/core/taskReport.d.ts +2 -0
  15. package/dist/core/taskReport.js +46 -11
  16. package/dist/core/taskReportEvidence.js +17 -1
  17. package/dist/core/taskReportHook.js +14 -2
  18. package/dist/core/workspace.d.ts +9 -0
  19. package/dist/core/workspace.js +15 -0
  20. package/dist/extractors/git.d.ts +5 -0
  21. package/dist/extractors/git.js +87 -10
  22. package/dist/extractors/indexer.d.ts +10 -0
  23. package/dist/extractors/indexer.js +17 -1
  24. package/dist/extractors/k8sManifest.js +104 -16
  25. package/dist/integrations/hookmatch.d.ts +25 -0
  26. package/dist/integrations/hookmatch.js +58 -0
  27. package/dist/integrations/hooks.d.ts +38 -7
  28. package/dist/integrations/hooks.js +250 -24
  29. package/dist/integrations/providers.js +2 -17
  30. package/dist/integrations/scaffold.d.ts +1 -1
  31. package/dist/integrations/scaffold.js +24 -21
  32. package/dist/integrations/workspaceLedger.d.ts +10 -0
  33. package/dist/integrations/workspaceLedger.js +9 -1
  34. package/dist/mcp/server.d.ts +10 -0
  35. package/dist/mcp/server.js +21 -13
  36. package/dist/serve/writelock.d.ts +21 -1
  37. package/dist/serve/writelock.js +157 -16
  38. package/dist/store/hunchStore.js +41 -11
  39. package/dist/store/jsonStore.d.ts +6 -4
  40. package/dist/store/jsonStore.js +225 -32
  41. package/dist/store/merge.d.ts +7 -4
  42. package/dist/store/merge.js +65 -5
  43. package/dist/store/privateMigrate.js +10 -0
  44. package/dist/wiki/adopt.js +13 -2
  45. package/package.json +1 -1
  46. package/server.json +2 -2
@@ -16,6 +16,7 @@
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 { homedir } from "node:os";
19
20
  import { hooksDir, gitCommonDir } from "../extractors/git.js";
20
21
  import { initiatorChildEnv } from "../synthesis/initiator.js";
21
22
  import { HUNCH_NPX_PACKAGE_SPEC } from "../core/version.js";
@@ -213,29 +214,222 @@ function preCommitId(mark) {
213
214
  [CHECKOUT_MARK]: "hunch-post-checkout",
214
215
  }[mark] ?? "hunch";
215
216
  }
216
- /** Whether our block for `mark` is present where git will actually run it. */
217
- function blockState(t, mark) {
217
+ const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=\S*$/;
218
+ const UNQUOTED_META = /[;|&$`<>()]/;
219
+ const LAUNCHER_EXT = /\.(?:exe|cmd|bat|ps1)$/;
220
+ const HUNCH_ENTRY = /\/cli\/index\.(?:js|ts)$/;
221
+ /** Split an invocation into simple shell words. Hunch writes its own paths with
222
+ * JSON.stringify, so a double-quoted token decodes with JSON.parse. Anything a
223
+ * shell would treat as more than a plain command (metacharacters, expansions,
224
+ * an unterminated quote) yields null — we refuse to guess. */
225
+ function shellWords(s) {
226
+ const out = [];
227
+ let i = 0;
228
+ while (i < s.length) {
229
+ const c = s[i];
230
+ if (/\s/.test(c)) {
231
+ i++;
232
+ continue;
233
+ }
234
+ if (c === '"' || c === "'") {
235
+ const end = s.indexOf(c, i + 1);
236
+ if (end < 0)
237
+ return null;
238
+ const raw = s.slice(i, end + 1);
239
+ if (c === '"') {
240
+ if (/[$`]/.test(raw))
241
+ return null;
242
+ try {
243
+ out.push(JSON.parse(raw));
244
+ }
245
+ catch {
246
+ return null;
247
+ }
248
+ }
249
+ else
250
+ out.push(raw.slice(1, -1));
251
+ i = end + 1;
252
+ continue;
253
+ }
254
+ let j = i;
255
+ while (j < s.length && !/\s/.test(s[j]))
256
+ j++;
257
+ const word = s.slice(i, j);
258
+ if (UNQUOTED_META.test(word))
259
+ return null;
260
+ out.push(word);
261
+ i = j;
262
+ }
263
+ return out;
264
+ }
265
+ /** The launcher's own name: last path segment, lower-cased, without a Windows
266
+ * executable extension — so `/usr/local/bin/hunch`, `hunch.cmd` and `hunch` all match. */
267
+ function base(tok) {
268
+ return (tok.split(/[\\/]/).at(-1) ?? "").toLowerCase().replace(LAUNCHER_EXT, "");
269
+ }
270
+ function isAbs(tok) {
271
+ return isAbsolute(tok) || /^[A-Za-z]:[\\/]/.test(tok);
272
+ }
273
+ function isPathish(tok) {
274
+ return tok.includes("/") || tok.includes("\\") || isAbs(tok);
275
+ }
276
+ function pathExists(tok, root) {
277
+ // A hand-written hook may spell this machine's install as `~/…`; the shell
278
+ // expands it, so we must too before asking the filesystem.
279
+ const path = tok === "~" ? homedir() : tok.startsWith("~/") ? join(homedir(), tok.slice(2)) : tok;
280
+ return existsSync(isAbs(path) ? path : resolve(root, path));
281
+ }
282
+ const NOT_OURS = "not a command Hunch writes";
283
+ /** Whether the command in front of a managed block's subcommand is a Hunch
284
+ * launcher that could actually run (issue #315): a block whose command was
285
+ * hand-edited into something else, or whose absolute node/CLI path no longer
286
+ * exists after a reinstall, is present but dead. `command` is the text BEFORE
287
+ * the subcommand; env assignments and a leading `(` belong to the block's own
288
+ * shape and are stripped. Never spawns anything, never touches the network. */
289
+ export function hookInvocationHealth(command, root) {
290
+ let rest = command.trim().replace(/^\(\s*/, "");
291
+ for (;;) {
292
+ const m = /^(\S+)\s+/.exec(rest);
293
+ if (!m || !ENV_ASSIGN.test(m[1]))
294
+ break;
295
+ rest = rest.slice(m[0].length);
296
+ }
297
+ const invocation = rest.trim();
298
+ const bad = (reason) => ({ ok: false, invocation, reason });
299
+ const toks = shellWords(invocation);
300
+ if (toks === null || toks.length === 0)
301
+ return bad(NOT_OURS);
302
+ const [head, ...rest2] = toks;
303
+ const missing = (tok) => `${tok} does not exist`;
304
+ // `<runtime> <script…>`: every non-flag token after the runtime is a script
305
+ // path (`<node> <cli>`, or tsx's `<node> <tsx> <cli>`) — a runtime flag like
306
+ // `--no-warnings` is not a path, but a runtime with no script at all is not ours.
307
+ const runtime = () => {
308
+ if (isPathish(head) && !pathExists(head, root))
309
+ return bad(missing(head));
310
+ const scripts = rest2.filter((t) => !t.startsWith("-"));
311
+ if (scripts.length === 0)
312
+ return bad(NOT_OURS);
313
+ const gone = scripts.find((t) => !pathExists(t, root));
314
+ return gone === undefined ? { ok: true, invocation } : bad(missing(gone));
315
+ };
316
+ switch (base(head)) {
317
+ case "hunch":
318
+ if (rest2.length > 0)
319
+ return bad(NOT_OURS);
320
+ if (isPathish(head) && !pathExists(head, root))
321
+ return bad(missing(head));
322
+ return { ok: true, invocation };
323
+ case "node":
324
+ return rest2.length === 0 ? bad(NOT_OURS) : runtime();
325
+ case "npx":
326
+ case "pnpm":
327
+ case "yarn":
328
+ case "bunx":
329
+ case "npm":
330
+ case "env": {
331
+ // A package runner in front of the launcher: `npx -y --package=…@x.y.z hunch`
332
+ // (PORTABLE_HOOK_INVOCATION), `npx hunch`, and the shapes users adapt the
333
+ // snippet into by hand (`pnpm exec hunch`, `yarn run hunch`, `npm exec --
334
+ // hunch`, `env X=1 hunch`). What the runner would resolve is never verified —
335
+ // doing so would mean a network install — so only the launcher it ends in is read.
336
+ const args = rest2.filter((t) => !t.startsWith("-"));
337
+ const last = args.at(-1);
338
+ if (last !== undefined && (base(last) === "hunch" || /^@davesheffer\/hunch(?:@\S+)?$/.test(last)))
339
+ return { ok: true, invocation };
340
+ const tsx = args.indexOf("tsx");
341
+ if (tsx >= 0 && args.length === tsx + 2) {
342
+ return pathExists(args[tsx + 1], root) ? { ok: true, invocation } : bad(missing(args[tsx + 1]));
343
+ }
344
+ return bad(NOT_OURS);
345
+ }
346
+ default:
347
+ // `resolveInvocation` writes process.execPath, which is not always named
348
+ // `node` (Debian's `nodejs`, a version-suffixed binary). Any other runtime
349
+ // is ours only as an absolute path running Hunch's own CLI entry — else
350
+ // `hunch init` would rewrite the same block and doctor would call it stale forever.
351
+ return isAbs(head) && rest2.length > 0 && HUNCH_ENTRY.test(rest2.at(-1).replace(/\\/g, "/")) ? runtime() : bad(NOT_OURS);
352
+ }
353
+ }
354
+ /** The command each managed block runs, keyed by mark. Resolved inside a
355
+ * function (like `preCommitId`) because the marks are declared further down. */
356
+ function blockSubcommand(mark) {
357
+ return {
358
+ [MARK]: { re: /(?:^|\s)sync\s+--from-hook\b/, words: "sync --from-hook" },
359
+ [PRE_MARK]: { re: /(?:^|\s)check\s+--staged\b/, words: "check --staged" },
360
+ [GROUNDING_MERGE_MARK]: { re: /(?:^|\s)grounding\s+--refresh\b/, words: "grounding --refresh" },
361
+ [REPAIR_MERGE_MARK]: { re: /(?:^|\s)repair-provenance\b/, words: "repair-provenance" },
362
+ [CHECKOUT_MARK]: { re: /(?:^|\s)workspaces\s+snapshot\b/, words: "workspaces snapshot" },
363
+ }[mark] ?? { re: /(?:^|\s)hunch\b/, words: "hunch" };
364
+ }
365
+ /** Hunch's OWN option flags a block's command line may carry after the
366
+ * subcommand — the ones `hunch init` decides from its options, so a regenerated
367
+ * block silently drops any the re-run does not ask for again. Fixed set, in a
368
+ * stable order; anything else on the line is not ours to report. */
369
+ const BLOCK_FLAGS = ["--private", "--commit", "--strict"];
370
+ function blockFlags(line) {
371
+ return BLOCK_FLAGS.filter((f) => new RegExp(`(?:^|\\s)${escapeRe(f)}(?=\\s|$)`).test(line));
372
+ }
373
+ /** Inspect the command inside `mark`'s block in `file`: the block body is the
374
+ * lines between the mark and the closing marker. A block may legitimately carry
375
+ * several command lines (or a hand-added one); any working line makes it live. */
376
+ function inspectBlock(file, mark, root) {
377
+ const { re, words } = blockSubcommand(mark);
378
+ const text = readText(file) ?? "";
379
+ const at = text.indexOf(mark);
380
+ const body = at < 0 ? [] : text.slice(at).replace(/\r/g, "").split("\n").slice(1);
381
+ let first = null;
382
+ for (const line of body) {
383
+ if (/^# <<< hunch /.test(line))
384
+ break;
385
+ if (line.trim().startsWith("#"))
386
+ continue;
387
+ const m = re.exec(line);
388
+ if (!m)
389
+ continue;
390
+ // Flags belong to the SUBCOMMAND, so they are read from the text after it.
391
+ const health = { ...hookInvocationHealth(line.slice(0, m.index), root), flags: blockFlags(line.slice(m.index)) };
392
+ if (health.ok)
393
+ return health;
394
+ first ??= health;
395
+ }
396
+ return first ?? { ok: false, invocation: "", reason: `the block has no \`hunch ${words}\` command`, flags: [] };
397
+ }
398
+ /** Whether our block for `mark` is present where git will actually run it AND
399
+ * its command is a Hunch launcher that exists (issue #315 — a marker alone
400
+ * proves nothing about what the block does). */
401
+ function blockInfo(t, mark, root) {
218
402
  const inHook = markerState(t.hookPath, mark);
403
+ // A reachable marker only earns "installed" once its command checks out.
404
+ const live = (file) => {
405
+ const health = inspectBlock(file, mark, root);
406
+ return health.ok
407
+ ? { state: "installed", invocation: health.invocation, flags: health.flags, file }
408
+ : { state: "stale", invocation: health.invocation, staleReason: health.reason, flags: health.flags, file };
409
+ };
219
410
  switch (t.manager) {
220
411
  case "none":
221
- return inHook === "reachable" ? "installed" : inHook === "unreachable" ? "unreachable" : "missing";
412
+ return inHook === "reachable" ? live(t.hookPath) : { state: inHook === "unreachable" ? "unreachable" : "missing", file: t.hookPath };
222
413
  case "husky":
223
414
  if (markerState(t.ownerFile, mark) === "reachable")
224
- return "installed";
415
+ return live(t.ownerFile);
225
416
  if (markerState(t.ownerFile, mark) === "unreachable" || inHook !== "absent")
226
- return "unreachable";
227
- return "missing";
417
+ return { state: "unreachable", file: t.hookPath };
418
+ return { state: "missing", file: t.ownerFile };
228
419
  case "pre-commit": {
229
420
  const config = readText(t.ownerFile);
421
+ // The framework's own config carries the command in its own format; the
422
+ // `id:` is the contract, so it is taken at face value.
230
423
  if (config !== null && new RegExp(`\\bid:\\s*["']?${escapeRe(preCommitId(mark))}["']?\\s*$`, "m").test(config))
231
- return "installed";
424
+ return { state: "installed", file: t.ownerFile };
232
425
  // 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";
426
+ const legacy = `${t.hookPath}.legacy`;
427
+ if (markerState(legacy, mark) === "reachable")
428
+ return live(legacy);
429
+ return inHook !== "absent" ? { state: "unreachable", file: t.hookPath } : { state: "missing", file: t.ownerFile };
236
430
  }
237
431
  default: // husky-legacy, tracked-hooks-path, exec-exit: the hook file itself
238
- return inHook === "reachable" ? "installed" : inHook === "unreachable" ? "unreachable" : "missing";
432
+ return inHook === "reachable" ? live(t.hookPath) : { state: inHook === "unreachable" ? "unreachable" : "missing", file: t.hookPath };
239
433
  }
240
434
  }
241
435
  function stripMarkers(blk) {
@@ -276,14 +470,21 @@ function snippetFor(t, mark, build, localInvocation) {
276
470
  function installManagedBlock(root, hookName, mark, end, build, invocation) {
277
471
  const t = resolveHookTarget(root, hookName, mark);
278
472
  if (t.manager !== "none") {
279
- if (blockState(t, mark) === "installed")
473
+ const info = blockInfo(t, mark, root);
474
+ if (info.state === "installed")
280
475
  return { path: t.ownerFile, action: "unchanged", manager: t.manager };
281
476
  return {
282
477
  path: t.ownerFile,
283
478
  action: t.manager === "exec-exit" ? "unreachable" : "managed-elsewhere",
284
479
  manager: t.manager,
285
- reason: t.reason,
480
+ // A stale block IS reachable — the manager's file already carries one, it
481
+ // just cannot run. Saying "a hook manager owns this hook" would send the
482
+ // user looking for a block that is right there (issue #315).
483
+ reason: info.state === "stale"
484
+ ? `the Hunch block in ${basename(info.file)} is stale (${info.staleReason ?? "its command is not a Hunch launcher"}) — replace it with the snippet below`
485
+ : t.reason,
286
486
  snippet: snippetFor(t, mark, build, invocation),
487
+ ...(info.state === "stale" ? { stale: true } : {}),
287
488
  };
288
489
  }
289
490
  const blk = build(invocation, GIT_CONTEXT);
@@ -422,23 +623,34 @@ export function installPostCheckoutHook(root, invocation) {
422
623
  function reportEntry(root, hookName, marks) {
423
624
  const states = marks.map((mark) => {
424
625
  const t = resolveHookTarget(root, hookName, mark);
425
- return { t, state: blockState(t, mark) };
626
+ return { t, ...blockInfo(t, mark, root) };
426
627
  });
427
628
  const unreachable = states.find((s) => s.state === "unreachable");
428
- const pick = unreachable ?? states.find((s) => s.state === "missing") ?? states[0];
629
+ const stale = states.find((s) => s.state === "stale");
630
+ // A dead block outranks a stale one, which outranks a missing one: each is a
631
+ // stronger statement about why this hook is not doing its job.
632
+ const pick = unreachable ?? stale ?? states.find((s) => s.state === "missing") ?? states[0];
429
633
  if (!pick)
430
634
  throw new Error("reportEntry needs at least one marker");
431
- const state = unreachable ? "unreachable" : states.every((s) => s.state === "installed") ? "installed" : "missing";
635
+ const state = unreachable ? "unreachable" : stale ? "stale" : states.every((s) => s.state === "installed") ? "installed" : "missing";
636
+ const invocation = [...new Set(states.map((s) => s.invocation).filter((i) => !!i))].join(" · ");
637
+ // Union across the hook's marks, in BLOCK_FLAGS order — a hook is re-installed
638
+ // as a whole, so a flag on any of its blocks is one a bare re-run would drop.
639
+ const flags = BLOCK_FLAGS.filter((f) => states.some((s) => s.flags?.includes(f)));
432
640
  return {
433
641
  state,
434
642
  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 } : {}),
643
+ path: state === "unreachable" && pick.t.manager !== "husky" ? pick.t.hookPath : state === "stale" ? pick.file : pick.t.ownerFile,
644
+ ...(state === "unreachable" ? { reason: pick.t.reason } : state === "stale" ? { reason: pick.staleReason } : {}),
645
+ ...(invocation ? { invocation } : {}),
646
+ ...(flags.length ? { flags } : {}),
437
647
  };
438
648
  }
439
649
  /** 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
650
+ * `installed` (present where git will run it, running a Hunch launcher that
651
+ * exists), `unreachable` (present, but after an exec/exit or in a
652
+ * manager-owned file git never reaches), `stale` (present where git runs it,
653
+ * but its command is not a working Hunch launcher — issue #315), or
442
654
  * `missing`. postMerge requires BOTH halves (grounding-refresh and
443
655
  * repair-provenance) — a repo carrying only one is a partial install, same as
444
656
  * `installPostMergeHook` self-healing it. Never writes anything. */
@@ -451,9 +663,9 @@ export function hookReport(root) {
451
663
  };
452
664
  }
453
665
  /** 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. */
666
+ * managed block is present where git will actually run it AND the command
667
+ * inside it is a Hunch launcher that exists. An unreachable block (issue #311)
668
+ * and a stale one (issue #315) are both NOT installed. */
457
669
  export function hookStatus(root) {
458
670
  const r = hookReport(root);
459
671
  return {
@@ -463,6 +675,18 @@ export function hookStatus(root) {
463
675
  postCheckout: r.postCheckout.state === "installed",
464
676
  };
465
677
  }
678
+ /** Doctor lines for where each installed/stale hook points (issue #315): hooks
679
+ * sharing one invocation share one line, and a line whose invocation is not the
680
+ * Hunch currently running says so. Informational — a hook may legitimately
681
+ * point at a different install. Pure; the caller adds indentation and dim(). */
682
+ export function hookInvocationLines(report, running) {
683
+ const named = [["post-commit", report.postCommit], ["post-merge", report.postMerge], ["pre-commit", report.preCommit], ["post-checkout", report.postCheckout]]
684
+ .filter(([, e]) => e.invocation && (e.state === "installed" || e.state === "stale"));
685
+ const grouped = new Map();
686
+ for (const [name, e] of named)
687
+ grouped.set(e.invocation, [...(grouped.get(e.invocation) ?? []), name]);
688
+ return [...grouped].map(([inv, names]) => `${names.join(", ")} → ${inv}${running && inv !== running ? ` (differs from the running Hunch: ${running})` : ""}`);
689
+ }
466
690
  /** CLI lines for one install result: the usual ✓ line when the block is in a
467
691
  * file git runs, otherwise a warning with the reason and the snippet to add to
468
692
  * the manager's own file. */
@@ -472,7 +696,9 @@ export function formatHookInstall(root, label, h, detail = "") {
472
696
  const shown = isInside(root, h.path) ? relative(realish(root), realish(h.path)).replace(/\\/g, "/") : h.path;
473
697
  return [
474
698
  ` ⚠ ${label} NOT installed — ${h.reason ?? "a hook manager owns this hook"}`,
475
- ` add this to ${shown} yourself:`,
699
+ // A stale block is already there and the reason said to replace it; anything
700
+ // else is missing and has to be added.
701
+ ` ${h.stale ? "replace the Hunch block in" : "add this to"} ${shown} yourself:`,
476
702
  ...(h.snippet ?? "").split("\n").map((l) => ` ${l}`),
477
703
  ];
478
704
  }
@@ -21,6 +21,7 @@ import { readFileSync, existsSync, mkdirSync } from "node:fs";
21
21
  import { writeFileAtomic } from "../core/io.js";
22
22
  import { homedir } from "node:os";
23
23
  import { join, dirname } from "node:path";
24
+ import { isHunchHookCommand } from "./hookmatch.js";
24
25
  import { renderHunchSection, stripManagedSection, upsertSection, updateClaudeMd } from "./claudemd.js";
25
26
  import { headFileContent, isGitCleanPath } from "../extractors/git.js";
26
27
  import { parseJsonc } from "../core/jsonc.js";
@@ -106,23 +107,7 @@ function isHunchProviderHook(entry) {
106
107
  // whose every nested hook is ours is ours; a mixed entry stays foreign.
107
108
  if (e && Array.isArray(e.hooks) && e.hooks.length && typeof e.command !== "string")
108
109
  return e.hooks.every(isHunchProviderHook);
109
- const command = typeof e?.command === "string" ? e.command : "";
110
- // Anchored to the shapes hookCommand() writes — a Hunch launcher (the pinned
111
- // npm package spec, or a …/index.js|ts path for source installs) plus a tail
112
- // of `hook --provider <name>`, tokens quoted or bare. The old unanchored
113
- // /index\.(js|ts)/ + /\bhook\b/ pair classified FOREIGN entries like
114
- // `node ./hook/index.js` as ours and silently deleted them, violating the
115
- // leave-every-foreign-hook-in-place contract (con_8460b6770f, issue #41), so
116
- // the bare tail must still carry --provider to match; only the LEGACY
117
- // fully-quoted form (written before this quoting fix, and by hunch versions
118
- // that predate --provider) may omit it, and its quotes keep it unambiguous.
119
- // Source-checkout invocations generated by resolveInvocation point at the
120
- // CLI entry specifically. A generic `other-tool/index.js hook` is foreign
121
- // even when it happens to use Hunch's old command tail.
122
- const launcher = /@davesheffer\/hunch|(?:dist|src)[\\/]+cli[\\/]+index\.(?:js|ts)(?=["\s]|$)/.test(command);
123
- const legacyTail = /\s"hook"(?:\s+"--provider"\s+"[a-z]+")?\s*$/.test(command);
124
- const tail = /\s"?hook"?\s+"?--provider"?\s+"?[a-z]+"?\s*$/.test(command);
125
- return launcher && (legacyTail || tail);
110
+ return isHunchHookCommand(typeof e?.command === "string" ? e.command : "", true);
126
111
  }
127
112
  /** Merge our command entries into a standard `{ hooks: { Event: [] } }` file.
128
113
  * We replace only old Hunch commands and leave every foreign hook in place. */
@@ -19,7 +19,7 @@ export interface ClaudeHookInstall {
19
19
  * - UserPromptSubmit → remind the agent to consult Hunch.
20
20
  * Both invoke `hunch hook`, which reads the firmness level from .hunch/config.json
21
21
  * at run time — so changing firmness needs no settings.json edit. We own only our
22
- * entries (matched by isHunchHook): other hooks and settings are preserved, and a
22
+ * commands (matched by isHunchHookCommand): other hooks and settings are preserved, and a
23
23
  * non-empty file we cannot parse THROWS rather than clobbering the user's config.
24
24
  */
25
25
  export declare function installClaudeHooks(root: string, hookCmd: string): ClaudeHookInstall;
@@ -6,6 +6,7 @@
6
6
  import { readFileSync, existsSync, mkdirSync } from "node:fs";
7
7
  import { writeFileAtomic } from "../core/io.js";
8
8
  import { join, dirname } from "node:path";
9
+ import { isHunchHookCommand } from "./hookmatch.js";
9
10
  /** Merge a `hunch` server entry into .mcp.json, preserving other servers.
10
11
  * A non-empty file we cannot parse THROWS instead of being silently replaced
11
12
  * (con_8460b6770f — it may hold the user's other MCP servers); the caller
@@ -115,25 +116,27 @@ Reconcile decision-grounding drift for **$ARGUMENTS** (or the whole repo).
115
116
  3. Only if I explicitly say "the DECISION is stale, not the doc" (Heal B): run /capture to record a superseding decision, then return to step 2 — the prose re-derives from the new decision as a separate confirm.
116
117
  4. Report: healed (Heal A), superseded (Heal B), skipped. Never touch the graph except via an explicit Heal B capture.
117
118
  `;
118
- /** A settings.json hook entry is Hunch's if any of its commands is either the
119
- * native/source CLI entry (`…/index.js hook`) or the exact published-package
120
- * launcher written by older Hunch versions (`npx --package=…@davesheffer/hunch…
121
- * hunch hook`). Matching both generations makes an upgrade idempotent instead
122
- * of leaving the portable old hook alongside the new native invocation. The
123
- * source form still requires `index` to be a full path segment, and the npx form
124
- * requires both the scoped package and the `hunch hook` tail, so foreign hooks
125
- * are preserved. */
126
- function isHunchHook(entry) {
127
- return !!entry.hooks?.some((h) => {
128
- if (typeof h.command !== "string")
129
- return false;
130
- const command = h.command;
131
- const nativeOrSource = /(?:dist|src)[\\/]+cli[\\/]+index\.(js|ts)"?\s+hook\s*$/.test(command);
132
- const publishedNpx = /^\s*"?npx(?:\.cmd)?"?\s+/i.test(command)
133
- && /--package=(?:hunch-exact@npm:)?@davesheffer\/hunch(?:@[^"\s]+)?/.test(command)
134
- && /\s"?hunch"?\s+"?hook"?\s*$/.test(command);
135
- return nativeOrSource || publishedNpx;
136
- });
119
+ /** Strip Hunch's own commands out of one settings.json hook entry, matching with
120
+ * the SAME anchored rule the provider writers use (isHunchHookCommand, issue
121
+ * #41) so an unrelated tool that merely shares our layout — `node
122
+ * tools/lint/dist/cli/index.js hook` — is never classified as ours. Claude
123
+ * Code's hooks carry no `--provider`, hence the bare-tail variant.
124
+ *
125
+ * Filtering per COMMAND rather than per entry is what keeps a MIXED entry (our
126
+ * hook and the user's own command side by side) intact: the entry survives with
127
+ * its matcher and the user's remaining commands in order, and is dropped only
128
+ * when nothing of the user's is left. Dropping the whole entry deleted user
129
+ * hooks (con_8460b6770f, issue #310). */
130
+ function withoutHunchCommands(entry, hookCmd) {
131
+ const hooks = entry.hooks;
132
+ if (!Array.isArray(hooks))
133
+ return entry;
134
+ // The command being installed is ours by definition, whatever shape a future
135
+ // launcher takes — so a re-run stays idempotent even if the matcher lags it.
136
+ const kept = hooks.filter((h) => !(typeof h?.command === "string" && (h.command === hookCmd || isHunchHookCommand(h.command, false))));
137
+ if (kept.length === hooks.length)
138
+ return entry;
139
+ return kept.length ? { ...entry, hooks: kept } : null;
137
140
  }
138
141
  /**
139
142
  * Install the Claude Code AGENT hooks into `.claude/settings.json` so the agent
@@ -143,7 +146,7 @@ function isHunchHook(entry) {
143
146
  * - UserPromptSubmit → remind the agent to consult Hunch.
144
147
  * Both invoke `hunch hook`, which reads the firmness level from .hunch/config.json
145
148
  * at run time — so changing firmness needs no settings.json edit. We own only our
146
- * entries (matched by isHunchHook): other hooks and settings are preserved, and a
149
+ * commands (matched by isHunchHookCommand): other hooks and settings are preserved, and a
147
150
  * non-empty file we cannot parse THROWS rather than clobbering the user's config.
148
151
  */
149
152
  export function installClaudeHooks(root, hookCmd) {
@@ -176,7 +179,7 @@ export function installClaudeHooks(root, hookCmd) {
176
179
  throw new Error(`refusing to edit ${file}: hooks.${event} must be an array when present; fix it, then re-run.`);
177
180
  }
178
181
  }
179
- const keep = (arr) => (Array.isArray(arr) ? arr.filter((e) => !isHunchHook(e)) : []);
182
+ const keep = (arr) => (Array.isArray(arr) ? arr.map((entry) => withoutHunchCommands(entry, hookCmd)).filter((e) => e !== null) : []);
180
183
  json.hooks.PreToolUse = [
181
184
  ...keep(json.hooks.PreToolUse),
182
185
  { matcher: "Edit|Write|MultiEdit", hooks: [{ type: "command", command: hookCmd }] },
@@ -33,6 +33,16 @@ export type SnapshotOutcome = {
33
33
  status: "collision";
34
34
  record: Workspace;
35
35
  reason: string;
36
+ }
37
+ /** A PUBLIC-home snapshot is a commit on the checked-out code branch. While git is replaying
38
+ * history (rebase / merge / cherry-pick / revert / bisect) or HEAD is detached, that write
39
+ * would land in the middle of the operation — an untracked `ws_*.json` that makes
40
+ * `git rebase --continue` abort, or a commit no branch owns. Nothing is lost: the next
41
+ * branch checkout or ledger read records this machine. */
42
+ | {
43
+ status: "deferred";
44
+ record: Workspace;
45
+ reason: "git-operation-in-progress" | "detached-head";
36
46
  } | {
37
47
  status: "written";
38
48
  record: Workspace;
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { execFileSync, spawnSync } from "node:child_process";
13
13
  import { createInterface } from "node:readline";
14
- import { foreignRepoEnv, mainWorktreeRoot } from "../extractors/git.js";
14
+ import { foreignRepoEnv, gitHeadUnsettled, mainWorktreeRoot } from "../extractors/git.js";
15
15
  import { hunchPaths } from "../core/paths.js";
16
16
  import { readConfig, workspacesConfig } from "../core/config.js";
17
17
  import { loadOrCreateMachine } from "../core/machine.js";
@@ -47,6 +47,14 @@ export function recordWorkspaceSnapshot(store, root, opts = {}) {
47
47
  if (previous && Date.now() - Date.parse(previous.observed_at) < 86_400_000 && sameWorkspaceContent(previous, record)) {
48
48
  return { status: "unchanged", record, previous };
49
49
  }
50
+ // Checked as late as possible (the live snapshot above takes time, and a rebase can start or
51
+ // finish while it runs). A private overlay is its own repository, whose commits never touch
52
+ // the code branch, so only a PUBLIC home defers.
53
+ if (!isPrivate) {
54
+ const unsettled = gitHeadUnsettled(root);
55
+ if (unsettled)
56
+ return { status: "deferred", record, reason: unsettled };
57
+ }
50
58
  try {
51
59
  store.putCapture("workspaces", record, isPrivate);
52
60
  }
@@ -7,7 +7,17 @@
7
7
  * diagnostics go to stderr.
8
8
  */
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { HunchStore } from "../store/hunchStore.js";
11
+ import type { Symbol } from "../core/types.js";
10
12
  export declare const publicationVocabulary: (hunchDir: string) => RegExp[];
13
+ /** Resolve a free-form target (symbol id / name / file path) to symbol records.
14
+ * Tiered exact-id > exact-name > exact-file > segment-anchored-suffix matching,
15
+ * shared with `HunchStore.why()` via `matchSymbolsTiered` so the two don't drift
16
+ * apart on the same question. */
17
+ export declare function resolveSymbols(store: HunchStore, target: string): Symbol[];
18
+ /** Resolve a target to canonical indexed file path(s) (for file-granular blast
19
+ * radius). Falls back to the literal target so direct-scope checks still run. */
20
+ export declare function resolveFiles(store: HunchStore, target: string): string[];
11
21
  export type RootControlledServer = {
12
22
  server: McpServer;
13
23
  getRoot: () => string;
@@ -12,7 +12,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
12
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
13
  import { RootsListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
14
14
  import { z } from "zod";
15
- import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
15
+ import { hunchPaths, findRoot, toPosixTarget, repoRelativeTarget } from "../core/paths.js";
16
+ import { isIndexedPath, matchSymbolsTiered } from "../core/glob.js";
16
17
  import { resolveMcpToolset } from "./toolset.js";
17
18
  import { readConfig } from "../core/config.js";
18
19
  import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
@@ -454,23 +455,30 @@ function qualityNudge(rec) {
454
455
  }
455
456
  return "";
456
457
  }
457
- /** Resolve a free-form target (symbol id / name / file path) to symbol records. */
458
- function resolveSymbols(store, target) {
459
- target = toPosixTarget(target);
458
+ /** Resolve a free-form target (symbol id / name / file path) to symbol records.
459
+ * Tiered exact-id > exact-name > exact-file > segment-anchored-suffix matching,
460
+ * shared with `HunchStore.why()` via `matchSymbolsTiered` so the two don't drift
461
+ * apart on the same question. */
462
+ export function resolveSymbols(store, target) {
463
+ // An absolute target (an agent's edit-payload path, verbatim) never matched
464
+ // the repo-relative stored file paths below (issue #296).
465
+ target = repoRelativeTarget(target, store.publicRoot);
460
466
  const syms = store.json.loadAll("symbols");
461
- const byId = syms.find((s) => s.id === target);
462
- if (byId)
463
- return [byId];
464
- const byName = syms.filter((s) => s.name === target);
465
- if (byName.length)
466
- return byName;
467
- return syms.filter((s) => s.file === target || s.file.endsWith(target));
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));
475
+ return matchSymbolsTiered(target, syms, indexed);
468
476
  }
469
477
  /** Resolve a target to canonical indexed file path(s) (for file-granular blast
470
478
  * radius). Falls back to the literal target so direct-scope checks still run. */
471
- function resolveFiles(store, target) {
479
+ export function resolveFiles(store, target) {
472
480
  const files = new Set(resolveSymbols(store, target).map((s) => s.file));
473
- return files.size ? [...files] : [toPosixTarget(target)];
481
+ return files.size ? [...files] : [repoRelativeTarget(target, store.publicRoot)];
474
482
  }
475
483
  function pullBackoff(status, finishedAt, failures) {
476
484
  if (status === "updated" || status === "current") {
@@ -3,11 +3,25 @@
3
3
  export declare const STALE_AFTER_MS = 60000;
4
4
  /** Longest a writer waits before giving up and telling the caller. */
5
5
  export declare const ACQUIRE_TIMEOUT_MS = 10000;
6
+ /** Longest a takeover claim may sit before another contender may clear it. The
7
+ * claimed section is a few syscalls, so anything this old means its claimer
8
+ * crashed inside it. */
9
+ export declare const CLAIM_STALE_MS = 10000;
6
10
  export interface LockOwner {
7
11
  pid: number;
8
12
  host: string;
9
13
  nonce: string;
10
14
  at: string;
15
+ start?: string;
16
+ }
17
+ /** Token seam: the caller's own process-instance token and a probe for another
18
+ * pid's, plus the platform the decision table should reason about (the own-pid
19
+ * fallback differs on linux, where pid namespaces exist). Injected by tests so
20
+ * the identity paths are exercised off linux. */
21
+ export interface StartTokenProbe {
22
+ self: string | null;
23
+ of: (pid: number) => string | null;
24
+ platform?: NodeJS.Platform;
11
25
  }
12
26
  export declare class WriteLockTimeout extends Error {
13
27
  readonly path: string;
@@ -16,7 +30,13 @@ export declare class WriteLockTimeout extends Error {
16
30
  constructor(path: string, heldBy: LockOwner | undefined, waitedMs: number);
17
31
  }
18
32
  export declare function writeLockPath(hunchDir: string): string;
19
- /** Hold the partition's lock across `fn` (sync or async); released even if it throws. */
33
+ /** Hold the partition's lock across `fn` (sync or async); released even if it throws.
34
+ *
35
+ * `opts.startToken` is an INTERNAL TEST SEAM, not API: it lets the test suite
36
+ * drive the process-identity rows of the decision table on a platform that has
37
+ * no such identity. Callers outside this repo must leave it unset — the real
38
+ * probe (`selfStartToken`/`startTokenOf`) is the only sound one. */
20
39
  export declare function withWriteLock<T>(hunchDir: string, fn: () => T | Promise<T>, opts?: {
21
40
  timeoutMs?: number;
41
+ startToken?: StartTokenProbe;
22
42
  }): Promise<T>;