@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.
@@ -0,0 +1,605 @@
1
+ /**
2
+ * Wiki — a generated component wiki that is a derived VIEW of the graph, never a
3
+ * second source of truth (same rule as the SQLite index, con_a87360128b). Pages
4
+ * are rebuilt from graph records; freshness is DETERMINISTIC, not scheduled: each
5
+ * page's graph inputs are content-hashed into a wiki-manifest.json, and a hash
6
+ * mismatch is a `wiki-stale` drift finding (`hunch drift` / `hunch heal`),
7
+ * healed by `hunch wiki --heal` which regenerates ONLY the stale pages.
8
+ *
9
+ * Grounding: every decision a page cites is pinned with the existing
10
+ * `<!-- hunch:topic <topic> <dec_id> -->` marker (docanchors.ts), so a later
11
+ * supersession fires the established doc-anchor-stale drift with zero new
12
+ * machinery — and the pre-edit hook grounds edits to wiki pages for free.
13
+ *
14
+ * Two HOMES, mirroring where memory itself lives (dec_9c4289a4bb / d7bad4ccb7):
15
+ * - public — pages under <repo>/wiki/, rendered from the PUBLIC store ONLY
16
+ * (`store.json`, never the overlay): committed pages are a
17
+ * publicly-posted leak surface.
18
+ * - private — `hunch wiki --private`: pages under the OVERLAY repo's root
19
+ * (sibling of its .hunch/), rendered from the FULL union
20
+ * (`store.recs`, overlay included). Nothing lands in the public
21
+ * repo; the manifest lives inside the overlay's .hunch/.
22
+ *
23
+ * The prose "Overview" section is optional LLM output (subscription CLI via
24
+ * SynthProvider.draftProse — never a pay-per-token API); everything drift-bearing
25
+ * (anchors, invariants, structure) is rendered deterministically around it, so a
26
+ * missing/failed CLI degrades to a complete template page, and the input hash
27
+ * covers graph inputs only — LLM nondeterminism can never fake staleness.
28
+ */
29
+ import { createHash } from "node:crypto";
30
+ import { existsSync, readFileSync, mkdirSync, rmSync } from "node:fs";
31
+ import { join, dirname } from "node:path";
32
+ import { writeFileAtomic } from "../core/io.js";
33
+ import { hunchPaths, toPosixTarget } from "../core/paths.js";
34
+ import { isLive } from "../core/topics.js";
35
+ import { scanRepoDocs } from "../core/docscan.js";
36
+ import { adoptedSlug, adoptionHash, renderAdoptedDoc } from "./adopt.js";
37
+ /** Normalize a --dir override: POSIX separators, no trailing slash — the dir is
38
+ * a committed manifest key prefix, so it must hash identically on every OS. */
39
+ const normDir = (d) => d ? toPosixTarget(d).replace(/\/+$/, "") || undefined : undefined;
40
+ export function publicHome(root, dirOverride) {
41
+ const manifestPath = join(hunchPaths(root).hunch, "wiki-manifest.json");
42
+ return {
43
+ kind: "public",
44
+ pagesRoot: root,
45
+ dir: normDir(dirOverride) ?? readWikiManifestAt(manifestPath)?.dir ?? "wiki",
46
+ manifestPath,
47
+ source: "public",
48
+ };
49
+ }
50
+ /** The private overlay's wiki home, or null when no overlay is configured.
51
+ * `store.privateDir` is the overlay's .hunch dir; pages go to the overlay repo
52
+ * root beside it (never inside .hunch/, which must stay memory-JSON-only for
53
+ * the overlay auto-commit guard), and the manifest rides the overlay store. */
54
+ export function privateHome(store, dirOverride) {
55
+ if (!store.privateDir)
56
+ return null;
57
+ const manifestPath = join(store.privateDir, "wiki-manifest.json");
58
+ return {
59
+ kind: "private",
60
+ pagesRoot: dirname(store.privateDir),
61
+ dir: normDir(dirOverride) ?? readWikiManifestAt(manifestPath)?.dir ?? "wiki",
62
+ manifestPath,
63
+ source: "all",
64
+ };
65
+ }
66
+ /** "src/store/**" → "src/store/"; "src/core/io.ts" → "src/core/io.ts". */
67
+ function globPrefix(glob) {
68
+ const posix = toPosixTarget(glob);
69
+ const i = posix.search(/[*?[]/);
70
+ return i < 0 ? posix : posix.slice(0, i);
71
+ }
72
+ function owns(prefixes, file) {
73
+ const f = toPosixTarget(file);
74
+ return prefixes.some((p) => p !== "" && (f === p || f === p.replace(/\/$/, "") || f.startsWith(p.endsWith("/") ? p : `${p}/`)));
75
+ }
76
+ const clip = (s, n) => (s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s);
77
+ const SEV = { blocking: 3, warning: 2, advisory: 1, critical: 4, high: 3, medium: 2, low: 1 };
78
+ /** Assemble one component's pack — pure graph queries, no LLM. `source` decides
79
+ * the leak boundary: "public" reads the committed store only; "all" unions the
80
+ * private overlay and must only ever feed a PRIVATE home's pages. `repoDocs`
81
+ * (pre-scanned specs) associate by the src files they mention. */
82
+ export function assemblePack(store, component, source = "public", repoDocs = [], adoptedPageByRel = new Map()) {
83
+ const read = (kind) => (source === "all" ? store.recs(kind) : store.json.loadAll(kind));
84
+ const prefixes = component.paths.map(globPrefix);
85
+ const symbols = read("symbols")
86
+ .filter((s) => owns(prefixes, s.file))
87
+ .sort((a, b) => b.metrics.fan_in - a.metrics.fan_in || b.metrics.loc - a.metrics.loc || a.name.localeCompare(b.name));
88
+ const files = [...new Set(symbols.map((s) => toPosixTarget(s.file)))].sort();
89
+ const decisions = read("decisions")
90
+ .filter(isLive)
91
+ .filter((d) => d.related_components.includes(component.id) || (d.related_files ?? []).some((f) => owns(prefixes, f)))
92
+ .sort((a, b) => (b.valid_from ?? b.date).localeCompare(a.valid_from ?? a.date) || a.id.localeCompare(b.id))
93
+ .slice(0, 8)
94
+ .map((d) => ({
95
+ id: d.id, topic: d.topic, title: d.title, decision: clip(d.decision, 500), context: clip(d.context, 300),
96
+ consequences: d.consequences.slice(0, 4).map((c) => clip(c, 200)),
97
+ alternatives_rejected: d.alternatives_rejected.slice(0, 4).map((a) => clip(a, 200)),
98
+ }));
99
+ // Scoped constraints only: a repo-wide constraint (scope []) belongs on the index
100
+ // page, not repeated on every component page.
101
+ const constraints = read("constraints")
102
+ .filter((c) => c.status !== "retired")
103
+ .filter((c) => c.scope.some((g) => { const p = globPrefix(g); return p !== "" && prefixes.some((q) => q !== "" && (p.startsWith(q) || q.startsWith(p))); }))
104
+ .sort((a, b) => (SEV[b.severity] ?? 0) - (SEV[a.severity] ?? 0) || a.id.localeCompare(b.id))
105
+ .slice(0, 8)
106
+ .map((c) => ({ id: c.id, severity: c.severity, statement: clip(c.statement, 300), rationale: clip(c.rationale, 200) }));
107
+ const symbolNames = new Set(symbols.map((s) => s.name));
108
+ const bugs = read("bugs")
109
+ .filter((b) => b.affected_files.some((f) => owns(prefixes, f)) || b.affected_symbols.some((s) => symbolNames.has(s)))
110
+ .sort((a, b) => (SEV[b.severity] ?? 0) - (SEV[a.severity] ?? 0) || a.id.localeCompare(b.id))
111
+ .slice(0, 6)
112
+ .map((b) => ({ id: b.id, title: b.title, root_cause: clip(b.root_cause, 250), severity: b.severity, status: b.status }));
113
+ const componentsById = new Map(read("components").map((c) => [c.id, c]));
114
+ const dependsOn = new Map();
115
+ const usedBy = new Map();
116
+ for (const e of read("edges")) {
117
+ if (e.type === "supersedes" || e.type === "related_to")
118
+ continue;
119
+ if (e.from === component.id && componentsById.has(e.to) && e.to !== component.id)
120
+ dependsOn.set(e.to, componentsById.get(e.to).name);
121
+ if (e.to === component.id && componentsById.has(e.from) && e.from !== component.id)
122
+ usedBy.set(e.from, componentsById.get(e.from).name);
123
+ }
124
+ const rel = (m) => [...m].map(([id, name]) => ({ id, name })).sort((a, b) => a.id.localeCompare(b.id));
125
+ const docs = repoDocs
126
+ .filter((doc) => doc.srcRefs.some((f) => owns(prefixes, f)))
127
+ .map((doc) => ({ path: doc.rel, title: doc.title, status: doc.status, adopted: adoptedPageByRel.get(doc.rel) ?? null }));
128
+ return {
129
+ component: {
130
+ id: component.id, name: component.name, kind: component.kind,
131
+ responsibility: component.responsibility, paths: component.paths.map(toPosixTarget), fragility: component.fragility,
132
+ },
133
+ files: files.slice(0, 25),
134
+ symbols: symbols.slice(0, 12).map((s) => ({
135
+ name: s.name, file: toPosixTarget(s.file), kind: s.kind,
136
+ fan_in: s.metrics.fan_in, fan_out: s.metrics.fan_out, loc: s.metrics.loc,
137
+ })),
138
+ decisions, constraints, bugs, docs,
139
+ dependsOn: rel(dependsOn), usedBy: rel(usedBy),
140
+ };
141
+ }
142
+ // ---------------------------------------------------------------------------
143
+ // Freshness hash — canonical (key-sorted) JSON of the pack. Deterministic by
144
+ // construction: same graph → same hash, on every OS and regardless of LLM prose.
145
+ // ---------------------------------------------------------------------------
146
+ function canonical(v) {
147
+ if (Array.isArray(v))
148
+ return v.map(canonical);
149
+ if (v && typeof v === "object") {
150
+ return Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)).map(([k, x]) => [k, canonical(x)]));
151
+ }
152
+ return v;
153
+ }
154
+ export function packHash(pack) {
155
+ return createHash("sha256").update(JSON.stringify(canonical(pack))).digest("hex").slice(0, 16);
156
+ }
157
+ // ---------------------------------------------------------------------------
158
+ // Rendering — deterministic skeleton; optional prose slots in as "Overview".
159
+ // No timestamps in page bodies: identical inputs must produce byte-identical
160
+ // pages (idempotent regen, no git noise). Generation time lives in the manifest.
161
+ // ---------------------------------------------------------------------------
162
+ export function slugFor(name, id, taken) {
163
+ let slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || id;
164
+ // "readme" and "specs" are reserved page names (index + specs ledger); the
165
+ // id-suffix itself can collide with a literal name, so loop until unique.
166
+ if (slug === "readme" || slug === "specs" || taken.has(slug))
167
+ slug = `${slug}-${id.replace(/^cmp_/, "").slice(0, 6)}`;
168
+ while (taken.has(slug))
169
+ slug = `${slug}-x`;
170
+ taken.add(slug);
171
+ return slug;
172
+ }
173
+ const DOC_BADGE = { grounded: "✅ grounded", stale: "⚠ stale", unverified: "◻ unverified" };
174
+ /** `docsLinkable`: pages in the MAIN repo can relative-link ../<doc>; a private
175
+ * home's pages live in the OVERLAY repo where those paths don't resolve, so
176
+ * they render doc paths as plain text instead. */
177
+ export function renderPage(pack, prose, slugById, docsLinkable = true) {
178
+ const c = pack.component;
179
+ const L = [];
180
+ L.push(`<!-- hunch:wiki ${c.id} — GENERATED from the Hunch graph by \`hunch wiki\`; the graph is the source of truth. Edit records (/capture, hunch_record_decision), then \`hunch wiki --heal\` — do not edit this page by hand. -->`);
181
+ L.push(`# ${c.name}`, "");
182
+ if (c.responsibility)
183
+ L.push(`> ${c.responsibility}`, "");
184
+ if (c.paths.length)
185
+ L.push(`Owns: ${c.paths.map((p) => `\`${p}\``).join(", ")}`, "");
186
+ if (prose)
187
+ L.push("## Overview", "", prose.trim(), "");
188
+ if (pack.decisions.length) {
189
+ L.push("## Why it is this way", "");
190
+ for (const d of pack.decisions) {
191
+ if (d.topic)
192
+ L.push(`<!-- hunch:topic ${d.topic} ${d.id} -->`);
193
+ L.push(`### ${d.title} (${d.id})`, "");
194
+ if (d.decision)
195
+ L.push(d.decision, "");
196
+ if (d.context)
197
+ L.push(`- **Context:** ${d.context}`);
198
+ for (const q of d.consequences)
199
+ L.push(`- **Consequence:** ${q}`);
200
+ if (d.alternatives_rejected.length)
201
+ L.push(`- **Rejected:** ${d.alternatives_rejected.join("; ")}`);
202
+ L.push("");
203
+ }
204
+ }
205
+ if (pack.constraints.length) {
206
+ L.push("## Invariants (do not break)", "");
207
+ for (const k of pack.constraints)
208
+ L.push(`- **[${k.severity}]** ${k.statement}${k.rationale ? ` — ${k.rationale}` : ""} _(${k.id})_`);
209
+ L.push("");
210
+ }
211
+ if (pack.symbols.length) {
212
+ L.push("## Structure", "", "| Symbol | Kind | File | Fan-in | LOC |", "| --- | --- | --- | ---: | ---: |");
213
+ for (const s of pack.symbols)
214
+ L.push(`| \`${s.name}\` | ${s.kind} | ${s.file} | ${s.fan_in} | ${s.loc} |`);
215
+ L.push("");
216
+ if (pack.files.length)
217
+ L.push(`Files: ${pack.files.map((f) => `\`${f}\``).join(", ")}`, "");
218
+ }
219
+ if (pack.docs.length) {
220
+ L.push("## Docs & specs", "");
221
+ for (const d of pack.docs) {
222
+ // A stale doc routes to its ADOPTED (wiki-managed, graph-healed) copy.
223
+ const ref = d.status === "stale" && d.adopted
224
+ ? `[${d.title}](${d.adopted}) (wiki-managed copy; original \`${d.path}\` is stale)`
225
+ : docsLinkable ? `[${d.title}](../${d.path})` : `${d.title} (\`${d.path}\`)`;
226
+ L.push(`- ${DOC_BADGE[d.status] ?? d.status} — ${ref}`);
227
+ }
228
+ L.push("");
229
+ }
230
+ if (pack.dependsOn.length || pack.usedBy.length) {
231
+ L.push("## Relations", "");
232
+ const link = (r) => { const s = slugById.get(r.id); return s ? `[${r.name}](${s}.md)` : r.name; };
233
+ if (pack.dependsOn.length)
234
+ L.push(`- Depends on: ${pack.dependsOn.map(link).join(", ")}`);
235
+ if (pack.usedBy.length)
236
+ L.push(`- Used by: ${pack.usedBy.map(link).join(", ")}`);
237
+ L.push("");
238
+ }
239
+ if (pack.bugs.length) {
240
+ L.push("## Bug history", "");
241
+ for (const b of pack.bugs)
242
+ L.push(`- **[${b.severity}]** ${b.title} — ${b.root_cause || "root cause unrecorded"} _(${b.id}, ${b.status})_`);
243
+ L.push("");
244
+ }
245
+ L.push("---", "", "_This page is a derived view of the Hunch graph. Regenerate: `hunch wiki --heal`._", "");
246
+ return L.join("\n");
247
+ }
248
+ export function renderIndex(entries, repoWide, home, docs = []) {
249
+ const L = [];
250
+ L.push("<!-- hunch:wiki _index — GENERATED from the Hunch graph by `hunch wiki`; do not edit by hand. -->");
251
+ L.push("# Component wiki", "");
252
+ if (home.kind === "private") {
253
+ L.push("> ⚠ **PRIVATE** — rendered from the full graph **including the private overlay**. This wiki lives in the overlay repo; do not copy pages into a public repo or paste them publicly.", "");
254
+ }
255
+ L.push("Generated from this repo's Hunch engineering-memory graph — the graph is the source of truth; staleness is drift-gated (`hunch drift`), healed with `hunch wiki --heal`.", "");
256
+ L.push("| Component | Responsibility | Decisions | Invariants |", "| --- | --- | ---: | ---: |");
257
+ const cell = (s) => s.replace(/\s*\n\s*/g, " ").replace(/\|/g, "\\|");
258
+ for (const { pack, slug } of entries) {
259
+ L.push(`| [${cell(pack.component.name)}](${slug}.md) | ${cell(clip(pack.component.responsibility || "—", 120))} | ${pack.decisions.length} | ${pack.constraints.length} |`);
260
+ }
261
+ if (docs.length) {
262
+ const n = (s) => docs.filter((d) => d.status === s).length;
263
+ L.push("", `📄 [Specs & docs ledger](specs.md) — ${docs.length} repo doc(s): ${n("grounded")} grounded, ${n("stale")} stale, ${n("unverified")} unverified.`);
264
+ }
265
+ if (repoWide.length) {
266
+ L.push("", "## Repo-wide invariants", "");
267
+ for (const k of repoWide)
268
+ L.push(`- **[${k.severity}]** ${clip(k.statement, 300)} _(${k.id})_`);
269
+ }
270
+ L.push("");
271
+ return L.join("\n");
272
+ }
273
+ /** The specs ledger — every repo doc with its deterministic freshness grade.
274
+ * This page is what makes the wiki usable as the trusted READING surface over
275
+ * the repo's own documentation: grounded docs are safe, stale docs carry their
276
+ * exact drift, unverified docs show how to get grounded. Prose is never
277
+ * rewritten — healing a stale doc is a human edit guided by `hunch heal`. */
278
+ export function renderSpecsPage(docs, home, adoptedPageByRel = new Map()) {
279
+ const L = [];
280
+ L.push("<!-- hunch:wiki _specs — GENERATED doc-freshness ledger by `hunch wiki`; do not edit by hand. -->");
281
+ L.push("# Specs & docs ledger", "");
282
+ L.push("Every markdown doc in this repo, graded **deterministically** against the decision graph (no LLM, no guessing). Trust ✅, distrust ⚠ (follow the graph instead), and consider anchoring ◻.", "");
283
+ const badge = (s) => DOC_BADGE[s] ?? s;
284
+ const link = (d) => (home.kind === "private" ? `${d.title} (\`${d.rel}\`)` : `[${d.title}](../${d.rel})`);
285
+ const stale = docs.filter((d) => d.status === "stale");
286
+ if (stale.length) {
287
+ L.push("## ⚠ Stale — ADOPTED; read the wiki-managed copy", "");
288
+ for (const d of stale) {
289
+ const copy = adoptedPageByRel.get(d.rel);
290
+ L.push(`- ${link(d)}${copy ? ` → **[wiki-managed copy](${copy})**` : ""}`);
291
+ for (const i of d.issues)
292
+ L.push(` - ${i}`);
293
+ }
294
+ L.push("", "Each stale doc is adopted: a copy healed against the graph lives under `docs/` here and is the version to READ. The original is preserved untouched; heal it toward the CURRENT decision and re-pin its `<!-- hunch:topic … -->` marker (`hunch heal` lists the actions) and the copy retires automatically.", "");
295
+ }
296
+ const grounded = docs.filter((d) => d.status === "grounded");
297
+ if (grounded.length) {
298
+ L.push("## ✅ Grounded — anchored to current decisions", "");
299
+ for (const d of grounded)
300
+ L.push(`- ${link(d)}${d.topics.length ? ` — topics: ${d.topics.map((t) => `\`${t}\``).join(", ")}` : ""}`);
301
+ L.push("");
302
+ }
303
+ const unverified = docs.filter((d) => d.status === "unverified");
304
+ if (unverified.length) {
305
+ L.push("## ◻ Unverified — Hunch can't vouch either way", "");
306
+ for (const d of unverified)
307
+ L.push(`- ${link(d)}`);
308
+ L.push("", "Ground a doc by adding `<!-- hunch:topic <topic> <dec_id> -->` above the section it describes — from then on drift detection covers it.", "");
309
+ }
310
+ if (!docs.length)
311
+ L.push("_No markdown docs found outside generated pages._", "");
312
+ L.push("---", "", `_${badge("grounded").slice(2)} / ${badge("stale").slice(2)} / ${badge("unverified").slice(2)} are computed from topic pins, supersession state, and code references — see \`hunch drift\`._`, "");
313
+ return L.join("\n");
314
+ }
315
+ export function readWikiManifestAt(manifestPath) {
316
+ try {
317
+ const raw = JSON.parse(readFileSync(manifestPath, "utf8"));
318
+ if (!raw || raw.version !== 1 || typeof raw.dir !== "string" || !raw.pages || typeof raw.pages !== "object")
319
+ return null;
320
+ // Drop malformed page entries (hand edit / bad merge) instead of crashing
321
+ // every drift-bearing command on `p.component.startsWith(...)`.
322
+ raw.pages = Object.fromEntries(Object.entries(raw.pages).filter(([, p]) => p && typeof p === "object" && typeof p.component === "string" && typeof p.hash === "string"));
323
+ return raw;
324
+ }
325
+ catch {
326
+ return null;
327
+ }
328
+ }
329
+ export function writeWikiManifestAt(manifestPath, manifest) {
330
+ writeFileAtomic(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
331
+ }
332
+ /** For the committed grounding docs (CLAUDE.md et al): is a PUBLIC wiki adopted
333
+ * here, and how big? Deliberately blind to the private home — committed docs
334
+ * must not advertise what the overlay holds. */
335
+ export function wikiSummary(root) {
336
+ const m = readWikiManifestAt(join(hunchPaths(root).hunch, "wiki-manifest.json"));
337
+ return m ? { dir: m.dir, pages: Object.keys(m.pages).length } : null;
338
+ }
339
+ /** Reserved manifest component id for the specs ledger page. */
340
+ const SPECS_ID = "_specs";
341
+ /** Reserved manifest component id for the README index page. */
342
+ const INDEX_ID = "_index";
343
+ /** Manifest component-id prefix for adopted (wiki-managed) doc copies. */
344
+ const ADOPTED_PREFIX = "doc:";
345
+ const sha16 = (s) => createHash("sha256").update(s).digest("hex").slice(0, 16);
346
+ /** The one freshness state machine every generated artifact goes through:
347
+ * unknown/mismatched manifest entry → new; input hash moved → stale; file
348
+ * gone → stale; file bytes differ from what was WRITTEN → stale (the
349
+ * "do not edit by hand" tripwire; skipped for pre-`bytes` manifests). */
350
+ function pageState(home, page, component, hash, prior) {
351
+ if (!prior || prior.component !== component)
352
+ return { state: "new", reason: "no page generated yet" };
353
+ if (prior.hash !== hash)
354
+ return { state: "stale", reason: "graph inputs changed since generation" };
355
+ const abs = join(home.pagesRoot, ...page.split("/"));
356
+ if (!existsSync(abs))
357
+ return { state: "stale", reason: "page file is missing" };
358
+ if (prior.bytes) {
359
+ try {
360
+ if (sha16(readFileSync(abs, "utf8")) !== prior.bytes) {
361
+ return { state: "stale", reason: "page was edited by hand (or merge-mangled) — regenerating restores the derived view" };
362
+ }
363
+ }
364
+ catch {
365
+ return { state: "stale", reason: "page file is unreadable" };
366
+ }
367
+ }
368
+ return { state: "fresh", reason: "" };
369
+ }
370
+ export function wikiStatus(store, home, srcRoot) {
371
+ const manifest = readWikiManifestAt(home.manifestPath);
372
+ const decisions = home.source === "all" ? store.recs("decisions") : store.json.loadAll("decisions");
373
+ const docs = scanRepoDocs(decisions, srcRoot);
374
+ const components = (home.source === "all" ? store.recs("components") : store.json.loadAll("components"))
375
+ .filter((c) => c.status === "active")
376
+ .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
377
+ // Adoption slugs are assigned FIRST (single authority): every stale doc gets a
378
+ // wiki-managed, graph-healed copy, and the packs/renderers receive its path.
379
+ const adoptedTaken = new Set();
380
+ const adoptions = [];
381
+ for (const doc of docs) {
382
+ if (doc.status !== "stale")
383
+ continue;
384
+ let content;
385
+ try {
386
+ content = readFileSync(join(srcRoot, ...doc.rel.split("/")), "utf8");
387
+ }
388
+ catch {
389
+ continue; // vanished between scan and read → next run re-grades
390
+ }
391
+ const page = `${home.dir}/docs/${adoptedSlug(doc.rel, adoptedTaken)}.md`;
392
+ const hash = adoptionHash(content, decisions, doc);
393
+ const { state } = pageState(home, page, `${ADOPTED_PREFIX}${doc.rel}`, hash, manifest?.pages[page]);
394
+ adoptions.push({ doc, content, page, hash, state });
395
+ }
396
+ const adoptedPageByRel = new Map(adoptions.map((a) => [a.doc.rel, a.page.slice(home.dir.length + 1)]));
397
+ const taken = new Set();
398
+ const entries = components.map((c) => {
399
+ const pack = assemblePack(store, c, home.source, docs, adoptedPageByRel);
400
+ const slug = slugFor(c.name, c.id, taken);
401
+ const page = `${home.dir}/${slug}.md`;
402
+ const hash = packHash(pack);
403
+ const { state, reason } = pageState(home, page, c.id, hash, manifest?.pages[page]);
404
+ return { pack, slug, page, hash, state, reason };
405
+ });
406
+ // Specs ledger freshness: hashed over the status snapshot (not raw prose), so
407
+ // a doc edit that doesn't change any grade/title/topic doesn't churn the page.
408
+ const specsPage = `${home.dir}/specs.md`;
409
+ const specsHash = sha16(JSON.stringify(canonical(docs.map((d) => ({ rel: d.rel, title: d.title, status: d.status, issues: d.issues, topics: d.topics })))));
410
+ const specs = { page: specsPage, hash: specsHash, state: pageState(home, specsPage, SPECS_ID, specsHash, manifest?.pages[specsPage]).state };
411
+ // The README index is a generated artifact like any other: hashed over its
412
+ // actual inputs (rows, repo-wide invariants, doc counts) so a repo-wide
413
+ // constraint change or a component rename re-renders it — and deleting or
414
+ // hand-editing it grades stale instead of staying invisible forever.
415
+ const repoWide = (home.source === "all" ? store.recs("constraints") : store.json.loadAll("constraints"))
416
+ .filter((c) => c.status !== "retired" && c.scope.every((g) => globPrefix(g) === ""))
417
+ .sort((a, b) => (SEV[b.severity] ?? 0) - (SEV[a.severity] ?? 0) || a.id.localeCompare(b.id))
418
+ .slice(0, 10);
419
+ const indexPage = `${home.dir}/README.md`;
420
+ const indexHash = sha16(JSON.stringify(canonical({
421
+ kind: home.kind,
422
+ rows: entries.map((e) => ({ slug: e.slug, name: e.pack.component.name, responsibility: e.pack.component.responsibility, decisions: e.pack.decisions.length, constraints: e.pack.constraints.length })),
423
+ repoWide: repoWide.map((c) => ({ id: c.id, severity: c.severity, statement: c.statement })),
424
+ docs: { grounded: docs.filter((d) => d.status === "grounded").length, stale: docs.filter((d) => d.status === "stale").length, unverified: docs.filter((d) => d.status === "unverified").length, total: docs.length },
425
+ })));
426
+ const index = { page: indexPage, hash: indexHash, state: pageState(home, indexPage, INDEX_ID, indexHash, manifest?.pages[indexPage]).state };
427
+ // Orphans by PAGE KEY, not component id: anything the manifest tracks that no
428
+ // current artifact claims (deleted component, renamed component whose slug
429
+ // moved, a retired adoption) gets removed on heal — nothing generated is ever
430
+ // stranded on disk while the manifest forgets it.
431
+ const expected = new Set([...entries.map((e) => e.page), ...adoptions.map((a) => a.page), specsPage, indexPage]);
432
+ const orphans = [];
433
+ const adoptionOrphans = [];
434
+ for (const [page, p] of Object.entries(manifest?.pages ?? {})) {
435
+ if (expected.has(page))
436
+ continue;
437
+ (p.component.startsWith(ADOPTED_PREFIX) ? adoptionOrphans : orphans).push(page);
438
+ }
439
+ return { home, entries, docs, adoptions, adoptionOrphans, decisions, specs, index, repoWide, orphans };
440
+ }
441
+ /** Read short excerpts of the component's heaviest files as LLM grounding.
442
+ * Source files always live in the MAIN repo (`srcRoot`), even for the private
443
+ * home — only the pages move; the code doesn't. */
444
+ function excerptsFor(srcRoot, pack) {
445
+ const parts = [];
446
+ for (const file of pack.files.slice(0, 3)) {
447
+ try {
448
+ const lines = readFileSync(join(srcRoot, ...file.split("/")), "utf8").split("\n").slice(0, 60);
449
+ parts.push(`--- ${file} (first ${lines.length} lines) ---\n${lines.join("\n")}`);
450
+ }
451
+ catch {
452
+ /* deleted/unreadable → skip; the pack alone still grounds the prose */
453
+ }
454
+ }
455
+ return parts.join("\n\n").slice(0, 6000);
456
+ }
457
+ const WIKI_SYSTEM = `You are the documentation engine of an Engineering Memory OS. Write a short,
458
+ factual overview of ONE component of a codebase for its generated wiki. Ground every claim in the
459
+ provided graph records (decisions, constraints, bugs, symbols) and code excerpts; never invent
460
+ behavior. Cite record ids inline like (dec_xxx) or (con_xxx) where a claim comes from one. Output
461
+ plain markdown paragraphs only — no headings, no lists, no code fences. 120-220 words.`;
462
+ export function wikiPrompt(pack, excerpts) {
463
+ return `${WIKI_SYSTEM}\n\n## Graph records for this component\n${JSON.stringify(pack, null, 1)}\n\n## Code excerpts\n${excerpts || "(none)"}\n\nWrite the overview now.`;
464
+ }
465
+ export async function generateWiki(store, srcRoot, home, opts) {
466
+ const status = wikiStatus(store, home, srcRoot);
467
+ const prior = readWikiManifestAt(home.manifestPath);
468
+ const targets = status.entries.filter((e) => opts.only === "all" || e.state !== "fresh");
469
+ const specsTarget = opts.only === "all" || status.specs.state !== "fresh";
470
+ const indexTarget = opts.only === "all" || status.index.state !== "fresh";
471
+ const log = opts.log ?? (() => { });
472
+ const slugById = new Map(status.entries.map((e) => [e.pack.component.id, e.slug]));
473
+ const written = [];
474
+ /** Written-bytes ledger — the hand-edit tripwire recorded per page. */
475
+ const bytesByPage = new Map();
476
+ const put = (page, content) => {
477
+ writeFileAtomic(join(home.pagesRoot, ...page.split("/")), content);
478
+ bytesByPage.set(page, sha16(content));
479
+ written.push(page);
480
+ };
481
+ mkdirSync(join(home.pagesRoot, home.dir), { recursive: true });
482
+ for (const e of targets) {
483
+ let prose = null;
484
+ if (opts.prose) {
485
+ try {
486
+ prose = await opts.prose(e.pack, excerptsFor(srcRoot, e.pack));
487
+ }
488
+ catch {
489
+ prose = null; // template-only page; never fail generation on a CLI hiccup
490
+ }
491
+ }
492
+ put(e.page, renderPage(e.pack, prose, slugById, home.kind === "public"));
493
+ log(` ✎ ${e.page}${e.state === "fresh" ? "" : ` (${e.state})`}${prose ? "" : " [template]"}`);
494
+ }
495
+ if (specsTarget) {
496
+ const adoptedPageByRel = new Map(status.adoptions.map((a) => [a.doc.rel, a.page.slice(home.dir.length + 1)]));
497
+ put(status.specs.page, renderSpecsPage(status.docs, home, adoptedPageByRel));
498
+ log(` ✎ ${status.specs.page}${status.specs.state === "fresh" ? "" : ` (${status.specs.state})`} [${status.docs.length} doc(s) graded]`);
499
+ }
500
+ // Adoption: stale docs get (re)healed wiki-managed copies.
501
+ const adoptionTargets = status.adoptions.filter((a) => opts.only === "all" || a.state !== "fresh");
502
+ if (adoptionTargets.length)
503
+ mkdirSync(join(home.pagesRoot, home.dir, "docs"), { recursive: true });
504
+ for (const a of adoptionTargets) {
505
+ put(a.page, renderAdoptedDoc(a.doc, a.content, status.decisions));
506
+ log(` ✚ ${a.page}${a.state === "fresh" ? "" : ` (${a.state})`} [adopted from ${a.doc.rel}]`);
507
+ }
508
+ if (indexTarget) {
509
+ put(status.index.page, renderIndex(status.entries.map((e) => ({ pack: e.pack, slug: e.slug })), status.repoWide, home, status.docs));
510
+ log(` ✎ ${status.index.page}${status.index.state === "fresh" ? "" : ` (${status.index.state})`}`);
511
+ }
512
+ // Orphaned pages (component deleted from the graph) are generated artifacts —
513
+ // remove them so the wiki never documents a component that no longer exists.
514
+ // Retired adoptions (original healed or deleted) leave the same way.
515
+ const removed = [];
516
+ for (const [pages, why] of [[status.orphans, "component gone"], [status.adoptionOrphans, "original healed or removed — copy retired"]]) {
517
+ for (const page of pages) {
518
+ try {
519
+ rmSync(join(home.pagesRoot, ...page.split("/")), { force: true });
520
+ }
521
+ catch {
522
+ /* best effort */
523
+ }
524
+ removed.push(page);
525
+ log(` ✗ ${page} (${why})`);
526
+ }
527
+ }
528
+ if (written.length || removed.length) {
529
+ const pages = {};
530
+ const entry = (page, component, hash, state) => {
531
+ const keep = state === "fresh" ? prior?.pages[page] : undefined;
532
+ pages[page] = keep ?? { component, hash, generated: opts.now, bytes: bytesByPage.get(page) };
533
+ };
534
+ for (const e of status.entries)
535
+ entry(e.page, e.pack.component.id, e.hash, e.state);
536
+ for (const a of status.adoptions)
537
+ entry(a.page, `${ADOPTED_PREFIX}${a.doc.rel}`, a.hash, a.state);
538
+ entry(status.specs.page, SPECS_ID, status.specs.hash, status.specs.state);
539
+ entry(status.index.page, INDEX_ID, status.index.hash, status.index.state);
540
+ writeWikiManifestAt(home.manifestPath, { version: 1, dir: home.dir, pages });
541
+ }
542
+ return { written, removed, unchanged: status.entries.length - targets.length };
543
+ }
544
+ // ---------------------------------------------------------------------------
545
+ // Drift — wiki-stale findings for `hunch drift` / `hunch heal`. Fires ONLY for
546
+ // homes that were adopted (their manifest exists): no manifest, no findings, so
547
+ // repos that never ran `hunch wiki` see zero noise. The private home is checked
548
+ // only where the overlay is configured — a CI runner without HUNCH_PRIVATE_DIR /
549
+ // local.json never sees (or leaks) private findings, by construction.
550
+ // ---------------------------------------------------------------------------
551
+ export function computeWikiDrift(store, root) {
552
+ const findings = [];
553
+ const homes = [publicHome(root), privateHome(store)].filter((h) => h !== null);
554
+ for (const home of homes) {
555
+ if (!readWikiManifestAt(home.manifestPath))
556
+ continue; // not adopted → silent
557
+ const status = wikiStatus(store, home, root);
558
+ const where = home.kind === "private" ? " (private overlay wiki)" : "";
559
+ const heal = `hunch wiki --heal${home.kind === "private" ? " --private" : ""}`;
560
+ for (const e of status.entries) {
561
+ if (e.state === "fresh")
562
+ continue;
563
+ findings.push({
564
+ kind: "wiki-stale",
565
+ id: e.page,
566
+ detail: (e.state === "new"
567
+ ? `component ${e.pack.component.id} ("${e.pack.component.name}") has no wiki page yet — generate with \`${heal}\``
568
+ : `${e.reason} (component ${e.pack.component.id}) — regenerate with \`${heal}\``) + where,
569
+ });
570
+ }
571
+ if (status.specs.state !== "fresh") {
572
+ findings.push({
573
+ kind: "wiki-stale",
574
+ id: status.specs.page,
575
+ detail: `the repo's doc freshness snapshot changed (a spec was added, removed, re-graded, or re-anchored) — regenerate with \`${heal}\`${where}`,
576
+ });
577
+ }
578
+ if (status.index.state !== "fresh") {
579
+ findings.push({
580
+ kind: "wiki-stale",
581
+ id: status.index.page,
582
+ detail: `the index's inputs moved (component set/names, repo-wide invariants, or doc counts) — regenerate with \`${heal}\`${where}`,
583
+ });
584
+ }
585
+ for (const a of status.adoptions) {
586
+ if (a.state === "fresh")
587
+ continue;
588
+ findings.push({
589
+ kind: "wiki-stale",
590
+ id: a.page,
591
+ detail: a.state === "new"
592
+ ? `stale doc "${a.doc.rel}" awaits adoption (wiki-managed healed copy) — generate with \`${heal}\`${where}`
593
+ : `adopted copy of "${a.doc.rel}" is out of date (source or graph moved) — re-heal with \`${heal}\`${where}`,
594
+ });
595
+ }
596
+ for (const page of status.adoptionOrphans) {
597
+ findings.push({ kind: "wiki-stale", id: page, detail: `its original healed or was removed — retire the copy with \`${heal}\`${where}` });
598
+ }
599
+ for (const page of status.orphans) {
600
+ findings.push({ kind: "wiki-stale", id: page, detail: `its component no longer exists in the graph — remove with \`${heal}\`${where}` });
601
+ }
602
+ }
603
+ return findings;
604
+ }
605
+ //# sourceMappingURL=wiki.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.2.0",
3
+ "version": "1.2.3",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
6
  "description": "Architectural Conformance for AI-generated code: a git-native graph that deterministically blocks AI changes which break your architecture — the semantic invariants (layering, must-reach, dependency direction) pattern-SAST can't express — grounded in the decisions and bugs behind each rule, across any MCP assistant (Claude Code, Cursor, Copilot, Windsurf, Codex).",