@davesheffer/hunch 1.2.0 β†’ 1.2.3

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
@@ -44,6 +44,8 @@ guards, troubleshooting. This README is the tour. Jump to:
44
44
  [CLI reference](https://hunch-pi.vercel.app/docs#cli) Β·
45
45
  [Troubleshooting](https://hunch-pi.vercel.app/docs#doctor)
46
46
 
47
+ > πŸ†• **v1.2.2** β€” the [component wiki + specs ledger](https://hunch-pi.vercel.app/docs#wiki): Hunch takes over your stale docs β€” graded deterministically, adopted into a graph-healed wiki copy, never rewriting your originals β€” with staleness drift-gated in CI. Plus honest auto-commit reporting, a friendly Node < 22.13 gate, and churn-free reindexing. [Full changelog β†’](https://hunch-pi.vercel.app/changelog)
48
+
47
49
  ## The problem
48
50
 
49
51
  Every AI coding session starts from zero. The model re-reads your code, re-guesses the
@@ -120,6 +122,17 @@ graphs are unaffected until you opt in.
120
122
  one topic**. `hunch reconcile-topics` catches the one case a git merge can create, for human resolution.
121
123
  - **`hunch heal`** + the **`/heal`** slash command do **read-only** doc↔graph reconciliation β€” they show
122
124
  exactly what disagrees and never rewrite your prose silently.
125
+ - **`hunch wiki`** β€” a generated component wiki + **specs ledger** rendered *from* the graph. Every repo
126
+ doc is graded deterministically (βœ… grounded / ⚠ stale / β—» unverified); a stale doc is **adopted** β€” a
127
+ wiki-managed copy re-pinned to the current decision with the correction inline, while your original
128
+ file is never touched (and the copy retires once you heal the original). Freshness is hash-gated:
129
+ `hunch drift` names exactly which pages went stale, `hunch wiki --heal` regenerates only those, and
130
+ `hunch wiki --check` is the CI gate. With a private overlay, `hunch wiki --private` renders the FULL
131
+ graph into the overlay repo β€” nothing private ever lands in the committed wiki.
132
+
133
+ ```bash
134
+ npm run build && bash demo/wiki.sh # watch a spec go stale, get adopted + healed, then released
135
+ ```
123
136
 
124
137
  β†’ [docs](https://hunch-pi.vercel.app/docs#grounding)
125
138
 
package/dist/cli/index.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * mcp start the MCP server (Claude Code connects here)
14
14
  * doctor environment diagnostics
15
15
  */
16
+ import "./preflight.js"; // MUST stay the first import β€” Node-version gate before node:sqlite loads
16
17
  import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync } from "node:fs";
17
18
  import { execFileSync, spawnSync } from "node:child_process";
18
19
  import { join, relative, dirname, basename, resolve, isAbsolute } from "node:path";
@@ -51,6 +52,7 @@ import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpol
51
52
  import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
52
53
  import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
53
54
  import { computeDrift } from "../core/drift.js";
55
+ import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt } from "../wiki/wiki.js";
54
56
  import { topicCollisions, renderGrounding } from "../core/topics.js";
55
57
  import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
56
58
  import { compareCandidates } from "../core/compare.js";
@@ -148,11 +150,17 @@ program
148
150
  else {
149
151
  console.log(" ⚠ not a git repo β€” skipped hooks (run `git init` to enable the learning loop)");
150
152
  }
151
- const mcp = writeMcpJson(root, inv.mcp);
152
153
  // .mcp.json is the CANONICAL registration: Claude Code resolves it by file path,
153
154
  // so it's immune to the Windows ~/.claude.json drive-letter case-split that a
154
- // global `claude mcp add` is prone to (see `hunch doctor`).
155
- console.log(` βœ“ wrote ${rel(root, mcp)} (registers the Hunch MCP server β€” canonical, path-keyed; prefer over a global \`claude mcp add\`)`);
155
+ // global `claude mcp add` is prone to (see `hunch doctor`). An unparseable
156
+ // existing file is refused (con_8460b6770f) β€” warn and continue, never clobber.
157
+ try {
158
+ const mcp = writeMcpJson(root, inv.mcp);
159
+ console.log(` βœ“ wrote ${rel(root, mcp)} (registers the Hunch MCP server β€” canonical, path-keyed; prefer over a global \`claude mcp add\`)`);
160
+ }
161
+ catch (e) {
162
+ console.log(` ⚠ skipped .mcp.json: ${e.message}`);
163
+ }
156
164
  const cmds = writeSlashCommands(root);
157
165
  console.log(` βœ“ wrote ${cmds.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile)`);
158
166
  const cmd = updateClaudeMd(root, store);
@@ -1887,6 +1895,108 @@ program
1887
1895
  store.close();
1888
1896
  }
1889
1897
  });
