@davesheffer/hunch 0.23.0 → 0.24.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/dist/cli/index.js CHANGED
@@ -43,6 +43,7 @@ import { formatContext } from "../core/format.js";
43
43
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
44
44
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
45
45
  import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
46
+ import { computeDrift } from "../core/drift.js";
46
47
  import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
47
48
  import { constraintId } from "../core/ids.js";
48
49
  import { readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
@@ -1273,6 +1274,20 @@ program
1273
1274
  }
1274
1275
  // Windows: detect/heal the Claude Code ~/.claude.json drive-letter case-split
1275
1276
  // that silently hides the hunch_* MCP tools. No-op (silent) off Windows.
1277
+ // Memory drift: deterministic, advisory smoke detector for memory that has
1278
+ // fallen out of sync with the code/docs (dead file refs, dangling supersedes,
1279
+ // docs still marked "proposed"). Never blocks; never auto-fixes.
1280
+ const drift = computeDrift(store, root);
1281
+ if (drift.findings.length) {
1282
+ console.log(`drift: ⚠ ${drift.findings.length} finding(s) — memory may be out of sync with the code:`);
1283
+ for (const f of drift.findings.slice(0, 20))
1284
+ console.log(` · [${f.kind}] ${f.id} — ${f.detail}`);
1285
+ if (drift.findings.length > 20)
1286
+ console.log(dim(` … and ${drift.findings.length - 20} more`));
1287
+ }
1288
+ else {
1289
+ console.log(`drift: ✓ no stale refs, dangling supersedes, or stale "proposed" docs`);
1290
+ }
1276
1291
  reportClaudeConfigHeal();
1277
1292
  store.close();
1278
1293
  });
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.23.0",
3
+ "version": "0.24.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.",