@davesheffer/hunch 0.11.3 → 0.12.1
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 +34 -7
- package/dist/cli/index.js +69 -70
- package/dist/core/checkreport.js +113 -0
- package/dist/extractors/git.js +18 -0
- package/dist/integrations/ciAction.js +94 -0
- package/package.json +68 -68
package/README.md
CHANGED
|
@@ -149,7 +149,8 @@ preserved) and is idempotent. Opt out with `hunch init --no-providers`.
|
|
|
149
149
|
| `hunch embed` | generate local embeddings for semantic recall (opt-in; needs `@huggingface/transformers`) |
|
|
150
150
|
| `hunch context <path\|symbol> [--as-of <ref>]` | minimal relevant slice for a task: invariants → decisions → bugs → blast radius (`--as-of` time-travels) |
|
|
151
151
|
| `hunch fragile` | ranked fragility report with evidence |
|
|
152
|
-
| `hunch check [--staged\|--commit <sha>] [--strict] [--blast]` | guardrail: flag changes touching a do-not-break invariant **directly or via blast radius** (a guarded file that depends on what you changed), **and changes that re-introduce something a decision deliberately retired** (the Regression Guard); `--blast` prints the dependency fan-out |
|
|
152
|
+
| `hunch check [--staged\|--commit <sha>\|--base <ref>] [--strict] [--format text\|markdown] [--blast]` | guardrail: flag changes touching a do-not-break invariant **directly or via blast radius** (a guarded file that depends on what you changed), **and changes that re-introduce something a decision deliberately retired** (the Regression Guard). `--base <ref>` checks a PR's diff (for CI); `--format markdown` emits a PR comment; `--strict` fails only on a direct, high-confidence, non-stale **blocking** invariant; `--blast` prints the dependency fan-out |
|
|
153
|
+
| `hunch ci` | scaffold the **CI Constraint Guard** — a GitHub Action that runs `hunch check` on every PR, comments the affected invariants/decisions, and fails on a blocking one |
|
|
153
154
|
| `hunch stale [--resync]` | drift: records whose files changed after last verification (`--resync` regenerates stale decisions from their commits) |
|
|
154
155
|
| `hunch review [--accept <id>\|--reject <id>]` | curate: triage / promote / drop low-confidence drafts |
|
|
155
156
|
| `hunch migrate` | upgrade `.hunch/` records to the current schema version |
|
|
@@ -264,6 +265,26 @@ again. It preserves the runner's exit code, so it's a drop-in CI step:
|
|
|
264
265
|
Repair drift after refactors with **`hunch stale --resync`** (re-synthesizes stale decisions
|
|
265
266
|
from their commits via the LLM).
|
|
266
267
|
|
|
268
|
+
### Block a PR that breaks memory (CI Constraint Guard)
|
|
269
|
+
|
|
270
|
+
Memory that only *advises* gets ignored. `hunch ci` scaffolds a GitHub Action that turns
|
|
271
|
+
Hunch into a **merge gate**: on every pull request it runs `hunch check` over the diff,
|
|
272
|
+
posts a sticky comment citing the affected `con_`/`dec_` ids, and **fails the check** when
|
|
273
|
+
the PR breaks a *direct, high-confidence, non-stale* blocking invariant, re-adds
|
|
274
|
+
deliberately-retired code, or contradicts an in-force decision.
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
hunch ci # writes .github/workflows/hunch-guard.yml — commit it
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
It reasons over **the diff plus the constraints committed in the same git history**, so the
|
|
281
|
+
comment says exactly which decision a change violates ("breaks `con_004` — server-side
|
|
282
|
+
revocation, from `dec_017`"). Make *Hunch Guard* a required status check in branch protection
|
|
283
|
+
to enforce on merge. The hardened strict gate only blocks on high-confidence, non-stale
|
|
284
|
+
invariants — stale / low-confidence / blast-radius hits stay advisory in the comment — so
|
|
285
|
+
it's safe to require on a shared repo. Under the hood it's just
|
|
286
|
+
`hunch check --base origin/<target> --strict --format markdown`.
|
|
287
|
+
|
|
267
288
|
## Maintenance
|
|
268
289
|
|
|
269
290
|
- **`hunch doctor`** — is git healthy? are you on the subscription path or the offline
|
|
@@ -313,10 +334,15 @@ src/
|
|
|
313
334
|
|
|
314
335
|
## VS Code
|
|
315
336
|
|
|
316
|
-
A companion **[VS Code extension](vscode-extension/)**
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
337
|
+
A companion **[VS Code extension](vscode-extension/)** (on
|
|
338
|
+
[Open VSX](https://open-vsx.org/extension/davesheffer/hunch-vscode) — works in
|
|
339
|
+
VS Code / Cursor / Windsurf / VSCodium) brings the graph into the editor: a tree of
|
|
340
|
+
decisions / invariants / bugs / **bug-lineage** / fragility / **stale records**, a
|
|
341
|
+
**CodeLens** summary + per-symbol bug/fragility marks, **hover** with bug history,
|
|
342
|
+
invariants surfaced in the **Problems panel**, overview-ruler hotspot marks, an
|
|
343
|
+
interactive **component graph**, fuzzy **search**, and a status-bar invariant counter.
|
|
344
|
+
It reads the committed `.hunch/` JSON directly (no server, no native deps); writes
|
|
345
|
+
delegate to the `hunch` CLI.
|
|
320
346
|
|
|
321
347
|
## Notable engineering decisions
|
|
322
348
|
|
|
@@ -341,5 +367,6 @@ npm test # node:test suite (store, graph, parse, indexer, synthesis,
|
|
|
341
367
|
npm run hunch -- why src/store/hunchStore.ts # run the CLI from source via tsx, no build
|
|
342
368
|
```
|
|
343
369
|
|
|
344
|
-
See [DESIGN.md](DESIGN.md) for the full spec.
|
|
345
|
-
|
|
370
|
+
See [DESIGN.md](DESIGN.md) for the full spec. PR/CI enforcement now ships as the
|
|
371
|
+
**CI Constraint Guard** (`hunch ci`). Still deferred by design: a hosted web dashboard
|
|
372
|
+
and multi-repo support.
|
package/dist/cli/index.js
CHANGED
|
@@ -24,12 +24,14 @@ import { indexRepo } from "../extractors/indexer.js";
|
|
|
24
24
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
25
25
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
26
26
|
import { selectProvider } from "../synthesis/provider.js";
|
|
27
|
-
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff } from "../extractors/git.js";
|
|
27
|
+
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, revExists } from "../extractors/git.js";
|
|
28
28
|
import { analyzeDiff } from "../extractors/diff.js";
|
|
29
29
|
import { isStrictBlocker } from "../core/strictgate.js";
|
|
30
|
+
import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkreport.js";
|
|
30
31
|
import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
|
|
31
32
|
import { installMergeDriver } from "../integrations/mergeDriver.js";
|
|
32
33
|
import { ensureGitignore } from "../integrations/gitignore.js";
|
|
34
|
+
import { writeCiWorkflow } from "../integrations/ciAction.js";
|
|
33
35
|
import { updateClaudeMd } from "../integrations/claudemd.js";
|
|
34
36
|
import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
|
|
35
37
|
import { scaffoldProviders } from "../integrations/providers.js";
|
|
@@ -534,54 +536,82 @@ program
|
|
|
534
536
|
// ---- check (constraint enforcement) ---------------------------------------
|
|
535
537
|
program
|
|
536
538
|
.command("check")
|
|
537
|
-
.description("Flag changes that touch a do-not-break invariant
|
|
539
|
+
.description("Flag changes that touch a do-not-break invariant — the local guardrail AND the CI/PR Constraint Guard.")
|
|
538
540
|
.option("--staged", "check git staged files (default)")
|
|
539
541
|
.option("--commit <sha>", "check a specific commit's files")
|
|
542
|
+
.option("--base <ref>", "check a PR/branch: files changed vs <ref> (e.g. origin/main) — for CI")
|
|
540
543
|
.option("--strict", "exit non-zero ONLY on a direct, high-confidence, non-stale blocking invariant (near/stale/low-confidence stay advisory)")
|
|
544
|
+
.option("--format <fmt>", "output: text (default) | markdown (a PR comment)", "text")
|
|
541
545
|
.option("--blast", "also print the dependency blast radius of the changed files")
|
|
542
546
|
.action((opts) => {
|
|
543
|
-
|
|
544
|
-
|
|
547
|
+
const sources = [opts.commit && "--commit", opts.base && "--base", opts.staged && "--staged"].filter(Boolean);
|
|
548
|
+
if (sources.length > 1)
|
|
549
|
+
return fail(`pick one of --staged / --commit / --base (got ${sources.join(", ")})`);
|
|
550
|
+
const markdown = opts.format === "markdown";
|
|
551
|
+
const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], strictBlockers: 0, regBlocking: 0 };
|
|
545
552
|
const { store, root } = storeFor();
|
|
553
|
+
// Fail loudly on an unresolvable --base (e.g. CI forgot to fetch the base
|
|
554
|
+
// branch) — otherwise the diff is empty and the guard passes vacuously.
|
|
555
|
+
if (opts.base && !revExists(opts.base, root)) {
|
|
556
|
+
store.close();
|
|
557
|
+
return fail(`--base ref "${opts.base}" does not resolve. In CI, fetch the base branch first (git fetch origin <branch>).`);
|
|
558
|
+
}
|
|
546
559
|
store.reindex(); // blast radius walks the edge graph — make the index current
|
|
547
|
-
const files = opts.commit ? commitFiles(opts.commit, root)
|
|
560
|
+
const files = opts.commit ? commitFiles(opts.commit, root)
|
|
561
|
+
: opts.base ? rangeFiles(opts.base, root)
|
|
562
|
+
: stagedFiles(root);
|
|
548
563
|
if (!files.length) {
|
|
549
|
-
console.log("No changed files to check.");
|
|
564
|
+
console.log(markdown ? renderMarkdown(emptyReport) : "No changed files to check.");
|
|
550
565
|
store.close();
|
|
551
566
|
return;
|
|
552
567
|
}
|
|
553
|
-
const mark = (s) => (s === "blocking" ? "⛔" : s === "warning" ? "⚠" : "·");
|
|
554
568
|
// 1) DIRECT — a changed file matches a constraint's scope.
|
|
555
569
|
const direct = new Map();
|
|
556
|
-
for (const f of files)
|
|
570
|
+
for (const f of files)
|
|
557
571
|
for (const c of store.checkConstraints(f)) {
|
|
558
572
|
const e = direct.get(c.id) ?? { c, files: [] };
|
|
559
573
|
e.files.push(f);
|
|
560
574
|
direct.set(c.id, e);
|
|
561
575
|
}
|
|
562
|
-
|
|
563
|
-
// 2) NEAR — a changed file's blast radius reaches a file an invariant guards:
|
|
564
|
-
// you didn't touch the invariant, but you touched something it depends on.
|
|
576
|
+
// 2) NEAR — reached only through the blast radius (a guarded dependency changed).
|
|
565
577
|
const near = new Map();
|
|
566
|
-
for (const f of files)
|
|
567
|
-
for (const b of store.blastRadiusFiles(f))
|
|
578
|
+
for (const f of files)
|
|
579
|
+
for (const b of store.blastRadiusFiles(f))
|
|
568
580
|
for (const c of store.checkConstraints(b.file)) {
|
|
569
581
|
if (direct.has(c.id))
|
|
570
|
-
continue; // already
|
|
582
|
+
continue; // already a direct hit
|
|
571
583
|
const e = near.get(c.id) ?? { c, via: [] };
|
|
572
584
|
e.via.push(`${f} → ${b.file} (${b.via}, depth ${b.depth})`);
|
|
573
585
|
near.set(c.id, e);
|
|
574
586
|
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
// 3) REGRESSION — does the diff RE-ADD something an in-force decision removed?
|
|
578
|
-
// (e.g. re-introducing a symbol/dep that was deliberately deleted). Warn
|
|
579
|
-
// always; only a blocking-linked resurrection fails the commit under strict.
|
|
580
|
-
const diff = opts.commit ? commitDiff(opts.commit, root) : stagedDiff(root);
|
|
587
|
+
// 3) REGRESSION — does the diff RE-ADD something an in-force decision retired?
|
|
588
|
+
const diff = opts.commit ? commitDiff(opts.commit, root) : opts.base ? rangeDiff(opts.base, root) : stagedDiff(root);
|
|
581
589
|
const an = analyzeDiff(diff);
|
|
582
590
|
const regHits = store.regressionHits({ symbols: an.addedSymbols.map((s) => s.name), deps: an.addedDeps }, files);
|
|
583
|
-
|
|
584
|
-
|
|
591
|
+
// Hardened strict gate (strictgate.ts): only DIRECT + high-confidence + non-stale
|
|
592
|
+
// can fail. near/stale/low-confidence stay advisory — safe on a shared repo / PR.
|
|
593
|
+
const staleConstraintIds = opts.strict
|
|
594
|
+
? new Set(store.staleness((f) => lastChangeDate(f, root)).filter((s) => s.kind === "constraint").map((s) => s.id))
|
|
595
|
+
: new Set();
|
|
596
|
+
const directReport = [...direct.values()].map(({ c, files: fs }) => {
|
|
597
|
+
const stale = staleConstraintIds.has(c.id);
|
|
598
|
+
const strictBlocks = isStrictBlocker(c, stale);
|
|
599
|
+
return {
|
|
600
|
+
id: c.id, severity: c.severity ?? "advisory", statement: c.statement, rationale: c.rationale ?? "",
|
|
601
|
+
files: fs, strictBlocks,
|
|
602
|
+
downgrade: c.severity === "blocking" && !strictBlocks ? (stale ? "stale" : "low-confidence") : undefined,
|
|
603
|
+
};
|
|
604
|
+
});
|
|
605
|
+
const report = {
|
|
606
|
+
fileCount: files.length,
|
|
607
|
+
strict: !!opts.strict,
|
|
608
|
+
direct: directReport,
|
|
609
|
+
near: [...near.values()].map(({ c, via }) => ({ id: c.id, severity: c.severity ?? "advisory", statement: c.statement, via })),
|
|
610
|
+
regressions: regHits.map((h) => ({ kind: h.kind, name: h.name, decision: h.decision, title: h.title, reason: h.reason, blocking: h.blocking })),
|
|
611
|
+
strictBlockers: directReport.filter((d) => d.strictBlocks).length,
|
|
612
|
+
regBlocking: regHits.filter((h) => h.blocking).length,
|
|
613
|
+
};
|
|
614
|
+
if (opts.blast && !markdown) {
|
|
585
615
|
console.log(`Blast radius of ${files.length} changed file(s):`);
|
|
586
616
|
for (const f of files) {
|
|
587
617
|
const b = store.blastRadiusFiles(f);
|
|
@@ -590,58 +620,27 @@ program
|
|
|
590
620
|
}
|
|
591
621
|
console.log("");
|
|
592
622
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
store.close();
|
|
596
|
-
return;
|
|
597
|
-
}
|
|
598
|
-
// --strict may FAIL a commit ONLY on a DIRECT, high-confidence, non-stale
|
|
599
|
-
// blocking invariant (see strictgate.ts) — never on a blast-radius ("near")
|
|
600
|
-
// guess or a stale/low-confidence record. Those weaker hits still print, as
|
|
601
|
-
// advisory, so strict mode is safe to enable on a shared repo.
|
|
602
|
-
const staleConstraintIds = opts.strict
|
|
603
|
-
? new Set(store.staleness((f) => lastChangeDate(f, root)).filter((s) => s.kind === "constraint").map((s) => s.id))
|
|
604
|
-
: new Set();
|
|
605
|
-
let strictBlockers = 0;
|
|
606
|
-
if (direct.size) {
|
|
607
|
-
console.log(`Directly touches ${direct.size} invariant(s):\n`);
|
|
608
|
-
for (const { c, files: fs } of direct.values()) {
|
|
609
|
-
const blocks = isStrictBlocker(c, staleConstraintIds.has(c.id));
|
|
610
|
-
if (blocks)
|
|
611
|
-
strictBlockers++;
|
|
612
|
-
const note = opts.strict && c.severity === "blocking" && !blocks
|
|
613
|
-
? staleConstraintIds.has(c.id) ? " (advisory: stale)" : " (advisory: low confidence)"
|
|
614
|
-
: "";
|
|
615
|
-
console.log(` ${mark(c.severity)} [${c.severity}] ${c.statement}${note}\n ${c.id} · in: ${fs.join(", ")}\n rationale: ${c.rationale || "—"}`);
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
if (near.size) {
|
|
619
|
-
console.log(`${direct.size ? "\n" : ""}Near ${near.size} invariant(s) via blast radius (a guarded dependency changed — review; never blocks):\n`);
|
|
620
|
-
for (const { c, via } of near.values()) {
|
|
621
|
-
console.log(` ${mark(c.severity)} [${c.severity}] ${c.statement}\n ${c.id}\n ${via.slice(0, 4).join("\n ")}${via.length > 4 ? `\n …+${via.length - 4} more path(s)` : ""}`);
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
if (regHits.length) {
|
|
625
|
-
console.log(`${direct.size || near.size ? "\n" : ""}Re-introduces ${regHits.length} deliberately-retired item(s):\n`);
|
|
626
|
-
for (const h of regHits) {
|
|
627
|
-
console.log(` ${h.blocking ? "⛔" : "⚠"} re-adds ${h.kind} \`${h.name}\` — ${h.decision} removed it${h.blocking ? " (blocking-linked)" : ""}\n “${h.title}”\n ${h.reason}`);
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
if (opts.strict && (strictBlockers || regBlocking)) {
|
|
631
|
-
const reasons = [
|
|
632
|
-
strictBlockers ? `${strictBlockers} high-confidence blocking invariant(s) directly in scope` : "",
|
|
633
|
-
regBlocking ? `${regBlocking} blocking-linked regression(s)` : "",
|
|
634
|
-
].filter(Boolean).join(" + ");
|
|
635
|
-
console.log(`\n✗ ${reasons} — review before committing.`);
|
|
623
|
+
console.log(markdown ? renderMarkdown(report) : renderText(report));
|
|
624
|
+
if (reportFailsStrict(report))
|
|
636
625
|
process.exitCode = 1;
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
626
|
+
store.close();
|
|
627
|
+
});
|
|
628
|
+
// ---- ci (scaffold the CI Constraint Guard) --------------------------------
|
|
629
|
+
program
|
|
630
|
+
.command("ci")
|
|
631
|
+
.description("Scaffold the CI Constraint Guard: a GitHub Action that runs `hunch check` on PRs, comments the result, and fails on a blocking invariant.")
|
|
632
|
+
.action(() => {
|
|
633
|
+
const root = findRoot();
|
|
634
|
+
const r = writeCiWorkflow(root);
|
|
635
|
+
if (r.action === "created") {
|
|
636
|
+
console.log(`✓ wrote ${rel(root, r.path)}`);
|
|
637
|
+
console.log(" Runs on every PR: comments the affected invariants/decisions and fails on a direct,");
|
|
638
|
+
console.log(" high-confidence, non-stale blocking invariant. Commit it, then (optionally) make");
|
|
639
|
+
console.log(' "Hunch Guard" a required status check in branch protection to enforce on merge.');
|
|
640
640
|
}
|
|
641
641
|
else {
|
|
642
|
-
console.log(
|
|
642
|
+
console.log(`· ${rel(root, r.path)} already exists — left untouched. Delete it to regenerate.`);
|
|
643
643
|
}
|
|
644
|
-
store.close();
|
|
645
644
|
});
|
|
646
645
|
// ---- context (surgical retrieval) -----------------------------------------
|
|
647
646
|
program
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Rendering for `hunch check` — pure, so the terminal output and the CI/PR
|
|
2
|
+
* comment share one source of truth and stay unit-testable. The CLI builds a
|
|
3
|
+
* CheckReport from the store (direct/near/regression + the hardened strict gate),
|
|
4
|
+
* then renders it as text (terminal, unchanged) or markdown (a PR comment posted
|
|
5
|
+
* by the GitHub Action). The exit-code decision lives with the caller. */
|
|
6
|
+
export function reportIsClean(r) {
|
|
7
|
+
return r.direct.length === 0 && r.near.length === 0 && r.regressions.length === 0;
|
|
8
|
+
}
|
|
9
|
+
/** True when --strict should FAIL the commit/PR. */
|
|
10
|
+
export function reportFailsStrict(r) {
|
|
11
|
+
return r.strict && (r.strictBlockers > 0 || r.regBlocking > 0);
|
|
12
|
+
}
|
|
13
|
+
const mark = (s) => (s === "blocking" ? "⛔" : s === "warning" ? "⚠" : "·");
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Terminal text (unchanged from the inline CLI output it replaces)
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
export function renderText(r) {
|
|
18
|
+
if (reportIsClean(r)) {
|
|
19
|
+
return `✓ ${r.fileCount} changed file(s) touch no recorded invariants (directly or via blast radius) and re-introduce nothing deliberately retired.`;
|
|
20
|
+
}
|
|
21
|
+
const out = [];
|
|
22
|
+
if (r.direct.length) {
|
|
23
|
+
out.push(`Directly touches ${r.direct.length} invariant(s):\n`);
|
|
24
|
+
for (const c of r.direct) {
|
|
25
|
+
const note = r.strict && c.severity === "blocking" && !c.strictBlocks
|
|
26
|
+
? c.downgrade === "stale" ? " (advisory: stale)" : " (advisory: low confidence)"
|
|
27
|
+
: "";
|
|
28
|
+
out.push(` ${mark(c.severity)} [${c.severity}] ${c.statement}${note}\n ${c.id} · in: ${c.files.join(", ")}\n rationale: ${c.rationale || "—"}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (r.near.length) {
|
|
32
|
+
out.push(`${r.direct.length ? "\n" : ""}Near ${r.near.length} invariant(s) via blast radius (a guarded dependency changed — review; never blocks):\n`);
|
|
33
|
+
for (const c of r.near) {
|
|
34
|
+
out.push(` ${mark(c.severity)} [${c.severity}] ${c.statement}\n ${c.id}\n ${c.via.slice(0, 4).join("\n ")}${c.via.length > 4 ? `\n …+${c.via.length - 4} more path(s)` : ""}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (r.regressions.length) {
|
|
38
|
+
out.push(`${r.direct.length || r.near.length ? "\n" : ""}Re-introduces ${r.regressions.length} deliberately-retired item(s):\n`);
|
|
39
|
+
for (const h of r.regressions) {
|
|
40
|
+
out.push(` ${h.blocking ? "⛔" : "⚠"} re-adds ${h.kind} \`${h.name}\` — ${h.decision} removed it${h.blocking ? " (blocking-linked)" : ""}\n “${h.title}”\n ${h.reason}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (reportFailsStrict(r)) {
|
|
44
|
+
const reasons = [
|
|
45
|
+
r.strictBlockers ? `${r.strictBlockers} high-confidence blocking invariant(s) directly in scope` : "",
|
|
46
|
+
r.regBlocking ? `${r.regBlocking} blocking-linked regression(s)` : "",
|
|
47
|
+
].filter(Boolean).join(" + ");
|
|
48
|
+
out.push(`\n✗ ${reasons} — review before committing.`);
|
|
49
|
+
}
|
|
50
|
+
else if (r.strict) {
|
|
51
|
+
out.push(`\nReview these — none are a direct, high-confidence, non-stale blocking invariant, so the commit is NOT blocked.`);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
out.push(`\nReview that these invariants still hold. (Advisory — run with --strict to fail on direct, high-confidence, non-stale blocking invariants.)`);
|
|
55
|
+
}
|
|
56
|
+
return out.join("\n");
|
|
57
|
+
}
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Markdown (a PR comment posted by the CI Constraint Guard)
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
export function renderMarkdown(r) {
|
|
62
|
+
const H = "## 🧠 Hunch — Engineering Memory Guard";
|
|
63
|
+
if (reportIsClean(r)) {
|
|
64
|
+
return `${H}\n\n✅ This PR touches **no recorded invariants** (directly or via blast radius) and re-introduces nothing deliberately retired across ${r.fileCount} changed file(s).`;
|
|
65
|
+
}
|
|
66
|
+
const out = [H, ""];
|
|
67
|
+
if (r.direct.length) {
|
|
68
|
+
out.push(`### ⛔ Invariants directly in scope`);
|
|
69
|
+
for (const c of r.direct) {
|
|
70
|
+
const note = r.strict && c.severity === "blocking" && !c.strictBlocks
|
|
71
|
+
? c.downgrade === "stale" ? " _(advisory: record is stale)_" : " _(advisory: low confidence)_"
|
|
72
|
+
: "";
|
|
73
|
+
out.push(`- **[${c.severity}] ${c.statement}** — \`${c.id}\`${note}`);
|
|
74
|
+
out.push(` - in: ${c.files.map((f) => `\`${f}\``).join(", ")}`);
|
|
75
|
+
if (c.rationale)
|
|
76
|
+
out.push(` - _${c.rationale}_`);
|
|
77
|
+
}
|
|
78
|
+
out.push("");
|
|
79
|
+
}
|
|
80
|
+
if (r.near.length) {
|
|
81
|
+
out.push(`### ⚠ Near-invariants (reached via blast radius — review, never blocks)`);
|
|
82
|
+
for (const c of r.near) {
|
|
83
|
+
out.push(`- **[${c.severity}] ${c.statement}** — \`${c.id}\``);
|
|
84
|
+
out.push(` - ${c.via.slice(0, 3).join("\n - ")}${c.via.length > 3 ? `\n - …+${c.via.length - 3} more path(s)` : ""}`);
|
|
85
|
+
}
|
|
86
|
+
out.push("");
|
|
87
|
+
}
|
|
88
|
+
if (r.regressions.length) {
|
|
89
|
+
out.push(`### ♻️ Re-introduces deliberately-retired code`);
|
|
90
|
+
for (const h of r.regressions) {
|
|
91
|
+
out.push(`- ${h.blocking ? "⛔" : "⚠"} re-adds ${h.kind} \`${h.name}\` — \`${h.decision}\` removed it${h.blocking ? " **(blocking-linked)**" : ""}`);
|
|
92
|
+
out.push(` - _${h.title}_`);
|
|
93
|
+
}
|
|
94
|
+
out.push("");
|
|
95
|
+
}
|
|
96
|
+
out.push("---");
|
|
97
|
+
if (reportFailsStrict(r)) {
|
|
98
|
+
const reasons = [
|
|
99
|
+
r.strictBlockers ? `${r.strictBlockers} high-confidence blocking invariant(s) directly in scope` : "",
|
|
100
|
+
r.regBlocking ? `${r.regBlocking} blocking-linked regression(s)` : "",
|
|
101
|
+
].filter(Boolean).join(" + ");
|
|
102
|
+
out.push(`❌ **This PR breaks ${reasons}.** Resolve or supersede the decision before merge.`);
|
|
103
|
+
}
|
|
104
|
+
else if (r.strict) {
|
|
105
|
+
out.push(`ℹ️ Nothing here is a direct, high-confidence, non-stale blocking invariant — **not blocking** this PR.`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
out.push(`ℹ️ Advisory — review that these invariants still hold.`);
|
|
109
|
+
}
|
|
110
|
+
out.push(`\n<sub>🧠 Hunch · engineering memory · run \`hunch why <file>\` for the full reasoning.</sub>`);
|
|
111
|
+
return out.join("\n");
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=checkreport.js.map
|
package/dist/extractors/git.js
CHANGED
|
@@ -153,6 +153,24 @@ export function stagedFiles(cwd) {
|
|
|
153
153
|
const out = gitSafe(["diff", "--cached", "--name-only", "--diff-filter=ACMR"], cwd);
|
|
154
154
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
155
155
|
}
|
|
156
|
+
/** Does a ref resolve to a commit in this repo? Lets `--base` fail LOUDLY on an
|
|
157
|
+
* unfetched/typo'd ref instead of silently diffing against nothing (a vacuous
|
|
158
|
+
* CI pass), since the diff helpers below swallow git errors to "". */
|
|
159
|
+
export function revExists(ref, cwd) {
|
|
160
|
+
return gitSafe(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], cwd) !== "";
|
|
161
|
+
}
|
|
162
|
+
/** Files a PR/branch changes vs `base` (3-dot: changes on HEAD since the merge-base,
|
|
163
|
+
* i.e. exactly the PR's own commits — the CI Constraint Guard's surface). */
|
|
164
|
+
export function rangeFiles(base, cwd, head = "HEAD") {
|
|
165
|
+
const out = gitSafe(["diff", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
|
|
166
|
+
return out ? out.split("\n").filter(Boolean) : [];
|
|
167
|
+
}
|
|
168
|
+
/** The PR's unified diff vs `base` (3-dot), for the Regression Guard's structural
|
|
169
|
+
* analysis. Same noise-exclusion + truncation budget as commit/staged diffs. */
|
|
170
|
+
export function rangeDiff(base, cwd, head = "HEAD", maxBytes = 60_000) {
|
|
171
|
+
const out = gitSafe(["diff", "--no-color", "--unified=2", `${base}...${head}`, "--", ...DIFF_NOISE], cwd);
|
|
172
|
+
return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
|
|
173
|
+
}
|
|
156
174
|
/** Unified diff of the staged changes (for the Regression Guard's structural
|
|
157
175
|
* analysis). Excludes machine-generated noise and truncates at the SAME budget as
|
|
158
176
|
* commitDiff, so the staged and `--commit` guard paths can't diverge on big diffs. */
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** The CI Constraint Guard — Hunch's enforcement edge. A GitHub Actions workflow
|
|
2
|
+
* that runs `hunch check` over a pull request's diff, posts the result as a
|
|
3
|
+
* sticky PR comment (citing the con_/dec_ ids), and FAILS the check on a direct,
|
|
4
|
+
* high-confidence, non-stale blocking invariant (the hardened strict gate). This
|
|
5
|
+
* turns the engineering memory from advisory into a merge gate — and it's
|
|
6
|
+
* structurally uncopyable: it reasons over the diff PLUS the constraints
|
|
7
|
+
* committed in the same git history. Idempotent: never clobbers a user's edits.
|
|
8
|
+
*/
|
|
9
|
+
import { mkdirSync, writeFileSync, existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
// `\${{ … }}` keeps GitHub Actions expressions literal inside this template
|
|
12
|
+
// literal (a bare `${` would be JS interpolation).
|
|
13
|
+
export function ciWorkflowYaml() {
|
|
14
|
+
return `# Hunch — CI Constraint Guard. Blocks a PR that breaks a recorded invariant,
|
|
15
|
+
# re-adds deliberately-retired code, or contradicts an in-force decision, and
|
|
16
|
+
# comments with the why (con_/dec_ ids). Make this a required check to enforce.
|
|
17
|
+
name: Hunch Guard
|
|
18
|
+
|
|
19
|
+
on:
|
|
20
|
+
pull_request:
|
|
21
|
+
|
|
22
|
+
permissions:
|
|
23
|
+
contents: read
|
|
24
|
+
pull-requests: write
|
|
25
|
+
|
|
26
|
+
jobs:
|
|
27
|
+
hunch-guard:
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/checkout@v4
|
|
31
|
+
with:
|
|
32
|
+
fetch-depth: 0 # full history: the guard diffs base...head and reads git log
|
|
33
|
+
|
|
34
|
+
- uses: actions/setup-node@v4
|
|
35
|
+
with:
|
|
36
|
+
node-version: 20
|
|
37
|
+
|
|
38
|
+
- name: Install Hunch
|
|
39
|
+
run: npm install -g @davesheffer/hunch
|
|
40
|
+
|
|
41
|
+
- name: Fetch the PR base branch
|
|
42
|
+
# checkout sets up no origin/<base> tracking ref; create it explicitly so
|
|
43
|
+
# the guard's base...head diff resolves (otherwise it sees zero changes and
|
|
44
|
+
# passes vacuously).
|
|
45
|
+
run: git fetch --no-tags origin "+refs/heads/\${{ github.base_ref }}:refs/remotes/origin/\${{ github.base_ref }}"
|
|
46
|
+
|
|
47
|
+
- name: Run Constraint Guard
|
|
48
|
+
id: guard
|
|
49
|
+
run: |
|
|
50
|
+
set +e
|
|
51
|
+
hunch check --base "origin/\${{ github.base_ref }}" --strict --format markdown > hunch-report.md
|
|
52
|
+
echo "exit=$?" >> "$GITHUB_OUTPUT"
|
|
53
|
+
set -e
|
|
54
|
+
|
|
55
|
+
- name: Comment on PR
|
|
56
|
+
if: always()
|
|
57
|
+
uses: actions/github-script@v7
|
|
58
|
+
with:
|
|
59
|
+
script: |
|
|
60
|
+
const fs = require('fs');
|
|
61
|
+
const body = (fs.existsSync('hunch-report.md') ? fs.readFileSync('hunch-report.md', 'utf8') : '').trim();
|
|
62
|
+
if (!body) { core.info('Hunch: empty report — skipping comment.'); return; }
|
|
63
|
+
const marker = '<!-- hunch-guard -->';
|
|
64
|
+
const { owner, repo } = context.repo;
|
|
65
|
+
const issue_number = context.payload.pull_request.number;
|
|
66
|
+
const out = marker + '\\n' + body;
|
|
67
|
+
try {
|
|
68
|
+
const all = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number });
|
|
69
|
+
const existing = all.find(c => c.body && c.body.includes(marker));
|
|
70
|
+
if (existing) await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: out });
|
|
71
|
+
else await github.rest.issues.createComment({ owner, repo, issue_number, body: out });
|
|
72
|
+
} catch (e) {
|
|
73
|
+
core.warning('Hunch: could not post PR comment (fork PR has a read-only token?): ' + e.message);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
- name: Enforce (fail on a blocking invariant)
|
|
77
|
+
# Default to 1 if the guard step died before recording its exit — never a
|
|
78
|
+
# vacuous pass.
|
|
79
|
+
if: always()
|
|
80
|
+
run: exit \${{ steps.guard.outputs.exit || '1' }}
|
|
81
|
+
`;
|
|
82
|
+
}
|
|
83
|
+
/** Write .github/workflows/hunch-guard.yml. Never overwrites an existing file
|
|
84
|
+
* (respects user edits) — reports "exists" instead. */
|
|
85
|
+
export function writeCiWorkflow(root) {
|
|
86
|
+
const dir = join(root, ".github", "workflows");
|
|
87
|
+
const path = join(dir, "hunch-guard.yml");
|
|
88
|
+
if (existsSync(path))
|
|
89
|
+
return { path, action: "exists" };
|
|
90
|
+
mkdirSync(dir, { recursive: true });
|
|
91
|
+
writeFileSync(path, ciWorkflowYaml());
|
|
92
|
+
return { path, action: "created" };
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=ciAction.js.map
|
package/package.json
CHANGED
|
@@ -1,68 +1,68 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"license": "MIT",
|
|
5
|
-
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
|
-
"description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",
|
|
7
|
-
"homepage": "https://hunch-pi.vercel.app",
|
|
8
|
-
"repository": {
|
|
9
|
-
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/davesheffer/hunch.git"
|
|
11
|
-
},
|
|
12
|
-
"bugs": {
|
|
13
|
-
"url": "https://github.com/davesheffer/hunch/issues"
|
|
14
|
-
},
|
|
15
|
-
"type": "module",
|
|
16
|
-
"bin": {
|
|
17
|
-
"hunch": "dist/cli/index.js"
|
|
18
|
-
},
|
|
19
|
-
"files": [
|
|
20
|
-
"dist/**/*.js"
|
|
21
|
-
],
|
|
22
|
-
"publishConfig": {
|
|
23
|
-
"access": "public"
|
|
24
|
-
},
|
|
25
|
-
"keywords": [
|
|
26
|
-
"claude-code",
|
|
27
|
-
"mcp",
|
|
28
|
-
"engineering-memory",
|
|
29
|
-
"knowledge-graph",
|
|
30
|
-
"code-intelligence",
|
|
31
|
-
"ai",
|
|
32
|
-
"developer-tools"
|
|
33
|
-
],
|
|
34
|
-
"engines": {
|
|
35
|
-
"node": ">=20"
|
|
36
|
-
},
|
|
37
|
-
"scripts": {
|
|
38
|
-
"clean": "node --input-type=commonjs -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
39
|
-
"build": "npm run clean && tsc -p tsconfig.json",
|
|
40
|
-
"dev": "tsx src/cli/index.ts",
|
|
41
|
-
"hunch": "tsx src/cli/index.ts",
|
|
42
|
-
"test": "tsx --test test/*.test.ts",
|
|
43
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
44
|
-
"prepublishOnly": "npm run build"
|
|
45
|
-
},
|
|
46
|
-
"dependencies": {
|
|
47
|
-
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
48
|
-
"better-sqlite3": "12.9.0",
|
|
49
|
-
"commander": "^15.0.0",
|
|
50
|
-
"tree-sitter": "0.21.1",
|
|
51
|
-
"tree-sitter-typescript": "^0.23.2",
|
|
52
|
-
"zod": "^4.4.3"
|
|
53
|
-
},
|
|
54
|
-
"devDependencies": {
|
|
55
|
-
"@types/better-sqlite3": "^7.6.13",
|
|
56
|
-
"@types/node": "^20.19.0",
|
|
57
|
-
"tsx": "^4.22.4",
|
|
58
|
-
"typescript": "^5.9.3"
|
|
59
|
-
},
|
|
60
|
-
"peerDependencies": {
|
|
61
|
-
"@huggingface/transformers": ">=3"
|
|
62
|
-
},
|
|
63
|
-
"peerDependenciesMeta": {
|
|
64
|
-
"@huggingface/transformers": {
|
|
65
|
-
"optional": true
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@davesheffer/hunch",
|
|
3
|
+
"version": "0.12.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
|
+
"description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",
|
|
7
|
+
"homepage": "https://hunch-pi.vercel.app",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/davesheffer/hunch.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/davesheffer/hunch/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"hunch": "dist/cli/index.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist/**/*.js"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"claude-code",
|
|
27
|
+
"mcp",
|
|
28
|
+
"engineering-memory",
|
|
29
|
+
"knowledge-graph",
|
|
30
|
+
"code-intelligence",
|
|
31
|
+
"ai",
|
|
32
|
+
"developer-tools"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"clean": "node --input-type=commonjs -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
39
|
+
"build": "npm run clean && tsc -p tsconfig.json",
|
|
40
|
+
"dev": "tsx src/cli/index.ts",
|
|
41
|
+
"hunch": "tsx src/cli/index.ts",
|
|
42
|
+
"test": "tsx --test test/*.test.ts",
|
|
43
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
44
|
+
"prepublishOnly": "npm run build"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
48
|
+
"better-sqlite3": "12.9.0",
|
|
49
|
+
"commander": "^15.0.0",
|
|
50
|
+
"tree-sitter": "0.21.1",
|
|
51
|
+
"tree-sitter-typescript": "^0.23.2",
|
|
52
|
+
"zod": "^4.4.3"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
56
|
+
"@types/node": "^20.19.0",
|
|
57
|
+
"tsx": "^4.22.4",
|
|
58
|
+
"typescript": "^5.9.3"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"@huggingface/transformers": ">=3"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"@huggingface/transformers": {
|
|
65
|
+
"optional": true
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|