1898
+ // ---- wiki (generated component wiki β€” a derived VIEW of the graph) ----------
1899
+ program
1900
+ .command("wiki")
1901
+ .description("Generate a component wiki from the graph β€” pages are a derived VIEW (the graph stays the source of truth), pinned with hunch:topic anchors and freshness-hashed into a wiki-manifest. Stale pages surface as wiki-stale in `hunch drift`; --heal regenerates ONLY those. Prose via a subscription CLI when available; deterministic template otherwise. Default: PUBLIC-store records only, written to <repo>/wiki/. With --private: the FULL graph (overlay included), written into the private overlay repo β€” never committed here.")
1902
+ .option("--dir <dir>", "output directory (default: wiki/, or the manifest's dir once adopted)")
1903
+ .option("--heal", "regenerate only new/stale pages (manifest hash mismatch) and remove orphans")
1904
+ .option("--check", "report stale pages and exit non-zero (CI gate); writes nothing")
1905
+ .option("--no-llm", "skip LLM prose; deterministic template pages only")
1906
+ .option("--private", "render the FULL graph (private overlay included) and write the wiki into the OVERLAY repo β€” nothing lands in this repo")
1907
+ .action(async (opts) => {
1908
+ const { store, root } = storeFor();
1909
+ try {
1910
+ store.reindex(); // reflect out-of-band JSON edits before reading the graph
1911
+ // The home pairs source with destination: overlay-inclusive reads may only
1912
+ // ever land in the overlay repo (a committed public page is a leak surface).
1913
+ const home = opts.private ? privateHome(store, opts.dir) : publicHome(root, opts.dir);
1914
+ if (!home)
1915
+ return fail("no private overlay configured β€” run `hunch private` (or `hunch shared`) first.");
1916
+ // A union-fed wiki must never land inside the public work tree: refuse the
1917
+ // degenerate overlay layout where the overlay's parent IS this repo's root.
1918
+ if (home.kind === "private" && resolve(home.pagesRoot) === resolve(root)) {
1919
+ return fail("the private overlay resolves directly under this repo's root β€” a private wiki here would land in the committed tree. Point the overlay at its own directory (e.g. .hunch-private/.hunch) and re-run.");
1920
+ }
1921
+ const manifest = readWikiManifestAt(home.manifestPath);
1922
+ // A wiki lives where its manifest says; a different --dir would strand the
1923
+ // old directory (its pages aren't orphans β€” their components are live).
1924
+ if (manifest && opts.dir && manifest.dir !== opts.dir) {
1925
+ return fail(`wiki already adopted at "${manifest.dir}/" β€” omit --dir (or delete ${manifest.dir}/ and the wiki manifest first, then re-run with --dir ${opts.dir}).`);
1926
+ }
1927
+ // CI-safe: checking a repo that never adopted a wiki is a no-op, not a failure.
1928
+ if (opts.check && !manifest) {
1929
+ console.log("βœ“ No wiki adopted here (no wiki manifest) β€” nothing to check.");
1930
+ return;
1931
+ }
1932
+ const status = wikiStatus(store, home, root);
1933
+ const healHint = `hunch wiki --heal${home.kind === "private" ? " --private" : ""}`;
1934
+ if (opts.check) {
1935
+ const stale = status.entries.filter((e) => e.state !== "fresh");
1936
+ const staleAdoptions = status.adoptions.filter((a) => a.state !== "fresh");
1937
+ const specsStale = status.specs.state !== "fresh";
1938
+ const indexStale = status.index.state !== "fresh";
1939
+ const orphanCount = status.orphans.length + status.adoptionOrphans.length;
1940
+ if (!stale.length && !staleAdoptions.length && !specsStale && !indexStale && !orphanCount) {
1941
+ console.log(`βœ“ Wiki is fresh β€” ${status.entries.length + status.adoptions.length + 2} page(s) match the graph and the doc ledger.`);
1942
+ return;
1943
+ }
1944
+ for (const e of stale)
1945
+ console.log(`Β· ${e.page} β€” ${e.state === "new" ? "no page generated yet" : e.reason}`);
1946
+ for (const a of staleAdoptions)
1947
+ console.log(`Β· ${a.page} β€” ${a.state === "new" ? `stale doc "${a.doc.rel}" awaits adoption` : `adopted copy of "${a.doc.rel}" out of date`}`);
1948
+ if (specsStale)
1949
+ console.log(`Β· ${status.specs.page} β€” the repo's doc freshness snapshot changed`);
1950
+ if (indexStale)
1951
+ console.log(`Β· ${status.index.page} β€” the index's inputs moved`);
1952
+ for (const p of status.adoptionOrphans)
1953
+ console.log(`Β· ${p} β€” original healed or removed; copy retires`);
1954
+ for (const p of status.orphans)
1955
+ console.log(`Β· ${p} β€” no current artifact claims this page`);
1956
+ console.log(`\n${stale.length + staleAdoptions.length + (specsStale ? 1 : 0) + (indexStale ? 1 : 0) + orphanCount} stale page(s) β€” run \`${healHint}\`.`);
1957
+ process.exitCode = 1;
1958
+ return;
1959
+ }
1960
+ // An empty graph still needs --heal reachable for orphan/adoption cleanup β€”
1961
+ // only a plain generate demands components (a broken drift↔heal loop
1962
+ // otherwise: drift says "remove with --heal", --heal refuses to run).
1963
+ if (!status.entries.length && !opts.heal)
1964
+ return fail("no active components in the graph β€” run `hunch index` first.");
1965
+ // Prose is optional garnish on the deterministic skeleton: subscription CLI
1966
+ // only (same rule as synthesis), feature-detected, and any failure degrades
1967
+ // to a template page β€” generation never depends on a model being present.
1968
+ let prose;
1969
+ if (opts.llm !== false) {
1970
+ const provider = await selectProvider();
1971
+ if (provider.draftProse) {
1972
+ console.log(`Prose via ${provider.name} (subscription); the drift-bearing skeleton stays deterministic.`);
1973
+ prose = (pack, excerpts) => provider.draftProse(wikiPrompt(pack, excerpts));
1974
+ }
1975
+ else {
1976
+ console.log("No subscription CLI available β€” deterministic template pages.");
1977
+ }
1978
+ }
1979
+ const res = await generateWiki(store, root, home, {
1980
+ now: new Date().toISOString(),
1981
+ only: opts.heal ? "stale" : "all",
1982
+ prose,
1983
+ log: (l) => console.log(l),
1984
+ });
1985
+ if (!res.written.length && !res.removed.length) {
1986
+ console.log(`βœ“ Nothing to regenerate β€” ${res.unchanged} page(s) already fresh.`);
1987
+ return;
1988
+ }
1989
+ const dest = home.kind === "private" ? `${join(home.pagesRoot, home.dir)} (private overlay repo β€” NOT committed here)` : `${home.dir}/`;
1990
+ console.log(`\nβœ“ ${res.written.length} page(s) written${res.removed.length ? `, ${res.removed.length} orphan(s) removed` : ""}${res.unchanged ? `, ${res.unchanged} fresh page(s) untouched` : ""} β†’ ${dest}`);
1991
+ // Committed grounding docs advertise the PUBLIC wiki only β€” they must not
1992
+ // reveal that (or what) a private overlay wiki exists.
1993
+ if (home.kind === "public")
1994
+ refreshExistingGrounding(root, store);
1995
+ }
1996
+ finally {
1997
+ store.close();
1998
+ }
1999
+ });
1890
2000
  // ---- heal (decision-grounded drift reconciliation front door) -------------
