@davesheffer/hunch 1.43.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +16 -16
  2. package/dist/cli/index.js +267 -89
  3. package/dist/core/agenthook.d.ts +1 -0
  4. package/dist/core/agenthook.js +11 -0
  5. package/dist/core/changeProof.js +2 -1
  6. package/dist/core/commitrepair.d.ts +9 -3
  7. package/dist/core/commitrepair.js +23 -6
  8. package/dist/core/correction.d.ts +1 -1
  9. package/dist/core/correction.js +4 -3
  10. package/dist/core/docanchors.d.ts +11 -2
  11. package/dist/core/docanchors.js +232 -65
  12. package/dist/core/drift.d.ts +1 -1
  13. package/dist/core/drift.js +10 -3
  14. package/dist/core/eol.d.ts +2 -0
  15. package/dist/core/eol.js +6 -0
  16. package/dist/core/escalations.d.ts +20 -0
  17. package/dist/core/escalations.js +47 -0
  18. package/dist/core/hookText.js +1 -1
  19. package/dist/core/procstart.d.ts +15 -4
  20. package/dist/core/procstart.js +56 -18
  21. package/dist/core/runbookDraft.d.ts +7 -0
  22. package/dist/core/runbookDraft.js +43 -0
  23. package/dist/core/taskTouched.d.ts +2 -0
  24. package/dist/core/taskTouched.js +1 -1
  25. package/dist/extractors/git.d.ts +36 -6
  26. package/dist/extractors/git.js +86 -9
  27. package/dist/extractors/indexer.js +53 -14
  28. package/dist/extractors/k8sManifest.d.ts +31 -1
  29. package/dist/extractors/k8sManifest.js +133 -8
  30. package/dist/integrations/claudemd.d.ts +2 -0
  31. package/dist/integrations/claudemd.js +5 -3
  32. package/dist/integrations/gitignore.d.ts +23 -0
  33. package/dist/integrations/gitignore.js +48 -0
  34. package/dist/integrations/hooks.d.ts +15 -0
  35. package/dist/integrations/hooks.js +49 -1
  36. package/dist/integrations/mergeDriver.d.ts +2 -0
  37. package/dist/integrations/mergeDriver.js +2 -2
  38. package/dist/integrations/providers.d.ts +16 -0
  39. package/dist/integrations/providers.js +35 -6
  40. package/dist/integrations/scaffold.d.ts +5 -0
  41. package/dist/integrations/scaffold.js +13 -8
  42. package/dist/integrations/uninstall.d.ts +40 -0
  43. package/dist/integrations/uninstall.js +435 -0
  44. package/dist/mcp/leanToolList.d.ts +9 -0
  45. package/dist/mcp/leanToolList.js +89 -0
  46. package/dist/mcp/server.d.ts +2 -1
  47. package/dist/mcp/server.js +62 -6
  48. package/dist/serve/mcpHttp.js +2 -0
  49. package/dist/serve/writelock.d.ts +7 -0
  50. package/dist/serve/writelock.js +30 -3
  51. package/dist/store/changeLedger.d.ts +5 -2
  52. package/dist/store/changeLedger.js +88 -37
  53. package/dist/store/hunchStore.js +4 -1
  54. package/package.json +3 -6
  55. package/server.json +2 -2
  56. package/tooling/md1-benchmark.mjs +1 -1
  57. package/tooling/competitive-watch.mjs +0 -142
