@davesheffer/hunch 1.42.0 → 1.44.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.
- package/README.md +14 -0
- package/dist/cli/index.js +206 -37
- package/dist/core/changeProof.js +2 -1
- package/dist/core/commitrepair.d.ts +9 -3
- package/dist/core/commitrepair.js +23 -6
- package/dist/core/correction.d.ts +1 -1
- package/dist/core/correction.js +4 -3
- package/dist/core/delivery.d.ts +12 -0
- package/dist/core/delivery.js +4 -4
- package/dist/core/docanchors.d.ts +11 -2
- package/dist/core/docanchors.js +232 -65
- package/dist/core/drift.d.ts +1 -1
- package/dist/core/drift.js +10 -3
- package/dist/core/eol.d.ts +2 -0
- package/dist/core/eol.js +6 -0
- package/dist/core/hookcache.d.ts +22 -0
- package/dist/core/hookcache.js +53 -2
- package/dist/core/procstart.d.ts +15 -4
- package/dist/core/procstart.js +56 -18
- package/dist/core/publication.js +17 -3
- package/dist/core/runbookDraft.d.ts +7 -0
- package/dist/core/runbookDraft.js +43 -0
- package/dist/core/taskReportHook.js +1 -1
- package/dist/core/taskSelection.d.ts +67 -0
- package/dist/core/taskSelection.js +196 -0
- package/dist/core/taskTouched.d.ts +2 -0
- package/dist/core/taskTouched.js +1 -1
- package/dist/extractors/git.d.ts +36 -6
- package/dist/extractors/git.js +86 -9
- package/dist/extractors/indexer.js +53 -14
- package/dist/extractors/k8sManifest.d.ts +31 -1
- package/dist/extractors/k8sManifest.js +133 -8
- package/dist/integrations/claudemd.d.ts +2 -0
- package/dist/integrations/claudemd.js +4 -2
- package/dist/integrations/gitignore.d.ts +7 -0
- package/dist/integrations/gitignore.js +18 -0
- package/dist/integrations/hooks.d.ts +15 -0
- package/dist/integrations/hooks.js +49 -1
- package/dist/integrations/mergeDriver.d.ts +2 -0
- package/dist/integrations/mergeDriver.js +2 -2
- package/dist/integrations/providers.d.ts +7 -0
- package/dist/integrations/providers.js +6 -5
- package/dist/integrations/scaffold.d.ts +5 -0
- package/dist/integrations/scaffold.js +14 -8
- package/dist/integrations/uninstall.d.ts +40 -0
- package/dist/integrations/uninstall.js +435 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +58 -4
- package/dist/mcp/taskReportTools.js +1 -1
- package/dist/serve/writelock.d.ts +7 -0
- package/dist/serve/writelock.js +30 -3
- package/dist/store/changeLedger.d.ts +5 -2
- package/dist/store/changeLedger.js +88 -37
- package/dist/store/hunchStore.js +4 -1
- package/package.json +3 -3
- package/server.json +2 -2
|
@@ -50,7 +50,7 @@ function hasJsoncComments(raw) {
|
|
|
50
50
|
/** Read a JSON/JSONC object. Returns {} only for an ABSENT or empty file. A
|
|
51
51
|
* non-empty file we cannot parse THROWS — overwriting it would silently wipe the
|
|
52
52
|
* user's other MCP servers. */
|
|
53
|
-
function readJsonObj(file) {
|
|
53
|
+
export function readJsonObj(file) {
|
|
54
54
|
if (!existsSync(file))
|
|
55
55
|
return {};
|
|
56
56
|
const raw = readFileSync(file, "utf8");
|
|
@@ -128,7 +128,7 @@ function shellToken(part) {
|
|
|
128
128
|
function hookCommand(inv, provider) {
|
|
129
129
|
return [inv.command, ...inv.args, "hook", "--provider", provider].map(shellToken).join(" ");
|
|
130
130
|
}
|
|
131
|
-
function isHunchProviderHook(entry) {
|
|
131
|
+
export function isHunchProviderHook(entry) {
|
|
132
132
|
const e = entry && typeof entry === "object" ? entry : null;
|
|
133
133
|
// Codex (like Claude Code) nests commands under a matcher entry: an entry
|
|
134
134
|
// whose every nested hook is ours is ours; a mixed entry stays foreign.
|
|
@@ -210,8 +210,8 @@ export function writeAntigravityWorkspaceMcp(root, inv) {
|
|
|
210
210
|
json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
|
|
211
211
|
return writeJson(file, json);
|
|
212
212
|
}
|
|
213
|
-
const TOML_START = "# >>> hunch mcp (managed) >>>";
|
|
214
|
-
const TOML_END = "# <<< hunch mcp <<<";
|
|
213
|
+
export const TOML_START = "# >>> hunch mcp (managed) >>>";
|
|
214
|
+
export const TOML_END = "# <<< hunch mcp <<<";
|
|
215
215
|
/** Codex CLI: .codex/config.toml — `[mcp_servers.hunch]` stdio entry. We own only
|
|
216
216
|
* a marker-delimited block; any other TOML the user has is preserved. Paths use
|
|
217
217
|
* TOML single-quote LITERAL strings so Windows backslashes need no escaping. */
|
|
@@ -364,7 +364,8 @@ export function writeCodexHooks(root, inv) {
|
|
|
364
364
|
return writeHookConfig(file, {
|
|
365
365
|
SessionStart: [entry()],
|
|
366
366
|
UserPromptSubmit: [entry()],
|
|
367
|
-
|
|
367
|
+
// The shell entries take the baseline a shell write is measured from.
|
|
368
|
+
PreToolUse: [entry("apply_patch|Bash|PowerShell|shell|local_shell")],
|
|
368
369
|
// Codex's native command tool arrives as `Bash` (or `PowerShell` on
|
|
369
370
|
// Windows), while older hosts may expose shell/local_shell names.
|
|
370
371
|
PostToolUse: [entry("apply_patch|Bash|PowerShell|shell|local_shell")],
|
|
@@ -23,6 +23,11 @@ export interface ClaudeHookInstall {
|
|
|
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;
|
|
26
|
+
/** Ownership marker for generated slash commands: its presence means Hunch may
|
|
27
|
+
* refresh the file; deleting the line hands the file to the user for good. */
|
|
28
|
+
export declare const CMD_MARKER = "<!-- hunch:generated \u2014 refreshed by hunch init; delete this line to take ownership -->";
|
|
29
|
+
/** File names `writeSlashCommands` writes under .claude/commands/. */
|
|
30
|
+
export declare const SLASH_COMMAND_FILES: readonly string[];
|
|
26
31
|
export declare function writeSlashCommands(root: string): {
|
|
27
32
|
written: string[];
|
|
28
33
|
skipped: string[];
|
|
@@ -180,9 +180,10 @@ export function installClaudeHooks(root, hookCmd) {
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
const keep = (arr) => (Array.isArray(arr) ? arr.map((entry) => withoutHunchCommands(entry, hookCmd)).filter((e) => e !== null) : []);
|
|
183
|
+
// Shell tools too: their PreToolUse takes the baseline a shell write is measured from.
|
|
183
184
|
json.hooks.PreToolUse = [
|
|
184
185
|
...keep(json.hooks.PreToolUse),
|
|
185
|
-
{ matcher: "Edit|Write|MultiEdit", hooks: [{ type: "command", command: hookCmd }] },
|
|
186
|
+
{ matcher: "Edit|Write|MultiEdit|Bash|PowerShell", hooks: [{ type: "command", command: hookCmd }] },
|
|
186
187
|
];
|
|
187
188
|
json.hooks.UserPromptSubmit = [
|
|
188
189
|
...keep(json.hooks.UserPromptSubmit),
|
|
@@ -232,13 +233,9 @@ export function installClaudeHooks(root, hookCmd) {
|
|
|
232
233
|
}
|
|
233
234
|
/** Ownership marker for generated slash commands: its presence means Hunch may
|
|
234
235
|
* refresh the file; deleting the line hands the file to the user for good. */
|
|
235
|
-
const CMD_MARKER = "<!-- hunch:generated — refreshed by hunch init; delete this line to take ownership -->";
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
mkdirSync(dir, { recursive: true });
|
|
239
|
-
const written = [];
|
|
240
|
-
const skipped = [];
|
|
241
|
-
const files = [
|
|
236
|
+
export const CMD_MARKER = "<!-- hunch:generated — refreshed by hunch init; delete this line to take ownership -->";
|
|
237
|
+
function slashCommandFiles() {
|
|
238
|
+
return [
|
|
242
239
|
["hunch-why.md", WHY_CMD],
|
|
243
240
|
["hunch-fix.md", FIX_CMD],
|
|
244
241
|
["hunch-fragile.md", FRAGILE_CMD],
|
|
@@ -247,6 +244,15 @@ export function writeSlashCommands(root) {
|
|
|
247
244
|
["audit.md", AUDIT_CMD],
|
|
248
245
|
["worktrees.md", WORKTREES_CMD],
|
|
249
246
|
];
|
|
247
|
+
}
|
|
248
|
+
/** File names `writeSlashCommands` writes under .claude/commands/. */
|
|
249
|
+
export const SLASH_COMMAND_FILES = Object.freeze(slashCommandFiles().map(([name]) => name));
|
|
250
|
+
export function writeSlashCommands(root) {
|
|
251
|
+
const dir = join(root, ".claude", "commands");
|
|
252
|
+
mkdirSync(dir, { recursive: true });
|
|
253
|
+
const written = [];
|
|
254
|
+
const skipped = [];
|
|
255
|
+
const files = slashCommandFiles();
|
|
250
256
|
for (const [name, body] of files) {
|
|
251
257
|
const p = join(dir, name);
|
|
252
258
|
// Generic names (capture/heal/audit) are plausibly the USER'S OWN commands;
|
|
@@ -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
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -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
|