@davesheffer/hunch 1.44.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.
@@ -12,6 +12,7 @@
12
12
  * block written by an older release gains entries added later) and is otherwise
13
13
  * byte-identical; everything outside the markers is left untouched.
14
14
  */
15
+ import { spawnSync } from "node:child_process";
15
16
  import { readFileSync, existsSync, lstatSync, realpathSync } from "node:fs";
16
17
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
17
18
  import { writeFileAtomic } from "../core/io.js";
@@ -231,6 +232,35 @@ export function describeGitignoreUpgrade(upgrade) {
231
232
  }
232
233
  return out;
233
234
  }
235
+ /** The user's ignore rule that keeps engineering memory out of Git, or null.
236
+ * A repository that ignores `.hunch/` (or `.hunch/decisions/`) silently loses every
237
+ * capture: the records are written, never committed, and gone on a fresh clone.
238
+ * Hunch's own private-only block ignores the same paths on purpose (memory lives
239
+ * in an overlay), so a rule inside that block is not reported. Unknown git state
240
+ * yields null: the caller then says nothing rather than guessing. */
241
+ export function memoryIgnoreRule(root) {
242
+ const probe = ".hunch/decisions/dec_0000000000.json";
243
+ const r = spawnSync("git", ["check-ignore", "-v", "--no-index", "--", probe], { cwd: root, encoding: "utf8", windowsHide: true });
244
+ if (r.error || r.status !== 0)
245
+ return null;
246
+ const m = /^(.*):(\d+):(.*)\t/.exec(r.stdout ?? "");
247
+ if (!m)
248
+ return null;
249
+ const rule = { source: m[1], line: Number(m[2]), pattern: m[3] };
250
+ if (rule.pattern.startsWith("!"))
251
+ return null;
252
+ const file = resolve(root, rule.source);
253
+ if (existsSync(file)) {
254
+ const span = findBlock(readFileSync(file, "utf8").split("\n"), MEM_MARK, MEM_END);
255
+ if (span && rule.line - 1 > span.start && rule.line - 1 < span.end)
256
+ return null;
257
+ }
258
+ return rule;
259
+ }
260
+ /** One line telling the user their memory will not reach Git, and how to fix it. */
261
+ export function describeMemoryIgnoreRule(rule) {
262
+ return `${rule.source} line ${rule.line} (\`${rule.pattern}\`) ignores .hunch/, so captured memory is never committed or shared and is lost on a fresh clone. Remove that line, or keep memory in a private repository with \`hunch private --repo <url>\`.`;
263
+ }
234
264
  /** `.gitignore` text with both managed Hunch blocks removed (markers inclusive),
235
265
  * every other byte kept, for `hunch uninstall --purge` (#449). `damaged` when a
236
266
  * marker line has no matching end line: the block is never guessed at. */
