@davesheffer/hunch 0.38.2 β 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/README.md +1 -0
- package/dist/cli/index.js +139 -34
- 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/extractors/git.js +53 -7
- 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/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# π§ Hunch β Architectural Conformance for AI code
|
|
2
2
|
|
|
3
|
+
[](https://github.com/davesheffer/hunch)
|
|
3
4
|
[](https://www.npmjs.com/package/@davesheffer/hunch)
|
|
4
5
|
[](https://www.npmjs.com/package/@davesheffer/hunch)
|
|
5
6
|
[](LICENSE)
|
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";
|
|
@@ -79,7 +80,8 @@ program
|
|
|
79
80
|
.option("--no-providers", "skip scaffolding non-Claude assistant configs (Cursor / VS Code / Codex / AGENTS.md)")
|
|
80
81
|
.option("--no-agent-hooks", "skip installing the Claude Code agent hooks (.claude/settings.json)")
|
|
81
82
|
.option("--firmness <level>", "agent-hook firmness: off | advisory | firm | strict")
|
|
82
|
-
.option("--private-sync", "post-commit synthesis writes captured decisions into the
|
|
83
|
+
.option("--private-sync", "post-commit synthesis writes captured decisions into the overlay repo (HUNCH_PRIVATE_DIR), never the public store")
|
|
84
|
+
.option("--shared-sync", "alias of --private-sync (for teams using one shared overlay repo for any code repo)")
|
|
83
85
|
.option("--auto-commit", "opt-in: the post-commit hook also git add+commit+pushes the captured decision (the repo it landed in)")
|
|
84
86
|
.action((opts) => {
|
|
85
87
|
// Validate --firmness up front, before any side effects (indexing, git hooks,
|
|
@@ -109,8 +111,9 @@ program
|
|
|
109
111
|
console.log(` β ${res.skipped} file(s) could not be parsed (skipped)`);
|
|
110
112
|
}
|
|
111
113
|
if (isGitRepo(root)) {
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
+
const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
|
|
115
|
+
const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit });
|
|
116
|
+
console.log(` β post-commit hook ${h.action} (learning loop)${syncToOverlay ? " β syncs to the shared overlay" : ""}${opts.autoCommit ? " β auto-commit+push on" : ""}`);
|
|
114
117
|
const m = installMergeDriver(root, inv.shell);
|
|
115
118
|
console.log(` β team merge driver ${m.action}`);
|
|
116
119
|
// Auto-install the pre-commit guard by default (advisory: flags invariants
|
|
@@ -266,7 +269,8 @@ program
|
|
|
266
269
|
.option("--from-hook", "invoked by the git hook")
|
|
267
270
|
.option("--quiet", "minimal output")
|
|
268
271
|
.option("--force", "re-synthesize even if a decision already exists for the commit")
|
|
269
|
-
.option("--private", "write the synthesized decision into the
|
|
272
|
+
.option("--private", "write the synthesized decision into the configured overlay (HUNCH_PRIVATE_DIR), not the public store")
|
|
273
|
+
.option("--overlay", "alias of --private")
|
|
270
274
|
.option("--commit", "after a capture, also git add+commit+push the repo the decision landed in (opt-in; best-effort) β the private store under --private, else this repo")
|
|
271
275
|
.option("--deep", "Deep Synthesis: ensemble every available subscription CLI and reconcile their drafts (agreement-weighted, advisory). Slower; subscription-only")
|
|
272
276
|
.option("--verify", "Critic pass: audit the draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra subscription call; advisory)")
|
|
@@ -275,12 +279,13 @@ program
|
|
|
275
279
|
const { store, root } = storeFor();
|
|
276
280
|
if (!isGitRepo(root))
|
|
277
281
|
return opts.quiet ? undefined : fail("sync needs a git repo");
|
|
278
|
-
|
|
282
|
+
const toOverlay = !!(opts.private || opts.overlay);
|
|
283
|
+
if (toOverlay && !store.hasPrivate) {
|
|
279
284
|
store.close();
|
|
280
|
-
return opts.quiet ? undefined : fail("--private needs HUNCH_PRIVATE_DIR set to
|
|
285
|
+
return opts.quiet ? undefined : fail("--private/--overlay needs HUNCH_PRIVATE_DIR set to an overlay store");
|
|
281
286
|
}
|
|
282
287
|
store.json.ensureDirs();
|
|
283
|
-
const r = await syncCommit(store, root, sha ?? headSha(root), { force: opts.force, private:
|
|
288
|
+
const r = await syncCommit(store, root, sha ?? headSha(root), { force: opts.force, private: toOverlay, deep: opts.deep, verify: opts.verify, samples: parseSamples(opts.samples) });
|
|
284
289
|
if (r.status === "written") {
|
|
285
290
|
store.reindex();
|
|
286
291
|
// Don't rewrite grounding from the hook β it would dirty the working tree on
|
|
@@ -296,7 +301,7 @@ program
|
|
|
296
301
|
// just no-ops. Stage ONLY the hunch dir (never sweep unrelated working-tree
|
|
297
302
|
// changes), and set HUNCH_SYNC=1 so the commit we create can't re-trigger this
|
|
298
303
|
// hook (no recursion, including on a manual `hunch sync --commit`).
|
|
299
|
-
const commitTarget = opts.commit ? (
|
|
304
|
+
const commitTarget = opts.commit ? (toOverlay ? store.privateDir : hunchPaths(root).hunch) : undefined;
|
|
300
305
|
if (commitTarget) {
|
|
301
306
|
commitAndPushHunch(commitTarget, `hunch: capture ${r.decision?.id ?? "decision"}`);
|
|
302
307
|
if (!opts.quiet)
|
|
@@ -310,29 +315,21 @@ program
|
|
|
310
315
|
}
|
|
311
316
|
store.close();
|
|
312
317
|
});
|
|
313
|
-
|
|
314
|
-
program
|
|
315
|
-
.command("private [dir]")
|
|
316
|
-
.description("Enable a PRIVATE memory overlay β sensitive decisions/bugs/constraints kept in a separate location, unioned into local queries, never committed here. Writes a gitignored .hunch/local.json so it's auto-detected (no env var needed).")
|
|
317
|
-
.option("--repo <url>", "clone a private git repo to use as the store (into ./.hunch-private)")
|
|
318
|
-
.option("--no-hook", "don't switch the post-commit hook to private sync")
|
|
319
|
-
.option("--no-auto-commit", "DON'T auto commit+push the overlay after each capture (default: ON β fully automated two-way sync, never push by hand)")
|
|
320
|
-
.option("--sync", "flush the configured private store now (git add+commit+push) β catches records made via MCP between commits")
|
|
321
|
-
.option("--migrate", "ONE-TIME: move this repo's EXISTING public .hunch memory into the overlay, then make the public repo code-only β untrack + gitignore the memory tree and regenerate grounding so no memory is published here")
|
|
322
|
-
.action((dir, opts) => {
|
|
318
|
+
function configureOverlay(dir, opts, mode) {
|
|
323
319
|
const root = findRoot();
|
|
320
|
+
const paths = hunchPaths(root);
|
|
321
|
+
const commandName = mode === "private" ? "private" : "shared";
|
|
324
322
|
if (opts.sync) {
|
|
325
323
|
const s = new HunchStore(hunchPaths(root));
|
|
326
324
|
const target = s.privateDir;
|
|
327
325
|
s.close();
|
|
328
326
|
if (!target)
|
|
329
|
-
return fail(
|
|
330
|
-
commitAndPushHunch(target, "hunch: sync
|
|
331
|
-
console.log(`β flushed
|
|
327
|
+
return fail(`no overlay configured β run \`hunch ${commandName}\` first`);
|
|
328
|
+
commitAndPushHunch(target, "hunch: sync overlay memory");
|
|
329
|
+
console.log(`β flushed overlay store β ${target}`);
|
|
332
330
|
return;
|
|
333
331
|
}
|
|
334
|
-
|
|
335
|
-
// 1) resolve the private store's hunch dir (holds decisions/, bugs/, β¦)
|
|
332
|
+
// 1) resolve the overlay store's hunch dir (holds decisions/, bugs/, β¦)
|
|
336
333
|
let hunchDir;
|
|
337
334
|
if (opts.repo) {
|
|
338
335
|
const dest = join(root, ".hunch-private");
|
|
@@ -353,6 +350,13 @@ program
|
|
|
353
350
|
// multiple machines/worktrees merge by RECORD ID (no manual conflict resolution) when the
|
|
354
351
|
// two-way auto-sync pulls before pushing. The overlay repo root is the parent of its .hunch.
|
|
355
352
|
const overlayRoot = hunchPathsForDir(hunchDir).root;
|
|
353
|
+
// CRITICAL (bug_overlay_clobber): with --auto-commit, the overlay MUST be its own git repo.
|
|
354
|
+
// Otherwise the post-commit auto-commit (commitAndPushHunch) runs `git -C overlayDir β¦` which
|
|
355
|
+
// walks UP to the PROJECT repo and can commit memory over your code. Initialize a standalone
|
|
356
|
+
// repo when one isn't there (a local repo with no remote just accumulates commits β safe).
|
|
357
|
+
if (opts.autoCommit && !isGitRepo(overlayRoot)) {
|
|
358
|
+
spawnSync("git", ["init", "-q", overlayRoot], { stdio: "ignore" });
|
|
359
|
+
}
|
|
356
360
|
if (isGitRepo(overlayRoot))
|
|
357
361
|
installMergeDriver(overlayRoot, inv.shell);
|
|
358
362
|
// 3) record the path in a GITIGNORED local config β auto-detected, no env var, and
|
|
@@ -371,7 +375,7 @@ program
|
|
|
371
375
|
// only an absolute path survives the move. Lives under .git/ (never tracked; nothing to ignore).
|
|
372
376
|
let worktreeNote = "";
|
|
373
377
|
if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit)) {
|
|
374
|
-
worktreeNote =
|
|
378
|
+
worktreeNote = " β registered in the git common dir β shared by every worktree of this repo, on any branch\n";
|
|
375
379
|
}
|
|
376
380
|
// 4) route post-commit synthesis to the overlay (local hook, never committed)
|
|
377
381
|
let hookNote = "";
|
|
@@ -405,18 +409,40 @@ program
|
|
|
405
409
|
` next: review, then commit the PUBLIC repo:\n` +
|
|
406
410
|
` git add -A && git commit -m "chore: move engineering memory to a private overlay" && git push\n`;
|
|
407
411
|
}
|
|
408
|
-
|
|
409
|
-
|
|
412
|
+
const lead = mode === "private"
|
|
413
|
+
? `β private overlay enabled β ${hunchDir}\n`
|
|
414
|
+
: `β shared overlay enabled β ${hunchDir}\n`;
|
|
415
|
+
const tail = mode === "private"
|
|
416
|
+
? " record sensitive items with private:true (hunch_record_decision / hunch_record_correction)\n override per-shell with HUNCH_PRIVATE_DIR; CI / public PR comments stay public-only."
|
|
417
|
+
: " this works for any repo (public or private): one shared memory source across teammates, branches, and worktrees.\n override per-shell with HUNCH_PRIVATE_DIR if needed.";
|
|
418
|
+
console.log(lead +
|
|
419
|
+
" β recorded in .hunch/local.json (gitignored) β auto-detected, no env var or shell-profile edit\n" +
|
|
410
420
|
worktreeNote +
|
|
411
421
|
hookNote +
|
|
412
422
|
migrateNote +
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
423
|
+
tail);
|
|
424
|
+
}
|
|
425
|
+
program
|
|
426
|
+
.command("private [dir]")
|
|
427
|
+
.description("Enable a PRIVATE memory overlay β sensitive decisions/bugs/constraints kept in a separate location, unioned into local queries, never committed here. Writes a gitignored .hunch/local.json so it's auto-detected (no env var needed).")
|
|
428
|
+
.option("--repo <url>", "clone a private git repo to use as the store (into ./.hunch-private)")
|
|
429
|
+
.option("--no-hook", "don't switch the post-commit hook to private sync")
|
|
430
|
+
.option("--no-auto-commit", "DON'T auto commit+push the overlay after each capture (default: ON β fully automated two-way sync, never push by hand)")
|
|
431
|
+
.option("--sync", "flush the configured private store now (git add+commit+push) β catches records made via MCP between commits")
|
|
432
|
+
.option("--migrate", "ONE-TIME: move this repo's EXISTING public .hunch memory into the overlay, then make the public repo code-only β untrack + gitignore the memory tree and regenerate grounding so no memory is published here")
|
|
433
|
+
.action((dir, opts) => configureOverlay(dir, opts, "private"));
|
|
434
|
+
program
|
|
435
|
+
.command("shared [dir]")
|
|
436
|
+
.description("Enable a SHARED memory overlay repo for this project (works for any repo: private or public). Memory stays in one location, shared across teammates, branches, and worktrees.")
|
|
437
|
+
.option("--repo <url>", "clone a git repo to use as the shared memory store (into ./.hunch-private)")
|
|
438
|
+
.option("--no-hook", "don't switch the post-commit hook to overlay sync")
|
|
439
|
+
.option("--no-auto-commit", "DON'T auto commit+push the overlay after each capture (default: ON β fully automated two-way sync)")
|
|
440
|
+
.option("--sync", "flush the configured overlay store now (git add+commit+push)")
|
|
441
|
+
.action((dir, opts) => configureOverlay(dir, opts, "shared"));
|
|
416
442
|
// ---- worktree (one-command worktree wired into Hunch) ----------------------
|
|
417
443
|
program
|
|
418
444
|
.command("worktree <path>")
|
|
419
|
-
.description("Create a git worktree already wired into Hunch β it shares this repo's memory
|
|
445
|
+
.description("Create a git worktree already wired into Hunch β it shares this repo's memory overlay, with zero per-worktree setup.")
|
|
420
446
|
.option("-b, --branch <name>", "create the worktree on a NEW branch")
|
|
421
447
|
.option("--no-share", "don't register the overlay at the git common dir (the worktree won't see private memory)")
|
|
422
448
|
.option("--no-index", "don't build the new worktree's code graph (skip if you'll index later)")
|
|
@@ -448,7 +474,7 @@ program
|
|
|
448
474
|
shareNote = ` Β· could not register the shared overlay pointer (no git common dir?)`;
|
|
449
475
|
}
|
|
450
476
|
else {
|
|
451
|
-
shareNote = ` Β· no
|
|
477
|
+
shareNote = ` Β· no overlay configured β run \`hunch shared\` (or \`hunch private\`) to share memory across worktrees`;
|
|
452
478
|
}
|
|
453
479
|
// 3) build the new worktree's CODE GRAPH (symbols/edges β blast-radius / dependents).
|
|
454
480
|
// Indexed IN-PROCESS (uses THIS install's tree-sitter, so the worktree needs no
|
|
@@ -696,7 +722,7 @@ program
|
|
|
696
722
|
const id = decisionId(`inline:${it.file}:${it.text}`);
|
|
697
723
|
const prev = store.recs("decisions").find((d) => d.id === id); // preserve window for idempotent re-capture
|
|
698
724
|
const rec = {
|
|
699
|
-
id, title: it.text, status: "accepted",
|
|
725
|
+
id, title: it.text, topic: prev?.topic ?? null, status: "accepted",
|
|
700
726
|
context: `Captured from an inline hunch-why comment (${it.file}:${it.line}).`,
|
|
701
727
|
decision: it.text, consequences: [], alternatives_rejected: [], rejected_tripwires: [],
|
|
702
728
|
related_components: [], related_files: [it.file], supersedes: null, superseded_by: null,
|
|
@@ -1509,6 +1535,11 @@ program
|
|
|
1509
1535
|
const items = retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ");
|
|
1510
1536
|
text += `\n\nβ Deliberately RETIRED from this file β do not re-introduce without cause: ${items}.`;
|
|
1511
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}`;
|
|
1512
1543
|
emitContext("PreToolUse", text);
|
|
1513
1544
|
}
|
|
1514
1545
|
catch {
|
|
@@ -1655,6 +1686,80 @@ program
|
|
|
1655
1686
|
}
|
|
1656
1687
|
store.close();
|
|
1657
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
|
+
});
|
|
1658
1763
|
// ---- compact (bound Hunch growth) -----------------------------------------
|
|
1659
1764
|
program
|
|
1660
1765
|
.command("compact")
|
|
@@ -1753,7 +1858,7 @@ program
|
|
|
1753
1858
|
console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
|
|
1754
1859
|
console.log(store.privateDir
|
|
1755
1860
|
? `private: on β ${store.privateDir} (local overlay β unioned into queries; never committed or posted publicly)`
|
|
1756
|
-
: dim(`private: off β run \`hunch private\` to
|
|
1861
|
+
: dim(`private: off β run \`hunch shared\` (or \`hunch private\`) to use one overlay repo across teammates/worktrees (or set HUNCH_PRIVATE_DIR)`));
|
|
1757
1862
|
// Worktree posture: linked worktrees share ONE memory via the git common dir. Only
|
|
1758
1863
|
// surfaced in a linked worktree (no noise in a normal single checkout), so a
|
|
1759
1864
|
// "memory missing here" symptom has an obvious cause + fix.
|
|
@@ -1762,7 +1867,7 @@ program
|
|
|
1762
1867
|
const sharedPtr = !!common && existsSync(join(common, "hunch", "local.json"));
|
|
1763
1868
|
console.log(store.privateDir
|
|
1764
1869
|
? `worktree: linked β sharing the repo's memory${sharedPtr ? " via the git common dir" : ""}`
|
|
1765
|
-
: dim(`worktree: linked, but no overlay resolved here β run \`hunch private\` once (any worktree) so all worktrees share it`));
|
|
1870
|
+
: dim(`worktree: linked, but no overlay resolved here β run \`hunch shared\` (or \`hunch private\`) once (any worktree) so all worktrees share it`));
|
|
1766
1871
|
}
|
|
1767
1872
|
// Semantic search is opt-in and local. Report availability + coverage without
|
|
1768
1873
|
// loading the model (selectEmbedder only probes; embeddingStats just counts rows).
|
|
@@ -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(""),
|
package/dist/extractors/git.js
CHANGED
|
@@ -43,13 +43,30 @@ export function commitAndPushHunch(hunchDir, message) {
|
|
|
43
43
|
catch { /* best-effort: nothing staged / not a repo / offline */ }
|
|
44
44
|
};
|
|
45
45
|
run(["add", "--", "."]);
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
// SAFETY BACKSTOP (critical β bug_overlay_clobber): a memory sync is PURELY ADDITIVE small
|
|
47
|
+
// JSON. If the staged set contains a DELETION, rename, or any non-.json file, hunchDir is NOT
|
|
48
|
+
// a clean overlay store β most dangerously, the overlay was never its own git repo so `git -C`
|
|
49
|
+
// walked UP to the PROJECT repo. Committing/pushing there would overwrite/delete the user's
|
|
50
|
+
// code (we shipped exactly this). Refuse hard: unstage and bail without committing or pushing.
|
|
51
|
+
if (!stagedIsMemoryOnly(hunchDir, env)) {
|
|
52
|
+
try {
|
|
53
|
+
execFileSync("git", ["-C", hunchDir, "reset", "-q", "--", "."], { stdio: "ignore", env });
|
|
54
|
+
}
|
|
55
|
+
catch { /* best-effort unstage */ }
|
|
56
|
+
console.error(`hunch: refusing to auto-commit memory at "${hunchDir}" β the staged change includes deletions or non-memory files, so this is not a clean overlay repo. Nothing was committed or pushed. (Use \`hunch shared --repo <url>\` so the overlay is its OWN git repo.)`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Only sync+push when a memory commit was actually created β never run pull/push against the
|
|
60
|
+
// enclosing repo on an empty stage. Two-way sync: MERGE the remote BEFORE pushing so a push
|
|
61
|
+
// can't be rejected non-fast-forward; the .hunch merge driver resolves same-record conflicts
|
|
62
|
+
// by id. On conflict/offline, mergeRemote aborts to a clean tree and we skip the push.
|
|
63
|
+
let committed = false;
|
|
64
|
+
try {
|
|
65
|
+
execFileSync("git", ["-C", hunchDir, "commit", "-m", message], { stdio: "ignore", env });
|
|
66
|
+
committed = true;
|
|
67
|
+
}
|
|
68
|
+
catch { /* nothing staged / not a repo */ }
|
|
69
|
+
if (committed && mergeRemote(hunchDir, env))
|
|
53
70
|
run(["push"]);
|
|
54
71
|
}
|
|
55
72
|
finally {
|
|
@@ -59,6 +76,35 @@ export function commitAndPushHunch(hunchDir, message) {
|
|
|
59
76
|
catch { /* released best-effort */ }
|
|
60
77
|
}
|
|
61
78
|
}
|
|
79
|
+
/** Is the staged set a clean, MEMORY-ONLY change β only JSON record adds/updates, nothing else?
|
|
80
|
+
* The overlay store is entirely JSON (decisions/, bugs/, β¦, manifest.json). A real memory sync
|
|
81
|
+
* is purely additive; a DELETION, rename, or any non-.json staged path means hunchDir is NOT a
|
|
82
|
+
* clean overlay repo (e.g. it resolved to the project repo), so committing there would clobber
|
|
83
|
+
* code. Empty stage β false (nothing to commit). The transient mkdir lock is ignored. */
|
|
84
|
+
function stagedIsMemoryOnly(hunchDir, env) {
|
|
85
|
+
let out = "";
|
|
86
|
+
try {
|
|
87
|
+
out = execFileSync("git", ["-C", hunchDir, "diff", "--cached", "--name-status"], { encoding: "utf8", env });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
93
|
+
if (!lines.length)
|
|
94
|
+
return false;
|
|
95
|
+
for (const line of lines) {
|
|
96
|
+
const parts = line.split("\t");
|
|
97
|
+
const status = (parts[0] ?? "").trim();
|
|
98
|
+
const path = (parts[parts.length - 1] ?? "").trim();
|
|
99
|
+
if (path.includes(".hunch-commit.lock"))
|
|
100
|
+
continue; // transient lock dir, never a record
|
|
101
|
+
if (!/^[AM]$/.test(status))
|
|
102
|
+
return false; // only Add / Modify β any D/R/C/T β not a memory sync
|
|
103
|
+
if (!path.endsWith(".json"))
|
|
104
|
+
return false; // the store is entirely JSON records
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
62
108
|
/** Merge the overlay's remote into the local branch (pull, no rebase), leaving a CLEAN tree
|
|
63
109
|
* whether it succeeds or not. Returns true when the branch is safe to push (merged, or there's
|
|
64
110
|
* no upstream to merge), false when a conflict was aborted (caller skips the push and retries
|
|
@@ -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).",
|