@davesheffer/hunch 1.15.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 CHANGED
@@ -6,9 +6,10 @@
6
6
  [![GitHub stars](https://img.shields.io/github/stars/davesheffer/hunch?color=2742ff&label=%E2%98%85%20star)](https://github.com/davesheffer/hunch)
7
7
  [![license](https://img.shields.io/npm/l/@davesheffer/hunch?color=2742ff)](LICENSE)
8
8
 
9
- Hunch is the engineering-memory and architectural-conformance layer for AI-assisted codebases. It
10
- records the decisions, constraints, rejected approaches, and bug history behind your code, delivers
11
- the relevant evidence before an assistant changes anything, then checks the result deterministically.
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.13.1:** `hunch_context` exposes that provenance-checked, hard-budgeted delivery
20
- > envelope as MCP structured output and records exactly which returned items reached the client.
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 Python structure feed dependency, blast-radius,
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
@@ -14,7 +14,7 @@
14
14
  * doctor environment diagnostics
15
15
  */
16
16
  import "./preflight.js"; // MUST stay the first import — Node-version gate before node:sqlite loads
17
- import { chmodSync, existsSync, lstatSync, readFileSync, readlinkSync, writeFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, rmdirSync, symlinkSync } from "node:fs";
17
+ import { chmodSync, existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, writeFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, rmdirSync, symlinkSync } from "node:fs";
18
18
  import { execFileSync, spawnSync } from "node:child_process";
19
19
  import { join, relative, dirname, basename, resolve, isAbsolute } from "node:path";
20
20
  import { tmpdir } from "node:os";
@@ -70,7 +70,10 @@ import { computeDrift } from "../core/drift.js";
70
70
  import { renderCompilerScorecard, scoreCompilerCaseBank } from "../constitution/scorecard.js";
71
71
  import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt, nowData } from "../wiki/wiki.js";
72
72
  import { adoptProsePrompt } from "../wiki/adopt.js";
73
- import { topicCollisions, isInForce } from "../core/topics.js";
73
+ import { topicCollisions, isInForce, liveForTopic } from "../core/topics.js";
74
+ import { ADR_DIR_CANDIDATES, ADR_FILE_RE, mapAdrCorpus } from "../extractors/adrImport.js";
75
+ import { exportMadrCorpus, isRegenerableMadr } from "../integrations/madrExport.js";
76
+ import { buildMadrManifest, writeMadrManifest, refreshMadrCorpus } from "../integrations/madrManifest.js";
74
77
  import { pendingEscalations, policyEscalations } from "../core/escalations.js";
75
78
  import { premiseEscalations } from "../core/premises.js";
76
79
  import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
@@ -498,6 +501,21 @@ program
498
501
  else if (!opts.quiet) {
499
502
  console.log(`· skipped: ${r.reason}`);
500
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
+ }
501
519
  let graphRefreshed = false;
502
520
  let publicCorrectionQueued = false;
503
521
  let privateCorrectionQueued = false;
@@ -2984,6 +3002,128 @@ program
2984
3002
  console.log(` ↳ promoted constraint ${r.constraint.id} [${r.constraint.severity}]: ${r.constraint.statement}`);
2985
3003
  store.close();
2986
3004
  });
3005
+ // ---- import-adr (MADR/Nygard corpus import — the MADR bridge, import half) --
3006
+ program
3007
+ .command("import-adr")
3008
+ .description("Import an existing MADR/Nygard ADR corpus (docs/adr etc.) into the decision graph — deterministic, no LLM. Re-running updates the same records (ids derive from file paths).")
3009
+ .argument("[dir]", "ADR directory (default: probe docs/adr, docs/decisions, doc/adr, adr, docs/architecture/decisions)")
3010
+ .option("--dry-run", "parse and report what would be imported without writing")
3011
+ .option("--private", "write imported decisions into the private overlay instead of the committed store")
3012
+ .action((dirArg, opts) => {
3013
+ const { store, root } = storeFor();
3014
+ try {
3015
+ const dir = dirArg
3016
+ ? toPosixTarget(dirArg)
3017
+ : ADR_DIR_CANDIDATES.find((c) => existsSync(join(root, c)) && readdirSync(join(root, c)).some((f) => ADR_FILE_RE.test(f)));
3018
+ if (!dir || !existsSync(join(root, dir))) {
3019
+ return fail(dirArg ? `ADR directory not found: ${dirArg}` : `no ADR corpus found (probed: ${ADR_DIR_CANDIDATES.join(", ")})`);
3020
+ }
3021
+ const files = readdirSync(join(root, dir)).filter((f) => ADR_FILE_RE.test(f)).sort();
3022
+ if (!files.length)
3023
+ return fail(`no NNNN-slug.md ADR files in ${dir}`);
3024
+ const sources = files.map((f) => ({ relPath: `${dir}/${f}`, text: readFileSync(join(root, dir, f), "utf8") }));
3025
+ const { decisions, warnings } = mapAdrCorpus(sources);
3026
+ for (const w of warnings)
3027
+ console.log(` ⚠ ${w}`);
3028
+ // Fail-safe topic anchoring: an import must never create a SECOND live
3029
+ // decision on a topic the graph already anchors — drop the anchor, keep
3030
+ // the record (visible, just not drift-anchored), and say so.
3031
+ const existing = store.recs("decisions").filter((d) => !decisions.some((n) => n.id === d.id));
3032
+ for (const d of decisions) {
3033
+ if (d.status !== "accepted" || !d.topic)
3034
+ continue;
3035
+ if (liveForTopic(existing, d.topic).length) {
3036
+ console.log(` ⚠ topic ${d.topic} already has a live decision in the graph — importing ${d.id} un-anchored`);
3037
+ d.topic = null;
3038
+ }
3039
+ }
3040
+ if (opts.dryRun) {
3041
+ for (const d of decisions) {
3042
+ const window = d.valid_to ? `${d.valid_from ?? "?"} → ${d.valid_to}` : "in force";
3043
+ console.log(` ${d.id} [${d.status}] ${d.title} (${window})`);
3044
+ }
3045
+ console.log(`✓ dry run: ${decisions.length} ADR(s) parsed from ${dir}, nothing written`);
3046
+ return;
3047
+ }
3048
+ store.json.ensureDirs();
3049
+ let created = 0, updated = 0;
3050
+ for (const d of decisions) {
3051
+ if (store.getRec("decisions", d.id))
3052
+ updated++;
3053
+ else
3054
+ created++;
3055
+ store.putCapture("decisions", d, opts.private);
3056
+ }
3057
+ store.reindex();
3058
+ const home = store.captureHome(!!opts.private);
3059
+ if (home === "public" && !store.autoCommit)
3060
+ refreshExistingGrounding(root, store);
3061
+ const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: import ${decisions.length} ADR(s) from ${dir}`);
3062
+ const live = decisions.filter((d) => d.status === "accepted").length;
3063
+ console.log(`✓ imported ${decisions.length} ADR(s) from ${dir} (${created} new, ${updated} updated; ${live} live, ${decisions.length - live} historical)${opts.private ? " [private overlay]" : ""}`);
3064
+ if (flush === "pushed")
3065
+ console.log(" ↳ private memory committed + pushed");
3066
+ }
3067
+ finally {
3068
+ store.close();
3069
+ }
3070
+ });
3071
+ // ---- export-adr (MADR projection — the MADR bridge, export half) -----------
3072
+ program
3073
+ .command("export-adr")
3074
+ .description("Project the PUBLIC decision graph as a regenerated MADR 3.x corpus (a disposable build artifact — the graph stays the source of truth). Only files carrying the hunch:generated marker are ever overwritten.")
3075
+ .argument("[dir]", "output directory (repo-relative)", "docs/adr")
3076
+ .option("--dry-run", "render and report without writing")
3077
+ .action((dirArg, opts) => {
3078
+ const { store, root } = storeFor();
3079
+ try {
3080
+ const dir = toPosixTarget(dirArg);
3081
+ // PUBLIC store only — an exported artifact is committable, so overlay
3082
+ // records must never reach it (one-way privacy boundary).
3083
+ const decisions = store.json.loadAll("decisions");
3084
+ if (!decisions.length)
3085
+ return fail("no public decisions to export");
3086
+ const { files, backstageAnnotation } = exportMadrCorpus(decisions, dir);
3087
+ const outDir = join(root, dir);
3088
+ // The projection owns its directory outright: mixing generated output into
3089
+ // a hand-written ADR corpus would duplicate numbering and corrupt every
3090
+ // MADR reader of that dir. Any non-generated ADR-shaped file → refuse whole.
3091
+ const existingGenerated = new Set();
3092
+ if (existsSync(outDir)) {
3093
+ for (const f of readdirSync(outDir)) {
3094
+ if (!f.endsWith(".md"))
3095
+ continue;
3096
+ const text = readFileSync(join(outDir, f), "utf8");
3097
+ if (isRegenerableMadr(text))
3098
+ existingGenerated.add(f);
3099
+ else if (ADR_FILE_RE.test(f)) {
3100
+ return fail(`${dir} holds a hand-written ADR corpus (${f} has no hunch:generated marker) — export to a different directory (e.g. \`hunch export-adr docs/adr-generated\`), or import it first with \`hunch import-adr ${dir}\``);
3101
+ }
3102
+ }
3103
+ }
3104
+ const stale = [...existingGenerated].filter((f) => !files.some((n) => n.name === f));
3105
+ if (opts.dryRun) {
3106
+ console.log(`✓ dry run: would write ${files.length} ADR(s) to ${dir}${stale.length ? `, remove ${stale.length} stale generated file(s)` : ""}`);
3107
+ return;
3108
+ }
3109
+ mkdirSync(outDir, { recursive: true });
3110
+ for (const f of files)
3111
+ writeFileSync(join(outDir, f.name), f.text);
3112
+ // A regeneration renumbers; previously generated files not in the new set
3113
+ // are OURS (marker-verified) and stale — remove so the corpus stays coherent.
3114
+ for (const f of stale)
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()));
3120
+ console.log(`✓ exported ${files.length} ADR(s) to ${dir}${stale.length ? `; removed ${stale.length} stale generated file(s)` : ""}`);
3121
+ console.log(` ↳ Backstage: add to catalog-info.yaml metadata.annotations → ${backstageAnnotation}`);
3122
+ }
3123
+ finally {
3124
+ store.close();
3125
+ }
3126
+ });
2987
3127
  // ---- record-constraint (human-authored invariant) -------------------------
