@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 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. */
@@ -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
- /** The six entity collections, keyed by their on-disk directory name. */
167
- export const ENTITY_KINDS = ["components", "edges", "symbols", "decisions", "bugs", "constraints"];
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 = []) {
@@ -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) {
@@ -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.23.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.",