@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.
@@ -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 = 1;
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) {
@@ -10,6 +10,7 @@ export function hunchPaths(root) {
10
10
  hunch,
11
11
  sqlite: join(hunch, "hunch.sqlite"),
12
12
  manifest: join(hunch, "manifest.json"),
13
+ config: join(hunch, "config.json"),
13
14
  dir: (kind) => join(hunch, kind),
14
15
  };
15
16
  }
@@ -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. */
@@ -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. */
@@ -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 CLAUDE.md, preserving everything else. */
49
- export function updateClaudeMd(root, store) {
50
- const file = join(root, "CLAUDE.md");
51
- const section = renderHunchSection(store);
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 (only one survived, or out of order): strip every
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 = `# ${root.split("/").pop()}\n\n${section}\n`;
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 });
@@ -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: { target: z.string().describe("A file path (e.g. src/auth/session.ts) or symbol name.") },
81
- }, async ({ target }) => {
82
- const w = store.why(target);
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
- return ok(formatContext(store.assembleContext(target, budget_tokens ?? 1500)));
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: new Date().toISOString(),
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}`);