@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 CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Dave Sheffer
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dave Sheffer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -56,13 +56,21 @@ Either way you then type `hunch …`. From a source checkout without `npm link`,
56
56
  `node dist/cli/index.js …` (or `npm run hunch -- …` to run via tsx). The rest of this
57
57
  README uses `hunch` for brevity.
58
58
 
59
- ### 2. (Recommended) make the `claude` CLI available
59
+ ### 2. (Recommended) make a coding-assistant CLI available
60
60
 
61
- Hunch's LLM synthesis is billed to your **Claude Pro/Max subscription** through the
62
- `claude` CLI — **never** the pay-per-token API. If `claude --version` works in your
63
- terminal, you get full LLM-quality capture for free. If it doesn't, Hunch still works
64
- using a deterministic structural heuristic (lower-confidence drafts). `hunch doctor`
65
- tells you which mode you're in.
61
+ Hunch's LLM synthesis is billed to **your subscription** through a coding-assistant
62
+ CLI — **never** a pay-per-token API key (the API key is stripped from the child env).
63
+ Hunch auto-detects the first one present, in this order:
64
+
65
+ | CLI | Subscription | Detected by |
66
+ |---|---|---|
67
+ | `claude` (Claude Code) | Claude Pro/Max | `claude --version` |
68
+ | `codex` (OpenAI Codex) | ChatGPT Plus/Pro | `codex --version` |
69
+ | `cursor-agent` (Cursor) | Cursor | `cursor-agent --version` |
70
+
71
+ If none is installed, Hunch still works using a deterministic structural heuristic
72
+ (lower-confidence drafts). `hunch doctor` tells you which mode you're in; force one
73
+ with `HUNCH_SYNTH_PROVIDER=claude-cli|codex-cli|cursor-agent|deterministic`.
66
74
 
67
75
  ### 3. Initialize the repo you want a memory for
68
76
 
@@ -100,24 +108,47 @@ normally and Claude consults Hunch, or invoke the slash commands:
100
108
  The MCP tools Claude calls under the hood: `hunch_why`, `hunch_query`,
101
109
  `hunch_check_constraints`, `hunch_get_dependents` (blast radius), `hunch_blast_radius`
102
110
  (dependent files + near-violations a change could break indirectly), `hunch_bug_lineage`,
103
- `hunch_context` (surgical minimal slice for a task), `hunch_record_decision` (write-back).
111
+ `hunch_context` (surgical minimal slice for a task), `hunch_timeline` (a target's decision
112
+ history over time), `hunch_record_decision` (write-back). `hunch_why` and `hunch_context`
113
+ take an optional `as_of` (commit/tag/branch) to **time-travel** the graph to a past state.
114
+
115
+ ### Works with any MCP assistant
116
+
117
+ The Hunch MCP server is **client-agnostic** — one `.hunch/` graph powers every
118
+ assistant. `hunch init` scaffolds each tool's MCP config + ambient grounding so
119
+ they all consult the same memory:
120
+
121
+ | Assistant | MCP config | Grounding file |
122
+ |---|---|---|
123
+ | Claude Code | `.mcp.json` | `CLAUDE.md` + `/hunch-*` slash commands |
124
+ | Cursor | `.cursor/mcp.json` | `.cursor/rules/hunch.mdc` (always-applied) |
125
+ | VS Code (Copilot) | `.vscode/mcp.json` | `.github/copilot-instructions.md` |
126
+ | Codex CLI | `.codex/config.toml` | `AGENTS.md` |
127
+ | Anything else | — | `AGENTS.md` (cross-tool standard) |
128
+
129
+ Each writer **merges** into existing files (other MCP servers and your own prose are
130
+ preserved) and is idempotent. Opt out with `hunch init --no-providers`.
104
131
 
105
132
  **Through the CLI** — the same graph, from your terminal:
106
133
 
107
134
  | Command | What |
108
135
  |---|---|
109
- | `hunch init [--enforce]` | scaffold `.hunch/`, index, install hook + merge driver, wire up Claude Code (`--enforce` adds a pre-commit invariant guard) |
136
+ | `hunch init` | scaffold `.hunch/`, index, install hook + merge driver, auto-install the advisory pre-commit guard, install the **Claude Code agent hooks**, and wire up **every assistant** (Claude Code, Cursor, VS Code/Copilot, Codex, AGENTS.md). Flags: `--no-enforce`, `--enforce-strict`, `--no-providers`, `--no-agent-hooks`, `--firmness <level>` |
110
137
  | `hunch index` | parse repo → symbols / edges / components (deterministic, no LLM) |
111
138
  | `hunch backfill --since 90d` | replay git history → seed decisions |
