@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/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
|
|
59
|
+
### 2. (Recommended) make a coding-assistant CLI available
|
|
60
60
|
|
|
61
|
-
Hunch's LLM synthesis is billed to your
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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), `
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
276
|
-
|
|
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
|
-
.
|
|
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
|
|
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 (
|
|
505
|
-
console.log(
|
|
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
|
|
680
|
-
// never
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
|
687
|
-
console.log(dim(` ↳ no
|
|
688
|
-
console.log(dim(` for full synthesis
|
|
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
|