@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
package/README.md
CHANGED
|
@@ -120,6 +120,17 @@ graphs are unaffected until you opt in.
|
|
|
120
120
|
one topic**. `hunch reconcile-topics` catches the one case a git merge can create, for human resolution.
|
|
121
121
|
- **`hunch heal`** + the **`/heal`** slash command do **read-only** doc↔graph reconciliation — they show
|
|
122
122
|
exactly what disagrees and never rewrite your prose silently.
|
|
123
|
+
- **`hunch wiki`** — a generated component wiki + **specs ledger** rendered *from* the graph. Every repo
|
|
124
|
+
doc is graded deterministically (✅ grounded / ⚠ stale / ◻ unverified); a stale doc is **adopted** — a
|
|
125
|
+
wiki-managed copy re-pinned to the current decision with the correction inline, while your original
|
|
126
|
+
file is never touched (and the copy retires once you heal the original). Freshness is hash-gated:
|
|
127
|
+
`hunch drift` names exactly which pages went stale, `hunch wiki --heal` regenerates only those, and
|
|
128
|
+
`hunch wiki --check` is the CI gate. With a private overlay, `hunch wiki --private` renders the FULL
|
|
129
|
+
graph into the overlay repo — nothing private ever lands in the committed wiki.
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
npm run build && bash demo/wiki.sh # watch a spec go stale, get adopted + healed, then released
|
|
133
|
+
```
|
|
123
134
|
|
|
124
135
|
→ [docs](https://hunch-pi.vercel.app/docs#grounding)
|
|
125
136
|
|
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";
|
|
@@ -1887,6 +1889,108 @@ program
|
|
|
1887
1889
|
store.close();
|
|
1888
1890
|
}
|
|
1889
1891
|
});
|
|
1892
|
+
// ---- wiki (generated component wiki — a derived VIEW of the graph) ----------
|
|
1893
|
+
program
|
|
1894
|
+
.command("wiki")
|
|
1895
|
+
.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.")
|
|
1896
|
+
.option("--dir <dir>", "output directory (default: wiki/, or the manifest's dir once adopted)")
|
|
1897
|
+
.option("--heal", "regenerate only new/stale pages (manifest hash mismatch) and remove orphans")
|
|
1898
|
+
.option("--check", "report stale pages and exit non-zero (CI gate); writes nothing")
|
|
1899
|
+
.option("--no-llm", "skip LLM prose; deterministic template pages only")
|
|
1900
|
+
.option("--private", "render the FULL graph (private overlay included) and write the wiki into the OVERLAY repo — nothing lands in this repo")
|
|
1901
|
+
.action(async (opts) => {
|
|
1902
|
+
const { store, root } = storeFor();
|
|
1903
|
+
try {
|
|
1904
|
+
store.reindex(); // reflect out-of-band JSON edits before reading the graph
|
|
1905
|
+
// The home pairs source with destination: overlay-inclusive reads may only
|
|
1906
|
+
// ever land in the overlay repo (a committed public page is a leak surface).
|
|
1907
|
+
const home = opts.private ? privateHome(store, opts.dir) : publicHome(root, opts.dir);
|
|
1908
|
+
if (!home)
|
|
1909
|
+
return fail("no private overlay configured — run `hunch private` (or `hunch shared`) first.");
|
|
1910
|
+
// A union-fed wiki must never land inside the public work tree: refuse the
|
|
1911
|
+
// degenerate overlay layout where the overlay's parent IS this repo's root.
|
|
1912
|
+
if (home.kind === "private" && resolve(home.pagesRoot) === resolve(root)) {
|
|
1913
|
+
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.");
|
|
1914
|
+
}
|
|
1915
|
+
const manifest = readWikiManifestAt(home.manifestPath);
|
|
1916
|
+
// A wiki lives where its manifest says; a different --dir would strand the
|
|
1917
|
+
// old directory (its pages aren't orphans — their components are live).
|
|
1918
|
+
if (manifest && opts.dir && manifest.dir !== opts.dir) {
|
|
1919
|
+
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}).`);
|
|
1920
|
+
}
|
|
1921
|
+
// CI-safe: checking a repo that never adopted a wiki is a no-op, not a failure.
|
|
1922
|
+
if (opts.check && !manifest) {
|
|
1923
|
+
console.log("✓ No wiki adopted here (no wiki manifest) — nothing to check.");
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
const status = wikiStatus(store, home, root);
|
|
1927
|
+
const healHint = `hunch wiki --heal${home.kind === "private" ? " --private" : ""}`;
|
|
1928
|
+
if (opts.check) {
|
|
1929
|
+
const stale = status.entries.filter((e) => e.state !== "fresh");
|
|
1930
|
+
const staleAdoptions = status.adoptions.filter((a) => a.state !== "fresh");
|
|
1931
|
+
const specsStale = status.specs.state !== "fresh";
|
|
1932
|
+
const indexStale = status.index.state !== "fresh";
|
|
1933
|
+
const orphanCount = status.orphans.length + status.adoptionOrphans.length;
|
|
1934
|
+
if (!stale.length && !staleAdoptions.length && !specsStale && !indexStale && !orphanCount) {
|
|
1935
|
+
console.log(`✓ Wiki is fresh — ${status.entries.length + status.adoptions.length + 2} page(s) match the graph and the doc ledger.`);
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
for (const e of stale)
|
|
1939
|
+
console.log(`· ${e.page} — ${e.state === "new" ? "no page generated yet" : e.reason}`);
|
|
1940
|
+
for (const a of staleAdoptions)
|
|
1941
|
+
console.log(`· ${a.page} — ${a.state === "new" ? `stale doc "${a.doc.rel}" awaits adoption` : `adopted copy of "${a.doc.rel}" out of date`}`);
|
|
1942
|
+
if (specsStale)
|
|
1943
|
+
console.log(`· ${status.specs.page} — the repo's doc freshness snapshot changed`);
|
|
1944
|
+
if (indexStale)
|
|
1945
|
+
console.log(`· ${status.index.page} — the index's inputs moved`);
|
|
1946
|
+
for (const p of status.adoptionOrphans)
|
|
1947
|
+
console.log(`· ${p} — original healed or removed; copy retires`);
|
|
1948
|
+
for (const p of status.orphans)
|
|
1949
|
+
console.log(`· ${p} — no current artifact claims this page`);
|
|
1950
|
+
console.log(`\n${stale.length + staleAdoptions.length + (specsStale ? 1 : 0) + (indexStale ? 1 : 0) + orphanCount} stale page(s) — run \`${healHint}\`.`);
|
|
1951
|
+
process.exitCode = 1;
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
// An empty graph still needs --heal reachable for orphan/adoption cleanup —
|
|
1955
|
+
// only a plain generate demands components (a broken drift↔heal loop
|
|
1956
|
+
// otherwise: drift says "remove with --heal", --heal refuses to run).
|
|
1957
|
+
if (!status.entries.length && !opts.heal)
|
|
1958
|
+
return fail("no active components in the graph — run `hunch index` first.");
|
|
1959
|
+
// Prose is optional garnish on the deterministic skeleton: subscription CLI
|
|
1960
|
+
// only (same rule as synthesis), feature-detected, and any failure degrades
|
|
1961
|
+
// to a template page — generation never depends on a model being present.
|
|
1962
|
+
let prose;
|
|
1963
|
+
if (opts.llm !== false) {
|
|
1964
|
+
const provider = await selectProvider();
|
|
1965
|
+
if (provider.draftProse) {
|
|
1966
|
+
console.log(`Prose via ${provider.name} (subscription); the drift-bearing skeleton stays deterministic.`);
|
|
1967
|
+
prose = (pack, excerpts) => provider.draftProse(wikiPrompt(pack, excerpts));
|
|
1968
|
+
}
|
|
1969
|
+
else {
|
|
1970
|
+
console.log("No subscription CLI available — deterministic template pages.");
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
const res = await generateWiki(store, root, home, {
|
|
1974
|
+
now: new Date().toISOString(),
|
|
1975
|
+
only: opts.heal ? "stale" : "all",
|
|
1976
|
+
prose,
|
|
1977
|
+
log: (l) => console.log(l),
|
|
1978
|
+
});
|
|
1979
|
+
if (!res.written.length && !res.removed.length) {
|
|
1980
|
+
console.log(`✓ Nothing to regenerate — ${res.unchanged} page(s) already fresh.`);
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
const dest = home.kind === "private" ? `${join(home.pagesRoot, home.dir)} (private overlay repo — NOT committed here)` : `${home.dir}/`;
|
|
1984
|
+
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}`);
|
|
1985
|
+
// Committed grounding docs advertise the PUBLIC wiki only — they must not
|
|
1986
|
+
// reveal that (or what) a private overlay wiki exists.
|
|
1987
|
+
if (home.kind === "public")
|
|
1988
|
+
refreshExistingGrounding(root, store);
|
|
1989
|
+
}
|
|
1990
|
+
finally {
|
|
1991
|
+
store.close();
|
|
1992
|
+
}
|
|
1993
|
+
});
|
|
1890
1994
|
// ---- heal (decision-grounded drift reconciliation front door) -------------
|
|
1891
1995
|
program
|
|
1892
1996
|
.command("heal")
|
|
@@ -1938,6 +2042,13 @@ program
|
|
|
1938
2042
|
console.log(`· ${f.id} — ${f.detail}`);
|
|
1939
2043
|
console.log(`\nHeal: update the doc's status marker to match reality.\n`);
|
|
1940
2044
|
}
|
|
2045
|
+
const wikiStale = kind("wiki-stale");
|
|
2046
|
+
if (wikiStale.length) {
|
|
2047
|
+
console.log(`${wikiStale.length} generated wiki page(s) drifted from the graph:\n`);
|
|
2048
|
+
for (const f of wikiStale)
|
|
2049
|
+
console.log(`· ${f.id} — ${f.detail}`);
|
|
2050
|
+
console.log(`\nHeal: run \`hunch wiki --heal\` — regenerates only the stale pages (the wiki is a derived view; never edit it by hand).\n`);
|
|
2051
|
+
}
|
|
1941
2052
|
console.log(`Hunch never rewrites prose for you; this is a read-only reconciliation report.`);
|
|
1942
2053
|
}
|
|
1943
2054
|
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
|
package/dist/core/drift.js
CHANGED
|
@@ -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
|
|
12
|
-
import { join
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
package/dist/extractors/git.js
CHANGED
|
@@ -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
|
|
92
|
-
|
|
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,7 +162,29 @@ export function indexRepo(store, root, opts = {}) {
|
|
|
162
162
|
// persist
|
|
163
163
|
store.json.replaceAll("symbols", symbols);
|
|
164
164
|
store.json.replaceAll("edges", edges);
|
|
165
|
-
|
|
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
|
}
|
|
@@ -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,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
|
|
9
|
-
* "pushed" (
|
|
10
|
-
*
|
|
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
|
package/dist/mcp/server.js
CHANGED
|
@@ -29,6 +29,15 @@ import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken }
|
|
|
29
29
|
import { randomUUID } from "node:crypto";
|
|
30
30
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
31
31
|
const err = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
32
|
+
/** Honest auto-commit suffix: reports only what flushCapture ACTUALLY did. A skipped
|
|
33
|
+
* commit (backstop/lock/nothing staged) says nothing — the record is on disk and the
|
|
34
|
+
* next flush sweeps it up; claiming "auto-committed" there would be a lie. */
|
|
35
|
+
const flushNote = (flush, home, mode) => flush === "pushed" ? ` (committed + pushed to the ${mode === "shared" ? "shared team store" : "private repo"})`
|
|
36
|
+
: flush === "committed"
|
|
37
|
+
? home === "private"
|
|
38
|
+
? " (committed to the overlay repo — push deferred: offline, no upstream, or merge conflict; the next capture or `hunch private --sync` retries)"
|
|
39
|
+
: " (auto-committed to .hunch/ — rides your next push)"
|
|
40
|
+
: "";
|
|
32
41
|
// Read-side token budgets: every tool result is injected into a Claude Code
|
|
33
42
|
// session, so an uncapped list pollutes the context window. Cap each list to its
|
|
34
43
|
// highest-signal head (records are pre-sorted by severity/confidence) and tell the
|
|
@@ -422,8 +431,7 @@ export function buildServer(root) {
|
|
|
422
431
|
// record commits+pushes its overlay repo; a public one commits .hunch/ in THIS repo
|
|
423
432
|
// (commit only — it rides the user's next push, never auto-pushing their code branch).
|
|
424
433
|
const flush = flushCapture(store, hunchPaths(root).hunch, !!decision.private, `hunch: capture ${id}`);
|
|
425
|
-
const flushed = flush
|
|
426
|
-
: flush === "committed" ? " (auto-committed to .hunch/ — rides your next push)" : "";
|
|
434
|
+
const flushed = flushNote(flush, home, store.mode);
|
|
427
435
|
// Capture-session gate (staged deprecation, §9.3): a token proves an interview
|
|
428
436
|
// preceded the write. No token still writes (non-breaking), but returns a nudge
|
|
429
437
|
// toward /capture so the un-interviewed bypass is visible, not silent. A token
|
|
@@ -480,8 +488,7 @@ export function buildServer(root) {
|
|
|
480
488
|
if (home === "public")
|
|
481
489
|
refreshExistingGrounding(root, store); // overlay rules never render into committed grounding
|
|
482
490
|
const flush = flushCapture(store, hunchPaths(root).hunch, !!input.private, `hunch: capture ${rec.id}`);
|
|
483
|
-
const flushed = flush
|
|
484
|
-
: flush === "committed" ? " (auto-committed to .hunch/ — rides your next push)" : "";
|
|
491
|
+
const flushed = flushNote(flush, home, store.mode);
|
|
485
492
|
const enforce = rec.severity === "blocking"
|
|
486
493
|
? "blocks a DIRECT edit to its scope at strict firmness, and fails a PR whose diff touches that scope (CI guard); blast-radius hits and lower firmness stay advisory"
|
|
487
494
|
: "flags violating edits and PRs (advisory)";
|
|
@@ -197,6 +197,15 @@ class CliSynthProvider {
|
|
|
197
197
|
throw new Error(`${this.name}: no usable bug JSON in output`);
|
|
198
198
|
return draft;
|
|
199
199
|
}
|
|
200
|
+
/** Grounded prose for the wiki. Same subscription-only run() path (API keys
|
|
201
|
+
* stripped). Throws on empty output so the caller falls back to its
|
|
202
|
+
* deterministic template page. */
|
|
203
|
+
async draftProse(prompt) {
|
|
204
|
+
const text = (await this.run(prompt)).trim();
|
|
205
|
+
if (!text)
|
|
206
|
+
throw new Error(`${this.name}: empty prose output`);
|
|
207
|
+
return text;
|
|
208
|
+
}
|
|
200
209
|
/** The Critic pass: audit a draft against its commit. Same subscription-only
|
|
201
210
|
* run() path (API keys stripped), so this never bills the pay-per-token API.
|
|
202
211
|
* Throws on unusable output so verifyDecisionSafe degrades to the un-audited
|