112
139
  | `hunch sync [sha]` | turn a commit into a Decision (run automatically by the hook) |
113
140
  | `hunch record-bug --test <id> --message <m>` | capture a Bug from a failing test |
141
+ | `hunch record-constraint "<statement>" [--scope <globs>] [--severity advisory\|warning\|blocking] [--type …] [--rationale <t>] [--source-decision <id>]` | record an invariant the code must not break (what `hunch check` + the strict agent hook enforce) |
142
+ | `hunch firmness [off\|advisory\|firm\|strict]` | get/set how firmly the agent hook enforces Hunch before edits (no arg prints the current level) |
114
143
  | `hunch test [cmd…]` | run the suite (default `npm test`); auto-capture failures as Bugs (suspects + recurrence→Constraints), mark passing tests' bugs fixed |
115
- | `hunch why <path\|symbol>` | decisions / bugs / constraints explaining a target (flags `⚠STALE`) |
144
+ | `hunch why <path\|symbol> [--as-of <ref>]` | decisions / bugs / constraints explaining a target (flags `⚠STALE`); `--as-of` time-travels to what was believed at a commit/tag/branch |
145
+ | `hunch timeline <path\|symbol>` | the decision history for a target — what was believed, its valid-time window, and what superseded it |
146
+ | `hunch supersede <old> --by <new>` | mark one decision as replaced by another: closes the old one's valid-time window (invalidate, don't delete) |
116
147
  | `hunch query "<q>" [--semantic]` | full-text + graph search (`--semantic` blends in local embeddings) |
117
148
  | `hunch embed` | generate local embeddings for semantic recall (opt-in; needs `@huggingface/transformers`) |
118
- | `hunch context <path\|symbol>` | minimal relevant slice for a task: invariants → decisions → bugs → blast radius |
149
+ | `hunch context <path\|symbol> [--as-of <ref>]` | minimal relevant slice for a task: invariants → decisions → bugs → blast radius (`--as-of` time-travels) |
119
150
  | `hunch fragile` | ranked fragility report with evidence |
120
- | `hunch check [--staged\|--commit <sha>] [--strict] [--blast]` | guardrail: flag changes touching a do-not-break invariant **directly or via blast radius** (a guarded file that depends on what you changed); `--blast` prints the dependency fan-out |
151
+ | `hunch check [--staged\|--commit <sha>] [--strict] [--blast]` | guardrail: flag changes touching a do-not-break invariant **directly or via blast radius** (a guarded file that depends on what you changed), **and changes that re-introduce something a decision deliberately retired** (the Regression Guard); `--blast` prints the dependency fan-out |
121
152
  | `hunch stale [--resync]` | drift: records whose files changed after last verification (`--resync` regenerates stale decisions from their commits) |
122
153
  | `hunch review [--accept <id>\|--reject <id>]` | curate: triage / promote / drop low-confidence drafts |
123
154
  | `hunch migrate` | upgrade `.hunch/` records to the current schema version |
@@ -125,6 +156,43 @@ The MCP tools Claude calls under the hood: `hunch_why`, `hunch_query`,
125
156
  | `hunch doctor` | environment diagnostics (git, auth mode, schema version, counts) |
126
157
  | `hunch mcp` | start the MCP server over stdio (Claude Code connects here) |
127
158
 
