@davesheffer/hunch 1.2.2 โ 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/cli/index.js +146 -8
- package/dist/core/dupdetect.js +100 -0
- package/dist/core/hookcache.js +75 -0
- package/dist/extractors/indexer.js +5 -1
- package/dist/integrations/claudemd.js +26 -10
- package/dist/integrations/scaffold.js +21 -6
- package/dist/mcp/server.js +63 -4
- package/dist/store/hunchStore.js +102 -6
- package/dist/synthesis/synthesize.js +14 -0
- package/dist/wiki/adopt.js +31 -2
- package/dist/wiki/wiki.js +94 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,6 +44,8 @@ guards, troubleshooting. This README is the tour. Jump to:
|
|
|
44
44
|
[CLI reference](https://hunch-pi.vercel.app/docs#cli) ยท
|
|
45
45
|
[Troubleshooting](https://hunch-pi.vercel.app/docs#doctor)
|
|
46
46
|
|
|
47
|
+
> ๐ **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
|
+
|
|
47
49
|
## The problem
|
|
48
50
|
|
|
49
51
|
Every AI coding session starts from zero. The model re-reads your code, re-guesses the
|
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";
|
|
@@ -150,11 +153,17 @@ program
|
|
|
150
153
|
else {
|
|
151
154
|
console.log(" โ not a git repo โ skipped hooks (run `git init` to enable the learning loop)");
|
|
152
155
|
}
|
|
153
|
-
const mcp = writeMcpJson(root, inv.mcp);
|
|
154
156
|
// .mcp.json is the CANONICAL registration: Claude Code resolves it by file path,
|
|
155
157
|
// so it's immune to the Windows ~/.claude.json drive-letter case-split that a
|
|
156
|
-
// global `claude mcp add` is prone to (see `hunch doctor`).
|
|
157
|
-
|
|
158
|
+
// global `claude mcp add` is prone to (see `hunch doctor`). An unparseable
|
|
159
|
+
// existing file is refused (con_8460b6770f) โ warn and continue, never clobber.
|
|
160
|
+
try {
|
|
161
|
+
const mcp = writeMcpJson(root, inv.mcp);
|
|
162
|
+
console.log(` โ wrote ${rel(root, mcp)} (registers the Hunch MCP server โ canonical, path-keyed; prefer over a global \`claude mcp add\`)`);
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
console.log(` โ skipped .mcp.json: ${e.message}`);
|
|
166
|
+
}
|
|
158
167
|
const cmds = writeSlashCommands(root);
|
|
159
168
|
console.log(` โ wrote ${cmds.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile)`);
|
|
160
169
|
const cmd = updateClaudeMd(root, store);
|
|
@@ -1405,13 +1414,30 @@ program
|
|
|
1405
1414
|
.argument("<target>", "file path or symbol")
|
|
1406
1415
|
.option("--budget <n>", "rough token budget", "1500")
|
|
1407
1416
|
.option("--as-of <ref>", "time-travel: assemble the slice as it stood at a commit/tag/branch")
|
|
1408
|
-
.action((target, opts) => {
|
|
1417
|
+
.action(async (target, opts) => {
|
|
1409
1418
|
const { store, root } = storeFor();
|
|
1410
1419
|
const asOf = opts.asOf ? asOfDate(opts.asOf, root) : undefined;
|
|
1411
1420
|
if (opts.asOf && !asOf)
|
|
1412
1421
|
return fail(`could not resolve --as-of "${opts.asOf}" to a commit`);
|
|
1413
1422
|
store.reindex(); // reflect any out-of-band JSON edits before assembling
|
|
1414
|
-
|
|
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));
|
|
1415
1441
|
store.close();
|
|
1416
1442
|
});
|
|
1417
1443
|
// ---- timeline -------------------------------------------------------------
|
|
@@ -1535,9 +1561,46 @@ program
|
|
|
1535
1561
|
// nudge the agent to PERSIST it as an enforced constraint (Never Twice) โ
|
|
1536
1562
|
// not just obey it this once and forget it next session.
|
|
1537
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;
|
|
1538
1569
|
emitContext("UserPromptSubmit", text);
|
|
1539
1570
|
return;
|
|
1540
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
|
+
}
|
|
1541
1604
|
if (evt.hook_event_name !== "PreToolUse")
|
|
1542
1605
|
return;
|
|
1543
1606
|
const abs = evt.tool_input?.file_path;
|
|
@@ -1610,6 +1673,13 @@ program
|
|
|
1610
1673
|
text += `\n\n${grounding}`;
|
|
1611
1674
|
if (docGround)
|
|
1612
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
|
+
}
|
|
1613
1683
|
emitContext("PreToolUse", text);
|
|
1614
1684
|
}
|
|
1615
1685
|
catch {
|
|
@@ -1657,6 +1727,7 @@ program
|
|
|
1657
1727
|
.option("--accept <id>", "promote a decision to accepted/human-confirmed (confirms its tripwires)")
|
|
1658
1728
|
.option("--reject <id>", "delete a draft decision")
|
|
1659
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)")
|
|
1660
1731
|
.option("--min-grounded <n>", "grounded-ness threshold for the ready group / --accept-verified", String(READY_MIN_GROUNDED))
|
|
1661
1732
|
.action((opts) => {
|
|
1662
1733
|
const { store, root } = storeFor();
|
|
@@ -1677,6 +1748,28 @@ program
|
|
|
1677
1748
|
store.reindex();
|
|
1678
1749
|
console.log(ok2 ? `โ rejected and removed ${opts.reject}` : `decision ${opts.reject} not found`);
|
|
1679
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
|
+
}
|
|
1680
1773
|
else if (opts.acceptVerified) {
|
|
1681
1774
|
// Batch path: only Critic-verified, well-grounded drafts qualify โ still the
|
|
1682
1775
|
// human-driven accept gate (the operator runs this), just over a safe subset.
|
|
@@ -1711,8 +1804,18 @@ program
|
|
|
1711
1804
|
}
|
|
1712
1805
|
if (scrutiny.length) {
|
|
1713
1806
|
console.log(`โ ${scrutiny.length} need scrutiny โ unverified / low-grounded (lowest confidence first):\n`);
|
|
1714
|
-
|
|
1807
|
+
const all = store.json.loadAll("decisions");
|
|
1808
|
+
let dupCount = 0;
|
|
1809
|
+
for (const it of scrutiny) {
|
|
1715
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`);
|
|
1716
1819
|
}
|
|
1717
1820
|
console.log(`\nAccept: hunch review --accept <id> Reject: hunch review --reject <id>`);
|
|
1718
1821
|
}
|
|
@@ -1897,6 +2000,7 @@ program
|
|
|
1897
2000
|
.option("--heal", "regenerate only new/stale pages (manifest hash mismatch) and remove orphans")
|
|
1898
2001
|
.option("--check", "report stale pages and exit non-zero (CI gate); writes nothing")
|
|
1899
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)")
|
|
1900
2004
|
.option("--private", "render the FULL graph (private overlay included) and write the wiki into the OVERLAY repo โ nothing lands in this repo")
|
|
1901
2005
|
.action(async (opts) => {
|
|
1902
2006
|
const { store, root } = storeFor();
|
|
@@ -1959,21 +2063,27 @@ program
|
|
|
1959
2063
|
// Prose is optional garnish on the deterministic skeleton: subscription CLI
|
|
1960
2064
|
// only (same rule as synthesis), feature-detected, and any failure degrades
|
|
1961
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.");
|
|
1962
2068
|
let prose;
|
|
2069
|
+
let adoptionProse;
|
|
1963
2070
|
if (opts.llm !== false) {
|
|
1964
2071
|
const provider = await selectProvider();
|
|
1965
2072
|
if (provider.draftProse) {
|
|
1966
2073
|
console.log(`Prose via ${provider.name} (subscription); the drift-bearing skeleton stays deterministic.`);
|
|
1967
2074
|
prose = (pack, excerpts) => provider.draftProse(wikiPrompt(pack, excerpts));
|
|
2075
|
+
if (opts.proseHeal)
|
|
2076
|
+
adoptionProse = (doc, content) => provider.draftProse(adoptProsePrompt(doc, content, status.decisions));
|
|
1968
2077
|
}
|
|
1969
2078
|
else {
|
|
1970
|
-
console.log(
|
|
2079
|
+
console.log(`No subscription CLI available โ deterministic template pages${opts.proseHeal ? " (prose-heal skipped)" : ""}.`);
|
|
1971
2080
|
}
|
|
1972
2081
|
}
|
|
1973
2082
|
const res = await generateWiki(store, root, home, {
|
|
1974
2083
|
now: new Date().toISOString(),
|
|
1975
2084
|
only: opts.heal ? "stale" : "all",
|
|
1976
2085
|
prose,
|
|
2086
|
+
adoptionProse,
|
|
1977
2087
|
log: (l) => console.log(l),
|
|
1978
2088
|
});
|
|
1979
2089
|
if (!res.written.length && !res.removed.length) {
|
|
@@ -1991,6 +2101,34 @@ program
|
|
|
1991
2101
|
store.close();
|
|
1992
2102
|
}
|
|
1993
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
|
+
});
|
|
1994
2132
|
// ---- heal (decision-grounded drift reconciliation front door) -------------
|
|
1995
2133
|
program
|
|
1996
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
|
|
@@ -191,7 +191,11 @@ export function indexRepo(store, root, opts = {}) {
|
|
|
191
191
|
// ---- helpers --------------------------------------------------------------
|
|
192
192
|
function listCodeFiles(root) {
|
|
193
193
|
if (isGitRepo(root)) {
|
|
194
|
-
|
|
194
|
+
// Apply SKIP_DIRS to the git-tracked list too: a repo that (accidentally)
|
|
195
|
+
// tracks node_modules/ or dist/ must not flood the graph with vendored symbols.
|
|
196
|
+
const tracked = trackedFiles(root, CODE_EXTS)
|
|
197
|
+
.filter((f) => !f.split(/[\\/]/).some((seg) => SKIP_DIRS.has(seg)))
|
|
198
|
+
.map((f) => join(root, f));
|
|
195
199
|
if (tracked.length > 0)
|
|
196
200
|
return tracked; // else fall through (nothing committed yet)
|
|
197
201
|
}
|
|
@@ -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("**
|
|
31
|
-
lines.push("
|
|
32
|
-
lines.push("
|
|
33
|
-
lines.push("- `
|
|
34
|
-
lines.push("- `
|
|
35
|
-
lines.push("- `
|
|
36
|
-
lines.push("- `
|
|
37
|
-
lines.push("
|
|
38
|
-
lines.push("
|
|
39
|
-
lines.push("- `
|
|
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("");
|
|
@@ -5,16 +5,25 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
7
7
|
import { join, dirname } from "node:path";
|
|
8
|
-
/** Merge a `hunch` server entry into .mcp.json, preserving other servers.
|
|
8
|
+
/** Merge a `hunch` server entry into .mcp.json, preserving other servers.
|
|
9
|
+
* A non-empty file we cannot parse THROWS instead of being silently replaced
|
|
10
|
+
* (con_8460b6770f โ it may hold the user's other MCP servers); the caller
|
|
11
|
+
* degrades that to a warning. */
|
|
9
12
|
export function writeMcpJson(root, inv) {
|
|
10
13
|
const file = join(root, ".mcp.json");
|
|
11
14
|
let json = {};
|
|
12
15
|
if (existsSync(file)) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
const raw = readFileSync(file, "utf8");
|
|
17
|
+
if (raw.trim()) {
|
|
18
|
+
try {
|
|
19
|
+
const v = JSON.parse(raw);
|
|
20
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
21
|
+
throw new Error("not a JSON object");
|
|
22
|
+
json = v;
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
throw new Error(`refusing to overwrite ${file}: could not parse it (${e.message}). Fix or remove it, then re-run.`);
|
|
26
|
+
}
|
|
18
27
|
}
|
|
19
28
|
}
|
|
20
29
|
json.mcpServers = json.mcpServers ?? {};
|
|
@@ -126,6 +135,12 @@ export function installClaudeHooks(root, hookCmd) {
|
|
|
126
135
|
...keep(json.hooks.UserPromptSubmit),
|
|
127
136
|
{ hooks: [{ type: "command", command: hookCmd }] },
|
|
128
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
|
+
];
|
|
129
144
|
const next = JSON.stringify(json, null, 2) + "\n";
|
|
130
145
|
if (existed && before === next)
|
|
131
146
|
return { path: file, action: "unchanged" };
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -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
|
|
274
|
-
*
|
|
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
|
|
406
|
-
|
|
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
|
|
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
|
package/dist/wiki/adopt.js
CHANGED
|
@@ -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
|
-
/**
|
|
59
|
-
|
|
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,
|
|
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) =>
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
506
|
-
|
|
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.
|
|
3
|
+
"version": "1.3.0",
|
|
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).",
|