@davesheffer/hunch 1.45.0 → 1.45.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.
@@ -61,6 +61,11 @@ export declare const PORTABLE_HOOK_INVOCATION: string;
61
61
  * or a trailing top-level `if … else … fi` whose every branch ends in
62
62
  * `exec`/`exit` — the shape of the pre-commit framework's generated hook. */
63
63
  export declare function terminalExitLine(content: string): string | null;
64
+ export declare function isInside(parent: string, child: string): boolean;
65
+ export declare function gitRun(args: string[], cwd: string): {
66
+ status: number | null;
67
+ stdout: string;
68
+ };
64
69
  type BlockState = "installed" | "unreachable" | "stale" | "missing";
65
70
  /** The result of inspecting the command a managed block actually runs (issue #315). */
66
71
  export type HookInvocationHealth = {
@@ -78,6 +83,21 @@ export type HookInvocationHealth = {
78
83
  * the subcommand; env assignments and a leading `(` belong to the block's own
79
84
  * shape and are stripped. Never spawns anything, never touches the network. */
80
85
  export declare function hookInvocationHealth(command: string, root: string): HookInvocationHealth;
86
+ /** The repo's worktree tops as `git worktree list --porcelain` reports them:
87
+ * `linked` is every entry AFTER the first (the first is always the main
88
+ * worktree, or the bare repo) that is not the bare entry, and `count` is how
89
+ * many non-bare worktrees the repo has — the number the shared-hooks note
90
+ * quotes, which must not include a bare repo's phantom entry. `main` is the
91
+ * first entry when it is a checkout, null when the common dir is bare (no
92
+ * main checkout exists). Entries are
93
+ * separated by blank lines and each starts with a `worktree <path>` line.
94
+ * Read straight from git rather than the workspace ledger: this runs during
95
+ * `hunch init`, before any ledger exists. */
96
+ export declare function worktreeTops(root: string): {
97
+ linked: string[];
98
+ count: number;
99
+ main: string | null;
100
+ };
81
101
  export declare function installPostCommitHook(root: string, invocation: string, opts?: {
82
102
  private?: boolean;
83
103
  commit?: boolean;
@@ -139,12 +139,12 @@ function realish(p) {
139
139
  return resolve(p);
140
140
  }
141
141
  }
142
- function isInside(parent, child) {
142
+ export function isInside(parent, child) {
143
143
  const norm = (p) => (process.platform === "win32" ? realish(p).toLowerCase() : realish(p));
144
144
  const rel = relative(norm(parent), norm(child));
145
145
  return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
146
146
  }
147
- function gitRun(args, cwd) {
147
+ export function gitRun(args, cwd) {
148
148
  const p = spawnSync("git", args, { cwd, encoding: "utf8", env: initiatorChildEnv(), stdio: ["ignore", "pipe", "ignore"] });
149
149
  return { status: p.status, stdout: (p.stdout ?? "").trim() };
150
150
  }
@@ -504,14 +504,16 @@ function snippetFor(t, mark, build, localInvocation) {
504
504
  * `linked` is every entry AFTER the first (the first is always the main
505
505
  * worktree, or the bare repo) that is not the bare entry, and `count` is how
506
506
  * many non-bare worktrees the repo has — the number the shared-hooks note
507
- * quotes, which must not include a bare repo's phantom entry. Entries are
507
+ * quotes, which must not include a bare repo's phantom entry. `main` is the
508
+ * first entry when it is a checkout, null when the common dir is bare (no
509
+ * main checkout exists). Entries are
508
510
  * separated by blank lines and each starts with a `worktree <path>` line.
509
511
  * Read straight from git rather than the workspace ledger: this runs during
510
512
  * `hunch init`, before any ledger exists. */
511
- function worktreeTops(root) {
513
+ export function worktreeTops(root) {
512
514
  const out = gitRun(["worktree", "list", "--porcelain"], root).stdout;
513
515
  if (!out)
514
- return { linked: [], count: 0 };
516
+ return { linked: [], count: 0, main: null };
515
517
  const entries = normalizeEol(out).split(/\n\s*\n/).map((e) => e.split("\n"));
516
518
  const parsed = entries
517
519
  .map((lines) => ({
@@ -519,7 +521,12 @@ function worktreeTops(root) {
519
521
  bare: lines.some((l) => l.trim() === "bare"),
520
522
  }))
521
523
  .filter((e) => e.path !== "");
522
- return { linked: parsed.slice(1).filter((e) => !e.bare).map((e) => e.path), count: parsed.filter((e) => !e.bare).length };
524
+ const first = parsed[0];
525
+ return {
526
+ linked: parsed.slice(1).filter((e) => !e.bare).map((e) => e.path),
527
+ count: parsed.filter((e) => !e.bare).length,
528
+ main: first && !first.bare ? first.path : null,
529
+ };
523
530
  }
524
531
  /** The LINKED worktree an invocation is bound to — one it can only work from
525
532
  * inside, because some token is an absolute path under it (that worktree's own
@@ -26,6 +26,7 @@ import { renderHunchSection, stripManagedSection, upsertSection, updateClaudeMd,
26
26
  import { headFileContent, isGitCleanPath } from "../extractors/git.js";
27
27
  import { parseJsonc } from "../core/jsonc.js";
28
28
  import { parse as parseToml } from "smol-toml";
29
+ import { publishedMcpInvocation } from "../cli/invocation.js";
29
30
  /** JSONC comments are user prose. A parsed object cannot retain their position,
30
31
  * so refuse a write rather than reserialize the file without them. */
31
32
  function hasJsoncComments(raw) {
@@ -388,18 +389,52 @@ export function writeVscodeHooks(root, inv) {
388
389
  Stop: [{ type: "command", command }],
389
390
  });
390
391
  }
392
+ const WINDSURF_EVENTS = ["pre_user_prompt", "pre_write_code", "post_write_code", "post_run_command"];
393
+ /** The published npx pin (any version), in the current spelling and in the fully
394
+ * quoted one written before v1.21.1 (`"npx" "-y" "--package=…" …`). */
395
+ const PUBLISHED_PIN = /^\s*"?npx(?:\.cmd)?"?\s+"?-y"?\s+"?--package=(?:hunch-exact@npm:)?@davesheffer\/hunch/;
396
+ /** The events of `file` holding a Hunch hook entry, when it has at least one and
397
+ * every one of them runs the published pin; null otherwise. Throws, like every
398
+ * writer, on a file it cannot parse. */
399
+ function publishedPinEvents(file) {
400
+ const hooks = objectField(readJsonObj(file), "hooks", file);
401
+ const events = new Set();
402
+ let onlyPins = true;
403
+ const collect = (event) => (entry) => {
404
+ const e = entry && typeof entry === "object" ? entry : null;
405
+ if (Array.isArray(e?.hooks) && typeof e.command !== "string")
406
+ e.hooks.forEach(collect(event));
407
+ else if (typeof e?.command === "string") {
408
+ if (PUBLISHED_PIN.test(e.command))
409
+ events.add(event);
410
+ else
411
+ onlyPins = false;
412
+ }
413
+ };
414
+ for (const [event, entries] of Object.entries(hooks)) {
415
+ if (Array.isArray(entries))
416
+ entries.filter(isHunchProviderHook).forEach(collect(event));
417
+ }
418
+ return onlyPins && events.size > 0 ? events : null;
419
+ }
391
420
  /** Windsurf's documented workspace hooks. It only supports deterministic
392
421
  * pre-hook blocking via exit code 2, so Hunch uses rules + MCP for context and
393
422
  * reserves the hook for strict edit protection and pipeline observation. */
394
423
  export function writeWindsurfHooks(root, inv) {
395
424
  const file = join(root, ".windsurf", "hooks.json");
396
- const command = hookCommand(inv, "windsurf");
397
- return writeHookConfig(file, {
398
- pre_user_prompt: [{ command, show_output: false }],
399
- pre_write_code: [{ command, show_output: false }],
400
- post_write_code: [{ command, show_output: false }],
401
- post_run_command: [{ command, show_output: false }],
402
- });
425
+ const published = hookCommand(publishedMcpInvocation(), "windsurf");
426
+ let command = hookCommand(inv, "windsurf");
427
+ // A source-checkout run (tsx / local dist) must never replace a tracked
428
+ // published pin with a machine-local path (#466): a complete pinned file is
429
+ // kept as is, and a partial one is completed with the published pin.
430
+ if (command !== published) {
431
+ const pinned = publishedPinEvents(file);
432
+ if (pinned && WINDSURF_EVENTS.every((event) => pinned.has(event)))
433
+ return file;
434
+ if (pinned)
435
+ command = published;
436
+ }
437
+ return writeHookConfig(file, Object.fromEntries(WINDSURF_EVENTS.map((event) => [event, [{ command, show_output: false }]])));
403
438
  }
404
439
  function antigravityHandler(command) {
405
440
  return { type: "command", command, timeout: 15 };
@@ -17,6 +17,8 @@ export interface UninstallPlan {
17
17
  path: string;
18
18
  reason: string;
19
19
  }>;
20
+ /** Consequences beyond this checkout (other worktrees, other branches); printed, never refused over. */
21
+ warnings?: string[];
20
22
  }
21
23
  /** Read-only: everything `hunch uninstall` would do in `root`. */
22
24
  export declare function planUninstall(root: string, opts?: {
@@ -14,15 +14,15 @@
14
14
  */
15
15
  import { existsSync, lstatSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, writeFileSync, chmodSync } from "node:fs";
16
16
  import { spawnSync } from "node:child_process";
17
- import { basename, join, relative } from "node:path";
17
+ import { basename, join, relative, resolve } from "node:path";
18
18
  import { parse as parseToml } from "smol-toml";
19
19
  import { writeFileAtomic } from "../core/io.js";
20
- import { gitCommonDir, isGitRepo, separateGitDirLink } from "../extractors/git.js";
20
+ import { currentBranch, gitCommonDir, isGitRepo, isLinkedWorktree, separateGitDirLink } from "../extractors/git.js";
21
21
  import { HUNCH_END, HUNCH_START } from "./claudemd.js";
22
22
  import { ciWorkflowYaml } from "./ciAction.js";
23
23
  import { assertSafeTopLevelConfigFile, stripManagedGitignore } from "./gitignore.js";
24
24
  import { isHunchHookCommand } from "./hookmatch.js";
25
- import { planHookRemoval } from "./hooks.js";
25
+ import { gitRun, isInside, planHookRemoval, worktreeTops } from "./hooks.js";
26
26
  import { ATTR_LINES, targetRepositoryEnv } from "./mergeDriver.js";
27
27
  import { antigravityMcpFile, isHunchProviderHook, readJsonObj, TOML_END, TOML_START, windsurfMcpFile } from "./providers.js";
28
28
  import { SLASH_COMMAND_FILES } from "./scaffold.js";
@@ -195,6 +195,31 @@ function gitConfigHasDrivers(root) {
195
195
  const r = spawnSync("git", ["config", "--local", "--get-regexp", "^merge\\.hunch(-grounding)?\\."], { cwd: root, env: targetRepositoryEnv(), encoding: "utf8" });
196
196
  return r.status === 0 && r.stdout.trim() !== "";
197
197
  }
198
+ /** The branch the clone treats as default: origin's HEAD, else a local main, else
199
+ * master; "" when none can be named. */
200
+ function defaultBranch(root) {
201
+ const head = gitRun(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], root);
202
+ if (head.status === 0 && head.stdout)
203
+ return head.stdout.replace(/^origin\//, "");
204
+ for (const b of ["main", "master"]) {
205
+ if (gitRun(["show-ref", "--verify", "--quiet", `refs/heads/${b}`], root).status === 0)
206
+ return b;
207
+ }
208
+ return "";
209
+ }
210
+ /** The marker is committed per branch: off the default branch, say the rest of the clone still has Hunch. */
211
+ function branchWarning(root) {
212
+ try {
213
+ const branch = currentBranch(root);
214
+ const def = branch ? defaultBranch(root) : "";
215
+ if (!branch || !def || branch === def)
216
+ return null;
217
+ return `the ${OPT_OUT_FILE} marker is only on branch ${branch}; other branches of this clone still have Hunch's instruction blocks and config — commit and merge it, or run uninstall there too`;
218
+ }
219
+ catch {
220
+ return null;
221
+ }
222
+ }
198
223
  /** The overlay clone a pointer names, for the "kept" note. Never deleted: it is
199
224
  * its own repository holding memory that may not be pushed yet. */
200
225
  function pointerOverlay(file) {
@@ -295,22 +320,49 @@ export function planUninstall(root, opts = {}) {
295
320
  p.steps.push({ path: ciRel, kind: "keep", note: "it differs from the workflow Hunch generates (edited?); delete it yourself if you no longer want the Hunch Guard check" });
296
321
  }
297
322
  }
298
- // Git hooks + merge driver.
323
+ // Git hooks + merge driver. Both live in the clone's common git dir, so they
324
+ // serve every worktree and every branch (#459): a linked worktree leaves them to
325
+ // the main checkout, and the main checkout says which worktrees lose them too.
326
+ // A bare common dir has no main checkout, so any worktree removes them and warns.
327
+ const warnings = [];
299
328
  if (isGitRepo(root)) {
329
+ const commonDir = gitCommonDir(root);
330
+ const top = gitRun(["rev-parse", "--show-toplevel"], root).stdout;
331
+ const here = top || root;
332
+ const same = (a, b) => isInside(a, b) && isInside(b, a);
333
+ // The main checkout as git lists it, not the common dir's parent, which in a
334
+ // bare clone or a --separate-git-dir checkout is no checkout at all.
335
+ const tops = worktreeTops(root);
336
+ const mainCheckout = tops.main;
337
+ const linked = commonDir !== "" && mainCheckout !== null && !same(mainCheckout, here) && isLinkedWorktree(root);
338
+ // The same test `hunch init` uses: in the common dir, or outside this checkout.
339
+ const shared = (file) => (commonDir !== "" && isInside(commonDir, file)) || (top !== "" && !isInside(top, file));
340
+ const elsewhere = (what, it) => `${what} shared by every worktree of this clone — run \`hunch uninstall\` from the main checkout (${mainCheckout}) to remove ${it}`;
341
+ let removesShared = false;
300
342
  for (const h of planHookRemoval(root)) {
301
343
  const path = p.rel(h.path);
302
344
  if (h.reason)
303
345
  p.steps.push({ path, kind: "keep", note: h.reason });
304
- else if (h.next === null)
305
- p.steps.push({ path, kind: "delete", note: `Hunch's ${h.hookName} git hook`, run: () => rmSync(h.path, { force: true }) });
346
+ else if (linked && shared(h.path))
347
+ p.steps.push({ path, kind: "keep", note: elsewhere(`Hunch's ${h.hookName} git hook is`, "it") });
306
348
  else {
307
- const next = h.next;
308
- p.steps.push({ path, kind: "edit", note: `removed Hunch's block from the ${h.hookName} git hook (your own lines are kept)`, run: () => { writeFileSync(h.path, next); chmodSync(h.path, 0o755); } });
349
+ if (shared(h.path))
350
+ removesShared = true;
351
+ if (h.next === null)
352
+ p.steps.push({ path, kind: "delete", note: `Hunch's ${h.hookName} git hook`, run: () => rmSync(h.path, { force: true }) });
353
+ else {
354
+ const next = h.next;
355
+ p.steps.push({ path, kind: "edit", note: `removed Hunch's block from the ${h.hookName} git hook (your own lines are kept)`, run: () => { writeFileSync(h.path, next); chmodSync(h.path, 0o755); } });
356
+ }
309
357
  }
310
358
  }
311
- if (gitConfigHasDrivers(root)) {
359
+ if (gitConfigHasDrivers(root) && linked) {
360
+ p.steps.push({ path: p.rel(join(commonDir, "config")), kind: "keep", note: elsewhere("the merge.hunch and merge.hunch-grounding drivers are", "them") });
361
+ }
362
+ else if (gitConfigHasDrivers(root)) {
363
+ removesShared = true;
312
364
  p.steps.push({
313
- path: ".git/config", kind: "edit", note: "unregistered the merge.hunch and merge.hunch-grounding drivers",
365
+ path: commonDir ? p.rel(join(commonDir, "config")) : ".git/config", kind: "edit", note: "unregistered the merge.hunch and merge.hunch-grounding drivers",
314
366
  run: () => {
315
367
  for (const section of ["merge.hunch", "merge.hunch-grounding"]) {
316
368
  spawnSync("git", ["config", "--local", "--remove-section", section], { cwd: root, env: targetRepositoryEnv(), stdio: "ignore" });
@@ -318,6 +370,13 @@ export function planUninstall(root, opts = {}) {
318
370
  },
319
371
  });
320
372
  }
373
+ const others = linked ? [] : tops.linked.filter((w) => !same(w, here)).map((w) => resolve(w));
374
+ if (removesShared && others.length) {
375
+ warnings.push(`the git hooks and merge drivers are shared with ${others.length === 1 ? "the linked worktree" : `${others.length} linked worktrees`} ${others.join(", ")}, so they are removed there too, though ${others.length === 1 ? "it still has" : "those still have"} Hunch and no ${OPT_OUT_FILE} marker`);
376
+ }
377
+ const onBranch = branchWarning(root);
378
+ if (onBranch)
379
+ warnings.push(onBranch);
321
380
  }
322
381
  const attrs = p.text(".gitattributes");
323
382
  if (attrs !== null) {
@@ -378,7 +437,7 @@ export function planUninstall(root, opts = {}) {
378
437
  for (const dir of overlays) {
379
438
  p.steps.push({ path: p.rel(dir), kind: "keep", note: "a private/shared overlay clone: its own repository with memory that may not be pushed; never deleted here" });
380
439
  }
381
- return { root, purge, steps: p.steps, refused: p.refused };
440
+ return { root, purge, steps: p.steps, refused: p.refused, warnings };
382
441
  }
383
442
  /** Directories Hunch's writers create; removed only when left empty. Deepest first. */
384
443
  const PRUNE_DIRS = [
@@ -430,6 +489,8 @@ export function formatUninstallPlan(plan, dryRun) {
430
489
  out.push(` ✗ ${r.path}: ${r.reason}`);
431
490
  for (const s of plan.steps)
432
491
  out.push(` ${icon(s.kind)} ${verb(s.kind)} ${s.path} — ${s.note}`);
492
+ for (const w of plan.warnings ?? [])
493
+ out.push(` ⚠ ${w}`);
433
494
  return out;
434
495
  }
435
496
  //# sourceMappingURL=uninstall.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.45.0",
3
+ "version": "1.45.1",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.45.0",
10
+ "version": "1.45.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.45.0",
16
+ "version": "1.45.1",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {