@davesheffer/hunch 0.5.0 → 0.9.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/LICENSE +21 -21
- package/README.md +81 -13
- package/dist/cli/index.js +287 -20
- package/dist/core/config.js +35 -0
- package/dist/core/hookpolicy.js +22 -0
- package/dist/core/migrate.js +36 -2
- package/dist/core/paths.js +1 -0
- package/dist/core/types.js +21 -0
- package/dist/extractors/git.js +16 -0
- package/dist/extractors/parse.js +13 -13
- package/dist/integrations/claudemd.js +13 -10
- package/dist/integrations/providers.js +229 -0
- package/dist/integrations/scaffold.js +57 -1
- package/dist/mcp/server.js +47 -10
- package/dist/store/hunchStore.js +135 -8
- package/dist/synthesis/provider.js +150 -33
- package/dist/synthesis/synthesize.js +12 -0
- package/package.json +1 -1
package/dist/core/migrate.js
CHANGED
|
@@ -18,13 +18,47 @@ import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
|
18
18
|
import { dirname } from "node:path";
|
|
19
19
|
import { writeFileAtomic } from "./io.js";
|
|
20
20
|
/** The schema generation this build writes and reads. Bump on any breaking change. */
|
|
21
|
-
export const SCHEMA_VERSION =
|
|
21
|
+
export const SCHEMA_VERSION = 2;
|
|
22
22
|
/** A repo whose `.hunch/` predates manifests is treated as v1. Migrations are
|
|
23
23
|
* numbered from 2 (each `version` is the number it PRODUCES), so a baseline repo
|
|
24
24
|
* runs every migration with version >= 2 — never author a no-op version:1 one. */
|
|
25
25
|
export const BASELINE_VERSION = 1;
|
|
26
26
|
/** Ordered, ascending by `version`. Empty at v1 (baseline); future versions append. */
|
|
27
|
-
export const MIGRATIONS = [
|
|
27
|
+
export const MIGRATIONS = [
|
|
28
|
+
{
|
|
29
|
+
// v2: bi-temporal valid-time on decisions + constraints (Time-Travel Memory).
|
|
30
|
+
// Backfill new fields from each record's existing date so a v1 graph migrates
|
|
31
|
+
// losslessly — no record is dropped, and `valid_from` is populated BEFORE the
|
|
32
|
+
// Zod pass. Defensive: input is untrusted JSON.
|
|
33
|
+
version: 2,
|
|
34
|
+
description: "Add valid_from/valid_to/superseded_by/retired (decisions) and status/valid_from/valid_to (constraints)",
|
|
35
|
+
up(kind, raw) {
|
|
36
|
+
if (kind === "decisions") {
|
|
37
|
+
const date = typeof raw.date === "string" ? raw.date : "";
|
|
38
|
+
if (raw.valid_from === undefined)
|
|
39
|
+
raw.valid_from = date;
|
|
40
|
+
// Legacy superseded decisions have no recorded successor instant. Leave
|
|
41
|
+
// valid_to = null (historically in force) rather than = date: a zero-length
|
|
42
|
+
// [date,date) window matches NO as-of query and would hide the record from
|
|
43
|
+
// all time-travel. A later `supersede` sets a real valid_to when known.
|
|
44
|
+
if (raw.valid_to === undefined)
|
|
45
|
+
raw.valid_to = null;
|
|
46
|
+
if (raw.superseded_by === undefined)
|
|
47
|
+
raw.superseded_by = null;
|
|
48
|
+
if (raw.retired === undefined)
|
|
49
|
+
raw.retired = { symbols: [], deps: [] };
|
|
50
|
+
}
|
|
51
|
+
else if (kind === "constraints") {
|
|
52
|
+
if (raw.status === undefined)
|
|
53
|
+
raw.status = "active";
|
|
54
|
+
if (raw.valid_to === undefined)
|
|
55
|
+
raw.valid_to = null;
|
|
56
|
+
// valid_from is optional on constraints; leave unset for legacy records.
|
|
57
|
+
}
|
|
58
|
+
return raw;
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
];
|
|
28
62
|
/** Read `.hunch/manifest.json`. A missing/corrupt manifest is treated as the
|
|
29
63
|
* BASELINE version (a pre-manifest `.hunch/`), so future builds still migrate it. */
|
|
30
64
|
export function readManifest(paths) {
|
package/dist/core/paths.js
CHANGED
package/dist/core/types.js
CHANGED
|
@@ -68,6 +68,12 @@ export const SymbolSchema = z.object({
|
|
|
68
68
|
metrics: SymbolMetricsSchema.default({ loc: 0, churn_90d: 0, bug_count: 0, fan_in: 0, fan_out: 0 }),
|
|
69
69
|
last_changed: z.string().default("").describe("commit:<sha> or ISO date"),
|
|
70
70
|
});
|
|
71
|
+
/** The structural delta a decision's commit DELETED — the evidence the Regression
|
|
72
|
+
* Guard matches a later diff against ("you're re-adding what dec_X removed"). */
|
|
73
|
+
export const RetiredSignalSchema = z.object({
|
|
74
|
+
symbols: z.array(z.string()).default([]).describe("symbol names this decision removed"),
|
|
75
|
+
deps: z.array(z.string()).default([]).describe("external deps this decision dropped"),
|
|
76
|
+
});
|
|
71
77
|
/** ADR-style decision record, auto-drafted and human-confirmable. */
|
|
72
78
|
export const DecisionSchema = z.object({
|
|
73
79
|
id: z.string().describe("dec_*"),
|
|
@@ -80,8 +86,17 @@ export const DecisionSchema = z.object({
|
|
|
80
86
|
related_components: z.array(z.string()).default([]),
|
|
81
87
|
related_files: z.array(z.string()).default([]),
|
|
82
88
|
supersedes: z.string().nullable().default(null),
|
|
89
|
+
superseded_by: z.string().nullable().default(null).describe("the decision that closed this one's window"),
|
|
83
90
|
caused_by_bug: z.string().nullable().default(null),
|
|
84
91
|
commit: z.string().nullable().default(null),
|
|
92
|
+
// Bi-temporal VALID-TIME window, git-anchored. `valid_from` is when the decision
|
|
93
|
+
// took effect (its commit date); `valid_to` is when a superseding decision closed
|
|
94
|
+
// it (null = still in force). Enables "what did we believe as of commit X?".
|
|
95
|
+
// Optional so legacy/hand-built records still validate (the migration backfills
|
|
96
|
+
// from `date`, and the capture paths always set it); undefined = always-started.
|
|
97
|
+
valid_from: z.string().optional().describe("ISO instant the decision took effect (commit date)"),
|
|
98
|
+
valid_to: z.string().nullable().default(null).describe("ISO instant it was superseded (null = in force)"),
|
|
99
|
+
retired: RetiredSignalSchema.default({ symbols: [], deps: [] }),
|
|
85
100
|
provenance: ProvenanceSchema,
|
|
86
101
|
date: z.string(),
|
|
87
102
|
});
|
|
@@ -120,6 +135,12 @@ export const ConstraintSchema = z.object({
|
|
|
120
135
|
rationale: z.string().default(""),
|
|
121
136
|
source_decision: z.string().nullable().default(null),
|
|
122
137
|
violations: z.array(z.string()).default([]),
|
|
138
|
+
// Bi-temporal VALID-TIME: a constraint can be RETIRED without deletion, so
|
|
139
|
+
// "what invariants were in force as of commit X?" stays answerable. `valid_to`
|
|
140
|
+
// null = still active. A retired constraint is excluded from enforcement at HEAD.
|
|
141
|
+
status: z.enum(["active", "retired"]).default("active"),
|
|
142
|
+
valid_from: z.string().optional().describe("ISO instant the invariant took effect"),
|
|
143
|
+
valid_to: z.string().nullable().default(null).describe("ISO instant it was retired (null = active)"),
|
|
123
144
|
provenance: ProvenanceSchema,
|
|
124
145
|
});
|
|
125
146
|
/** The six entity collections, keyed by their on-disk directory name. */
|
package/dist/extractors/git.js
CHANGED
|
@@ -105,6 +105,22 @@ export function stagedFiles(cwd) {
|
|
|
105
105
|
const out = gitSafe(["diff", "--cached", "--name-only", "--diff-filter=ACMR"], cwd);
|
|
106
106
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
107
107
|
}
|
|
108
|
+
/** Unified diff of the staged changes (for the Regression Guard's structural
|
|
109
|
+
* analysis). Excludes machine-generated noise and truncates at the SAME budget as
|
|
110
|
+
* commitDiff, so the staged and `--commit` guard paths can't diverge on big diffs. */
|
|
111
|
+
export function stagedDiff(cwd, maxBytes = 60_000) {
|
|
112
|
+
const out = gitSafe(["diff", "--cached", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
|
|
113
|
+
return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
|
|
114
|
+
}
|
|
115
|
+
/** Resolve a time-travel ref (commit / tag / branch / HEAD~n) to the ISO author-
|
|
116
|
+
* date of that commit — the instant valid-time windows are filtered against.
|
|
117
|
+
* Undefined if it can't be resolved (not a git repo, or an unknown ref). Single
|
|
118
|
+
* source for the CLI and MCP as-of paths so they can't drift. */
|
|
119
|
+
export function asOfDate(ref, cwd) {
|
|
120
|
+
if (!isGitRepo(cwd))
|
|
121
|
+
return undefined;
|
|
122
|
+
return commitMeta(revParse(ref, cwd), cwd)?.date || undefined;
|
|
123
|
+
}
|
|
108
124
|
/** Translate a backfill window spec into git-log window args.
|
|
109
125
|
* "90d" / bare "90" -> last 90 days | "40c" -> last 40 commits
|
|
110
126
|
* anything else -> passed to --since as an approxidate/date string. */
|
package/dist/extractors/parse.js
CHANGED
|
@@ -26,19 +26,19 @@ const BUILTIN_METHODS = new Set([
|
|
|
26
26
|
"log", "error", "warn", "info", "debug",
|
|
27
27
|
]);
|
|
28
28
|
/** Tree-sitter query capturing every construct we care about in one pass. */
|
|
29
|
-
const QUERY_SRC = `
|
|
30
|
-
(function_declaration name: (identifier) @fn.name) @fn.def
|
|
31
|
-
(generator_function_declaration name: (identifier) @fn.name) @fn.def
|
|
32
|
-
(method_definition name: (property_identifier) @method.name) @method.def
|
|
33
|
-
(class_declaration name: (type_identifier) @class.name) @class.def
|
|
34
|
-
(interface_declaration name: (type_identifier) @iface.name) @iface.def
|
|
35
|
-
(type_alias_declaration name: (type_identifier) @type.name) @type.def
|
|
36
|
-
(variable_declarator
|
|
37
|
-
name: (identifier) @arrow.name
|
|
38
|
-
value: [(arrow_function) (function_expression)]) @arrow.def
|
|
39
|
-
(import_statement source: (string) @import.src)
|
|
40
|
-
(call_expression function: (identifier) @call.id)
|
|
41
|
-
(call_expression function: (member_expression property: (property_identifier) @call.member))
|
|
29
|
+
const QUERY_SRC = `
|
|
30
|
+
(function_declaration name: (identifier) @fn.name) @fn.def
|
|
31
|
+
(generator_function_declaration name: (identifier) @fn.name) @fn.def
|
|
32
|
+
(method_definition name: (property_identifier) @method.name) @method.def
|
|
33
|
+
(class_declaration name: (type_identifier) @class.name) @class.def
|
|
34
|
+
(interface_declaration name: (type_identifier) @iface.name) @iface.def
|
|
35
|
+
(type_alias_declaration name: (type_identifier) @type.name) @type.def
|
|
36
|
+
(variable_declarator
|
|
37
|
+
name: (identifier) @arrow.name
|
|
38
|
+
value: [(arrow_function) (function_expression)]) @arrow.def
|
|
39
|
+
(import_statement source: (string) @import.src)
|
|
40
|
+
(call_expression function: (identifier) @call.id)
|
|
41
|
+
(call_expression function: (member_expression property: (property_identifier) @call.member))
|
|
42
42
|
`;
|
|
43
43
|
const cache = new Map();
|
|
44
44
|
function bundleFor(lang, key) {
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* context loaded every session for free"). We own ONLY the region between the
|
|
4
4
|
* HUNCH markers — any user-authored content outside it is preserved verbatim.
|
|
5
5
|
*/
|
|
6
|
-
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
7
|
-
import { join } from "node:path";
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
8
|
const START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
|
|
9
9
|
const END = "<!-- HUNCH:END -->";
|
|
10
10
|
export function renderHunchSection(store) {
|
|
@@ -45,20 +45,18 @@ export function renderHunchSection(store) {
|
|
|
45
45
|
lines.push(END);
|
|
46
46
|
return lines.join("\n");
|
|
47
47
|
}
|
|
48
|
-
/** Insert/replace the HUNCH section in
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
/** Insert/replace the marker-delimited HUNCH section in a markdown doc, preserving
|
|
49
|
+
* all user-authored content outside the markers. Shared by CLAUDE.md, AGENTS.md,
|
|
50
|
+
* and .github/copilot-instructions.md so every assistant gets the same grounding. */
|
|
51
|
+
export function upsertSection(file, section, fallbackTitle) {
|
|
52
52
|
let content = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
53
53
|
const iStart = content.indexOf(START);
|
|
54
54
|
const iEnd = content.indexOf(END);
|
|
55
55
|
if (iStart >= 0 && iEnd > iStart) {
|
|
56
|
-
// clean both-marker case: replace in place, preserving surrounding content
|
|
57
56
|
content = content.slice(0, iStart) + section + content.slice(iEnd + END.length);
|
|
58
57
|
}
|
|
59
58
|
else if (iStart >= 0 || iEnd >= 0) {
|
|
60
|
-
// partial/corrupt markers
|
|
61
|
-
// stray marker line, then append ONE clean section — never duplicate.
|
|
59
|
+
// partial/corrupt markers: strip stray marker lines, then append ONE clean section.
|
|
62
60
|
const body = content.split("\n").filter((l) => !l.includes(START) && !l.includes(END)).join("\n").trimEnd();
|
|
63
61
|
content = body ? `${body}\n\n${section}\n` : `${section}\n`;
|
|
64
62
|
}
|
|
@@ -66,11 +64,16 @@ export function updateClaudeMd(root, store) {
|
|
|
66
64
|
content = `${content.trimEnd()}\n\n${section}\n`;
|
|
67
65
|
}
|
|
68
66
|
else {
|
|
69
|
-
content =
|
|
67
|
+
content = `${fallbackTitle}\n\n${section}\n`;
|
|
70
68
|
}
|
|
69
|
+
mkdirSync(dirname(file), { recursive: true }); // e.g. .github/ for copilot-instructions
|
|
71
70
|
writeFileSync(file, content);
|
|
72
71
|
return file;
|
|
73
72
|
}
|
|
73
|
+
/** Insert/replace the HUNCH section in CLAUDE.md, preserving everything else. */
|
|
74
|
+
export function updateClaudeMd(root, store) {
|
|
75
|
+
return upsertSection(join(root, "CLAUDE.md"), renderHunchSection(store), `# ${root.split("/").pop()}`);
|
|
76
|
+
}
|
|
74
77
|
function sev(s) {
|
|
75
78
|
return { blocking: 3, warning: 2, advisory: 1 }[s] ?? 0;
|
|
76
79
|
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-assistant compatibility (DESIGN §7, extended). The Hunch MCP server is
|
|
3
|
+
* client-agnostic — any MCP-capable assistant can call the `hunch_*` tools. The
|
|
4
|
+
* only per-tool difference is HOW each one is told to launch the server and where
|
|
5
|
+
* its ambient grounding lives. This module scaffolds those surfaces for the major
|
|
6
|
+
* assistants so the same `.hunch/` graph powers all of them:
|
|
7
|
+
*
|
|
8
|
+
* Assistant | MCP config | root key | grounding file
|
|
9
|
+
* ------------|-------------------------|----------------|---------------------------------
|
|
10
|
+
* Claude Code | .mcp.json | mcpServers | CLAUDE.md (scaffold.ts)
|
|
11
|
+
* Cursor | .cursor/mcp.json | mcpServers | .cursor/rules/hunch.mdc
|
|
12
|
+
* VS Code | .vscode/mcp.json | servers (+type)| .github/copilot-instructions.md
|
|
13
|
+
* Codex CLI | .codex/config.toml | [mcp_servers.*]| AGENTS.md
|
|
14
|
+
* (any other) | — | — | AGENTS.md (cross-tool standard)
|
|
15
|
+
*
|
|
16
|
+
* Every writer MERGES into existing files (preserving other servers / user prose)
|
|
17
|
+
* and is idempotent, so re-running `hunch init` is safe.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
20
|
+
import { join, dirname } from "node:path";
|
|
21
|
+
import { renderHunchSection, upsertSection } from "./claudemd.js";
|
|
22
|
+
/** Strip // line and block comments + trailing commas (JSONC → JSON). String-aware
|
|
23
|
+
* (double-quoted, with escapes) so a // inside a value isn't mangled. VS Code's
|
|
24
|
+
* .vscode/mcp.json is JSONC, so we must tolerate comments. */
|
|
25
|
+
function stripJsonc(s) {
|
|
26
|
+
let out = "";
|
|
27
|
+
let inStr = false;
|
|
28
|
+
for (let i = 0; i < s.length; i++) {
|
|
29
|
+
const c = s[i];
|
|
30
|
+
const n = s[i + 1];
|
|
31
|
+
if (inStr) {
|
|
32
|
+
out += c;
|
|
33
|
+
if (c === "\\") {
|
|
34
|
+
out += n ?? "";
|
|
35
|
+
i++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (c === '"')
|
|
39
|
+
inStr = false;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (c === '"') {
|
|
43
|
+
inStr = true;
|
|
44
|
+
out += c;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (c === "/" && n === "/") {
|
|
48
|
+
while (i < s.length && s[i] !== "\n")
|
|
49
|
+
i++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (c === "/" && n === "*") {
|
|
53
|
+
i += 2;
|
|
54
|
+
while (i < s.length && !(s[i] === "*" && s[i + 1] === "/"))
|
|
55
|
+
i++;
|
|
56
|
+
i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
out += c;
|
|
60
|
+
}
|
|
61
|
+
return dropTrailingCommas(out);
|
|
62
|
+
}
|
|
63
|
+
/** Remove trailing commas (`,` before `}`/`]`) — string-aware, so a comma inside
|
|
64
|
+
* a string value (e.g. "a,]") is never touched. A blanket regex would corrupt it
|
|
65
|
+
* (the same trap test/migrate.test.ts guards against). Runs on comment-free text,
|
|
66
|
+
* so lookahead need only skip whitespace. */
|
|
67
|
+
function dropTrailingCommas(s) {
|
|
68
|
+
let out = "";
|
|
69
|
+
let inStr = false;
|
|
70
|
+
let esc = false;
|
|
71
|
+
for (let i = 0; i < s.length; i++) {
|
|
72
|
+
const c = s[i];
|
|
73
|
+
if (inStr) {
|
|
74
|
+
out += c;
|
|
75
|
+
if (esc)
|
|
76
|
+
esc = false;
|
|
77
|
+
else if (c === "\\")
|
|
78
|
+
esc = true;
|
|
79
|
+
else if (c === '"')
|
|
80
|
+
inStr = false;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (c === '"') {
|
|
84
|
+
inStr = true;
|
|
85
|
+
out += c;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (c === ",") {
|
|
89
|
+
let j = i + 1;
|
|
90
|
+
while (j < s.length && /\s/.test(s[j]))
|
|
91
|
+
j++;
|
|
92
|
+
if (s[j] === "}" || s[j] === "]")
|
|
93
|
+
continue; // trailing comma → drop
|
|
94
|
+
}
|
|
95
|
+
out += c;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
/** Read a JSON/JSONC object. Returns {} only for an ABSENT or empty file. A
|
|
100
|
+
* non-empty file we cannot parse THROWS — overwriting it would silently wipe the
|
|
101
|
+
* user's other MCP servers. */
|
|
102
|
+
function readJsonObj(file) {
|
|
103
|
+
if (!existsSync(file))
|
|
104
|
+
return {};
|
|
105
|
+
const raw = readFileSync(file, "utf8");
|
|
106
|
+
if (!raw.trim())
|
|
107
|
+
return {};
|
|
108
|
+
try {
|
|
109
|
+
const v = JSON.parse(stripJsonc(raw));
|
|
110
|
+
if (v && typeof v === "object" && !Array.isArray(v))
|
|
111
|
+
return v;
|
|
112
|
+
throw new Error("not a JSON object");
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
throw new Error(`refusing to overwrite ${file}: could not parse it (${e.message}). Fix or remove it, then re-run.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Render a string as a TOML value: a literal '…' when safe (no escaping needed —
|
|
119
|
+
* ideal for Windows backslash paths), else a basic "…" with escapes. */
|
|
120
|
+
function tomlStr(s) {
|
|
121
|
+
// TOML literal '…' needs no escaping (ideal for Windows backslash paths) but
|
|
122
|
+
// can't contain a quote or newline; otherwise a basic "…" with escapes.
|
|
123
|
+
if (!/['\r\n]/.test(s))
|
|
124
|
+
return `'${s}'`;
|
|
125
|
+
const esc = s
|
|
126
|
+
.replace(/\\/g, "\\\\")
|
|
127
|
+
.replace(/"/g, '\\"')
|
|
128
|
+
.replace(/\n/g, "\\n")
|
|
129
|
+
.replace(/\r/g, "\\r")
|
|
130
|
+
.replace(/\t/g, "\\t");
|
|
131
|
+
return `"${esc}"`;
|
|
132
|
+
}
|
|
133
|
+
function writeJson(file, obj) {
|
|
134
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
135
|
+
writeFileSync(file, JSON.stringify(obj, null, 2) + "\n");
|
|
136
|
+
return file;
|
|
137
|
+
}
|
|
138
|
+
/** Cursor: .cursor/mcp.json — same `mcpServers` shape as Claude Desktop/Code. */
|
|
139
|
+
export function writeCursorMcp(root, inv) {
|
|
140
|
+
const file = join(root, ".cursor", "mcp.json");
|
|
141
|
+
const json = readJsonObj(file);
|
|
142
|
+
json.mcpServers = json.mcpServers ?? {};
|
|
143
|
+
json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
|
|
144
|
+
return writeJson(file, json);
|
|
145
|
+
}
|
|
146
|
+
/** VS Code (Copilot agent mode): .vscode/mcp.json — root key is `servers`, and
|
|
147
|
+
* each stdio entry carries an explicit `type: "stdio"` (VS Code's schema). */
|
|
148
|
+
export function writeVscodeMcp(root, inv) {
|
|
149
|
+
const file = join(root, ".vscode", "mcp.json");
|
|
150
|
+
const json = readJsonObj(file);
|
|
151
|
+
json.servers = json.servers ?? {};
|
|
152
|
+
json.servers.hunch = { type: "stdio", command: inv.command, args: [...inv.args, "mcp"] };
|
|
153
|
+
return writeJson(file, json);
|
|
154
|
+
}
|
|
155
|
+
const TOML_START = "# >>> hunch mcp (managed) >>>";
|
|
156
|
+
const TOML_END = "# <<< hunch mcp <<<";
|
|
157
|
+
/** Codex CLI: .codex/config.toml — `[mcp_servers.hunch]` stdio entry. We own only
|
|
158
|
+
* a marker-delimited block; any other TOML the user has is preserved. Paths use
|
|
159
|
+
* TOML single-quote LITERAL strings so Windows backslashes need no escaping. */
|
|
160
|
+
export function writeCodexConfig(root, inv) {
|
|
161
|
+
const file = join(root, ".codex", "config.toml");
|
|
162
|
+
const argsToml = [...inv.args, "mcp"].map(tomlStr).join(", ");
|
|
163
|
+
const block = [
|
|
164
|
+
TOML_START,
|
|
165
|
+
"[mcp_servers.hunch]",
|
|
166
|
+
`command = ${tomlStr(inv.command)}`,
|
|
167
|
+
`args = [${argsToml}]`,
|
|
168
|
+
TOML_END,
|
|
169
|
+
].join("\n");
|
|
170
|
+
// Strip any prior managed block first, so `base` is the user's own TOML.
|
|
171
|
+
const content = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
172
|
+
const i = content.indexOf(TOML_START);
|
|
173
|
+
const j = content.indexOf(TOML_END);
|
|
174
|
+
let base;
|
|
175
|
+
if (i >= 0 && j > i)
|
|
176
|
+
base = content.slice(0, i) + content.slice(j + TOML_END.length);
|
|
177
|
+
else if (i >= 0 || j >= 0)
|
|
178
|
+
base = content.split("\n").filter((l) => !l.includes(TOML_START) && !l.includes(TOML_END)).join("\n");
|
|
179
|
+
else
|
|
180
|
+
base = content;
|
|
181
|
+
// A user-authored [mcp_servers.hunch] outside our block would make TWO tables of
|
|
182
|
+
// the same name → TOML duplicate-table error. Refuse rather than corrupt it.
|
|
183
|
+
if (/^\s*\[mcp_servers\.hunch\]/m.test(base)) {
|
|
184
|
+
throw new Error(`refusing to edit ${file}: it already defines [mcp_servers.hunch] outside Hunch's managed block. Remove it, then re-run.`);
|
|
185
|
+
}
|
|
186
|
+
base = base.trimEnd();
|
|
187
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
188
|
+
writeFileSync(file, base ? `${base}\n\n${block}\n` : `${block}\n`);
|
|
189
|
+
return file;
|
|
190
|
+
}
|
|
191
|
+
/** AGENTS.md — the cross-tool ambient-instruction standard (Codex and a growing
|
|
192
|
+
* set of assistants read it). Marker-delimited so user prose is preserved. */
|
|
193
|
+
export function writeAgentsMd(root, store) {
|
|
194
|
+
return upsertSection(join(root, "AGENTS.md"), renderHunchSection(store), "# AGENTS.md");
|
|
195
|
+
}
|
|
196
|
+
/** GitHub Copilot custom instructions (VS Code / github.com). Same grounding. */
|
|
197
|
+
export function writeCopilotInstructions(root, store) {
|
|
198
|
+
return upsertSection(join(root, ".github", "copilot-instructions.md"), renderHunchSection(store), "# Copilot instructions");
|
|
199
|
+
}
|
|
200
|
+
/** Cursor project rule (.mdc = frontmatter + body). `alwaysApply` keeps the Hunch
|
|
201
|
+
* grounding in context for every request. Fully managed by Hunch (overwritten). */
|
|
202
|
+
export function writeCursorRule(root, store) {
|
|
203
|
+
const file = join(root, ".cursor", "rules", "hunch.mdc");
|
|
204
|
+
const body = `---\ndescription: Hunch engineering memory — consult the hunch_* MCP tools before editing\nalwaysApply: true\n---\n\n${renderHunchSection(store)}\n`;
|
|
205
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
206
|
+
writeFileSync(file, body);
|
|
207
|
+
return file;
|
|
208
|
+
}
|
|
209
|
+
/** Scaffold MCP config + grounding for all supported assistants. Returns a
|
|
210
|
+
* per-assistant summary for `hunch init` to print. Each assistant is isolated:
|
|
211
|
+
* a writer that refuses to clobber a malformed file degrades to a warning rather
|
|
212
|
+
* than aborting the rest. Claude Code is handled separately by scaffold.ts. */
|
|
213
|
+
export function scaffoldProviders(root, inv, store) {
|
|
214
|
+
const tasks = [
|
|
215
|
+
["Cursor", () => [writeCursorMcp(root, inv), writeCursorRule(root, store)]],
|
|
216
|
+
["VS Code (Copilot)", () => [writeVscodeMcp(root, inv), writeCopilotInstructions(root, store)]],
|
|
217
|
+
["Codex CLI", () => [writeCodexConfig(root, inv)]],
|
|
218
|
+
["Any (AGENTS.md)", () => [writeAgentsMd(root, store)]],
|
|
219
|
+
];
|
|
220
|
+
return tasks.map(([assistant, run]) => {
|
|
221
|
+
try {
|
|
222
|
+
return { assistant, files: run() };
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
return { assistant, files: [], error: e.message };
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
//# sourceMappingURL=providers.js.map
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* - .claude/commands/* → user-triggered slash commands for the §5 workflows
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
7
|
-
import { join } from "node:path";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
8
|
/** Merge a `hunch` server entry into .mcp.json, preserving other servers. */
|
|
9
9
|
export function writeMcpJson(root, inv) {
|
|
10
10
|
const file = join(root, ".mcp.json");
|
|
@@ -55,6 +55,62 @@ then produce a **fragility report with evidence**: the specific files/functions,
|
|
|
55
55
|
the bug history behind them, their churn and fan-in, and any missing guards.
|
|
56
56
|
Avoid generic advice — every claim must cite a Hunch record or metric.
|
|
57
57
|
`;
|
|
58
|
+
/** A settings.json hook entry is Hunch's if any of its commands ends with the
|
|
59
|
+
* Hunch CLI entry + the `hook` subcommand (e.g. `…/index.js hook`). Matching the
|
|
60
|
+
* command TAIL — not the absolute path — makes re-init idempotent AND survives a
|
|
61
|
+
* repo-folder rename (the path before index.js changes; the tail does not). The
|
|
62
|
+
* leading path separator (`/` or `\`) requires `index` to be a full path segment,
|
|
63
|
+
* so a foreign tool's `…/myindex.js hook` isn't mistaken for ours and clobbered. */
|
|
64
|
+
function isHunchHook(entry) {
|
|
65
|
+
return !!entry.hooks?.some((h) => typeof h.command === "string" && /[\\/]index\.(js|ts)"?\s+hook\s*$/.test(h.command));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Install the Claude Code AGENT hooks into `.claude/settings.json` so the agent
|
|
69
|
+
* is grounded in Hunch automatically (not by remembering to call the tools):
|
|
70
|
+
* - PreToolUse (Edit|Write|MultiEdit) → inject the relevant Hunch slice before
|
|
71
|
+
* an edit, and (at strict firmness) deny edits that hit a blocking invariant.
|
|
72
|
+
* - UserPromptSubmit → remind the agent to consult Hunch.
|
|
73
|
+
* Both invoke `hunch hook`, which reads the firmness level from .hunch/config.json
|
|
74
|
+
* at run time — so changing firmness needs no settings.json edit. We own only our
|
|
75
|
+
* entries (matched by isHunchHook): other hooks and settings are preserved, and a
|
|
76
|
+
* non-empty file we cannot parse THROWS rather than clobbering the user's config.
|
|
77
|
+
*/
|
|
78
|
+
export function installClaudeHooks(root, hookCmd) {
|
|
79
|
+
const file = join(root, ".claude", "settings.json");
|
|
80
|
+
const existed = existsSync(file);
|
|
81
|
+
let json = {};
|
|
82
|
+
let before = "";
|
|
83
|
+
if (existed) {
|
|
84
|
+
before = readFileSync(file, "utf8");
|
|
85
|
+
if (before.trim()) {
|
|
86
|
+
try {
|
|
87
|
+
const v = JSON.parse(before);
|
|
88
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
89
|
+
throw new Error("not a JSON object");
|
|
90
|
+
json = v;
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
throw new Error(`refusing to overwrite ${file}: could not parse it (${e.message}). Fix or remove it, then re-run.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
json.hooks = json.hooks ?? {};
|
|
98
|
+
const keep = (arr) => (Array.isArray(arr) ? arr.filter((e) => !isHunchHook(e)) : []);
|
|
99
|
+
json.hooks.PreToolUse = [
|
|
100
|
+
...keep(json.hooks.PreToolUse),
|
|
101
|
+
{ matcher: "Edit|Write|MultiEdit", hooks: [{ type: "command", command: hookCmd }] },
|
|
102
|
+
];
|
|
103
|
+
json.hooks.UserPromptSubmit = [
|
|
104
|
+
...keep(json.hooks.UserPromptSubmit),
|
|
105
|
+
{ hooks: [{ type: "command", command: hookCmd }] },
|
|
106
|
+
];
|
|
107
|
+
const next = JSON.stringify(json, null, 2) + "\n";
|
|
108
|
+
if (existed && before === next)
|
|
109
|
+
return { path: file, action: "unchanged" };
|
|
110
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
111
|
+
writeFileSync(file, next);
|
|
112
|
+
return { path: file, action: existed ? "updated" : "created" };
|
|
113
|
+
}
|
|
58
114
|
export function writeSlashCommands(root) {
|
|
59
115
|
const dir = join(root, ".claude", "commands");
|
|
60
116
|
mkdirSync(dir, { recursive: true });
|
package/dist/mcp/server.js
CHANGED
|
@@ -13,7 +13,7 @@ import { hunchPaths, findRoot } from "../core/paths.js";
|
|
|
13
13
|
import { HunchStore } from "../store/hunchStore.js";
|
|
14
14
|
import { selectEmbedder } from "../store/embedder.js";
|
|
15
15
|
import { decisionId } from "../core/ids.js";
|
|
16
|
-
import { revParse } from "../extractors/git.js";
|
|
16
|
+
import { revParse, asOfDate } from "../extractors/git.js";
|
|
17
17
|
import { formatContext } from "../core/format.js";
|
|
18
18
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
19
19
|
const err = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
@@ -76,10 +76,16 @@ export function buildServer(root) {
|
|
|
76
76
|
// -- hunch_why ------------------------------------------------------------
|
|
77
77
|
server.registerTool("hunch_why", {
|
|
78
78
|
title: "Explain why a file/symbol is the way it is",
|
|
79
|
-
description: "Return the decisions, bugs, and constraints that explain a file path or symbol — the 'why' and the 'what must not break', with evidence.",
|
|
80
|
-
inputSchema: {
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
description: "Return the decisions, bugs, and constraints that explain a file path or symbol — the 'why' and the 'what must not break', with evidence. Pass `as_of` (a commit/tag/branch) to time-travel: see what was believed at that point in history.",
|
|
80
|
+
inputSchema: {
|
|
81
|
+
target: z.string().describe("A file path (e.g. src/auth/session.ts) or symbol name."),
|
|
82
|
+
as_of: z.string().optional().describe("Time-travel ref: a commit sha, tag, or branch (e.g. v0.7.0). Omit for the current view."),
|
|
83
|
+
},
|
|
84
|
+
}, async ({ target, as_of }) => {
|
|
85
|
+
const asOf = as_of ? asOfDate(as_of, root) : undefined;
|
|
86
|
+
if (as_of && !asOf)
|
|
87
|
+
return err(`Could not resolve as_of "${as_of}" to a commit.`);
|
|
88
|
+
const w = store.why(target, { asOf });
|
|
83
89
|
// Highest-signal first, then cap: invariants by severity, decisions by
|
|
84
90
|
// confidence, bugs by severity — so a hot file's trim drops the tail, not
|
|
85
91
|
// the records that matter most.
|
|
@@ -185,9 +191,30 @@ export function buildServer(root) {
|
|
|
185
191
|
inputSchema: {
|
|
186
192
|
target: z.string().describe("A file path or symbol you're about to edit."),
|
|
187
193
|
budget_tokens: z.number().optional().describe("Rough token budget for the brief (default 1500)."),
|
|
194
|
+
as_of: z.string().optional().describe("Time-travel ref (commit/tag/branch): assemble the slice as it stood then."),
|
|
188
195
|
},
|
|
189
|
-
}, async ({ target, budget_tokens }) => {
|
|
190
|
-
|
|
196
|
+
}, async ({ target, budget_tokens, as_of }) => {
|
|
197
|
+
const asOf = as_of ? asOfDate(as_of, root) : undefined;
|
|
198
|
+
if (as_of && !asOf)
|
|
199
|
+
return err(`Could not resolve as_of "${as_of}" to a commit.`);
|
|
200
|
+
return ok(formatContext(store.assembleContext(target, budget_tokens ?? 1500, { asOf })));
|
|
201
|
+
});
|
|
202
|
+
// -- hunch_timeline (decision history) ------------------------------------
|
|
203
|
+
server.registerTool("hunch_timeline", {
|
|
204
|
+
title: "The decision history for a file/symbol",
|
|
205
|
+
description: "Time-travel: the decisions touching a file/symbol over time — what was believed, its valid-time window, and what superseded it. Use to understand how (and why) the design changed, and to avoid re-introducing a deliberately-retired approach.",
|
|
206
|
+
inputSchema: { target: z.string().describe("A file path or symbol name.") },
|
|
207
|
+
}, async ({ target }) => {
|
|
208
|
+
const tl = store.timeline(target);
|
|
209
|
+
if (!tl.length)
|
|
210
|
+
return ok(`No decision history for "${target}" yet.`);
|
|
211
|
+
const lines = tl.map((d) => {
|
|
212
|
+
const from = (d.valid_from ?? d.date).slice(0, 10);
|
|
213
|
+
const window = d.valid_to ? `${from} → ${d.valid_to.slice(0, 10)}` : `${from} → now`;
|
|
214
|
+
const sup = d.superseded_by ? ` (superseded by ${d.superseded_by})` : "";
|
|
215
|
+
return ` • ${d.id} [${d.status}] (${window})${sup}\n ${d.title}`;
|
|
216
|
+
});
|
|
217
|
+
return ok(`Decision timeline for "${target}" (newest first):\n${lines.join("\n")}`);
|
|
191
218
|
});
|
|
192
219
|
// -- hunch_record_decision (write-back) -----------------------------------
|
|
193
220
|
server.registerTool("hunch_record_decision", {
|
|
@@ -204,6 +231,7 @@ export function buildServer(root) {
|
|
|
204
231
|
related_components: z.array(z.string()).optional(),
|
|
205
232
|
status: z.enum(["proposed", "accepted", "rejected", "superseded"]).optional(),
|
|
206
233
|
commit: z.string().optional(),
|
|
234
|
+
supersedes: z.string().optional().describe("id of a decision this one replaces — closes its valid-time window (invalidate, don't delete)"),
|
|
207
235
|
}),
|
|
208
236
|
},
|
|
209
237
|
}, async ({ decision }) => {
|
|
@@ -223,6 +251,7 @@ export function buildServer(root) {
|
|
|
223
251
|
const source = existing && existing.provenance.source.includes("llm_draft")
|
|
224
252
|
? "llm_draft+human_confirmed"
|
|
225
253
|
: "human_confirmed";
|
|
254
|
+
const now = new Date().toISOString();
|
|
226
255
|
const rec = {
|
|
227
256
|
id,
|
|
228
257
|
title: decision.title,
|
|
@@ -233,16 +262,24 @@ export function buildServer(root) {
|
|
|
233
262
|
alternatives_rejected: decision.alternatives_rejected ?? [],
|
|
234
263
|
related_components: decision.related_components ?? existing?.related_components ?? [],
|
|
235
264
|
related_files: decision.related_files ?? existing?.related_files ?? [],
|
|
236
|
-
supersedes: existing?.supersedes ?? null,
|
|
265
|
+
supersedes: decision.supersedes ?? existing?.supersedes ?? null,
|
|
266
|
+
superseded_by: existing?.superseded_by ?? null,
|
|
237
267
|
caused_by_bug: existing?.caused_by_bug ?? null,
|
|
238
268
|
commit: decision.commit ?? existing?.commit ?? null,
|
|
269
|
+
valid_from: existing?.valid_from ?? now,
|
|
270
|
+
valid_to: existing?.valid_to ?? null,
|
|
271
|
+
retired: existing?.retired ?? { symbols: [], deps: [] },
|
|
239
272
|
provenance: { source, confidence: 0.95, evidence: decision.related_files ?? existing?.provenance.evidence ?? [] },
|
|
240
|
-
date:
|
|
273
|
+
date: now,
|
|
241
274
|
};
|
|
242
275
|
store.json.put("decisions", rec);
|
|
276
|
+
// Invalidate, don't delete: closing the superseded decision's valid-time
|
|
277
|
+
// window (+ a supersedes edge) preserves the why-it-changed trail.
|
|
278
|
+
const superseded = decision.supersedes ? store.supersede(decision.supersedes, rec) : null;
|
|
243
279
|
store.reindex();
|
|
280
|
+
const supNote = superseded ? ` Superseded ${superseded.id} (window closed at ${rec.valid_from}).` : "";
|
|
244
281
|
const note = decision.commit && !fullSha ? ` (note: commit "${decision.commit}" could not be resolved — recorded as a standalone decision, not linked to a commit)` : "";
|
|
245
|
-
return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${note}`);
|
|
282
|
+
return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${supNote}${note}`);
|
|
246
283
|
}
|
|
247
284
|
catch (e) {
|
|
248
285
|
return err(`Failed to record decision: ${e.message}`);
|