159
+ ## Grounding the agent automatically (firmness)
160
+
161
+ Telling an assistant "consult Hunch first" in a prompt is advisory — it drifts. `hunch
162
+ init` instead installs two **Claude Code agent hooks** (in `.claude/settings.json`) so the
163
+ grounding is enforced by the harness, not by the model's memory:
164
+
165
+ - **Before every edit** (`PreToolUse` on `Edit`/`Write`/`MultiEdit`) Hunch injects the
166
+ relevant slice for the file being touched — its decisions, invariants, bug history, and
167
+ blast radius — straight into the model's context.
168
+ - **On every prompt** (`UserPromptSubmit`) it reminds the agent to query Hunch.
169
+
170
+ How hard it pushes is one committed knob — set it once, it applies to the whole team:
171
+
172
+ ```bash
173
+ hunch firmness # print the current level
174
+ hunch firmness strict # change it (takes effect on the next edit; no restart)
175
+ ```
176
+
177
+ | Level | Before an edit |
178
+ |---|---|
179
+ | `off` | nothing (hook is a no-op) |
180
+ | `advisory` *(default)* | inject the relevant Hunch slice as context |
181
+ | `firm` | advisory **+** explicitly flag invariants in the file's scope |
182
+ | `strict` | firm **+** **deny** an edit that hits a *blocking* invariant (directly or via blast radius), feeding the invariant back as the refusal reason |
183
+
184
+ Before an edit, the hook also grounds the agent in anything an in-force decision
185
+ **deliberately retired** from that file ("don't re-introduce `login` here — dec_017 removed
186
+ it"). The actual gate is at commit time: `hunch check` runs the **Regression Guard** over
187
+ the staged diff and, under `--strict`, fails the commit when a change re-adds a retired
188
+ symbol/dependency tied to a blocking invariant (otherwise it warns).
189
+
190
+ The hook never breaks your flow: any error or unrecognized input emits nothing and exits
191
+ 0, and it stays silent on files Hunch hasn't learned yet. `strict` only bites once you have
192
+ **blocking** constraints recorded (`hunch record-constraint … --severity blocking`) — with
193
+ none, every level degrades to context-only. Opt out of the hooks entirely with `hunch init
194
+ --no-agent-hooks`.
195
+
128
196
  ## Semantic search (optional)
129
197
 
130
198
  By default `hunch query` and the `hunch_query` MCP tool use fast keyword (FTS) search —
@@ -272,5 +340,5 @@ npm test # node:test suite (store, graph, parse, indexer, synthesis,
272
340
  npm run hunch -- why src/store/hunchStore.ts # run the CLI from source via tsx, no build
273
341
  ```
274
342
 
275
- See [DESIGN.md](DESIGN.md) for the full spec. Deferred by design: embeddings / vector
276
- search, PR/CI webhooks, a web dashboard, and multi-repo support.
343
+ See [DESIGN.md](DESIGN.md) for the full spec. Deferred by design: PR/CI webhooks, a
344
+ web dashboard, and multi-repo support.
package/dist/cli/index.js CHANGED
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
17
17
  import { execFileSync, spawnSync } from "node:child_process";
18
+ import { relative } from "node:path";
18
19
  import { Command } from "commander";
19
20
  import { hunchPaths, findRoot } from "../core/paths.js";
20
21
  import { HunchStore } from "../store/hunchStore.js";
@@ -23,12 +24,17 @@ import { indexRepo } from "../extractors/indexer.js";
23
24
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
24
25
  import { parseTestReport } from "../extractors/testreport.js";
25
26
  import { selectProvider } from "../synthesis/provider.js";
26
- import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles } from "../extractors/git.js";
27
+ import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff } from "../extractors/git.js";
28
+ import { analyzeDiff } from "../extractors/diff.js";
27
29
  import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
28
30
  import { installMergeDriver } from "../integrations/mergeDriver.js";
29
31
  import { updateClaudeMd } from "../integrations/claudemd.js";
30
- import { writeMcpJson, writeSlashCommands } from "../integrations/scaffold.js";
32
+ import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
33
+ import { scaffoldProviders } from "../integrations/providers.js";
31
34
  import { formatContext } from "../core/format.js";
35
+ import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
36
+ import { blockingInScope } from "../core/hookpolicy.js";
37
+ import { constraintId } from "../core/ids.js";
32
38
  import { readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
33
39
  import { mergeHunchJson } from "../store/merge.js";
34
40
  import { planCompaction } from "../store/compact.js";
@@ -49,7 +55,15 @@ program
49
55
  .option("--no-index", "skip the initial repo index")
50
56
  .option("--no-enforce", "do not install the advisory pre-commit constraint guard")
51
57
  .option("--enforce-strict", "make the pre-commit guard FAIL the commit on a blocking invariant (direct or near)")
58
+ .option("--no-providers", "skip scaffolding non-Claude assistant configs (Cursor / VS Code / Codex / AGENTS.md)")
59
+ .option("--no-agent-hooks", "skip installing the Claude Code agent hooks (.claude/settings.json)")
60
+ .option("--firmness <level>", "agent-hook firmness: off | advisory | firm | strict")
52
61
  .action((opts) => {
62
+ // Validate --firmness up front, before any side effects (indexing, git hooks,
63
+ // .mcp.json) or opening the store — a bad value must not leave a half-init.
64
+ if (opts.firmness !== undefined && !isFirmness(opts.firmness)) {
65
+ return fail(`--firmness must be one of: ${FIRMNESS_LEVELS.join(", ")}`);
66
+ }
53
67
  const root = findRoot();
54
68
  const paths = hunchPaths(root);
55
69
  const store = new HunchStore(paths);
@@ -88,6 +102,26 @@ program
88
102
  console.log(` ✓ wrote ${cmds.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile)`);
89
103
  const cmd = updateClaudeMd(root, store);
90
104
  console.log(` ✓ updated ${rel(root, cmd)} with ambient Hunch context`);
105
+ // Firmness: stamp .hunch/config.json (default advisory) so `hunch hook` reads a
106
+ // level even before the user runs `hunch firmness` (--firmness validated above).
107
+ const firmness = writeConfig(paths, opts.firmness ? { firmness: opts.firmness } : {}).firmness;
108
+ // Agent hooks: ground the assistant in Hunch automatically (PreToolUse injects
109
+ // context before edits; UserPromptSubmit reminds). Reads firmness at run time.
110
+ if (opts.agentHooks !== false) {
111
+ const a = installClaudeHooks(root, `${inv.shell} hook`);
112
+ console.log(` ✓ Claude Code agent hooks ${a.action} (firmness: ${firmness} — change with \`hunch firmness <level>\`)`);
113
+ }
114
+ // Multi-assistant compatibility: the MCP server is client-agnostic, so wire up
115
+ // Cursor / VS Code (Copilot) / Codex / AGENTS.md to the same .hunch/ graph.
116
+ if (opts.providers !== false) {
117
+ const ps = scaffoldProviders(root, inv.mcp, store);
118
+ const ok = ps.filter((p) => !p.error);
119
+ const total = ok.reduce((a, p) => a + p.files.length, 0);
120
+ console.log(` ✓ wrote ${total} multi-assistant config file(s) → ${ok.map((p) => p.assistant).join(", ")}`);
121
+ for (const p of ps)
122
+ if (p.error)
123
+ console.log(` ⚠ skipped ${p.assistant}: ${p.error}`);
124
+ }
91
125
  store.close();
