@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 +13 -7
- package/dist/cli/index.js +166 -2
- package/dist/core/drift.js +12 -0
- package/dist/extractors/adrImport.js +286 -0
- package/dist/integrations/madrExport.js +109 -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
|
@@ -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
|
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,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
|
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
|
{
|