@davesheffer/hunch 1.2.3 → 1.3.1

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
@@ -7,30 +7,34 @@
7
7
  [![node](https://img.shields.io/badge/node-%E2%89%A522.13-2742ff)](https://nodejs.org)
8
8
  [![MCP](https://img.shields.io/badge/MCP-native-2742ff)](https://modelcontextprotocol.io)
9
9
 
10
- > **A linter checks whether code matches a *pattern*. Hunch checks whether code still matches your *architecture***
11
- > and blocks the AI change that breaks it, citing the decision and the past bug it would reopen.
12
- > The semantic invariants pattern-SAST can't express (layering, must-reach, dependency direction),
13
- > enforced deterministically over a **git-native** graph of *why* across any MCP assistant.
10
+ > **Your repo remembers *what* changed. Hunch makes it remember *why*** the decisions, the
11
+ > trade-offs, the bugs you already paid for and keeps every AI coding session consistent with them.
12
+ > When a change would quietly undo something you decided on purpose, Hunch notices and shows the
13
+ > receipt: which decision, and which past bug it protects you from. A linter checks *patterns*;
14
+ > Hunch understands your *architecture* (layering, must-reach, dependency direction) — deterministically,
15
+ > from a **git-native** graph, across any MCP assistant.
14
16
 
15
17
  ```bash
16
18
  npm i -g @davesheffer/hunch
17
- cd your-repo && hunch init
19
+ cd your-repo && hunch init # 2 minutes; advisory by default — nothing blocks until you say so
18
20
 
19
- # record an architectural invariant the kind Semgrep/SonarQube structurally can't express
21
+ # teach it one architectural rule, in plain words with the why
20
22
  hunch conform --add "controllers never reach the DB directly — go through the service layer" \
21
- --assert not-calls --subject listOrders --object dbQuery --why "the Mar-2025 N+1 meltdown"
23
+ --assert not-calls --subject listOrders --object dbQuery --why "the Mar-2025 N+1 incident"
22
24
 
23
- hunch conform --strict # ✅/⛔ deterministic gatewire into CI; runs on every AI change
25
+ hunch conform # deterministic checkand when you're ready, --strict wires it into CI
24
26
  ```
25
27
 
26
- > An AI "optimizes" the controller to query the DB directly. **Semgrep: green. SonarQube: green.**
27
- > (it's a legitimate internal import no bad pattern.) **Hunch: ⛔ BLOCKED** — *"listOrders now reaches
28
- > dbQuery VIOLATED · why: the Mar-2025 N+1 meltdown · prevents recurrence of bug_0317."* See
28
+ > Here's the moment it earns its keep: an AI "optimizes" your controller to query the DB directly.
29
+ > Pattern tools stay **green** — it's a legitimate internal import, no bad pattern to match.
30
+ > Hunch quietly flags it: *"listOrders now reaches dbQuery · why this matters: the Mar-2025 N+1
31
+ > incident · protects against a repeat of bug_0317."* You decide what happens next — advisory
32
+ > shows the note, **strict** (opt-in) holds the change. Watch the whole loop in 15 seconds:
29
33
  > [`demo/architectural-conformance.sh`](demo/architectural-conformance.sh).
30
34
 
31
- **It works both ways prevent *and* catch and you need both:**
32
- - **Prevent** — in a reproducible benchmark ([`bench/`](bench/architectural-conformance.md): n=90, Haiku/Sonnet/Opus, 3 invariant classes), the recorded invariant in context cut architectural violations **58% → 16%** overall (Sonnet **67% → 0%**). But prevention is *necessary, not sufficient*: **even Opus ignored a layering rule 60% of the time when told.** Each violation passes a linter clean.
33
- - **Catch** — which is exactly why the deterministic gate exists. `hunch check --strict` (the pre-commit hook + the [`hunch ci`](https://hunch-pi.vercel.app/docs#ci) PR gate) **blocks** what the model ignores with the receipt, **no model in the gate**. Injection helps; the gate is the guarantee.
35
+ **It helps twicebefore the change, and after:**
36
+ - **Before** — the recorded rule rides into the AI's context. In a reproducible benchmark ([`bench/`](bench/architectural-conformance.md): n=90, three models, 3 invariant classes), that alone cut architectural drift **58% → 16%** (one model: **67% → 0%**).
37
+ - **After** — reminders help, but models still drift even when told (the same benchmark measured it). So a deterministic check — [`hunch check`](https://hunch-pi.vercel.app/docs#ci) on commit or in your PR gate catches what reminders miss, always with the receipt, **no model in the loop**. Advisory first; you choose the [firmness](https://hunch-pi.vercel.app/docs#firmness).
34
38
 
35
39
  <sub>Works with **Claude Code, Cursor, Copilot, Windsurf & Google Antigravity** from one shared, git-native graph.</sub>
36
40
 
@@ -44,7 +48,7 @@ guards, troubleshooting. This README is the tour. Jump to:
44
48
  [CLI reference](https://hunch-pi.vercel.app/docs#cli) ·
45
49
  [Troubleshooting](https://hunch-pi.vercel.app/docs#doctor)
46
50
 
47
- > 🆕 **v1.2.2** — the [component wiki + specs ledger](https://hunch-pi.vercel.app/docs#wiki): Hunch takes over your stale docs graded deterministically, adopted into a graph-healed wiki copy, never rewriting your originals with staleness drift-gated in CI. Plus honest auto-commit reporting, a friendly Node < 22.13 gate, and churn-free reindexing. [Full changelog →](https://hunch-pi.vercel.app/changelog)
51
+ > 🆕 **v1.3.0** — retrieval that follows the graph: live decisions outrank superseded ones, human-vouched outrank drafts, and a query that hits history surfaces the topic's *current* decision (Recall@10 90→100% on the committed golden set). Plus `hunch now` + the roadmap-as-proposed-decisions, session-deduped hook grounding (~100× smaller on repeats), content-matched blocking gates, and duplicate auto-drafts skipped before the LLM is called. [Full changelog →](https://hunch-pi.vercel.app/changelog)
48
52
 
49
53
  ## The problem
50
54
 
package/dist/cli/index.js CHANGED
@@ -49,10 +49,13 @@ import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
49
49
  import { formatContext, formatStructure } from "../core/format.js";
50
50
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
51
51
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
52
+ import { injectionMode } from "../core/hookcache.js";
53
+ import { draftDuplicateOf } from "../core/dupdetect.js";
52
54
  import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
53
55
  import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
54
56
  import { computeDrift } from "../core/drift.js";
55
- import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt } from "../wiki/wiki.js";
57
+ import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt, nowData } from "../wiki/wiki.js";
58
+ import { adoptProsePrompt } from "../wiki/adopt.js";
56
59
  import { topicCollisions, renderGrounding } from "../core/topics.js";
57
60
  import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
58
61
  import { compareCandidates } from "../core/compare.js";
@@ -1411,13 +1414,30 @@ program
1411
1414
  .argument("<target>", "file path or symbol")
1412
1415
  .option("--budget <n>", "rough token budget", "1500")
1413
1416
  .option("--as-of <ref>", "time-travel: assemble the slice as it stood at a commit/tag/branch")
1414
- .action((target, opts) => {
1417
+ .action(async (target, opts) => {
1415
1418
  const { store, root } = storeFor();
1416
1419
  const asOf = opts.asOf ? asOfDate(opts.asOf, root) : undefined;
1417
1420
  if (opts.asOf && !asOf)
1418
1421
  return fail(`could not resolve --as-of "${opts.asOf}" to a commit`);
1419
1422
  store.reindex(); // reflect any out-of-band JSON edits before assembling
1420
- process.stdout.write(formatContext(store.assembleContext(target, Number(opts.budget), { asOf })));
1423
+ const ctx = store.assembleContext(target, Number(opts.budget), { asOf });
1424
+ // A task PHRASE ("improve retrieval ranking") resolves no file/symbol target and
1425
+ // used to come back empty while the graph held the answer one FTS query away —
1426
+ // the task-shaped entry point must not whiff on task-shaped input. Fall back to
1427
+ // search so the caller always leaves with the closest graph matches.
1428
+ const empty = !ctx.constraints.length && !ctx.decisions.length && !ctx.bugs.length && !ctx.blast_radius.length;
1429
+ if (empty && !asOf) {
1430
+ const hits = store.search(target, 8);
1431
+ if (hits.length) {
1432
+ console.log(`No file/symbol resolves for "${target}" — closest graph matches instead:\n`);
1433
+ for (const h of hits)
1434
+ console.log(`• ${h.ref} — ${h.title}\n ${h.snippet}`);
1435
+ console.log(`\n(For a file/symbol brief use a concrete target; for free-text this is what \`hunch query\` returns.)`);
1436
+ store.close();
1437
+ return;
1438
+ }
1439
+ }
1440
+ process.stdout.write(formatContext(ctx));
1421
1441
  store.close();
1422
1442
  });
1423
1443
  // ---- timeline -------------------------------------------------------------
@@ -1541,9 +1561,46 @@ program
1541
1561
  // nudge the agent to PERSIST it as an enforced constraint (Never Twice) —
1542
1562
  // not just obey it this once and forget it next session.
1543
1563
  const text = looksLikeCorrection(evt.prompt) ? `${HOOK_REMINDER}\n\n${CORRECTION_NUDGE}` : HOOK_REMINDER;
1564
+ // Once per session is enough for the availability reminder — repeating it
1565
+ // every prompt burns context for zero information. A correction nudge has
1566
+ // different content, so it always comes through (dec_244397d920).
1567
+ if (injectionMode(evt.session_id, "prompt-reminder", text) === "delta")
1568
+ return;
1544
1569
  emitContext("UserPromptSubmit", text);
1545
1570
  return;
1546
1571
  }
1572
+ if (evt.hook_event_name === "SessionStart") {
1573
+ // Orientation at the moment it matters: what just happened + what's next,
1574
+ // straight from the graph — the agent sits down already knowing where it
1575
+ // is instead of pulling (or worse, grepping) for it. Cheap reads only
1576
+ // (no reindex, no drift walk); public store only — session transcripts
1577
+ // travel further than a terminal. Union view: `hunch now --private`.
1578
+ const s = new HunchStore(paths);
1579
+ try {
1580
+ const decisions = s.json.loadAll("decisions");
1581
+ const { recent, roadmap, pendingReview } = nowData(decisions, 3);
1582
+ if (!decisions.length)
1583
+ return;
1584
+ const L = [];
1585
+ L.push(`🧠 Hunch orientation — ${decisions.length} decision(s) in the graph.`);
1586
+ if (recent.length) {
1587
+ L.push("Recent:");
1588
+ for (const r of recent)
1589
+ L.push(` ${r.date} [${r.status}] ${r.title} (${r.id})`);
1590
+ }
1591
+ if (roadmap.length) {
1592
+ L.push(`Roadmap (${roadmap.length} live proposed): ${roadmap.slice(0, 3).map((r) => r.title).join(" · ")}${roadmap.length > 3 ? " · …" : ""}`);
1593
+ }
1594
+ if (pendingReview > 0)
1595
+ L.push(`${pendingReview} auto-draft(s) awaiting \`hunch review\`.`);
1596
+ L.push("Orient further: hunch_context(task) · hunch_structure() · `hunch now`.");
1597
+ emitContext("SessionStart", L.join("\n"));
1598
+ }
1599
+ finally {
1600
+ s.close();
1601
+ }
1602
+ return;
1603
+ }
1547
1604
  if (evt.hook_event_name !== "PreToolUse")
1548
1605
  return;
1549
1606
  const abs = evt.tool_input?.file_path;
@@ -1616,6 +1673,13 @@ program
1616
1673
  text += `\n\n${grounding}`;
1617
1674
  if (docGround)
1618
1675
  text += `\n\n${docGround}`;
1676
+ // Identical grounding already shown this session → one-line delta instead of
1677
+ // the full 10-16KB block. Any record change re-sends the full text; the
1678
+ // strict-gate deny path above never routes through this (dec_244397d920).
1679
+ if (injectionMode(evt.session_id, `pre:${target}`, text) === "delta") {
1680
+ emitContext("PreToolUse", `Hunch grounding for ${target}: unchanged this session (${ctx.decisions.length} decision(s), ${ctx.constraints.length} invariant(s) shown earlier — still current; hunch_why("${target}") to re-expand).`);
1681
+ return;
1682
+ }
1619
1683
  emitContext("PreToolUse", text);
1620
1684
  }
1621
1685
  catch {
@@ -1663,6 +1727,7 @@ program
1663
1727
  .option("--accept <id>", "promote a decision to accepted/human-confirmed (confirms its tripwires)")
1664
1728
  .option("--reject <id>", "delete a draft decision")
1665
1729
  .option("--accept-verified", "batch-accept every Critic-verified, well-grounded draft (>= --min-grounded)")
1730
+ .option("--reject-duplicates", "batch-reject drafts that near-duplicate an accepted record (deterministic term+file similarity — hygiene, not judgment)")
1666
1731
  .option("--min-grounded <n>", "grounded-ness threshold for the ready group / --accept-verified", String(READY_MIN_GROUNDED))
1667
1732
  .action((opts) => {
1668
1733
  const { store, root } = storeFor();
@@ -1683,6 +1748,28 @@ program
1683
1748
  store.reindex();
1684
1749
  console.log(ok2 ? `✓ rejected and removed ${opts.reject}` : `decision ${opts.reject} not found`);
1685
1750
  }
1751
+ else if (opts.rejectDuplicates) {
1752
+ // Deterministic hygiene, not a trust decision (dec_a466655539 stays intact):
1753
+ // only drafts, only against ACCEPTED records, conservative threshold.
1754
+ const all = store.json.loadAll("decisions");
1755
+ const drafts = all.filter((d) => d.status === "proposed" && !d.provenance.source.includes("human_confirmed"));
1756
+ const dupes = drafts
1757
+ .map((d) => ({ d, m: draftDuplicateOf(d, all) }))
1758
+ .filter((x) => !!x.m);
1759
+ if (!dupes.length) {
1760
+ console.log("✓ No near-duplicate drafts.");
1761
+ }
1762
+ else {
1763
+ let removed = 0;
1764
+ for (const { d, m } of dupes) {
1765
+ if (store.json.delete("decisions", d.id))
1766
+ removed++;
1767
+ console.log(` ✗ ${d.id} — "${d.title}"\n duplicate of ${m.of.id} — "${m.of.title}" (${Math.round(m.score * 100)}%)`);
1768
+ }
1769
+ store.reindex();
1770
+ console.log(`\n✓ Rejected ${removed} duplicate draft(s). Accepted records untouched.`);
1771
+ }
1772
+ }
1686
1773
  else if (opts.acceptVerified) {
1687
1774
  // Batch path: only Critic-verified, well-grounded drafts qualify — still the
1688
1775
  // human-driven accept gate (the operator runs this), just over a safe subset.
@@ -1717,8 +1804,18 @@ program
1717
1804
  }
1718
1805
  if (scrutiny.length) {
1719
1806
  console.log(`⚠ ${scrutiny.length} need scrutiny — unverified / low-grounded (lowest confidence first):\n`);
1720
- for (const it of scrutiny)
1807
+ const all = store.json.loadAll("decisions");
1808
+ let dupCount = 0;
1809
+ for (const it of scrutiny) {
1721
1810
  printReviewItem(it);
1811
+ const m = draftDuplicateOf(it.d, all);
1812
+ if (m) {
1813
+ dupCount++;
1814
+ console.log(` ⚠ likely DUPLICATE of ${m.of.id} — "${m.of.title}" (${Math.round(m.score * 100)}%)`);
1815
+ }
1816
+ }
1817
+ if (dupCount)
1818
+ console.log(`\n Batch-reject the ${dupCount} duplicate(s): hunch review --reject-duplicates`);
1722
1819
  }
1723
1820
  console.log(`\nAccept: hunch review --accept <id> Reject: hunch review --reject <id>`);
1724
1821
  }
@@ -1903,6 +2000,7 @@ program
1903
2000
  .option("--heal", "regenerate only new/stale pages (manifest hash mismatch) and remove orphans")
1904
2001
  .option("--check", "report stale pages and exit non-zero (CI gate); writes nothing")
1905
2002
  .option("--no-llm", "skip LLM prose; deterministic template pages only")
2003
+ .option("--prose-heal", "also LLM-rewrite each adopted copy's reconciled overview (subscription; the deterministic corrections always remain)")
1906
2004
  .option("--private", "render the FULL graph (private overlay included) and write the wiki into the OVERLAY repo — nothing lands in this repo")
1907
2005
  .action(async (opts) => {
1908
2006
  const { store, root } = storeFor();
@@ -1965,21 +2063,27 @@ program
1965
2063
  // Prose is optional garnish on the deterministic skeleton: subscription CLI
1966
2064
  // only (same rule as synthesis), feature-detected, and any failure degrades
1967
2065
  // to a template page — generation never depends on a model being present.
2066
+ if (opts.proseHeal && opts.llm === false)
2067
+ return fail("--prose-heal needs the LLM — drop --no-llm.");
1968
2068
  let prose;
2069
+ let adoptionProse;
1969
2070
  if (opts.llm !== false) {
1970
2071
  const provider = await selectProvider();
1971
2072
  if (provider.draftProse) {
1972
2073
  console.log(`Prose via ${provider.name} (subscription); the drift-bearing skeleton stays deterministic.`);
1973
2074
  prose = (pack, excerpts) => provider.draftProse(wikiPrompt(pack, excerpts));
2075
+ if (opts.proseHeal)
2076
+ adoptionProse = (doc, content) => provider.draftProse(adoptProsePrompt(doc, content, status.decisions));
1974
2077
  }
1975
2078
  else {
1976
- console.log("No subscription CLI available — deterministic template pages.");
2079
+ console.log(`No subscription CLI available — deterministic template pages${opts.proseHeal ? " (prose-heal skipped)" : ""}.`);
1977
2080
  }
1978
2081
  }
1979
2082
  const res = await generateWiki(store, root, home, {
1980
2083
  now: new Date().toISOString(),
1981
2084
  only: opts.heal ? "stale" : "all",
1982
2085
  prose,
2086
+ adoptionProse,
1983
2087
  log: (l) => console.log(l),
1984
2088
  });
1985
2089
  if (!res.written.length && !res.removed.length) {
@@ -1997,6 +2101,34 @@ program
1997
2101
  store.close();
1998
2102
  }
1999
2103
  });
2104
+ // ---- now (the hot view: recent activity + roadmap) --------------------------
2105
+ program
2106
+ .command("now")
2107
+ .description("The hot view — last decisions recorded (any status) and the ROADMAP: every live PROPOSED decision. Record what's next as a proposed decision; shipping it (accept/supersede) removes it here automatically. Same data as the wiki's now.md page. Read-only.")
2108
+ .option("--private", "include the private overlay (union) — local terminal output only")
2109
+ .option("--recent <n>", "how many recent decisions to show", "10")
2110
+ .action((opts) => {
2111
+ const { store } = storeFor();
2112
+ try {
2113
+ if (opts.private && !store.hasPrivate)
2114
+ return fail("no private overlay configured — run `hunch private` (or `hunch shared`) first.");
2115
+ const decisions = opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
2116
+ const { recent, roadmap, pendingReview } = nowData(decisions, Number(opts.recent) || 10);
2117
+ console.log(`🔥 Recent (${recent.length})${opts.private ? " — union incl. private overlay; do not paste publicly" : ""}:`);
2118
+ for (const r of recent)
2119
+ console.log(` ${r.date} [${r.status}] ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""})`);
2120
+ console.log(`\n🗺 Roadmap — live proposed decisions (${roadmap.length}):`);
2121
+ if (!roadmap.length)
2122
+ console.log(" (empty — record what's next as a PROPOSED decision via /capture and it appears here)");
2123
+ for (const r of roadmap)
2124
+ console.log(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})\n ${r.note}`);
2125
+ if (pendingReview > 0)
2126
+ console.log(`\n (${pendingReview} auto-drafted proposal(s) awaiting review — \`hunch review\`)`);
2127
+ }
2128
+ finally {
2129
+ store.close();
2130
+ }
2131
+ });
2000
2132
  // ---- heal (decision-grounded drift reconciliation front door) -------------
2001
2133
  program
2002
2134
  .command("heal")
@@ -0,0 +1,100 @@
1
+ import { toPosixTarget } from "./paths.js";
2
+ /** Days a human-confirmed decision "covers" its files against auto-drafts.
3
+ * Wide enough for record-then-commit workflows (even across a weekend),
4
+ * narrow enough that revisiting the same files next month drafts normally. */
5
+ const COVER_WINDOW_DAYS = 14;
6
+ const STOP = new Set([
7
+ "the", "a", "an", "and", "or", "not", "for", "with", "into", "onto", "over", "under", "this", "that",
8
+ "when", "then", "than", "from", "are", "is", "was", "were", "will", "must", "never", "always", "via",
9
+ "instead", "rather", "only", "every", "each", "its", "their", "our", "your", "has", "have", "had",
10
+ "feat", "fix", "chore", "docs", "refactor", "test", "tests", "add", "adds", "added", "new", "now",
11
+ ]);
12
+ /** Lowercased salient terms (≥4 chars, non-stopword) — deliberately simple and
13
+ * deterministic; shared by both layers so their notion of "similar" agrees. */
14
+ export function dupTerms(text) {
15
+ const out = new Set();
16
+ for (const m of text.toLowerCase().matchAll(/[a-z][a-z0-9_-]{3,}/g))
17
+ if (!STOP.has(m[0]))
18
+ out.add(m[0]);
19
+ return out;
20
+ }
21
+ function jaccard(a, b) {
22
+ if (!a.size || !b.size)
23
+ return 0;
24
+ let inter = 0;
25
+ for (const x of a)
26
+ if (b.has(x))
27
+ inter++;
28
+ return inter / (a.size + b.size - inter);
29
+ }
30
+ function fileSet(files) {
31
+ return new Set((files ?? []).map(toPosixTarget).filter((f) => f && !f.includes("*")));
32
+ }
33
+ /** Does a RECENT human-confirmed live decision already claim this commit's
34
+ * files (and roughly its subject)? Deterministic pre-draft gate. */
35
+ export function commitCoveredBy(codeFiles, subject, existing, nowMs) {
36
+ const commitFiles = fileSet(codeFiles);
37
+ if (!commitFiles.size)
38
+ return null;
39
+ const subjectTerms = dupTerms(subject);
40
+ let best = null;
41
+ for (const d of existing) {
42
+ if (!d.provenance.source.includes("human_confirmed"))
43
+ continue;
44
+ if (d.status === "superseded" || d.status === "rejected" || d.superseded_by || d.valid_to)
45
+ continue;
46
+ const at = Date.parse(d.valid_from ?? d.date);
47
+ if (!Number.isFinite(at))
48
+ continue;
49
+ const ageDays = (nowMs - at) / 86400000;
50
+ if (ageDays < 0 || ageDays > COVER_WINDOW_DAYS)
51
+ continue;
52
+ const decFiles = fileSet(d.related_files);
53
+ if (!decFiles.size)
54
+ continue;
55
+ let overlap = 0;
56
+ for (const f of commitFiles)
57
+ if (decFiles.has(f))
58
+ overlap++;
59
+ const overlapRatio = overlap / commitFiles.size; // how much of the COMMIT the decision claims
60
+ const termSim = jaccard(subjectTerms, dupTerms(`${d.title} ${d.decision}`));
61
+ // Strong file claim alone, or a moderate claim corroborated by the subject.
62
+ const covered = overlapRatio >= 0.6 || (overlap >= 2 && overlapRatio >= 0.34 && termSim >= 0.15);
63
+ if (!covered)
64
+ continue;
65
+ const cov = { id: d.id, title: d.title, hoursAgo: Math.round((nowMs - at) / 3600000), fileOverlapPct: Math.round(overlapRatio * 100) };
66
+ if (!best || cov.fileOverlapPct > best.fileOverlapPct)
67
+ best = cov;
68
+ }
69
+ return best;
70
+ }
71
+ /** Is an existing DRAFT a near-duplicate of an accepted record? Review-time
72
+ * flag; threshold callers use 0.35 (batch-reject) — conservative on purpose. */
73
+ export function draftDuplicateOf(draft, existing) {
74
+ const draftTerms = dupTerms(`${draft.title} ${draft.decision}`);
75
+ const draftFiles = fileSet(draft.related_files);
76
+ let best = null;
77
+ for (const d of existing) {
78
+ if (d.id === draft.id)
79
+ continue;
80
+ if (!d.provenance.source.includes("human_confirmed"))
81
+ continue;
82
+ if (d.status === "superseded" || d.status === "rejected" || d.superseded_by || d.valid_to)
83
+ continue;
84
+ const termSim = jaccard(draftTerms, dupTerms(`${d.title} ${d.decision}`));
85
+ let fileBoost = 0;
86
+ if (draftFiles.size) {
87
+ const decFiles = fileSet(d.related_files);
88
+ let overlap = 0;
89
+ for (const f of draftFiles)
90
+ if (decFiles.has(f))
91
+ overlap++;
92
+ fileBoost = draftFiles.size ? (overlap / draftFiles.size) * 0.25 : 0;
93
+ }
94
+ const score = Math.min(1, termSim + fileBoost);
95
+ if (score >= 0.35 && (!best || score > best.score))
96
+ best = { of: d, score };
97
+ }
98
+ return best;
99
+ }
100
+ //# sourceMappingURL=dupdetect.js.map
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Session-scoped injection dedup (roadmap dec_244397d920): the pre-edit hook
3
+ * re-fires on EVERY edit, and re-injecting an identical 10-16KB grounding block
4
+ * 20+ times per session buries the agent's working context under repeats — the
5
+ * cost of being grounded starts competing with the work.
6
+ *
7
+ * Mechanism: per Claude-Code session (the hook event carries session_id), keep
8
+ * a tiny {key → content-hash} map in the OS tmpdir. First injection for a key
9
+ * (or any time the underlying records CHANGE) → "full". Identical repeat →
10
+ * "delta" (the caller emits a one-liner, or nothing).
11
+ *
12
+ * Failure posture inherits con_03a0b94b2e but inverted for safety: the hook
13
+ * must never crash an edit AND dedup must never cost grounding — so on ANY
14
+ * cache error (unwritable tmpdir, corrupt file, missing session id) the answer
15
+ * is "full". Deny decisions are never routed through here: the gate re-checks
16
+ * every edit regardless. Kill switch: HUNCH_HOOK_DEDUP=0.
17
+ */
18
+ import { createHash } from "node:crypto";
19
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, rmSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { tmpdir } from "node:os";
22
+ const MAX_KEYS = 300;
23
+ const SWEEP_AGE_MS = 48 * 3600 * 1000;
24
+ /** Decide whether this injection should be the FULL grounding block or a delta
25
+ * one-liner. Records the content hash as a side effect (so the next identical
26
+ * call dedups). Never throws. */
27
+ export function injectionMode(sessionId, key, content) {
28
+ try {
29
+ if (!sessionId || process.env.HUNCH_HOOK_DEDUP === "0")
30
+ return "full";
31
+ const dir = join(tmpdir(), "hunch-hookcache");
32
+ mkdirSync(dir, { recursive: true });
33
+ sweep(dir);
34
+ const file = join(dir, `${sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
35
+ const hash = createHash("sha256").update(content).digest("hex").slice(0, 16);
36
+ let map;
37
+ try {
38
+ const raw = JSON.parse(readFileSync(file, "utf8"));
39
+ map = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
40
+ }
41
+ catch {
42
+ map = {};
43
+ }
44
+ if (map[key] === hash)
45
+ return "delta";
46
+ map[key] = hash;
47
+ const keys = Object.keys(map);
48
+ if (keys.length > MAX_KEYS)
49
+ for (const k of keys.slice(0, keys.length - MAX_KEYS))
50
+ delete map[k];
51
+ writeFileSync(file, JSON.stringify(map));
52
+ return "full";
53
+ }
54
+ catch {
55
+ return "full"; // grounded beats deduped, always
56
+ }
57
+ }
58
+ /** Drop session caches from long-gone sessions (best effort, bounded dir). */
59
+ function sweep(dir) {
60
+ try {
61
+ for (const f of readdirSync(dir)) {
62
+ try {
63
+ if (Date.now() - statSync(join(dir, f)).mtimeMs > SWEEP_AGE_MS)
64
+ rmSync(join(dir, f), { force: true });
65
+ }
66
+ catch {
67
+ /* someone else's file / raced — skip */
68
+ }
69
+ }
70
+ }
71
+ catch {
72
+ /* dir unreadable — skip */
73
+ }
74
+ }
75
+ //# sourceMappingURL=hookcache.js.map
@@ -27,16 +27,32 @@ export function renderHunchSection(store, root) {
27
27
  "(decisions, bug history, invariants). It currently holds " +
28
28
  `**${counts.decisions} decisions, ${counts.bugs} bugs, ${counts.constraints} constraints, ${counts.components} components**.`);
29
29
  lines.push("");
30
- lines.push("**Before reasoning about or editing this codebase, consult Hunch via the `hunch_*` MCP tools:**");
31
- lines.push("- `hunch_why(target)` — why a file/symbol is shaped this way (decisions, bugs, constraints).");
32
- lines.push("- `hunch_check_constraints(scope)` — invariants you must not break. **Always run before editing.**");
33
- lines.push("- `hunch_get_dependents(symbol)` — blast radius before a change.");
34
- lines.push("- `hunch_bug_lineage(symptom_or_symbol)` — has this bug happened before? what was the root cause?");
35
- lines.push("- `hunch_query(query)` — free-text search across all of Hunch.");
36
- lines.push("- `hunch_runbook(task)`the proven steps for a recurring task (e.g. \"add an MCP tool\", \"cut a release\").");
37
- lines.push("- `hunch_compare(candidates)` — rank N candidate branches/commits by architectural fit (fewest invariant hits).");
38
- lines.push("- `hunch_conformance()` does the code still SATISFY recorded intent? (e.g. `pay` still reaches `verifySession`). Run before a refactor.");
39
- lines.push("- `hunch_record_decision(...)` — write back a decision after a non-trivial choice.");
30
+ lines.push("**Consult Hunch via the `hunch_*` MCP tools — pick by MOMENT, not from memory:**");
31
+ lines.push("");
32
+ lines.push("**Orient (session/task start):**");
33
+ lines.push("- `hunch_context(target_or_task)` — the minimal relevant slice for what you're about to do; a task phrase falls back to the closest graph matches. **Call FIRST.**");
34
+ lines.push("- `hunch_structure(target?)` — the indexed shape of the repo/dir/file/symbol orient from the graph, not grep rounds.");
35
+ lines.push("- `hunch_runbook(task)` — the proven steps for a recurring task, before re-deriving them.");
36
+ lines.push("- `hunch now` (CLI) — recent decisions + the live roadmap.");
37
+ lines.push("");
38
+ lines.push("**Before designing / choosing an approach:**");
39
+ lines.push("- `hunch_why(target)` — why a file/symbol is shaped this way (decisions, bugs, constraints) — including what was already REJECTED.");
40
+ lines.push("- `hunch_current_decision(topic)` — the one live answer for a topic (history + rejected included).");
41
+ lines.push("- `hunch_bug_lineage(symptom_or_symbol)` — has this failed before? what was the root cause?");
42
+ lines.push("- `hunch_compare(candidates)` — rank candidate branches/commits by fewest invariant hits.");
43
+ lines.push("- `hunch_query(query)` — free-text search when nothing above fits.");
44
+ lines.push("");
45
+ lines.push("**Before editing:**");
46
+ lines.push("- `hunch_check_constraints(scope)` and `hunch_get_dependents(symbol)` / `hunch_blast_radius(target)` — invariants in scope + who you'd break. (The pre-edit hook injects this per file automatically; call these for PLANNING breadth.)");
47
+ lines.push("");
48
+ lines.push("**Before committing / merging:**");
49
+ lines.push("- `hunch_conformance()` — does the code still SATISFY recorded intent? Run before and after a refactor.");
50
+ lines.push("- `hunch_pr_impact(base?)` / `hunch_merge_verdict(...)` — a change's memory surface; would it re-open a closed bug?");
51
+ lines.push("");
52
+ lines.push("**After deciding / when corrected:**");
53
+ lines.push("- `hunch_capture_decision(topic?)` → `hunch_record_decision(...)` — interview first, then write; status `proposed` = roadmap intent (shows in `hunch now`).");
54
+ lines.push("- `hunch_record_correction(...)` — a human correction becomes an ENFORCED rule (Never Twice), not a one-session memory.");
55
+ lines.push("- `hunch_timeline(target)` — decision history when investigating how something evolved.");
40
56
  const wiki = root ? wikiSummary(root) : null;
41
57
  if (wiki) {
42
58
  lines.push("");
@@ -135,6 +135,12 @@ export function installClaudeHooks(root, hookCmd) {
135
135
  ...keep(json.hooks.UserPromptSubmit),
136
136
  { hooks: [{ type: "command", command: hookCmd }] },
137
137
  ];
138
+ // Orientation at session start: recent decisions + the live roadmap, injected
139
+ // once, so the agent begins already knowing where the work stands.
140
+ json.hooks.SessionStart = [
141
+ ...keep(json.hooks.SessionStart),
142
+ { hooks: [{ type: "command", command: hookCmd }] },
143
+ ];
138
144
  const next = JSON.stringify(json, null, 2) + "\n";
139
145
  if (existed && before === next)
140
146
  return { path: file, action: "unchanged" };
@@ -23,6 +23,7 @@ import { formatContext, formatStructure } from "../core/format.js";
23
23
  import { compareCandidates } from "../core/compare.js";
24
24
  import { checkConformance } from "../core/conformance.js";
25
25
  import { renderMarkdown, renderImpact, verdict } from "../core/checkreport.js";
26
+ import { nowData, wikiStatus, publicHome, readWikiManifestAt } from "../wiki/wiki.js";
26
27
  import { HUNCH_VERSION } from "../core/version.js";
27
28
  import { liveForTopic, historyForTopic, rejectedForTopic, captureConflicts } from "../core/topics.js";
28
29
  import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken } from "../core/capturetoken.js";
@@ -264,9 +265,9 @@ export function buildServer(root) {
264
265
  // -- hunch_context (surgical retrieval) -----------------------------------
265
266
  server.registerTool("hunch_context", {
266
267
  title: "Assemble the minimal relevant Hunch slice for a task",
267
- description: "Given a file or symbol you're about to work on, return the MINIMAL relevant memory — invariants to preserve, decisions explaining the design, bug history not to reintroduce, and the blast radius — as a compact brief. Call this FIRST when starting work on something.",
268
+ description: "Given a file, symbol, or task phrase you're about to work on, return the MINIMAL relevant memory — invariants to preserve, decisions explaining the design, bug history not to reintroduce, and the blast radius — as a compact brief. Call this FIRST when starting work on something. A task phrase that resolves to no file/symbol falls back to the closest graph matches.",
268
269
  inputSchema: {
269
- target: z.string().describe("A file path or symbol you're about to edit."),
270
+ target: z.string().describe("A file path, symbol, or task phrase you're about to work on."),
270
271
  budget_tokens: z.number().optional().describe("Rough token budget for the brief (default 1500)."),
271
272
  as_of: z.string().optional().describe("Time-travel ref (commit/tag/branch): assemble the slice as it stood then."),
272
273
  },
@@ -274,7 +275,65 @@ export function buildServer(root) {
274
275
  const asOf = as_of ? asOfDate(as_of, root) : undefined;
275
276
  if (as_of && !asOf)
276
277
  return err(`Could not resolve as_of "${as_of}" to a commit.`);
277
- return ok(formatContext(store.assembleContext(target, budget_tokens ?? 1500, { asOf })));
278
+ const ctx = store.assembleContext(target, budget_tokens ?? 1500, { asOf });
279
+ // Task-phrase input ("improve retrieval ranking") resolves no file/symbol and
280
+ // used to return an empty brief while the graph held the answer — fall back to
281
+ // FTS so the assistant always leaves with the closest matches, not a shrug.
282
+ const empty = !ctx.constraints.length && !ctx.decisions.length && !ctx.bugs.length && !ctx.blast_radius.length;
283
+ if (empty && !asOf) {
284
+ const hits = store.search(target, 8);
285
+ if (hits.length) {
286
+ const lines = hits.map((h) => `• ${h.ref} — ${h.title}\n ${h.snippet}`);
287
+ return ok(`No file/symbol resolves for "${target}" — closest graph matches instead:\n\n${lines.join("\n")}\n\n(For a file/symbol brief pass a concrete target; free-text goes through the same search as hunch_query.)`);
288
+ }
289
+ }
290
+ return ok(formatContext(ctx));
291
+ });
292
+ // -- hunch_now (the hot view: recent activity + roadmap) --------------------
293
+ // PUBLIC store only, per dec_29eff08c69's jurisdiction rule: an assistant may
294
+ // paste this anywhere, so it must be publishable by construction. Union view
295
+ // stays behind `hunch now --private` on the local terminal.
296
+ server.registerTool("hunch_now", {
297
+ title: "Recent activity + the roadmap (the hot view)",
298
+ description: "What just happened and what's next, straight from the graph: the last N decisions (any status — a supersession IS activity) and the ROADMAP (every live human-vouched PROPOSED decision). Call at session start to orient, or before planning what to work on. Same data as the wiki's now.md. Public store only.",
299
+ inputSchema: {
300
+ recent_limit: z.number().optional().describe("How many recent decisions to include (default 10)."),
301
+ },
302
+ }, async ({ recent_limit }) => {
303
+ const { recent, roadmap, pendingReview } = nowData(store.json.loadAll("decisions"), recent_limit ?? 10);
304
+ const L = [`🔥 Recent (${recent.length}):`];
305
+ for (const r of recent)
306
+ L.push(` ${r.date} [${r.status}] ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""})`);
307
+ L.push("", `🗺 Roadmap — live proposed decisions (${roadmap.length}):`);
308
+ if (!roadmap.length)
309
+ L.push(" (empty — record intent as a PROPOSED decision and it appears here)");
310
+ for (const r of roadmap)
311
+ L.push(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})\n ${r.note}`);
312
+ if (pendingReview > 0)
313
+ L.push("", `${pendingReview} auto-drafted proposal(s) awaiting review — \`hunch review\`.`);
314
+ return ok(L.join("\n"));
315
+ });
316
+ // -- hunch_wiki_status (generated-wiki freshness) ---------------------------
317
+ server.registerTool("hunch_wiki_status", {
318
+ title: "Freshness of the generated wiki (public home)",
319
+ description: "Which generated wiki pages are fresh vs stale (graph moved, source doc changed, hand-edited), plus the specs ledger's doc grades. Call before trusting wiki pages or when deciding whether `hunch wiki --heal` is needed. Public home only — the private overlay wiki is a local concern.",
320
+ inputSchema: {},
321
+ }, async () => {
322
+ const home = publicHome(root);
323
+ if (!readWikiManifestAt(home.manifestPath))
324
+ return ok("No wiki adopted in this repo (no wiki manifest). Generate one with `hunch wiki`.");
325
+ const s = wikiStatus(store, home, root);
326
+ const stale = [...s.entries.filter((e) => e.state !== "fresh").map((e) => `${e.page} — ${e.reason || e.state}`),
327
+ ...(s.specs.state !== "fresh" ? [`${s.specs.page} — doc grade snapshot moved`] : []),
328
+ ...(s.now.state !== "fresh" ? [`${s.now.page} — activity/roadmap moved`] : []),
329
+ ...(s.index.state !== "fresh" ? [`${s.index.page} — index inputs moved`] : []),
330
+ ...s.adoptions.filter((a) => a.state !== "fresh").map((a) => `${a.page} — adopted copy of ${a.doc.rel} (${a.state})`)];
331
+ const grades = { grounded: s.docs.filter((d) => d.status === "grounded").length, stale: s.docs.filter((d) => d.status === "stale").length, unverified: s.docs.filter((d) => d.status === "unverified").length };
332
+ const head = `Wiki "${home.dir}/": ${s.entries.length} component page(s), ${s.adoptions.length} adopted doc(s). Docs graded: ${grades.grounded} grounded / ${grades.stale} stale / ${grades.unverified} unverified.`;
333
+ if (!stale.length && !s.orphans.length && !s.adoptionOrphans.length)
334
+ return ok(`${head}\n✓ Everything fresh.`);
335
+ const orphans = [...s.orphans, ...s.adoptionOrphans].map((p) => `${p} — orphaned (heal removes it)`);
336
+ return ok(`${head}\n${[...stale, ...orphans].map((l) => `· ${l}`).join("\n")}\nHeal: \`hunch wiki --heal\`.`);
278
337
  });
279
338
  // -- hunch_timeline (decision history) ------------------------------------
280
339
  server.registerTool("hunch_timeline", {
@@ -441,7 +500,7 @@ export function buildServer(root) {
441
500
  ? " [via capture front door]"
442
501
  : capture_token
443
502
  ? ""
444
- : "\n\n⚠ Recorded WITHOUT a capture interview. Prefer /capture (hunch_capture_decision), which grills the decision to a resolved state before writing — the graph should hold a well-examined decision, not a guess. (A future major version will require a capture token here.)";
503
+ : `\n\n⚠ Recorded WITHOUT a capture interview the record stands, but harden it NOW in one exchange instead of switching flows: answer the first grilling question directly — "What alternative did you seriously consider and reject for '${rec.title.slice(0, 60)}', and what breaks if a future session re-introduces it?"then fold the answer into alternatives_rejected via hunch_record_decision(supersedes: ${id}) or start the full interview with hunch_capture_decision. (A future major version will require a capture token here.)`;
445
504
  const supNote = superseded ? ` Superseded ${superseded.id} (window closed at ${rec.valid_from}).` : "";
446
505
  const note = decision.commit && !fullSha ? ` (note: commit "${decision.commit}" could not be resolved — recorded as a standalone decision, not linked to a commit)` : "";
447
506
  const where = decision.private
@@ -20,6 +20,7 @@ import { selectEmbedder } from "./embedder.js";
20
20
  import { JsonStore } from "./jsonStore.js";
21
21
  import { gitCommonDir } from "../extractors/git.js";
22
22
  import { pathMatchesGlob } from "../core/glob.js";
23
+ import { currentForTopic } from "../core/topics.js";
23
24
  import { edgeId } from "../core/ids.js";
24
25
  import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
25
26
  import { effectiveForbids, matchForbids } from "../core/constraintmatch.js";
@@ -270,8 +271,10 @@ export class HunchStore {
270
271
  return { counts };
271
272
  }
272
273
  // ---- read path ----------------------------------------------------------
273
- /** FTS5 ranked search (hunch_query). Falls back to LIKE if the query has no
274
- * FTS-tokenizable terms. */
274
+ /** FTS5 ranked search the RAW bm25 primitive, no priors. Callers wanting
275
+ * relevance ordering (liveness/provenance/recency + topic-chain promotion)
276
+ * go through hybridSearch/searchScoped, where rerankByPriors applies.
277
+ * Falls back to LIKE if the query has no FTS-tokenizable terms. */
275
278
  search(query, limit = 12) {
276
279
  const match = toFtsQuery(query);
277
280
  // No FTS-tokenizable terms (e.g. a CJK-only query) — degrade to LIKE rather
@@ -369,6 +372,98 @@ export class HunchStore {
369
372
  }
370
373
  return { embedded, skipped: docs.length - todo.length, total: docs.length };
371
374
  }
375
+ /** Post-fusion rerank by graph PRIORS (dec_25e277f479): relevance ordering, not
376
+ * just reachability. Rank-based blend — blended(i) = 1/(60+i) × liveness ×
377
+ * provenance × recency — so bm25's negative scale never leaks in. Priors are
378
+ * BOUNDED (a superseded or ancient record dims, never disappears): liveness 0.6
379
+ * for superseded/retired/rejected, provenance 1.0 / 0.85 / 0.75 for
380
+ * human_confirmed / llm_draft / extracted-inferred, recency 0.7 + 0.3·½^(age/90d).
381
+ * Runbook trigger phrases matching the query boost ×1.5 (exact intent beats
382
+ * keyword luck). Structural refs (symbols/components/edges) stay neutral.
383
+ * Deterministic; ties keep fused order (stable sort). */
384
+ rerankByPriors(hits, limit, query) {
385
+ if (!hits.length)
386
+ return hits; // a SINGLE hit still runs — topic-chain promotion must fire for the lone stale match
387
+ const now = Date.now();
388
+ const q = query?.toLowerCase();
389
+ // Topic-chain promotion: for every superseded decision in the pool whose topic
390
+ // has a live successor NOT already in the pool, inject the successor at the
391
+ // predecessor's rank — the reader asked about the topic, and the graph's one
392
+ // live answer must be reachable even when only history matches lexically.
393
+ const present = new Set(hits.map((h) => h.ref));
394
+ // Each candidate carries its BASE rank position; an injected successor
395
+ // inherits its predecessor's position (× a hair under, so an exact-match
396
+ // live record already in the pool is never displaced by an injection).
397
+ const pool = hits.map((h, i) => ({ h, base: 1 / (60 + i) }));
398
+ for (const [i, h] of hits.entries()) {
399
+ if (h.kind !== "decisions")
400
+ continue;
401
+ const d = this.recs("decisions").find((x) => x.id === h.ref);
402
+ if (!d?.topic || !(d.status === "superseded" || d.superseded_by))
403
+ continue;
404
+ const cur = currentForTopic(this.recs("decisions"), d.topic);
405
+ if (!cur || cur.id === h.ref || present.has(cur.id))
406
+ continue;
407
+ present.add(cur.id);
408
+ pool.push({
409
+ h: { ref: cur.id, kind: "decisions", title: cur.title, snippet: `current for topic "${d.topic}" (supersedes ${h.ref})`, score: h.score },
410
+ base: (1 / (60 + i)) * 0.98,
411
+ });
412
+ }
413
+ const scored = pool.map(({ h, base }) => {
414
+ const m = this.priorMeta(h.ref, h.kind);
415
+ let w = 1;
416
+ if (m) {
417
+ if (m.dead)
418
+ w *= 0.6;
419
+ w *= m.provenance.includes("human_confirmed") ? 1 : m.provenance.includes("llm_draft") ? 0.85 : 0.75;
420
+ if (m.at) {
421
+ const ageDays = Math.max(0, now - Date.parse(m.at)) / 86400000;
422
+ if (Number.isFinite(ageDays))
423
+ w *= 0.7 + 0.3 * Math.pow(0.5, ageDays / 90);
424
+ }
425
+ if (q && m.triggers?.some((tr) => q.includes(tr) || tr.includes(q)))
426
+ w *= 1.5;
427
+ }
428
+ return { h, s: base * w };
429
+ });
430
+ scored.sort((a, b) => b.s - a.s);
431
+ return scored.slice(0, limit).map((x) => x.h);
432
+ }
433
+ /** The prior-bearing metadata for a hit: liveness, provenance, effective date,
434
+ * and (runbooks) trigger phrases. null = structural ref, neutral prior. */
435
+ priorMeta(ref, kind) {
436
+ if (kind === "decisions") {
437
+ const d = this.recs("decisions").find((x) => x.id === ref);
438
+ if (!d)
439
+ return null;
440
+ return {
441
+ dead: d.status === "superseded" || d.status === "rejected" || !!d.superseded_by || !!d.valid_to,
442
+ provenance: d.provenance.source,
443
+ at: d.valid_from ?? d.date,
444
+ };
445
+ }
446
+ if (kind === "runbooks") {
447
+ const r = this.recs("runbooks").find((x) => x.id === ref);
448
+ if (!r)
449
+ return null;
450
+ return { dead: !!r.valid_to, provenance: r.provenance.source, at: r.valid_from ?? r.date, triggers: r.trigger.map((x) => x.toLowerCase()) };
451
+ }
452
+ if (kind === "constraints") {
453
+ const c = this.recs("constraints").find((x) => x.id === ref);
454
+ if (!c)
455
+ return null;
456
+ return { dead: c.status === "retired" || !!c.valid_to, provenance: c.provenance.source, at: c.valid_from };
457
+ }
458
+ if (kind === "bugs") {
459
+ const b = this.recs("bugs").find((x) => x.id === ref);
460
+ if (!b)
461
+ return null;
462
+ // A fixed bug is not "dead" — lineage is the point of keeping it findable.
463
+ return { dead: false, provenance: b.provenance.source };
464
+ }
465
+ return null;
466
+ }
372
467
  /** Hybrid search (hunch_query / `hunch query --semantic`): FTS bm25 fused with
373
468
  * cosine over stored embeddings via Reciprocal Rank Fusion. Degrades to pure
374
469
  * sync FTS (zero added latency) when there's no embedder or no vectors yet, so
@@ -402,8 +497,9 @@ export class HunchStore {
402
497
  // zero-fusion-overhead fast path is preserved.
403
498
  const graph = this.graphExpand([...fts, ...sem], 50, gw);
404
499
  if (!sem.length && !graph.length)
405
- return fts.slice(0, limit);
406
- return this.rrfFuse(fts, sem, graph, limit, gw);
500
+ return this.rerankByPriors(fts, limit, query);
501
+ // Fuse with headroom so the prior rerank can promote from below the cut line.
502
+ return this.rerankByPriors(this.rrfFuse(fts, sem, graph, Math.max(limit, 24), gw), limit, query);
407
503
  }
408
504
  /** Runbook-scoped retrieval (roadmap #5): the same FTS+graph(+semantic) fusion,
409
505
  * restricted to the `runbooks` kind — so a "what's the procedure for X" query
@@ -433,8 +529,8 @@ export class HunchStore {
433
529
  }
434
530
  }
435
531
  if (!sem.length)
436
- return fts.slice(0, limit);
437
- return this.rrfFuse(fts, sem, [], limit);
532
+ return this.rerankByPriors(fts, limit, query);
533
+ return this.rerankByPriors(this.rrfFuse(fts, sem, [], Math.max(limit, 20)), limit, query);
438
534
  }
439
535
  /** FTS bm25 over a single kind (the `kind` column is UNINDEXED, so a plain `=`
440
536
  * constraint composes with MATCH). Empty when the query has no FTS-able terms. */
@@ -2,6 +2,7 @@ import { commitMeta, commitDiff, headSha, currentBranch } from "../extractors/gi
2
2
  import { analyzeDiff } from "../extractors/diff.js";
3
3
  import { selectProvider, selectEnsemble, selectVerifier, verifyDecisionSafe, DeterministicProvider } from "./provider.js";
4
4
  import { decisionId, bugId, constraintId } from "../core/ids.js";
5
+ import { commitCoveredBy } from "../core/dupdetect.js";
5
6
  import { pathMatchesGlob } from "../core/glob.js";
6
7
  import { draftTripwires, knownRepoDeps } from "./tripwires.js";
7
8
  const CODE_RE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
@@ -61,6 +62,19 @@ export async function syncCommit(store, root, sha, opts = {}) {
61
62
  decision: existing,
62
63
  };
63
64
  }
65
+ // Duplicate-factory gate (deterministic, pre-LLM): the human recorded this
66
+ // choice via MCP minutes-to-days ago (commit: null, different id), and now the
67
+ // post-commit hook would re-draft the same content under a commit-keyed id —
68
+ // review triage measured 7 of 14 queued drafts as exactly this. A recent
69
+ // human-confirmed decision claiming this commit's files → skip the draft (and
70
+ // the subscription call). Recency-windowed; --force overrides.
71
+ const covered = commitCoveredBy(codeFiles, meta.subject, store.recs("decisions"), Date.now());
72
+ if (covered && !opts.force) {
73
+ return {
74
+ status: "skipped",
75
+ reason: `already covered by ${covered.id} — "${covered.title}" (recorded ${covered.hoursAgo}h ago, claims ${covered.fileOverlapPct}% of this commit's files; --force to draft anyway)`,
76
+ };
77
+ }
64
78
  const diff = commitDiff(target, root);
65
79
  const analysis = analyzeDiff(diff);
66
80
  // Significance gate: reserve the paid LLM for substantive commits; trivial ones
@@ -55,8 +55,34 @@ export function adoptionHash(content, decisions, doc) {
55
55
  .update(JSON.stringify({ content, topical, issues: doc.issues }))
56
56
  .digest("hex").slice(0, 16);
57
57
  }
58
- /** Render the wiki-managed copy of a stale doc, healed against the graph. */
59
- export function renderAdoptedDoc(doc, content, decisions) {
58
+ /** Prompt for the optional PROSE-HEAL tier (dec roadmap.adoption-prose-heal):
59
+ * a subscription-CLI rewrite of what the stale doc SHOULD say now. The output
60
+ * is garnish on the deterministic skeleton — never hashed, never a substitute
61
+ * for the graph corrections rendered below it. */
62
+ export function adoptProsePrompt(doc, content, decisions) {
63
+ const topical = doc.topics
64
+ .map((t) => {
65
+ const cur = currentForTopic(decisions, t);
66
+ return cur ? `topic "${t}" → CURRENT decision ${cur.id} — ${cur.title}: ${clip(cur.decision, 400)}` : null;
67
+ })
68
+ .filter((x) => !!x);
69
+ return `You are the documentation engine of an Engineering Memory OS. A repo doc went STALE
70
+ against the decision graph. Write the short RECONCILED version: what this doc should say NOW,
71
+ grounded ONLY in the current decisions below — never invent behavior, cite decision ids inline
72
+ like (dec_xxx). Plain markdown paragraphs, no headings or lists, 100-180 words.
73
+
74
+ ## The stale doc (${doc.rel})
75
+ ${content.slice(0, 4000)}
76
+
77
+ ## Current decisions for its topics
78
+ ${topical.join("\n") || "(none — the doc is stale for non-pin reasons: " + doc.issues.join("; ") + ")"}
79
+
80
+ Write the reconciled version now.`;
81
+ }
82
+ /** Render the wiki-managed copy of a stale doc, healed against the graph.
83
+ * `reconciled` (optional, LLM prose-heal) slots in under the banner; the
84
+ * deterministic corrections below remain the authoritative layer. */
85
+ export function renderAdoptedDoc(doc, content, decisions, reconciled = null) {
60
86
  const byId = new Map(decisions.map((d) => [d.id, d]));
61
87
  const lines = content.split("\n");
62
88
  const out = [];
@@ -67,6 +93,9 @@ export function renderAdoptedDoc(doc, content, decisions) {
67
93
  for (const i of doc.issues)
68
94
  out.push(`> - ${i}`);
69
95
  out.push("");
96
+ if (reconciled) {
97
+ out.push("## 🩹 Reconciled overview", "", reconciled.trim(), "", "_LLM-drafted from the current decisions (subscription); the graph corrections below are the deterministic, authoritative layer._", "");
98
+ }
70
99
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
71
100
  for (const raw of lines) {
72
101
  // Heal EVERY stale pin on the line, and only inside its own marker — a bare
package/dist/wiki/wiki.js CHANGED
@@ -79,7 +79,7 @@ const SEV = { blocking: 3, warning: 2, advisory: 1, critical: 4, high: 3, medium
79
79
  * the leak boundary: "public" reads the committed store only; "all" unions the
80
80
  * private overlay and must only ever feed a PRIVATE home's pages. `repoDocs`
81
81
  * (pre-scanned specs) associate by the src files they mention. */
82
- export function assemblePack(store, component, source = "public", repoDocs = [], adoptedPageByRel = new Map()) {
82
+ export function assemblePack(store, component, source = "public", repoDocs = [], adoptedPageByRel = new Map(), slugById = new Map()) {
83
83
  const read = (kind) => (source === "all" ? store.recs(kind) : store.json.loadAll(kind));
84
84
  const prefixes = component.paths.map(globPrefix);
85
85
  const symbols = read("symbols")
@@ -121,7 +121,7 @@ export function assemblePack(store, component, source = "public", repoDocs = [],
121
121
  if (e.to === component.id && componentsById.has(e.from) && e.from !== component.id)
122
122
  usedBy.set(e.from, componentsById.get(e.from).name);
123
123
  }
124
- const rel = (m) => [...m].map(([id, name]) => ({ id, name })).sort((a, b) => a.id.localeCompare(b.id));
124
+ const rel = (m) => [...m].map(([id, name]) => ({ id, name, slug: slugById.get(id) ?? null })).sort((a, b) => a.id.localeCompare(b.id));
125
125
  const docs = repoDocs
126
126
  .filter((doc) => doc.srcRefs.some((f) => owns(prefixes, f)))
127
127
  .map((doc) => ({ path: doc.rel, title: doc.title, status: doc.status, adopted: adoptedPageByRel.get(doc.rel) ?? null }));
@@ -163,7 +163,7 @@ export function slugFor(name, id, taken) {
163
163
  let slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || id;
164
164
  // "readme" and "specs" are reserved page names (index + specs ledger); the
165
165
  // id-suffix itself can collide with a literal name, so loop until unique.
166
- if (slug === "readme" || slug === "specs" || taken.has(slug))
166
+ if (slug === "readme" || slug === "specs" || slug === "now" || taken.has(slug))
167
167
  slug = `${slug}-${id.replace(/^cmp_/, "").slice(0, 6)}`;
168
168
  while (taken.has(slug))
169
169
  slug = `${slug}-x`;
@@ -174,7 +174,7 @@ const DOC_BADGE = { grounded: "✅ grounded", stale: "⚠ stale", unverified: "
174
174
  /** `docsLinkable`: pages in the MAIN repo can relative-link ../<doc>; a private
175
175
  * home's pages live in the OVERLAY repo where those paths don't resolve, so
176
176
  * they render doc paths as plain text instead. */
177
- export function renderPage(pack, prose, slugById, docsLinkable = true) {
177
+ export function renderPage(pack, prose, docsLinkable = true) {
178
178
  const c = pack.component;
179
179
  const L = [];
180
180
  L.push(`<!-- hunch:wiki ${c.id} — GENERATED from the Hunch graph by \`hunch wiki\`; the graph is the source of truth. Edit records (/capture, hunch_record_decision), then \`hunch wiki --heal\` — do not edit this page by hand. -->`);
@@ -229,7 +229,7 @@ export function renderPage(pack, prose, slugById, docsLinkable = true) {
229
229
  }
230
230
  if (pack.dependsOn.length || pack.usedBy.length) {
231
231
  L.push("## Relations", "");
232
- const link = (r) => { const s = slugById.get(r.id); return s ? `[${r.name}](${s}.md)` : r.name; };
232
+ const link = (r) => (r.slug ? `[${r.name}](${r.slug}.md)` : r.name);
233
233
  if (pack.dependsOn.length)
234
234
  L.push(`- Depends on: ${pack.dependsOn.map(link).join(", ")}`);
235
235
  if (pack.usedBy.length)
@@ -262,6 +262,7 @@ export function renderIndex(entries, repoWide, home, docs = []) {
262
262
  const n = (s) => docs.filter((d) => d.status === s).length;
263
263
  L.push("", `📄 [Specs & docs ledger](specs.md) — ${docs.length} repo doc(s): ${n("grounded")} grounded, ${n("stale")} stale, ${n("unverified")} unverified.`);
264
264
  }
265
+ L.push("", "🔥 [Now — recent activity & roadmap](now.md)");
265
266
  if (repoWide.length) {
266
267
  L.push("", "## Repo-wide invariants", "");
267
268
  for (const k of repoWide)
@@ -340,8 +341,58 @@ export function wikiSummary(root) {
340
341
  const SPECS_ID = "_specs";
341
342
  /** Reserved manifest component id for the README index page. */
342
343
  const INDEX_ID = "_index";
344
+ /** Reserved manifest component id for the NOW page (activity ledger + roadmap). */
345
+ const NOW_ID = "_now";
343
346
  /** Manifest component-id prefix for adopted (wiki-managed) doc copies. */
344
347
  const ADOPTED_PREFIX = "doc:";
348
+ /** The hot view's inputs: last `recentLimit` decisions by date (any status — a
349
+ * supersession IS activity), and every live PROPOSED decision (the roadmap:
350
+ * record intent as a proposed decision; accepting or superseding it removes it
351
+ * from the roadmap with zero file maintenance). Home-scoped like every read. */
352
+ export function nowData(decisions, recentLimit = 10) {
353
+ const clip1 = (s) => (s.length > 220 ? s.slice(0, 219).trimEnd() + "…" : s).replace(/\s*\n\s*/g, " ");
354
+ const byDateDesc = (a, b) => (b.valid_from ?? b.date).localeCompare(a.valid_from ?? a.date) || a.id.localeCompare(b.id);
355
+ const recent = [...decisions].sort(byDateDesc).slice(0, recentLimit)
356
+ .map((d) => ({ id: d.id, topic: d.topic, title: d.title, status: d.status, date: (d.valid_from ?? d.date).slice(0, 10), note: clip1(d.decision) }));
357
+ // Roadmap = INTENT the human vouched for. Auto-synthesized drafts are also
358
+ // status "proposed", but they describe work already done and belong to the
359
+ // review queue (`hunch review`) — surfacing them here would bury real plans.
360
+ const live = decisions.filter((d) => d.status === "proposed" && !d.superseded_by && !d.valid_to);
361
+ const vouched = live.filter((d) => d.provenance.source.includes("human_confirmed"));
362
+ const roadmap = vouched
363
+ .sort(byDateDesc)
364
+ .map((d) => ({ id: d.id, topic: d.topic, title: d.title, status: d.status, date: (d.valid_from ?? d.date).slice(0, 10), note: clip1(d.context || d.decision) }));
365
+ return { recent, roadmap, pendingReview: live.length - vouched.length };
366
+ }
367
+ /** The hot file — a DERIVED view like every other page: what just happened
368
+ * (last N decisions) and what's next (live proposed decisions). No topic pins
369
+ * by design: ledger entries going stale is history, not drift — the freshness
370
+ * hash alone re-renders the page when the graph moves. */
371
+ export function renderNowPage(recent, roadmap, home, pendingReview = 0) {
372
+ const L = [];
373
+ L.push("<!-- hunch:wiki _now — GENERATED activity ledger + roadmap by `hunch wiki`; do not edit by hand. Roadmap items are PROPOSED decisions — record intent with /capture (status: proposed); shipping it (accept/supersede) removes it here automatically. -->");
374
+ L.push("# Now — recent activity & roadmap", "");
375
+ if (home.kind === "private")
376
+ L.push("> ⚠ **PRIVATE** — includes overlay records; do not publish.", "");
377
+ L.push("## 🔥 Recent", "");
378
+ if (!recent.length)
379
+ L.push("_No decisions recorded yet._", "");
380
+ for (const r of recent)
381
+ L.push(`- ${r.date} · **${r.title}** — ${r.note || "(no decision text)"} _(${r.id}${r.topic ? `, topic \`${r.topic}\`` : ""}, ${r.status})_`);
382
+ if (recent.length)
383
+ L.push("");
384
+ L.push("## 🗺 Roadmap — live proposed decisions", "");
385
+ if (!roadmap.length)
386
+ L.push("_Empty. Record what's next as a PROPOSED decision (`/capture`, status: proposed) and it appears here._", "");
387
+ for (const r of roadmap)
388
+ L.push(`- **${r.title}** — ${r.note || "(no context)"} _(${r.id}${r.topic ? `, topic \`${r.topic}\`` : ""}, since ${r.date})_`);
389
+ if (roadmap.length)
390
+ L.push("");
391
+ if (pendingReview > 0)
392
+ L.push(`_${pendingReview} auto-drafted proposed decision(s) awaiting review are not shown — triage with \`hunch review\`._`, "");
393
+ L.push("---", "", "_Derived from the decision graph — regen: `hunch wiki --heal`. Ship a roadmap item by accepting/superseding its decision; never edit this page._", "");
394
+ return L.join("\n");
395
+ }
345
396
  const sha16 = (s) => createHash("sha256").update(s).digest("hex").slice(0, 16);
346
397
  /** The one freshness state machine every generated artifact goes through:
347
398
  * unknown/mismatched manifest entry → new; input hash moved → stale; file
@@ -394,10 +445,14 @@ export function wikiStatus(store, home, srcRoot) {
394
445
  adoptions.push({ doc, content, page, hash, state });
395
446
  }
396
447
  const adoptedPageByRel = new Map(adoptions.map((a) => [a.doc.rel, a.page.slice(home.dir.length + 1)]));
448
+ // Slugs FIRST (single authority): packs embed their relations' slugs, so the
449
+ // freshness hash covers cross-page links — a sibling rename re-renders every
450
+ // page that links to it instead of leaving a fresh page pointing at a ghost.
397
451
  const taken = new Set();
452
+ const slugById = new Map(components.map((c) => [c.id, slugFor(c.name, c.id, taken)]));
398
453
  const entries = components.map((c) => {
399
- const pack = assemblePack(store, c, home.source, docs, adoptedPageByRel);
400
- const slug = slugFor(c.name, c.id, taken);
454
+ const pack = assemblePack(store, c, home.source, docs, adoptedPageByRel, slugById);
455
+ const slug = slugById.get(c.id);
401
456
  const page = `${home.dir}/${slug}.md`;
402
457
  const hash = packHash(pack);
403
458
  const { state, reason } = pageState(home, page, c.id, hash, manifest?.pages[page]);
@@ -424,11 +479,16 @@ export function wikiStatus(store, home, srcRoot) {
424
479
  docs: { grounded: docs.filter((d) => d.status === "grounded").length, stale: docs.filter((d) => d.status === "stale").length, unverified: docs.filter((d) => d.status === "unverified").length, total: docs.length },
425
480
  })));
426
481
  const index = { page: indexPage, hash: indexHash, state: pageState(home, indexPage, INDEX_ID, indexHash, manifest?.pages[indexPage]).state };
482
+ // The NOW page: recent activity + live proposed decisions (the roadmap).
483
+ const { recent, roadmap, pendingReview } = nowData(decisions);
484
+ const nowPage = `${home.dir}/now.md`;
485
+ const nowHash = sha16(JSON.stringify(canonical({ recent, roadmap, pendingReview })));
486
+ const now = { page: nowPage, hash: nowHash, state: pageState(home, nowPage, NOW_ID, nowHash, manifest?.pages[nowPage]).state, recent, roadmap, pendingReview };
427
487
  // Orphans by PAGE KEY, not component id: anything the manifest tracks that no
428
488
  // current artifact claims (deleted component, renamed component whose slug
429
489
  // moved, a retired adoption) gets removed on heal — nothing generated is ever
430
490
  // stranded on disk while the manifest forgets it.
431
- const expected = new Set([...entries.map((e) => e.page), ...adoptions.map((a) => a.page), specsPage, indexPage]);
491
+ const expected = new Set([...entries.map((e) => e.page), ...adoptions.map((a) => a.page), specsPage, indexPage, nowPage]);
432
492
  const orphans = [];
433
493
  const adoptionOrphans = [];
434
494
  for (const [page, p] of Object.entries(manifest?.pages ?? {})) {
@@ -436,7 +496,7 @@ export function wikiStatus(store, home, srcRoot) {
436
496
  continue;
437
497
  (p.component.startsWith(ADOPTED_PREFIX) ? adoptionOrphans : orphans).push(page);
438
498
  }
439
- return { home, entries, docs, adoptions, adoptionOrphans, decisions, specs, index, repoWide, orphans };
499
+ return { home, entries, docs, adoptions, adoptionOrphans, decisions, specs, index, now, repoWide, orphans };
440
500
  }
441
501
  /** Read short excerpts of the component's heaviest files as LLM grounding.
442
502
  * Source files always live in the MAIN repo (`srcRoot`), even for the private
@@ -468,8 +528,8 @@ export async function generateWiki(store, srcRoot, home, opts) {
468
528
  const targets = status.entries.filter((e) => opts.only === "all" || e.state !== "fresh");
469
529
  const specsTarget = opts.only === "all" || status.specs.state !== "fresh";
470
530
  const indexTarget = opts.only === "all" || status.index.state !== "fresh";
531
+ const nowTarget = opts.only === "all" || status.now.state !== "fresh";
471
532
  const log = opts.log ?? (() => { });
472
- const slugById = new Map(status.entries.map((e) => [e.pack.component.id, e.slug]));
473
533
  const written = [];
474
534
  /** Written-bytes ledger — the hand-edit tripwire recorded per page. */
475
535
  const bytesByPage = new Map();
@@ -489,7 +549,7 @@ export async function generateWiki(store, srcRoot, home, opts) {
489
549
  prose = null; // template-only page; never fail generation on a CLI hiccup
490
550
  }
491
551
  }
492
- put(e.page, renderPage(e.pack, prose, slugById, home.kind === "public"));
552
+ put(e.page, renderPage(e.pack, prose, home.kind === "public"));
493
553
  log(` ✎ ${e.page}${e.state === "fresh" ? "" : ` (${e.state})`}${prose ? "" : " [template]"}`);
494
554
  }
495
555
  if (specsTarget) {
@@ -502,8 +562,21 @@ export async function generateWiki(store, srcRoot, home, opts) {
502
562
  if (adoptionTargets.length)
503
563
  mkdirSync(join(home.pagesRoot, home.dir, "docs"), { recursive: true });
504
564
  for (const a of adoptionTargets) {
505
- put(a.page, renderAdoptedDoc(a.doc, a.content, status.decisions));
506
- log(` ✚ ${a.page}${a.state === "fresh" ? "" : ` (${a.state})`} [adopted from ${a.doc.rel}]`);
565
+ let reconciled = null;
566
+ if (opts.adoptionProse) {
567
+ try {
568
+ reconciled = await opts.adoptionProse(a.doc, a.content);
569
+ }
570
+ catch {
571
+ reconciled = null; // deterministic copy; a CLI hiccup never fails adoption
572
+ }
573
+ }
574
+ put(a.page, renderAdoptedDoc(a.doc, a.content, status.decisions, reconciled));
575
+ log(` ✚ ${a.page}${a.state === "fresh" ? "" : ` (${a.state})`} [adopted from ${a.doc.rel}]${reconciled ? " [prose-healed]" : ""}`);
576
+ }
577
+ if (nowTarget) {
578
+ put(status.now.page, renderNowPage(status.now.recent, status.now.roadmap, home, status.now.pendingReview));
579
+ log(` ✎ ${status.now.page}${status.now.state === "fresh" ? "" : ` (${status.now.state})`} [${status.now.recent.length} recent, ${status.now.roadmap.length} roadmap]`);
507
580
  }
508
581
  if (indexTarget) {
509
582
  put(status.index.page, renderIndex(status.entries.map((e) => ({ pack: e.pack, slug: e.slug })), status.repoWide, home, status.docs));
@@ -537,6 +610,7 @@ export async function generateWiki(store, srcRoot, home, opts) {
537
610
  entry(a.page, `${ADOPTED_PREFIX}${a.doc.rel}`, a.hash, a.state);
538
611
  entry(status.specs.page, SPECS_ID, status.specs.hash, status.specs.state);
539
612
  entry(status.index.page, INDEX_ID, status.index.hash, status.index.state);
613
+ entry(status.now.page, NOW_ID, status.now.hash, status.now.state);
540
614
  writeWikiManifestAt(home.manifestPath, { version: 1, dir: home.dir, pages });
541
615
  }
542
616
  return { written, removed, unchanged: status.entries.length - targets.length };
@@ -582,6 +656,13 @@ export function computeWikiDrift(store, root) {
582
656
  detail: `the index's inputs moved (component set/names, repo-wide invariants, or doc counts) — regenerate with \`${heal}\`${where}`,
583
657
  });
584
658
  }
659
+ if (status.now.state !== "fresh") {
660
+ findings.push({
661
+ kind: "wiki-stale",
662
+ id: status.now.page,
663
+ detail: `the activity ledger / roadmap moved (a decision was recorded, accepted, or superseded) — regenerate with \`${heal}\`${where}`,
664
+ });
665
+ }
585
666
  for (const a of status.adoptions) {
586
667
  if (a.state === "fresh")
587
668
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.2.3",
3
+ "version": "1.3.1",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
6
  "description": "Architectural Conformance for AI-generated code: a git-native graph that deterministically blocks AI changes which break your architecture — the semantic invariants (layering, must-reach, dependency direction) pattern-SAST can't express — grounded in the decisions and bugs behind each rule, across any MCP assistant (Claude Code, Cursor, Copilot, Windsurf, Codex).",