@davesheffer/hunch 1.1.1 → 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 +12 -0
- package/dist/cli/index.js +127 -1
- package/dist/cli/preflight.js +19 -0
- package/dist/core/docscan.js +110 -0
- package/dist/core/drift.js +16 -34
- package/dist/core/format.js +46 -0
- 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 +20 -5
- package/dist/store/hunchStore.js +64 -0
- 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
|
|
|
@@ -131,6 +142,7 @@ cd your-repo
|
|
|
131
142
|
hunch init # scaffold .hunch/, index, install hooks, wire up assistants
|
|
132
143
|
hunch backfill --since 90d # cold start: seed decisions from recent git history
|
|
133
144
|
hunch why src/auth/session.ts # …then ask your assistant: "why is X built this way?"
|
|
145
|
+
hunch structure src/auth # the map, from the graph — no grep rounds
|
|
134
146
|
```
|
|
135
147
|
|
|
136
148
|
**Claude Code users — one-step plugin install** (MCP tools + `/hunch:capture`, `/hunch:heal`, `/hunch:why`, `/hunch:fix`, `/hunch:fragile`):
|
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";
|
|
@@ -45,12 +46,13 @@ import { updateClaudeMd } from "../integrations/claudemd.js";
|
|
|
45
46
|
import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
|
|
46
47
|
import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding } from "../integrations/providers.js";
|
|
47
48
|
import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
|
|
48
|
-
import { formatContext } from "../core/format.js";
|
|
49
|
+
import { formatContext, formatStructure } from "../core/format.js";
|
|
49
50
|
import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
|
|
50
51
|
import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
|
|
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";
|
|
@@ -1843,6 +1845,21 @@ program
|
|
|
1843
1845
|
store.close();
|
|
1844
1846
|
}
|
|
1845
1847
|
});
|
|
1848
|
+
// ---- structure (graph-served orientation — the anti-grep) -------------------
|
|
1849
|
+
program
|
|
1850
|
+
.command("structure")
|
|
1851
|
+
.description("The indexed shape of the repo, a directory, a file, or a symbol — orient from the graph instead of grep rounds. No target: repo map. Read-only.")
|
|
1852
|
+
.argument("[target]", "a directory, file path, or exact symbol name (omit for the repo map)")
|
|
1853
|
+
.action((target) => {
|
|
1854
|
+
const { store } = storeFor();
|
|
1855
|
+
try {
|
|
1856
|
+
store.reindex(); // reflect out-of-band JSON edits before reading the graph
|
|
1857
|
+
console.log(formatStructure(store.structure(target)));
|
|
1858
|
+
}
|
|
1859
|
+
finally {
|
|
1860
|
+
store.close();
|
|
1861
|
+
}
|
|
1862
|
+
});
|
|
1846
1863
|
// ---- impact (PR impact — read-only, advisory) ------------------------------
|
|
1847
1864
|
program
|
|
1848
1865
|
.command("impact")
|
|
@@ -1872,6 +1889,108 @@ program
|
|
|
1872
1889
|
store.close();
|
|
1873
1890
|
}
|
|
1874
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
|
+
});
|
|
1875
1994
|
// ---- heal (decision-grounded drift reconciliation front door) -------------
|
|
1876
1995
|
program
|
|
1877
1996
|
.command("heal")
|
|
@@ -1923,6 +2042,13 @@ program
|
|
|
1923
2042
|
console.log(`· ${f.id} — ${f.detail}`);
|
|
1924
2043
|
console.log(`\nHeal: update the doc's status marker to match reality.\n`);
|
|
1925
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
|
+
}
|
|
1926
2052
|
console.log(`Hunch never rewrites prose for you; this is a read-only reconciliation report.`);
|
|
1927
2053
|
}
|
|
1928
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/core/format.js
CHANGED
|
@@ -43,4 +43,50 @@ export function formatContext(ctx) {
|
|
|
43
43
|
trimmed = trimmed.slice(0, lastNl);
|
|
44
44
|
return trimmed + "\n… (trimmed to budget)\n";
|
|
45
45
|
}
|
|
46
|
+
/** Render a StructureView as a compact orientation brief (hunch_structure). */
|
|
47
|
+
export function formatStructure(v) {
|
|
48
|
+
const NL = "\n";
|
|
49
|
+
if (v.kind === "none")
|
|
50
|
+
return `Nothing indexed matches "${v.target}" — not a known file, directory, or symbol. Run hunch index if the repo changed, or hunch_query for fuzzy search.`;
|
|
51
|
+
if (v.kind === "repo") {
|
|
52
|
+
const out = [`# Repo structure (from the graph — no grep needed)`];
|
|
53
|
+
if (v.components.length) {
|
|
54
|
+
out.push(`${NL}## Components`);
|
|
55
|
+
for (const c of v.components)
|
|
56
|
+
out.push(`- ${c.name}: ${c.responsibility} (${c.paths.join(", ")})`);
|
|
57
|
+
}
|
|
58
|
+
out.push(`${NL}## Directories (by symbol count)`);
|
|
59
|
+
for (const d of v.dirs.slice(0, 20))
|
|
60
|
+
out.push(`- ${d.dir} — ${d.files} file(s), ${d.symbols} symbol(s)`);
|
|
61
|
+
if (v.dirs.length > 20)
|
|
62
|
+
out.push(` …(+${v.dirs.length - 20} more)`);
|
|
63
|
+
return out.join(NL);
|
|
64
|
+
}
|
|
65
|
+
if (v.kind === "dir") {
|
|
66
|
+
const out = [`# ${v.dir}/ — ${v.files.length} indexed file(s)`];
|
|
67
|
+
for (const f of v.files) {
|
|
68
|
+
const syms = f.symbols.slice(0, 8).map((s) => `${s.name}${s.fan_in ? ` (fan-in ${s.fan_in})` : ""}`).join(", ");
|
|
69
|
+
out.push(`- ${f.file}: ${syms}${f.symbols.length > 8 ? ` …(+${f.symbols.length - 8})` : ""}`);
|
|
70
|
+
}
|
|
71
|
+
return out.join(NL);
|
|
72
|
+
}
|
|
73
|
+
if (v.kind === "file") {
|
|
74
|
+
const out = [`# ${v.file} — outline (${v.symbols.length} symbol(s))`];
|
|
75
|
+
for (const sy of v.symbols) {
|
|
76
|
+
out.push(`- ${sy.name} [${sy.kind}] loc ${sy.loc}, fan-in ${sy.fan_in}, fan-out ${sy.fan_out}`);
|
|
77
|
+
if (sy.callers.length)
|
|
78
|
+
out.push(` called by: ${sy.callers.join("; ")}`);
|
|
79
|
+
}
|
|
80
|
+
return out.join(NL);
|
|
81
|
+
}
|
|
82
|
+
const out = [`# "${v.matches[0]?.name}" — ${v.matches.length} definition site(s)`];
|
|
83
|
+
for (const m of v.matches) {
|
|
84
|
+
out.push(`- ${m.name} [${m.kind}] @ ${m.file} (fan-in ${m.fan_in}, fan-out ${m.fan_out})`);
|
|
85
|
+
if (m.callers.length)
|
|
86
|
+
out.push(` called by: ${m.callers.join("; ")}`);
|
|
87
|
+
if (m.callees.length)
|
|
88
|
+
out.push(` reaches: ${m.callees.join("; ")}`);
|
|
89
|
+
}
|
|
90
|
+
return out.join(NL);
|
|
91
|
+
}
|
|
46
92
|
//# sourceMappingURL=format.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
|