@@ -0,0 +1,40 @@
1
+ /** The opt-out marker, at the repository root. Commit it so every clone stays opted out. */
2
+ export declare const OPT_OUT_FILE = ".hunch-removed";
3
+ export declare function isOptedOut(root: string): boolean;
4
+ export declare function optOutMessage(): string;
5
+ export interface UninstallStep {
6
+ /** Repo-relative (POSIX) where possible, else absolute. */
7
+ path: string;
8
+ kind: "edit" | "delete" | "create" | "keep";
9
+ note: string;
10
+ run?: () => void;
11
+ }
12
+ export interface UninstallPlan {
13
+ root: string;
14
+ purge: boolean;
15
+ steps: UninstallStep[];
16
+ refused: Array<{
17
+ path: string;
18
+ reason: string;
19
+ }>;
20
+ }
21
+ /** Read-only: everything `hunch uninstall` would do in `root`. */
22
+ export declare function planUninstall(root: string, opts?: {
23
+ purge?: boolean;
24
+ }): UninstallPlan;
25
+ /** Apply a plan with no refusals. The opt-out marker goes first, so an interrupted
26
+ * run is already opted out. A step that fails at write time (a file held open by a
27
+ * running `hunch mcp`/`hunch serve` on Windows, a permission) does not stop the
28
+ * others; the failures are returned so the caller can say what is left and that a
29
+ * re-run — idempotent — finishes the job. */
30
+ /** What applyUninstall actually did: the steps that ran (so output never
31
+ * claims a step that was skipped), and the ones that failed. */
32
+ export interface UninstallResult {
33
+ applied: UninstallStep[];
34
+ failed: Array<{
35
+ path: string;
36
+ error: string;
37
+ }>;
38
+ }
39
+ export declare function applyUninstall(plan: UninstallPlan): UninstallResult;
40
+ export declare function formatUninstallPlan(plan: UninstallPlan, dryRun: boolean): string[];
@@ -0,0 +1,435 @@
1
+ /**
2
+ * `hunch uninstall` (#449): remove exactly what Hunch installed in a repository
3
+ * and leave an opt-out marker so nothing re-installs it.
4
+ *
5
+ * Planning is read-only and complete before anything is written: every file is
6
+ * parsed first, and one that cannot be parsed (or whose Hunch markers are
7
+ * damaged) is refused, so a run either changes nothing or applies the whole plan
8
+ * (con_8460b6770f). Each step removes only Hunch's own part of a file — the
9
+ * marker-delimited block, the `hunch` server entry, the hook commands the
10
+ * installers' own classifiers call ours — and a file is deleted only when
11
+ * nothing but Hunch's part was in it. Running it twice is a no-op.
12
+ *
13
+ * `.hunch/` holds the engineering memory, so it stays unless `--purge`.
14
+ */
15
+ import { existsSync, lstatSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, writeFileSync, chmodSync } from "node:fs";
16
+ import { spawnSync } from "node:child_process";
17
+ import { basename, join, relative } from "node:path";
18
+ import { parse as parseToml } from "smol-toml";
19
+ import { writeFileAtomic } from "../core/io.js";
20
+ import { gitCommonDir, isGitRepo, separateGitDirLink } from "../extractors/git.js";
21
+ import { HUNCH_END, HUNCH_START } from "./claudemd.js";
22
+ import { ciWorkflowYaml } from "./ciAction.js";
23
+ import { assertSafeTopLevelConfigFile, stripManagedGitignore } from "./gitignore.js";
24
+ import { isHunchHookCommand } from "./hookmatch.js";
25
+ import { planHookRemoval } from "./hooks.js";
26
+ import { ATTR_LINES, targetRepositoryEnv } from "./mergeDriver.js";
27
+ import { antigravityMcpFile, isHunchProviderHook, readJsonObj, TOML_END, TOML_START, windsurfMcpFile } from "./providers.js";
28
+ import { SLASH_COMMAND_FILES } from "./scaffold.js";
29
+ /** The opt-out marker, at the repository root. Commit it so every clone stays opted out. */
30
+ export const OPT_OUT_FILE = ".hunch-removed";
31
+ export function isOptedOut(root) {
32
+ try {
33
+ return lstatSync(join(root, OPT_OUT_FILE)).isFile();
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ export function optOutMessage() {
40
+ return `Hunch was removed from this repository on purpose (${OPT_OUT_FILE}), so it will not set itself up here again. To reinstall, delete ${OPT_OUT_FILE} and run \`hunch init\`.`;
41
+ }
42
+ function markerText() {
43
+ return [
44
+ `Hunch was removed from this repository on purpose (\`hunch uninstall\`, ${new Date().toISOString().slice(0, 10)}).`,
45
+ "",
46
+ "While this file exists, Hunch commands refuse to set Hunch up here again.",
47
+ "Coding agents: do not re-install Hunch or re-add its MCP server, hooks or",
48
+ "instruction blocks in this repository.",
49
+ "",
50
+ "To reinstall Hunch, delete this file and run `hunch init`.",
51
+ "",
52
+ ].join("\n");
53
+ }
54
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
55
+ /** A delete step's note: the file held nothing but Hunch's part. */
56
+ const deleted = (note) => `${note.replace(/ \(.*\)$/, "")}; nothing else was in the file`;
57
+ /** Text that may hold a Hunch entry: the name, or the CLI entry path a source install writes. */
58
+ const MAY_HOLD_HUNCH = /hunch|(?:dist|src)[\\/]+cli[\\/]+index\.(?:js|ts)/i;
59
+ /** Join the text around a removed block, dropping the blank lines the writer added. */
60
+ function joinAround(before, after, eol) {
61
+ const b = before.replace(/\s+$/, "");
62
+ const a = after.replace(/^(?:[ \t]*(?:\r\n|\r|\n))+/, ""); // blank lines only: the next line keeps its indent
63
+ if (!a.trim())
64
+ return b ? `${b}${eol}` : "";
65
+ return b ? `${b}${eol}${eol}${a}` : a;
66
+ }
67
+ class Planner {
68
+ root;
69
+ steps = [];
70
+ refused = [];
71
+ constructor(root) {
72
+ this.root = root;
73
+ }
74
+ rel(abs) {
75
+ const r = relative(this.root, abs);
76
+ return r && !r.startsWith("..") ? r.replace(/\\/g, "/") : abs;
77
+ }
78
+ text(rel) {
79
+ const file = join(this.root, rel);
80
+ try {
81
+ return statSync(file).isFile() ? readFileSync(file, "utf8") : null;
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ writeOrDelete(rel, next, deleteWhenEmpty, note) {
88
+ const file = join(this.root, rel);
89
+ if (deleteWhenEmpty && next.trim() === "") {
90
+ this.steps.push({ path: rel, kind: "delete", note: deleted(note), run: () => rmSync(file, { force: true }) });
91
+ }
92
+ else {
93
+ this.steps.push({ path: rel, kind: "edit", note, run: () => writeFileAtomic(file, next) });
94
+ }
95
+ }
96
+ /** Parse a JSON config with the writers' own strictness, let `mutate` take Hunch's
97
+ * part out, then rewrite it — or delete it when only Hunch's part was there. */
98
+ json(rel, note, mutate, empty = (j) => Object.keys(j).length === 0) {
99
+ const file = join(this.root, rel);
100
+ if (!existsSync(file))
101
+ return;
102
+ let json;
103
+ try {
104
+ json = readJsonObj(file);
105
+ }
106
+ catch (e) {
107
+ // A file Hunch never wrote into (say, a JSONC file with comments) is not ours to fix.
108
+ // A source install's hook names only its …/dist|src/cli/index.js entry, not "hunch".
109
+ if (MAY_HOLD_HUNCH.test(this.text(rel) ?? ""))
110
+ this.refused.push({ path: rel, reason: e.message });
111
+ return;
112
+ }
113
+ if (!mutate(json))
114
+ return;
115
+ if (empty(json))
116
+ this.steps.push({ path: rel, kind: "delete", note: deleted(note), run: () => rmSync(file, { force: true }) });
117
+ else
118
+ this.steps.push({ path: rel, kind: "edit", note, run: () => writeFileAtomic(file, JSON.stringify(json, null, 2) + "\n") });
119
+ }
120
+ mcpEntry(rel, key) {
121
+ this.json(rel, "removed the hunch MCP server entry", (json) => {
122
+ const servers = json[key];
123
+ if (!isObj(servers) || !("hunch" in servers))
124
+ return false;
125
+ delete servers.hunch;
126
+ if (Object.keys(servers).length === 0)
127
+ delete json[key];
128
+ return true;
129
+ });
130
+ }
131
+ /** Hunch commands in a `{ hooks: { Event: [...] } }` file. */
132
+ hookConfig(rel, isOurs, empty) {
133
+ this.json(rel, "removed Hunch's hook commands", (json) => {
134
+ const hooks = json.hooks;
135
+ if (!isObj(hooks))
136
+ return false;
137
+ let changed = false;
138
+ for (const [event, list] of Object.entries(hooks)) {
139
+ if (!Array.isArray(list))
140
+ continue;
141
+ const kept = list.map(isOurs).filter((e) => e !== null);
142
+ if (kept.length === list.length && kept.every((e, i) => e === list[i]))
143
+ continue;
144
+ changed = true;
145
+ if (kept.length)
146
+ hooks[event] = kept;
147
+ else
148
+ delete hooks[event];
149
+ }
150
+ if (changed && Object.keys(hooks).length === 0)
151
+ delete json.hooks;
152
+ return changed;
153
+ }, empty);
154
+ }
155
+ /** A marker-delimited block in a text file. */
156
+ block(rel, start, end, note, isEmpty, validate) {
157
+ const cur = this.text(rel);
158
+ if (cur === null || (!cur.includes(start) && !cur.includes(end)))
159
+ return;
160
+ const i = cur.indexOf(start);
161
+ const j = cur.indexOf(end);
162
+ if (i < 0 || j < i) {
163
+ this.refused.push({ path: rel, reason: `its Hunch block markers are damaged (${start.slice(0, 24)}… / ${end}); remove the block by hand, then re-run` });
164
+ return;
165
+ }
166
+ const problem = validate?.(cur);
167
+ if (problem) {
168
+ this.refused.push({ path: rel, reason: problem });
169
+ return;
170
+ }
171
+ const eol = cur.includes("\r\n") ? "\r\n" : "\n";
172
+ const next = joinAround(cur.slice(0, i), cur.slice(j + end.length), eol);
173
+ const file = join(this.root, rel);
174
+ if (isEmpty(next))
175
+ this.steps.push({ path: rel, kind: "delete", note: deleted(note), run: () => rmSync(file, { force: true }) });
176
+ else
177
+ this.steps.push({ path: rel, kind: "edit", note, run: () => writeFileAtomic(file, next) });
178
+ }
179
+ }
180
+ /** Claude Code's settings.json: Hunch's commands inside each matcher entry. */
181
+ function claudeEntry(entry) {
182
+ if (!isObj(entry) || !Array.isArray(entry.hooks))
183
+ return entry;
184
+ const hooks = entry.hooks;
185
+ const kept = hooks.filter((h) => !(isObj(h) && typeof h.command === "string" && isHunchHookCommand(h.command, false)));
186
+ if (kept.length === hooks.length)
187
+ return entry;
188
+ return kept.length ? { ...entry, hooks: kept } : null;
189
+ }
190
+ const providerEntry = (entry) => (isHunchProviderHook(entry) ? null : entry);
191
+ function normalizedCi(text) {
192
+ return text.replace(/\r\n/g, "\n").replace(/@davesheffer\/hunch@\S+/g, "@davesheffer/hunch@<version>");
193
+ }
194
+ function gitConfigHasDrivers(root) {
195
+ const r = spawnSync("git", ["config", "--local", "--get-regexp", "^merge\\.hunch(-grounding)?\\."], { cwd: root, env: targetRepositoryEnv(), encoding: "utf8" });
196
+ return r.status === 0 && r.stdout.trim() !== "";
197
+ }
198
+ /** The overlay clone a pointer names, for the "kept" note. Never deleted: it is
199
+ * its own repository holding memory that may not be pushed yet. */
200
+ function pointerOverlay(file) {
201
+ try {
202
+ const v = JSON.parse(readFileSync(file, "utf8"));
203
+ return typeof v.privateDir === "string" ? v.privateDir : null;
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ }
209
+ /** Read-only: everything `hunch uninstall` would do in `root`. */
210
+ export function planUninstall(root, opts = {}) {
211
+ const purge = !!opts.purge;
212
+ const p = new Planner(root);
213
+ if (!isOptedOut(root)) {
214
+ const file = join(root, OPT_OUT_FILE);
215
+ p.steps.push({ path: OPT_OUT_FILE, kind: "create", note: "opt-out marker: Hunch will not set itself up here again (commit it so every clone stays opted out)", run: () => writeFileAtomic(file, markerText()) });
216
+ }
217
+ // Grounding docs. Shared docs keep the user's prose; a doc left holding only the
218
+ // title Hunch gave it when it created the file goes entirely.
219
+ const shared = [
220
+ ["CLAUDE.md", `# ${basename(root)}`],
221
+ ["AGENTS.md", "# AGENTS.md"],
222
+ [".github/copilot-instructions.md", "# Copilot instructions"],
223
+ ];
224
+ for (const [rel, title] of shared) {
225
+ p.block(rel, HUNCH_START, HUNCH_END, "removed the Hunch instruction block (your own text is kept)", (rest) => rest.trim() === "" || rest.trim() === title);
226
+ }
227
+ for (const rel of [".cursor/rules/hunch.mdc", ".windsurf/rules/hunch.md"]) {
228
+ const cur = p.text(rel);
229
+ if (cur === null)
230
+ continue;
231
+ if (cur.includes(HUNCH_START))
232
+ p.steps.push({ path: rel, kind: "delete", note: "Hunch's rule file", run: () => rmSync(join(root, rel), { force: true }) });
233
+ else
234
+ p.steps.push({ path: rel, kind: "keep", note: "no Hunch block in it any more, so it is yours; delete it yourself if you don't want it" });
235
+ }
236
+ // MCP registrations.
237
+ p.mcpEntry(".mcp.json", "mcpServers");
238
+ p.mcpEntry(".cursor/mcp.json", "mcpServers");
239
+ p.mcpEntry(".vscode/mcp.json", "servers");
240
+ p.mcpEntry(".windsurf/mcp_config.json", "mcpServers");
241
+ p.mcpEntry(".agents/mcp_config.json", "mcpServers");
242
+ // Global (per-user) registrations `init` adds when the host is installed: shared by
243
+ // every repository on this machine, so never edited here — reported, so the user
244
+ // knows the entry remains (it is inert in this repository: `hunch mcp` refuses).
245
+ for (const [file, host] of [[windsurfMcpFile(), "Windsurf"], [antigravityMcpFile(), "Antigravity"]]) {
246
+ if (!file)
247
+ continue;
248
+ try {
249
+ const servers = readJsonObj(file).mcpServers;
250
+ if (isObj(servers) && "hunch" in servers)
251
+ p.steps.push({ path: file, kind: "keep", note: `${host}'s global hunch MCP entry serves every repository on this machine; remove it there if you want Hunch gone everywhere` });
252
+ }
253
+ catch { /* unreadable global config: nothing to report about it here */ }
254
+ }
255
+ p.block(".codex/config.toml", TOML_START, TOML_END, "removed the managed hunch MCP block", (rest) => rest.trim() === "", (text) => {
256
+ try {
257
+ parseToml(text);
258
+ return null;
259
+ }
260
+ catch (e) {
261
+ return `refusing to edit: could not parse TOML (${e.message}); fix it, then re-run`;
262
+ }
263
+ });
264
+ // Agent lifecycle hooks.
265
+ p.hookConfig(".claude/settings.json", claudeEntry);
266
+ p.hookConfig(".cursor/hooks.json", providerEntry, (j) => Object.keys(j).every((k) => k === "version"));
267
+ p.hookConfig(".codex/hooks.json", providerEntry);
268
+ p.hookConfig(".github/hooks/hunch.json", providerEntry);
269
+ p.hookConfig(".windsurf/hooks.json", providerEntry);
270
+ p.json(".agents/hooks.json", "removed the hunch hook group", (json) => {
271
+ if (!("hunch" in json))
272
+ return false;
273
+ delete json.hunch;
274
+ return true;
275
+ });
276
+ // Generated slash commands: only files carrying the ownership marker, or in the
277
+ // hunch- namespace the writer always treats as its own.
278
+ for (const name of SLASH_COMMAND_FILES) {
279
+ const rel = `.claude/commands/${name}`;
280
+ const cur = p.text(rel);
281
+ if (cur === null)
282
+ continue;
283
+ if (name.startsWith("hunch-") || cur.includes("hunch:generated")) {
284
+ p.steps.push({ path: rel, kind: "delete", note: "generated slash command", run: () => rmSync(join(root, rel), { force: true }) });
285
+ }
286
+ }
287
+ // CI workflow, only when it is still the file Hunch generated.
288
+ const ciRel = ".github/workflows/hunch-guard.yml";
289
+ const ci = p.text(ciRel);
290
+ if (ci !== null) {
291
+ if (normalizedCi(ci) === normalizedCi(ciWorkflowYaml())) {
292
+ p.steps.push({ path: ciRel, kind: "delete", note: "the Hunch Guard CI workflow", run: () => rmSync(join(root, ciRel), { force: true }) });
293
+ }
294
+ else {
295
+ 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
+ }
297
+ }
298
+ // Git hooks + merge driver.
299
+ if (isGitRepo(root)) {
300
+ for (const h of planHookRemoval(root)) {
301
+ const path = p.rel(h.path);
302
+ if (h.reason)
303
+ 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 }) });
306
+ 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); } });
309
+ }
310
+ }
311
+ if (gitConfigHasDrivers(root)) {
312
+ p.steps.push({
313
+ path: ".git/config", kind: "edit", note: "unregistered the merge.hunch and merge.hunch-grounding drivers",
314
+ run: () => {
315
+ for (const section of ["merge.hunch", "merge.hunch-grounding"]) {
316
+ spawnSync("git", ["config", "--local", "--remove-section", section], { cwd: root, env: targetRepositoryEnv(), stdio: "ignore" });
317
+ }
318
+ },
319
+ });
320
+ }
321
+ }
322
+ const attrs = p.text(".gitattributes");
323
+ if (attrs !== null) {
324
+ try {
325
+ assertSafeTopLevelConfigFile(root, ".gitattributes");
326
+ const ours = new Set(ATTR_LINES);
327
+ const lines = attrs.split("\n");
328
+ const kept = lines.filter((l) => !ours.has(l.trim()));
329
+ if (kept.length !== lines.length)
330
+ p.writeOrDelete(".gitattributes", kept.join("\n"), true, "removed Hunch's merge-driver routes");
331
+ }
332
+ catch (e) {
333
+ p.refused.push({ path: ".gitattributes", reason: e.message });
334
+ }
335
+ }
336
+ // Memory and its wiring.
337
+ const hunchDir = join(root, ".hunch");
338
+ const localPointer = join(hunchDir, "local.json");
339
+ const common = isGitRepo(root) ? gitCommonDir(root) : "";
340
+ const commonPointer = common ? join(common, "hunch", "local.json") : "";
341
+ const overlays = new Set([localPointer, commonPointer].filter((f) => f && existsSync(f)).map(pointerOverlay).filter((d) => !!d));
342
+ const inRepoOverlay = existsSync(join(root, ".hunch-private"));
343
+ if (!purge) {
344
+ if (existsSync(hunchDir))
345
+ p.steps.push({ path: ".hunch/", kind: "keep", note: "your engineering memory; `hunch uninstall --purge` deletes it" });
346
+ }
347
+ else {
348
+ const gi = p.text(".gitignore");
349
+ if (gi !== null) {
350
+ try {
351
+ assertSafeTopLevelConfigFile(root, ".gitignore");
352
+ const { next, damaged } = stripManagedGitignore(gi);
353
+ if (damaged)
354
+ p.refused.push({ path: ".gitignore", reason: "a Hunch block has no closing marker; remove it by hand, then re-run" });
355
+ else if (next !== gi) {
356
+ if (inRepoOverlay)
357
+ p.steps.push({ path: ".gitignore", kind: "keep", note: "its Hunch block still ignores the .hunch-private/ overlay clone; delete that clone yourself (after pushing it), then re-run `hunch uninstall --purge`" });
358
+ else
359
+ p.writeOrDelete(".gitignore", next, true, "removed Hunch's ignore blocks");
360
+ }
361
+ }
362
+ catch (e) {
363
+ p.refused.push({ path: ".gitignore", reason: e.message });
364
+ }
365
+ }
366
+ if (existsSync(hunchDir))
367
+ p.steps.push({ path: ".hunch/", kind: "delete", note: "engineering memory, config and derived index (--purge)", run: () => rmSync(hunchDir, { recursive: true, force: true }) });
368
+ const cache = join(root, ".hunch-cache");
369
+ if (existsSync(cache))
370
+ p.steps.push({ path: ".hunch-cache/", kind: "delete", note: "derived cache", run: () => rmSync(cache, { recursive: true, force: true }) });
371
+ if (common) {
372
+ for (const file of [commonPointer, separateGitDirLink(common)]) {
373
+ if (existsSync(file))
374
+ p.steps.push({ path: p.rel(file), kind: "delete", note: "this clone's Hunch pointer in the git dir", run: () => rmSync(file, { force: true }) });
375
+ }
376
+ }
377
+ }
378
+ for (const dir of overlays) {
379
+ 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
+ }
381
+ return { root, purge, steps: p.steps, refused: p.refused };
382
+ }
383
+ /** Directories Hunch's writers create; removed only when left empty. Deepest first. */
384
+ const PRUNE_DIRS = [
385
+ ".claude/commands", ".claude", ".cursor/rules", ".cursor", ".windsurf/rules", ".windsurf",
386
+ ".vscode", ".github/hooks", ".github/workflows", ".github", ".codex", ".agents",
387
+ ];
388
+ export function applyUninstall(plan) {
389
+ if (plan.refused.length)
390
+ throw new Error("refusing to apply an uninstall plan that has refusals");
391
+ const applied = [];
392
+ const failed = [];
393
+ for (const step of plan.steps) {
394
+ try {
395
+ step.run?.();
396
+ applied.push(step);
397
+ }
398
+ catch (e) {
399
+ failed.push({ path: step.path, error: e.message });
400
+ if (step.path === OPT_OUT_FILE)
401
+ return { applied, failed }; // never strip Hunch without the marker that keeps it out
402
+ }
403
+ }
404
+ for (const rel of PRUNE_DIRS) {
405
+ const dir = join(plan.root, rel);
406
+ try {
407
+ if (readdirSync(dir).length === 0)
408
+ rmdirSync(dir);
409
+ }
410
+ catch { /* absent or not empty */ }
411
+ }
412
+ if (plan.purge) {
413
+ const common = isGitRepo(plan.root) ? gitCommonDir(plan.root) : "";
414
+ if (common) {
415
+ const dir = join(common, "hunch");
416
+ try {
417
+ if (readdirSync(dir).length === 0)
418
+ rmdirSync(dir);
419
+ }
420
+ catch { /* absent or not empty */ }
421
+ }
422
+ }
423
+ return { applied, failed };
424
+ }
425
+ export function formatUninstallPlan(plan, dryRun) {
426
+ const verb = (k) => ({ edit: dryRun ? "would edit" : "edited", delete: dryRun ? "would delete" : "deleted", create: dryRun ? "would create" : "created", keep: "kept" })[k];
427
+ const icon = (k) => (k === "keep" ? "·" : "✓");
428
+ const out = [];
429
+ for (const r of plan.refused)
430
+ out.push(` ✗ ${r.path}: ${r.reason}`);
431
+ for (const s of plan.steps)
432
+ out.push(` ${icon(s.kind)} ${verb(s.kind)} ${s.path} — ${s.note}`);
433
+ return out;
434
+ }
435
+ //# 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
@@ -10,7 +10,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { z } from "zod";
11
11
  import { HunchStore } from "../store/hunchStore.js";
