@davesheffer/hunch 1.44.0 → 1.45.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -16
- package/dist/cli/index.js +153 -70
- package/dist/core/agenthook.d.ts +1 -0
- package/dist/core/agenthook.js +11 -0
- package/dist/core/escalations.d.ts +20 -0
- package/dist/core/escalations.js +47 -0
- package/dist/core/hookText.js +1 -1
- package/dist/integrations/claudemd.js +1 -1
- package/dist/integrations/gitignore.d.ts +16 -0
- package/dist/integrations/gitignore.js +30 -0
- package/dist/integrations/hooks.d.ts +20 -0
- package/dist/integrations/hooks.js +13 -6
- package/dist/integrations/providers.d.ts +9 -0
- package/dist/integrations/providers.js +73 -9
- package/dist/integrations/scaffold.js +1 -1
- package/dist/integrations/uninstall.d.ts +2 -0
- package/dist/integrations/uninstall.js +72 -11
- package/dist/mcp/leanToolList.d.ts +9 -0
- package/dist/mcp/leanToolList.js +89 -0
- package/dist/mcp/server.js +4 -2
- package/dist/serve/mcpHttp.js +2 -0
- package/package.json +2 -5
- package/server.json +2 -2
- package/tooling/md1-benchmark.mjs +1 -1
- package/tooling/competitive-watch.mjs +0 -142
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* block written by an older release gains entries added later) and is otherwise
|
|
13
13
|
* byte-identical; everything outside the markers is left untouched.
|
|
14
14
|
*/
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
15
16
|
import { readFileSync, existsSync, lstatSync, realpathSync } from "node:fs";
|
|
16
17
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
17
18
|
import { writeFileAtomic } from "../core/io.js";
|
|
@@ -231,6 +232,35 @@ export function describeGitignoreUpgrade(upgrade) {
|
|
|
231
232
|
}
|
|
232
233
|
return out;
|
|
233
234
|
}
|
|
235
|
+
/** The user's ignore rule that keeps engineering memory out of Git, or null.
|
|
236
|
+
* A repository that ignores `.hunch/` (or `.hunch/decisions/`) silently loses every
|
|
237
|
+
* capture: the records are written, never committed, and gone on a fresh clone.
|
|
238
|
+
* Hunch's own private-only block ignores the same paths on purpose (memory lives
|
|
239
|
+
* in an overlay), so a rule inside that block is not reported. Unknown git state
|
|
240
|
+
* yields null: the caller then says nothing rather than guessing. */
|
|
241
|
+
export function memoryIgnoreRule(root) {
|
|
242
|
+
const probe = ".hunch/decisions/dec_0000000000.json";
|
|
243
|
+
const r = spawnSync("git", ["check-ignore", "-v", "--no-index", "--", probe], { cwd: root, encoding: "utf8", windowsHide: true });
|
|
244
|
+
if (r.error || r.status !== 0)
|
|
245
|
+
return null;
|
|
246
|
+
const m = /^(.*):(\d+):(.*)\t/.exec(r.stdout ?? "");
|
|
247
|
+
if (!m)
|
|
248
|
+
return null;
|
|
249
|
+
const rule = { source: m[1], line: Number(m[2]), pattern: m[3] };
|
|
250
|
+
if (rule.pattern.startsWith("!"))
|
|
251
|
+
return null;
|
|
252
|
+
const file = resolve(root, rule.source);
|
|
253
|
+
if (existsSync(file)) {
|
|
254
|
+
const span = findBlock(readFileSync(file, "utf8").split("\n"), MEM_MARK, MEM_END);
|
|
255
|
+
if (span && rule.line - 1 > span.start && rule.line - 1 < span.end)
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
return rule;
|
|
259
|
+
}
|
|
260
|
+
/** One line telling the user their memory will not reach Git, and how to fix it. */
|
|
261
|
+
export function describeMemoryIgnoreRule(rule) {
|
|
262
|
+
return `${rule.source} line ${rule.line} (\`${rule.pattern}\`) ignores .hunch/, so captured memory is never committed or shared and is lost on a fresh clone. Remove that line, or keep memory in a private repository with \`hunch private --repo <url>\`.`;
|
|
263
|
+
}
|
|
234
264
|
/** `.gitignore` text with both managed Hunch blocks removed (markers inclusive),
|
|
235
265
|
* every other byte kept, for `hunch uninstall --purge` (#449). `damaged` when a
|
|
236
266
|
* marker line has no matching end line: the block is never guessed at. */
|
|
@@ -61,6 +61,11 @@ export declare const PORTABLE_HOOK_INVOCATION: string;
|
|
|
61
61
|
* or a trailing top-level `if … else … fi` whose every branch ends in
|
|
62
62
|
* `exec`/`exit` — the shape of the pre-commit framework's generated hook. */
|
|
63
63
|
export declare function terminalExitLine(content: string): string | null;
|
|
64
|
+
export declare function isInside(parent: string, child: string): boolean;
|
|
65
|
+
export declare function gitRun(args: string[], cwd: string): {
|
|
66
|
+
status: number | null;
|
|
67
|
+
stdout: string;
|
|
68
|
+
};
|
|
64
69
|
type BlockState = "installed" | "unreachable" | "stale" | "missing";
|
|
65
70
|
/** The result of inspecting the command a managed block actually runs (issue #315). */
|
|
66
71
|
export type HookInvocationHealth = {
|
|
@@ -78,6 +83,21 @@ export type HookInvocationHealth = {
|
|
|
78
83
|
* the subcommand; env assignments and a leading `(` belong to the block's own
|
|
79
84
|
* shape and are stripped. Never spawns anything, never touches the network. */
|
|
80
85
|
export declare function hookInvocationHealth(command: string, root: string): HookInvocationHealth;
|
|
86
|
+
/** The repo's worktree tops as `git worktree list --porcelain` reports them:
|
|
87
|
+
* `linked` is every entry AFTER the first (the first is always the main
|
|
88
|
+
* worktree, or the bare repo) that is not the bare entry, and `count` is how
|
|
89
|
+
* many non-bare worktrees the repo has — the number the shared-hooks note
|
|
90
|
+
* quotes, which must not include a bare repo's phantom entry. `main` is the
|
|
91
|
+
* first entry when it is a checkout, null when the common dir is bare (no
|
|
92
|
+
* main checkout exists). Entries are
|
|
93
|
+
* separated by blank lines and each starts with a `worktree <path>` line.
|
|
94
|
+
* Read straight from git rather than the workspace ledger: this runs during
|
|
95
|
+
* `hunch init`, before any ledger exists. */
|
|
96
|
+
export declare function worktreeTops(root: string): {
|
|
97
|
+
linked: string[];
|
|
98
|
+
count: number;
|
|
99
|
+
main: string | null;
|
|
100
|
+
};
|
|
81
101
|
export declare function installPostCommitHook(root: string, invocation: string, opts?: {
|
|
82
102
|
private?: boolean;
|
|
83
103
|
commit?: boolean;
|
|
@@ -139,12 +139,12 @@ function realish(p) {
|
|
|
139
139
|
return resolve(p);
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
-
function isInside(parent, child) {
|
|
142
|
+
export function isInside(parent, child) {
|
|
143
143
|
const norm = (p) => (process.platform === "win32" ? realish(p).toLowerCase() : realish(p));
|
|
144
144
|
const rel = relative(norm(parent), norm(child));
|
|
145
145
|
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
146
146
|
}
|
|
147
|
-
function gitRun(args, cwd) {
|
|
147
|
+
export function gitRun(args, cwd) {
|
|
148
148
|
const p = spawnSync("git", args, { cwd, encoding: "utf8", env: initiatorChildEnv(), stdio: ["ignore", "pipe", "ignore"] });
|
|
149
149
|
return { status: p.status, stdout: (p.stdout ?? "").trim() };
|
|
150
150
|
}
|
|
@@ -504,14 +504,16 @@ function snippetFor(t, mark, build, localInvocation) {
|
|
|
504
504
|
* `linked` is every entry AFTER the first (the first is always the main
|
|
505
505
|
* worktree, or the bare repo) that is not the bare entry, and `count` is how
|
|
506
506
|
* many non-bare worktrees the repo has — the number the shared-hooks note
|
|
507
|
-
* quotes, which must not include a bare repo's phantom entry.
|
|
507
|
+
* quotes, which must not include a bare repo's phantom entry. `main` is the
|
|
508
|
+
* first entry when it is a checkout, null when the common dir is bare (no
|
|
509
|
+
* main checkout exists). Entries are
|
|
508
510
|
* separated by blank lines and each starts with a `worktree <path>` line.
|
|
509
511
|
* Read straight from git rather than the workspace ledger: this runs during
|
|
510
512
|
* `hunch init`, before any ledger exists. */
|
|
511
|
-
function worktreeTops(root) {
|
|
513
|
+
export function worktreeTops(root) {
|
|
512
514
|
const out = gitRun(["worktree", "list", "--porcelain"], root).stdout;
|
|
513
515
|
if (!out)
|
|
514
|
-
return { linked: [], count: 0 };
|
|
516
|
+
return { linked: [], count: 0, main: null };
|
|
515
517
|
const entries = normalizeEol(out).split(/\n\s*\n/).map((e) => e.split("\n"));
|
|
516
518
|
const parsed = entries
|
|
517
519
|
.map((lines) => ({
|
|
@@ -519,7 +521,12 @@ function worktreeTops(root) {
|
|
|
519
521
|
bare: lines.some((l) => l.trim() === "bare"),
|
|
520
522
|
}))
|
|
521
523
|
.filter((e) => e.path !== "");
|
|
522
|
-
|
|
524
|
+
const first = parsed[0];
|
|
525
|
+
return {
|
|
526
|
+
linked: parsed.slice(1).filter((e) => !e.bare).map((e) => e.path),
|
|
527
|
+
count: parsed.filter((e) => !e.bare).length,
|
|
528
|
+
main: first && !first.bare ? first.path : null,
|
|
529
|
+
};
|
|
523
530
|
}
|
|
524
531
|
/** The LINKED worktree an invocation is bound to — one it can only work from
|
|
525
532
|
* inside, because some token is an absolute path under it (that worktree's own
|
|
@@ -115,7 +115,16 @@ export interface ProviderScaffold {
|
|
|
115
115
|
* per-assistant summary for `hunch init` to print. Each assistant is isolated:
|
|
116
116
|
* a writer that refuses to clobber a malformed file degrades to a warning rather
|
|
117
117
|
* than aborting the rest. Claude Code is handled separately by scaffold.ts. */
|
|
118
|
+
/** The assistants `scaffoldProviders` can configure, besides the always-written AGENTS.md. */
|
|
119
|
+
export declare const PROVIDER_ASSISTANTS: readonly ["Cursor", "VS Code (Copilot)", "Codex CLI", "Windsurf", "Google Antigravity"];
|
|
120
|
+
export type ProviderAssistant = typeof PROVIDER_ASSISTANTS[number];
|
|
121
|
+
/** Assistants with a footprint here: a config already in the repository (a teammate
|
|
122
|
+
* set it up), a settings folder in the user's home, or a command on PATH. `hunch init`
|
|
123
|
+
* configures only these, so a repository is not filled with settings for tools nobody
|
|
124
|
+
* on it uses; when none is found it configures every assistant, as before. */
|
|
125
|
+
export declare function detectAssistants(root: string, home?: string, env?: NodeJS.ProcessEnv): Set<ProviderAssistant>;
|
|
118
126
|
export declare function scaffoldProviders(root: string, inv: Invocation, store: HunchStore, options?: {
|
|
119
127
|
agentHooks?: boolean;
|
|
120
128
|
home?: string;
|
|
129
|
+
only?: ReadonlySet<string>;
|
|
121
130
|
}): ProviderScaffold[];
|
|
@@ -20,12 +20,13 @@
|
|
|
20
20
|
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
21
21
|
import { writeFileAtomic } from "../core/io.js";
|
|
22
22
|
import { homedir } from "node:os";
|
|
23
|
-
import { join, dirname } from "node:path";
|
|
23
|
+
import { join, dirname, delimiter } from "node:path";
|
|
24
24
|
import { isHunchHookCommand } from "./hookmatch.js";
|
|
25
25
|
import { renderHunchSection, stripManagedSection, upsertSection, updateClaudeMd, preserveNewerTemplate } from "./claudemd.js";
|
|
26
26
|
import { headFileContent, isGitCleanPath } from "../extractors/git.js";
|
|
27
27
|
import { parseJsonc } from "../core/jsonc.js";
|
|
28
28
|
import { parse as parseToml } from "smol-toml";
|
|
29
|
+
import { publishedMcpInvocation } from "../cli/invocation.js";
|
|
29
30
|
/** JSONC comments are user prose. A parsed object cannot retain their position,
|
|
30
31
|
* so refuse a write rather than reserialize the file without them. */
|
|
31
32
|
function hasJsoncComments(raw) {
|
|
@@ -388,18 +389,52 @@ export function writeVscodeHooks(root, inv) {
|
|
|
388
389
|
Stop: [{ type: "command", command }],
|
|
389
390
|
});
|
|
390
391
|
}
|
|
392
|
+
const WINDSURF_EVENTS = ["pre_user_prompt", "pre_write_code", "post_write_code", "post_run_command"];
|
|
393
|
+
/** The published npx pin (any version), in the current spelling and in the fully
|
|
394
|
+
* quoted one written before v1.21.1 (`"npx" "-y" "--package=…" …`). */
|
|
395
|
+
const PUBLISHED_PIN = /^\s*"?npx(?:\.cmd)?"?\s+"?-y"?\s+"?--package=(?:hunch-exact@npm:)?@davesheffer\/hunch/;
|
|
396
|
+
/** The events of `file` holding a Hunch hook entry, when it has at least one and
|
|
397
|
+
* every one of them runs the published pin; null otherwise. Throws, like every
|
|
398
|
+
* writer, on a file it cannot parse. */
|
|
399
|
+
function publishedPinEvents(file) {
|
|
400
|
+
const hooks = objectField(readJsonObj(file), "hooks", file);
|
|
401
|
+
const events = new Set();
|
|
402
|
+
let onlyPins = true;
|
|
403
|
+
const collect = (event) => (entry) => {
|
|
404
|
+
const e = entry && typeof entry === "object" ? entry : null;
|
|
405
|
+
if (Array.isArray(e?.hooks) && typeof e.command !== "string")
|
|
406
|
+
e.hooks.forEach(collect(event));
|
|
407
|
+
else if (typeof e?.command === "string") {
|
|
408
|
+
if (PUBLISHED_PIN.test(e.command))
|
|
409
|
+
events.add(event);
|
|
410
|
+
else
|
|
411
|
+
onlyPins = false;
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
for (const [event, entries] of Object.entries(hooks)) {
|
|
415
|
+
if (Array.isArray(entries))
|
|
416
|
+
entries.filter(isHunchProviderHook).forEach(collect(event));
|
|
417
|
+
}
|
|
418
|
+
return onlyPins && events.size > 0 ? events : null;
|
|
419
|
+
}
|
|
391
420
|
/** Windsurf's documented workspace hooks. It only supports deterministic
|
|
392
421
|
* pre-hook blocking via exit code 2, so Hunch uses rules + MCP for context and
|
|
393
422
|
* reserves the hook for strict edit protection and pipeline observation. */
|
|
394
423
|
export function writeWindsurfHooks(root, inv) {
|
|
395
424
|
const file = join(root, ".windsurf", "hooks.json");
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
425
|
+
const published = hookCommand(publishedMcpInvocation(), "windsurf");
|
|
426
|
+
let command = hookCommand(inv, "windsurf");
|
|
427
|
+
// A source-checkout run (tsx / local dist) must never replace a tracked
|
|
428
|
+
// published pin with a machine-local path (#466): a complete pinned file is
|
|
429
|
+
// kept as is, and a partial one is completed with the published pin.
|
|
430
|
+
if (command !== published) {
|
|
431
|
+
const pinned = publishedPinEvents(file);
|
|
432
|
+
if (pinned && WINDSURF_EVENTS.every((event) => pinned.has(event)))
|
|
433
|
+
return file;
|
|
434
|
+
if (pinned)
|
|
435
|
+
command = published;
|
|
436
|
+
}
|
|
437
|
+
return writeHookConfig(file, Object.fromEntries(WINDSURF_EVENTS.map((event) => [event, [{ command, show_output: false }]])));
|
|
403
438
|
}
|
|
404
439
|
function antigravityHandler(command) {
|
|
405
440
|
return { type: "command", command, timeout: 15 };
|
|
@@ -560,6 +595,32 @@ function runProvider(writers) {
|
|
|
560
595
|
* per-assistant summary for `hunch init` to print. Each assistant is isolated:
|
|
561
596
|
* a writer that refuses to clobber a malformed file degrades to a warning rather
|
|
562
597
|
* than aborting the rest. Claude Code is handled separately by scaffold.ts. */
|
|
598
|
+
/** The assistants `scaffoldProviders` can configure, besides the always-written AGENTS.md. */
|
|
599
|
+
export const PROVIDER_ASSISTANTS = ["Cursor", "VS Code (Copilot)", "Codex CLI", "Windsurf", "Google Antigravity"];
|
|
600
|
+
function onPath(names, env) {
|
|
601
|
+
const dirs = (env.PATH ?? env.Path ?? "").split(delimiter).filter(Boolean);
|
|
602
|
+
const exts = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").concat("") : [""];
|
|
603
|
+
return dirs.some((dir) => names.some((name) => exts.some((ext) => existsSync(join(dir, name + ext)))));
|
|
604
|
+
}
|
|
605
|
+
/** Assistants with a footprint here: a config already in the repository (a teammate
|
|
606
|
+
* set it up), a settings folder in the user's home, or a command on PATH. `hunch init`
|
|
607
|
+
* configures only these, so a repository is not filled with settings for tools nobody
|
|
608
|
+
* on it uses; when none is found it configures every assistant, as before. */
|
|
609
|
+
export function detectAssistants(root, home = homedir(), env = process.env) {
|
|
610
|
+
const has = (...paths) => paths.some((p) => existsSync(p));
|
|
611
|
+
const found = new Set();
|
|
612
|
+
if (has(join(root, ".cursor"), join(home, ".cursor")) || onPath(["cursor", "cursor-agent"], env))
|
|
613
|
+
found.add("Cursor");
|
|
614
|
+
if (has(join(root, ".vscode"), join(root, ".github", "copilot-instructions.md"), join(home, ".vscode")) || onPath(["code", "code-insiders"], env))
|
|
615
|
+
found.add("VS Code (Copilot)");
|
|
616
|
+
if (has(join(root, ".codex"), join(home, ".codex")) || onPath(["codex"], env))
|
|
617
|
+
found.add("Codex CLI");
|
|
618
|
+
if (has(join(root, ".windsurf"), join(home, ".codeium", "windsurf")) || onPath(["windsurf"], env))
|
|
619
|
+
found.add("Windsurf");
|
|
620
|
+
if (has(join(root, ".agents", "mcp_config.json"), join(home, ".gemini")) || onPath(["antigravity"], env))
|
|
621
|
+
found.add("Google Antigravity");
|
|
622
|
+
return found;
|
|
623
|
+
}
|
|
563
624
|
export function scaffoldProviders(root, inv, store, options = {}) {
|
|
564
625
|
const hooks = options.agentHooks !== false;
|
|
565
626
|
const home = options.home;
|
|
@@ -584,6 +645,9 @@ export function scaffoldProviders(root, inv, store, options = {}) {
|
|
|
584
645
|
}],
|
|
585
646
|
["Any (AGENTS.md)", () => runProvider([() => writeAgentsMd(root, store)])],
|
|
586
647
|
];
|
|
587
|
-
|
|
648
|
+
const only = options.only;
|
|
649
|
+
return tasks
|
|
650
|
+
.filter(([assistant]) => !only || only.has(assistant) || assistant === "Any (AGENTS.md)")
|
|
651
|
+
.map(([assistant, run]) => ({ assistant, ...run() }));
|
|
588
652
|
}
|
|
589
653
|
//# sourceMappingURL=providers.js.map
|
|
@@ -62,7 +62,7 @@ Follow the Hunch-grounded workflow (DESIGN §5) — do NOT skip the memory looku
|
|
|
62
62
|
2. Identify the suspect symbol/file, then \`hunch_get_dependents(<symbol>)\` to learn the blast radius.
|
|
63
63
|
3. \`hunch_check_constraints(<scope>)\` — list invariants you must preserve.
|
|
64
64
|
4. Propose a fix that honors past root causes AND constraints. Apply it and run the tests.
|
|
65
|
-
5. If the
|
|
65
|
+
5. If the human decided or corrected something during the fix, record it (\`hunch_record_decision\` / \`hunch_record_correction\`); the commit itself is captured automatically.
|
|
66
66
|
`;
|
|
67
67
|
const FRAGILE_CMD = `---
|
|
68
68
|
description: Report the most fragile parts of this codebase, with evidence
|
|
@@ -17,6 +17,8 @@ export interface UninstallPlan {
|
|
|
17
17
|
path: string;
|
|
18
18
|
reason: string;
|
|
19
19
|
}>;
|
|
20
|
+
/** Consequences beyond this checkout (other worktrees, other branches); printed, never refused over. */
|
|
21
|
+
warnings?: string[];
|
|
20
22
|
}
|
|
21
23
|
/** Read-only: everything `hunch uninstall` would do in `root`. */
|
|
22
24
|
export declare function planUninstall(root: string, opts?: {
|
|
@@ -14,15 +14,15 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync, lstatSync, readFileSync, readdirSync, rmdirSync, rmSync, statSync, writeFileSync, chmodSync } from "node:fs";
|
|
16
16
|
import { spawnSync } from "node:child_process";
|
|
17
|
-
import { basename, join, relative } from "node:path";
|
|
17
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
18
18
|
import { parse as parseToml } from "smol-toml";
|
|
19
19
|
import { writeFileAtomic } from "../core/io.js";
|
|
20
|
-
import { gitCommonDir, isGitRepo, separateGitDirLink } from "../extractors/git.js";
|
|
20
|
+
import { currentBranch, gitCommonDir, isGitRepo, isLinkedWorktree, separateGitDirLink } from "../extractors/git.js";
|
|
21
21
|
import { HUNCH_END, HUNCH_START } from "./claudemd.js";
|
|
22
22
|
import { ciWorkflowYaml } from "./ciAction.js";
|
|
23
23
|
import { assertSafeTopLevelConfigFile, stripManagedGitignore } from "./gitignore.js";
|
|
24
24
|
import { isHunchHookCommand } from "./hookmatch.js";
|
|
25
|
-
import { planHookRemoval } from "./hooks.js";
|
|
25
|
+
import { gitRun, isInside, planHookRemoval, worktreeTops } from "./hooks.js";
|
|
26
26
|
import { ATTR_LINES, targetRepositoryEnv } from "./mergeDriver.js";
|
|
27
27
|
import { antigravityMcpFile, isHunchProviderHook, readJsonObj, TOML_END, TOML_START, windsurfMcpFile } from "./providers.js";
|
|
28
28
|
import { SLASH_COMMAND_FILES } from "./scaffold.js";
|
|
@@ -195,6 +195,31 @@ function gitConfigHasDrivers(root) {
|
|
|
195
195
|
const r = spawnSync("git", ["config", "--local", "--get-regexp", "^merge\\.hunch(-grounding)?\\."], { cwd: root, env: targetRepositoryEnv(), encoding: "utf8" });
|
|
196
196
|
return r.status === 0 && r.stdout.trim() !== "";
|
|
197
197
|
}
|
|
198
|
+
/** The branch the clone treats as default: origin's HEAD, else a local main, else
|
|
199
|
+
* master; "" when none can be named. */
|
|
200
|
+
function defaultBranch(root) {
|
|
201
|
+
const head = gitRun(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], root);
|
|
202
|
+
if (head.status === 0 && head.stdout)
|
|
203
|
+
return head.stdout.replace(/^origin\//, "");
|
|
204
|
+
for (const b of ["main", "master"]) {
|
|
205
|
+
if (gitRun(["show-ref", "--verify", "--quiet", `refs/heads/${b}`], root).status === 0)
|
|
206
|
+
return b;
|
|
207
|
+
}
|
|
208
|
+
return "";
|
|
209
|
+
}
|
|
210
|
+
/** The marker is committed per branch: off the default branch, say the rest of the clone still has Hunch. */
|
|
211
|
+
function branchWarning(root) {
|
|
212
|
+
try {
|
|
213
|
+
const branch = currentBranch(root);
|
|
214
|
+
const def = branch ? defaultBranch(root) : "";
|
|
215
|
+
if (!branch || !def || branch === def)
|
|
216
|
+
return null;
|
|
217
|
+
return `the ${OPT_OUT_FILE} marker is only on branch ${branch}; other branches of this clone still have Hunch's instruction blocks and config — commit and merge it, or run uninstall there too`;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
198
223
|
/** The overlay clone a pointer names, for the "kept" note. Never deleted: it is
|
|
199
224
|
* its own repository holding memory that may not be pushed yet. */
|
|
200
225
|
function pointerOverlay(file) {
|
|
@@ -295,22 +320,49 @@ export function planUninstall(root, opts = {}) {
|
|
|
295
320
|
p.steps.push({ path: ciRel, kind: "keep", note: "it differs from the workflow Hunch generates (edited?); delete it yourself if you no longer want the Hunch Guard check" });
|
|
296
321
|
}
|
|
297
322
|
}
|
|
298
|
-
// Git hooks + merge driver.
|
|
323
|
+
// Git hooks + merge driver. Both live in the clone's common git dir, so they
|
|
324
|
+
// serve every worktree and every branch (#459): a linked worktree leaves them to
|
|
325
|
+
// the main checkout, and the main checkout says which worktrees lose them too.
|
|
326
|
+
// A bare common dir has no main checkout, so any worktree removes them and warns.
|
|
327
|
+
const warnings = [];
|
|
299
328
|
if (isGitRepo(root)) {
|
|
329
|
+
const commonDir = gitCommonDir(root);
|
|
330
|
+
const top = gitRun(["rev-parse", "--show-toplevel"], root).stdout;
|
|
331
|
+
const here = top || root;
|
|
332
|
+
const same = (a, b) => isInside(a, b) && isInside(b, a);
|
|
333
|
+
// The main checkout as git lists it, not the common dir's parent, which in a
|
|
334
|
+
// bare clone or a --separate-git-dir checkout is no checkout at all.
|
|
335
|
+
const tops = worktreeTops(root);
|
|
336
|
+
const mainCheckout = tops.main;
|
|
337
|
+
const linked = commonDir !== "" && mainCheckout !== null && !same(mainCheckout, here) && isLinkedWorktree(root);
|
|
338
|
+
// The same test `hunch init` uses: in the common dir, or outside this checkout.
|
|
339
|
+
const shared = (file) => (commonDir !== "" && isInside(commonDir, file)) || (top !== "" && !isInside(top, file));
|
|
340
|
+
const elsewhere = (what, it) => `${what} shared by every worktree of this clone — run \`hunch uninstall\` from the main checkout (${mainCheckout}) to remove ${it}`;
|
|
341
|
+
let removesShared = false;
|
|
300
342
|
for (const h of planHookRemoval(root)) {
|
|
301
343
|
const path = p.rel(h.path);
|
|
302
344
|
if (h.reason)
|
|
303
345
|
p.steps.push({ path, kind: "keep", note: h.reason });
|
|
304
|
-
else if (h.
|
|
305
|
-
p.steps.push({ path, kind: "
|
|
346
|
+
else if (linked && shared(h.path))
|
|
347
|
+
p.steps.push({ path, kind: "keep", note: elsewhere(`Hunch's ${h.hookName} git hook is`, "it") });
|
|
306
348
|
else {
|
|
307
|
-
|
|
308
|
-
|
|
349
|
+
if (shared(h.path))
|
|
350
|
+
removesShared = true;
|
|
351
|
+
if (h.next === null)
|
|
352
|
+
p.steps.push({ path, kind: "delete", note: `Hunch's ${h.hookName} git hook`, run: () => rmSync(h.path, { force: true }) });
|
|
353
|
+
else {
|
|
354
|
+
const next = h.next;
|
|
355
|
+
p.steps.push({ path, kind: "edit", note: `removed Hunch's block from the ${h.hookName} git hook (your own lines are kept)`, run: () => { writeFileSync(h.path, next); chmodSync(h.path, 0o755); } });
|
|
356
|
+
}
|
|
309
357
|
}
|
|
310
358
|
}
|
|
311
|
-
if (gitConfigHasDrivers(root)) {
|
|
359
|
+
if (gitConfigHasDrivers(root) && linked) {
|
|
360
|
+
p.steps.push({ path: p.rel(join(commonDir, "config")), kind: "keep", note: elsewhere("the merge.hunch and merge.hunch-grounding drivers are", "them") });
|
|
361
|
+
}
|
|
362
|
+
else if (gitConfigHasDrivers(root)) {
|
|
363
|
+
removesShared = true;
|
|
312
364
|
p.steps.push({
|
|
313
|
-
path: ".git/config", kind: "edit", note: "unregistered the merge.hunch and merge.hunch-grounding drivers",
|
|
365
|
+
path: commonDir ? p.rel(join(commonDir, "config")) : ".git/config", kind: "edit", note: "unregistered the merge.hunch and merge.hunch-grounding drivers",
|
|
314
366
|
run: () => {
|
|
315
367
|
for (const section of ["merge.hunch", "merge.hunch-grounding"]) {
|
|
316
368
|
spawnSync("git", ["config", "--local", "--remove-section", section], { cwd: root, env: targetRepositoryEnv(), stdio: "ignore" });
|
|
@@ -318,6 +370,13 @@ export function planUninstall(root, opts = {}) {
|
|
|
318
370
|
},
|
|
319
371
|
});
|
|
320
372
|
}
|
|
373
|
+
const others = linked ? [] : tops.linked.filter((w) => !same(w, here)).map((w) => resolve(w));
|
|
374
|
+
if (removesShared && others.length) {
|
|
375
|
+
warnings.push(`the git hooks and merge drivers are shared with ${others.length === 1 ? "the linked worktree" : `${others.length} linked worktrees`} ${others.join(", ")}, so they are removed there too, though ${others.length === 1 ? "it still has" : "those still have"} Hunch and no ${OPT_OUT_FILE} marker`);
|
|
376
|
+
}
|
|
377
|
+
const onBranch = branchWarning(root);
|
|
378
|
+
if (onBranch)
|
|
379
|
+
warnings.push(onBranch);
|
|
321
380
|
}
|
|
322
381
|
const attrs = p.text(".gitattributes");
|
|
323
382
|
if (attrs !== null) {
|
|
@@ -378,7 +437,7 @@ export function planUninstall(root, opts = {}) {
|
|
|
378
437
|
for (const dir of overlays) {
|
|
379
438
|
p.steps.push({ path: p.rel(dir), kind: "keep", note: "a private/shared overlay clone: its own repository with memory that may not be pushed; never deleted here" });
|
|
380
439
|
}
|
|
381
|
-
return { root, purge, steps: p.steps, refused: p.refused };
|
|
440
|
+
return { root, purge, steps: p.steps, refused: p.refused, warnings };
|
|
382
441
|
}
|
|
383
442
|
/** Directories Hunch's writers create; removed only when left empty. Deepest first. */
|
|
384
443
|
const PRUNE_DIRS = [
|
|
@@ -430,6 +489,8 @@ export function formatUninstallPlan(plan, dryRun) {
|
|
|
430
489
|
out.push(` ✗ ${r.path}: ${r.reason}`);
|
|
431
490
|
for (const s of plan.steps)
|
|
432
491
|
out.push(` ${icon(s.kind)} ${verb(s.kind)} ${s.path} — ${s.note}`);
|
|
492
|
+
for (const w of plan.warnings ?? [])
|
|
493
|
+
out.push(` ⚠ ${w}`);
|
|
433
494
|
return out;
|
|
434
495
|
}
|
|
435
496
|
//# sourceMappingURL=uninstall.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
type JsonObject = Record<string, unknown>;
|
|
3
|
+
/** One tool definition as the host should see it. */
|
|
4
|
+
export declare function leanTool(tool: JsonObject): JsonObject;
|
|
5
|
+
/** Route the SDK's tools/list result through leanTool. Call right after
|
|
6
|
+
* constructing the server: McpServer installs its tools/list handler on the
|
|
7
|
+
* first registerTool, through this public setRequestHandler. */
|
|
8
|
+
export declare function installLeanToolList(server: McpServer): void;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
3
|
+
// Keywords whose meaning differs between draft-07 and 2020-12 — the dialect MCP
|
|
4
|
+
// assumes when `$schema` is absent. A schema that uses any keeps its marker.
|
|
5
|
+
// A property merely NAMED like one also keeps it: conservative, never wrong.
|
|
6
|
+
const DIALECT_SENSITIVE = new Set(["definitions", "dependencies", "additionalItems", "$ref", "$id", "$recursiveRef"]);
|
|
7
|
+
function dialectNeutral(node) {
|
|
8
|
+
if (Array.isArray(node))
|
|
9
|
+
return node.every(dialectNeutral);
|
|
10
|
+
if (!isObject(node))
|
|
11
|
+
return true;
|
|
12
|
+
for (const [key, value] of Object.entries(node)) {
|
|
13
|
+
if (DIALECT_SENSITIVE.has(key) || (key === "items" && Array.isArray(value)))
|
|
14
|
+
return false;
|
|
15
|
+
if (!dialectNeutral(value))
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
// Keywords whose value is data, not a subschema: copied as-is.
|
|
21
|
+
const VALUE_KEYWORDS = new Set(["default", "const", "enum", "examples"]);
|
|
22
|
+
/** A copy without zod's safe-integer bounds, which only restate what JSON numbers can hold. */
|
|
23
|
+
function withoutSafeIntegerBounds(node) {
|
|
24
|
+
if (Array.isArray(node))
|
|
25
|
+
return node.map(withoutSafeIntegerBounds);
|
|
26
|
+
if (!isObject(node))
|
|
27
|
+
return node;
|
|
28
|
+
const integer = node.type === "integer";
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const [key, value] of Object.entries(node)) {
|
|
31
|
+
if (integer && ((key === "maximum" && value === Number.MAX_SAFE_INTEGER) || (key === "minimum" && value === Number.MIN_SAFE_INTEGER)))
|
|
32
|
+
continue;
|
|
33
|
+
out[key] = VALUE_KEYWORDS.has(key) ? value : withoutSafeIntegerBounds(value);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
// Only these markers are safe to drop on a dialect-neutral schema; any other
|
|
38
|
+
// dialect (draft-04's boolean exclusiveMinimum, say) keeps its marker.
|
|
39
|
+
const DROPPABLE_MARKERS = new Set([
|
|
40
|
+
"http://json-schema.org/draft-07/schema#",
|
|
41
|
+
"https://json-schema.org/draft/2020-12/schema",
|
|
42
|
+
]);
|
|
43
|
+
function leanSchema(schema) {
|
|
44
|
+
const lean = withoutSafeIntegerBounds(schema);
|
|
45
|
+
if (DROPPABLE_MARKERS.has(schema.$schema) && dialectNeutral(schema))
|
|
46
|
+
delete lean.$schema;
|
|
47
|
+
return lean;
|
|
48
|
+
}
|
|
49
|
+
const isDefaultExecution = (v) => isObject(v) && Object.entries(v).every(([key, value]) => key === "taskSupport" && (value === undefined || value === "forbidden"));
|
|
50
|
+
/** One tool definition as the host should see it. */
|
|
51
|
+
export function leanTool(tool) {
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const [key, value] of Object.entries(tool)) {
|
|
54
|
+
if (key === "execution" && isDefaultExecution(value))
|
|
55
|
+
continue;
|
|
56
|
+
out[key] = (key === "inputSchema" || key === "outputSchema") && isObject(value) ? leanSchema(value) : value;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/** The request method a schema handles — by identity, or by its method literal
|
|
61
|
+
* when a second SDK copy supplied a different schema object. */
|
|
62
|
+
function handlesToolsList(schema) {
|
|
63
|
+
if (schema === ListToolsRequestSchema)
|
|
64
|
+
return true;
|
|
65
|
+
try {
|
|
66
|
+
return schema.shape?.method?.value === "tools/list";
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Route the SDK's tools/list result through leanTool. Call right after
|
|
73
|
+
* constructing the server: McpServer installs its tools/list handler on the
|
|
74
|
+
* first registerTool, through this public setRequestHandler. */
|
|
75
|
+
export function installLeanToolList(server) {
|
|
76
|
+
const protocol = server.server;
|
|
77
|
+
const setRequestHandler = protocol.setRequestHandler.bind(protocol);
|
|
78
|
+
protocol.setRequestHandler = ((schema, handler) => {
|
|
79
|
+
if (!handlesToolsList(schema))
|
|
80
|
+
return setRequestHandler(schema, handler);
|
|
81
|
+
setRequestHandler(schema, async (...args) => {
|
|
82
|
+
const result = await handler(...args);
|
|
83
|
+
if (!isObject(result) || !Array.isArray(result.tools))
|
|
84
|
+
return result;
|
|
85
|
+
return { ...result, tools: result.tools.map((t) => (isObject(t) ? leanTool(t) : t)) };
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=leanToolList.js.map
|
package/dist/mcp/server.js
CHANGED
|
@@ -16,6 +16,7 @@ import { z } from "zod";
|
|
|
16
16
|
import { hunchPaths, findRoot, toPosixTarget, repoRelativeTarget } from "../core/paths.js";
|
|
17
17
|
import { matchSymbolsTiered } from "../core/glob.js";
|
|
18
18
|
import { resolveMcpToolset } from "./toolset.js";
|
|
19
|
+
import { installLeanToolList } from "./leanToolList.js";
|
|
19
20
|
import { PolicyRepository } from "../constitution/repository.js";
|
|
20
21
|
import { readConfig } from "../core/config.js";
|
|
21
22
|
import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
|
|
@@ -1129,7 +1130,7 @@ function prepareRoot(root, explicitOverlay, requireIndex) {
|
|
|
1129
1130
|
export const MCP_INSTRUCTIONS = [
|
|
1130
1131
|
"Hunch is this repository's engineering memory: decisions, bug history, invariants, components, with provenance.",
|
|
1131
1132
|
"Per user task: (1) hunch_task(action:\"start\", title) once — skip it when the host's prompt hook already opened the task and printed its verify command, and reuse that task_id; (2) hunch_context(target, task_id) FIRST, before reading or editing, for the file, symbol, or task phrase; (3) hunch_check_constraints(scope) before editing shared code; (4) hunch_task(action:\"finish\", task_id) before the final response, showing its contribution card — required, with one exception: the host's prompt hook opened the task AND its instruction said that host closes the task AND the task used no Hunch (no hunch_* call on this task_id, no task verify check, no hook context you acted on, nothing to claim). A task you started with hunch_task is always finished by you.",
|
|
1132
|
-
"Then by moment: hunch_why(target) for rationale and rejected alternatives, hunch_bug_lineage before fixing a failure,
|
|
1133
|
+
"Then by moment: hunch_why(target) for rationale and rejected alternatives, hunch_bug_lineage before fixing a failure, hunch_record_correction when a human corrects you, hunch_record_decision for a decision the human makes; record only what the human decides or corrects.",
|
|
1133
1134
|
"Hosts without lifecycle hooks (Windsurf, Cursor, or Codex before its hooks are trusted) receive no automatic grounding: call these tools yourself.",
|
|
1134
1135
|
].join("\n");
|
|
1135
1136
|
export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
@@ -1207,6 +1208,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1207
1208
|
if (toolset.hidden.length)
|
|
1208
1209
|
process.stderr.write(`[hunch-mcp] tool groups: ${toolset.groups.length ? toolset.groups.join(", ") : "core only"} (${toolset.source}); ${toolset.hidden.length} specialist tool(s) hidden — HUNCH_MCP_TOOLS=all or .hunch/config.json mcp_tools to expose\n`);
|
|
1209
1210
|
const server = new McpServer({ name: "hunch", version: HUNCH_VERSION }, { instructions: MCP_INSTRUCTIONS });
|
|
1211
|
+
installLeanToolList(server);
|
|
1210
1212
|
let activeRequests = 0;
|
|
1211
1213
|
let pendingRoot = null;
|
|
1212
1214
|
let pendingScheduled = false;
|
|
@@ -2103,7 +2105,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2103
2105
|
// -- hunch_record_decision (write-back) -----------------------------------
|
|
2104
2106
|
server.registerTool("hunch_record_decision", {
|
|
2105
2107
|
title: "Record a decision (write-back)",
|
|
2106
|
-
description: "Persist a Decision (ADR) with provenance
|
|
2108
|
+
description: "Persist a Decision (ADR) with provenance when the human makes or confirms one in conversation; routine choices are captured from commits. private:true keeps it in the HUNCH_PRIVATE_DIR overlay, never committed here. Returns id, home, status. Not for an agent-obeyed rule (hunch_record_correction) or an observation (hunch_record_finding). Errors: 'Refused:' a gate held (don't retry); 'Invalid:' fix arguments; 'Failed to' internal.",
|
|
2107
2109
|
inputSchema: {
|
|
2108
2110
|
decision: z.object({
|
|
2109
2111
|
title: z.string(),
|
package/dist/serve/mcpHttp.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { installLeanToolList } from "../mcp/leanToolList.js";
|
|
2
3
|
// The web-standard transport, not the Node one: the Node wrapper imports @hono/node-server,
|
|
3
4
|
// which the production dependency audit keeps unreachable (tooling/production-dependency-audit.mjs).
|
|
4
5
|
// Bridging IncomingMessage → Request → ServerResponse here is a few lines and keeps that boundary.
|
|
@@ -51,6 +52,7 @@ const TOOLS = [
|
|
|
51
52
|
];
|
|
52
53
|
function buildServer(dispatch, problemOf, version) {
|
|
53
54
|
const server = new McpServer({ name: "hunch-serve", version });
|
|
55
|
+
installLeanToolList(server);
|
|
54
56
|
for (const tool of TOOLS) {
|
|
55
57
|
server.registerTool(tool.name, { title: tool.title, description: tool.description, inputSchema: tool.input }, async (input) => {
|
|
56
58
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.45.1",
|
|
4
4
|
"mcpName": "io.github.davesheffer/hunch",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
@@ -44,7 +44,6 @@
|
|
|
44
44
|
"contracts/change-proof/*.json",
|
|
45
45
|
"server.json",
|
|
46
46
|
"bench/constitution-exp03-v1.json",
|
|
47
|
-
"tooling/competitive-watch.mjs",
|
|
48
47
|
"tooling/md1-benchmark.mjs",
|
|
49
48
|
"LICENSE",
|
|
50
49
|
"NOTICE"
|
|
@@ -91,12 +90,10 @@
|
|
|
91
90
|
"site:proof": "npm run build && node tooling/generate-public-proof.mjs",
|
|
92
91
|
"site:sitemap": "node tooling/generate-sitemap.mjs",
|
|
93
92
|
"bench:md1": "node tooling/md1-benchmark.mjs",
|
|
94
|
-
"research:competitors": "node tooling/competitive-watch.mjs",
|
|
95
|
-
"outreach": "node tooling/outreach-pipeline.mjs",
|
|
96
93
|
"prepublishOnly": "npm run build"
|
|
97
94
|
},
|
|
98
95
|
"dependencies": {
|
|
99
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
96
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
100
97
|
"@tree-sitter-grammars/tree-sitter-yaml": "^0.6.1",
|
|
101
98
|
"commander": "^15.0.0",
|
|
102
99
|
"cross-spawn": "^7.0.6",
|
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://www.hunchmemory.com",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.45.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@davesheffer/hunch",
|
|
16
|
-
"version": "1.
|
|
16
|
+
"version": "1.45.1",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|
|
@@ -18,7 +18,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
18
18
|
import process from "node:process";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
|
|
21
|
-
export const BENCHMARK_BASELINE_REF = "
|
|
21
|
+
export const BENCHMARK_BASELINE_REF = "a1ad6d89631cce7c194f3f679abf7ed4e56d13e3";
|
|
22
22
|
export const BENCHMARK_SCHEMA = "hunch.md1a.performance.v1";
|
|
23
23
|
|
|
24
24
|
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|