92
126
  console.log("\nNext: make a commit (the hook captures a decision), then ask Claude Code \"why is X built this way?\"");
93
127
  console.log("Cold start? Seed from history: hunch backfill --since 90d");
@@ -260,12 +294,16 @@ program
260
294
  .command("why")
261
295
  .description("Explain why a file/symbol is the way it is (decisions, bugs, constraints).")
262
296
  .argument("<target>", "file path or symbol name")
263
- .action((target) => {
297
+ .option("--as-of <ref>", "time-travel: what was believed as of a commit/tag/branch (e.g. v0.7.0, HEAD~5)")
298
+ .action((target, opts) => {
264
299
  const { store, root } = storeFor();
265
- const w = store.why(target);
300
+ const asOf = opts.asOf ? asOfDate(opts.asOf, root) : undefined;
301
+ if (opts.asOf && !asOf)
302
+ return fail(`could not resolve --as-of "${opts.asOf}" to a commit (need a git repo and a valid ref)`);
303
+ const w = store.why(target, { asOf });
266
304
  const staleIds = new Set(store.staleness((f) => lastChangeDate(f, root)).map((s) => s.id));
267
305
  const drift = (id) => (staleIds.has(id) ? " ⚠STALE" : "");
268
- console.log(`Why "${target}":\n`);
306
+ console.log(asOf ? `Why "${target}" (as of ${opts.asOf} — ${asOf.slice(0, 10)}):\n` : `Why "${target}":\n`);
269
307
  if (w.decisions.length) {
270
308
  console.log("DECISIONS:");
271
309
  for (const d of w.decisions)
@@ -324,6 +362,44 @@ program
324
362
  console.log(` ↳ promoted constraint ${r.constraint.id} [${r.constraint.severity}]: ${r.constraint.statement}`);
325
363
  store.close();
326
364
  });
365
+ // ---- record-constraint (human-authored invariant) -------------------------
366
+ program
367
+ .command("record-constraint")
368
+ .description("Record an invariant the codebase must not break — what `hunch check` and the strict agent hook enforce.")
369
+ .argument("<statement>", 'the invariant, e.g. "vectors are derived, never the source of truth"')
370
+ .option("--scope <globs>", "comma-separated path/glob(s) it applies to (e.g. src/store/**)", "")
371
+ .option("--severity <s>", "advisory | warning | blocking", "warning")
372
+ .option("--type <t>", "security | performance | correctness | architecture | compliance", "correctness")
373
+ .option("--rationale <text>", "why it must hold", "")
374
+ .option("--source-decision <id>", "decision id this derives from")
375
+ .option("--enforcement <e>", "advisory_v1 | ci | manual", "advisory_v1")
376
+ .action((statement, opts) => {
377
+ const SEV = ["advisory", "warning", "blocking"];
378
+ if (!SEV.includes(opts.severity))
379
+ return fail(`--severity must be one of: ${SEV.join(", ")}`);
380
+ const { store, root } = storeFor();
381
+ store.json.ensureDirs();
382
+ const scope = opts.scope.split(",").map((s) => s.trim()).filter(Boolean);
383
+ const c = store.json.put("constraints", {
384
+ id: constraintId(statement),
385
+ type: opts.type,
386
+ statement,
387
+ scope,
388
+ severity: opts.severity,
389
+ enforcement: opts.enforcement,
390
+ rationale: opts.rationale,
391
+ source_decision: opts.sourceDecision ?? null,
392
+ violations: [],
393
+ status: "active",
394
+ valid_from: new Date().toISOString(),
395
+ valid_to: null,
396
+ provenance: { source: "human_confirmed", confidence: 1, evidence: [], last_verified: new Date().toISOString() },
397
+ });
398
+ store.reindex();
399
+ updateClaudeMd(root, store);
400
+ console.log(`✓ recorded ${c.severity} constraint ${c.id}: "${c.statement}" (scope: ${scope.join(", ") || "repo"})`);
401
+ store.close();
402
+ });
327
403
  // ---- test (failure-learning loop) -----------------------------------------
328
404
  program
329
405
  .command("test")
@@ -470,6 +546,13 @@ program
470
546
  }
471
547
  }
472
548
  }