@@ -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
@@ -115,7 +115,16 @@ export interface ProviderScaffold {
115
115
  * per-assistant summary for `hunch init` to print. Each assistant is isolated:
116
116
  * a writer that refuses to clobber a malformed file degrades to a warning rather
117
117
  * than aborting the rest. Claude Code is handled separately by scaffold.ts. */
118
+ /** The assistants `scaffoldProviders` can configure, besides the always-written AGENTS.md. */
119
+ export declare const PROVIDER_ASSISTANTS: readonly ["Cursor", "VS Code (Copilot)", "Codex CLI", "Windsurf", "Google Antigravity"];
120
+ export type ProviderAssistant = typeof PROVIDER_ASSISTANTS[number];
121
+ /** Assistants with a footprint here: a config already in the repository (a teammate
122
+ * set it up), a settings folder in the user's home, or a command on PATH. `hunch init`
123
+ * configures only these, so a repository is not filled with settings for tools nobody
124
+ * on it uses; when none is found it configures every assistant, as before. */
125
+ export declare function detectAssistants(root: string, home?: string, env?: NodeJS.ProcessEnv): Set<ProviderAssistant>;
118
126
  export declare function scaffoldProviders(root: string, inv: Invocation, store: HunchStore, options?: {
119
127
  agentHooks?: boolean;
120
128
  home?: string;
129
+ only?: ReadonlySet<string>;
121
130
  }): ProviderScaffold[];
@@ -20,12 +20,13 @@
20
20
  import { readFileSync, existsSync, mkdirSync } from "node:fs";
21
21
  import { writeFileAtomic } from "../core/io.js";
22
22
  import { homedir } from "node:os";
23
- import { join, dirname } from "node:path";
23
+ import { join, dirname, delimiter } from "node:path";
24
24
  import { isHunchHookCommand } from "./hookmatch.js";
25
25
  import { renderHunchSection, stripManagedSection, upsertSection, updateClaudeMd, preserveNewerTemplate } from "./claudemd.js";
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 };
@@ -560,6 +595,32 @@ function runProvider(writers) {
560
595
  * per-assistant summary for `hunch init` to print. Each assistant is isolated:
561
596
  * a writer that refuses to clobber a malformed file degrades to a warning rather
562
597
  * than aborting the rest. Claude Code is handled separately by scaffold.ts. */
598
+ /** The assistants `scaffoldProviders` can configure, besides the always-written AGENTS.md. */
599
+ export const PROVIDER_ASSISTANTS = ["Cursor", "VS Code (Copilot)", "Codex CLI", "Windsurf", "Google Antigravity"];
600
+ function onPath(names, env) {
601
+ const dirs = (env.PATH ?? env.Path ?? "").split(delimiter).filter(Boolean);
602
+ const exts = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").concat("") : [""];
603
+ return dirs.some((dir) => names.some((name) => exts.some((ext) => existsSync(join(dir, name + ext)))));
604
+ }
605
+ /** Assistants with a footprint here: a config already in the repository (a teammate
606
+ * set it up), a settings folder in the user's home, or a command on PATH. `hunch init`
607
+ * configures only these, so a repository is not filled with settings for tools nobody
608
+ * on it uses; when none is found it configures every assistant, as before. */
609
+ export function detectAssistants(root, home = homedir(), env = process.env) {
610
+ const has = (...paths) => paths.some((p) => existsSync(p));
611
+ const found = new Set();
612
+ if (has(join(root, ".cursor"), join(home, ".cursor")) || onPath(["cursor", "cursor-agent"], env))
613
+ found.add("Cursor");
614
+ if (has(join(root, ".vscode"), join(root, ".github", "copilot-instructions.md"), join(home, ".vscode")) || onPath(["code", "code-insiders"], env))
615
+ found.add("VS Code (Copilot)");
616
+ if (has(join(root, ".codex"), join(home, ".codex")) || onPath(["codex"], env))
617
+ found.add("Codex CLI");
618
+ if (has(join(root, ".windsurf"), join(home, ".codeium", "windsurf")) || onPath(["windsurf"], env))
619
+ found.add("Windsurf");
620
+ if (has(join(root, ".agents", "mcp_config.json"), join(home, ".gemini")) || onPath(["antigravity"], env))
621
+ found.add("Google Antigravity");
622
+ return found;
623
+ }
563
624
  export function scaffoldProviders(root, inv, store, options = {}) {
564
625
  const hooks = options.agentHooks !== false;
565
626
  const home = options.home;
@@ -584,6 +645,9 @@ export function scaffoldProviders(root, inv, store, options = {}) {
584
645
  }],
585
646
  ["Any (AGENTS.md)", () => runProvider([() => writeAgentsMd(root, store)])],
586
647
  ];
587
- return tasks.map(([assistant, run]) => ({ assistant, ...run() }));
648
+ const only = options.only;
649
+ return tasks
650
+ .filter(([assistant]) => !only || only.has(assistant) || assistant === "Any (AGENTS.md)")
651
+ .map(([assistant, run]) => ({ assistant, ...run() }));
588
652
  }
589
653
  //# sourceMappingURL=providers.js.map
