@davesheffer/hunch 0.28.0 → 0.30.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 +95 -1
- package/dist/core/conformance.js +57 -0
- package/dist/core/types.js +13 -0
- package/dist/extractors/comments.js +75 -0
- package/dist/integrations/claudemd.js +1 -0
- package/dist/mcp/server.js +15 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -29,7 +29,8 @@ import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesi
|
|
|
29
29
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
30
30
|
import { selectProvider } from "../synthesis/provider.js";
|
|
31
31
|
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, commitAndPushHunch, gitUntrackCached } from "../extractors/git.js";
|
|
32
|
-
import { runbookId } from "../core/ids.js";
|
|
32
|
+
import { runbookId, decisionId } from "../core/ids.js";
|
|
33
|
+
import { extractInlineIntent } from "../extractors/comments.js";
|
|
33
34
|
import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkreport.js";
|
|
34
35
|
import { partitionReview, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
|
|
35
36
|
import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
|
|
@@ -46,6 +47,7 @@ import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpol
|
|
|
46
47
|
import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
|
|
47
48
|
import { computeDrift } from "../core/drift.js";
|
|
48
49
|
import { compareCandidates } from "../core/compare.js";
|
|
50
|
+
import { checkConformance } from "../core/conformance.js";
|
|
49
51
|
import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
|
|
50
52
|
import { constraintId } from "../core/ids.js";
|
|
51
53
|
import { readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
|
|
@@ -536,6 +538,98 @@ program
|
|
|
536
538
|
console.log(dim(" advisory, deterministic draft — refine the steps/gotchas; surfaced via `hunch query` and MCP."));
|
|
537
539
|
store.close();
|
|
538
540
|
});
|
|
541
|
+
// ---- capture-comments (inline intent → graph; addendum #2) ----------------
|
|
542
|
+
program
|
|
543
|
+
.command("capture-comments")
|
|
544
|
+
.description("Capture inline intent comments into the graph: `hunch-why:` → a decision, `hunch-rule:` → a file-scoped constraint. Deterministic + idempotent.")
|
|
545
|
+
.option("--private", "write captured records into the private overlay (HUNCH_PRIVATE_DIR)")
|
|
546
|
+
.action((opts) => {
|
|
547
|
+
const { store, root } = storeFor();
|
|
548
|
+
if (opts.private && !store.hasPrivate) {
|
|
549
|
+
store.close();
|
|
550
|
+
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
551
|
+
}
|
|
552
|
+
const intents = extractInlineIntent(root);
|
|
553
|
+
const now = new Date().toISOString();
|
|
554
|
+
let dec = 0, con = 0;
|
|
555
|
+
for (const it of intents) {
|
|
556
|
+
const ev = [`${it.file}:${it.line}`];
|
|
557
|
+
if (it.kind === "why") {
|
|
558
|
+
const id = decisionId(`inline:${it.file}:${it.text}`);
|
|
559
|
+
const prev = store.recs("decisions").find((d) => d.id === id); // preserve window for idempotent re-capture
|
|
560
|
+
const rec = {
|
|
561
|
+
id, title: it.text, status: "accepted",
|
|
562
|
+
context: `Captured from an inline hunch-why comment (${it.file}:${it.line}).`,
|
|
563
|
+
decision: it.text, consequences: [], alternatives_rejected: [], rejected_tripwires: [],
|
|
564
|
+
related_components: [], related_files: [it.file], supersedes: null, superseded_by: null,
|
|
565
|
+
caused_by_bug: null, commit: null, valid_from: prev?.valid_from ?? now, valid_to: null,
|
|
566
|
+
retired: { symbols: [], deps: [] },
|
|
567
|
+
provenance: { source: "human_confirmed", confidence: 0.9, evidence: ev }, date: prev?.date ?? now,
|
|
568
|
+
};
|
|
569
|
+
if (opts.private)
|
|
570
|
+
store.putPrivate("decisions", rec);
|
|
571
|
+
else
|
|
572
|
+
store.json.put("decisions", rec);
|
|
573
|
+
dec++;
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
const id = constraintId(`inline:${it.file}:${it.text}`);
|
|
577
|
+
const prev = store.recs("constraints").find((c) => c.id === id);
|
|
578
|
+
const rec = {
|
|
579
|
+
id, type: "correctness", statement: it.text, scope: [it.file],
|
|
580
|
+
// Advisory by default — an inline rule never auto-blocks a build; raise severity
|
|
581
|
+
// deliberately if you want enforcement. Keeps day-one zero false-positive rage.
|
|
582
|
+
severity: "warning", enforcement: "advisory_v1",
|
|
583
|
+
rationale: `Captured from an inline hunch-rule comment (${it.file}:${it.line}).`,
|
|
584
|
+
source_decision: null, violations: [], status: "active",
|
|
585
|
+
valid_from: prev?.valid_from ?? now, valid_to: null,
|
|
586
|
+
provenance: { source: "human_confirmed", confidence: 0.9, evidence: ev },
|
|
587
|
+
};
|
|
588
|
+
if (opts.private)
|
|
589
|
+
store.putPrivate("constraints", rec);
|
|
590
|
+
else
|
|
591
|
+
store.json.put("constraints", rec);
|
|
592
|
+
con++;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
store.reindex();
|
|
596
|
+
if (!intents.length)
|
|
597
|
+
console.log("No `hunch-why:` / `hunch-rule:` comments found.");
|
|
598
|
+
else
|
|
599
|
+
console.log(`✓ captured ${dec} decision(s) + ${con} constraint(s) from inline comments${opts.private ? " [private overlay]" : ""}`);
|
|
600
|
+
store.close();
|
|
601
|
+
});
|
|
602
|
+
// ---- conform (intent-conformance: does code still satisfy the recorded why) ----
|
|
603
|
+
program
|
|
604
|
+
.command("conform")
|
|
605
|
+
.description("Intent-conformance: prove the code still SATISFIES each in-force decision's recorded intent (deterministic, over the graph). Surfaces where code drifted from the why — even with no diff in scope.")
|
|
606
|
+
.option("--strict", "exit non-zero if any intent is violated")
|
|
607
|
+
.action((opts) => {
|
|
608
|
+
const { store } = storeFor();
|
|
609
|
+
store.reindex();
|
|
610
|
+
const results = checkConformance(store);
|
|
611
|
+
if (!results.length) {
|
|
612
|
+
console.log("No conformance predicates recorded yet.");
|
|
613
|
+
console.log(dim(" Add a `conformance` predicate to a decision (e.g. { assert: \"calls\", subject: \"pay\", object: \"verifySession\" }) to prove the code honors its intent."));
|
|
614
|
+
store.close();
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const violations = results.filter((r) => !r.satisfied);
|
|
618
|
+
console.log(`Intent-conformance: ${results.length - violations.length}/${results.length} satisfied\n`);
|
|
619
|
+
for (const r of results) {
|
|
620
|
+
console.log(` ${r.satisfied ? "✅" : "⛔"} ${r.decision} — "${r.title}"`);
|
|
621
|
+
console.log(` ${r.assert} ${r.subject}${r.object ? ` → ${r.object}` : ""}: ${r.detail}`);
|
|
622
|
+
}
|
|
623
|
+
if (violations.length) {
|
|
624
|
+
console.log(`\n⛔ ${violations.length} intent(s) the code no longer satisfies.`);
|
|
625
|
+
if (opts.strict)
|
|
626
|
+
process.exitCode = 1;
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
console.log(`\n✅ the code satisfies every recorded intent.`);
|
|
630
|
+
}
|
|
631
|
+
store.close();
|
|
632
|
+
});
|
|
539
633
|
// ---- compare (rank N candidate solutions by architectural fit) ------------
|
|
540
634
|
program
|
|
541
635
|
.command("compare")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
function resolveSymbol(store, ref) {
|
|
2
|
+
const syms = store.recs("symbols");
|
|
3
|
+
if (ref.startsWith("sym_"))
|
|
4
|
+
return syms.find((s) => s.id === ref) ?? null;
|
|
5
|
+
if (ref.includes(":")) {
|
|
6
|
+
const [f, n] = ref.split(":");
|
|
7
|
+
return syms.find((s) => s.name === n && (s.file === f || s.file.endsWith("/" + (f ?? "")))) ?? null;
|
|
8
|
+
}
|
|
9
|
+
return syms.find((s) => s.name === ref) ?? null;
|
|
10
|
+
}
|
|
11
|
+
function reaches(store, id, transitive) {
|
|
12
|
+
const set = new Set();
|
|
13
|
+
for (const d of store.getDependencies(id, transitive ? 6 : 1)) {
|
|
14
|
+
if (transitive || d.depth === 1)
|
|
15
|
+
set.add(d.id);
|
|
16
|
+
}
|
|
17
|
+
return set;
|
|
18
|
+
}
|
|
19
|
+
function evalPredicate(store, d, p) {
|
|
20
|
+
const base = { decision: d.id, title: d.title, assert: p.assert, subject: p.subject, object: p.object };
|
|
21
|
+
const subj = resolveSymbol(store, p.subject);
|
|
22
|
+
if (p.assert === "exists") {
|
|
23
|
+
return { ...base, satisfied: !!subj, detail: subj ? `${p.subject} exists (${subj.file})` : `${p.subject} no longer exists in the graph` };
|
|
24
|
+
}
|
|
25
|
+
if (!subj)
|
|
26
|
+
return { ...base, satisfied: false, detail: `subject "${p.subject}" not found in the graph — intent's subject is gone` };
|
|
27
|
+
const wantReach = p.assert === "calls" || p.assert === "imports";
|
|
28
|
+
const obj = p.object ? resolveSymbol(store, p.object) : null;
|
|
29
|
+
if (!obj) {
|
|
30
|
+
// a required target gone ⇒ the link can't hold (violated); a forbidden one trivially holds.
|
|
31
|
+
return { ...base, satisfied: !wantReach, detail: `target "${p.object ?? ""}" not found in the graph` };
|
|
32
|
+
}
|
|
33
|
+
const linked = reaches(store, subj.id, p.transitive).has(obj.id);
|
|
34
|
+
const satisfied = wantReach ? linked : !linked;
|
|
35
|
+
const via = p.transitive ? " (transitively)" : "";
|
|
36
|
+
const detail = satisfied
|
|
37
|
+
? wantReach
|
|
38
|
+
? `${subj.name} →${via} ${obj.name} ✓`
|
|
39
|
+
: `${subj.name} does not reach ${obj.name} ✓`
|
|
40
|
+
: wantReach
|
|
41
|
+
? `${subj.name} no longer reaches${via} ${obj.name} — intent VIOLATED`
|
|
42
|
+
: `${subj.name} now reaches${via} ${obj.name} — intent VIOLATED`;
|
|
43
|
+
return { ...base, satisfied, detail };
|
|
44
|
+
}
|
|
45
|
+
/** Check every in-force decision's conformance predicates against the CURRENT graph.
|
|
46
|
+
* `.satisfied === false` means the code drifted from the recorded intent. Deterministic. */
|
|
47
|
+
export function checkConformance(store) {
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const d of store.recs("decisions")) {
|
|
50
|
+
if (d.status === "superseded" || d.superseded_by)
|
|
51
|
+
continue; // in-force decisions only
|
|
52
|
+
for (const p of d.conformance ?? [])
|
|
53
|
+
out.push(evalPredicate(store, d, p));
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=conformance.js.map
|
package/dist/core/types.js
CHANGED
|
@@ -94,6 +94,18 @@ export const RejectedTripwireSchema = z.object({
|
|
|
94
94
|
provenance: ProvenanceSchema,
|
|
95
95
|
});
|
|
96
96
|
/** ADR-style decision record, auto-drafted and human-confirmable. */
|
|
97
|
+
/** Intent-conformance predicate (the "inversion": prove the code still SATISFIES a
|
|
98
|
+
* decision's intent, not just that a diff didn't touch a guarded file). Each predicate
|
|
99
|
+
* compiles a decision's intent into a DETERMINISTIC check over the symbol/dependency
|
|
100
|
+
* graph Hunch already builds — no model. "pay must verify the session" becomes
|
|
101
|
+
* { assert: "calls", subject: "pay", object: "verifySession" }; if pay stops calling
|
|
102
|
+
* verifySession the intent is VIOLATED even with no diff in scope. */
|
|
103
|
+
export const ConformancePredicateSchema = z.object({
|
|
104
|
+
assert: z.enum(["calls", "not-calls", "imports", "not-imports", "exists"]),
|
|
105
|
+
subject: z.string().describe("symbol name / id / file:name the intent is about"),
|
|
106
|
+
object: z.string().optional().describe("required (calls/imports) or forbidden (not-*) target"),
|
|
107
|
+
transitive: z.boolean().default(false).describe("allow an indirect path over the dependency graph"),
|
|
108
|
+
});
|
|
97
109
|
export const DecisionSchema = z.object({
|
|
98
110
|
id: z.string().describe("dec_*"),
|
|
99
111
|
title: z.string(),
|
|
@@ -117,6 +129,7 @@ export const DecisionSchema = z.object({
|
|
|
117
129
|
valid_from: z.string().optional().describe("ISO instant the decision took effect (commit date)"),
|
|
118
130
|
valid_to: z.string().nullable().default(null).describe("ISO instant it was superseded (null = in force)"),
|
|
119
131
|
retired: RetiredSignalSchema.default({ symbols: [], deps: [] }),
|
|
132
|
+
conformance: z.array(ConformancePredicateSchema).optional().describe("deterministic intent-conformance checks over the graph"),
|
|
120
133
|
provenance: ProvenanceSchema,
|
|
121
134
|
date: z.string(),
|
|
122
135
|
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline intent capture (roadmap addendum #2). A developer marks intent right where
|
|
3
|
+
* it lives in the code — `hunch-why: <reason>` (in a comment → a Decision) or
|
|
4
|
+
* `hunch-rule: <invariant>` (→ a file-scoped Constraint) — and Hunch lifts it into
|
|
5
|
+
* the graph, deterministically. The third capture source alongside commit synthesis and
|
|
6
|
+
* correction capture. The tag must follow a comment marker (the slash pair, #, *, --,
|
|
7
|
+
* <!--, ;) so a matching STRING literal in code isn't mistaken for intent. (Line-based,
|
|
8
|
+
* so a tagged line that is itself a string literal can still false-positive — advisory.)
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { trackedFiles } from "./git.js";
|
|
13
|
+
import { toPosixTarget } from "../core/paths.js";
|
|
14
|
+
const EXTS = [
|
|
15
|
+
".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs",
|
|
16
|
+
".py", ".go", ".rb", ".java", ".rs", ".php", ".cs", ".kt", ".swift", ".scala",
|
|
17
|
+
".c", ".h", ".cc", ".cpp", ".hpp", ".sql", ".sh",
|
|
18
|
+
];
|
|
19
|
+
const SKIP = new Set(["node_modules", ".git", ".hunch", ".hunch-private", "dist", "build", "out", "vendor", ".next"]);
|
|
20
|
+
const TAG = /(?:\/\/|#|\*|--|<!--|;)\s*hunch-(why|rule)\s*:\s*(.+?)\s*(?:\*\/|-->|$)/i;
|
|
21
|
+
/** Tracked source files (git ls-files); falls back to a bounded walk outside git. */
|
|
22
|
+
function sourceFiles(root) {
|
|
23
|
+
const tracked = trackedFiles(root, EXTS);
|
|
24
|
+
if (tracked.length)
|
|
25
|
+
return tracked;
|
|
26
|
+
const out = [];
|
|
27
|
+
const walk = (dir, rel, depth) => {
|
|
28
|
+
if (depth > 8)
|
|
29
|
+
return;
|
|
30
|
+
let entries;
|
|
31
|
+
try {
|
|
32
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
for (const e of entries) {
|
|
38
|
+
if (e.name.startsWith("."))
|
|
39
|
+
continue;
|
|
40
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
41
|
+
if (e.isDirectory()) {
|
|
42
|
+
if (!SKIP.has(e.name))
|
|
43
|
+
walk(join(dir, e.name), r, depth + 1);
|
|
44
|
+
}
|
|
45
|
+
else if (EXTS.some((x) => e.name.endsWith(x))) {
|
|
46
|
+
out.push(r);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
walk(root, "", 0);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
export function extractInlineIntent(root) {
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const rel of sourceFiles(root)) {
|
|
56
|
+
let content;
|
|
57
|
+
try {
|
|
58
|
+
content = readFileSync(join(root, rel), "utf8");
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!content.includes("hunch-"))
|
|
64
|
+
continue; // cheap skip before the per-line scan
|
|
65
|
+
const file = toPosixTarget(rel);
|
|
66
|
+
const lines = content.split("\n");
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
const m = TAG.exec(lines[i]);
|
|
69
|
+
if (m)
|
|
70
|
+
out.push({ kind: m[1].toLowerCase(), text: m[2].trim(), file, line: i + 1 });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=comments.js.map
|
|
@@ -34,6 +34,7 @@ export function renderHunchSection(store) {
|
|
|
34
34
|
lines.push("- `hunch_query(query)` — free-text search across all of Hunch.");
|
|
35
35
|
lines.push("- `hunch_runbook(task)` — the proven steps for a recurring task (e.g. \"add an MCP tool\", \"cut a release\").");
|
|
36
36
|
lines.push("- `hunch_compare(candidates)` — rank N candidate branches/commits by architectural fit (fewest invariant hits).");
|
|
37
|
+
lines.push("- `hunch_conformance()` — does the code still SATISFY recorded intent? (e.g. `pay` still reaches `verifySession`). Run before a refactor.");
|
|
37
38
|
lines.push("- `hunch_record_decision(...)` — write back a decision after a non-trivial choice.");
|
|
38
39
|
if (constraints.length) {
|
|
39
40
|
lines.push("");
|
package/dist/mcp/server.js
CHANGED
|
@@ -17,6 +17,7 @@ import { buildCorrectionConstraint } from "../core/correction.js";
|
|
|
17
17
|
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, commitAndPushHunch } from "../extractors/git.js";
|
|
18
18
|
import { formatContext } from "../core/format.js";
|
|
19
19
|
import { compareCandidates } from "../core/compare.js";
|
|
20
|
+
import { checkConformance } from "../core/conformance.js";
|
|
20
21
|
import { renderMarkdown, verdict } from "../core/checkreport.js";
|
|
21
22
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
22
23
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
@@ -435,6 +436,20 @@ export function buildServer(root) {
|
|
|
435
436
|
return err(`Failed to compare candidates: ${e.message}`);
|
|
436
437
|
}
|
|
437
438
|
});
|
|
439
|
+
// -- hunch_conformance ----------------------------------------------------
|
|
440
|
+
server.registerTool("hunch_conformance", {
|
|
441
|
+
title: "Does the code still satisfy the recorded intent?",
|
|
442
|
+
description: "Intent-conformance (the inversion of a normal guard): for every in-force decision carrying a conformance predicate, deterministically verify the CODE still satisfies its intent over the dependency graph — e.g. 'pay still reaches verifySession'. Returns the violations: intent the code has silently drifted away from, with NO diff required. Run before a refactor or merge to catch intent erosion a diff-only check can't see.",
|
|
443
|
+
inputSchema: {},
|
|
444
|
+
}, async () => {
|
|
445
|
+
const results = checkConformance(store);
|
|
446
|
+
if (!results.length)
|
|
447
|
+
return ok("No conformance predicates recorded. Add a `conformance` predicate to a decision (e.g. {assert:'calls', subject:'pay', object:'verifySession'}) to prove the code honors its intent.");
|
|
448
|
+
const violations = results.filter((r) => !r.satisfied);
|
|
449
|
+
const lines = results.map((r) => `${r.satisfied ? "✅" : "⛔"} ${r.decision} "${r.title}" — ${r.assert} ${r.subject}${r.object ? ` → ${r.object}` : ""}: ${r.detail}`);
|
|
450
|
+
const head = violations.length ? `⛔ ${violations.length} intent(s) the code no longer satisfies` : "✅ the code satisfies every recorded intent";
|
|
451
|
+
return ok(`Intent-conformance (${results.length - violations.length}/${results.length} satisfied):\n\n${lines.join("\n")}\n\n${head}`);
|
|
452
|
+
});
|
|
438
453
|
return server;
|
|
439
454
|
}
|
|
440
455
|
function provLine(record) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
6
|
"description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",
|