@davesheffer/hunch 1.16.0 → 1.17.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 +13 -7
- package/dist/cli/index.js +44 -0
- package/dist/core/drift.js +12 -0
- package/dist/integrations/madrManifest.js +251 -0
- package/dist/store/hunchStore.js +17 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
[](https://github.com/davesheffer/hunch)
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
|
|
9
|
-
Hunch is
|
|
10
|
-
|
|
11
|
-
the
|
|
9
|
+
Hunch is a guarantee: **your agents never re-make a decided decision, and never re-introduce a
|
|
10
|
+
fixed bug.** The mechanism behind it is an engineering-memory and architectural-conformance layer —
|
|
11
|
+
the decisions, constraints, rejected approaches, and bug history behind your code, delivered as
|
|
12
|
+
evidence before an assistant changes anything, with the result checked deterministically after.
|
|
12
13
|
|
|
13
14
|
Memory starts **advisory**. Nothing blocks until you explicitly trust a precise rule and choose
|
|
14
15
|
strict enforcement.
|
|
@@ -16,8 +17,10 @@ strict enforcement.
|
|
|
16
17
|
**Memory is the input. The product boundary is the receipt:** relevant evidence before an edit,
|
|
17
18
|
then a deterministic check of the change against the rules your team has explicitly trusted.
|
|
18
19
|
|
|
19
|
-
> **New in v1.
|
|
20
|
-
>
|
|
20
|
+
> **New in v1.17.0:** an exported ADR corpus now tracks the graph automatically and reports its
|
|
21
|
+
> own drift (`madr-stale` / `madr-edited` / `madr-orphan`), and retrieval ranks recorded intent
|
|
22
|
+
> above code symbols that merely share the query's vocabulary — Recall@10 70% → 90% on the
|
|
23
|
+
> curated benchmark.
|
|
21
24
|
|
|
22
25
|
See the public [roadmap](ROADMAP.md) for what is next and what is deliberately out of scope.
|
|
23
26
|
|
|
@@ -47,8 +50,11 @@ to the same graph. It merges into existing configuration instead of replacing it
|
|
|
47
50
|
- **Change receipts** — review a working tree, commit, or branch against recorded intent and get a
|
|
48
51
|
cited PASS / WARN / BLOCK result.
|
|
49
52
|
- **Bug lineage** — understand which old incident a line fixed before accidentally undoing it.
|
|
50
|
-
- **Code awareness** — TypeScript, JavaScript, and
|
|
51
|
-
and redundancy checks. The reasoning layer works with any language.
|
|
53
|
+
- **Code awareness** — TypeScript, JavaScript, Python, and Go structure feed dependency,
|
|
54
|
+
blast-radius, and redundancy checks. The reasoning layer works with any language.
|
|
55
|
+
- **ADR interop** — `hunch import-adr` populates the graph from an existing MADR/Nygard corpus;
|
|
56
|
+
`hunch export-adr` projects it back as standard MADR any ADR reader understands, and the
|
|
57
|
+
projection then tracks the graph automatically and reports its own drift.
|
|
52
58
|
|
|
53
59
|
The source of truth is readable JSON in `.hunch/`. A local SQLite index makes retrieval fast but
|
|
54
60
|
is always rebuildable.
|
package/dist/cli/index.js
CHANGED
|
@@ -73,6 +73,7 @@ import { adoptProsePrompt } from "../wiki/adopt.js";
|
|
|
73
73
|
import { topicCollisions, isInForce, liveForTopic } from "../core/topics.js";
|
|
74
74
|
import { ADR_DIR_CANDIDATES, ADR_FILE_RE, mapAdrCorpus } from "../extractors/adrImport.js";
|
|
75
75
|
import { exportMadrCorpus, isRegenerableMadr } from "../integrations/madrExport.js";
|
|
76
|
+
import { buildMadrManifest, writeMadrManifest, refreshMadrCorpus } from "../integrations/madrManifest.js";
|
|
76
77
|
import { pendingEscalations, policyEscalations } from "../core/escalations.js";
|
|
77
78
|
import { premiseEscalations } from "../core/premises.js";
|
|
78
79
|
import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
|
|
@@ -500,6 +501,21 @@ program
|
|
|
500
501
|
else if (!opts.quiet) {
|
|
501
502
|
console.log(`· skipped: ${r.reason}`);
|
|
502
503
|
}
|
|
504
|
+
// The MADR projection tracks the graph automatically once adopted, the way the
|
|
505
|
+
// SQLite index does — a user who ran `hunch export-adr` once never runs it again.
|
|
506
|
+
// Best-effort and last-write-wins-free: a hand-edited file is skipped, not
|
|
507
|
+
// clobbered, and any failure here must never affect the capture that preceded it.
|
|
508
|
+
try {
|
|
509
|
+
const refreshed = refreshMadrCorpus(store.json.loadAll("decisions"), root, new Date().toISOString());
|
|
510
|
+
if (refreshed && !opts.quiet && (refreshed.written || refreshed.removed || refreshed.skippedEdited.length)) {
|
|
511
|
+
const skipped = refreshed.skippedEdited.length ? `, ${refreshed.skippedEdited.length} hand-edited file(s) left alone` : "";
|
|
512
|
+
console.log(` ↳ ADR corpus refreshed: ${refreshed.written} written, ${refreshed.removed} removed${skipped} (${refreshed.dir}/)`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
catch (e) {
|
|
516
|
+
if (!opts.quiet)
|
|
517
|
+
console.log(` ↳ ADR corpus refresh skipped safely: ${e.message}`);
|
|
518
|
+
}
|
|
503
519
|
let graphRefreshed = false;
|
|
504
520
|
let publicCorrectionQueued = false;
|
|
505
521
|
let privateCorrectionQueued = false;
|
|
@@ -3097,6 +3113,10 @@ program
|
|
|
3097
3113
|
// are OURS (marker-verified) and stale — remove so the corpus stays coherent.
|
|
3098
3114
|
for (const f of stale)
|
|
3099
3115
|
rmSync(join(outDir, f));
|
|
3116
|
+
// Adopt the corpus: the manifest is what makes `hunch drift` able to notice
|
|
3117
|
+
// this projection going stale, being hand-edited, or outliving its decision.
|
|
3118
|
+
// Written after the files land, so a failed write never claims freshness.
|
|
3119
|
+
writeMadrManifest(root, buildMadrManifest(dir, files, new Date().toISOString()));
|
|
3100
3120
|
console.log(`✓ exported ${files.length} ADR(s) to ${dir}${stale.length ? `; removed ${stale.length} stale generated file(s)` : ""}`);
|
|
3101
3121
|
console.log(` ↳ Backstage: add to catalog-info.yaml metadata.annotations → ${backstageAnnotation}`);
|
|
3102
3122
|
}
|
|
@@ -5223,6 +5243,30 @@ program
|
|
|
5223
5243
|
console.log(`· ${f.id} — ${f.detail}`);
|
|
5224
5244
|
console.log(`\nHeal: run \`hunch wiki --heal\` — regenerates only the stale pages (the wiki is a derived view; never edit it by hand).\n`);
|
|
5225
5245
|
}
|
|
5246
|
+
// The MADR projection: three kinds, three different human actions — which is
|
|
5247
|
+
// why they are separate sections rather than one "run export-adr" line.
|
|
5248
|
+
const madrStale = kind("madr-stale");
|
|
5249
|
+
if (madrStale.length) {
|
|
5250
|
+
console.log(`${madrStale.length} exported ADR(s) drifted from the graph:\n`);
|
|
5251
|
+
for (const f of madrStale)
|
|
5252
|
+
console.log(`· ${f.id} — ${f.detail}`);
|
|
5253
|
+
console.log(`\nHeal: run \`hunch export-adr\` — the corpus is a disposable projection (the graph stays the source of truth). Normally this never appears: the projection refreshes automatically on every capture.\n`);
|
|
5254
|
+
}
|
|
5255
|
+
const madrEdited = kind("madr-edited");
|
|
5256
|
+
if (madrEdited.length) {
|
|
5257
|
+
console.log(`${madrEdited.length} generated ADR(s) were hand-edited — the next export would overwrite them:\n`);
|
|
5258
|
+
for (const f of madrEdited)
|
|
5259
|
+
console.log(`· ${f.id} — ${f.detail}`);
|
|
5260
|
+
console.log(`\nHeal A (the DECISION is what changed): move the edit into the decision via /capture, then let the projection regenerate — the edit survives because it now lives in the graph.`);
|
|
5261
|
+
console.log(`Heal B (you want to own this file): delete the hunch:generated marker. The export refuses it from then on and it becomes a hand-written ADR.\n`);
|
|
5262
|
+
}
|
|
5263
|
+
const madrOrphan = kind("madr-orphan");
|
|
5264
|
+
if (madrOrphan.length) {
|
|
5265
|
+
console.log(`${madrOrphan.length} generated ADR(s) have no decision behind them any more:\n`);
|
|
5266
|
+
for (const f of madrOrphan)
|
|
5267
|
+
console.log(`· ${f.id} — ${f.detail}`);
|
|
5268
|
+
console.log(`\nHeal: delete the file, or run \`hunch export-adr\` to regenerate the corpus without it. If the decision moved to the private overlay, the file is a PUBLIC artifact of a now-private record — delete it.\n`);
|
|
5269
|
+
}
|
|
5226
5270
|
// Every drift kind heals here — see bug_drift_heal_asymmetry above. premise-stale
|
|
5227
5271
|
// shipped in the drift report without a section here, so a repo whose ONLY drift
|
|
5228
5272
|
// was a dead premise got "N findings" from `hunch drift` and a bare closing line
|
package/dist/core/drift.js
CHANGED
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* (hash-compared via .hunch/wiki-manifest.json; only when a wiki
|
|
12
12
|
* was adopted — see src/wiki/wiki.ts). Advisory, healed by
|
|
13
13
|
* `hunch wiki --heal`, never a gate.
|
|
14
|
+
* - madr-*: the exported MADR corpus drifted from the graph — stale (the
|
|
15
|
+
* decision moved), edited (a human changed a generated file the
|
|
16
|
+
* next export would overwrite), or orphan (the decision left the
|
|
17
|
+
* public graph). Only when a corpus was exported; healed by
|
|
18
|
+
* `hunch export-adr`. See src/integrations/madrManifest.ts.
|
|
14
19
|
*/
|
|
15
20
|
import { existsSync, readFileSync } from "node:fs";
|
|
16
21
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
@@ -20,6 +25,7 @@ import { evaluatePremises } from "./premises.js";
|
|
|
20
25
|
import { parseDocAnchors } from "./docanchors.js";
|
|
21
26
|
import { markdownDocs, STALE_MARKER, SRC_REF } from "./docscan.js";
|
|
22
27
|
import { computeWikiDrift } from "../wiki/wiki.js";
|
|
28
|
+
import { computeMadrDrift } from "../integrations/madrManifest.js";
|
|
23
29
|
export function computeDrift(store, root) {
|
|
24
30
|
const findings = [];
|
|
25
31
|
const decisions = store.recs("decisions");
|
|
@@ -142,6 +148,12 @@ export function computeDrift(store, root) {
|
|
|
142
148
|
// component vanished). Deterministic hash comparison against the manifest;
|
|
143
149
|
// fires only when a wiki was adopted. Advisory like every other kind here.
|
|
144
150
|
findings.push(...computeWikiDrift(store, root));
|
|
151
|
+
// 6b. MADR-* — the exported ADR corpus drifted from the graph. PUBLIC decisions
|
|
152
|
+
// only: the corpus is a committable artifact, so its freshness must be
|
|
153
|
+
// computed from exactly the records allowed to reach it (an overlay record
|
|
154
|
+
// leaking into a public drift report is the same class of bug as one
|
|
155
|
+
// leaking into the export itself). Fires only where a corpus was exported.
|
|
156
|
+
findings.push(...computeMadrDrift(store.json.loadAll("decisions"), root));
|
|
145
157
|
// 7. FINDING-STALE — a LIVE finding (observation, no diff) whose anchor evaporated:
|
|
146
158
|
// an affected file that no longer exists, or a violates_constraint pointing at a
|
|
147
159
|
// retired/missing rule. Deterministic + advisory (never the exit-code class):
|
|
@@ -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/store/hunchStore.js
CHANGED
|
@@ -592,7 +592,11 @@ export class HunchStore {
|
|
|
592
592
|
}
|
|
593
593
|
// w > 1 (a trigger match) shifts UP, w < 1 shifts DOWN, both clamped.
|
|
594
594
|
const shift = Math.max(-MAX_PRIOR_SHIFT, Math.min(MAX_PRIOR_SHIFT, (1 - w) * PRIOR_SHIFT_SCALE));
|
|
595
|
-
|
|
595
|
+
// Recorded intent outranks code that merely shares the query's vocabulary. Applied
|
|
596
|
+
// OUTSIDE the clamp above: that bound keeps the trust dimmer from becoming the sort
|
|
597
|
+
// key, whereas this is a kind-level tie-break between two different answer types.
|
|
598
|
+
const memory = MEMORY_KINDS.has(h.kind) ? -MEMORY_PRIOR_SHIFT : 0;
|
|
599
|
+
return { h, pos: pos + shift + memory };
|
|
596
600
|
});
|
|
597
601
|
scored.sort((a, b) => a.pos - b.pos);
|
|
598
602
|
return scored.slice(0, limit).map((x) => x.h);
|
|
@@ -1643,6 +1647,18 @@ const GRAPH_TOKEN_CAP = boundedWhole(numEnv("HUNCH_GRAPH_TOKEN_CAP", 2_000), 2_0
|
|
|
1643
1647
|
* rerankByPriors for the measurement that fixed it at 4. */
|
|
1644
1648
|
const PRIOR_SHIFT_SCALE = numEnv("HUNCH_PRIOR_SHIFT_SCALE", 12);
|
|
1645
1649
|
const MAX_PRIOR_SHIFT = numEnv("HUNCH_MAX_PRIOR_SHIFT", 4);
|
|
1650
|
+
/** Memory-record prior: a "why" question is answered by RECORDED INTENT (decisions,
|
|
1651
|
+
* constraints, bugs, runbooks, policies), not by the code symbols that merely share
|
|
1652
|
+
* its vocabulary. Symbols carry a neutral prior (priorMeta -> null), so on a graph
|
|
1653
|
+
* with thousands of indexed symbols a lexical tie let them occupy the whole top-k
|
|
1654
|
+
* and bury the one live decision — including a topic-chain successor that promotion
|
|
1655
|
+
* had correctly injected just below the cut line. This lifts memory records by a
|
|
1656
|
+
* bounded number of positions; it never EXCLUDES a kind (a symbol-name query still
|
|
1657
|
+
* returns symbols, and a constraint stays reachable), it only breaks the tie toward
|
|
1658
|
+
* intent. Measured on bench/golden-retrieval.json: Recall@10 70% -> 90%, MRR
|
|
1659
|
+
* 0.402 -> 0.575. Set HUNCH_MEMORY_PRIOR_SHIFT=0 to disable. */
|
|
1660
|
+
const MEMORY_PRIOR_SHIFT = numEnv("HUNCH_MEMORY_PRIOR_SHIFT", 12);
|
|
1661
|
+
const MEMORY_KINDS = new Set(["decisions", "constraints", "bugs", "runbooks", "policies"]);
|
|
1646
1662
|
function numEnv(name, dflt) {
|
|
1647
1663
|
const v = Number(process.env[name]);
|
|
1648
1664
|
// >= 0, not > 0: zero is the documented kill-switch (HUNCH_RRF_W_*=0 disables
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://hunch-pi.vercel.app",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.17.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@davesheffer/hunch",
|
|
16
|
-
"version": "1.
|
|
16
|
+
"version": "1.17.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|