@@ -62,7 +62,7 @@ Follow the Hunch-grounded workflow (DESIGN §5) — do NOT skip the memory looku
62
62
  2. Identify the suspect symbol/file, then \`hunch_get_dependents(<symbol>)\` to learn the blast radius.
63
63
  3. \`hunch_check_constraints(<scope>)\` — list invariants you must preserve.
64
64
  4. Propose a fix that honors past root causes AND constraints. Apply it and run the tests.
65
- 5. If the fix encodes a non-trivial choice, \`hunch_record_decision(...)\` so the next session is grounded in it.
65
+ 5. If the human decided or corrected something during the fix, record it (\`hunch_record_decision\` / \`hunch_record_correction\`); the commit itself is captured automatically.
66
66
  `;
67
67
  const FRAGILE_CMD = `---
68
68
  description: Report the most fragile parts of this codebase, with evidence
@@ -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
@@ -0,0 +1,9 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ type JsonObject = Record<string, unknown>;
3
+ /** One tool definition as the host should see it. */
4
+ export declare function leanTool(tool: JsonObject): JsonObject;
5
+ /** Route the SDK's tools/list result through leanTool. Call right after
6
+ * constructing the server: McpServer installs its tools/list handler on the
7
+ * first registerTool, through this public setRequestHandler. */
8
+ export declare function installLeanToolList(server: McpServer): void;
9
+ export {};
@@ -0,0 +1,89 @@
1
+ import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
2
+ const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
3
+ // Keywords whose meaning differs between draft-07 and 2020-12 — the dialect MCP
4
+ // assumes when `$schema` is absent. A schema that uses any keeps its marker.
5
+ // A property merely NAMED like one also keeps it: conservative, never wrong.
6
+ const DIALECT_SENSITIVE = new Set(["definitions", "dependencies", "additionalItems", "$ref", "$id", "$recursiveRef"]);
7
+ function dialectNeutral(node) {
8
+ if (Array.isArray(node))
9
+ return node.every(dialectNeutral);
10
+ if (!isObject(node))
11
+ return true;
12
+ for (const [key, value] of Object.entries(node)) {
13
+ if (DIALECT_SENSITIVE.has(key) || (key === "items" && Array.isArray(value)))
14
+ return false;
15
+ if (!dialectNeutral(value))
16
+ return false;
17
+ }
18
+ return true;
19
+ }
20
+ // Keywords whose value is data, not a subschema: copied as-is.
21
+ const VALUE_KEYWORDS = new Set(["default", "const", "enum", "examples"]);
22
+ /** A copy without zod's safe-integer bounds, which only restate what JSON numbers can hold. */
23
+ function withoutSafeIntegerBounds(node) {
24
+ if (Array.isArray(node))
25
+ return node.map(withoutSafeIntegerBounds);
26
+ if (!isObject(node))
27
+ return node;
28
+ const integer = node.type === "integer";
29
+ const out = {};
30
+ for (const [key, value] of Object.entries(node)) {
31
+ if (integer && ((key === "maximum" && value === Number.MAX_SAFE_INTEGER) || (key === "minimum" && value === Number.MIN_SAFE_INTEGER)))
32
+ continue;
33
+ out[key] = VALUE_KEYWORDS.has(key) ? value : withoutSafeIntegerBounds(value);
34
+ }
35
+ return out;
36
+ }
37
+ // Only these markers are safe to drop on a dialect-neutral schema; any other
38
+ // dialect (draft-04's boolean exclusiveMinimum, say) keeps its marker.
39
+ const DROPPABLE_MARKERS = new Set([
40
+ "http://json-schema.org/draft-07/schema#",
41
+ "https://json-schema.org/draft/2020-12/schema",
42
+ ]);
43
+ function leanSchema(schema) {
44
+ const lean = withoutSafeIntegerBounds(schema);
45
+ if (DROPPABLE_MARKERS.has(schema.$schema) && dialectNeutral(schema))
46
+ delete lean.$schema;
47
+ return lean;
48
+ }
49
+ const isDefaultExecution = (v) => isObject(v) && Object.entries(v).every(([key, value]) => key === "taskSupport" && (value === undefined || value === "forbidden"));
50
+ /** One tool definition as the host should see it. */
51
+ export function leanTool(tool) {
52
+ const out = {};
53
+ for (const [key, value] of Object.entries(tool)) {
54
+ if (key === "execution" && isDefaultExecution(value))
55
+ continue;
56
+ out[key] = (key === "inputSchema" || key === "outputSchema") && isObject(value) ? leanSchema(value) : value;
57
+ }
58
+ return out;
59
+ }
60
+ /** The request method a schema handles — by identity, or by its method literal
61
+ * when a second SDK copy supplied a different schema object. */
62
+ function handlesToolsList(schema) {
63
+ if (schema === ListToolsRequestSchema)
64
+ return true;
65
+ try {
66
+ return schema.shape?.method?.value === "tools/list";
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ /** Route the SDK's tools/list result through leanTool. Call right after
73
+ * constructing the server: McpServer installs its tools/list handler on the
74
+ * first registerTool, through this public setRequestHandler. */
75
+ export function installLeanToolList(server) {
76
+ const protocol = server.server;
77
+ const setRequestHandler = protocol.setRequestHandler.bind(protocol);
78
+ protocol.setRequestHandler = ((schema, handler) => {
79
+ if (!handlesToolsList(schema))
80
+ return setRequestHandler(schema, handler);
81
+ setRequestHandler(schema, async (...args) => {
82
+ const result = await handler(...args);
83
+ if (!isObject(result) || !Array.isArray(result.tools))
84
+ return result;
85
+ return { ...result, tools: result.tools.map((t) => (isObject(t) ? leanTool(t) : t)) };
86
+ });
87
+ });
88
+ }
89
+ //# sourceMappingURL=leanToolList.js.map
@@ -16,6 +16,7 @@ import { z } from "zod";
16
16
  import { hunchPaths, findRoot, toPosixTarget, repoRelativeTarget } from "../core/paths.js";
17
17
  import { matchSymbolsTiered } from "../core/glob.js";
18
18
  import { resolveMcpToolset } from "./toolset.js";
19
+ import { installLeanToolList } from "./leanToolList.js";
19
20
  import { PolicyRepository } from "../constitution/repository.js";
20
21
  import { readConfig } from "../core/config.js";
21
22
  import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
@@ -1129,7 +1130,7 @@ function prepareRoot(root, explicitOverlay, requireIndex) {
1129
1130
  export const MCP_INSTRUCTIONS = [
1130
1131
  "Hunch is this repository's engineering memory: decisions, bug history, invariants, components, with provenance.",
1131
1132
  "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.",
1132
- "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.",
1133
+ "Then by moment: hunch_why(target) for rationale and rejected alternatives, hunch_bug_lineage before fixing a failure, hunch_record_correction when a human corrects you, hunch_record_decision for a decision the human makes; record only what the human decides or corrects.",
1133
1134
  "Hosts without lifecycle hooks (Windsurf, Cursor, or Codex before its hooks are trusted) receive no automatic grounding: call these tools yourself.",
1134
1135
  ].join("\n");
1135
1136
  export function buildServerWithRootControl(initialRoot, options = {}) {
@@ -1207,6 +1208,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
1207
1208
  if (toolset.hidden.length)
1208
1209
  process.stderr.write(`[hunch-mcp] tool groups: ${toolset.groups.length ? toolset.groups.join(", ") : "core only"} (${toolset.source}); ${toolset.hidden.length} specialist tool(s) hidden — HUNCH_MCP_TOOLS=all or .hunch/config.json mcp_tools to expose\n`);
1209
1210
  const server = new McpServer({ name: "hunch", version: HUNCH_VERSION }, { instructions: MCP_INSTRUCTIONS });
1211
+ installLeanToolList(server);
1210
1212
  let activeRequests = 0;
1211
1213
  let pendingRoot = null;
1212
1214
  let pendingScheduled = false;
@@ -2103,7 +2105,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
2103
2105
  // -- hunch_record_decision (write-back) -----------------------------------
2104
2106
  server.registerTool("hunch_record_decision", {
2105
2107
  title: "Record a decision (write-back)",
2106
- description: "Persist a Decision (ADR) with provenance after a non-trivial design choice. private:true keeps it in the HUNCH_PRIVATE_DIR overlay, never committed here. Returns id, home, status. Not for an agent-obeyed rule (hunch_record_correction) or an observation (hunch_record_finding). Errors: 'Refused:' a gate held (don't retry); 'Invalid:' fix arguments; 'Failed to' internal.",
2108
+ description: "Persist a Decision (ADR) with provenance when the human makes or confirms one in conversation; routine choices are captured from commits. private:true keeps it in the HUNCH_PRIVATE_DIR overlay, never committed here. Returns id, home, status. Not for an agent-obeyed rule (hunch_record_correction) or an observation (hunch_record_finding). Errors: 'Refused:' a gate held (don't retry); 'Invalid:' fix arguments; 'Failed to' internal.",
2107
2109
  inputSchema: {
2108
2110
  decision: z.object({
2109
2111
  title: z.string(),
@@ -1,4 +1,5 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { installLeanToolList } from "../mcp/leanToolList.js";
2
3
  // The web-standard transport, not the Node one: the Node wrapper imports @hono/node-server,
3
4
  // which the production dependency audit keeps unreachable (tooling/production-dependency-audit.mjs).
4
5
  // Bridging IncomingMessage → Request → ServerResponse here is a few lines and keeps that boundary.
@@ -51,6 +52,7 @@ const TOOLS = [
51
52
  ];
52
53
  function buildServer(dispatch, problemOf, version) {
53
54
  const server = new McpServer({ name: "hunch-serve", version });
55
+ installLeanToolList(server);
54
56
  for (const tool of TOOLS) {
55
57
  server.registerTool(tool.name, { title: tool.title, description: tool.description, inputSchema: tool.input }, async (input) => {
56
58
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.44.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>",
@@ -44,7 +44,6 @@
44
44
  "contracts/change-proof/*.json",
45
45
  "server.json",
46
46
  "bench/constitution-exp03-v1.json",
47
- "tooling/competitive-watch.mjs",
48
47
  "tooling/md1-benchmark.mjs",
49
48
  "LICENSE",
50
49
  "NOTICE"
@@ -91,12 +90,10 @@
91
90
  "site:proof": "npm run build && node tooling/generate-public-proof.mjs",
92
91
  "site:sitemap": "node tooling/generate-sitemap.mjs",
93
92
  "bench:md1": "node tooling/md1-benchmark.mjs",
94
- "research:competitors": "node tooling/competitive-watch.mjs",
95
- "outreach": "node tooling/outreach-pipeline.mjs",
96
93
  "prepublishOnly": "npm run build"
97
94
  },
98
95
  "dependencies": {
99
- "@modelcontextprotocol/sdk": "^1.31.0",
96
+ "@modelcontextprotocol/sdk": "^1.32.1",
100
97
  "@tree-sitter-grammars/tree-sitter-yaml": "^0.6.1",
101
98
  "commander": "^15.0.0",
102
99
  "cross-spawn": "^7.0.6",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.44.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.44.0",
16
+ "version": "1.45.1",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {
@@ -18,7 +18,7 @@ import { dirname, join, resolve } from "node:path";
18
18
  import process from "node:process";
19
19
  import { fileURLToPath } from "node:url";
20
20
 
21
- export const BENCHMARK_BASELINE_REF = "610cd2e5673bf3c69ac984b4737e2d4a749ed374";
21
+ export const BENCHMARK_BASELINE_REF = "a1ad6d89631cce7c194f3f679abf7ed4e56d13e3";
22
22
  export const BENCHMARK_SCHEMA = "hunch.md1a.performance.v1";
23
23
 
24
24
  const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");