@davesheffer/hunch 0.23.0 → 0.25.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/README.md +18 -0
- package/dist/cli/index.js +67 -1
- package/dist/core/drift.js +98 -0
- package/dist/core/ids.js +5 -0
- package/dist/core/types.js +20 -2
- package/dist/extractors/git.js +6 -0
- package/dist/store/hunchStore.js +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,6 +66,24 @@ came before. Local-first, no documentation toil, no SaaS.
|
|
|
66
66
|
[provenance](https://hunch-pi.vercel.app/docs#provenance) ·
|
|
67
67
|
[time-travel](https://hunch-pi.vercel.app/docs#time-travel)
|
|
68
68
|
|
|
69
|
+
## Why Hunch is different
|
|
70
|
+
|
|
71
|
+
"Memory for coding agents" is getting crowded, but most of it is a *server-side, ephemeral,
|
|
72
|
+
single-vendor* RAG cache over your current code. Hunch is the opposite on every axis — and that
|
|
73
|
+
combination is the moat:
|
|
74
|
+
|
|
75
|
+
| | Typical agent memory | **Hunch** |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| **Storage** | server-side / a vendor's cloud | **git-tracked JSON in your repo** — diff it, review it in PRs, sync it over `git push` |
|
|
78
|
+
| **Lifetime** | the session; often auto-expiring | the **lifetime of the codebase** — non-destructive supersede/veto keeps the *why-it-changed* trail |
|
|
79
|
+
| **Clients** | one vendor's agent | **client-agnostic** — one `.hunch/` graph serves Claude Code, Cursor, Copilot & Windsurf via MCP |
|
|
80
|
+
| **What's stored** | opaque extracted "facts" | **structured ADRs** — decisions with rejected-alternatives, bug lineage, and invariants |
|
|
81
|
+
| **Enforcement** | advisory / just-in-time hints | **fail-closed deterministic guards** — no model in the block path; a commit fails on a human-vouched, set-intersection match |
|
|
82
|
+
| **Trust** | take it on faith | **provenance on every record** (source + confidence + evidence) and a measurable retrieval signal (`hunch eval`) |
|
|
83
|
+
|
|
84
|
+
The short version: **git tracks *what* changed; Hunch tracks *why*** — locally, durably, and under
|
|
85
|
+
your control, with guards that actually hold the line instead of just suggesting.
|
|
86
|
+
|
|
69
87
|
## Getting started
|
|
70
88
|
|
|
71
89
|
```bash
|
package/dist/cli/index.js
CHANGED
|
@@ -28,7 +28,8 @@ import { indexRepo } from "../extractors/indexer.js";
|
|
|
28
28
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
29
29
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
30
30
|
import { selectProvider } from "../synthesis/provider.js";
|
|
31
|
-
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, revExists, commitAndPushHunch, gitUntrackCached } from "../extractors/git.js";
|
|
31
|
+
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, commitAndPushHunch, gitUntrackCached } from "../extractors/git.js";
|
|
32
|
+
import { runbookId } from "../core/ids.js";
|
|
32
33
|
import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkreport.js";
|
|
33
34
|
import { partitionReview, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
|
|
34
35
|
import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
|
|
@@ -43,6 +44,7 @@ import { formatContext } from "../core/format.js";
|
|
|
43
44
|
import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
|
|
44
45
|
import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
|
|
45
46
|
import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
|
|
47
|
+
import { computeDrift } from "../core/drift.js";
|
|
46
48
|
import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
|
|
47
49
|
import { constraintId } from "../core/ids.js";
|
|
48
50
|
import { readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
|
|
@@ -453,6 +455,56 @@ program
|
|
|
453
455
|
}
|
|
454
456
|
store.close();
|
|
455
457
|
});
|
|
458
|
+
// ---- runbook (distill reusable "how" from a commit range; roadmap #5) ------
|
|
459
|
+
program
|
|
460
|
+
.command("runbook")
|
|
461
|
+
.description("Distill a reusable runbook (the 'how' of a recurring task) from a commit range. Advisory; refine before relying on it.")
|
|
462
|
+
.argument("<range>", "commit range: <base>..<head>, or <base> (→ <base>..HEAD)")
|
|
463
|
+
.requiredOption("--task <task>", "the recurring task this runbook answers")
|
|
464
|
+
.option("--private", "write into the private overlay (HUNCH_PRIVATE_DIR), not the committed repo")
|
|
465
|
+
.action((range, opts) => {
|
|
466
|
+
const { store, root } = storeFor();
|
|
467
|
+
if (opts.private && !store.hasPrivate) {
|
|
468
|
+
store.close();
|
|
469
|
+
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
470
|
+
}
|
|
471
|
+
const [base, head = "HEAD"] = range.split("..");
|
|
472
|
+
if (!base || !revExists(base, root)) {
|
|
473
|
+
store.close();
|
|
474
|
+
return fail(`base ref "${base ?? ""}" not found (range: ${range})`);
|
|
475
|
+
}
|
|
476
|
+
const steps = rangeSubjects(base, root, head);
|
|
477
|
+
const files = rangeFiles(base, root, head);
|
|
478
|
+
if (!steps.length && !files.length) {
|
|
479
|
+
store.close();
|
|
480
|
+
return fail(`no commits or changes in range ${range}`);
|
|
481
|
+
}
|
|
482
|
+
const now = new Date().toISOString();
|
|
483
|
+
const rec = {
|
|
484
|
+
id: runbookId(opts.task),
|
|
485
|
+
task: opts.task,
|
|
486
|
+
trigger: [opts.task],
|
|
487
|
+
steps, // already oldest-first (chronological procedure)
|
|
488
|
+
files,
|
|
489
|
+
gotchas: [],
|
|
490
|
+
outcome: "",
|
|
491
|
+
source_range: range,
|
|
492
|
+
valid_from: now,
|
|
493
|
+
valid_to: null,
|
|
494
|
+
// Deterministic draft (commit subjects + changed files); advisory, low-confidence.
|
|
495
|
+
// Refine steps/gotchas by hand. LLM enrichment is a later tier.
|
|
496
|
+
provenance: { source: "extracted", confidence: 0.5, evidence: [range] },
|
|
497
|
+
date: now,
|
|
498
|
+
};
|
|
499
|
+
if (opts.private)
|
|
500
|
+
store.putPrivate("runbooks", rec);
|
|
501
|
+
else
|
|
502
|
+
store.json.put("runbooks", rec);
|
|
503
|
+
store.reindex();
|
|
504
|
+
console.log(`✓ runbook ${rec.id} — "${rec.task}" (${rec.steps.length} steps, ${rec.files.length} files)${opts.private ? " [private overlay]" : ""}`);
|
|
505
|
+
console.log(dim(" advisory, deterministic draft — refine the steps/gotchas; surfaced via `hunch query` and MCP."));
|
|
506
|
+
store.close();
|
|
507
|
+
});
|
|
456
508
|
// ---- embed (opt-in semantic search) ---------------------------------------
|
|
457
509
|
program
|
|
458
510
|
.command("embed")
|
|
@@ -1273,6 +1325,20 @@ program
|
|
|
1273
1325
|
}
|
|
1274
1326
|
// Windows: detect/heal the Claude Code ~/.claude.json drive-letter case-split
|
|
1275
1327
|
// that silently hides the hunch_* MCP tools. No-op (silent) off Windows.
|
|
1328
|
+
// Memory drift: deterministic, advisory smoke detector for memory that has
|
|
1329
|
+
// fallen out of sync with the code/docs (dead file refs, dangling supersedes,
|
|
1330
|
+
// docs still marked "proposed"). Never blocks; never auto-fixes.
|
|
1331
|
+
const drift = computeDrift(store, root);
|
|
1332
|
+
if (drift.findings.length) {
|
|
1333
|
+
console.log(`drift: ⚠ ${drift.findings.length} finding(s) — memory may be out of sync with the code:`);
|
|
1334
|
+
for (const f of drift.findings.slice(0, 20))
|
|
1335
|
+
console.log(` · [${f.kind}] ${f.id} — ${f.detail}`);
|
|
1336
|
+
if (drift.findings.length > 20)
|
|
1337
|
+
console.log(dim(` … and ${drift.findings.length - 20} more`));
|
|
1338
|
+
}
|
|
1339
|
+
else {
|
|
1340
|
+
console.log(`drift: ✓ no stale refs, dangling supersedes, or stale "proposed" docs`);
|
|
1341
|
+
}
|
|
1276
1342
|
reportClaudeConfigHeal();
|
|
1277
1343
|
store.close();
|
|
1278
1344
|
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory drift checks (roadmap #7, dec_2a53072620). Deterministic, model-free
|
|
3
|
+
* comparisons of the curated graph against the actual code/docs — a smoke detector
|
|
4
|
+
* for stale memory, NOT a robot that rewrites it. Advisory only: `hunch doctor`
|
|
5
|
+
* prints findings; nothing blocks and nothing is auto-fixed. Each check maps to drift
|
|
6
|
+
* observed in practice:
|
|
7
|
+
* - dead-ref: an in-force decision points at a file that no longer exists.
|
|
8
|
+
* - supersede: A claims to supersede B, but B was never properly closed.
|
|
9
|
+
* - doc-stale: a doc marked "proposed / not yet implemented" references shipped code.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
12
|
+
import { join, extname } from "node:path";
|
|
13
|
+
const STALE_MARKER = /\b(proposed|not yet implemented|no code yet)\b/i;
|
|
14
|
+
const SRC_REF = /\bsrc\/[A-Za-z0-9_\-/]+\.ts\b/g;
|
|
15
|
+
export function computeDrift(store, root) {
|
|
16
|
+
const findings = [];
|
|
17
|
+
const decisions = store.recs("decisions");
|
|
18
|
+
const byId = new Map(decisions.map((d) => [d.id, d]));
|
|
19
|
+
for (const d of decisions) {
|
|
20
|
+
// 1. DEAD-REFERENCE — only for in-force decisions; a superseded one referencing
|
|
21
|
+
// a since-deleted file is legitimate history, not drift.
|
|
22
|
+
const inForce = d.status !== "superseded" && !d.superseded_by;
|
|
23
|
+
if (inForce) {
|
|
24
|
+
for (const f of d.related_files ?? []) {
|
|
25
|
+
if (!f || f.includes("*"))
|
|
26
|
+
continue; // skip globs / empties
|
|
27
|
+
if (!existsSync(join(root, f))) {
|
|
28
|
+
findings.push({ kind: "dead-ref", id: d.id, detail: `references missing file "${f}"` });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// 2. SUPERSEDE-INTEGRITY — a contradiction class: A.supersedes = B, but B is
|
|
33
|
+
// either gone or still in force (the private-supersede bug shape).
|
|
34
|
+
if (d.supersedes) {
|
|
35
|
+
const target = byId.get(d.supersedes);
|
|
36
|
+
if (!target) {
|
|
37
|
+
findings.push({ kind: "supersede", id: d.id, detail: `supersedes "${d.supersedes}", which does not exist` });
|
|
38
|
+
}
|
|
39
|
+
else if (target.status !== "superseded" || target.superseded_by !== d.id) {
|
|
40
|
+
findings.push({
|
|
41
|
+
kind: "supersede",
|
|
42
|
+
id: d.id,
|
|
43
|
+
detail: `supersedes "${d.supersedes}", but it is still in force (status=${target.status}, superseded_by=${target.superseded_by ?? "null"})`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// 3. DOC-STALE — a doc that still advertises "proposed / not implemented" while
|
|
49
|
+
// referencing code that exists. Heuristic + advisory; scoped to the repo's own
|
|
50
|
+
// markdown (node_modules and sub-projects skipped).
|
|
51
|
+
for (const doc of markdownDocs(root)) {
|
|
52
|
+
const text = safeRead(doc.path);
|
|
53
|
+
if (!STALE_MARKER.test(text.slice(0, 1500)))
|
|
54
|
+
continue;
|
|
55
|
+
const existing = (text.match(SRC_REF) ?? []).find((r) => existsSync(join(root, r)));
|
|
56
|
+
if (existing) {
|
|
57
|
+
findings.push({ kind: "doc-stale", id: doc.rel, detail: `marked proposed/not-implemented but references shipped code (${existing})` });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { findings };
|
|
61
|
+
}
|
|
62
|
+
function safeRead(path) {
|
|
63
|
+
try {
|
|
64
|
+
return readFileSync(path, "utf8");
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".hunch", ".hunch-private", "dist", "vscode-extension", "site"]);
|
|
71
|
+
/** Bounded walk for repo markdown (root + docs/, depth-limited; heavy/irrelevant trees skipped). */
|
|
72
|
+
function markdownDocs(root) {
|
|
73
|
+
const out = [];
|
|
74
|
+
const walk = (dir, rel, depth) => {
|
|
75
|
+
if (depth > 4)
|
|
76
|
+
return;
|
|
77
|
+
let entries;
|
|
78
|
+
try {
|
|
79
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
for (const e of entries) {
|
|
85
|
+
if (e.isDirectory()) {
|
|
86
|
+
if (e.name.startsWith(".") || SKIP_DIRS.has(e.name))
|
|
87
|
+
continue;
|
|
88
|
+
walk(join(dir, e.name), rel ? `${rel}/${e.name}` : e.name, depth + 1);
|
|
89
|
+
}
|
|
90
|
+
else if (extname(e.name) === ".md") {
|
|
91
|
+
out.push({ path: join(dir, e.name), rel: rel ? `${rel}/${e.name}` : e.name });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
walk(root, "", 0);
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=drift.js.map
|
package/dist/core/ids.js
CHANGED
|
@@ -32,6 +32,11 @@ export function decisionId(seed) {
|
|
|
32
32
|
export function bugId(seed) {
|
|
33
33
|
return "bug_" + shortHash(seed);
|
|
34
34
|
}
|
|
35
|
+
/** Runbook id seeded by its task; trim + lowercase so re-capturing the same task
|
|
36
|
+
* is idempotent rather than minting a duplicate. */
|
|
37
|
+
export function runbookId(seed) {
|
|
38
|
+
return "rb_" + shortHash(seed.trim().toLowerCase());
|
|
39
|
+
}
|
|
35
40
|
/** Constraint id seeded by its statement. Trim + lowercase so trivial
|
|
36
41
|
* whitespace/case variants of the same rule collapse to one id (idempotent
|
|
37
42
|
* re-capture), instead of minting a duplicate constraint. */
|
package/dist/core/types.js
CHANGED
|
@@ -163,8 +163,25 @@ export const ConstraintSchema = z.object({
|
|
|
163
163
|
valid_to: z.string().nullable().default(null).describe("ISO instant it was retired (null = active)"),
|
|
164
164
|
provenance: ProvenanceSchema,
|
|
165
165
|
});
|
|
166
|
-
/**
|
|
167
|
-
|
|
166
|
+
/** A reusable "how" for a recurring task — trajectory/runbook memory (roadmap #5).
|
|
167
|
+
* ADVISORY retrieval context only; never enters any block path. Distilled from a
|
|
168
|
+
* commit range, surfaced through the same FTS+graph retrieval as every record. */
|
|
169
|
+
export const RunbookSchema = z.object({
|
|
170
|
+
id: z.string().describe("rb_*"),
|
|
171
|
+
task: z.string().describe("the recurring task this answers"),
|
|
172
|
+
trigger: z.array(z.string()).default([]).describe("phrases/intents that should surface it"),
|
|
173
|
+
steps: z.array(z.string()).default([]).describe("ordered procedure"),
|
|
174
|
+
files: z.array(z.string()).default([]).describe("canonical files the task touches (drift-checkable)"),
|
|
175
|
+
gotchas: z.array(z.string()).default([]),
|
|
176
|
+
outcome: z.string().default("").describe("what 'done' looks like"),
|
|
177
|
+
source_range: z.string().nullable().default(null).describe("the commit range it was distilled from"),
|
|
178
|
+
valid_from: z.string().optional(),
|
|
179
|
+
valid_to: z.string().nullable().default(null),
|
|
180
|
+
provenance: ProvenanceSchema,
|
|
181
|
+
date: z.string(),
|
|
182
|
+
});
|
|
183
|
+
/** The entity collections, keyed by their on-disk directory name. */
|
|
184
|
+
export const ENTITY_KINDS = ["components", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks"];
|
|
168
185
|
export const SCHEMAS = {
|
|
169
186
|
components: ComponentSchema,
|
|
170
187
|
edges: EdgeSchema,
|
|
@@ -172,6 +189,7 @@ export const SCHEMAS = {
|
|
|
172
189
|
decisions: DecisionSchema,
|
|
173
190
|
bugs: BugSchema,
|
|
174
191
|
constraints: ConstraintSchema,
|
|
192
|
+
runbooks: RunbookSchema,
|
|
175
193
|
};
|
|
176
194
|
/** Default provenance helper for deterministic (extracted) records. */
|
|
177
195
|
export function extracted(confidence, evidence = []) {
|
package/dist/extractors/git.js
CHANGED
|
@@ -194,6 +194,12 @@ export function rangeFiles(base, cwd, head = "HEAD") {
|
|
|
194
194
|
const out = gitSafe(["diff", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
|
|
195
195
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
196
196
|
}
|
|
197
|
+
/** Commit subjects on `head` since `base` (2-dot: commits added by the task),
|
|
198
|
+
* oldest-first, for distilling a runbook's ordered steps (roadmap #5). */
|
|
199
|
+
export function rangeSubjects(base, cwd, head = "HEAD", max = 50) {
|
|
200
|
+
const out = gitSafe(["log", "--reverse", `-n${max}`, "--format=%s", `${base}..${head}`], cwd);
|
|
201
|
+
return out ? out.split("\n").filter(Boolean) : [];
|
|
202
|
+
}
|
|
197
203
|
/** The PR's unified diff vs `base` (3-dot), for the Regression Guard's structural
|
|
198
204
|
* analysis. Same noise-exclusion + truncation budget as commit/staged diffs. */
|
|
199
205
|
export function rangeDiff(base, cwd, head = "HEAD", maxBytes = 60_000) {
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -179,6 +179,13 @@ export class HunchStore {
|
|
|
179
179
|
fts(c.id, "constraints", c.statement, `${c.rationale} ${c.scope.join(" ")}`);
|
|
180
180
|
}
|
|
181
181
|
counts.constraints = cons.length;
|
|
182
|
+
// Runbooks (roadmap #5): advisory "how" records — no dedicated SQL table, they
|
|
183
|
+
// ride the unified `search` FTS so they retrieve through the same FTS+graph path.
|
|
184
|
+
const runbooks = this.recs("runbooks");
|
|
185
|
+
for (const r of runbooks) {
|
|
186
|
+
fts(r.id, "runbooks", r.task, `${r.trigger.join(" ")} ${r.steps.join(" ")} ${r.gotchas.join(" ")} ${r.outcome} ${r.files.join(" ")}`);
|
|
187
|
+
}
|
|
188
|
+
counts.runbooks = runbooks.length;
|
|
182
189
|
void j;
|
|
183
190
|
});
|
|
184
191
|
tx();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
6
|
"description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",
|