@davesheffer/hunch 0.1.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/LICENSE +21 -0
- package/README.md +241 -0
- package/dist/cli/index.js +587 -0
- package/dist/cli/invocation.js +23 -0
- package/dist/core/format.js +46 -0
- package/dist/core/glob.js +62 -0
- package/dist/core/ids.js +39 -0
- package/dist/core/io.js +44 -0
- package/dist/core/migrate.js +59 -0
- package/dist/core/paths.js +41 -0
- package/dist/core/types.js +142 -0
- package/dist/extractors/diff.js +198 -0
- package/dist/extractors/git.js +136 -0
- package/dist/extractors/indexer.js +271 -0
- package/dist/extractors/parse.js +176 -0
- package/dist/integrations/claudemd.js +77 -0
- package/dist/integrations/hooks.js +80 -0
- package/dist/integrations/mergeDriver.js +41 -0
- package/dist/integrations/scaffold.js +74 -0
- package/dist/mcp/server.js +233 -0
- package/dist/store/compact.js +100 -0
- package/dist/store/db.js +19 -0
- package/dist/store/embedder.js +133 -0
- package/dist/store/hunchStore.js +469 -0
- package/dist/store/jsonStore.js +268 -0
- package/dist/store/merge.js +179 -0
- package/dist/store/schema.js +100 -0
- package/dist/synthesis/provider.js +488 -0
- package/dist/synthesis/synthesize.js +312 -0
- package/package.json +68 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The JSON source of truth under .hunch/. One file per entity (human-reviewable
|
|
3
|
+
* in PRs, diffable, mergeable). This layer never touches SQLite — it is the
|
|
4
|
+
* authoritative read/write surface; SQLite is rebuilt from it.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdirSync, readdirSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
|
|
9
|
+
import { migrateRaw, readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
|
|
10
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
11
|
+
/** High-cardinality collections (symbols, edges) are stored as a single
|
|
12
|
+
* index.json array — there can be thousands, and one file per edge would create
|
|
13
|
+
* enormous git noise. Curated, low-volume entities (components, decisions, bugs,
|
|
14
|
+
* constraints) are one file per record so they're cleanly reviewable in PRs. */
|
|
15
|
+
const SINGLE_FILE = { symbols: "index.json", edges: "index.json" };
|
|
16
|
+
const encode = (v) => JSON.stringify(v, null, 2) + "\n";
|
|
17
|
+
export class JsonStore {
|
|
18
|
+
paths;
|
|
19
|
+
_warnedForward = false;
|
|
20
|
+
constructor(paths) {
|
|
21
|
+
this.paths = paths;
|
|
22
|
+
}
|
|
23
|
+
/** Create .hunch/<kind>/ directories. Stamp the manifest at the CURRENT version
|
|
24
|
+
* only when scaffolding a FRESH .hunch/ (so `hunch index`/`sync` on a brand-new
|
|
25
|
+
* repo records the version too, not just `init`). A pre-existing .hunch/ without
|
|
26
|
+
* a manifest is LEGACY — left unstamped so it defaults to the baseline and
|
|
27
|
+
* `hunch migrate` upgrades it. */
|
|
28
|
+
ensureDirs() {
|
|
29
|
+
const fresh = !existsSync(this.paths.hunch);
|
|
30
|
+
mkdirSync(this.paths.hunch, { recursive: true });
|
|
31
|
+
for (const kind of ENTITY_KINDS)
|
|
32
|
+
mkdirSync(this.paths.dir(kind), { recursive: true });
|
|
33
|
+
if (fresh && !existsSync(this.paths.manifest))
|
|
34
|
+
writeManifest(this.paths, SCHEMA_VERSION);
|
|
35
|
+
}
|
|
36
|
+
/** The on-disk schema version (from the manifest), read FRESH each call so a
|
|
37
|
+
* long-lived process (the MCP server) reflects an out-of-band `hunch migrate`. */
|
|
38
|
+
schemaVersion() {
|
|
39
|
+
const v = readManifest(this.paths).schema_version;
|
|
40
|
+
if (v > SCHEMA_VERSION && !this._warnedForward) {
|
|
41
|
+
this._warnedForward = true;
|
|
42
|
+
console.warn(`[hunch] .hunch/ was written by a newer schema (v${v} > v${SCHEMA_VERSION}); ` +
|
|
43
|
+
`records may not load correctly — upgrade hunch.`);
|
|
44
|
+
}
|
|
45
|
+
return v;
|
|
46
|
+
}
|
|
47
|
+
/** Migrate one raw record UP to the current schema before validation, so a
|
|
48
|
+
* schema bump never makes the loader silently skip (drop) old records. The
|
|
49
|
+
* on-disk `version` is read ONCE per load (not per record) by the caller. */
|
|
50
|
+
migrate(kind, raw, version) {
|
|
51
|
+
return migrateRaw(kind, raw, version);
|
|
52
|
+
}
|
|
53
|
+
fileFor(kind, id) {
|
|
54
|
+
const single = SINGLE_FILE[kind];
|
|
55
|
+
if (single)
|
|
56
|
+
return join(this.paths.dir(kind), single);
|
|
57
|
+
return join(this.paths.dir(kind), `${id}.json`);
|
|
58
|
+
}
|
|
59
|
+
/** Load every record of a kind, validated against its schema. Invalid records
|
|
60
|
+
* are skipped with a warning rather than crashing the whole load. */
|
|
61
|
+
loadAll(kind) {
|
|
62
|
+
const dir = this.paths.dir(kind);
|
|
63
|
+
if (!existsSync(dir))
|
|
64
|
+
return [];
|
|
65
|
+
const schema = SCHEMAS[kind];
|
|
66
|
+
const out = [];
|
|
67
|
+
const version = this.schemaVersion(); // read the manifest ONCE per load, not per record
|
|
68
|
+
const single = SINGLE_FILE[kind];
|
|
69
|
+
if (single) {
|
|
70
|
+
const f = join(dir, single);
|
|
71
|
+
if (!existsSync(f))
|
|
72
|
+
return [];
|
|
73
|
+
let arr;
|
|
74
|
+
try {
|
|
75
|
+
arr = JSON.parse(readFileSync(f, "utf8"));
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
console.warn(`[hunch] skipping corrupt ${kind}/${single}: ${e.message}`);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
for (const raw of Array.isArray(arr) ? arr : []) {
|
|
82
|
+
const r = schema.safeParse(this.migrate(kind, raw, version));
|
|
83
|
+
if (r.success)
|
|
84
|
+
out.push(r.data);
|
|
85
|
+
else
|
|
86
|
+
console.warn(`[hunch] skipping invalid ${kind} record: ${r.error.issues[0]?.message}`);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
for (const name of readdirSync(dir)) {
|
|
91
|
+
if (!name.endsWith(".json"))
|
|
92
|
+
continue;
|
|
93
|
+
let raw;
|
|
94
|
+
try {
|
|
95
|
+
raw = JSON.parse(readFileSync(join(dir, name), "utf8"));
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
console.warn(`[hunch] skipping corrupt ${kind}/${name}: ${e.message}`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const r = schema.safeParse(this.migrate(kind, raw, version));
|
|
102
|
+
if (r.success)
|
|
103
|
+
out.push(r.data);
|
|
104
|
+
else
|
|
105
|
+
console.warn(`[hunch] skipping invalid ${kind}/${name}: ${r.error.issues[0]?.message}`);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
/** Write a single record (validated) to its JSON file / into the index array. */
|
|
110
|
+
put(kind, record) {
|
|
111
|
+
const schema = SCHEMAS[kind];
|
|
112
|
+
const validated = schema.parse(record);
|
|
113
|
+
mkdirSync(this.paths.dir(kind), { recursive: true });
|
|
114
|
+
const single = SINGLE_FILE[kind];
|
|
115
|
+
if (single) {
|
|
116
|
+
// Operate on the RAW array (NOT the validating loadAll) so updating one
|
|
117
|
+
// record can't silently drop schema-invalid / future-schema siblings — the
|
|
118
|
+
// same reason delete() reads raw. Keep the index sorted by id (stable diff,
|
|
119
|
+
// and agrees with the merge driver so a re-index after a merge is a no-op).
|
|
120
|
+
const f = this.fileFor(kind, validated.id);
|
|
121
|
+
const arr = this.readRawArray(f).filter((r) => r?.id !== validated.id);
|
|
122
|
+
arr.push(validated);
|
|
123
|
+
arr.sort((a, b) => String(a?.id).localeCompare(String(b?.id)));
|
|
124
|
+
writeFileAtomic(f, encode(arr));
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
writeFileAtomic(this.fileFor(kind, validated.id), encode(validated));
|
|
128
|
+
}
|
|
129
|
+
return validated;
|
|
130
|
+
}
|
|
131
|
+
/** Bulk replace all records of a kind (used by the extractor for symbols/edges). */
|
|
132
|
+
replaceAll(kind, records) {
|
|
133
|
+
const schema = SCHEMAS[kind];
|
|
134
|
+
const validated = records.map((r) => schema.parse(r));
|
|
135
|
+
mkdirSync(this.paths.dir(kind), { recursive: true });
|
|
136
|
+
const single = SINGLE_FILE[kind];
|
|
137
|
+
if (single) {
|
|
138
|
+
// Sorted by id so the index has ONE canonical order — re-indexing after a
|
|
139
|
+
// git merge (which the driver also id-sorts) doesn't churn the whole file.
|
|
140
|
+
validated.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
141
|
+
writeFileAtomic(this.fileFor(kind, "index"), encode(validated));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// one file per record: clear stale files, then write
|
|
145
|
+
for (const name of existsSync(this.paths.dir(kind)) ? readdirSync(this.paths.dir(kind)) : []) {
|
|
146
|
+
if (name.endsWith(".json"))
|
|
147
|
+
rmSync(join(this.paths.dir(kind), name));
|
|
148
|
+
}
|
|
149
|
+
for (const r of validated) {
|
|
150
|
+
writeFileAtomic(this.fileFor(kind, r.id), encode(r));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Read a single-file index as a raw array (no validation). Missing/empty → [].
|
|
154
|
+
* A non-empty file that fails to parse THROWS — we must never silently treat a
|
|
155
|
+
* corrupt index as empty and then rewrite it, which would flatten every existing
|
|
156
|
+
* record. (`hunch index` rebuilds from scratch via replaceAll to recover.) */
|
|
157
|
+
readRawArray(f) {
|
|
158
|
+
if (!existsSync(f))
|
|
159
|
+
return [];
|
|
160
|
+
const text = readFileSync(f, "utf8");
|
|
161
|
+
if (!text.trim())
|
|
162
|
+
return [];
|
|
163
|
+
let v;
|
|
164
|
+
try {
|
|
165
|
+
v = JSON.parse(text);
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
throw new Error(`refusing to rewrite ${f}: the existing index is not valid JSON (${e.message}). Fix or remove it, then re-run \`hunch index\`.`);
|
|
169
|
+
}
|
|
170
|
+
return Array.isArray(v) ? v : [];
|
|
171
|
+
}
|
|
172
|
+
get(kind, id) {
|
|
173
|
+
return this.loadAll(kind).find((r) => r.id === id);
|
|
174
|
+
}
|
|
175
|
+
/** Remove a record (used by the curate/reject flow). Returns true if removed.
|
|
176
|
+
* For single-file kinds we operate on the RAW JSON array (not the validating
|
|
177
|
+
* loader) so deleting one record can't silently drop schema-invalid siblings. */
|
|
178
|
+
delete(kind, id) {
|
|
179
|
+
const single = SINGLE_FILE[kind];
|
|
180
|
+
if (single) {
|
|
181
|
+
const f = this.fileFor(kind, "index");
|
|
182
|
+
if (!existsSync(f))
|
|
183
|
+
return false;
|
|
184
|
+
const arr = this.readRawArray(f);
|
|
185
|
+
const next = arr.filter((r) => r?.id !== id);
|
|
186
|
+
if (next.length === arr.length)
|
|
187
|
+
return false;
|
|
188
|
+
writeFileAtomic(f, encode(next));
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
const f = this.fileFor(kind, id);
|
|
192
|
+
if (!existsSync(f))
|
|
193
|
+
return false;
|
|
194
|
+
rmSync(f);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
/** Persist a schema migration: rewrite every LOADABLE record in its current shape.
|
|
198
|
+
* A record that still fails validation after migration is kept untouched (never
|
|
199
|
+
* deleted) and counted as `skipped`, so migration can't lose data. The caller
|
|
200
|
+
* bumps the manifest afterward. */
|
|
201
|
+
persistMigration() {
|
|
202
|
+
let migrated = 0;
|
|
203
|
+
let skipped = 0;
|
|
204
|
+
const version = this.schemaVersion(); // read once; we're migrating FROM this
|
|
205
|
+
for (const kind of ENTITY_KINDS) {
|
|
206
|
+
const dir = this.paths.dir(kind);
|
|
207
|
+
if (!existsSync(dir))
|
|
208
|
+
continue;
|
|
209
|
+
const schema = SCHEMAS[kind];
|
|
210
|
+
const single = SINGLE_FILE[kind];
|
|
211
|
+
if (single) {
|
|
212
|
+
const f = join(dir, single);
|
|
213
|
+
if (!existsSync(f))
|
|
214
|
+
continue;
|
|
215
|
+
let arr;
|
|
216
|
+
try {
|
|
217
|
+
arr = JSON.parse(readFileSync(f, "utf8"));
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
skipped++;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (!Array.isArray(arr)) {
|
|
224
|
+
skipped++;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const kept = [];
|
|
228
|
+
for (const raw of arr) {
|
|
229
|
+
const r = schema.safeParse(this.migrate(kind, raw, version));
|
|
230
|
+
if (r.success) {
|
|
231
|
+
kept.push(r.data);
|
|
232
|
+
migrated++;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
kept.push(raw); // preserve unmigratable records rather than drop them
|
|
236
|
+
skipped++;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
writeFileAtomic(f, encode(kept));
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
for (const name of readdirSync(dir)) {
|
|
243
|
+
if (!name.endsWith(".json"))
|
|
244
|
+
continue;
|
|
245
|
+
const p = join(dir, name);
|
|
246
|
+
let raw;
|
|
247
|
+
try {
|
|
248
|
+
raw = JSON.parse(readFileSync(p, "utf8"));
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
skipped++;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const r = schema.safeParse(this.migrate(kind, raw, version));
|
|
255
|
+
if (r.success) {
|
|
256
|
+
writeFileAtomic(p, encode(r.data));
|
|
257
|
+
migrated++;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
skipped++; // leave the file as-is
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return { migrated, skipped };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
//# sourceMappingURL=jsonStore.js.map
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured three-way merge of `.hunch/` JSON for TEAM workflows.
|
|
3
|
+
*
|
|
4
|
+
* Concurrent branches both writing the Hunch conflict on `git merge`: the
|
|
5
|
+
* single-file symbols/edges index is rewritten wholesale by `hunch index`, and two
|
|
6
|
+
* people can touch the same decision/bug/constraint. A registered git merge driver
|
|
7
|
+
* (`hunch merge-driver`, wired up by `hunch init`) calls mergeHunchJson per
|
|
8
|
+
* conflicted file so records merge BY ID instead of leaving conflict markers.
|
|
9
|
+
*
|
|
10
|
+
* Scope: git only invokes a content merge driver when the SAME path differs on both
|
|
11
|
+
* sides — so this resolves (a) the symbols/edges index ARRAY and (b) edits to the
|
|
12
|
+
* same id in the same per-record file. Per-record ADD/DELETE across branches are
|
|
13
|
+
* distinct files and git handles them at the tree level (the driver isn't called).
|
|
14
|
+
*
|
|
15
|
+
* Resolution for a record changed on BOTH sides: human-confirmed beats auto, then
|
|
16
|
+
* higher provenance.confidence, then the more recently verified, then a deterministic
|
|
17
|
+
* content tiebreak (so both developers' merges converge on the same result). Records
|
|
18
|
+
* are pure data here — no filesystem access; the CLI reads/writes the files.
|
|
19
|
+
*/
|
|
20
|
+
/** Merge three versions of one `.hunch` JSON file (an index array OR a single
|
|
21
|
+
* record object). Returns the merged text, or conflict=true to fall back. */
|
|
22
|
+
export function mergeHunchJson(baseText, oursText, theirsText) {
|
|
23
|
+
const ours = parseSide(oursText);
|
|
24
|
+
const theirs = parseSide(theirsText);
|
|
25
|
+
const base = parseSide(baseText);
|
|
26
|
+
// Can't structurally merge non-JSON or id-less records → let git handle it.
|
|
27
|
+
if (!ours.ok || !theirs.ok)
|
|
28
|
+
return { text: oursText, conflict: true };
|
|
29
|
+
if (!hasIds(ours.records) || !hasIds(theirs.records))
|
|
30
|
+
return { text: oursText, conflict: true };
|
|
31
|
+
const merged = mergeRecordsById(base.ok ? base.records : [], ours.records, theirs.records);
|
|
32
|
+
merged.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
33
|
+
// An index file stays an array; a per-record file stays a single object.
|
|
34
|
+
const asArray = ours.isArray || theirs.isArray;
|
|
35
|
+
if (asArray)
|
|
36
|
+
return { text: serialize(merged), conflict: false };
|
|
37
|
+
if (merged.length === 0)
|
|
38
|
+
return { text: "", conflict: false }; // both deleted the record
|
|
39
|
+
// A single-record file that produced >1 record means a side rewrote the `id`
|
|
40
|
+
// (a logical rename). Don't silently drop one — fall back so git surfaces it.
|
|
41
|
+
if (merged.length > 1)
|
|
42
|
+
return { text: oursText, conflict: true };
|
|
43
|
+
return { text: serialize(merged[0]), conflict: false };
|
|
44
|
+
}
|
|
45
|
+
/** Three-way merge of record arrays keyed by `id`. Additions on either side are
|
|
46
|
+
* kept; a record changed on one side only takes that side; a delete is honored
|
|
47
|
+
* only if the other side left the record unchanged (a modification beats a delete);
|
|
48
|
+
* a both-sides change is resolved by `pickWinner`. */
|
|
49
|
+
export function mergeRecordsById(base, ours, theirs) {
|
|
50
|
+
const b = byId(base);
|
|
51
|
+
const o = byId(ours);
|
|
52
|
+
const t = byId(theirs);
|
|
53
|
+
const ids = new Set([...o.keys(), ...t.keys(), ...b.keys()]);
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const id of ids) {
|
|
56
|
+
const bv = b.get(id);
|
|
57
|
+
const ov = o.get(id);
|
|
58
|
+
const tv = t.get(id);
|
|
59
|
+
if (ov && tv) {
|
|
60
|
+
if (canon(ov) === canon(tv))
|
|
61
|
+
out.push(ov);
|
|
62
|
+
else if (bv && canon(ov) === canon(bv))
|
|
63
|
+
out.push(tv); // only theirs changed
|
|
64
|
+
else if (bv && canon(tv) === canon(bv))
|
|
65
|
+
out.push(ov); // only ours changed
|
|
66
|
+
else
|
|
67
|
+
out.push(pickWinner(ov, tv)); // both changed (or both added differently)
|
|
68
|
+
}
|
|
69
|
+
else if (ov && !tv) {
|
|
70
|
+
// theirs lacks it: a delete (bv present & ours unchanged) loses to a keep/modify
|
|
71
|
+
if (bv && canon(ov) === canon(bv))
|
|
72
|
+
continue; // theirs deleted, ours unchanged → drop
|
|
73
|
+
out.push(ov); // ours added, or ours modified vs a theirs-delete → keep ours
|
|
74
|
+
}
|
|
75
|
+
else if (!ov && tv) {
|
|
76
|
+
if (bv && canon(tv) === canon(bv))
|
|
77
|
+
continue; // ours deleted, theirs unchanged → drop
|
|
78
|
+
out.push(tv);
|
|
79
|
+
}
|
|
80
|
+
// neither side has it → both deleted → drop
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/** Both sides changed the same record: pick the one to keep. */
|
|
85
|
+
export function pickWinner(ours, theirs) {
|
|
86
|
+
const oc = humanConfirmed(ours);
|
|
87
|
+
const tc = humanConfirmed(theirs);
|
|
88
|
+
if (oc !== tc)
|
|
89
|
+
return oc ? ours : theirs; // human-confirmed beats auto
|
|
90
|
+
const od = confidence(ours);
|
|
91
|
+
const td = confidence(theirs);
|
|
92
|
+
if (od !== td)
|
|
93
|
+
return od > td ? ours : theirs;
|
|
94
|
+
const orr = recency(ours);
|
|
95
|
+
const tr = recency(theirs);
|
|
96
|
+
if (orr !== tr)
|
|
97
|
+
return orr > tr ? ours : theirs;
|
|
98
|
+
// Deterministic, side-independent tiebreak so A-merges-B and B-merges-A agree.
|
|
99
|
+
return canon(ours) >= canon(theirs) ? ours : theirs;
|
|
100
|
+
}
|
|
101
|
+
function parseSide(text) {
|
|
102
|
+
const trimmed = (text ?? "").trim();
|
|
103
|
+
if (!trimmed)
|
|
104
|
+
return { ok: true, isArray: false, records: [] }; // empty (e.g. deleted) side
|
|
105
|
+
let v;
|
|
106
|
+
try {
|
|
107
|
+
v = JSON.parse(trimmed);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return { ok: false, isArray: false, records: [] };
|
|
111
|
+
}
|
|
112
|
+
if (Array.isArray(v))
|
|
113
|
+
return { ok: true, isArray: true, records: v.filter(isRec) };
|
|
114
|
+
if (isRec(v))
|
|
115
|
+
return { ok: true, isArray: false, records: [v] };
|
|
116
|
+
return { ok: false, isArray: false, records: [] };
|
|
117
|
+
}
|
|
118
|
+
function isRec(v) {
|
|
119
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
120
|
+
}
|
|
121
|
+
function hasIds(records) {
|
|
122
|
+
return records.every((r) => typeof r.id === "string" && r.id.length > 0);
|
|
123
|
+
}
|
|
124
|
+
function byId(records) {
|
|
125
|
+
const m = new Map();
|
|
126
|
+
for (const r of records)
|
|
127
|
+
if (typeof r.id === "string")
|
|
128
|
+
m.set(r.id, r);
|
|
129
|
+
return m;
|
|
130
|
+
}
|
|
131
|
+
function humanConfirmed(r) {
|
|
132
|
+
return /human_confirmed/.test(provSource(r));
|
|
133
|
+
}
|
|
134
|
+
function provSource(r) {
|
|
135
|
+
const p = r.provenance;
|
|
136
|
+
return isRec(p) && typeof p.source === "string" ? p.source : "";
|
|
137
|
+
}
|
|
138
|
+
function confidence(r) {
|
|
139
|
+
const p = r.provenance;
|
|
140
|
+
return isRec(p) && typeof p.confidence === "number" ? p.confidence : 0;
|
|
141
|
+
}
|
|
142
|
+
/** Most recent timestamp on the record (verified / decided / updated), epoch ms. */
|
|
143
|
+
function recency(r) {
|
|
144
|
+
const p = r.provenance;
|
|
145
|
+
const cands = [
|
|
146
|
+
isRec(p) ? p.last_verified : undefined,
|
|
147
|
+
r.date,
|
|
148
|
+
r.updated_at,
|
|
149
|
+
];
|
|
150
|
+
let best = 0;
|
|
151
|
+
for (const c of cands) {
|
|
152
|
+
if (typeof c === "string") {
|
|
153
|
+
const t = Date.parse(c);
|
|
154
|
+
if (!Number.isNaN(t) && t > best)
|
|
155
|
+
best = t;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return best;
|
|
159
|
+
}
|
|
160
|
+
/** Stable JSON with recursively SORTED keys, so equality/compare ignore key order. */
|
|
161
|
+
export function canon(v) {
|
|
162
|
+
return JSON.stringify(sortKeys(v));
|
|
163
|
+
}
|
|
164
|
+
function sortKeys(v) {
|
|
165
|
+
if (Array.isArray(v))
|
|
166
|
+
return v.map(sortKeys);
|
|
167
|
+
if (v !== null && typeof v === "object") {
|
|
168
|
+
const out = {};
|
|
169
|
+
for (const k of Object.keys(v).sort())
|
|
170
|
+
out[k] = sortKeys(v[k]);
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
return v;
|
|
174
|
+
}
|
|
175
|
+
/** Match the store's on-disk format (2-space indent + trailing newline). */
|
|
176
|
+
function serialize(v) {
|
|
177
|
+
return JSON.stringify(v, null, 2) + "\n";
|
|
178
|
+
}
|
|
179
|
+
//# sourceMappingURL=merge.js.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite schema for the DERIVED index (DESIGN.md §2.1 / §6).
|
|
3
|
+
*
|
|
4
|
+
* The JSON files under .hunch/ are the source of truth; this database is rebuilt
|
|
5
|
+
* from them by `hunch index`. JSON-array/object fields are stored as TEXT (JSON)
|
|
6
|
+
* — we only need them indexed where we query them. Search is a single unified
|
|
7
|
+
* FTS5 table; the graph is plain tables walked with recursive CTEs.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
/** Canonical content hash of the exact title+body that fed both FTS and the
|
|
11
|
+
* embedding for a doc. Stored in `embeddings.doc_hash` so reindex can tell, with
|
|
12
|
+
* NO model loaded, whether a stored vector is stale (its source text changed).
|
|
13
|
+
* The NUL separator keeps the title/body boundary unambiguous. */
|
|
14
|
+
export function embedHash(title, body) {
|
|
15
|
+
return createHash("sha1").update(title).update("\x00").update(body ?? "").digest("hex").slice(0, 16);
|
|
16
|
+
}
|
|
17
|
+
export const SCHEMA_SQL = /* sql */ `
|
|
18
|
+
PRAGMA journal_mode = WAL;
|
|
19
|
+
PRAGMA foreign_keys = OFF;
|
|
20
|
+
|
|
21
|
+
CREATE TABLE IF NOT EXISTS components (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
kind TEXT, name TEXT, responsibility TEXT,
|
|
24
|
+
paths TEXT, status TEXT, owners TEXT,
|
|
25
|
+
fragility REAL,
|
|
26
|
+
prov_source TEXT, prov_confidence REAL, prov_evidence TEXT,
|
|
27
|
+
created_at TEXT, updated_at TEXT
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
31
|
+
id TEXT PRIMARY KEY,
|
|
32
|
+
"from" TEXT, "to" TEXT, type TEXT, reason TEXT, strength REAL,
|
|
33
|
+
prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
|
|
34
|
+
);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges("from");
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges("to");
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(type);
|
|
38
|
+
|
|
39
|
+
CREATE TABLE IF NOT EXISTS symbols (
|
|
40
|
+
id TEXT PRIMARY KEY,
|
|
41
|
+
file TEXT, name TEXT, kind TEXT, signature_hash TEXT,
|
|
42
|
+
calls TEXT, called_by TEXT,
|
|
43
|
+
loc INTEGER, churn_90d INTEGER, bug_count INTEGER, fan_in INTEGER, fan_out INTEGER,
|
|
44
|
+
last_changed TEXT
|
|
45
|
+
);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
|
|
48
|
+
|
|
49
|
+
CREATE TABLE IF NOT EXISTS decisions (
|
|
50
|
+
id TEXT PRIMARY KEY,
|
|
51
|
+
title TEXT, status TEXT, context TEXT, decision TEXT,
|
|
52
|
+
consequences TEXT, alternatives_rejected TEXT,
|
|
53
|
+
related_components TEXT, related_files TEXT,
|
|
54
|
+
supersedes TEXT, caused_by_bug TEXT, "commit" TEXT,
|
|
55
|
+
prov_source TEXT, prov_confidence REAL, prov_evidence TEXT,
|
|
56
|
+
date TEXT
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE IF NOT EXISTS bugs (
|
|
60
|
+
id TEXT PRIMARY KEY,
|
|
61
|
+
title TEXT, symptom TEXT, root_cause TEXT, severity TEXT, status TEXT,
|
|
62
|
+
affected_files TEXT, affected_symbols TEXT, lineage TEXT,
|
|
63
|
+
prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE IF NOT EXISTS constraints (
|
|
67
|
+
id TEXT PRIMARY KEY,
|
|
68
|
+
type TEXT, statement TEXT, scope TEXT, severity TEXT, enforcement TEXT,
|
|
69
|
+
rationale TEXT, source_decision TEXT, violations TEXT,
|
|
70
|
+
prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
-- Unified full-text search across every entity. Rebuilt on index; bm25-ranked.
|
|
74
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(
|
|
75
|
+
ref UNINDEXED, -- entity id
|
|
76
|
+
kind UNINDEXED, -- components | edges | symbols | decisions | bugs | constraints
|
|
77
|
+
title,
|
|
78
|
+
body,
|
|
79
|
+
tokenize = 'porter unicode61'
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
-- Local semantic-search vectors (opt-in; written by \`hunch embed\`). One row per
|
|
83
|
+
-- (ref, model); vec is a Float32 BLOB. DELIBERATELY NOT in RESET_SQL: reindex()
|
|
84
|
+
-- runs RESET on nearly every path (MCP startup, every query/context), so resetting
|
|
85
|
+
-- embeddings here would wipe them constantly and make the feature a no-op. Staleness
|
|
86
|
+
-- is tracked by doc_hash and reconciled by pruneStaleEmbeddings() instead. Recall is
|
|
87
|
+
-- exact brute-force cosine in JS (graphs are small); sqlite-vec only past ~100k rows.
|
|
88
|
+
CREATE TABLE IF NOT EXISTS embeddings (
|
|
89
|
+
ref TEXT, kind TEXT, model TEXT, dim INTEGER, doc_hash TEXT, vec BLOB,
|
|
90
|
+
PRIMARY KEY (ref, model)
|
|
91
|
+
);
|
|
92
|
+
`;
|
|
93
|
+
/** Drop derived data (used before a full reindex). NOTE: embeddings is omitted on
|
|
94
|
+
* purpose — see the embeddings table comment above. */
|
|
95
|
+
export const RESET_SQL = /* sql */ `
|
|
96
|
+
DELETE FROM components; DELETE FROM edges; DELETE FROM symbols;
|
|
97
|
+
DELETE FROM decisions; DELETE FROM bugs; DELETE FROM constraints;
|
|
98
|
+
DELETE FROM search;
|
|
99
|
+
`;
|
|
100
|
+
//# sourceMappingURL=schema.js.map
|