@davesheffer/hunch 1.43.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 +10 -0
- package/dist/cli/index.js +114 -19
- 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/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/procstart.d.ts +15 -4
- package/dist/core/procstart.js +56 -18
- package/dist/core/runbookDraft.d.ts +7 -0
- package/dist/core/runbookDraft.js +43 -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 +4 -4
- package/dist/integrations/scaffold.d.ts +5 -0
- package/dist/integrations/scaffold.js +12 -7
- 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/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
|
@@ -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
|
package/dist/mcp/server.js
CHANGED
|
@@ -32,7 +32,7 @@ import { knownRepoDeps } from "../synthesis/tripwires.js";
|
|
|
32
32
|
import { refreshExistingGrounding } from "../integrations/providers.js";
|
|
33
33
|
import { workspaceLedgerView, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, snapshotHasHome, recordWorkspaceSnapshot, branchRows, worktreeRows } from "../integrations/workspaceLedger.js";
|
|
34
34
|
import { workspacesConfig } from "../core/config.js";
|
|
35
|
-
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeGateDiff, commitFiles, commitGateDiff, stagedFiles, stagedGateDiff, workingFiles, workingGateDiff, pullHunchStatus, sameRemoteUrl, currentBranch, worktreePaths, pathKnownToHistory } from "../extractors/git.js";
|
|
35
|
+
import { revParse, asOfDate, revExists, lastChangeDate, checkFreshnessRevision, teamPullIsFresh, rangeFiles, rangeGateDiff, commitFiles, commitGateDiff, stagedFiles, stagedGateDiff, workingFiles, workingGateDiff, pullHunchStatus, sameRemoteUrl, currentBranch, worktreePaths, pathKnownToHistory } from "../extractors/git.js";
|
|
36
36
|
import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
|
|
37
37
|
import { withWriteLock } from "../serve/writelock.js";
|
|
38
38
|
import { advertisedTeamRemoteContract, ensureTeamOverlay, isTeamStoreTrusted, overlayMatchesTeamRemote, teamWiringConsented, untrustedTeamStoreMessage, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
|
|
@@ -940,6 +940,16 @@ function unconfirmedReason(c) {
|
|
|
940
940
|
: "this client could not ask the human to confirm it (no MCP form elicitation, or the request failed)";
|
|
941
941
|
}
|
|
942
942
|
const clipForPrompt = (s, max = 400) => (s.length > max ? `${s.slice(0, max - 1)}…` : s);
|
|
943
|
+
/** Whether a re-record changes what a human signed: every field except bookkeeping
|
|
944
|
+
* (provenance, date, valid_from). Fails closed: a field added to Decision later counts
|
|
945
|
+
* as substance until it is named here. Absent, null and empty-array values are equal. */
|
|
946
|
+
const DECISION_BOOKKEEPING = new Set(["provenance", "date", "valid_from"]);
|
|
947
|
+
export function decisionSubstanceChanged(before, after) {
|
|
948
|
+
const norm = (v) => (v === undefined || (Array.isArray(v) && v.length === 0) ? null : v);
|
|
949
|
+
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((k) => !DECISION_BOOKKEEPING.has(k)).sort();
|
|
950
|
+
const substance = (d) => JSON.stringify(keys.map((k) => [k, norm(d[k])]));
|
|
951
|
+
return substance(before) !== substance(after);
|
|
952
|
+
}
|
|
943
953
|
/** The interrogation protocol returned by hunch_capture_decision. With `deciding`,
|
|
944
954
|
* the choice is NOT yet made: the verdict loop runs first so the record's
|
|
945
955
|
* alternatives_rejected are attacks that actually ran — not post-hoc fiction. */
|
|
@@ -1018,7 +1028,7 @@ function pullBackoff(status, finishedAt, failures) {
|
|
|
1018
1028
|
if (status === "busy") {
|
|
1019
1029
|
return { consecutivePullFailures: failures, nextRemotePullAt: finishedAt + 100 };
|
|
1020
1030
|
}
|
|
1021
|
-
if (status === "unconfigured") {
|
|
1031
|
+
if (status === "unconfigured" || status === "empty") {
|
|
1022
1032
|
return { consecutivePullFailures: 0, nextRemotePullAt: finishedAt + 30_000 };
|
|
1023
1033
|
}
|
|
1024
1034
|
const consecutivePullFailures = Math.min(failures + 1, 6);
|
|
@@ -1954,6 +1964,24 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1954
1964
|
const decision = store.advisoryRecs("decisions").find((candidate) => candidate.id === decision_id);
|
|
1955
1965
|
if (!decision)
|
|
1956
1966
|
return invalid(`Imported ADR ${decision_id} is not present in the current advisory memory home.`);
|
|
1967
|
+
// Approval grants human authority, so the agent's report that the human approved
|
|
1968
|
+
// is not enough: the human confirms in the client prompt, a channel the agent does
|
|
1969
|
+
// not control, or approves with the CLI. A decline keeps the ADR advisory.
|
|
1970
|
+
if (disposition === "approve") {
|
|
1971
|
+
const confirmation = await askHumanToConfirm(server, [
|
|
1972
|
+
"Hunch: an agent reports that you approved this imported ADR as your decision. Confirm only if YOU approve it.",
|
|
1973
|
+
"",
|
|
1974
|
+
`Title: ${clipForPrompt(decision.title, 200)}`,
|
|
1975
|
+
`Decision: ${clipForPrompt(decision.decision || "(none)")}`,
|
|
1976
|
+
`Source hash: ${expected_source_hash}`,
|
|
1977
|
+
"",
|
|
1978
|
+
"Confirmed, it carries your authority (human_confirmed). Unconfirmed, it stays advisory.",
|
|
1979
|
+
].join("\n"));
|
|
1980
|
+
if (confirmation !== "confirmed") {
|
|
1981
|
+
return refused(`Not approved: ${unconfirmedReason(confirmation)}, so ${decision_id} stays advisory and nothing was written. ` +
|
|
1982
|
+
`The human approves it by running: hunch review --approve-import ${decision_id} --expected-source-hash ${expected_source_hash} --expected-review-hash ${expected_review_hash} --reviewed-by <you>`);
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1957
1985
|
const reviewed = applyImportedAdrReview(decision, {
|
|
1958
1986
|
disposition,
|
|
1959
1987
|
expectedSourceHash: expected_source_hash,
|
|
@@ -2046,7 +2074,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2046
2074
|
// bounded convergence. An unavailable remote produces an explicit abstention.
|
|
2047
2075
|
if (live.length === 0 && store.mode === "shared" && store.privateDir) {
|
|
2048
2076
|
const status = pullTeamMemory(true);
|
|
2049
|
-
if (status
|
|
2077
|
+
if (status === "cooldown" || status === "not_shared" || !teamPullIsFresh(status)) {
|
|
2050
2078
|
return err(`Shared team memory refresh is ${status}; Hunch cannot confirm that topic "${topic}" has no current decision. Retry when the shared store is available.`);
|
|
2051
2079
|
}
|
|
2052
2080
|
try {
|
|
@@ -2212,6 +2240,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2212
2240
|
valid_from: existing?.valid_from ?? now,
|
|
2213
2241
|
valid_to: existing?.valid_to ?? null,
|
|
2214
2242
|
retired: existing?.retired ?? { symbols: [], deps: [] },
|
|
2243
|
+
...(existing?.conformance ? { conformance: existing.conformance } : {}),
|
|
2244
|
+
...(existing?.visibility ? { visibility: existing.visibility } : {}),
|
|
2215
2245
|
provenance: { source: "agent_recorded", confidence: 0.75, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
|
|
2216
2246
|
date: now,
|
|
2217
2247
|
};
|
|
@@ -2261,6 +2291,23 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2261
2291
|
const humanSigned = confirmation === "confirmed";
|
|
2262
2292
|
if (slotConflict && !humanSigned)
|
|
2263
2293
|
return slotRefusal();
|
|
2294
|
+
// An inherited signature covers only the text the human signed. Changed substance
|
|
2295
|
+
// without a fresh confirmation would keep human_confirmed on words nobody vouched for.
|
|
2296
|
+
if (existingIsHuman && !humanSigned) {
|
|
2297
|
+
if (decisionSubstanceChanged(existing, rec)) {
|
|
2298
|
+
return refused(`Decision ${id} is human-confirmed and this write changes its content; refusing to keep the human's signature on unconfirmed text, so nothing was written. ` +
|
|
2299
|
+
"Record the change through /capture so the human confirms it in the client prompt.");
|
|
2300
|
+
}
|
|
2301
|
+
// Same substance: the human's record already says this. Rewriting it would only
|
|
2302
|
+
// replace the human's provenance with the agent's, so it is a no-op.
|
|
2303
|
+
return ok(`Decision ${id} is human-confirmed and already says this; nothing was written.`);
|
|
2304
|
+
}
|
|
2305
|
+
// Closing a human-confirmed decision is as much a change to it as rewriting it.
|
|
2306
|
+
const supersededIncumbent = decision.supersedes ? store.recs("decisions").find((d) => d.id === decision.supersedes) : undefined;
|
|
2307
|
+
if (supersededIncumbent && !humanSigned && supersededIncumbent.provenance.source.split("+").includes("human_confirmed")) {
|
|
2308
|
+
return refused(`Decision ${supersededIncumbent.id} is human-confirmed; an unconfirmed write cannot supersede it, so nothing was written. ` +
|
|
2309
|
+
"Record the change through /capture so the human confirms it in the client prompt.");
|
|
2310
|
+
}
|
|
2264
2311
|
const tier = humanSigned || existingIsHuman ? "human_confirmed" : "agent_recorded";
|
|
2265
2312
|
const source = existing && existing.provenance.source.includes("llm_draft")
|
|
2266
2313
|
? `llm_draft+${tier}`
|
|
@@ -2385,6 +2432,12 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2385
2432
|
}
|
|
2386
2433
|
const rec = buildCorrectionConstraint({ ...input, knownDeps, root, vouched: false }, now);
|
|
2387
2434
|
const existing = home === "private" ? store.getPrivateRec("constraints", rec.id) : store.json.get("constraints", rec.id);
|
|
2435
|
+
// A human-confirmed rule is changed only by the same human act that confirmed it.
|
|
2436
|
+
// Writing `rec` here would replace the signature with testimony and cap its severity.
|
|
2437
|
+
if (existing && existing.provenance.source.split("+").includes("human_confirmed")) {
|
|
2438
|
+
return refused(`Constraint ${rec.id} is already human-confirmed; an agent write cannot replace or downgrade it, so nothing was written. ` +
|
|
2439
|
+
`A change needs the human, outside the agent channel: ${confirmCommand(rec.id, { private: home === "private", severity: requested.severity })}`);
|
|
2440
|
+
}
|
|
2388
2441
|
// Same cross-home twin guard as the decision path above.
|
|
2389
2442
|
const stored = store.putCapture("constraints", rec, !!input.private);
|
|
2390
2443
|
const observed = observeReportCapture(root, input.task_id, "constraints", stored, home, !!existing, home === "private" ? store.privateDir ?? undefined : hunchPaths(root).hunch);
|
|
@@ -2812,7 +2865,8 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2812
2865
|
if (!files.length)
|
|
2813
2866
|
return ok(`VERDICT: ✅ PASS — no changed files in ${scope}.`);
|
|
2814
2867
|
const gate = commit ? commitGateDiff(commit, root) : base ? rangeGateDiff(base, root) : working ? workingGateDiff(root) : stagedGateDiff(root);
|
|
2815
|
-
const
|
|
2868
|
+
const freshAt = checkFreshnessRevision({ base, commit }, root);
|
|
2869
|
+
const report = store.buildCheckReport(files, gate.diff, { strict: true, lastChange: (f) => lastChangeDate(f, root, freshAt), diffStatus: gate });
|
|
2816
2870
|
const v = verdict(report);
|
|
2817
2871
|
const head = v === "block"
|
|
2818
2872
|
? "VERDICT: ⛔ BLOCK — a recorded guard requires review; inspect the cited scope and evidence below before merge."
|
|
@@ -7,6 +7,10 @@ export declare const ACQUIRE_TIMEOUT_MS = 10000;
|
|
|
7
7
|
* claimed section is a few syscalls, so anything this old means its claimer
|
|
8
8
|
* crashed inside it. */
|
|
9
9
|
export declare const CLAIM_STALE_MS = 10000;
|
|
10
|
+
/** How much later than the lock's recorded `at` the pid's current process must
|
|
11
|
+
* have been created before it is proven not to be the owner. A genuine owner
|
|
12
|
+
* started BEFORE it took the lock; the slack only absorbs clock granularity. */
|
|
13
|
+
export declare const CREATED_AFTER_SLACK_MS = 5000;
|
|
10
14
|
export interface LockOwner {
|
|
11
15
|
pid: number;
|
|
12
16
|
host: string;
|
|
@@ -22,6 +26,9 @@ export interface StartTokenProbe {
|
|
|
22
26
|
self: string | null;
|
|
23
27
|
of: (pid: number) => string | null;
|
|
24
28
|
platform?: NodeJS.Platform;
|
|
29
|
+
/** When the process now holding `pid` was created (wall-clock ms), or null.
|
|
30
|
+
* Defaults to the OS probe `processStartedAt`. */
|
|
31
|
+
startedAt?: (pid: number) => number | null;
|
|
25
32
|
}
|
|
26
33
|
export declare class WriteLockTimeout extends Error {
|
|
27
34
|
readonly path: string;
|