1891
2001
  program
1892
2002
  .command("heal")
@@ -1938,6 +2048,13 @@ program
1938
2048
  console.log(`Β· ${f.id} β€” ${f.detail}`);
1939
2049
  console.log(`\nHeal: update the doc's status marker to match reality.\n`);
1940
2050
  }
2051
+ const wikiStale = kind("wiki-stale");
2052
+ if (wikiStale.length) {
2053
+ console.log(`${wikiStale.length} generated wiki page(s) drifted from the graph:\n`);
2054
+ for (const f of wikiStale)
2055
+ console.log(`Β· ${f.id} β€” ${f.detail}`);
2056
+ console.log(`\nHeal: run \`hunch wiki --heal\` β€” regenerates only the stale pages (the wiki is a derived view; never edit it by hand).\n`);
2057
+ }
1941
2058
  console.log(`Hunch never rewrites prose for you; this is a read-only reconciliation report.`);
1942
2059
  }
1943
2060
  finally {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Node-version gate β€” MUST be the first import of the CLI entry, and must itself
3
+ * import nothing: the store loads `node:sqlite` eagerly (db.ts), so on Node < 22.13
4
+ * the import graph dies with a raw ERR_UNKNOWN_BUILTIN_MODULE before Commander can
5
+ * print anything helpful. ESM evaluates this module (no deps) before the rest of
6
+ * the graph, so the check runs first and the user gets an actionable message.
7
+ */
8
+ const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
9
+ if (major < 22 || (major === 22 && minor < 13)) {
10
+ // The agent edit-hook must never block an edit on failure (con_03a0b94b2e):
11
+ // emit nothing and exit 0 β€” the edit proceeds ungrounded rather than blocked.
12
+ if (process.argv[2] === "hook")
13
+ process.exit(0);
14
+ process.stderr.write(`hunch: Node ${process.versions.node} is too old β€” hunch needs Node >= 22.13 (its index uses the built-in node:sqlite).\n` +
15
+ "Upgrade Node (e.g. `nvm install 24 && nvm use 24`) and re-run.\n");
16
+ process.exit(1);
17
+ }
18
+ export {};
19
+ //# sourceMappingURL=preflight.js.map
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Repo-doc scanner — the "specs" half of the doc≠graph spoke. Walks the repo's
3
+ * own markdown (same bounded walk `hunch drift` uses) and grades every doc with
4
+ * a DETERMINISTIC freshness status from signals that already exist:
5
+ *
6
+ * - grounded β€” carries `<!-- hunch:topic … -->` anchors and every pin points
7
+ * at the CURRENT decision for its topic: safe to trust.
8
+ * - stale β€” a pin points at a superseded/missing decision, the doc still
9
+ * says "proposed / not yet implemented" while referencing shipped
10
+ * code, or it references src files that no longer exist.
11
+ * - unverified β€” no anchors and no stale signal: Hunch can't vouch either way
12
+ * (the honest tier; ground it by adding a topic marker).
13
+ *
14
+ * No semantic guessing and no LLM β€” same philosophy as drift.ts. The wiki uses
15
+ * these statuses to become the trusted READING surface over the repo's docs
16
+ * (route to grounded prose, warn on stale) without ever rewriting prose.
17
+ */
18
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
19
+ import { join, extname } from "node:path";
20
+ import { parseDocAnchors } from "./docanchors.js";
21
+ import { currentForTopic } from "./topics.js";
22
+ export const STALE_MARKER = /\b(proposed|not yet implemented|no code yet)\b/i;
23
+ export const SRC_REF = /\bsrc\/[A-Za-z0-9_\-/]+\.ts\b/g;
24
+ const SKIP_DIRS = new Set(["node_modules", ".git", ".hunch", ".hunch-private", "dist", "vscode-extension", "site"]);
25
+ /** Bounded walk for repo markdown (root + docs/, depth-limited; heavy/irrelevant trees skipped). */
26
+ export function markdownDocs(root) {
27
+ const out = [];
28
+ const walk = (dir, rel, depth) => {
29
+ if (depth > 4)
30
+ return;
31
+ let entries;
32
+ try {
33
+ entries = readdirSync(dir, { withFileTypes: true });
34
+ }
35
+ catch {
36
+ return;
37
+ }
38
+ for (const e of entries) {
39
+ if (e.isDirectory()) {
40
+ if (e.name.startsWith(".") || SKIP_DIRS.has(e.name))
41
+ continue;
42
+ walk(join(dir, e.name), rel ? `${rel}/${e.name}` : e.name, depth + 1);
43
+ }
44
+ else if (extname(e.name) === ".md") {
45
+ out.push({ path: join(dir, e.name), rel: rel ? `${rel}/${e.name}` : e.name });
46
+ }
47
+ }
48
+ };
49
+ walk(root, "", 0);
50
+ return out;
51
+ }
52
+ /** Grade every repo doc. GENERATED wiki pages (hunch:wiki header) are views of
53
+ * the graph, not specs β€” they are excluded here and freshness-gated by their
54
+ * own manifest hash instead. */
55
+ export function scanRepoDocs(decisions, root) {
56
+ const byId = new Map(decisions.map((d) => [d.id, d]));
57
+ const out = [];
58
+ for (const doc of markdownDocs(root)) {
59
+ let text;
60
+ try {
61
+ text = readFileSync(doc.path, "utf8");
62
+ }
63
+ catch {
64
+ continue;
65
+ }
66
+ if (text.startsWith("<!-- hunch:wiki "))
67
+ continue; // generated view, not a spec
68
+ const anchors = parseDocAnchors(text);
69
+ const topics = [...new Set(anchors.map((a) => a.topic))];
70
+ const srcRefs = [...new Set(text.match(SRC_REF) ?? [])];
71
+ const issues = [];
72
+ let groundedPins = 0;
73
+ for (const a of anchors) {
74
+ if (!a.pin)
75
+ continue;
76
+ const pinned = byId.get(a.pin);
77
+ const current = currentForTopic(decisions, a.topic);
78
+ const superseded = !!pinned && (pinned.status === "superseded" || !!pinned.superseded_by);
79
+ if (!pinned) {
80
+ issues.push(`line ${a.line}: pinned to ${a.pin} (topic "${a.topic}"), which does not exist`);
81
+ }
82
+ else if (superseded && current && current.id !== a.pin) {
83
+ issues.push(`line ${a.line}: pinned to superseded ${a.pin}; current for "${a.topic}" is ${current.id}`);
84
+ }
85
+ else if (!superseded) {
86
+ groundedPins++;
87
+ }
88
+ // superseded with NO visible successor: this store cannot vouch either way
89
+ // (the successor may live in a private overlay) β†’ neither grounds nor stales,
90
+ // mirroring drift's explicit-pin-plus-live-successor rule.
91
+ }
92
+ if (STALE_MARKER.test(text.slice(0, 1500)) && srcRefs.some((r) => existsSync(join(root, r)))) {
93
+ issues.push("marked proposed/not-implemented but references shipped code");
94
+ }
95
+ const missing = srcRefs.filter((r) => !existsSync(join(root, r)));
96
+ if (missing.length && missing.length === srcRefs.length) {
97
+ // every code reference is gone β€” the doc describes code that no longer exists
98
+ issues.push(`references only missing files (${missing.slice(0, 3).join(", ")}${missing.length > 3 ? ", …" : ""})`);
99
+ }
100
+ const status = issues.length
101
+ ? "stale"
102
+ : anchors.length && (groundedPins > 0 || anchors.some((a) => !a.pin && currentForTopic(decisions, a.topic)))
103
+ ? "grounded"
104
+ : "unverified";
105
+ const title = /^#\s+(.+)$/m.exec(text)?.[1]?.trim() ?? doc.rel;
106
+ out.push({ rel: doc.rel, title, topics, srcRefs, status, issues });
107
+ }
108
+ return out.sort((a, b) => a.rel.localeCompare(b.rel));
109
+ }
110
+ //# sourceMappingURL=docscan.js.map
@@ -7,14 +7,18 @@
7
7
  * - dead-ref: an in-force decision points at a file that no longer exists.
8
8
  * - supersede: A claims to supersede B, but B was never properly closed.
9
9
  * - doc-stale: a doc marked "proposed / not yet implemented" references shipped code.
10
+ * - wiki-stale: a generated wiki page's graph inputs changed since generation
11
+ * (hash-compared via .hunch/wiki-manifest.json; only when a wiki
12
+ * was adopted β€” see src/wiki/wiki.ts). Advisory, healed by
13
+ * `hunch wiki --heal`, never a gate.
10
14
  */
11
- import { existsSync, readFileSync, readdirSync } from "node:fs";
12
- import { join, extname } from "node:path";
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { join } from "node:path";
13
17
  import { toPosixTarget } from "./paths.js";
14
18
  import { currentForTopic, isLive } from "./topics.js";
15
19
  import { parseDocAnchors } from "./docanchors.js";
16
- const STALE_MARKER = /\b(proposed|not yet implemented|no code yet)\b/i;
17
- const SRC_REF = /\bsrc\/[A-Za-z0-9_\-/]+\.ts\b/g;
20
+ import { markdownDocs, STALE_MARKER, SRC_REF } from "./docscan.js";
21
+ import { computeWikiDrift } from "../wiki/wiki.js";
18
22
  export function computeDrift(store, root) {
19
23
  const findings = [];
20
24
  const decisions = store.recs("decisions");
@@ -78,8 +82,10 @@ export function computeDrift(store, root) {
78
82
  const text = safeRead(doc.path);
79
83
  // 3. DOC-STALE β€” a doc that still advertises "proposed / not implemented" while
80
84
  // referencing code that exists. Heuristic + advisory; scoped to the repo's own
81
- // markdown (node_modules and sub-projects skipped).
82
- if (STALE_MARKER.test(text.slice(0, 1500))) {
85
+ // markdown (node_modules and sub-projects skipped). GENERATED wiki pages are
86
+ // exempt: they quote decision prose verbatim (which may legitimately contain
87
+ // "proposed"), and their staleness is already hash-gated (wiki-stale).
88
+ if (!text.startsWith("<!-- hunch:wiki ") && STALE_MARKER.test(text.slice(0, 1500))) {
83
89
  const existing = (text.match(SRC_REF) ?? []).find((r) => existsSync(join(root, r)));
84
90
  if (existing) {
85
91
  findings.push({ kind: "doc-stale", id: doc.rel, detail: `marked proposed/not-implemented but references shipped code (${existing})` });
@@ -108,6 +114,10 @@ export function computeDrift(store, root) {
108
114
  }
109
115
  }
110
116
  }
117
+ // 6. WIKI-STALE β€” generated wiki pages whose graph inputs drifted (or whose
118
+ // component vanished). Deterministic hash comparison against the manifest;
119
+ // fires only when a wiki was adopted. Advisory like every other kind here.
120
+ findings.push(...computeWikiDrift(store, root));
111
121
  return { findings };
112
122
  }
113
123
  function safeRead(path) {
@@ -118,32 +128,4 @@ function safeRead(path) {
118
128
  return "";
119
129
  }
120
130
  }
121
- const SKIP_DIRS = new Set(["node_modules", ".git", ".hunch", ".hunch-private", "dist", "vscode-extension", "site"]);
122
- /** Bounded walk for repo markdown (root + docs/, depth-limited; heavy/irrelevant trees skipped). */
123
- function markdownDocs(root) {
124
- const out = [];
125
- const walk = (dir, rel, depth) => {
126
- if (depth > 4)
127
- return;
128
- let entries;
129
- try {
130
- entries = readdirSync(dir, { withFileTypes: true });
131
- }
132
- catch {
133
- return;
134
- }
135
- for (const e of entries) {
136
- if (e.isDirectory()) {
137
- if (e.name.startsWith(".") || SKIP_DIRS.has(e.name))
138
- continue;
139
- walk(join(dir, e.name), rel ? `${rel}/${e.name}` : e.name, depth + 1);
140
- }
141
- else if (extname(e.name) === ".md") {
142
- out.push({ path: join(dir, e.name), rel: rel ? `${rel}/${e.name}` : e.name });
143
- }
144
- }
145
- };
146
- walk(root, "", 0);
147
- return out;
148
- }
149
131
  //# sourceMappingURL=drift.js.map
@@ -41,7 +41,11 @@ export function mainWorktreeRoot(root) {
41
41
  * `push: false` commits WITHOUT merging or pushing β€” required when hunchDir is the PUBLIC
42
42
  * .hunch/ inside the user's code repo: an automatic pull/push there would merge the remote
43
43
  * into their working branch and publish their unpushed code commits. The memory commit
44
- * simply rides the user's next push. */
44
+ * simply rides the user's next push.
45
+ * Returns what ACTUALLY happened, so callers never report a commit that was skipped:
46
+ * "pushed" (commit created and pushed), "committed" (commit created; push not requested,
47
+ * or the merge/push failed β€” retry rides the next flush), null (nothing committed: lock
48
+ * held, backstop refusal, nothing staged, or not a repo). */
45
49
  export function commitAndPushHunch(hunchDir, message, opts = {}) {
46
50
  // Serialize across worktrees: several worktrees auto-committing the SAME overlay repo
47
51
  // at once would race git's index.lock. An atomic-mkdir lock lets one proceed; the others
@@ -49,7 +53,7 @@ export function commitAndPushHunch(hunchDir, message, opts = {}) {
49
53
  // up anything a skipped run left pending (eventually-consistent, never lost).
50
54
  const lock = join(hunchDir, ".hunch-commit.lock");
51
55
  if (!acquireCommitLock(lock))
52
- return;
56
+ return null;
53
57
  try {
54
58
  const env = { ...process.env, HUNCH_SYNC: "1" };
55
59
  const run = (args) => {
@@ -76,7 +80,7 @@ export function commitAndPushHunch(hunchDir, message, opts = {}) {
76
80
  if (opts.push !== false) {
77
81
  console.error(`hunch: refusing to auto-commit memory at "${hunchDir}" β€” the staged change includes deletions or non-memory files, so this is not a clean overlay repo. Nothing was committed or pushed. (Use \`hunch shared --repo <url>\` so the overlay is its OWN git repo.)`);
78
82
  }
79
- return;
83
+ return null;
80
84
  }
81
85
  // Only sync+push when a memory commit was actually created β€” never run pull/push against the
82
86
  // enclosing repo on an empty stage. Two-way sync: MERGE the remote BEFORE pushing so a push
@@ -88,8 +92,18 @@ export function commitAndPushHunch(hunchDir, message, opts = {}) {
88
92
  committed = true;
89
93
  }
90
94
  catch { /* nothing staged / not a repo */ }
91
- if (committed && opts.push !== false && mergeRemote(hunchDir, env))
92
- run(["push"]);
95
+ if (!committed)
96
+ return null;
97
+ if (opts.push !== false && mergeRemote(hunchDir, env)) {
98
+ // Push tracked (not via run): a no-upstream/offline/rejected push must report
99
+ // "committed", not overclaim "pushed" β€” the next flush's merge+push retries.
100
+ try {
101
+ execFileSync("git", ["-C", hunchDir, "push"], { stdio: "ignore", env });
102
+ return "pushed";
103
+ }
104
+ catch { /* offline / no upstream */ }
105
+ }
106
+ return "committed";
93
107
  }
94
108
  finally {
95
109
  try {
@@ -162,14 +162,40 @@ export function indexRepo(store, root, opts = {}) {
162
162
  // persist
163
163
  store.json.replaceAll("symbols", symbols);
164
164
  store.json.replaceAll("edges", edges);
165
- const compsOut = components.map(({ _files, ...c }) => c);
165
+ // Components are derived-but-ENRICHED records: layout facts (paths, kind, name)
166
+ // come from this scan, while curation/synthesis (responsibility, owners, status,
167
+ // fragility from raiseFragility, upgraded provenance) lives only on the stored
168
+ // record and must survive a reindex. Timestamps are preserved so an unchanged
169
+ // component is byte-identical β€” reindexing must not churn git.
170
+ const prior = new Map(store.json.loadAll("components").map((c) => [c.id, c]));
171
+ const stamp = (c) => JSON.stringify({ ...c, created_at: "", updated_at: "" });
172
+ const compsOut = components.map(({ _files, ...draft }) => {
173
+ const prev = prior.get(draft.id);
174
+ if (!prev)
175
+ return draft;
176
+ const merged = {
177
+ ...draft,
178
+ responsibility: prev.responsibility || draft.responsibility,
179
+ owners: prev.owners.length ? prev.owners : draft.owners,
180
+ status: prev.status,
181
+ fragility: Math.max(prev.fragility, draft.fragility),
182
+ provenance: prev.provenance.source !== "inferred" ? prev.provenance : draft.provenance,
183
+ created_at: prev.created_at,
184
+ updated_at: prev.updated_at,
185
+ };
186
+ return stamp(merged) === stamp(prev) ? prev : { ...merged, updated_at: draft.updated_at };
187
+ });
166
188
  store.json.replaceAll("components", compsOut);
167
189
  return { files: files.length, symbols: symbols.length, edges: edges.length, components: compsOut.length, skipped };
168
190
  }
169
191
  // ---- helpers --------------------------------------------------------------
170
192
  function listCodeFiles(root) {
171
193
  if (isGitRepo(root)) {
172
- const tracked = trackedFiles(root, CODE_EXTS).map((f) => join(root, f));
194
+ // Apply SKIP_DIRS to the git-tracked list too: a repo that (accidentally)
195
+ // tracks node_modules/ or dist/ must not flood the graph with vendored symbols.
196
+ const tracked = trackedFiles(root, CODE_EXTS)
197
+ .filter((f) => !f.split(/[\\/]/).some((seg) => SKIP_DIRS.has(seg)))
198
+ .map((f) => join(root, f));
173
199
  if (tracked.length > 0)
174
200
  return tracked; // else fall through (nothing committed yet)
175
201
  }
@@ -5,9 +5,10 @@
5
5
  */
6
6
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7
7
  import { join, dirname } from "node:path";
8
+ import { wikiSummary } from "../wiki/wiki.js";
8
9
  const START = "<!-- HUNCH:START β€” auto-generated, do not edit by hand -->";
9
10
  const END = "<!-- HUNCH:END -->";
10
- export function renderHunchSection(store) {
11
+ export function renderHunchSection(store, root) {
11
12
  const constraints = store.json
12
13
  .loadAll("constraints")
13
14
  .sort((a, b) => sev(b.severity) - sev(a.severity))
@@ -36,6 +37,11 @@ export function renderHunchSection(store) {
36
37
  lines.push("- `hunch_compare(candidates)` β€” rank N candidate branches/commits by architectural fit (fewest invariant hits).");
37
38
  lines.push("- `hunch_conformance()` β€” does the code still SATISFY recorded intent? (e.g. `pay` still reaches `verifySession`). Run before a refactor.");
38
39
  lines.push("- `hunch_record_decision(...)` β€” write back a decision after a non-trivial choice.");
40
+ const wiki = root ? wikiSummary(root) : null;
41
+ if (wiki) {
42
+ lines.push("");
43
+ lines.push(`πŸ“– Component wiki: \`${wiki.dir}/\` (${wiki.pages} page(s)) β€” a GENERATED view of this graph; the graph stays the source of truth. Stale pages surface in \`hunch drift\`; regenerate with \`hunch wiki --heal\`.`);
44
+ }
39
45
  if (constraints.length) {
40
46
  lines.push("");
41
47
  lines.push("### β›” Top invariants (do not break)");
@@ -75,7 +81,7 @@ export function upsertSection(file, section, fallbackTitle) {
75
81
  }
76
82
  /** Insert/replace the HUNCH section in CLAUDE.md, preserving everything else. */
77
83
  export function updateClaudeMd(root, store) {
78
- return upsertSection(join(root, "CLAUDE.md"), renderHunchSection(store), `# ${root.split("/").pop()}`);
84
+ return upsertSection(join(root, "CLAUDE.md"), renderHunchSection(store, root), `# ${root.split("/").pop()}`);
79
85
  }
80
86
  function sev(s) {
81
87
  return { blocking: 3, warning: 2, advisory: 1 }[s] ?? 0;
@@ -224,17 +224,17 @@ export function writeCodexConfig(root, inv) {
224
224
  /** AGENTS.md β€” the cross-tool ambient-instruction standard (Codex and a growing
225
225
  * set of assistants read it). Marker-delimited so user prose is preserved. */
226
226
  export function writeAgentsMd(root, store) {
227
- return upsertSection(join(root, "AGENTS.md"), renderHunchSection(store), "# AGENTS.md");
227
+ return upsertSection(join(root, "AGENTS.md"), renderHunchSection(store, root), "# AGENTS.md");
228
228
  }
229
229
  /** GitHub Copilot custom instructions (VS Code / github.com). Same grounding. */
230
230
  export function writeCopilotInstructions(root, store) {
231
- return upsertSection(join(root, ".github", "copilot-instructions.md"), renderHunchSection(store), "# Copilot instructions");
231
+ return upsertSection(join(root, ".github", "copilot-instructions.md"), renderHunchSection(store, root), "# Copilot instructions");
232
232
  }
233
233
  /** Cursor project rule (.mdc = frontmatter + body). `alwaysApply` keeps the Hunch
234
234
  * grounding in context for every request. Fully managed by Hunch (overwritten). */
235
235
  export function writeCursorRule(root, store) {
236
236
  const file = join(root, ".cursor", "rules", "hunch.mdc");
237
- const body = `---\ndescription: Hunch engineering memory β€” consult the hunch_* MCP tools before editing\nalwaysApply: true\n---\n\n${renderHunchSection(store)}\n`;
237
+ const body = `---\ndescription: Hunch engineering memory β€” consult the hunch_* MCP tools before editing\nalwaysApply: true\n---\n\n${renderHunchSection(store, root)}\n`;
238
238
  mkdirSync(dirname(file), { recursive: true });
239
239
  writeFileSync(file, body);
240
240
  return file;
@@ -253,7 +253,7 @@ export function writeWindsurfMcp(root, inv) {
253
253
  * Hunch grounding in Cascade's context for every request. Fully managed (overwritten). */
254
254
  export function writeWindsurfRule(root, store) {
255
255
  const file = join(root, ".windsurf", "rules", "hunch.md");
256
- const body = `---\ntrigger: always_on\ndescription: Hunch engineering memory β€” consult the hunch_* MCP tools before editing\n---\n\n${renderHunchSection(store)}\n`;
256
+ const body = `---\ntrigger: always_on\ndescription: Hunch engineering memory β€” consult the hunch_* MCP tools before editing\n---\n\n${renderHunchSection(store, root)}\n`;
257
257
  mkdirSync(dirname(file), { recursive: true });
258
258
  writeFileSync(file, body);
259
259
  return file;
@@ -5,16 +5,25 @@
5
5
  */
6
6
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7
7
  import { join, dirname } from "node:path";
8
- /** Merge a `hunch` server entry into .mcp.json, preserving other servers. */
8
+ /** Merge a `hunch` server entry into .mcp.json, preserving other servers.
9
+ * A non-empty file we cannot parse THROWS instead of being silently replaced
10
+ * (con_8460b6770f β€” it may hold the user's other MCP servers); the caller
11
+ * degrades that to a warning. */
9
12
  export function writeMcpJson(root, inv) {
10
13
  const file = join(root, ".mcp.json");
11
14
  let json = {};
12
15
  if (existsSync(file)) {
13
- try {
14
- json = JSON.parse(readFileSync(file, "utf8"));
15
- }
16
- catch {
17
- json = {};
16
+ const raw = readFileSync(file, "utf8");
17
+ if (raw.trim()) {
18
+ try {
19
+ const v = JSON.parse(raw);
20
+ if (!v || typeof v !== "object" || Array.isArray(v))
21
+ throw new Error("not a JSON object");
22
+ json = v;
23
+ }
24
+ catch (e) {
25
+ throw new Error(`refusing to overwrite ${file}: could not parse it (${e.message}). Fix or remove it, then re-run.`);
26
+ }
18
27
  }
19
28
  }
20
29
  json.mcpServers = json.mcpServers ?? {};
@@ -5,22 +5,22 @@ export function flushPrivate(store, message) {
5
5
  if (store.privateAutoCommit && store.privateDir)
6
6
  commitAndPushHunch(store.privateDir, message);
7
7
  }
8
- /** Auto-commit the store a capture landed in. Returns what happened so callers can report it:
9
- * "pushed" (private overlay, committed + pushed), "committed" (public .hunch/, commit only β€”
10
- * rides the next push), or null (auto-commit off / no overlay for a private record). */
8
+ /** Auto-commit the store a capture landed in. Returns what ACTUALLY happened so callers
9
+ * never report a commit that was skipped: "pushed" (overlay committed + pushed),
10
+ * "committed" (commit created but not pushed β€” public .hunch/ rides the next push; an
11
+ * overlay commit whose merge/push failed retries on the next flush), or null (auto-commit
12
+ * off, no overlay for a private record, or the commit was skipped β€” lock held, safety
13
+ * backstop, nothing staged; the record stays on disk and the next flush sweeps it up). */
11
14
  export function flushCapture(store, publicHunchDir, isPrivate, message) {
12
15
  // Follow the same routing as HunchStore.captureHome: unified ("shared") mode homes
13
16
  // EVERY capture in the overlay, so the flush must go there too β€” one source of truth.
14
17
  if (store.captureHome(isPrivate) === "private") {
15
- if (store.privateAutoCommit && store.privateDir) {
16
- commitAndPushHunch(store.privateDir, message);
17
- return "pushed";
18
- }
18
+ if (store.privateAutoCommit && store.privateDir)
19
+ return commitAndPushHunch(store.privateDir, message);
19
20
  return null;
20
21
  }
21
22
  if (!store.autoCommit)
22
23
  return null;
23
- commitAndPushHunch(publicHunchDir, message, { push: false });
24
- return "committed";
24
+ return commitAndPushHunch(publicHunchDir, message, { push: false });
25
25
  }
26
26
  //# sourceMappingURL=sync.js.map