@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.
- package/README.md +28 -0
- package/dist/cli/index.js +353 -9
- package/dist/constitution/experiment.js +60 -1
- package/dist/constitution/g3Conformance.js +26 -9
- package/dist/constitution/lifecycle.js +35 -0
- package/dist/constitution/repairPolicies.js +78 -0
- package/dist/constitution/schema.js +1 -1
- package/dist/constitution/service.js +72 -10
- package/dist/core/escalations.js +65 -0
- package/dist/core/memorylog.js +69 -0
- package/dist/core/repair.js +71 -0
- package/dist/core/reviewqueue.js +11 -0
- package/dist/extractors/git.js +39 -0
- package/dist/mcp/server.js +33 -1
- package/dist/synthesis/synthesize.js +8 -1
- package/dist/wiki/graph.js +301 -0
- package/dist/wiki/wiki.js +31 -3
- package/package.json +1 -1
package/dist/mcp/server.js
CHANGED
|
@@ -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}
|
|
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
|
-
|
|
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}
|
|
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.
|
|
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).",
|