2988
3128
  program
2989
3129
  .command("record-constraint")
@@ -5103,6 +5243,30 @@ program
5103
5243
  console.log(`· ${f.id} — ${f.detail}`);
5104
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`);
5105
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
+ }
5106
5270
  // Every drift kind heals here — see bug_drift_heal_asymmetry above. premise-stale
5107
5271
  // shipped in the drift report without a section here, so a repo whose ONLY drift
5108
5272
  // was a dead premise got "N findings" from `hunch drift` and a bare closing line
@@ -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,286 @@
1
+ /**
2
+ * MADR/Nygard ADR corpus import (roadmap: MADR bridge, import half).
3
+ *
4
+ * Deterministic, no-LLM: parse an existing `docs/adr`-style corpus into Decision
5
+ * records so an ADR-practicing repo gets a populated graph on day 1 instead of
6
+ * waiting for commit backfill. Mapping contract:
7
+ * accepted -> status accepted (live)
8
+ * proposed/draft -> status proposed
9
+ * rejected -> status rejected
10
+ * superseded/deprecated -> status superseded, valid_to closed (bi-temporal)
11
+ * "Considered Options" minus the chosen one -> alternatives_rejected
12
+ * file slug -> topic `adr.<slug>` (namespaced so an import can never
13
+ * collide with a live hand-captured topic; the CLI still
14
+ * drops the anchor entirely if a collision exists)
15
+ * provenance -> source "imported:madr", the ADR file as evidence
16
+ *
17
+ * Ids derive from the ADR's repo-relative path (decisionId("madr:<relPath>")),
18
+ * so re-importing an updated corpus is an idempotent per-record update, never a
19
+ * duplicate. Parsing is pure (string in, records out) — the CLI owns all IO.
20
+ */
21
+ import { decisionId } from "../core/ids.js";
22
+ /** Directories probed (in order) when no explicit dir is given — adr-tools'
23
+ * default (doc/adr), MADR's (docs/decisions), and the common variants. */
24
+ export const ADR_DIR_CANDIDATES = [
25
+ "docs/adr",
26
+ "docs/decisions",
27
+ "doc/adr",
28
+ "adr",
29
+ "docs/architecture/decisions",
30
+ ];
31
+ /** An ADR file is NNNN-slug.md (adr-tools / MADR convention). Templates and
32
+ * indexes (adr-template.md, README.md, index.md) never match. */
33
+ export const ADR_FILE_RE = /^(\d{1,5})-([a-z0-9][a-z0-9._-]*)\.md$/i;
34
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
35
+ function frontmatterField(fm, key) {
36
+ const m = new RegExp(`^${key}:[ \\t]*(.+)$`, "mi").exec(fm);
37
+ if (!m)
38
+ return null;
39
+ return m[1].trim().replace(/^["']|["']$/g, "");
40
+ }
41
+ /** Split a markdown body into `## `-heading sections (heading -> body text).
42
+ * `###` subsections stay inside their parent's body. */
43
+ function sections(body) {
44
+ const out = new Map();
45
+ const lines = body.split(/\r?\n/);
46
+ let current = null;
47
+ let buf = [];
48
+ const flush = () => {
49
+ if (current !== null)
50
+ out.set(current.toLowerCase(), buf.join("\n").trim());
51
+ buf = [];
52
+ };
53
+ for (const line of lines) {
54
+ const h = /^##\s+(.+?)\s*$/.exec(line);
55
+ if (h && !line.startsWith("###")) {
56
+ flush();
57
+ current = h[1];
58
+ }
59
+ else if (current !== null) {
60
+ buf.push(line);
61
+ }
62
+ }
63
+ flush();
64
+ return out;
65
+ }
66
+ function sectionOf(secs, ...names) {
67
+ for (const n of names) {
68
+ const v = secs.get(n.toLowerCase());
69
+ if (v)
70
+ return v;
71
+ }
72
+ return "";
73
+ }
74
+ /** Top-level `- ` / `* ` list items of a section (MADR "Considered Options"). */
75
+ function listItems(sectionText) {
76
+ const items = [];
77
+ for (const line of sectionText.split(/\r?\n/)) {
78
+ const m = /^[-*]\s+(.+)$/.exec(line.trim());
79
+ if (m)
80
+ items.push(stripMdLinks(m[1].trim()));
81
+ }
82
+ return items;
83
+ }
84
+ /** Consequences render either as a list or prose paragraphs; normalize to items. */
85
+ function consequenceItems(sectionText) {
86
+ const items = listItems(sectionText);
87
+ if (items.length)
88
+ return items.map((i) => i.replace(/^(good|bad|neutral),\s*(because\s*)?/i, "").trim()).filter(Boolean);
89
+ const prose = sectionText.trim();
90
+ return prose ? [stripMdLinks(prose)] : [];
91
+ }
92
+ function stripMdLinks(s) {
93
+ return s.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").trim();
94
+ }
95
+ /** ADR cross-reference numbers in a status/frontmatter phrase, e.g.
96
+ * "Superseded by [ADR-0007](0007-x.md)" or "supersedes 3". */
97
+ function refNumbers(text) {
98
+ const out = [];
99
+ for (const m of text.matchAll(/(?:adr[-\s]?)?0*(\d{1,5})\b/gi))
100
+ out.push(Number(m[1]));
101
+ return out;
102
+ }
103
+ function mapStatus(raw) {
104
+ const s = raw.toLowerCase();
105
+ if (/supersed|deprecat/.test(s))
106
+ return "superseded";
107
+ if (/reject/.test(s))
108
+ return "rejected";
109
+ if (/accept|approv/.test(s))
110
+ return "accepted";
111
+ return "proposed";
112
+ }
113
+ /** Parse one ADR markdown file (MADR 3.x/4 frontmatter style or Nygard heading
114
+ * style). Returns null when the filename doesn't follow NNNN-slug.md. */
115
+ export function parseAdrMarkdown(text, relPath) {
116
+ const base = relPath.split("/").pop() ?? relPath;
117
+ const nameMatch = ADR_FILE_RE.exec(base);
118
+ if (!nameMatch)
119
+ return null;
120
+ const number = Number(nameMatch[1]);
121
+ const slug = nameMatch[2].toLowerCase();
122
+ const fmMatch = FRONTMATTER_RE.exec(text);
123
+ const fm = fmMatch ? fmMatch[1] : "";
124
+ const body = fmMatch ? text.slice(fmMatch[0].length) : text;
125
+ const titleMatch = /^#\s+(.+?)\s*$/m.exec(body);
126
+ // Nygard titles carry the number ("1. Record architecture decisions") — strip it.
127
+ const title = (titleMatch ? titleMatch[1] : slug.replace(/[-_]/g, " ")).replace(/^\d+\.\s*/, "").trim();
128
+ const secs = sections(body);
129
+ const statusSection = sectionOf(secs, "Status");
130
+ const statusRaw = frontmatterField(fm, "status") ?? statusSection.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)[0] ?? "";
131
+ const status = mapStatus(statusRaw);
132
+ const date = frontmatterField(fm, "date");
133
+ // Cross-links live in the status phrase(s) and frontmatter supersede fields.
134
+ const statusText = `${statusRaw}\n${statusSection}`;
135
+ const supersededByNumbers = [];
136
+ const supersedesNumbers = [];
137
+ for (const line of statusText.split(/\r?\n/)) {
138
+ if (/superseded\s+by|deprecated\s+by|replaced\s+by/i.test(line))
139
+ supersededByNumbers.push(...refNumbers(line.replace(/.*?(?:by)/i, "")));
140
+ else if (/supersedes|replaces/i.test(line))
141
+ supersedesNumbers.push(...refNumbers(line.replace(/.*?(?:supersedes|replaces)/i, "")));
142
+ }
143
+ for (const key of ["superseded-by", "superseded_by"]) {
144
+ const v = frontmatterField(fm, key);
145
+ if (v)
146
+ supersededByNumbers.push(...refNumbers(v));
147
+ }
148
+ const fmSupersedes = frontmatterField(fm, "supersedes");
149
+ if (fmSupersedes)
150
+ supersedesNumbers.push(...refNumbers(fmSupersedes));
151
+ const decisionOutcome = sectionOf(secs, "Decision Outcome", "Decision");
152
+ const chosenMatch = /chosen option:\s*["“]?([^"”\n,]+)["”]?/i.exec(decisionOutcome);
153
+ const consideredOptions = listItems(sectionOf(secs, "Considered Options", "Options"));
154
+ // "### Consequences" nests inside Decision Outcome in MADR; Nygard has it top-level.
155
+ let consequencesText = sectionOf(secs, "Consequences");
156
+ if (!consequencesText) {
157
+ const nested = /###\s+Consequences\s*\r?\n([\s\S]*?)(?=\r?\n###\s|$)/i.exec(decisionOutcome);
158
+ if (nested)
159
+ consequencesText = nested[1].trim();
160
+ }
161
+ return {
162
+ relPath,
163
+ number,
164
+ slug,
165
+ title,
166
+ statusRaw,
167
+ status,
168
+ date,
169
+ context: stripMdLinks(sectionOf(secs, "Context and Problem Statement", "Context")).trim(),
170
+ decision: stripMdLinks(decisionOutcome.replace(/###\s+Consequences[\s\S]*$/i, "")).trim(),
171
+ consequences: consequenceItems(consequencesText),
172
+ consideredOptions,
173
+ chosenOption: chosenMatch ? chosenMatch[1].trim() : null,
174
+ supersedesNumbers: [...new Set(supersedesNumbers)],
175
+ supersededByNumbers: [...new Set(supersededByNumbers)],
176
+ };
177
+ }
178
+ export function adrDecisionId(relPath) {
179
+ return decisionId(`madr:${relPath}`);
180
+ }
181
+ /** Map a parsed corpus to Decision records. Pure: cross-links resolve by ADR
182
+ * number WITHIN the given corpus only; unresolvable references become warnings,
183
+ * never guessed ids. Bi-temporal closure: a superseded ADR's valid_to is its
184
+ * successor's valid_from (falling back to the successor's date, then its own
185
+ * date) so as-of queries see the corpus's real history. */
186
+ export function mapAdrCorpus(sources) {
187
+ const warnings = [];
188
+ const parsed = [];
189
+ for (const src of sources) {
190
+ const p = parseAdrMarkdown(src.text, src.relPath);
191
+ if (!p) {
192
+ warnings.push(`${src.relPath}: filename does not follow NNNN-slug.md — skipped`);
193
+ continue;
194
+ }
195
+ parsed.push(p);
196
+ }
197
+ const byNumber = new Map();
198
+ for (const p of parsed) {
199
+ if (byNumber.has(p.number))
200
+ warnings.push(`duplicate ADR number ${p.number}: ${byNumber.get(p.number).relPath} and ${p.relPath} — cross-links resolve to the first`);
201
+ else
202
+ byNumber.set(p.number, p);
203
+ }
204
+ // Derive the missing half of each supersede link so a one-sided "Superseded by"
205
+ // still closes windows and sets both pointers.
206
+ for (const p of parsed) {
207
+ for (const n of p.supersedesNumbers) {
208
+ const target = byNumber.get(n);
209
+ if (!target) {
210
+ warnings.push(`${p.relPath}: supersedes ADR ${n}, which is not in the corpus`);
211
+ continue;
212
+ }
213
+ if (!target.supersededByNumbers.includes(p.number))
214
+ target.supersededByNumbers.push(p.number);
215
+ }
216
+ for (const n of p.supersededByNumbers) {
217
+ const successor = byNumber.get(n);
218
+ if (!successor) {
219
+ warnings.push(`${p.relPath}: superseded by ADR ${n}, which is not in the corpus`);
220
+ continue;
221
+ }
222
+ if (!successor.supersedesNumbers.includes(p.number))
223
+ successor.supersedesNumbers.push(p.number);
224
+ }
225
+ }
226
+ const decisions = parsed.map((p) => {
227
+ const successor = p.supersededByNumbers.map((n) => byNumber.get(n)).find(Boolean) ?? null;
228
+ const superseded = p.status === "superseded" || !!successor;
229
+ const validTo = superseded ? (successor?.date ?? p.date) : null;
230
+ const alternatives = p.consideredOptions.filter((o) => !p.chosenOption || o.toLowerCase() !== p.chosenOption.toLowerCase());
231
+ return {
232
+ id: adrDecisionId(p.relPath),
233
+ title: p.title,
234
+ topic: `adr.${p.slug}`,
235
+ status: superseded ? "superseded" : p.status,
236
+ context: p.context,
237
+ decision: p.decision || p.title,
238
+ consequences: p.consequences,
239
+ alternatives_rejected: alternatives,
240
+ rejected_tripwires: [],
241
+ related_components: [],
242
+ related_files: [p.relPath],
243
+ supersedes: p.supersedesNumbers.map((n) => byNumber.get(n)).filter(Boolean).map((t) => adrDecisionId(t.relPath))[0] ?? null,
244
+ superseded_by: successor ? adrDecisionId(successor.relPath) : null,
245
+ caused_by_bug: null,
246
+ commit: null,
247
+ valid_from: p.date ?? undefined,
248
+ valid_to: validTo,
249
+ retired: { symbols: [], deps: [] },
250
+ provenance: {
251
+ source: "imported:madr",
252
+ confidence: 0.75,
253
+ evidence: [p.relPath, `status: ${p.statusRaw || "(none)"}`],
254
+ },
255
+ date: p.date ?? new Date().toISOString(),
256
+ };
257
+ });
258
+ // A corpus must not import two LIVE decisions onto one topic (the store-wide
259
+ // one-live-per-topic invariant): keep the highest ADR number live, close the rest.
260
+ const liveByTopic = new Map();
261
+ for (const d of decisions) {
262
+ if (d.status === "accepted" && d.topic) {
263
+ const list = liveByTopic.get(d.topic) ?? [];
264
+ list.push(d);
265
+ liveByTopic.set(d.topic, list);
266
+ }
267
+ }
268
+ for (const [topic, list] of liveByTopic) {
269
+ if (list.length < 2)
270
+ continue;
271
+ list.sort((a, b) => adrNumberOf(a, parsed) - adrNumberOf(b, parsed));
272
+ const winner = list[list.length - 1];
273
+ for (const loser of list.slice(0, -1)) {
274
+ loser.status = "superseded";
275
+ loser.superseded_by = winner.id;
276
+ loser.valid_to = winner.valid_from ?? loser.date;
277
+ warnings.push(`topic ${topic}: two live ADRs — kept ${winner.id} live, closed ${loser.id}`);
278
+ }
279
+ }
280
+ return { decisions, warnings };
281
+ }
282
+ function adrNumberOf(d, parsed) {
283
+ const rel = d.related_files[0];
284
+ return parsed.find((p) => p.relPath === rel)?.number ?? 0;
285
+ }
286
+ //# sourceMappingURL=adrImport.js.map
@@ -0,0 +1,109 @@
1
+ export const MADR_EXPORT_MARKER = "<!-- hunch:generated madr-export — regenerated by `hunch export-adr`; edits will be overwritten -->";
2
+ function slugify(title) {
3
+ const s = title
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9]+/g, "-")
6
+ .replace(/^-+|-+$/g, "")
7
+ .slice(0, 60)
8
+ .replace(/-+$/, "");
9
+ return s || "decision";
10
+ }
11
+ function statusLine(d, nameOf) {
12
+ if (d.status === "superseded") {
13
+ const successor = d.superseded_by ? nameOf.get(d.superseded_by) : null;
14
+ return successor ? `superseded by [${successor}](${successor})` : "superseded";
15
+ }
16
+ return d.status;
17
+ }
18
+ /** Render one decision as a MADR 3.x document. Deterministic: content depends
19
+ * only on the decision record and the corpus's name map. */
20
+ export function renderMadr(d, nameOf) {
21
+ const lines = [];
22
+ lines.push("---");
23
+ lines.push(`status: ${statusLine(d, nameOf)}`);
24
+ const date = (d.valid_from ?? d.date).slice(0, 10);
25
+ lines.push(`date: ${date}`);
26
+ lines.push("---");
27
+ lines.push("");
28
+ lines.push(MADR_EXPORT_MARKER);
29
+ lines.push("");
30
+ lines.push(`# ${d.title}`);
31
+ if (d.context) {
32
+ lines.push("");
33
+ lines.push("## Context and Problem Statement");
34
+ lines.push("");
35
+ lines.push(d.context);
36
+ }
37
+ if (d.alternatives_rejected.length) {
38
+ lines.push("");
39
+ lines.push("## Considered Options");
40
+ lines.push("");
41
+ lines.push(`- ${d.title}`);
42
+ for (const alt of d.alternatives_rejected)
43
+ lines.push(`- ${alt}`);
44
+ }
45
+ lines.push("");
46
+ lines.push("## Decision Outcome");
47
+ lines.push("");
48
+ // Canonical MADR phrasing so the projection round-trips through import-adr:
49
+ // the "Chosen option:" line is what marks the winner among Considered Options.
50
+ if (d.alternatives_rejected.length) {
51
+ lines.push(`Chosen option: "${d.title}", because of the following.`);
52
+ if (d.decision) {
53
+ lines.push("");
54
+ lines.push(d.decision);
55
+ }
56
+ }
57
+ else {
58
+ lines.push(d.decision || d.title);
59
+ }
60
+ if (d.supersedes && nameOf.get(d.supersedes)) {
61
+ lines.push("");
62
+ lines.push(`Supersedes [${nameOf.get(d.supersedes)}](${nameOf.get(d.supersedes)}).`);
63
+ }
64
+ if (d.consequences.length) {
65
+ lines.push("");
66
+ lines.push("### Consequences");
67
+ lines.push("");
68
+ for (const c of d.consequences)
69
+ lines.push(`- ${c}`);
70
+ }
71
+ lines.push("");
72
+ lines.push(`<!-- source: ${d.id}${d.commit ? ` @ ${d.commit}` : ""} -->`);
73
+ lines.push("");
74
+ return lines.join("\n");
75
+ }
76
+ /** Project a PUBLIC decision list into a MADR corpus. Numbering is date order
77
+ * (valid_from, then date, then id for determinism) and assigned per export —
78
+ * the projection is disposable, links inside it are internally consistent. */
79
+ export function exportMadrCorpus(decisions, dirForAnnotation) {
80
+ const sorted = [...decisions].sort((a, b) => {
81
+ const ka = `${a.valid_from ?? a.valid_to ?? a.date} ${a.valid_to ? 0 : 1} ${a.id}`;
82
+ const kb = `${b.valid_from ?? b.valid_to ?? b.date} ${b.valid_to ? 0 : 1} ${b.id}`;
83
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
84
+ });
85
+ const nameOf = new Map();
86
+ const taken = new Set();
87
+ sorted.forEach((d, i) => {
88
+ let slug = slugify(d.title);
89
+ while (taken.has(slug))
90
+ slug = `${slug}-${d.id.slice(4, 10)}`;
91
+ taken.add(slug);
92
+ nameOf.set(d.id, `${String(i + 1).padStart(4, "0")}-${slug}.md`);
93
+ });
94
+ const files = sorted.map((d) => ({
95
+ name: nameOf.get(d.id),
96
+ text: renderMadr(d, nameOf),
97
+ decisionId: d.id,
98
+ }));
99
+ return {
100
+ files,
101
+ backstageAnnotation: `backstage.io/adr-location: ${dirForAnnotation}`,
102
+ };
103
+ }
104
+ /** True when an existing file may be overwritten by the export: only our own
105
+ * generated output ever qualifies. */
106
+ export function isRegenerableMadr(existingText) {
107
+ return existingText.includes(MADR_EXPORT_MARKER);
108
+ }
109
+ //# sourceMappingURL=madrExport.js.map
@@ -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
@@ -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
- return { h, pos: pos + shift };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
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.15.0",
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.15.0",
16
+ "version": "1.17.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {