@davesheffer/hunch 0.38.3 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +81 -1
- package/dist/core/capturetoken.js +36 -0
- package/dist/core/drift.js +27 -0
- package/dist/core/topics.js +81 -0
- package/dist/core/types.js +7 -0
- package/dist/integrations/scaffold.js +24 -0
- package/dist/mcp/server.js +91 -2
- package/dist/store/hunchStore.js +9 -0
- package/dist/synthesis/synthesize.js +3 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -50,6 +50,7 @@ import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpol
|
|
|
50
50
|
import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
|
|
51
51
|
import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
|
|
52
52
|
import { computeDrift } from "../core/drift.js";
|
|
53
|
+
import { topicCollisions, renderGrounding } from "../core/topics.js";
|
|
53
54
|
import { compareCandidates } from "../core/compare.js";
|
|
54
55
|
import { checkConformance } from "../core/conformance.js";
|
|
55
56
|
import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
|
|
@@ -721,7 +722,7 @@ program
|
|
|
721
722
|
const id = decisionId(`inline:${it.file}:${it.text}`);
|
|
722
723
|
const prev = store.recs("decisions").find((d) => d.id === id); // preserve window for idempotent re-capture
|
|
723
724
|
const rec = {
|
|
724
|
-
id, title: it.text, status: "accepted",
|
|
725
|
+
id, title: it.text, topic: prev?.topic ?? null, status: "accepted",
|
|
725
726
|
context: `Captured from an inline hunch-why comment (${it.file}:${it.line}).`,
|
|
726
727
|
decision: it.text, consequences: [], alternatives_rejected: [], rejected_tripwires: [],
|
|
727
728
|
related_components: [], related_files: [it.file], supersedes: null, superseded_by: null,
|
|
@@ -1534,6 +1535,11 @@ program
|
|
|
1534
1535
|
const items = retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ");
|
|
1535
1536
|
text += `\n\n⚠ Deliberately RETIRED from this file — do not re-introduce without cause: ${items}.`;
|
|
1536
1537
|
}
|
|
1538
|
+
// Decision-grounding (§3): for topic-anchored decisions governing this file, state
|
|
1539
|
+
// the current decision assertively (graph over any stale doc) + what it rejected.
|
|
1540
|
+
const grounding = renderGrounding(ctx.decisions);
|
|
1541
|
+
if (grounding)
|
|
1542
|
+
text += `\n\n${grounding}`;
|
|
1537
1543
|
emitContext("PreToolUse", text);
|
|
1538
1544
|
}
|
|
1539
1545
|
catch {
|
|
@@ -1680,6 +1686,80 @@ program
|
|
|
1680
1686
|
}
|
|
1681
1687
|
store.close();
|
|
1682
1688
|
});
|
|
1689
|
+
// ---- reconcile-topics (decision-grounding §4 Enforcement) -----------------
|
|
1690
|
+
program
|
|
1691
|
+
.command("reconcile-topics")
|
|
1692
|
+
.description("Find topics with more than one live decision (the invariant a git merge can violate) and surface them for human resolution. Exits non-zero if any collision exists — wire into a post-merge hook or CI.")
|
|
1693
|
+
.action(() => {
|
|
1694
|
+
const { store } = storeFor();
|
|
1695
|
+
try {
|
|
1696
|
+
const collisions = topicCollisions(store.recs("decisions"));
|
|
1697
|
+
if (collisions.size === 0) {
|
|
1698
|
+
console.log("✓ No topic collisions — every topic has at most one live decision.");
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
console.error(`⚠ ${collisions.size} topic(s) have more than one live decision — the graph cannot say which is current. Resolve each (supersede one, or split the topic):\n`);
|
|
1702
|
+
for (const [topic, decs] of collisions) {
|
|
1703
|
+
console.error(` topic "${topic}":`);
|
|
1704
|
+
for (const d of decs)
|
|
1705
|
+
console.error(` - ${d.id} — "${d.title}" (${d.status})`);
|
|
1706
|
+
}
|
|
1707
|
+
console.error(`\nResolve: re-record one with supersedes:<other-id> to link it over the other, or give one a distinct topic to split.`);
|
|
1708
|
+
process.exitCode = 1;
|
|
1709
|
+
}
|
|
1710
|
+
finally {
|
|
1711
|
+
store.close();
|
|
1712
|
+
}
|
|
1713
|
+
});
|
|
1714
|
+
// ---- drift (doc≠graph detector; advisory + CI-gateable) -------------------
|
|
1715
|
+
program
|
|
1716
|
+
.command("drift")
|
|
1717
|
+
.description("Detect memory drift: dead refs, dangling supersedes, stale 'proposed' docs, and doc≠graph anchor-stale (a file still anchored to a superseded decision). Exits non-zero on any anchor-stale drift or topic collision — the doc≠graph gate.")
|
|
1718
|
+
.action(() => {
|
|
1719
|
+
const { store, root } = storeFor();
|
|
1720
|
+
try {
|
|
1721
|
+
const { findings } = computeDrift(store, root);
|
|
1722
|
+
const collisions = topicCollisions(store.recs("decisions"));
|
|
1723
|
+
if (!findings.length && collisions.size === 0) {
|
|
1724
|
+
console.log("✓ No drift — memory is in sync with the code/docs.");
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
for (const f of findings.slice(0, 50))
|
|
1728
|
+
console.log(`· [${f.kind}] ${f.id} — ${f.detail}`);
|
|
1729
|
+
for (const [topic, decs] of collisions)
|
|
1730
|
+
console.log(`· [topic-collision] "${topic}" has ${decs.length} live decisions: ${decs.map((d) => d.id).join(", ")} — run \`hunch reconcile-topics\``);
|
|
1731
|
+
const anchor = findings.filter((f) => f.kind === "anchor-stale").length;
|
|
1732
|
+
console.log(`\n${findings.length} finding(s)${anchor ? `, ${anchor} doc≠graph (anchor-stale)` : ""}${collisions.size ? `, ${collisions.size} topic-collision(s)` : ""}.`);
|
|
1733
|
+
if (anchor || collisions.size)
|
|
1734
|
+
process.exitCode = 1;
|
|
1735
|
+
}
|
|
1736
|
+
finally {
|
|
1737
|
+
store.close();
|
|
1738
|
+
}
|
|
1739
|
+
});
|
|
1740
|
+
// ---- heal (decision-grounded drift reconciliation front door) -------------
|
|
1741
|
+
program
|
|
1742
|
+
.command("heal")
|
|
1743
|
+
.description("Decision-grounded drift reconciliation: report doc≠graph anchor-stale sections with the current decision to reconcile toward. Read-only — proposes, never rewrites. Escalate to /capture only if the DECISION (not the doc) is stale.")
|
|
1744
|
+
.action(() => {
|
|
1745
|
+
const { store, root } = storeFor();
|
|
1746
|
+
try {
|
|
1747
|
+
const anchor = computeDrift(store, root).findings.filter((f) => f.kind === "anchor-stale");
|
|
1748
|
+
if (!anchor.length) {
|
|
1749
|
+
console.log("✓ No doc≠graph drift to heal — every anchored view matches its current decision.");
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
console.log(`${anchor.length} anchored section(s) drifted from the graph:\n`);
|
|
1753
|
+
for (const f of anchor)
|
|
1754
|
+
console.log(`· ${f.detail}`);
|
|
1755
|
+
console.log(`\nHeal A (doc stale): edit each file to match its CURRENT decision — a prose fix.`);
|
|
1756
|
+
console.log(`Heal B (decision stale): only if the DECISION is wrong now, run /capture (hunch_capture_decision) to supersede it, then re-derive the doc.`);
|
|
1757
|
+
console.log(`Hunch never rewrites prose for you; this is a read-only reconciliation report.`);
|
|
1758
|
+
}
|
|
1759
|
+
finally {
|
|
1760
|
+
store.close();
|
|
1761
|
+
}
|
|
1762
|
+
});
|
|
1683
1763
|
// ---- compact (bound Hunch growth) -----------------------------------------
|
|
1684
1764
|
program
|
|
1685
1765
|
.command("compact")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture-session tokens (decision-grounding, DESIGN §5 Stage 1 / §9.3).
|
|
3
|
+
*
|
|
4
|
+
* hunch_capture_decision issues a short-lived token; the commit path consumes it, so a
|
|
5
|
+
* decision written through the capture front door is provably the tail of an interview
|
|
6
|
+
* — the identity-principle guard against a silent, un-interviewed write. In-memory (the
|
|
7
|
+
* MCP server is long-lived); tokens are one-time-use and expire so an abandoned
|
|
8
|
+
* interview can't leak. Absence of a token never BLOCKS a write yet (staged
|
|
9
|
+
* deprecation §9.3) — the caller decides how to treat an un-gated write.
|
|
10
|
+
*/
|
|
11
|
+
const CAPTURE_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 min
|
|
12
|
+
const sessions = new Map(); // token -> issuedAt (epoch ms)
|
|
13
|
+
/** Issue a token stamped `now` (epoch ms). Prunes expired tokens first so the map can't
|
|
14
|
+
* grow unbounded across a long server life. `mint` supplies the random id (injectable
|
|
15
|
+
* for tests); the call site passes crypto.randomUUID. */
|
|
16
|
+
export function issueCaptureToken(mint, now) {
|
|
17
|
+
for (const [tok, at] of sessions)
|
|
18
|
+
if (now - at > CAPTURE_TOKEN_TTL_MS)
|
|
19
|
+
sessions.delete(tok);
|
|
20
|
+
const token = mint();
|
|
21
|
+
sessions.set(token, now);
|
|
22
|
+
return token;
|
|
23
|
+
}
|
|
24
|
+
/** Consume a token iff it is a live, unexpired capture session. One-time use: a second
|
|
25
|
+
* consume of the same token returns false. */
|
|
26
|
+
export function consumeCaptureToken(token, now) {
|
|
27
|
+
if (!token)
|
|
28
|
+
return false;
|
|
29
|
+
const at = sessions.get(token);
|
|
30
|
+
if (at === undefined)
|
|
31
|
+
return false;
|
|
32
|
+
sessions.delete(token);
|
|
33
|
+
return now - at <= CAPTURE_TOKEN_TTL_MS;
|
|
34
|
+
}
|
|
35
|
+
export { CAPTURE_TOKEN_TTL_MS };
|
|
36
|
+
//# sourceMappingURL=capturetoken.js.map
|
package/dist/core/drift.js
CHANGED
|
@@ -10,12 +10,19 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
12
12
|
import { join, extname } from "node:path";
|
|
13
|
+
import { toPosixTarget } from "./paths.js";
|
|
14
|
+
import { currentForTopic, isLive } from "./topics.js";
|
|
13
15
|
const STALE_MARKER = /\b(proposed|not yet implemented|no code yet)\b/i;
|
|
14
16
|
const SRC_REF = /\bsrc\/[A-Za-z0-9_\-/]+\.ts\b/g;
|
|
15
17
|
export function computeDrift(store, root) {
|
|
16
18
|
const findings = [];
|
|
17
19
|
const decisions = store.recs("decisions");
|
|
18
20
|
const byId = new Map(decisions.map((d) => [d.id, d]));
|
|
21
|
+
// Files any LIVE decision (any topic) still claims. A file governed by a live decision
|
|
22
|
+
// is NOT orphaned to a stale one — only a file listed solely by superseded decisions is
|
|
23
|
+
// anchor-stale. Keeps the doc≠graph gate's false-positive rate ~zero: a routine
|
|
24
|
+
// narrowing supersession (successor lists fewer files) never flags files still governed.
|
|
25
|
+
const liveFiles = new Set(decisions.filter(isLive).flatMap((d) => (d.related_files ?? []).map(toPosixTarget)));
|
|
19
26
|
for (const d of decisions) {
|
|
20
27
|
// 1. DEAD-REFERENCE — only for in-force decisions; a superseded one referencing
|
|
21
28
|
// a since-deleted file is legitimate history, not drift.
|
|
@@ -44,6 +51,26 @@ export function computeDrift(store, root) {
|
|
|
44
51
|
});
|
|
45
52
|
}
|
|
46
53
|
}
|
|
54
|
+
// 4. ANCHOR-STALE (doc≠graph, decision-grounding) — a derived view still anchored
|
|
55
|
+
// to a SUPERSEDED decision while a current one exists for the same topic. Fully
|
|
56
|
+
// deterministic: fires only on the explicit topic anchor + a live successor
|
|
57
|
+
// (never a semantic guess), and only for a file NO live decision claims. Advisory.
|
|
58
|
+
if (d.topic && (d.status === "superseded" || d.superseded_by)) {
|
|
59
|
+
const current = currentForTopic(decisions, d.topic);
|
|
60
|
+
if (current && current.id !== d.id) {
|
|
61
|
+
for (const f of d.related_files ?? []) {
|
|
62
|
+
if (!f || f.includes("*") || liveFiles.has(toPosixTarget(f)))
|
|
63
|
+
continue;
|
|
64
|
+
if (!existsSync(join(root, f)))
|
|
65
|
+
continue; // missing file is history → dead-ref's job
|
|
66
|
+
findings.push({
|
|
67
|
+
kind: "anchor-stale",
|
|
68
|
+
id: d.id,
|
|
69
|
+
detail: `"${f}" is anchored to superseded decision ${d.id} (topic "${d.topic}"); the current decision is ${current.id} — "${current.title}". Reconcile the file with the current decision.`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
47
74
|
}
|
|
48
75
|
// 3. DOC-STALE — a doc that still advertises "proposed / not implemented" while
|
|
49
76
|
// referencing code that exists. Heuristic + advisory; scoped to the repo's own
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** A decision is "live" for a topic when it is the accepted, non-superseded,
|
|
2
|
+
* still-in-force entry: the status gate plus both closure links open. Matches the
|
|
3
|
+
* in-force predicate used across the veto/regression guards. */
|
|
4
|
+
export function isLive(d) {
|
|
5
|
+
return d.status === "accepted" && d.superseded_by === null && d.valid_to === null;
|
|
6
|
+
}
|
|
7
|
+
/** Every live decision anchored to `topic`. In a healthy graph this is length 0 or 1;
|
|
8
|
+
* length > 1 is a topic collision the §4 resolution must settle. */
|
|
9
|
+
export function liveForTopic(decisions, topic) {
|
|
10
|
+
return decisions.filter((d) => d.topic === topic && isLive(d));
|
|
11
|
+
}
|
|
12
|
+
/** current(topic): the single live decision for a topic, or null. Null when there is
|
|
13
|
+
* none — AND when the topic is in an unresolved collision (>1 live), because an
|
|
14
|
+
* ambiguous current must never be injected as authoritative truth. */
|
|
15
|
+
export function currentForTopic(decisions, topic) {
|
|
16
|
+
const live = liveForTopic(decisions, topic);
|
|
17
|
+
return live.length === 1 ? live[0] : null;
|
|
18
|
+
}
|
|
19
|
+
/** history(topic): the full chain for a topic, newest first (by effect-time). */
|
|
20
|
+
export function historyForTopic(decisions, topic) {
|
|
21
|
+
return decisions
|
|
22
|
+
.filter((d) => d.topic === topic)
|
|
23
|
+
.sort((a, b) => (b.valid_from ?? b.date).localeCompare(a.valid_from ?? a.date));
|
|
24
|
+
}
|
|
25
|
+
/** rejected(topic): the alternatives the current decision ruled out — what Veto/drift
|
|
26
|
+
* check a derived view against. Empty when there is no unambiguous current decision. */
|
|
27
|
+
export function rejectedForTopic(decisions, topic) {
|
|
28
|
+
const cur = currentForTopic(decisions, topic);
|
|
29
|
+
return cur ? [...cur.alternatives_rejected] : [];
|
|
30
|
+
}
|
|
31
|
+
/** The live decisions that would COLLIDE if an `accepted` decision `selfId` is written
|
|
32
|
+
* on `topic` while superseding `willCloseId` (or null if it supersedes nothing). The
|
|
33
|
+
* self record and the incumbent this write will actually close are excluded; anything
|
|
34
|
+
* left is a second live decision the write must not create (the capture guard refuses
|
|
35
|
+
* when this is non-empty). `willCloseId` MUST be an incumbent the write can truly close
|
|
36
|
+
* (same store) — a cross-store supersede that will no-op must be passed as null so the
|
|
37
|
+
* incumbent stays counted and the write is refused. */
|
|
38
|
+
export function captureConflicts(decisions, topic, selfId, willCloseId) {
|
|
39
|
+
return liveForTopic(decisions, topic).filter((d) => d.id !== selfId && d.id !== willCloseId);
|
|
40
|
+
}
|
|
41
|
+
/** Read-time grounding block (§3): for the topic-anchored decisions governing an edited
|
|
42
|
+
* file, state the CURRENT decision assertively ("the graph overrides any doc that says
|
|
43
|
+
* otherwise") plus what it rejected. Input is the file-scoped IN-FORCE decisions from
|
|
44
|
+
* assembleContext, so no freshness re-check is needed here — a superseded-only-anchored
|
|
45
|
+
* file is caught by the anchor-stale drift check, and the commit-time staleness gate
|
|
46
|
+
* applies the age-downgrade. Returns "" when no anchored decision governs the file. */
|
|
47
|
+
export function renderGrounding(fileDecisions) {
|
|
48
|
+
const anchored = fileDecisions.filter((d) => d.topic && isLive(d));
|
|
49
|
+
if (!anchored.length)
|
|
50
|
+
return "";
|
|
51
|
+
const lines = anchored.map((d) => {
|
|
52
|
+
const rej = d.alternatives_rejected.length ? ` (rejected: ${d.alternatives_rejected.join("; ")})` : "";
|
|
53
|
+
return `• "${d.topic}": ${d.decision || d.title} [${d.id}]${rej}`;
|
|
54
|
+
});
|
|
55
|
+
return `🧭 Hunch grounding — this file is anchored to recorded decisions; follow the graph, not a stale doc:\n${lines.join("\n")}`;
|
|
56
|
+
}
|
|
57
|
+
/** Every topic with MORE THAN ONE live decision — the invariant violations a post-merge
|
|
58
|
+
* reconcile pass surfaces for human resolution. This is the distributed half of §4
|
|
59
|
+
* Enforcement: the content merge driver merges by id and is NOT invoked for cross-file
|
|
60
|
+
* ADD/ADD, so two branches each adding an `accepted` decision for one topic land both
|
|
61
|
+
* files with no collision. This scan catches them after the merge. Keyed by topic;
|
|
62
|
+
* value is the colliding live set (length >= 2), each sorted by id for stable output. */
|
|
63
|
+
export function topicCollisions(decisions) {
|
|
64
|
+
const byTopic = new Map();
|
|
65
|
+
for (const d of decisions) {
|
|
66
|
+
if (!d.topic || !isLive(d))
|
|
67
|
+
continue;
|
|
68
|
+
const arr = byTopic.get(d.topic);
|
|
69
|
+
if (arr)
|
|
70
|
+
arr.push(d);
|
|
71
|
+
else
|
|
72
|
+
byTopic.set(d.topic, [d]);
|
|
73
|
+
}
|
|
74
|
+
const collisions = new Map();
|
|
75
|
+
for (const [topic, arr] of byTopic) {
|
|
76
|
+
if (arr.length >= 2)
|
|
77
|
+
collisions.set(topic, [...arr].sort((a, b) => a.id.localeCompare(b.id)));
|
|
78
|
+
}
|
|
79
|
+
return collisions;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=topics.js.map
|
package/dist/core/types.js
CHANGED
|
@@ -109,6 +109,13 @@ export const ConformancePredicateSchema = z.object({
|
|
|
109
109
|
export const DecisionSchema = z.object({
|
|
110
110
|
id: z.string().describe("dec_*"),
|
|
111
111
|
title: z.string(),
|
|
112
|
+
// Decision-grounding anchor: the join key that relates a doc section, a decision,
|
|
113
|
+
// and a code region for drift detection. Exactly one topic per decision; null =
|
|
114
|
+
// un-anchored (still valid, just invisible to doc≠graph detection until tagged —
|
|
115
|
+
// honest and bounded). Optional-with-default, so every legacy record validates with
|
|
116
|
+
// no migration (Zod fills null on read); grounding freshness reuses the existing
|
|
117
|
+
// valid-time / last_verified signals rather than a separate clock.
|
|
118
|
+
topic: z.string().nullable().default(null).describe("decision-grounding anchor; one topic per decision, null = un-anchored"),
|
|
112
119
|
status: z.enum(["proposed", "accepted", "rejected", "superseded"]).default("proposed"),
|
|
113
120
|
context: z.string().default(""),
|
|
114
121
|
decision: z.string().default(""),
|
|
@@ -55,6 +55,28 @@ then produce a **fragility report with evidence**: the specific files/functions,
|
|
|
55
55
|
the bug history behind them, their churn and fan-in, and any missing guards.
|
|
56
56
|
Avoid generic advice — every claim must cite a Hunch record or metric.
|
|
57
57
|
`;
|
|
58
|
+
const CAPTURE_CMD = `---
|
|
59
|
+
description: Capture an engineering decision into Hunch's graph via a grilling interview (topic, rationale, rejected alternatives)
|
|
60
|
+
---
|
|
61
|
+
Capture the decision for **$ARGUMENTS** into Hunch's graph.
|
|
62
|
+
|
|
63
|
+
1. Call \`hunch_capture_decision(topic?, seed?)\` — it returns the grilling protocol and a capture-session token.
|
|
64
|
+
2. Run the GRILLING LOOP: one focused question at a time. Push back on hand-wavy answers. Resolve every branch before committing — an unexamined decision poisons the graph.
|
|
65
|
+
3. Confirm the TOPIC anchor with me before committing. One topic per decision; if it spans two, split into two captures.
|
|
66
|
+
4. Capture REJECTED alternatives explicitly (what, and why not) — this is what makes the decision enforceable (Veto/drift check against it).
|
|
67
|
+
5. Commit with \`hunch_record_decision\`, passing \`capture_token\` (from step 1) and the confirmed \`topic\`. The artifact is the graph write, not prose.
|
|
68
|
+
6. On CONFLICT for the topic, do NOT auto-supersede — Hunch refuses and presents both; let me choose supersede (link) / split the topic / discard.
|
|
69
|
+
`;
|
|
70
|
+
const HEAL_CMD = `---
|
|
71
|
+
description: Reconcile docs/code with Hunch's decision graph (doc≠graph drift), never rewriting prose silently
|
|
72
|
+
---
|
|
73
|
+
Reconcile decision-grounding drift for **$ARGUMENTS** (or the whole repo).
|
|
74
|
+
|
|
75
|
+
1. Run \`hunch drift\` (or \`hunch heal\`) to list doc≠graph **anchor-stale** sections — a file still anchored to a superseded decision while a current one exists. Only explicit topic anchors fire; never a semantic guess.
|
|
76
|
+
2. For each, assume the DOC is stale first (Heal A). Propose an edit bringing the file to the CURRENT decision; show it as a diff and wait for my confirm. Never rewrite prose silently.
|
|
77
|
+
3. Only if I explicitly say "the DECISION is stale, not the doc" (Heal B): run /capture to record a superseding decision, then return to step 2 — the prose re-derives from the new decision as a separate confirm.
|
|
78
|
+
4. Report: healed (Heal A), superseded (Heal B), skipped. Never touch the graph except via an explicit Heal B capture.
|
|
79
|
+
`;
|
|
58
80
|
/** A settings.json hook entry is Hunch's if any of its commands ends with the
|
|
59
81
|
* Hunch CLI entry + the `hook` subcommand (e.g. `…/index.js hook`). Matching the
|
|
60
82
|
* command TAIL — not the absolute path — makes re-init idempotent AND survives a
|
|
@@ -119,6 +141,8 @@ export function writeSlashCommands(root) {
|
|
|
119
141
|
["hunch-why.md", WHY_CMD],
|
|
120
142
|
["hunch-fix.md", FIX_CMD],
|
|
121
143
|
["hunch-fragile.md", FRAGILE_CMD],
|
|
144
|
+
["capture.md", CAPTURE_CMD],
|
|
145
|
+
["heal.md", HEAL_CMD],
|
|
122
146
|
];
|
|
123
147
|
for (const [name, body] of files) {
|
|
124
148
|
const p = join(dir, name);
|
package/dist/mcp/server.js
CHANGED
|
@@ -22,6 +22,9 @@ import { compareCandidates } from "../core/compare.js";
|
|
|
22
22
|
import { checkConformance } from "../core/conformance.js";
|
|
23
23
|
import { renderMarkdown, verdict } from "../core/checkreport.js";
|
|
24
24
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
25
|
+
import { liveForTopic, historyForTopic, rejectedForTopic, captureConflicts } from "../core/topics.js";
|
|
26
|
+
import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken } from "../core/capturetoken.js";
|
|
27
|
+
import { randomUUID } from "node:crypto";
|
|
25
28
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
26
29
|
const err = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
27
30
|
// Read-side token budgets: every tool result is injected into a Claude Code
|
|
@@ -34,6 +37,25 @@ const QUERY_HITS = 8; // hunch_query matches (was 12)
|
|
|
34
37
|
const SEV_CONSTRAINT = { blocking: 3, warning: 2, advisory: 1 };
|
|
35
38
|
const SEV_BUG = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
36
39
|
const more = (total, cap, hint = "") => total > cap ? `\n …(+${total - cap} more${hint ? ` — ${hint}` : ""})` : "";
|
|
40
|
+
// Capture-session tokens live in src/core/capturetoken.ts (pure + testable). These
|
|
41
|
+
// thin wrappers bind the process clock and id source at the call site (§5 Stage 1).
|
|
42
|
+
const issueCaptureToken = () => issueToken(randomUUID, Date.now());
|
|
43
|
+
const consumeCaptureToken = (token) => consumeToken(token, Date.now());
|
|
44
|
+
/** The interrogation protocol returned by hunch_capture_decision. */
|
|
45
|
+
function grillingProtocol(topic, token) {
|
|
46
|
+
return [
|
|
47
|
+
"You are capturing an engineering decision into Hunch's graph. Run the GRILLING LOOP, then commit.",
|
|
48
|
+
"",
|
|
49
|
+
"RULES:",
|
|
50
|
+
"1. Grill ONE focused question at a time. Push back on hand-wavy answers. Resolve every branch of the decision tree before committing — an unexamined decision poisons the graph.",
|
|
51
|
+
`2. Confirm the TOPIC anchor with the human before committing${topic ? ` (proposed: "${topic}")` : ""}. Exactly one topic per decision; if it spans two, split into two captures.`,
|
|
52
|
+
"3. Capture REJECTED alternatives explicitly — for each, what it was and why not. This is what makes the decision enforceable (Veto/drift check against it).",
|
|
53
|
+
`4. Commit with hunch_record_decision, passing capture_token:"${token}" and the confirmed topic. The artifact is the graph write, not prose.`,
|
|
54
|
+
"5. On CONFLICT with an existing live decision for the topic, do NOT auto-supersede — Hunch refuses and presents both; let the human choose to supersede (link), split the topic, or discard.",
|
|
55
|
+
"",
|
|
56
|
+
"Required before commit: topic, title, decision, context (the rationale/why), alternatives_rejected. Missing any → keep grilling.",
|
|
57
|
+
].join("\n");
|
|
58
|
+
}
|
|
37
59
|
/** Resolve a free-form target (symbol id / name / file path) to symbol records. */
|
|
38
60
|
function resolveSymbols(store, target) {
|
|
39
61
|
target = toPosixTarget(target);
|
|
@@ -252,6 +274,39 @@ export function buildServer(root) {
|
|
|
252
274
|
});
|
|
253
275
|
return ok(`Decision timeline for "${target}" (newest first):\n${lines.join("\n")}`);
|
|
254
276
|
});
|
|
277
|
+
// -- hunch_capture_decision (decision-grounding: the grilling front door) --
|
|
278
|
+
server.registerTool("hunch_capture_decision", {
|
|
279
|
+
title: "Capture a decision (grilling interview)",
|
|
280
|
+
description: "Start a decision-capture interview: returns the grilling protocol (interrogate ONE question at a time until the decision tree is resolved) plus a capture-session token. Grill the human, then commit via hunch_record_decision with the token + confirmed topic. Use for '/capture', 'record this decision', 'grill me on this'. The token proves the write is the tail of an interview, not a silent guess.",
|
|
281
|
+
inputSchema: {
|
|
282
|
+
topic: z.string().optional().describe("proposed topic anchor (confirm with the human before committing)"),
|
|
283
|
+
seed: z.string().optional().describe("what the decision is about, to focus the first question"),
|
|
284
|
+
},
|
|
285
|
+
}, async ({ topic, seed }) => {
|
|
286
|
+
const token = issueCaptureToken();
|
|
287
|
+
return ok(`${grillingProtocol(topic, token)}${seed ? `\n\nSeed: ${seed}` : ""}`);
|
|
288
|
+
});
|
|
289
|
+
// -- hunch_current_decision (decision-grounding: current(topic)) ----------
|
|
290
|
+
server.registerTool("hunch_current_decision", {
|
|
291
|
+
title: "Current decision for a topic",
|
|
292
|
+
description: "Decision-grounding: return the single CURRENT (accepted, non-superseded) decision anchored to a topic — the authoritative answer a doc or diff is checked against, plus what it rejected. If a topic has NO current decision, or an unresolved collision (>1 live), it says so and injects nothing (fail-safe).",
|
|
293
|
+
inputSchema: { topic: z.string().describe("the decision anchor, e.g. 'auth-transport'") },
|
|
294
|
+
}, async ({ topic }) => {
|
|
295
|
+
const decs = store.recs("decisions");
|
|
296
|
+
const live = liveForTopic(decs, topic);
|
|
297
|
+
if (live.length === 0)
|
|
298
|
+
return ok(`No current decision for topic "${topic}". (Un-anchored, or never captured.)`);
|
|
299
|
+
if (live.length > 1) {
|
|
300
|
+
const list = live.map((d) => `${d.id} ("${d.title}")`).join(", ");
|
|
301
|
+
return ok(`Topic "${topic}" has an UNRESOLVED collision (${live.length} live decisions): ${list}.\nGrounding injects nothing until this is resolved — supersede one, or split the topic.`);
|
|
302
|
+
}
|
|
303
|
+
const d = live[0];
|
|
304
|
+
const rejected = rejectedForTopic(decs, topic);
|
|
305
|
+
const rej = rejected.length ? `\n rejected: ${rejected.join("; ")}` : "";
|
|
306
|
+
const hist = historyForTopic(decs, topic);
|
|
307
|
+
const chain = hist.length > 1 ? `\n history: ${hist.length} decisions on this topic (current is newest)` : "";
|
|
308
|
+
return ok(`Current decision for "${topic}": ${d.id} — "${d.title}" (${d.status}).\n ${d.decision}${rej}${chain}${provLine(d)}`);
|
|
309
|
+
});
|
|
255
310
|
// -- hunch_record_decision (write-back) -----------------------------------
|
|
256
311
|
server.registerTool("hunch_record_decision", {
|
|
257
312
|
title: "Record a decision (write-back)",
|
|
@@ -265,13 +320,15 @@ export function buildServer(root) {
|
|
|
265
320
|
alternatives_rejected: z.array(z.string()).optional(),
|
|
266
321
|
related_files: z.array(z.string()).optional(),
|
|
267
322
|
related_components: z.array(z.string()).optional(),
|
|
323
|
+
topic: z.string().optional().describe("decision-grounding anchor — one topic per decision; enables doc≠graph drift detection for it. Omit to leave un-anchored."),
|
|
268
324
|
status: z.enum(["proposed", "accepted", "rejected", "superseded"]).optional(),
|
|
269
325
|
commit: z.string().optional(),
|
|
270
326
|
supersedes: z.string().optional().describe("id of a decision this one replaces — closes its valid-time window (invalidate, don't delete)"),
|
|
271
327
|
private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo — for sensitive decisions kept out of a public repo. Errors if no private store is configured."),
|
|
272
328
|
}),
|
|
329
|
+
capture_token: z.string().optional().describe("token from hunch_capture_decision — proves this write is the tail of a grilling interview. Omit only for a quick manual record (a deprecation nudge is returned)."),
|
|
273
330
|
},
|
|
274
|
-
}, async ({ decision }) => {
|
|
331
|
+
}, async ({ decision, capture_token }) => {
|
|
275
332
|
try {
|
|
276
333
|
// Commit-keyed on the CANONICAL full sha (resolved via git rev-parse), so a
|
|
277
334
|
// human passing the short sha they see in `commit` produces the SAME id as
|
|
@@ -294,6 +351,7 @@ export function buildServer(root) {
|
|
|
294
351
|
const rec = {
|
|
295
352
|
id,
|
|
296
353
|
title: decision.title,
|
|
354
|
+
topic: decision.topic ?? existing?.topic ?? null,
|
|
297
355
|
status: decision.status ?? "accepted",
|
|
298
356
|
context: decision.context ?? existing?.context ?? "",
|
|
299
357
|
decision: decision.decision ?? existing?.decision ?? "",
|
|
@@ -312,6 +370,27 @@ export function buildServer(root) {
|
|
|
312
370
|
provenance: { source, confidence: 0.95, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
|
|
313
371
|
date: now,
|
|
314
372
|
};
|
|
373
|
+
// Decision-grounding uniqueness guard (§4 Enforcement): never create a SECOND
|
|
374
|
+
// live decision for one topic. Exclude ONLY the incumbent this write will
|
|
375
|
+
// actually close — one resolvable in the SAME store the write lands in. A
|
|
376
|
+
// cross-store supersede (public write vs a private incumbent, or vice-versa)
|
|
377
|
+
// would no-op and leave two live decisions, so it is treated as unresolved
|
|
378
|
+
// (willClose=null) → the guard fires and refuses. Same-id re-record is allowed.
|
|
379
|
+
if (rec.topic && rec.status === "accepted") {
|
|
380
|
+
const willClose = decision.supersedes && store.decisionInStore(decision.supersedes, !!decision.private)
|
|
381
|
+
? decision.supersedes
|
|
382
|
+
: null;
|
|
383
|
+
const others = captureConflicts(store.recs("decisions"), rec.topic, id, willClose);
|
|
384
|
+
if (others.length) {
|
|
385
|
+
const list = others.map((d) => `${d.id} ("${d.title}")`).join(", ");
|
|
386
|
+
const crossStore = decision.supersedes && !willClose
|
|
387
|
+
? ` (note: supersedes:"${decision.supersedes}" is not in the ${decision.private ? "private" : "public"} store, so it can't be closed from here)`
|
|
388
|
+
: "";
|
|
389
|
+
return err(`Topic "${rec.topic}" already has a live decision: ${list}.${crossStore} ` +
|
|
390
|
+
`Hunch will not create a second current decision for one topic. Resolve it: ` +
|
|
391
|
+
`re-record with supersedes:<id> to replace it (linked, same store), pick a distinct topic to split, or discard this capture.`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
315
394
|
// Route the write: private records go to the HUNCH_PRIVATE_DIR overlay (never
|
|
316
395
|
// the committed repo); everything else to the public store. putPrivate throws
|
|
317
396
|
// if no private store is configured, so "private" can't silently fall public.
|
|
@@ -335,10 +414,20 @@ export function buildServer(root) {
|
|
|
335
414
|
commitAndPushHunch(store.privateDir, `hunch: capture ${id}`);
|
|
336
415
|
flushed = " (committed + pushed to the private repo)";
|
|
337
416
|
}
|
|
417
|
+
// Capture-session gate (staged deprecation, §9.3): a token proves an interview
|
|
418
|
+
// preceded the write. No token still writes (non-breaking), but returns a nudge
|
|
419
|
+
// toward /capture so the un-interviewed bypass is visible, not silent. A token
|
|
420
|
+
// presented but unknown to THIS process (server restart/expiry) is not shamed.
|
|
421
|
+
const gated = consumeCaptureToken(capture_token);
|
|
422
|
+
const captureNote = gated
|
|
423
|
+
? " [via capture front door]"
|
|
424
|
+
: capture_token
|
|
425
|
+
? ""
|
|
426
|
+
: "\n\n⚠ Recorded WITHOUT a capture interview. Prefer /capture (hunch_capture_decision), which grills the decision to a resolved state before writing — the graph should hold a well-examined decision, not a guess. (A future major version will require a capture token here.)";
|
|
338
427
|
const supNote = superseded ? ` Superseded ${superseded.id} (window closed at ${rec.valid_from}).` : "";
|
|
339
428
|
const note = decision.commit && !fullSha ? ` (note: commit "${decision.commit}" could not be resolved — recorded as a standalone decision, not linked to a commit)` : "";
|
|
340
429
|
const where = decision.private ? ` [PRIVATE overlay — not committed to this repo]${flushed}` : "";
|
|
341
|
-
return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${supNote}${note}`);
|
|
430
|
+
return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${supNote}${note}${captureNote}`);
|
|
342
431
|
}
|
|
343
432
|
catch (e) {
|
|
344
433
|
return err(`Failed to record decision: ${e.message}`);
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -767,6 +767,15 @@ export class HunchStore {
|
|
|
767
767
|
supersede(oldId, by) {
|
|
768
768
|
return this.supersedeIn(this.json, oldId, by);
|
|
769
769
|
}
|
|
770
|
+
/** Look up a decision by id in a SPECIFIC store — the public store, or the private
|
|
771
|
+
* overlay when `priv` is true — NOT the union. The capture guard uses this to know
|
|
772
|
+
* whether a supersede will actually close its target: `supersede`/`supersedePrivate`
|
|
773
|
+
* each look in only one store, so a cross-store supersede silently no-ops and would
|
|
774
|
+
* leave two live decisions on one topic. Returns undefined if absent (or no overlay). */
|
|
775
|
+
decisionInStore(id, priv) {
|
|
776
|
+
const store = priv ? this.privateJson : this.json;
|
|
777
|
+
return store?.get("decisions", id);
|
|
778
|
+
}
|
|
770
779
|
/** Private-overlay counterpart of `supersede`: close + link the old decision inside
|
|
771
780
|
* the HUNCH_PRIVATE_DIR store, so a PRIVATE decision can supersede another private
|
|
772
781
|
* one (the MCP record path is private→private). A private write never mutates the
|
|
@@ -121,6 +121,9 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
121
121
|
const decision = {
|
|
122
122
|
id,
|
|
123
123
|
title: draft.title,
|
|
124
|
+
// Auto-synthesized decisions are un-anchored (topic null) — a topic is a human
|
|
125
|
+
// act, never a machine guess. Preserve one an earlier human capture attached.
|
|
126
|
+
topic: existing?.topic ?? null,
|
|
124
127
|
status: existing?.status === "accepted" ? "accepted" : "proposed",
|
|
125
128
|
context: draft.context + constraintNote,
|
|
126
129
|
decision: draft.decision,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.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).",
|