@davesheffer/hunch 1.16.0 → 1.18.0
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 +22 -10
- package/dist/cli/index.js +49 -2
- package/dist/constitution/delta.js +3 -3
- package/dist/constitution/evaluator.js +3 -0
- package/dist/constitution/schema.js +1 -1
- package/dist/core/drift.js +12 -0
- package/dist/core/ids.js +16 -0
- package/dist/core/migrate.js +20 -1
- package/dist/core/types.js +207 -1
- package/dist/extractors/git.js +7 -0
- package/dist/extractors/helm.js +77 -0
- package/dist/extractors/indexer.js +100 -24
- package/dist/extractors/landscapeDiscovery.js +492 -0
- package/dist/extractors/languages.js +36 -1
- package/dist/extractors/nativeTreeSitter.js +17 -8
- package/dist/extractors/parse.js +58 -14
- package/dist/integrations/madrManifest.js +251 -0
- package/dist/mcp/server.js +18 -4
- package/dist/store/hunchStore.js +96 -2
- package/dist/store/jsonStore.js +8 -1
- package/dist/store/schema.js +25 -2
- package/package.json +4 -3
- package/server.json +2 -2
package/dist/extractors/parse.js
CHANGED
|
@@ -15,10 +15,27 @@ function bundleFor(spec) {
|
|
|
15
15
|
return b;
|
|
16
16
|
}
|
|
17
17
|
const STR_QUOTES = /^['"`]|['"`]$/g;
|
|
18
|
+
/** Cap on a stored symbol's bodyText — large enough for review context, small
|
|
19
|
+
* enough that a huge function/file doesn't bloat every JSON symbol record. */
|
|
20
|
+
export const MAX_BODY_TEXT_CHARS = 4000;
|
|
18
21
|
export function parseSource(file, source) {
|
|
19
22
|
const spec = languageFor(file);
|
|
20
23
|
if (!spec)
|
|
21
24
|
return null;
|
|
25
|
+
// Templated text (Helm chart / Jinja CI config) isn't {spec.id} yet — a real
|
|
26
|
+
// grammar correctly reports ERROR nodes for the delimiters. Still run the
|
|
27
|
+
// parse below (a well-formed anchor elsewhere in the file still contributes
|
|
28
|
+
// a real symbol, same as any other YAML file) — just don't let those
|
|
29
|
+
// expected errors fail-close the whole-repo scan on content that was never
|
|
30
|
+
// meant to stand alone (#33). Heavy top-level templating can break error
|
|
31
|
+
// recovery badly enough that even the whole-file root node never forms
|
|
32
|
+
// (root.type becomes "ERROR", not "stream") — the fallback-symbol synthesis
|
|
33
|
+
// below covers that case so the file doesn't vanish from the component graph.
|
|
34
|
+
// String.prototype.search ignores lastIndex (unlike RegExp.test with a /g or
|
|
35
|
+
// /y flag), so a future templatingMarkers entry can't introduce cross-call
|
|
36
|
+
// statefulness here even if it forgets to keep its pattern flag-free.
|
|
37
|
+
const templated = (spec.alwaysTemplatedExtensions?.some((ext) => file.endsWith(ext)) ?? false)
|
|
38
|
+
|| (spec.templatingMarkers?.some((marker) => source.search(marker) !== -1) ?? false);
|
|
22
39
|
const { parser, query } = bundleFor(spec);
|
|
23
40
|
// The native binding caps its scratch buffer at 32 KB unless bufferSize is
|
|
24
41
|
// given — without this, any source >= 32768 bytes throws "Invalid argument"
|
|
@@ -72,17 +89,34 @@ export function parseSource(file, source) {
|
|
|
72
89
|
}
|
|
73
90
|
}
|
|
74
91
|
for (const { kind, def, name } of pendingDefs.values()) {
|
|
75
|
-
|
|
92
|
+
const resolvedName = name ?? spec.fallbackDefName?.(file);
|
|
93
|
+
if (!resolvedName)
|
|
76
94
|
continue;
|
|
77
95
|
const loc = def.endPosition.row - def.startPosition.row + 1;
|
|
78
96
|
symbols.push({
|
|
79
|
-
name, kind,
|
|
97
|
+
name: resolvedName, kind,
|
|
80
98
|
startByte: def.startIndex, endByte: def.endIndex, loc,
|
|
81
|
-
bodyText: def.text.slice(0,
|
|
99
|
+
bodyText: def.text.slice(0, MAX_BODY_TEXT_CHARS),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
// Every other successfully-parsed YAML file gets at least a file-root symbol
|
|
103
|
+
// (fallbackDefName). If templating broke error recovery badly enough that
|
|
104
|
+
// the doc.def capture never fired, synthesize the same fallback here rather
|
|
105
|
+
// than let the file silently drop out of the component graph. Push it before
|
|
106
|
+
// the sort below — parse()'s callers (indexer.ts) rely on symbols staying in
|
|
107
|
+
// start-byte order.
|
|
108
|
+
if (templated && spec.fallbackDefName && !symbols.some((s) => s.kind === "file")) {
|
|
109
|
+
symbols.push({
|
|
110
|
+
name: spec.fallbackDefName(file),
|
|
111
|
+
kind: "file",
|
|
112
|
+
startByte: 0,
|
|
113
|
+
endByte: source.length,
|
|
114
|
+
loc: source.split("\n").length,
|
|
115
|
+
bodyText: source.slice(0, MAX_BODY_TEXT_CHARS),
|
|
82
116
|
});
|
|
83
117
|
}
|
|
84
118
|
symbols.sort((a, b) => a.startByte - b.startByte);
|
|
85
|
-
return { symbols, imports, calls, parseable: isParseable(tree.rootNode, spec) };
|
|
119
|
+
return { symbols, imports, calls, parseable: templated || isParseable(tree.rootNode, spec) };
|
|
86
120
|
}
|
|
87
121
|
/** True when every ERROR/MISSING node in the tree sits in an ancestor shape this
|
|
88
122
|
* language declares as a known grammar limitation (LanguageSpec.toleratedErrorScopes).
|
|
@@ -137,25 +171,35 @@ function ascendToDef(node, defNodeTypes) {
|
|
|
137
171
|
return null;
|
|
138
172
|
}
|
|
139
173
|
/** Map each call site to the innermost symbol whose byte-range contains it.
|
|
140
|
-
* Keyed by the symbol's
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
174
|
+
* Keyed by the symbol's position (index) in `parsed.symbols` — NOT its
|
|
175
|
+
* startByte, which is not a reliable per-symbol identity: a language whose
|
|
176
|
+
* extractor merges a synthetic whole-file symbol with independently-derived
|
|
177
|
+
* symbols (e.g. YAML's fallback-root synthetic symbol alongside Helm's
|
|
178
|
+
* regex-derived `define` blocks) can produce two distinct symbols that both
|
|
179
|
+
* start at byte 0. Indexing by array position is unique by construction,
|
|
180
|
+
* regardless of byte overlap — the caller must consume the exact same
|
|
181
|
+
* `parsed.symbols` array (or an equivalently-ordered copy) to look up a
|
|
182
|
+
* symbol by the index this function returns. The value maps callee name ->
|
|
183
|
+
* `memberOnly` (true iff every occurrence was a `x.foo()` member call, never
|
|
184
|
+
* a direct `foo()`), so the indexer can resolve member calls conservatively. */
|
|
145
185
|
export function attributeCalls(parsed) {
|
|
146
186
|
const out = new Map();
|
|
147
187
|
for (const call of parsed.calls) {
|
|
148
188
|
let best = null;
|
|
149
|
-
|
|
189
|
+
let bestIndex = -1;
|
|
190
|
+
for (let i = 0; i < parsed.symbols.length; i++) {
|
|
191
|
+
const s = parsed.symbols[i];
|
|
150
192
|
if (call.atByte >= s.startByte && call.atByte < s.endByte) {
|
|
151
|
-
if (!best || s.endByte - s.startByte < best.endByte - best.startByte)
|
|
193
|
+
if (!best || s.endByte - s.startByte < best.endByte - best.startByte) {
|
|
152
194
|
best = s;
|
|
195
|
+
bestIndex = i;
|
|
196
|
+
}
|
|
153
197
|
}
|
|
154
198
|
}
|
|
155
199
|
if (best && best.name !== call.callee) {
|
|
156
|
-
if (!out.has(
|
|
157
|
-
out.set(
|
|
158
|
-
const m = out.get(
|
|
200
|
+
if (!out.has(bestIndex))
|
|
201
|
+
out.set(bestIndex, new Map());
|
|
202
|
+
const m = out.get(bestIndex);
|
|
159
203
|
const prev = m.get(call.callee);
|
|
160
204
|
m.set(call.callee, prev === undefined ? call.member : prev && call.member);
|
|
161
205
|
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MADR projection freshness — the manifest and the drift it feeds.
|
|
3
|
+
*
|
|
4
|
+
* The export half (madrExport.ts) renders the graph into a disposable MADR
|
|
5
|
+
* corpus. Nothing, until now, noticed when that corpus went stale: the wiki gets
|
|
6
|
+
* `wiki-stale` when its inputs move, but an exported ADR file could sit in
|
|
7
|
+
* `docs/adr/` confidently wrong forever. This closes that seam with the exact
|
|
8
|
+
* mechanism the wiki already proves out — a content-hash manifest, adopted on
|
|
9
|
+
* first export, silent when absent.
|
|
10
|
+
*
|
|
11
|
+
* Two hashes per file, and they answer different questions:
|
|
12
|
+
* - `hash` — the decision's projected content. Moves when the GRAPH moves,
|
|
13
|
+
* so a mismatch means "the projection is behind the graph".
|
|
14
|
+
* - `bytes` — the file as written. Moves when a HUMAN edits it, so a mismatch
|
|
15
|
+
* means "someone hand-edited a generated file", which the export
|
|
16
|
+
* marker warns against but nothing detected.
|
|
17
|
+
*
|
|
18
|
+
* Three findings, matching the three ways a projection can rot:
|
|
19
|
+
* - madr-stale the decision changed since export (or the file is gone)
|
|
20
|
+
* - madr-edited a generated file was hand-edited; the next export overwrites it
|
|
21
|
+
* - madr-orphan a generated file whose decision no longer exists in the graph
|
|
22
|
+
*
|
|
23
|
+
* All advisory, like every other drift kind: this is a smoke detector, not a
|
|
24
|
+
* robot that rewrites the corpus. `hunch export-adr` is the heal.
|
|
25
|
+
*/
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
30
|
+
import { hunchPaths } from "../core/paths.js";
|
|
31
|
+
import { toPosixTarget } from "../core/paths.js";
|
|
32
|
+
import { exportMadrCorpus, isRegenerableMadr } from "./madrExport.js";
|
|
33
|
+
const sha16 = (s) => createHash("sha256").update(s).digest("hex").slice(0, 16);
|
|
34
|
+
export function madrManifestPath(root) {
|
|
35
|
+
return join(hunchPaths(root).hunch, "madr-manifest.json");
|
|
36
|
+
}
|
|
37
|
+
export function readMadrManifest(root) {
|
|
38
|
+
try {
|
|
39
|
+
const raw = JSON.parse(readFileSync(madrManifestPath(root), "utf8"));
|
|
40
|
+
if (!raw || raw.version !== 1 || typeof raw.dir !== "string" || !raw.files || typeof raw.files !== "object")
|
|
41
|
+
return null;
|
|
42
|
+
// Drop malformed entries rather than crashing every drift-bearing command,
|
|
43
|
+
// the same tolerance readWikiManifestAt applies to a bad merge.
|
|
44
|
+
raw.files = Object.fromEntries(Object.entries(raw.files).filter(([, f]) => f && typeof f === "object" && typeof f.decision === "string" && typeof f.hash === "string"));
|
|
45
|
+
return raw;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function writeMadrManifest(root, manifest) {
|
|
52
|
+
writeFileAtomic(madrManifestPath(root), JSON.stringify(manifest, null, 2) + "\n");
|
|
53
|
+
}
|
|
54
|
+
/** The projected-content hash for one file. Content, not mtime: a re-export that
|
|
55
|
+
* changes nothing must not read as drift. */
|
|
56
|
+
export const madrContentHash = (text) => sha16(text);
|
|
57
|
+
/**
|
|
58
|
+
* Build the manifest for a corpus that was just written.
|
|
59
|
+
*
|
|
60
|
+
* `written` carries the bytes actually placed on disk, which may differ from the
|
|
61
|
+
* rendered text when a file was refused (hand-written corpus in the target dir).
|
|
62
|
+
* Refused files are absent from the manifest, so they are never later reported
|
|
63
|
+
* as edited — they were never ours.
|
|
64
|
+
*/
|
|
65
|
+
export function buildMadrManifest(dir, entries, generatedAt) {
|
|
66
|
+
const files = {};
|
|
67
|
+
for (const e of entries) {
|
|
68
|
+
files[e.name] = {
|
|
69
|
+
decision: e.decisionId,
|
|
70
|
+
hash: madrContentHash(e.text),
|
|
71
|
+
bytes: sha16(e.text),
|
|
72
|
+
generated: generatedAt,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { version: 1, dir: toPosixTarget(dir), files };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Drift for the MADR projection. Fires ONLY where a manifest exists — a repo that
|
|
79
|
+
* never ran `hunch export-adr` sees zero noise, matching the wiki's rule.
|
|
80
|
+
*
|
|
81
|
+
* Takes the PUBLIC decision list, never the overlay union: the projection is a
|
|
82
|
+
* committable artifact, so its freshness must be computed from exactly the
|
|
83
|
+
* records that are allowed to reach it. Passing the union here would leak the
|
|
84
|
+
* existence of overlay decisions into a public drift report.
|
|
85
|
+
*/
|
|
86
|
+
export function computeMadrDrift(publicDecisions, root) {
|
|
87
|
+
const manifest = readMadrManifest(root);
|
|
88
|
+
if (!manifest)
|
|
89
|
+
return []; // never exported → silent
|
|
90
|
+
const findings = [];
|
|
91
|
+
// Re-render from the current graph. Numbering is assigned per export, so a file
|
|
92
|
+
// name is only stable while the decision set is; compare by DECISION id, which
|
|
93
|
+
// is the thing that actually has identity.
|
|
94
|
+
const { files } = exportMadrCorpus(publicDecisions, manifest.dir);
|
|
95
|
+
const currentByDecision = new Map(files.map((f) => [f.decisionId, f]));
|
|
96
|
+
const liveIds = new Set(publicDecisions.map((d) => d.id));
|
|
97
|
+
for (const [name, entry] of Object.entries(manifest.files)) {
|
|
98
|
+
const rel = `${manifest.dir}/${name}`;
|
|
99
|
+
const abs = join(root, manifest.dir, name);
|
|
100
|
+
// 1. ORPHAN — the decision left the public graph (deleted, or moved to the
|
|
101
|
+
// overlay). The file is now a public artifact with no record behind it,
|
|
102
|
+
// which is the shape of a leak as much as of staleness.
|
|
103
|
+
if (!liveIds.has(entry.decision)) {
|
|
104
|
+
findings.push({
|
|
105
|
+
kind: "madr-orphan",
|
|
106
|
+
id: rel,
|
|
107
|
+
detail: `generated ADR has no decision in the public graph (${entry.decision} is gone or moved to the overlay) — delete it, or re-run \`hunch export-adr\``,
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// 2. MISSING — manifested but not on disk.
|
|
112
|
+
if (!existsSync(abs)) {
|
|
113
|
+
findings.push({
|
|
114
|
+
kind: "madr-stale",
|
|
115
|
+
id: rel,
|
|
116
|
+
detail: `generated ADR for ${entry.decision} is missing from ${manifest.dir}/ — regenerate with \`hunch export-adr\``,
|
|
117
|
+
});
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
let onDisk;
|
|
121
|
+
try {
|
|
122
|
+
onDisk = readFileSync(abs, "utf8");
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
continue; // unreadable is an environment problem, not memory drift
|
|
126
|
+
}
|
|
127
|
+
// 3. HAND-EDITED — the bytes moved and the marker is still there, so the next
|
|
128
|
+
// export silently overwrites human work. Report before that happens.
|
|
129
|
+
// A file whose marker was REMOVED is deliberately not ours any more: the
|
|
130
|
+
// export already refuses it, and calling that drift would nag forever.
|
|
131
|
+
if (sha16(onDisk) !== entry.bytes && isRegenerableMadr(onDisk)) {
|
|
132
|
+
findings.push({
|
|
133
|
+
kind: "madr-edited",
|
|
134
|
+
id: rel,
|
|
135
|
+
detail: `generated ADR was hand-edited — \`hunch export-adr\` will overwrite it. Move the change into decision ${entry.decision} (\`/capture\`), or drop the hunch:generated marker to adopt the file`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// 4. STALE — the graph moved underneath the projection.
|
|
139
|
+
const current = currentByDecision.get(entry.decision);
|
|
140
|
+
if (current && madrContentHash(current.text) !== entry.hash) {
|
|
141
|
+
findings.push({
|
|
142
|
+
kind: "madr-stale",
|
|
143
|
+
id: rel,
|
|
144
|
+
detail: `decision ${entry.decision} changed since export — regenerate with \`hunch export-adr\``,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// 5. UNEXPORTED — a public decision with no file at all. Only reported once a
|
|
149
|
+
// corpus exists, so adopting the export does not immediately indict every
|
|
150
|
+
// decision recorded before it.
|
|
151
|
+
const manifested = new Set(Object.values(manifest.files).map((f) => f.decision));
|
|
152
|
+
const missing = publicDecisions.filter((d) => !manifested.has(d.id));
|
|
153
|
+
if (missing.length) {
|
|
154
|
+
findings.push({
|
|
155
|
+
kind: "madr-stale",
|
|
156
|
+
id: manifest.dir,
|
|
157
|
+
detail: `${missing.length} public decision(s) have no ADR in ${manifest.dir}/ (e.g. ${missing[0].id}) — regenerate with \`hunch export-adr\``,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return findings;
|
|
161
|
+
}
|
|
162
|
+
export function refreshMadrCorpus(publicDecisions, root, now) {
|
|
163
|
+
const manifest = readMadrManifest(root);
|
|
164
|
+
if (!manifest)
|
|
165
|
+
return null; // never adopted → stay out of the way
|
|
166
|
+
const { files } = exportMadrCorpus(publicDecisions, manifest.dir);
|
|
167
|
+
const outDir = join(root, manifest.dir);
|
|
168
|
+
if (!existsSync(outDir))
|
|
169
|
+
return null; // corpus deleted wholesale; drift reports it
|
|
170
|
+
const skippedEdited = [];
|
|
171
|
+
const kept = [];
|
|
172
|
+
/** Manifest entries carried through verbatim (hand-edited files we refused to touch). */
|
|
173
|
+
const preserved = new Map();
|
|
174
|
+
let written = 0;
|
|
175
|
+
// Edit detection is keyed by CONTENT, not by file name. Numbering is assigned
|
|
176
|
+
// per export, so adding one decision shifts every later file to a new name —
|
|
177
|
+
// a name-keyed check then finds no prior entry for the shifted name, calls the
|
|
178
|
+
// hand-edited file at that path "stale", and overwrites it (and the removal
|
|
179
|
+
// sweep would delete it under its old name). Bytes we have ever written are
|
|
180
|
+
// exactly the manifest's `bytes` values: an on-disk generated file whose hash
|
|
181
|
+
// is not among them was edited by a human, whatever it is currently called.
|
|
182
|
+
const knownBytes = new Set(Object.values(manifest.files).map((entry) => entry.bytes));
|
|
183
|
+
for (const f of files) {
|
|
184
|
+
const abs = join(outDir, f.name);
|
|
185
|
+
const prior = manifest.files[f.name];
|
|
186
|
+
if (existsSync(abs)) {
|
|
187
|
+
let onDisk;
|
|
188
|
+
try {
|
|
189
|
+
onDisk = readFileSync(abs, "utf8");
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
// Someone else's file: leave it alone (its decision then surfaces as
|
|
195
|
+
// "no ADR" drift rather than being silently unrepresented).
|
|
196
|
+
if (!isRegenerableMadr(onDisk))
|
|
197
|
+
continue;
|
|
198
|
+
if (onDisk === f.text) {
|
|
199
|
+
kept.push(f);
|
|
200
|
+
continue; // already current — no write, no churn
|
|
201
|
+
}
|
|
202
|
+
const bytes = sha16(onDisk);
|
|
203
|
+
const edited = prior ? bytes !== prior.bytes : !knownBytes.has(bytes);
|
|
204
|
+
if (edited) {
|
|
205
|
+
skippedEdited.push(f.name);
|
|
206
|
+
// Carry the PRIOR entry through untouched (when one exists). Rebuilding
|
|
207
|
+
// it from the edited bytes would make the file match its own manifest
|
|
208
|
+
// and the edit would stop being reported — the refresh would quietly
|
|
209
|
+
// launder a hand edit into the record of what we generated.
|
|
210
|
+
if (prior)
|
|
211
|
+
preserved.set(f.name, prior);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
writeFileAtomic(abs, f.text);
|
|
216
|
+
kept.push(f);
|
|
217
|
+
written++;
|
|
218
|
+
}
|
|
219
|
+
// Drop generated files the new numbering no longer produces — but ONLY files
|
|
220
|
+
// whose bytes we wrote. An edited file under its old name is preserved (with
|
|
221
|
+
// its manifest entry, so madr-edited keeps firing) rather than deleted: this
|
|
222
|
+
// sweep was the second way a renumbering could destroy a human's edit.
|
|
223
|
+
let removed = 0;
|
|
224
|
+
const produced = new Set(files.map((f) => f.name));
|
|
225
|
+
for (const name of Object.keys(manifest.files)) {
|
|
226
|
+
if (produced.has(name))
|
|
227
|
+
continue;
|
|
228
|
+
const abs = join(outDir, name);
|
|
229
|
+
if (!existsSync(abs))
|
|
230
|
+
continue;
|
|
231
|
+
try {
|
|
232
|
+
const onDisk = readFileSync(abs, "utf8");
|
|
233
|
+
if (!isRegenerableMadr(onDisk))
|
|
234
|
+
continue;
|
|
235
|
+
if (!knownBytes.has(sha16(onDisk))) {
|
|
236
|
+
skippedEdited.push(name);
|
|
237
|
+
preserved.set(name, manifest.files[name]);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
rmSync(abs);
|
|
241
|
+
removed++;
|
|
242
|
+
}
|
|
243
|
+
catch { /* best effort */ }
|
|
244
|
+
}
|
|
245
|
+
const next = buildMadrManifest(manifest.dir, kept, now);
|
|
246
|
+
for (const [name, entry] of preserved)
|
|
247
|
+
next.files[name] = entry;
|
|
248
|
+
writeMadrManifest(root, next);
|
|
249
|
+
return { dir: manifest.dir, written, removed, skippedEdited };
|
|
250
|
+
}
|
|
251
|
+
//# sourceMappingURL=madrManifest.js.map
|
package/dist/mcp/server.js
CHANGED
|
@@ -73,10 +73,21 @@ const flushNote = (flush, home, mode) => flush === "pushed" ? ` (committed + pus
|
|
|
73
73
|
* lands in the committed store publishes on the next push, and an agent writing
|
|
74
74
|
* strategy/competitive content there is a leak nobody notices until it ships
|
|
75
75
|
* (2026-08-09: 15 roadmap records caught pre-push only by a release sweep). */
|
|
76
|
-
/** Repo-local term list, read once per
|
|
77
|
-
* `.hunch/publication.json` is how a repo opts in (see src/core/publication.ts).
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
/** Repo-local term list, read once per STORE. The package ships none;
|
|
77
|
+
* `.hunch/publication.json` is how a repo opts in (see src/core/publication.ts).
|
|
78
|
+
* Keyed by hunchDir: the previous scalar memoized the FIRST store's vocabulary
|
|
79
|
+
* and served it to every store in the process, so a multi-store consumer would
|
|
80
|
+
* leak-scan project B's records against project A's terms — a wrong answer in
|
|
81
|
+
* the exact subsystem whose job is catching leaks (GATE-P1-UPSTREAM.md). */
|
|
82
|
+
const vocabularyCache = new Map();
|
|
83
|
+
export const publicationVocabulary = (hunchDir) => {
|
|
84
|
+
let vocab = vocabularyCache.get(hunchDir);
|
|
85
|
+
if (!vocab) {
|
|
86
|
+
vocab = loadVocabulary(hunchDir);
|
|
87
|
+
vocabularyCache.set(hunchDir, vocab);
|
|
88
|
+
}
|
|
89
|
+
return vocab;
|
|
90
|
+
};
|
|
80
91
|
const publicHomeNote = (home, hasPrivate, record, hunchDir) => {
|
|
81
92
|
if (home !== "public")
|
|
82
93
|
return "";
|
|
@@ -280,6 +291,9 @@ function prepareRoot(root, explicitOverlay, requireIndex) {
|
|
|
280
291
|
ensureTeamOverlay(root);
|
|
281
292
|
const store = new HunchStore(hunchPaths(root));
|
|
282
293
|
try {
|
|
294
|
+
const overlayWarning = store.overlayResolutionWarning(explicitOverlay && existsSync(teamFile));
|
|
295
|
+
if (overlayWarning)
|
|
296
|
+
console.error(`[hunch-mcp] ⚠ ${overlayWarning}`);
|
|
283
297
|
if (teamAdvertised && (store.mode !== "shared"
|
|
284
298
|
|| !store.privateDir
|
|
285
299
|
|| !existsSync(store.privateDir)
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -55,6 +55,13 @@ export class HunchStore {
|
|
|
55
55
|
/** The resolved private-overlay hunch dir (from env or .hunch/local.json), or undefined
|
|
56
56
|
* when no overlay is configured. Surfaced so `hunch doctor` reflects the true state. */
|
|
57
57
|
privateDir;
|
|
58
|
+
/** How privateDir was selected. Multi-store consumers can use this instead of
|
|
59
|
+
* inferring process-global routing from process.env. */
|
|
60
|
+
overlaySource;
|
|
61
|
+
/** Present only when HUNCH_PRIVATE_DIR redirects this store away from the
|
|
62
|
+
* repo/worktree-local pointer. Precedence is compatibility-sensitive and stays
|
|
63
|
+
* env-first; making the redirection queryable removes the silent footgun. */
|
|
64
|
+
overlayOverride;
|
|
58
65
|
/** Whether captures auto-commit the store they land in — ON by default in EVERY mode;
|
|
59
66
|
* `--no-auto-commit` (hunch init/private/shared) persists `autoCommit: false` in
|
|
60
67
|
* local.json to opt out. Read by the MCP write tools and `hunch sync`. */
|
|
@@ -84,7 +91,36 @@ export class HunchStore {
|
|
|
84
91
|
// local config (.hunch/local.json) so `hunch private` enables it with NO env var, and
|
|
85
92
|
// the MCP server / hook pick it up automatically. Relative paths resolve from root.
|
|
86
93
|
const local = this.localConfig();
|
|
87
|
-
const
|
|
94
|
+
const environmentDir = process.env.HUNCH_PRIVATE_DIR?.trim();
|
|
95
|
+
const configuredDir = local.privateDir
|
|
96
|
+
? resolve(this.paths.root, local.privateDir)
|
|
97
|
+
: undefined;
|
|
98
|
+
const resolvedEnvironmentDir = environmentDir
|
|
99
|
+
? resolve(this.paths.root, environmentDir)
|
|
100
|
+
: undefined;
|
|
101
|
+
const priv = resolvedEnvironmentDir || configuredDir;
|
|
102
|
+
this.overlaySource = resolvedEnvironmentDir
|
|
103
|
+
? "environment"
|
|
104
|
+
: configuredDir
|
|
105
|
+
? "local-config"
|
|
106
|
+
: null;
|
|
107
|
+
if (resolvedEnvironmentDir && configuredDir) {
|
|
108
|
+
const canonical = (path) => {
|
|
109
|
+
try {
|
|
110
|
+
return realpathSync(path);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return resolve(path);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const comparable = (path) => process.platform === "win32" ? canonical(path).toLowerCase() : canonical(path);
|
|
117
|
+
if (comparable(resolvedEnvironmentDir) !== comparable(configuredDir)) {
|
|
118
|
+
this.overlayOverride = {
|
|
119
|
+
configuredDir: canonical(configuredDir),
|
|
120
|
+
environmentDir: canonical(resolvedEnvironmentDir),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
88
124
|
if (priv) {
|
|
89
125
|
const candidate = resolve(this.paths.root, priv);
|
|
90
126
|
const canonical = (path) => { try {
|
|
@@ -118,6 +154,21 @@ export class HunchStore {
|
|
|
118
154
|
this.mode = priv ? (local.mode ?? "private") : "public";
|
|
119
155
|
this.unified = this.mode === "shared" && !!this.privateJson;
|
|
120
156
|
}
|
|
157
|
+
/** Human-facing warning for the compatibility-preserving env-first resolution.
|
|
158
|
+
* Callers decide where it is safe to emit (CLI/MCP stderr, doctor output); the
|
|
159
|
+
* store constructor stays side-effect-free for hooks and embedded consumers. */
|
|
160
|
+
overlayResolutionWarning(teamConfigBypassed = false) {
|
|
161
|
+
if (this.overlayOverride) {
|
|
162
|
+
const effect = this.mode === "shared"
|
|
163
|
+
? "all memory reads and captures in this process use the environment path"
|
|
164
|
+
: "private-overlay reads and private captures in this process use the environment path; public captures remain in the repo";
|
|
165
|
+
return `HUNCH_PRIVATE_DIR redirects this repo from its configured ${this.mode} memory at ${this.overlayOverride.configuredDir} to ${this.overlayOverride.environmentDir}; ${effect} (routing mode remains ${this.mode}). Unset HUNCH_PRIVATE_DIR to use the configured store.`;
|
|
166
|
+
}
|
|
167
|
+
if (this.overlaySource === "environment" && teamConfigBypassed && this.privateDir) {
|
|
168
|
+
return `HUNCH_PRIVATE_DIR bypasses .hunch/team.json and selects ${this.privateDir} in ${this.mode} mode; team-store auto-discovery is disabled for this process. Unset HUNCH_PRIVATE_DIR to use the advertised team store.`;
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
121
172
|
/** Where a capture belongs: an explicit private:true always goes to the overlay
|
|
122
173
|
* (putPrivate throws rather than silently landing public when none is configured);
|
|
123
174
|
* otherwise the overlay in unified ("shared") mode, else the public store. ONE home
|
|
@@ -313,11 +364,35 @@ export class HunchStore {
|
|
|
313
364
|
fts(c.id, "components", c.name, `${c.responsibility} ${c.paths.join(" ")}`);
|
|
314
365
|
}
|
|
315
366
|
counts.components = comps.length;
|
|
367
|
+
const resources = this.recs("resources");
|
|
368
|
+
const insResource = db.prepare(`INSERT INTO resources VALUES (@id,@schema,@kind,@name,@scope,@locator,@lifecycle,@criticality,@contract_version,@currentness,@metadata,@ps,@pc,@pe,@created_at,@updated_at)`);
|
|
369
|
+
for (const resource of resources) {
|
|
370
|
+
insResource.run({
|
|
371
|
+
id: resource.id, schema: resource.schema, kind: resource.kind, name: resource.name,
|
|
372
|
+
scope: JSON.stringify(resource.scope), locator: resource.locator, lifecycle: resource.lifecycle,
|
|
373
|
+
criticality: resource.criticality ?? null, contract_version: resource.contract_version ?? null,
|
|
374
|
+
currentness: JSON.stringify(resource.currentness), metadata: JSON.stringify(resource.metadata),
|
|
375
|
+
ps: resource.provenance.source, pc: resource.provenance.confidence,
|
|
376
|
+
pe: JSON.stringify(resource.provenance.evidence), created_at: resource.created_at, updated_at: resource.updated_at,
|
|
377
|
+
});
|
|
378
|
+
fts(resource.id, "resources", resource.name, `${resource.kind} ${resource.scope.join(" ")} ${resource.locator ?? ""} ${resource.lifecycle} ${resource.contract_version ?? ""}`);
|
|
379
|
+
}
|
|
380
|
+
counts.resources = resources.length;
|
|
316
381
|
const edges = this.recs("edges");
|
|
317
382
|
const insEdge = db.prepare(`INSERT INTO edges VALUES (@id,@from,@to,@type,@reason,@strength,@ps,@pc,@pe)`);
|
|
383
|
+
const insResourceRelationship = db.prepare(`INSERT INTO resource_relationships VALUES (@id,@schema,@from,@to,@type,@reason,@strength,@currentness,@environment,@criticality,@contract_version,@metadata,@ps,@pc,@pe)`);
|
|
318
384
|
for (const e of edges) {
|
|
319
385
|
insEdge.run({ id: e.id, from: e.from, to: e.to, type: e.type, reason: e.reason, strength: e.strength,
|
|
320
386
|
ps: e.provenance.source, pc: e.provenance.confidence, pe: JSON.stringify(e.provenance.evidence) });
|
|
387
|
+
if (e.schema === "hunch.resource-relationship/1") {
|
|
388
|
+
insResourceRelationship.run({
|
|
389
|
+
id: e.id, schema: e.schema, from: e.from, to: e.to, type: e.type, reason: e.reason,
|
|
390
|
+
strength: e.strength, currentness: JSON.stringify(e.currentness), environment: e.environment,
|
|
391
|
+
criticality: e.criticality ?? null, contract_version: e.contract_version ?? null,
|
|
392
|
+
metadata: JSON.stringify(e.metadata), ps: e.provenance.source, pc: e.provenance.confidence,
|
|
393
|
+
pe: JSON.stringify(e.provenance.evidence),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
321
396
|
}
|
|
322
397
|
counts.edges = edges.length;
|
|
323
398
|
const syms = this.recs("symbols");
|
|
@@ -592,7 +667,11 @@ export class HunchStore {
|
|
|
592
667
|
}
|
|
593
668
|
// w > 1 (a trigger match) shifts UP, w < 1 shifts DOWN, both clamped.
|
|
594
669
|
const shift = Math.max(-MAX_PRIOR_SHIFT, Math.min(MAX_PRIOR_SHIFT, (1 - w) * PRIOR_SHIFT_SCALE));
|
|
595
|
-
|
|
670
|
+
// Recorded intent outranks code that merely shares the query's vocabulary. Applied
|
|
671
|
+
// OUTSIDE the clamp above: that bound keeps the trust dimmer from becoming the sort
|
|
672
|
+
// key, whereas this is a kind-level tie-break between two different answer types.
|
|
673
|
+
const memory = MEMORY_KINDS.has(h.kind) ? -MEMORY_PRIOR_SHIFT : 0;
|
|
674
|
+
return { h, pos: pos + shift + memory };
|
|
596
675
|
});
|
|
597
676
|
scored.sort((a, b) => a.pos - b.pos);
|
|
598
677
|
return scored.slice(0, limit).map((x) => x.h);
|
|
@@ -1320,6 +1399,7 @@ export class HunchStore {
|
|
|
1320
1399
|
};
|
|
1321
1400
|
json.put("decisions", closed);
|
|
1322
1401
|
const edge = {
|
|
1402
|
+
schema: "hunch.edge/1",
|
|
1323
1403
|
id: edgeId(by.id, oldId, "supersedes"),
|
|
1324
1404
|
from: by.id,
|
|
1325
1405
|
to: oldId,
|
|
@@ -1327,6 +1407,8 @@ export class HunchStore {
|
|
|
1327
1407
|
reason: `${by.id} supersedes ${oldId}`,
|
|
1328
1408
|
strength: 1,
|
|
1329
1409
|
provenance: { source: "derived", confidence: 1, evidence: [by.id, oldId] },
|
|
1410
|
+
environment: null,
|
|
1411
|
+
metadata: {},
|
|
1330
1412
|
};
|
|
1331
1413
|
json.put("edges", edge);
|
|
1332
1414
|
return closed;
|
|
@@ -1643,6 +1725,18 @@ const GRAPH_TOKEN_CAP = boundedWhole(numEnv("HUNCH_GRAPH_TOKEN_CAP", 2_000), 2_0
|
|
|
1643
1725
|
* rerankByPriors for the measurement that fixed it at 4. */
|
|
1644
1726
|
const PRIOR_SHIFT_SCALE = numEnv("HUNCH_PRIOR_SHIFT_SCALE", 12);
|
|
1645
1727
|
const MAX_PRIOR_SHIFT = numEnv("HUNCH_MAX_PRIOR_SHIFT", 4);
|
|
1728
|
+
/** Memory-record prior: a "why" question is answered by RECORDED INTENT (decisions,
|
|
1729
|
+
* constraints, bugs, runbooks, policies), not by the code symbols that merely share
|
|
1730
|
+
* its vocabulary. Symbols carry a neutral prior (priorMeta -> null), so on a graph
|
|
1731
|
+
* with thousands of indexed symbols a lexical tie let them occupy the whole top-k
|
|
1732
|
+
* and bury the one live decision — including a topic-chain successor that promotion
|
|
1733
|
+
* had correctly injected just below the cut line. This lifts memory records by a
|
|
1734
|
+
* bounded number of positions; it never EXCLUDES a kind (a symbol-name query still
|
|
1735
|
+
* returns symbols, and a constraint stays reachable), it only breaks the tie toward
|
|
1736
|
+
* intent. Measured on bench/golden-retrieval.json: Recall@10 70% -> 90%, MRR
|
|
1737
|
+
* 0.402 -> 0.575. Set HUNCH_MEMORY_PRIOR_SHIFT=0 to disable. */
|
|
1738
|
+
const MEMORY_PRIOR_SHIFT = numEnv("HUNCH_MEMORY_PRIOR_SHIFT", 12);
|
|
1739
|
+
const MEMORY_KINDS = new Set(["decisions", "constraints", "bugs", "runbooks", "policies"]);
|
|
1646
1740
|
function numEnv(name, dflt) {
|
|
1647
1741
|
const v = Number(process.env[name]);
|
|
1648
1742
|
// >= 0, not > 0: zero is the documented kill-switch (HUNCH_RRF_W_*=0 disables
|
package/dist/store/jsonStore.js
CHANGED
|
@@ -12,7 +12,14 @@ import { writeFileAtomic } from "../core/io.js";
|
|
|
12
12
|
* index.json array — there can be thousands, and one file per edge would create
|
|
13
13
|
* enormous git noise. Curated, low-volume entities (components, decisions, bugs,
|
|
14
14
|
* constraints) are one file per record so they're cleanly reviewable in PRs. */
|
|
15
|
-
const SINGLE_FILE = {
|
|
15
|
+
const SINGLE_FILE = {
|
|
16
|
+
symbols: "index.json",
|
|
17
|
+
edges: "index.json",
|
|
18
|
+
// Resource ids remain readable kind-qualified identities (and may contain '/'),
|
|
19
|
+
// so the canonical array avoids lossy filename encoding while keeping Git diffs
|
|
20
|
+
// deterministic through id sorting.
|
|
21
|
+
resources: "index.json",
|
|
22
|
+
};
|
|
16
23
|
const encode = (v) => JSON.stringify(v, null, 2) + "\n";
|
|
17
24
|
// Sleep primitive for the single-file RMW lock's bounded spin (issue #35);
|
|
18
25
|
// same idiom as core/io.ts's rename backoff.
|
package/dist/store/schema.js
CHANGED
|
@@ -29,6 +29,17 @@ CREATE TABLE IF NOT EXISTS components (
|
|
|
29
29
|
created_at TEXT, updated_at TEXT
|
|
30
30
|
);
|
|
31
31
|
|
|
32
|
+
CREATE TABLE IF NOT EXISTS resources (
|
|
33
|
+
id TEXT PRIMARY KEY,
|
|
34
|
+
schema TEXT, kind TEXT, name TEXT, scope TEXT, locator TEXT,
|
|
35
|
+
lifecycle TEXT, criticality TEXT, contract_version TEXT,
|
|
36
|
+
currentness TEXT, metadata TEXT,
|
|
37
|
+
prov_source TEXT, prov_confidence REAL, prov_evidence TEXT,
|
|
38
|
+
created_at TEXT, updated_at TEXT
|
|
39
|
+
);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_resources_kind ON resources(kind);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_resources_lifecycle ON resources(lifecycle);
|
|
42
|
+
|
|
32
43
|
CREATE TABLE IF NOT EXISTS edges (
|
|
33
44
|
id TEXT PRIMARY KEY,
|
|
34
45
|
"from" TEXT, "to" TEXT, type TEXT, reason TEXT, strength REAL,
|
|
@@ -38,6 +49,18 @@ CREATE INDEX IF NOT EXISTS idx_edges_from ON edges("from");
|
|
|
38
49
|
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges("to");
|
|
39
50
|
CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(type);
|
|
40
51
|
|
|
52
|
+
-- A rebuildable projection over the subset of the existing edge graph carrying
|
|
53
|
+
-- the resource-relationship contract. JSON edges remain the one authority.
|
|
54
|
+
CREATE TABLE IF NOT EXISTS resource_relationships (
|
|
55
|
+
id TEXT PRIMARY KEY,
|
|
56
|
+
schema TEXT, "from" TEXT, "to" TEXT, type TEXT, reason TEXT, strength REAL,
|
|
57
|
+
currentness TEXT, environment TEXT, criticality TEXT, contract_version TEXT,
|
|
58
|
+
metadata TEXT, prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
|
|
59
|
+
);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_resource_relationships_from ON resource_relationships("from");
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_resource_relationships_to ON resource_relationships("to");
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_resource_relationships_type ON resource_relationships(type);
|
|
63
|
+
|
|
41
64
|
CREATE TABLE IF NOT EXISTS symbols (
|
|
42
65
|
id TEXT PRIMARY KEY,
|
|
43
66
|
file TEXT, name TEXT, kind TEXT, signature_hash TEXT,
|
|
@@ -89,7 +112,7 @@ CREATE TABLE IF NOT EXISTS embeddings (
|
|
|
89
112
|
export const FTS_SEARCH_SCHEMA_SQL = /* sql */ `
|
|
90
113
|
CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(
|
|
91
114
|
ref UNINDEXED, -- entity id
|
|
92
|
-
kind UNINDEXED, -- components | edges | symbols | decisions | bugs | constraints | runbooks | findings
|
|
115
|
+
kind UNINDEXED, -- components | resources | edges | symbols | decisions | bugs | constraints | runbooks | findings
|
|
93
116
|
title,
|
|
94
117
|
body,
|
|
95
118
|
tokenize = 'porter unicode61'
|
|
@@ -110,7 +133,7 @@ CREATE INDEX IF NOT EXISTS idx_search_kind ON search(kind);
|
|
|
110
133
|
/** Drop derived data (used before a full reindex). NOTE: embeddings is omitted on
|
|
111
134
|
* purpose — see the embeddings table comment above. */
|
|
112
135
|
export const RESET_SQL = /* sql */ `
|
|
113
|
-
DELETE FROM components; DELETE FROM edges; DELETE FROM symbols;
|
|
136
|
+
DELETE FROM components; DELETE FROM resources; DELETE FROM edges; DELETE FROM resource_relationships; DELETE FROM symbols;
|
|
114
137
|
DELETE FROM decisions; DELETE FROM bugs; DELETE FROM constraints;
|
|
115
138
|
DELETE FROM search;
|
|
116
139
|
`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.0",
|
|
4
4
|
"mcpName": "io.github.davesheffer/hunch",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
@@ -71,11 +71,12 @@
|
|
|
71
71
|
"prepublishOnly": "npm run build"
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
74
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
75
|
+
"@tree-sitter-grammars/tree-sitter-yaml": "^0.6.1",
|
|
75
76
|
"commander": "^15.0.0",
|
|
76
77
|
"tree-sitter": "0.21.1",
|
|
77
78
|
"tree-sitter-go": "^0.23.4",
|
|
78
|
-
"tree-sitter-python": "
|
|
79
|
+
"tree-sitter-python": "0.23.4",
|
|
79
80
|
"tree-sitter-typescript": "^0.23.2",
|
|
80
81
|
"zod": "^4.4.3"
|
|
81
82
|
},
|