549
+ // 3) REGRESSION — does the diff RE-ADD something an in-force decision removed?
550
+ // (e.g. re-introducing a symbol/dep that was deliberately deleted). Warn
551
+ // always; only a blocking-linked resurrection fails the commit under strict.
552
+ const diff = opts.commit ? commitDiff(opts.commit, root) : stagedDiff(root);
553
+ const an = analyzeDiff(diff);
554
+ const regHits = store.regressionHits({ symbols: an.addedSymbols.map((s) => s.name), deps: an.addedDeps }, files);
555
+ const regBlocking = regHits.filter((h) => h.blocking).length;
473
556
  if (opts.blast) {
474
557
  console.log(`Blast radius of ${files.length} changed file(s):`);
475
558
  for (const f of files) {
@@ -479,8 +562,8 @@ program
479
562
  }
480
563
  console.log("");
481
564
  }
482
- if (!direct.size && !near.size) {
483
- console.log(`✓ ${files.length} changed file(s) touch no recorded invariants (directly or via blast radius).`);
565
+ if (!direct.size && !near.size && !regHits.length) {
566
+ console.log(`✓ ${files.length} changed file(s) touch no recorded invariants (directly or via blast radius) and re-introduce nothing deliberately retired.`);
484
567
  store.close();
485
568
  return;
486
569
  }
@@ -501,8 +584,18 @@ program
501
584
  console.log(` ${mark(c.severity)} [${c.severity}] ${c.statement}\n ${c.id}\n ${via.slice(0, 4).join("\n ")}${via.length > 4 ? `\n …+${via.length - 4} more path(s)` : ""}`);
502
585
  }
503
586
  }
