@davesheffer/hunch 1.7.1 → 1.8.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.
@@ -29,6 +29,7 @@ import { nowData, wikiStatus, publicHome, readWikiManifestAt } from "../wiki/wik
29
29
  import { HUNCH_VERSION } from "../core/version.js";
30
30
  import { indexRepo } from "../extractors/indexer.js";
31
31
  import { liveForTopic, historyForTopic, rejectedForTopic, captureConflicts } from "../core/topics.js";
32
+ import { pendingEscalations, policyEscalations } from "../core/escalations.js";
32
33
  import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken } from "../core/capturetoken.js";
33
34
  import { randomUUID } from "node:crypto";
34
35
  const ok = (text) => ({ content: [{ type: "text", text }] });
@@ -341,7 +342,38 @@ export function buildServer(root) {
341
342
  for (const r of roadmap)
342
343
  L.push(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})\n ${r.note}`);
343
344
  if (pendingReview > 0)
344
- L.push("", `${pendingReview} auto-drafted proposal(s) awaiting review — \`hunch review\`.`);
345
+ L.push("", `${pendingReview} legacy un-vouched draft(s) — \`hunch adopt-drafts\` auto-trusts them as advisory (new captures land trusted automatically).`);
346
+ const escalations = pendingEscalations(store.json.loadAll("decisions"));
347
+ if (escalations.length) {
348
+ L.push("", `⚖ ${escalations.length} decision(s) need the human's call — ASK inline (never queue): ${escalations.map((e) => e.question).join(" · ")}`);
349
+ }
350
+ return ok(L.join("\n"));
351
+ });
352
+ // -- hunch_escalations (the inline "ask the human" surface) -----------------
353
+ // Captured memory auto-trusts; this returns ONLY what the graph can't resolve
354
+ // itself, framed as questions to raise in conversation: topic conflicts, plus the
355
+ // Constitution's human moments (a candidate awaiting review, a proposed policy
356
+ // whose activation is a human call — §59.5.3). Public store only — same
357
+ // jurisdiction rule as hunch_now (an assistant may paste it). Client-agnostic
358
+ // (con_e04226bd05): no Claude-specific behavior.
359
+ server.registerTool("hunch_escalations", {
360
+ title: "Decisions the human must make now (ask inline, not a queue)",
361
+ description: "The rare decisions the graph cannot resolve on its own — surfaced so you ASK THE USER in the prompt at the moment, then act. Auto-captured memory is trusted automatically and never appears here; this returns topic conflicts (>1 live decision for one topic) and Constitution human moments (candidate policies awaiting review, proposed policies awaiting an activation decision). Normally empty. Raise each question with the user; do NOT decide it for them — an entry is a question, never an approval. Public store only.",
362
+ inputSchema: {},
363
+ }, async () => {
364
+ const items = pendingEscalations(store.json.loadAll("decisions"));
365
+ try {
366
+ items.push(...policyEscalations(new ConstitutionService(store, root).list({ publicOnly: true }).map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
367
+ }
368
+ catch { /* constitution unavailable — memory escalations still surface */ }
369
+ if (!items.length)
370
+ return ok("✓ Nothing needs a human decision — memory is auto-trusted and self-consistent.");
371
+ const L = [`${items.length} decision(s) need the human's call — ask each inline, don't decide it for them:`, ""];
372
+ for (const e of items) {
373
+ L.push(`⚖ ${e.question}`);
374
+ L.push(` ${e.detail}`);
375
+ L.push(` → ${e.resolution}`, "");
376
+ }
345
377
  return ok(L.join("\n"));
346
378
  });
347
379
  // -- hunch_wiki_status (generated-wiki freshness) ---------------------------
@@ -149,7 +149,14 @@ export async function syncCommit(store, root, sha, opts = {}) {
149
149
  // Auto-synthesized decisions are un-anchored (topic null) — a topic is a human
150
150
  // act, never a machine guess. Preserve one an earlier human capture attached.
151
151
  topic: existing?.topic ?? null,
152
- status: existing?.status === "accepted" ? "accepted" : "proposed",
152
+ // Auto-trust model: captured memory enters the graph LIVE (accepted = in-force
153
+ // advisory), never a `proposed` draft rotting in a review queue. It grounds and
154
+ // ranks immediately but NEVER blocks — the source stays llm_draft, so the veto /
155
+ // strict gates (which key on human_confirmed, not status) treat it as advisory
156
+ // and its tripwires stay unarmed until a human vouches INLINE (dec_a466655539
157
+ // intact). An existing human status (a deliberate `proposed` roadmap entry, or a
158
+ // `superseded` record) is preserved — re-sync never clobbers a human's intent.
159
+ status: existing?.status ?? "accepted",
153
160
  context: draft.context + constraintNote,
154
161
  decision: draft.decision,
155
162
  consequences: draft.consequences,
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Wiki memory graph — one self-contained, zero-dependency HTML page
3
+ * (<dir>/graph.html): the VISUAL KNOWLEDGE BASE over the wiki.
4
+ *
5
+ * • Components as circles — sized by code, colored by fragility, ringed when
6
+ * a blocking invariant guards them; dependencies as links.
7
+ * • Repo docs as squares wired to the components they describe — freshness is
8
+ * the color: grounded ✅ green, STALE ⚠ amber-red (pulsing), unverified ◻
9
+ * gray. A stale doc's click opens its wiki-managed healed copy.
10
+ * • An ACT-NOW panel: stale docs, drafts awaiting `hunch review`, unverified
11
+ * docs — each row highlights its nodes; nothing is ever mutated from here.
12
+ * • A time scrubber that replays the memory compounding, plus a this-week
13
+ * pulse on components whose decisions are fresh.
14
+ *
15
+ * Same doctrine as every wiki page: a derived VIEW, deterministic inputs only.
16
+ * The embedded JSON is the page's freshness hash input; the force layout and
17
+ * the "this week" cutoff run client-side at view time and are presentation.
18
+ * No CDN, no fetch — works as a local file in the private overlay repo
19
+ * (con_547fff76bd).
20
+ */
21
+ /** Pure assembly from already-computed wiki inputs — sorted for a stable hash. */
22
+ export function assembleGraphData(kind, entries, decisionDates, repoDocs = [], adoptedPageByRel = new Map(), pendingReview = 0) {
23
+ const ids = new Set(entries.map((e) => e.pack.component.id));
24
+ const nodes = entries.map((e) => ({
25
+ id: e.pack.component.id,
26
+ name: e.pack.component.name,
27
+ slug: e.slug,
28
+ responsibility: e.pack.component.responsibility.slice(0, 160),
29
+ fragility: e.pack.component.fragility,
30
+ symbols: e.pack.symbols.length,
31
+ blocking: e.pack.constraints.filter((c) => c.severity === "blocking").length,
32
+ constraints: e.pack.constraints.length,
33
+ bugs: e.pack.bugs.length,
34
+ decisions: e.pack.decisions
35
+ .map((d) => (decisionDates.get(d.id) ?? "").slice(0, 10))
36
+ .filter(Boolean)
37
+ .sort(),
38
+ })).sort((a, b) => a.id.localeCompare(b.id));
39
+ const links = [];
40
+ for (const e of entries) {
41
+ for (const dep of e.pack.dependsOn) {
42
+ if (ids.has(dep.id))
43
+ links.push({ source: e.pack.component.id, target: dep.id });
44
+ }
45
+ }
46
+ links.sort((a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target));
47
+ const componentsByDoc = new Map();
48
+ for (const e of entries) {
49
+ for (const d of e.pack.docs) {
50
+ const list = componentsByDoc.get(d.path) ?? [];
51
+ list.push(e.pack.component.id);
52
+ componentsByDoc.set(d.path, list);
53
+ }
54
+ }
55
+ const docs = repoDocs
56
+ .filter((d) => d.status === "grounded" || d.status === "stale" || d.status === "unverified")
57
+ .map((d) => ({
58
+ rel: d.rel,
59
+ title: d.title.slice(0, 80),
60
+ status: d.status,
61
+ adopted: adoptedPageByRel.get(d.rel) ?? null,
62
+ components: [...new Set(componentsByDoc.get(d.rel) ?? [])].sort(),
63
+ }))
64
+ .sort((a, b) => a.rel.localeCompare(b.rel));
65
+ return { kind, nodes, links, docs, pendingReview };
66
+ }
67
+ export function renderGraphPage(data) {
68
+ const json = JSON.stringify(data).replace(/</g, "\\u003c");
69
+ const priv = data.kind === "private"
70
+ ? `<div class="banner">⚠ PRIVATE — rendered from the full graph including the private overlay; do not publish.</div>`
71
+ : "";
72
+ return `<!DOCTYPE html>
73
+ <!-- hunch:wiki _graph — GENERATED memory graph by \`hunch wiki\`; do not edit by hand. -->
74
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
75
+ <title>Hunch — memory graph</title>
76
+ <style>
77
+ :root{--bg:#faf8f4;--fg:#26241f;--dim:#8a857b;--line:#c9c2b4;--panel:#f1ede4;--accent:#3a6b58;--warn:#b5544d;--stale:#c9822e}
78
+ @media (prefers-color-scheme: dark){:root{--bg:#16150f;--fg:#e8e4da;--dim:#8a857b;--line:#3d392f;--panel:#201e17;--accent:#5d9c82;--warn:#d0736b;--stale:#d99a45}}
79
+ html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);font:14px/1.45 system-ui,sans-serif;overflow:hidden}
80
+ .banner{position:fixed;top:0;left:0;right:0;background:var(--warn);color:#fff;text-align:center;padding:3px 8px;font-size:12px;z-index:9}
81
+ header{position:fixed;top:${data.kind === "private" ? "26px" : "8px"};left:14px;z-index:5;pointer-events:none;max-width:44vw}
82
+ header h1{margin:0;font-size:16px}header p{margin:2px 0 0;font-size:12px;color:var(--dim)}
83
+ svg{width:100vw;height:100vh;display:block;cursor:grab}
84
+ svg.panning{cursor:grabbing}
85
+ .link{stroke:var(--line);stroke-width:1.2}
86
+ .doclink{stroke:var(--line);stroke-width:1;stroke-dasharray:2 3;opacity:.7}
87
+ .node circle.body{stroke:var(--bg);stroke-width:1.5;cursor:pointer}
88
+ .node circle.shield{fill:none;stroke:var(--warn);stroke-width:2;stroke-dasharray:3 3}
89
+ .node circle.mem{fill:var(--accent);opacity:.16;pointer-events:none}
90
+ .node circle.week{fill:none;stroke:var(--accent);stroke-width:2;opacity:.9}
91
+ .node text{font-size:11px;fill:var(--fg);pointer-events:none;text-anchor:middle}
92
+ .node.asleep{opacity:.22}
93
+ .doc rect{stroke:var(--bg);stroke-width:1.2;cursor:pointer;rx:3}
94
+ .doc.grounded rect{fill:#7fae9a}
95
+ .doc.unverified rect{fill:#9b968b}
96
+ .doc.stale rect{fill:var(--stale);animation:pulse 1.6s ease-in-out infinite}
97
+ @keyframes pulse{50%{opacity:.45}}
98
+ .doc text{font-size:10px;fill:var(--dim);pointer-events:none;text-anchor:middle}
99
+ .hi .body,.hi rect{stroke:var(--fg) !important;stroke-width:3 !important}
100
+ #tip{position:fixed;display:none;max-width:320px;background:var(--panel);border:1px solid var(--line);border-radius:6px;
101
+ padding:8px 10px;font-size:12px;pointer-events:none;z-index:8}
102
+ #tip b{display:block;margin-bottom:2px}
103
+ #bar{position:fixed;left:50%;transform:translateX(-50%);bottom:14px;background:var(--panel);border:1px solid var(--line);
104
+ border-radius:8px;padding:8px 14px;display:flex;gap:12px;align-items:center;z-index:6}
105
+ #bar input[type=range]{width:min(40vw,380px)}
106
+ #bar button{background:var(--accent);border:0;color:#fff;border-radius:4px;padding:3px 10px;cursor:pointer;font-size:13px}
107
+ #when{font-variant-numeric:tabular-nums;min-width:170px;text-align:left;font-size:12px;color:var(--dim)}
108
+ #act{position:fixed;right:14px;top:${data.kind === "private" ? "34px" : "14px"};width:250px;background:var(--panel);border:1px solid var(--line);
109
+ border-radius:8px;padding:10px 12px;font-size:12px;z-index:6}
110
+ #act h2{margin:0 0 6px;font-size:13px}
111
+ #act .row{display:flex;justify-content:space-between;gap:8px;padding:4px 6px;border-radius:5px;cursor:pointer;margin:2px -6px}
112
+ #act .row:hover{background:var(--bg)}
113
+ #act .row b{font-variant-numeric:tabular-nums}
114
+ #act .ok{color:var(--dim);cursor:default}
115
+ #act .hint{color:var(--dim);margin-top:6px;font-size:11px}
116
+ #act .warnc{color:var(--stale)}#act .badc{color:var(--warn)}
117
+ #legend{position:fixed;right:14px;bottom:14px;background:var(--panel);border:1px solid var(--line);border-radius:8px;
118
+ padding:8px 12px;font-size:11px;color:var(--dim);z-index:6}
119
+ #legend .sw{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px;vertical-align:-1px}
120
+ #legend .sq{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:4px;vertical-align:-1px}
121
+ </style></head><body>
122
+ ${priv}
123
+ <header><h1>🧠 Memory graph — the knowledge base</h1><p>Circles = components (size = code, color = fragility, dashed red ring = blocking invariant, halo = memory). Squares = docs, colored by TRUST: green grounded, pulsing amber STALE, gray unverified. Drag time to replay the compounding. Click anything to read it.</p></header>
124
+ <svg id="s"><g id="view"><g id="links"></g><g id="nodes"></g></g></svg>
125
+ <div id="tip"></div>
126
+ <div id="act"><h2>⚡ Act now</h2><div id="actrows"></div></div>
127
+ <div id="bar"><button id="play">▶ replay</button><input id="t" type="range" min="0" max="1000" value="1000"><span id="when"></span></div>
128
+ <div id="legend"><span class="sw" style="background:#7fae9a"></span>calm <span class="sw" style="background:#c9a24a"></span>warm <span class="sw" style="background:#c96a4a"></span>fragile · <span class="sq" style="background:#7fae9a"></span>grounded <span class="sq" style="background:var(--stale)"></span>stale <span class="sq" style="background:#9b968b"></span>unverified</div>
129
+ <script id="hunch-graph-data" type="application/json">${json}</script>
130
+ <script>
131
+ "use strict";
132
+ const DATA = JSON.parse(document.getElementById("hunch-graph-data").textContent);
133
+ const svg = document.getElementById("s"), view = document.getElementById("view");
134
+ const W = innerWidth, H = innerHeight;
135
+ const N = DATA.nodes.map((n, i) => ({ ...n, kind: "cmp",
136
+ x: W/2 + Math.cos(i / Math.max(1, DATA.nodes.length) * 2 * Math.PI) * Math.min(W,H)/3.4,
137
+ y: H/2 + Math.sin(i / Math.max(1, DATA.nodes.length) * 2 * Math.PI) * Math.min(W,H)/3.4,
138
+ vx: 0, vy: 0, r: 7 + Math.sqrt(n.symbols || 1) * 1.4 }));
139
+ const byId = new Map(N.map((n) => [n.id, n]));
140
+ const D = DATA.docs.map((d, i) => ({ ...d, kind: "doc",
141
+ x: W/2 + Math.cos((i + .5) / Math.max(1, DATA.docs.length) * 2 * Math.PI) * Math.min(W,H)/2.2,
142
+ y: H/2 + Math.sin((i + .5) / Math.max(1, DATA.docs.length) * 2 * Math.PI) * Math.min(W,H)/2.2,
143
+ vx: 0, vy: 0, r: 7 }));
144
+ const ALL = N.concat(D);
145
+ const L = DATA.links.map((l) => ({ a: byId.get(l.source), b: byId.get(l.target), doc: false }))
146
+ .concat(D.flatMap((d) => d.components.map((c) => ({ a: d, b: byId.get(c), doc: true }))))
147
+ .filter((l) => l.a && l.b);
148
+
149
+ const DATES = N.flatMap((n) => n.decisions).sort();
150
+ const T0 = DATES.length ? Date.parse(DATES[0]) : Date.now();
151
+ const T1 = DATES.length ? Date.parse(DATES[DATES.length - 1]) + 864e5 : Date.now();
152
+ const WEEK_AGO = Date.now() - 7 * 864e5; // presentation only — never hashed
153
+
154
+ function frag(f){
155
+ const stops = [[0,[127,174,154]],[.4,[201,162,74]],[1,[201,106,74]]];
156
+ let lo = stops[0], hi = stops[stops.length-1];
157
+ for (let i=0;i<stops.length-1;i++) if (f>=stops[i][0] && f<=stops[i+1][0]) { lo=stops[i]; hi=stops[i+1]; break; }
158
+ const t = hi[0]===lo[0] ? 0 : (f-lo[0])/(hi[0]-lo[0]);
159
+ const c = lo[1].map((v,i)=>Math.round(v+(hi[1][i]-v)*t));
160
+ return "rgb("+c.join(",")+")";
161
+ }
162
+
163
+ const linkEls = L.map((l) => { const e = document.createElementNS("http://www.w3.org/2000/svg","line"); e.setAttribute("class", l.doc ? "doclink" : "link"); document.getElementById("links").appendChild(e); return e; });
164
+
165
+ const tipEl = document.getElementById("tip");
166
+ function showTip(ev, html){
167
+ tipEl.innerHTML = html; tipEl.style.display = "block";
168
+ tipEl.style.left = Math.min(ev.clientX + 14, innerWidth - 330) + "px";
169
+ tipEl.style.top = (ev.clientY + 14) + "px";
170
+ }
171
+
172
+ const nodeEls = N.map((n) => {
173
+ const g = document.createElementNS("http://www.w3.org/2000/svg","g"); g.setAttribute("class","node");
174
+ const mem = document.createElementNS("http://www.w3.org/2000/svg","circle"); mem.setAttribute("class","mem");
175
+ const body = document.createElementNS("http://www.w3.org/2000/svg","circle"); body.setAttribute("class","body");
176
+ body.setAttribute("r", n.r); body.setAttribute("fill", frag(Math.min(1, n.fragility)));
177
+ g.appendChild(mem); g.appendChild(body);
178
+ if (n.blocking) { const sh = document.createElementNS("http://www.w3.org/2000/svg","circle"); sh.setAttribute("class","shield"); sh.setAttribute("r", n.r + 3.5); g.appendChild(sh); }
179
+ if (n.decisions.some((d)=>Date.parse(d) >= WEEK_AGO)) { const wk = document.createElementNS("http://www.w3.org/2000/svg","circle"); wk.setAttribute("class","week"); wk.setAttribute("r", n.r + 7); g.appendChild(wk); }
180
+ const label = document.createElementNS("http://www.w3.org/2000/svg","text"); label.textContent = n.name; label.setAttribute("dy", -(n.r + 9));
181
+ g.appendChild(label);
182
+ body.addEventListener("click", () => { if (!dragged) location.href = n.slug + ".md"; });
183
+ body.addEventListener("mousemove", (ev) => showTip(ev, "<b>" + n.name + "</b>" + (n.responsibility || "") +
184
+ "<br><span style='opacity:.7'>" + n.decisions.length + " decisions · " + n.constraints + " invariants" +
185
+ (n.blocking ? " (" + n.blocking + " blocking)" : "") + " · " + n.bugs + " bugs · fragility " + n.fragility.toFixed(2) + "</span>"));
186
+ body.addEventListener("mouseleave", () => { tipEl.style.display = "none"; });
187
+ attachDrag(g, n);
188
+ document.getElementById("nodes").appendChild(g);
189
+ return { g, mem };
190
+ });
191
+
192
+ const STATUS_TIP = { grounded: "anchored to current decisions — safe to trust",
193
+ stale: "contradicts the live decision — read the wiki-managed copy; heal the original (hunch heal)",
194
+ unverified: "Hunch can't vouch — anchor it with a hunch:topic marker" };
195
+ const docEls = D.map((d) => {
196
+ const g = document.createElementNS("http://www.w3.org/2000/svg","g"); g.setAttribute("class","doc " + d.status);
197
+ const rect = document.createElementNS("http://www.w3.org/2000/svg","rect");
198
+ rect.setAttribute("width", 14); rect.setAttribute("height", 14); rect.setAttribute("x", -7); rect.setAttribute("y", -7);
199
+ const label = document.createElementNS("http://www.w3.org/2000/svg","text"); label.textContent = d.title; label.setAttribute("dy", -12);
200
+ g.appendChild(rect); g.appendChild(label);
201
+ rect.addEventListener("click", () => {
202
+ if (dragged) return;
203
+ if (d.status === "stale" && d.adopted) location.href = d.adopted; // read the healed copy
204
+ else if (DATA.kind === "public") location.href = "../" + d.rel; // original (main repo)
205
+ });
206
+ rect.addEventListener("mousemove", (ev) => showTip(ev, "<b>📄 " + d.title + "</b><code>" + d.rel + "</code>" +
207
+ "<br><span style='opacity:.7'>" + d.status + " — " + STATUS_TIP[d.status] + "</span>" +
208
+ (d.status === "stale" && d.adopted ? "<br><span style='opacity:.7'>click → wiki-managed healed copy</span>" : "")));
209
+ rect.addEventListener("mouseleave", () => { tipEl.style.display = "none"; });
210
+ attachDrag(g, d);
211
+ document.getElementById("nodes").appendChild(g);
212
+ return { g };
213
+ });
214
+
215
+ // --- Act now panel: read-only rows that HIGHLIGHT their nodes ----------------
216
+ const rows = document.getElementById("actrows");
217
+ let hiSet = null;
218
+ function setHi(items){
219
+ hiSet = hiSet === items ? null : items;
220
+ D.forEach((d, i) => docEls[i].g.classList.toggle("hi", !!hiSet && hiSet.includes(d)));
221
+ N.forEach((n, i) => nodeEls[i].g.classList.toggle("hi", false));
222
+ }
223
+ function row(cls, label, count, onClick, hint){
224
+ const r = document.createElement("div"); r.className = "row" + (count ? "" : " ok");
225
+ r.innerHTML = "<span class='" + cls + "'>" + label + "</span><b>" + count + "</b>";
226
+ if (count && onClick) r.addEventListener("click", onClick);
227
+ if (count && hint) r.title = hint;
228
+ rows.appendChild(r);
229
+ }
230
+ const staleDocs = D.filter((d) => d.status === "stale");
231
+ const unverified = D.filter((d) => d.status === "unverified");
232
+ row("badc", "⚠ stale docs — click to locate", staleDocs.length, () => setHi(staleDocs), "amber squares pulse; click one to read its healed copy");
233
+ row("warnc", "◻ unverified docs", unverified.length, () => setHi(unverified), "gray squares; anchor with a hunch:topic marker to ground them");
234
+ row("", "🗂 drafts awaiting review", DATA.pendingReview, null, null);
235
+ if (DATA.pendingReview) { const h = document.createElement("div"); h.className = "hint"; h.textContent = "triage in a terminal: hunch review"; rows.appendChild(h); }
236
+ if (!staleDocs.length && !unverified.length && !DATA.pendingReview) { const h = document.createElement("div"); h.className = "hint"; h.textContent = "Nothing needs you — the knowledge base is clean. 🎉"; rows.appendChild(h); }
237
+
238
+ // --- tiny force simulation ----------------------------------------------------
239
+ let alpha = 1;
240
+ function tick(){
241
+ for (let i=0;i<ALL.length;i++) for (let j=i+1;j<ALL.length;j++){
242
+ const a=ALL[i], b=ALL[j]; let dx=b.x-a.x, dy=b.y-a.y; let d2=dx*dx+dy*dy || 1;
243
+ const f = Math.min((a.kind==="doc"||b.kind==="doc" ? 700 : 1200)/d2, .6); dx*=f; dy*=f; a.vx-=dx; a.vy-=dy; b.vx+=dx; b.vy+=dy;
244
+ }
245
+ for (const l of L){
246
+ const dx=l.b.x-l.a.x, dy=l.b.y-l.a.y, d=Math.sqrt(dx*dx+dy*dy)||1;
247
+ const rest = l.doc ? 70 : 120;
248
+ const f=(d-rest)/d*.02; l.a.vx+=dx*f; l.a.vy+=dy*f; l.b.vx-=dx*f; l.b.vy-=dy*f;
249
+ }
250
+ for (const n of ALL){
251
+ n.vx += (W/2-n.x)*.0008; n.vy += (H/2-n.y)*.0008;
252
+ if (n !== held) { n.x += n.vx*alpha; n.y += n.vy*alpha; }
253
+ n.vx*=.85; n.vy*=.85;
254
+ }
255
+ alpha = Math.max(.06, alpha*.995);
256
+ L.forEach((l,i)=>{ linkEls[i].setAttribute("x1",l.a.x); linkEls[i].setAttribute("y1",l.a.y); linkEls[i].setAttribute("x2",l.b.x); linkEls[i].setAttribute("y2",l.b.y); });
257
+ N.forEach((n,i)=>{ nodeEls[i].g.setAttribute("transform","translate("+n.x+","+n.y+")"); });
258
+ D.forEach((d,i)=>{ docEls[i].g.setAttribute("transform","translate("+d.x+","+d.y+")"); });
259
+ requestAnimationFrame(tick);
260
+ }
261
+
262
+ // --- pan / zoom / drag ---------------------------------------------------------
263
+ let scale=1, tx=0, ty=0, held=null, dragged=false;
264
+ function applyView(){ view.setAttribute("transform","translate("+tx+","+ty+") scale("+scale+")"); }
265
+ svg.addEventListener("wheel",(e)=>{ e.preventDefault();
266
+ const k = e.deltaY < 0 ? 1.1 : 1/1.1, mx = e.clientX, my = e.clientY;
267
+ tx = mx - (mx - tx) * k; ty = my - (my - ty) * k; scale *= k; applyView();
268
+ },{passive:false});
269
+ let panning=null;
270
+ svg.addEventListener("mousedown",(e)=>{ if(e.target===svg||e.target===view){ panning={x:e.clientX-tx,y:e.clientY-ty}; svg.classList.add("panning"); }});
271
+ addEventListener("mousemove",(e)=>{ if(panning){ tx=e.clientX-panning.x; ty=e.clientY-panning.y; applyView(); }
272
+ if(held){ held.x=(e.clientX-tx)/scale; held.y=(e.clientY-ty)/scale; dragged=true; alpha=Math.max(alpha,.3); }});
273
+ addEventListener("mouseup",()=>{ panning=null; held=null; svg.classList.remove("panning"); setTimeout(()=>{dragged=false;},0); });
274
+ function attachDrag(g,n){ g.addEventListener("mousedown",(e)=>{ e.stopPropagation(); held=n; dragged=false; }); }
275
+
276
+ // --- the time scrubber: replay the memory compounding ---------------------------
277
+ const slider = document.getElementById("t"), when = document.getElementById("when"), play = document.getElementById("play");
278
+ function setTime(frac){
279
+ const t = T0 + (T1 - T0) * frac;
280
+ let total = 0;
281
+ N.forEach((n,i)=>{
282
+ const k = n.decisions.filter((d)=>Date.parse(d) <= t).length;
283
+ total += k;
284
+ nodeEls[i].mem.setAttribute("r", n.r + Math.sqrt(k) * 5);
285
+ nodeEls[i].g.classList.toggle("asleep", k === 0 && n.decisions.length > 0);
286
+ });
287
+ when.textContent = new Date(t).toISOString().slice(0,10) + " · " + total + " decision" + (total===1?"":"s") + " remembered";
288
+ }
289
+ slider.addEventListener("input",()=>setTime(slider.value/1000));
290
+ let timer=null;
291
+ play.addEventListener("click",()=>{
292
+ if (timer){ clearInterval(timer); timer=null; play.textContent="▶ replay"; return; }
293
+ slider.value=0; play.textContent="⏸";
294
+ timer=setInterval(()=>{ const v=Math.min(1000,+slider.value+6); slider.value=v; setTime(v/1000); if(v>=1000){ clearInterval(timer); timer=null; play.textContent="▶ replay"; } },50);
295
+ });
296
+ setTime(1);
297
+ tick();
298
+ </script></body></html>
299
+ `;
300
+ }
301
+ //# sourceMappingURL=graph.js.map
package/dist/wiki/wiki.js CHANGED
@@ -34,6 +34,7 @@ import { hunchPaths, toPosixTarget } from "../core/paths.js";
34
34
  import { isLive } from "../core/topics.js";
35
35
  import { scanRepoDocs } from "../core/docscan.js";
36
36
  import { adoptedSlug, adoptionHash, renderAdoptedDoc } from "./adopt.js";
37
+ import { assembleGraphData, renderGraphPage } from "./graph.js";
37
38
  /** Normalize a --dir override: POSIX separators, no trailing slash — the dir is
38
39
  * a committed manifest key prefix, so it must hash identically on every OS. */
39
40
  const normDir = (d) => d ? toPosixTarget(d).replace(/\/+$/, "") || undefined : undefined;
@@ -263,6 +264,7 @@ export function renderIndex(entries, repoWide, home, docs = []) {
263
264
  L.push("", `📄 [Specs & docs ledger](specs.md) — ${docs.length} repo doc(s): ${n("grounded")} grounded, ${n("stale")} stale, ${n("unverified")} unverified.`);
264
265
  }
265
266
  L.push("", "🔥 [Now — recent activity & roadmap](now.md)");
267
+ L.push("", "🕸 [Memory graph](graph.html) — the interactive map: components, dependencies, and a time scrubber that replays the memory compounding.");
266
268
  if (repoWide.length) {
267
269
  L.push("", "## Repo-wide invariants", "");
268
270
  for (const k of repoWide)
@@ -343,6 +345,8 @@ const SPECS_ID = "_specs";
343
345
  const INDEX_ID = "_index";
344
346
  /** Reserved manifest component id for the NOW page (activity ledger + roadmap). */
345
347
  const NOW_ID = "_now";
348
+ /** Reserved manifest component id for the interactive memory-graph page. */
349
+ const GRAPH_ID = "_graph";
346
350
  /** Manifest component-id prefix for adopted (wiki-managed) doc copies. */
347
351
  const ADOPTED_PREFIX = "doc:";
348
352
  /** The hot view's inputs: last `recentLimit` decisions by date (any status — a
@@ -389,7 +393,7 @@ export function renderNowPage(recent, roadmap, home, pendingReview = 0) {
389
393
  if (roadmap.length)
390
394
  L.push("");
391
395
  if (pendingReview > 0)
392
- L.push(`_${pendingReview} auto-drafted proposed decision(s) awaiting review are not shown — triage with \`hunch review\`._`, "");
396
+ L.push(`_${pendingReview} legacy un-vouched proposed decision(s) not shown — \`hunch adopt-drafts\` auto-trusts them as advisory memory._`, "");
393
397
  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
398
  return L.join("\n");
395
399
  }
@@ -477,6 +481,7 @@ export function wikiStatus(store, home, srcRoot) {
477
481
  rows: entries.map((e) => ({ slug: e.slug, name: e.pack.component.name, responsibility: e.pack.component.responsibility, decisions: e.pack.decisions.length, constraints: e.pack.constraints.length })),
478
482
  repoWide: repoWide.map((c) => ({ id: c.id, severity: c.severity, statement: c.statement })),
479
483
  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 },
484
+ graphLink: true, // the index links graph.html — pre-graph manifests re-render once
480
485
  })));
481
486
  const index = { page: indexPage, hash: indexHash, state: pageState(home, indexPage, INDEX_ID, indexHash, manifest?.pages[indexPage]).state };
482
487
  // The NOW page: recent activity + live proposed decisions (the roadmap).
@@ -484,11 +489,21 @@ export function wikiStatus(store, home, srcRoot) {
484
489
  const nowPage = `${home.dir}/now.md`;
485
490
  const nowHash = sha16(JSON.stringify(canonical({ recent, roadmap, pendingReview })));
486
491
  const now = { page: nowPage, hash: nowHash, state: pageState(home, nowPage, NOW_ID, nowHash, manifest?.pages[nowPage]).state, recent, roadmap, pendingReview };
492
+ // The memory-graph page — the visual knowledge base: components + dependencies
493
+ // + per-component decision dates (the scrubber's timeline) + every repo doc
494
+ // with its freshness grade (stale docs point at their adopted healed copy) +
495
+ // the actionable review count. Hashed over the exact embedded data — the
496
+ // client-side force layout is presentation and never participates.
497
+ const decisionDates = new Map(decisions.map((d) => [d.id, d.valid_from ?? d.date]));
498
+ const graphData = assembleGraphData(home.kind, entries, decisionDates, docs, adoptedPageByRel, pendingReview);
499
+ const graphPage = `${home.dir}/graph.html`;
500
+ const graphHash = sha16(JSON.stringify(canonical(graphData)));
501
+ const graph = { page: graphPage, hash: graphHash, state: pageState(home, graphPage, GRAPH_ID, graphHash, manifest?.pages[graphPage]).state, data: graphData };
487
502
  // Orphans by PAGE KEY, not component id: anything the manifest tracks that no
488
503
  // current artifact claims (deleted component, renamed component whose slug
489
504
  // moved, a retired adoption) gets removed on heal — nothing generated is ever
490
505
  // stranded on disk while the manifest forgets it.
491
- const expected = new Set([...entries.map((e) => e.page), ...adoptions.map((a) => a.page), specsPage, indexPage, nowPage]);
506
+ const expected = new Set([...entries.map((e) => e.page), ...adoptions.map((a) => a.page), specsPage, indexPage, nowPage, graphPage]);
492
507
  const orphans = [];
493
508
  const adoptionOrphans = [];
494
509
  for (const [page, p] of Object.entries(manifest?.pages ?? {})) {
@@ -496,7 +511,7 @@ export function wikiStatus(store, home, srcRoot) {
496
511
  continue;
497
512
  (p.component.startsWith(ADOPTED_PREFIX) ? adoptionOrphans : orphans).push(page);
498
513
  }
499
- return { home, entries, docs, adoptions, adoptionOrphans, decisions, specs, index, now, repoWide, orphans };
514
+ return { home, entries, docs, adoptions, adoptionOrphans, decisions, specs, index, now, graph, repoWide, orphans };
500
515
  }
501
516
  /** Read short excerpts of the component's heaviest files as LLM grounding.
502
517
  * Source files always live in the MAIN repo (`srcRoot`), even for the private
@@ -529,6 +544,7 @@ export async function generateWiki(store, srcRoot, home, opts) {
529
544
  const specsTarget = opts.only === "all" || status.specs.state !== "fresh";
530
545
  const indexTarget = opts.only === "all" || status.index.state !== "fresh";
531
546
  const nowTarget = opts.only === "all" || status.now.state !== "fresh";
547
+ const graphTarget = opts.only === "all" || status.graph.state !== "fresh";
532
548
  const log = opts.log ?? (() => { });
533
549
  const written = [];
534
550
  /** Written-bytes ledger — the hand-edit tripwire recorded per page. */
@@ -578,6 +594,10 @@ export async function generateWiki(store, srcRoot, home, opts) {
578
594
  put(status.now.page, renderNowPage(status.now.recent, status.now.roadmap, home, status.now.pendingReview));
579
595
  log(` ✎ ${status.now.page}${status.now.state === "fresh" ? "" : ` (${status.now.state})`} [${status.now.recent.length} recent, ${status.now.roadmap.length} roadmap]`);
580
596
  }
597
+ if (graphTarget) {
598
+ put(status.graph.page, renderGraphPage(status.graph.data));
599
+ log(` ✎ ${status.graph.page}${status.graph.state === "fresh" ? "" : ` (${status.graph.state})`} [${status.graph.data.nodes.length} node(s), ${status.graph.data.links.length} link(s)]`);
600
+ }
581
601
  if (indexTarget) {
582
602
  put(status.index.page, renderIndex(status.entries.map((e) => ({ pack: e.pack, slug: e.slug })), status.repoWide, home, status.docs));
583
603
  log(` ✎ ${status.index.page}${status.index.state === "fresh" ? "" : ` (${status.index.state})`}`);
@@ -611,6 +631,7 @@ export async function generateWiki(store, srcRoot, home, opts) {
611
631
  entry(status.specs.page, SPECS_ID, status.specs.hash, status.specs.state);
612
632
  entry(status.index.page, INDEX_ID, status.index.hash, status.index.state);
613
633
  entry(status.now.page, NOW_ID, status.now.hash, status.now.state);
634
+ entry(status.graph.page, GRAPH_ID, status.graph.hash, status.graph.state);
614
635
  writeWikiManifestAt(home.manifestPath, { version: 1, dir: home.dir, pages });
615
636
  }
616
637
  return { written, removed, unchanged: status.entries.length - targets.length };
@@ -663,6 +684,13 @@ export function computeWikiDrift(store, root) {
663
684
  detail: `the activity ledger / roadmap moved (a decision was recorded, accepted, or superseded) — regenerate with \`${heal}\`${where}`,
664
685
  });
665
686
  }
687
+ if (status.graph.state !== "fresh") {
688
+ findings.push({
689
+ kind: "wiki-stale",
690
+ id: status.graph.page,
691
+ detail: `the memory graph's inputs moved (components, dependencies, or decision dates) — regenerate with \`${heal}\`${where}`,
692
+ });
693
+ }
666
694
  for (const a of status.adoptions) {
667
695
  if (a.state === "fresh")
668
696
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.7.1",
3
+ "version": "1.8.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, Antigravity, Codex).",