@davesheffer/hunch 1.2.0 → 1.2.2
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 +11 -0
- package/dist/cli/index.js +111 -0
- package/dist/cli/preflight.js +19 -0
- package/dist/core/docscan.js +110 -0
- package/dist/core/drift.js +16 -34
- package/dist/extractors/git.js +19 -5
- package/dist/extractors/indexer.js +23 -1
- package/dist/integrations/claudemd.js +8 -2
- package/dist/integrations/providers.js +4 -4
- package/dist/integrations/sync.js +9 -9
- package/dist/mcp/server.js +11 -4
- package/dist/synthesis/provider.js +9 -0
- package/dist/wiki/adopt.js +97 -0
- package/dist/wiki/wiki.js +605 -0
- package/package.json +1 -1
|
@@ -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.
|
|
3
|
+
"version": "1.2.2",
|
|
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).",
|