504
- if (opts.strict && blocking) {
505
- console.log(`\n ${blocking} blocking invariant(s) in scope (direct or near) — review before committing.`);
587
+ if (regHits.length) {
588
+ console.log(`${direct.size || near.size ? "\n" : ""}Re-introduces ${regHits.length} deliberately-retired item(s):\n`);
589
+ for (const h of regHits) {
590
+ console.log(` ${h.blocking ? "⛔" : "⚠"} re-adds ${h.kind} \`${h.name}\` — ${h.decision} removed it${h.blocking ? " (blocking-linked)" : ""}\n “${h.title}”\n ${h.reason}`);
591
+ }
592
+ }
593
+ if (opts.strict && (blocking || regBlocking)) {
594
+ const reasons = [
595
+ blocking ? `${blocking} blocking invariant(s) in scope` : "",
596
+ regBlocking ? `${regBlocking} blocking-linked regression(s)` : "",
597
+ ].filter(Boolean).join(" + ");
598
+ console.log(`\n✗ ${reasons} — review before committing.`);
506
599
  process.exitCode = 1;
507
600
  }
508
601
  else {
@@ -516,12 +609,149 @@ program
516
609
  .description("Assemble the minimal relevant Hunch slice for a task on a file/symbol.")
517
610
  .argument("<target>", "file path or symbol")
518
611
  .option("--budget <n>", "rough token budget", "1500")
612
+ .option("--as-of <ref>", "time-travel: assemble the slice as it stood at a commit/tag/branch")
519
613
  .action((target, opts) => {
520
- const { store } = storeFor();
614
+ const { store, root } = storeFor();
615
+ const asOf = opts.asOf ? asOfDate(opts.asOf, root) : undefined;
616
+ if (opts.asOf && !asOf)
617
+ return fail(`could not resolve --as-of "${opts.asOf}" to a commit`);
521
618
  store.reindex(); // reflect any out-of-band JSON edits before assembling
522
- process.stdout.write(formatContext(store.assembleContext(target, Number(opts.budget))));
619
+ process.stdout.write(formatContext(store.assembleContext(target, Number(opts.budget), { asOf })));
523
620
  store.close();
524
621
  });
622
+ // ---- timeline -------------------------------------------------------------
623
+ program
624
+ .command("timeline")
625
+ .description("Time-travel: the decision history for a file/symbol — what was believed, and when/why it changed.")
626
+ .argument("<target>", "file path or symbol name")
627
+ .action((target) => {
628
+ const { store } = storeFor();
629
+ const tl = store.timeline(target);
630
+ if (!tl.length) {
631
+ console.log(`No decision history for "${target}" yet.`);
632
+ }
633
+ else {
634
+ console.log(`Decision timeline for "${target}" (newest first):\n`);
635
+ for (const d of tl) {
636
+ const from = (d.valid_from ?? d.date).slice(0, 10);
637
+ const window = d.valid_to ? `${from} → ${d.valid_to.slice(0, 10)}` : `${from} → now`;
638
+ const sup = d.superseded_by ? ` ↦ superseded by ${d.superseded_by}` : "";
639
+ console.log(` • ${d.id} [${d.status}] (${window})${sup}\n ${d.title}`);
640
+ }
641
+ }
642
+ store.close();
643
+ });
644
+ // ---- supersede ------------------------------------------------------------
645
+ program
646
+ .command("supersede")
647
+ .description("Mark one decision as replaced by another: closes the old one's valid-time window (invalidate, don't delete).")
648
+ .argument("<old>", "decision id being replaced")
649
+ .requiredOption("--by <new>", "decision id that supersedes it")
650
+ .action((oldId, opts) => {
651
+ const { store } = storeFor();
652
+ const by = store.json.get("decisions", opts.by);
653
+ if (!by) {
654
+ store.close();
655
+ return fail(`--by decision "${opts.by}" not found`);
656
+ }
657
+ const closed = store.supersede(oldId, by);
658
+ if (!closed) {
659
+ store.close();
660
+ return fail(`decision "${oldId}" not found (or same as --by)`);
661
+ }
662
+ store.reindex();
663
+ console.log(`✓ ${oldId} superseded by ${opts.by} — window closed at ${closed.valid_to?.slice(0, 10)}.`);
664
+ store.close();
665
+ });
666
+ // ---- firmness (agent-hook enforcement level) ------------------------------
667
+ program
668
+ .command("firmness")
669
+ .description("Get or set how firmly the Claude Code agent hook enforces Hunch before edits.")
670
+ .argument("[level]", "off | advisory | firm | strict (omit to print the current level)")
671
+ .action((level) => {
672
+ const paths = hunchPaths(findRoot());
673
+ if (!level) {
674
+ console.log(`firmness: ${readConfig(paths).firmness}`);
675
+ console.log(`levels: ${FIRMNESS_LEVELS.join(" | ")} (set with: hunch firmness <level>)`);
676
+ return;
677
+ }
678
+ if (!isFirmness(level)) {
679
+ return fail(`firmness must be one of: ${FIRMNESS_LEVELS.join(", ")}`);
680
+ }
681
+ const next = writeConfig(paths, { firmness: level }).firmness;
682
+ console.log(`✓ firmness set to ${next} (takes effect on the next edit — no Claude Code restart needed).`);
683
+ });
684
+ // ---- hook (Claude Code agent-hook handler) --------------------------------
685
+ program
686
+ .command("hook")
687
+ .description("Claude Code hook handler: inject relevant Hunch context before edits (and, at strict firmness, deny edits that hit a blocking invariant). Reads the hook event JSON on stdin.")
688
+ .action(async () => {
689
+ // A hook MUST NEVER break the agent: on ANY error or unrecognized input we
690
+ // emit nothing and exit 0 (the action defers to Claude Code's normal flow).
691
+ let store = null;
692
+ try {
693
+ const evt = JSON.parse(await readStdin());
694
+ const root = findRoot();
695
+ const paths = hunchPaths(root);
696
+ const firmness = readConfig(paths).firmness;
697
+ if (firmness === "off")
698
+ return;
699
+ if (evt.hook_event_name === "UserPromptSubmit") {
700
+ emitContext("UserPromptSubmit", HOOK_REMINDER);
701
+ return;
702
+ }
703
+ if (evt.hook_event_name !== "PreToolUse")
704
+ return;
705
+ const abs = evt.tool_input?.file_path;
706
+ if (!abs)
707
+ return;
708
+ const target = toRepoRel(root, abs);
709
+ // Outside the repo (".." prefix) or on another drive (absolute, e.g. "D:/…")
710
+ // → nothing for Hunch to say.
711
+ if (!target || target.startsWith("..") || /^[a-zA-Z]:/.test(target))
712
+ return;
713
+ store = new HunchStore(paths);
714
+ // strict: refuse an edit that hits a BLOCKING invariant (direct OR via blast
715
+ // radius), feeding the invariant statement back as the refusal reason. Reindex
716
+ // first so the blast radius reflects uncommitted edges — strict opts into the
717
+ // cost for correctness. Advisory/firm skip it: the hook fires on every edit,
718
+ // and decisions/constraints don't change between commits, so the committed
719
+ // index is good enough for grounding.
720
+ if (firmness === "strict") {
721
+ store.reindex();
722
+ const deny = blockingInScope(store, target);
723
+ if (deny) {
724
+ emitDeny(deny.reason);
725
+ return;
726
+ }
727
+ }
728
+ // advisory / firm / strict(non-blocking): inject the relevant Hunch slice.
729
+ const ctx = store.assembleContext(target);
730
+ // Regression Guard (edit-time grounding): what an in-force decision retired
731
+ // from this file. No diff exists yet, so this is context — "don't re-add X" —
732
+ // not a block; the commit-time `hunch check` does the actual gating.
733
+ const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
734
+ const hasContent = ctx.constraints.length || ctx.decisions.length || ctx.bugs.length || ctx.blast_radius.length || retired.length;
735
+ if (!hasContent)
736
+ return; // no noise on files Hunch hasn't learned yet
737
+ let text = formatContext(ctx).trim();
738
+ if (firmness !== "advisory" && ctx.constraints.length) {
739
+ const names = ctx.constraints.map((c) => `[${c.severity}] ${c.statement}`).join("; ");
740
+ text += `\n\n⚠ This file is in scope of ${ctx.constraints.length} invariant(s): ${names}. Preserve them.`;
741
+ }
742
+ if (retired.length) {
743
+ const items = retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ");
744
+ text += `\n\n⚠ Deliberately RETIRED from this file — do not re-introduce without cause: ${items}.`;
745
+ }
746
+ emitContext("PreToolUse", text);
747
+ }
748
+ catch {
749
+ // swallow — never block an edit on a hook failure
750
+ }
751
+ finally {
752
+ store?.close();
753
+ }
754
+ });
525
755
  // ---- review (curate loop) -------------------------------------------------
526
756
  program
527
757
  .command("review")
@@ -676,16 +906,22 @@ program
676
906
  console.log(`schema: v${onDisk} (hunch v${SCHEMA_VERSION})${schemaNote}`);
677
907
  const provider = await selectProvider();
678
908
  console.log(`synthesis: ${provider.name}`);
679
- // Synthesis is billed to the user's Claude SUBSCRIPTION via the `claude` CLI,
680
- // never the pay-per-token API. Surface whatever stands between here and that.
681
- if (provider.name === "claude-cli") {
682
- const hadKey = !!(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN);
683
- console.log(` ↳ LLM synthesis billed to your Claude subscription` +
684
- (hadKey ? ` (ANTHROPIC_API_KEY in env is stripped — never billed to the API)` : ``));
909
+ // Synthesis is billed to the user's SUBSCRIPTION via a coding-assistant CLI,
910
+ // never a pay-per-token API key. Surface which one or what's missing.
911
+ const SUB = {
912
+ "claude-cli": { label: "Claude subscription (claude CLI)", strip: "ANTHROPIC_API_KEY" },
913
+ "codex-cli": { label: "ChatGPT subscription (codex CLI)", strip: "OPENAI_API_KEY" },
914
+ "cursor-agent": { label: "Cursor subscription (cursor-agent CLI)" },
915
+ };
916
+ const sub = SUB[provider.name];
917
+ if (sub) {
918
+ const hadKey = sub.strip && !!process.env[sub.strip];
919
+ console.log(` ↳ LLM synthesis billed to your ${sub.label}` +
920
+ (hadKey ? ` (${sub.strip} in env is stripped — never billed to the API)` : ``));
685
921
  }
686
- else if (provider.name === "deterministic") {
687
- console.log(dim(` ↳ no \`claude\` CLI — synthesis uses the offline heuristic (advisory, low-confidence)`));
688
- console.log(dim(` for full synthesis: install Claude Code + \`claude /login\`, or set CLAUDE_CODE_OAUTH_TOKEN (\`claude setup-token\`) for CI`));
922
+ else {
923
+ console.log(dim(` ↳ no assistant CLI found — synthesis uses the offline heuristic (advisory, low-confidence)`));
924
+ console.log(dim(` for full synthesis install one: Claude Code (\`claude /login\`), Codex (\`codex login\`), or Cursor (\`cursor-agent login\`)`));
689
925
  }
690
926
  const c = store.reindex().counts;
691
927
  console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
@@ -712,6 +948,37 @@ function fail(msg) {
712
948
  console.error(`error: ${msg}`);
713
949
  process.exitCode = 1;
714
950
  }
951
+ // --- agent-hook helpers (used by `hunch hook`) -----------------------------
952
+ const HOOK_REMINDER = "Hunch (engineering memory) is available for this repo. Before editing, call " +
953
+ "hunch_check_constraints(scope) for do-not-break invariants and hunch_why(target) " +
954
+ "for the rationale; use hunch_get_dependents for blast radius and hunch_bug_lineage " +
955
+ "for prior root causes. After a non-trivial choice, record it with hunch_record_decision.";
956
+ /** Read all of stdin (the hook event JSON). A TTY (no piped input) resolves to ""
957
+ * so an accidental interactive `hunch hook` exits cleanly instead of hanging. */
958
+ function readStdin() {
959
+ return new Promise((resolve) => {
960
+ if (process.stdin.isTTY)
961
+ return resolve("");
962
+ let data = "";
963
+ process.stdin.setEncoding("utf8");
964
+ process.stdin.on("data", (c) => (data += c));
965
+ process.stdin.on("end", () => resolve(data));
966
+ process.stdin.on("error", () => resolve(data));
967
+ });
968
+ }
969
+ /** Absolute edit path → repo-relative, forward-slash (constraint scopes are
970
+ * forward-slash globs even on Windows). */
971
+ function toRepoRel(root, abs) {
972
+ return relative(root, abs).split("\\").join("/");
973
+ }
974
+ function emitContext(event, text) {
975
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } }));
976
+ }
977
+ function emitDeny(reason) {
978
+ process.stdout.write(JSON.stringify({
979
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
980
+ }));
981
+ }
715
982
  program.parseAsync().catch((e) => {
716
983
  try {
717
984
  openStore?.close();
@@ -0,0 +1,35 @@
1
+ /** Hunch user config (`.hunch/config.json`) — runtime knobs that are NOT schema
2
+ * state (the on-disk schema version lives in manifest.json). Committed alongside
3
+ * the graph, so a whole team shares the same settings — e.g. how firmly the
4
+ * Claude Code agent hook enforces engineering memory before an edit. */
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
+ import { dirname } from "node:path";
7
+ export const FIRMNESS_LEVELS = ["off", "advisory", "firm", "strict"];
8
+ export const DEFAULT_FIRMNESS = "advisory";
9
+ function defaults() {
10
+ return { firmness: DEFAULT_FIRMNESS };
11
+ }
12
+ export function isFirmness(v) {
13
+ return typeof v === "string" && FIRMNESS_LEVELS.includes(v);
14
+ }
15
+ /** Read `.hunch/config.json`. A missing/unparseable file, or an unknown firmness
16
+ * value, falls back to defaults — the hook must NEVER crash an edit over config. */
17
+ export function readConfig(paths) {
18
+ if (!existsSync(paths.config))
19
+ return defaults();
20
+ try {
21
+ const raw = JSON.parse(readFileSync(paths.config, "utf8"));
22
+ return { firmness: isFirmness(raw.firmness) ? raw.firmness : DEFAULT_FIRMNESS };
23
+ }
24
+ catch {
25
+ return defaults();
26
+ }
27
+ }
28
+ /** Write `.hunch/config.json`, merging `patch` over the current on-disk config. */
29
+ export function writeConfig(paths, patch) {
30
+ const next = { ...readConfig(paths), ...patch };
31
+ mkdirSync(dirname(paths.config), { recursive: true });
32
+ writeFileSync(paths.config, JSON.stringify(next, null, 2) + "\n");
33
+ return next;
34
+ }
35
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,22 @@
1
+ /** Return a BlockingHit if editing `file` (repo-relative) hits a blocking
2
+ * invariant directly or through its blast radius, else null. */
3
+ export function blockingInScope(store, file) {
4
+ for (const c of store.checkConstraints(file)) {
5
+ if (c.severity === "blocking") {
6
+ return {
7
+ reason: `Hunch: editing ${file} would touch a BLOCKING invariant — "${c.statement}" (${c.id}). Do not proceed unless this change is meant to modify that invariant; otherwise preserve it.`,
8
+ };
9
+ }
10
+ }
11
+ for (const b of store.blastRadiusFiles(file)) {
12
+ for (const c of store.checkConstraints(b.file)) {
13
+ if (c.severity === "blocking") {
14
+ return {
15
+ reason: `Hunch: ${file} is in the blast radius of a BLOCKING invariant — "${c.statement}" (${c.id}; via ${b.file}, ${b.via} depth ${b.depth}). Verify the invariant still holds before editing.`,
16
+ };
17
+ }
18
+ }
19
+ }
20
+ return null;
21
+ }
22
+ //# sourceMappingURL=hookpolicy.js.map