12
12
  import { type StateFacet } from "../core/stateContract.js";
13
- import type { Symbol } from "../core/types.js";
13
+ import type { Decision, Symbol } from "../core/types.js";
14
14
  export declare const publicationVocabulary: (hunchDir: string) => RegExp[];
15
15
  /** The cwd-hint fallback (see `cwdHintField` above) only helps when the CALLER
16
16
  * remembers to pass it — a subagent spawned straight into its own linked
@@ -623,6 +623,7 @@ export declare function compactEnvelope(envelope: z.infer<typeof DELIVERY_OUTPUT
623
623
  * reason. Runs after recordServed and after the full envelope validated, so
624
624
  * receipts and receipt_id are unchanged. */
625
625
  export declare function compactOmissions(envelope: z.infer<typeof DELIVERY_OUTPUT_SCHEMA>): z.infer<typeof DELIVERY_ADVERTISED_OUTPUT_SCHEMA>;
626
+ export declare function decisionSubstanceChanged(before: Decision, after: Decision): boolean;
626
627
  /** Resolve a free-form target (symbol id / name / file path) to symbol records.
627
628
  * Tiered exact-id > exact-name > exact-file > segment-anchored-suffix matching,
628
629
  * shared with `HunchStore.why()` via `matchSymbolsTiered` so the two don't drift