@davesheffer/hunch 1.15.0 → 1.16.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/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,9 @@ 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";
74
76
  import { pendingEscalations, policyEscalations } from "../core/escalations.js";
75
77
  import { premiseEscalations } from "../core/premises.js";
76
78
  import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
@@ -2984,6 +2986,124 @@ program
2984
2986
  console.log(` ↳ promoted constraint ${r.constraint.id} [${r.constraint.severity}]: ${r.constraint.statement}`);
2985
2987
  store.close();
2986
2988
  });
2989
+ // ---- import-adr (MADR/Nygard corpus import — the MADR bridge, import half) --
2990
+ program
2991
+ .command("import-adr")
2992
+ .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).")
2993
+ .argument("[dir]", "ADR directory (default: probe docs/adr, docs/decisions, doc/adr, adr, docs/architecture/decisions)")
2994
+ .option("--dry-run", "parse and report what would be imported without writing")
2995
+ .option("--private", "write imported decisions into the private overlay instead of the committed store")
2996
+ .action((dirArg, opts) => {
2997
+ const { store, root } = storeFor();
2998
+ try {
2999
+ const dir = dirArg
3000
+ ? toPosixTarget(dirArg)
3001
+ : ADR_DIR_CANDIDATES.find((c) => existsSync(join(root, c)) && readdirSync(join(root, c)).some((f) => ADR_FILE_RE.test(f)));
3002
+ if (!dir || !existsSync(join(root, dir))) {
3003
+ return fail(dirArg ? `ADR directory not found: ${dirArg}` : `no ADR corpus found (probed: ${ADR_DIR_CANDIDATES.join(", ")})`);
3004
+ }
3005
+ const files = readdirSync(join(root, dir)).filter((f) => ADR_FILE_RE.test(f)).sort();
3006
+ if (!files.length)
3007
+ return fail(`no NNNN-slug.md ADR files in ${dir}`);
3008
+ const sources = files.map((f) => ({ relPath: `${dir}/${f}`, text: readFileSync(join(root, dir, f), "utf8") }));
3009
+ const { decisions, warnings } = mapAdrCorpus(sources);
3010
+ for (const w of warnings)
3011
+ console.log(` ⚠ ${w}`);
3012
+ // Fail-safe topic anchoring: an import must never create a SECOND live
3013
+ // decision on a topic the graph already anchors — drop the anchor, keep
3014
+ // the record (visible, just not drift-anchored), and say so.
3015
+ const existing = store.recs("decisions").filter((d) => !decisions.some((n) => n.id === d.id));
3016
+ for (const d of decisions) {
3017
+ if (d.status !== "accepted" || !d.topic)
3018
+ continue;
3019
+ if (liveForTopic(existing, d.topic).length) {
3020
+ console.log(` ⚠ topic ${d.topic} already has a live decision in the graph — importing ${d.id} un-anchored`);
3021
+ d.topic = null;
3022
+ }
3023
+ }
3024
+ if (opts.dryRun) {
3025
+ for (const d of decisions) {
3026
+ const window = d.valid_to ? `${d.valid_from ?? "?"} → ${d.valid_to}` : "in force";
3027
+ console.log(` ${d.id} [${d.status}] ${d.title} (${window})`);
3028
+ }
3029
+ console.log(`✓ dry run: ${decisions.length} ADR(s) parsed from ${dir}, nothing written`);
3030
+ return;
3031
+ }
3032
+ store.json.ensureDirs();
3033
+ let created = 0, updated = 0;
3034
+ for (const d of decisions) {
3035
+ if (store.getRec("decisions", d.id))
3036
+ updated++;
3037
+ else
3038
+ created++;
3039
+ store.putCapture("decisions", d, opts.private);
3040
+ }
3041
+ store.reindex();
3042
+ const home = store.captureHome(!!opts.private);
3043
+ if (home === "public" && !store.autoCommit)
3044
+ refreshExistingGrounding(root, store);
3045
+ const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: import ${decisions.length} ADR(s) from ${dir}`);
3046
+ const live = decisions.filter((d) => d.status === "accepted").length;
3047
+ console.log(`✓ imported ${decisions.length} ADR(s) from ${dir} (${created} new, ${updated} updated; ${live} live, ${decisions.length - live} historical)${opts.private ? " [private overlay]" : ""}`);
3048
+ if (flush === "pushed")
3049
+ console.log(" ↳ private memory committed + pushed");
3050
+ }
3051
+ finally {
3052
+ store.close();
3053
+ }
3054
+ });
3055
+ // ---- export-adr (MADR projection — the MADR bridge, export half) -----------
3056
+ program
3057
+ .command("export-adr")
3058
+ .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.")
3059
+ .argument("[dir]", "output directory (repo-relative)", "docs/adr")
3060
+ .option("--dry-run", "render and report without writing")
3061
+ .action((dirArg, opts) => {
3062
+ const { store, root } = storeFor();
3063
+ try {
3064
+ const dir = toPosixTarget(dirArg);
3065
+ // PUBLIC store only — an exported artifact is committable, so overlay
3066
+ // records must never reach it (one-way privacy boundary).
3067
+ const decisions = store.json.loadAll("decisions");
3068
+ if (!decisions.length)
3069
+ return fail("no public decisions to export");
3070
+ const { files, backstageAnnotation } = exportMadrCorpus(decisions, dir);
3071
+ const outDir = join(root, dir);
3072
+ // The projection owns its directory outright: mixing generated output into
3073
+ // a hand-written ADR corpus would duplicate numbering and corrupt every
3074
+ // MADR reader of that dir. Any non-generated ADR-shaped file → refuse whole.
3075
+ const existingGenerated = new Set();
3076
+ if (existsSync(outDir)) {
3077
+ for (const f of readdirSync(outDir)) {
3078
+ if (!f.endsWith(".md"))
3079
+ continue;
3080
+ const text = readFileSync(join(outDir, f), "utf8");
3081
+ if (isRegenerableMadr(text))
3082
+ existingGenerated.add(f);
3083
+ else if (ADR_FILE_RE.test(f)) {
3084
+ 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}\``);
3085
+ }
3086
+ }
3087
+ }
3088
+ const stale = [...existingGenerated].filter((f) => !files.some((n) => n.name === f));
3089
+ if (opts.dryRun) {
3090
+ console.log(`✓ dry run: would write ${files.length} ADR(s) to ${dir}${stale.length ? `, remove ${stale.length} stale generated file(s)` : ""}`);
3091
+ return;
3092
+ }
3093
+ mkdirSync(outDir, { recursive: true });
3094
+ for (const f of files)
3095
+ writeFileSync(join(outDir, f.name), f.text);
3096
+ // A regeneration renumbers; previously generated files not in the new set
3097
+ // are OURS (marker-verified) and stale — remove so the corpus stays coherent.
3098
+ for (const f of stale)
3099
+ rmSync(join(outDir, f));
3100
+ console.log(`✓ exported ${files.length} ADR(s) to ${dir}${stale.length ? `; removed ${stale.length} stale generated file(s)` : ""}`);
3101
+ console.log(` ↳ Backstage: add to catalog-info.yaml metadata.annotations → ${backstageAnnotation}`);
3102
+ }
3103
+ finally {
3104
+ store.close();
3105
+ }
3106
+ });
2987
3107
  // ---- record-constraint (human-authored invariant) -------------------------
2988
3108
  program
2989
3109
  .command("record-constraint")
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.15.0",
3
+ "version": "1.16.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.16.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.16.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {