@davesheffer/hunch 1.4.2 → 1.5.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 +115 -23
- package/dist/cli/index.js +219 -29
- package/dist/core/autoreview.js +52 -0
- package/dist/core/drift.js +25 -3
- package/dist/core/refrepair.js +33 -0
- package/dist/extractors/git.js +31 -1
- package/dist/integrations/hooks.js +4 -0
- package/dist/mcp/server.js +23 -22
- package/dist/store/hunchStore.js +26 -5
- package/dist/synthesis/provider.js +58 -0
- package/dist/synthesis/synthesize.js +32 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,44 +1,136 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Hunch
|
|
2
|
+
|
|
3
|
+
## Your AI can write code. Hunch makes it remember the consequences.
|
|
2
4
|
|
|
3
5
|
[](https://www.npmjs.com/package/@davesheffer/hunch)
|
|
4
6
|
[](https://github.com/davesheffer/hunch)
|
|
5
7
|
[](LICENSE)
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
**Hunch is engineering memory and a deterministic Change Gate for AI-assisted codebases.**
|
|
10
|
+
It captures the decisions, rejected approaches, and bug history behind your code—then gives every
|
|
11
|
+
assistant the same evidence before it changes anything.
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
Not another chat history. Not a wiki your team must remember to update. A git-native reasoning
|
|
14
|
+
graph that turns “we already learned this the hard way” into something your tools can actually use.
|
|
12
15
|
|
|
13
16
|
```bash
|
|
14
17
|
npm i -g @davesheffer/hunch
|
|
15
|
-
cd your-repo
|
|
16
|
-
hunch
|
|
18
|
+
cd your-repo
|
|
19
|
+
hunch init
|
|
17
20
|
```
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
Then ask any connected assistant: **“Why is this built this way?”**
|
|
23
|
+
|
|
24
|
+
## The problem Hunch solves
|
|
25
|
+
|
|
26
|
+
AI can read your current code. It does not know the decision you made six months ago, the incident
|
|
27
|
+
that made it necessary, or the approach you deliberately rejected yesterday.
|
|
28
|
+
|
|
29
|
+
That gap is where architectural drift starts:
|
|
30
|
+
|
|
31
|
+
| Without Hunch | With Hunch |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| A refactor passes tests but bypasses a hard-won service boundary. | The change is checked against the decision, its constraint, and the incident behind it. |
|
|
34
|
+
| A new coding session starts from scratch. | Claude Code, Cursor, Copilot, Windsurf, and Codex retrieve the same project memory over MCP. |
|
|
35
|
+
| A correction disappears into a chat transcript. | “Never do that again” becomes a scoped, auditable guard. |
|
|
36
|
+
| Code review sees a diff, not the reason behind it. | Change Gate produces a PASS / WARN / BLOCK receipt with causal evidence. |
|
|
37
|
+
|
|
38
|
+
## What you get in five minutes
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
hunch init # index code + wire supported assistants
|
|
42
|
+
hunch backfill --since 90d # optional: seed memory from recent history
|
|
43
|
+
hunch check --working --strict # review the whole working tree before a commit
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Hunch creates a local graph of:
|
|
47
|
+
|
|
48
|
+
- **Decisions** — what was chosen, why, and what alternatives were rejected.
|
|
49
|
+
- **Constraints** — the invariants a change must not violate.
|
|
50
|
+
- **Bug lineage** — the root cause behind fixes, recurrences, and regression guards.
|
|
51
|
+
- **Architecture** — symbols, components, dependencies, blast radius, and fragility.
|
|
52
|
+
|
|
53
|
+
It then puts that context where work happens: MCP tools, the CLI, a VS Code Change Gate, git hooks,
|
|
54
|
+
and an optional pull-request guard.
|
|
55
|
+
|
|
56
|
+
## One graph. Every assistant. No lock-in.
|
|
57
|
+
|
|
58
|
+
Hunch is agent-agnostic by design. It scaffolds MCP and grounding for Claude Code, Cursor, VS Code
|
|
59
|
+
/ Copilot, Windsurf, Codex, and any agent that can read `AGENTS.md`.
|
|
60
|
+
|
|
61
|
+
Your memory is plain JSON that you own. Hunch adds a SQLite index only as a rebuildable derived
|
|
62
|
+
layer—your decisions never disappear into a proprietary hosted memory system.
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
Claude Code ─┐
|
|
66
|
+
Cursor ├── MCP ──> .hunch/ reasoning graph ──> deterministic checks
|
|
67
|
+
Copilot ┤
|
|
68
|
+
Codex ┤
|
|
69
|
+
Windsurf ─┘
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## The Change Gate: review intent, not just code
|
|
73
|
+
|
|
74
|
+
Before you commit, ask Hunch to review staged files, your working tree, or a branch against its
|
|
75
|
+
base. It returns a receipt your human reviewer or any coding agent can understand:
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
BLOCK src/payments/charge.ts
|
|
79
|
+
|
|
80
|
+
[blocking] Controllers must not reach the database directly
|
|
81
|
+
why: dec_service_boundary → bug_n_plus_one_2025
|
|
82
|
+
evidence: charge() now imports dbQuery
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The gate is deterministic: no prompt-quality lottery and no model call in the enforcement path.
|
|
86
|
+
Start advisory. Turn on strictness only when the rules have earned it.
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
hunch firmness strict
|
|
90
|
+
hunch check --staged --strict
|
|
91
|
+
hunch conform --strict
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Private when the reasoning is sensitive
|
|
95
|
+
|
|
96
|
+
Open-source the code without open-sourcing the reasoning.
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
hunch private ~/work/hunch-private/.hunch
|
|
100
|
+
hunch record-bug --private --test "billing regression" --message "…"
|
|
101
|
+
hunch review --private
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Private decisions, bugs, constraints, and wiki pages live in a separate overlay you control.
|
|
105
|
+
Local checks enforce them; public CI reports use `--public-only`, so private memory never appears in
|
|
106
|
+
a pull-request comment or committed grounding file. Private captures default to deterministic local
|
|
107
|
+
synthesis, keeping sensitive diffs and failure messages out of subscription-model drafting.
|
|
108
|
+
|
|
109
|
+
## A workflow your team can trust
|
|
20
110
|
|
|
21
|
-
|
|
111
|
+
Hunch is deliberately conservative:
|
|
22
112
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
113
|
+
- **Human-confirmed rules get the teeth.** Drafted memory advises; confirmed, precise invariants can block.
|
|
114
|
+
- **Every result carries receipts.** Decisions, constraints, bugs, confidence, and evidence are connected.
|
|
115
|
+
- **Drift is visible.** `hunch doctor` catches stale references, stale generated docs, and broken overlay pointers.
|
|
116
|
+
- **Public surfaces are public-only.** Private overlay data stays local unless you explicitly choose to share it.
|
|
117
|
+
- **No magic rewrite bot.** Hunch proposes and checks; you decide what becomes truth.
|
|
27
118
|
|
|
28
|
-
|
|
29
|
-
in context cut architectural drift **58% → 16%**. The deterministic check catches the rest —
|
|
30
|
-
no model in the loop.
|
|
119
|
+
## Try the moment it earns its keep
|
|
31
120
|
|
|
32
|
-
|
|
121
|
+
Imagine an assistant “simplifies” a controller by querying the database directly. Linters are green.
|
|
122
|
+
Unit tests pass. The architecture is still wrong.
|
|
33
123
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
- **Verification pipeline** (v1.4) — the agent can't end a turn claiming success on unverified edits
|
|
38
|
-
- **One graph, every assistant** — plain git-tracked JSON in `.hunch/`, served over MCP; no SaaS, $0, works offline
|
|
124
|
+
Hunch can answer with the actual context: this boundary exists because of the N+1 incident, the
|
|
125
|
+
service layer was the chosen repair, and the direct import violates a confirmed constraint. That is
|
|
126
|
+
the missing layer between fast code generation and durable engineering judgment.
|
|
39
127
|
|
|
40
128
|
## Learn more
|
|
41
129
|
|
|
42
|
-
|
|
130
|
+
- [Full documentation](https://hunch-pi.vercel.app/docs)
|
|
131
|
+
- [Interactive product site](https://hunch-pi.vercel.app)
|
|
132
|
+
- [VS Code extension](vscode-extension/README.md)
|
|
133
|
+
- [Architecture benchmark](bench/architectural-conformance.md)
|
|
134
|
+
- [15-second demo](demo/architectural-conformance.sh)
|
|
43
135
|
|
|
44
136
|
Apache-2.0
|
package/dist/cli/index.js
CHANGED
|
@@ -29,7 +29,7 @@ import { indexRepo } from "../extractors/indexer.js";
|
|
|
29
29
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
30
30
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
31
31
|
import { selectProvider } from "../synthesis/provider.js";
|
|
32
|
-
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, commitAndPushHunch, pullHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree, mainWorktreeRoot } from "../extractors/git.js";
|
|
32
|
+
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, workingFiles, commitFiles, asOfDate, stagedDiff, workingDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, commitAndPushHunch, pullHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree, mainWorktreeRoot } from "../extractors/git.js";
|
|
33
33
|
import { writeTeamConfig, ensureTeamOverlay, readTeamConfig } from "../integrations/team.js";
|
|
34
34
|
import { runbookId, decisionId } from "../core/ids.js";
|
|
35
35
|
import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
|
|
@@ -52,6 +52,7 @@ import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpol
|
|
|
52
52
|
import { injectionMode } from "../core/hookcache.js";
|
|
53
53
|
import { PIPELINE_LOOP, UNVERIFIED_NAG, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, savePipelineState, stopVerdict, } from "../core/pipeline.js";
|
|
54
54
|
import { draftDuplicateOf } from "../core/dupdetect.js";
|
|
55
|
+
import { planAutoReview, planMutations } from "../core/autoreview.js";
|
|
55
56
|
import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
|
|
56
57
|
import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
|
|
57
58
|
import { computeDrift } from "../core/drift.js";
|
|
@@ -68,6 +69,7 @@ import { mergeHunchJson } from "../store/merge.js";
|
|
|
68
69
|
import { movePublicMemoryToPrivate } from "../store/privateMigrate.js";
|
|
69
70
|
import { ENTITY_KINDS } from "../core/types.js";
|
|
70
71
|
import { planCompaction } from "../store/compact.js";
|
|
72
|
+
import { repairDecisionReference } from "../core/refrepair.js";
|
|
71
73
|
import { resolveInvocation } from "./invocation.js";
|
|
72
74
|
const program = new Command();
|
|
73
75
|
program.name("hunch").description("Hunch — an Engineering Memory OS: a git-native reasoning graph for your codebase.").version(HUNCH_VERSION);
|
|
@@ -138,7 +140,7 @@ program
|
|
|
138
140
|
}
|
|
139
141
|
if (isGitRepo(root)) {
|
|
140
142
|
const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
|
|
141
|
-
const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit });
|
|
143
|
+
const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit, localOnly: syncToOverlay });
|
|
142
144
|
console.log(` ✓ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`);
|
|
143
145
|
const m = installMergeDriver(root, inv.shell);
|
|
144
146
|
console.log(` ✓ team merge driver ${m.action}`);
|
|
@@ -320,7 +322,16 @@ program
|
|
|
320
322
|
return opts.quiet ? undefined : fail("--private/--overlay needs HUNCH_PRIVATE_DIR set to an overlay store");
|
|
321
323
|
}
|
|
322
324
|
store.json.ensureDirs();
|
|
323
|
-
const r = await syncCommit(store, root, sha ?? headSha(root), {
|
|
325
|
+
const r = await syncCommit(store, root, sha ?? headSha(root), {
|
|
326
|
+
force: opts.force,
|
|
327
|
+
private: toOverlay,
|
|
328
|
+
// A split-private overlay is sensitive/local by definition. A shared store
|
|
329
|
+
// is an explicit team policy and may keep its configured synthesis provider.
|
|
330
|
+
localOnly: toOverlay && store.mode === "private",
|
|
331
|
+
deep: opts.deep,
|
|
332
|
+
verify: opts.verify,
|
|
333
|
+
samples: parseSamples(opts.samples),
|
|
334
|
+
});
|
|
324
335
|
if (r.status === "written") {
|
|
325
336
|
store.reindex();
|
|
326
337
|
// Don't rewrite grounding from the hook — it would dirty the working tree on
|
|
@@ -455,7 +466,7 @@ function configureOverlay(dir, opts, mode) {
|
|
|
455
466
|
// 4) route post-commit synthesis to the overlay (local hook, never committed)
|
|
456
467
|
let hookNote = "";
|
|
457
468
|
if (opts.hook && isGitRepo(root)) {
|
|
458
|
-
const h = installPostCommitHook(root, inv.shell, { private: true, commit: opts.autoCommit });
|
|
469
|
+
const h = installPostCommitHook(root, inv.shell, { private: true, commit: opts.autoCommit, localOnly: mode === "private" });
|
|
459
470
|
hookNote = ` ✓ post-commit hook ${h.action} — captured decisions route here${opts.autoCommit ? " (auto-commit+push on)" : ""}\n`;
|
|
460
471
|
}
|
|
461
472
|
// 5) one-time migration: MOVE existing public memory INTO the overlay, then make
|
|
@@ -1047,16 +1058,24 @@ program
|
|
|
1047
1058
|
.description("Capture a Bug from a failing test (symptom + suspect ranking).")
|
|
1048
1059
|
.requiredOption("--test <id>", "failing test id/name")
|
|
1049
1060
|
.requiredOption("--message <msg>", "failure message / stack")
|
|
1061
|
+
.option("--private", "keep the bug and its failure text in the private overlay; uses deterministic local synthesis")
|
|
1050
1062
|
.action(async (opts) => {
|
|
1051
1063
|
const { store, root } = storeFor();
|
|
1064
|
+
if (opts.private && !store.hasPrivate) {
|
|
1065
|
+
store.close();
|
|
1066
|
+
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
1067
|
+
}
|
|
1052
1068
|
store.json.ensureDirs();
|
|
1053
|
-
const r = await recordFailure(store, root, { test: opts.test, message: opts.message });
|
|
1069
|
+
const r = await recordFailure(store, root, { test: opts.test, message: opts.message }, { private: opts.private });
|
|
1054
1070
|
store.reindex();
|
|
1055
|
-
|
|
1071
|
+
const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${r.bug.id}`);
|
|
1072
|
+
console.log(`✓ recorded bug ${r.bug.id} via ${r.provider}: "${r.bug.title}"${opts.private ? " [private overlay; local-only synthesis]" : ""}`);
|
|
1056
1073
|
if (r.bug.lineage.recurrence_of)
|
|
1057
1074
|
console.log(` ↳ recurrence of ${r.bug.lineage.recurrence_of}`);
|
|
1058
1075
|
if (r.constraint)
|
|
1059
1076
|
console.log(` ↳ promoted constraint ${r.constraint.id} [${r.constraint.severity}]: ${r.constraint.statement}`);
|
|
1077
|
+
if (flush === "pushed")
|
|
1078
|
+
console.log(" ↳ private memory committed + pushed");
|
|
1060
1079
|
store.close();
|
|
1061
1080
|
});
|
|
1062
1081
|
// ---- record-constraint (human-authored invariant) -------------------------
|
|
@@ -1073,11 +1092,16 @@ program
|
|
|
1073
1092
|
.option("--forbid-dep <names>", "comma-sep imports that BREAK the rule (parsed-import precise; e.g. lodash) — blocks the real violation, immune to staleness")
|
|
1074
1093
|
.option("--forbid-symbol <names>", "comma-sep identifier names that break the rule")
|
|
1075
1094
|
.option("--match <regex>", "textual line regex (lint-grade last resort; prefer --forbid-dep/--forbid-symbol)")
|
|
1095
|
+
.option("--private", "write the invariant into the private overlay (local enforcement only; never included in public CI output)")
|
|
1076
1096
|
.action((statement, opts) => {
|
|
1077
1097
|
const SEV = ["advisory", "warning", "blocking"];
|
|
1078
1098
|
if (!SEV.includes(opts.severity))
|
|
1079
1099
|
return fail(`--severity must be one of: ${SEV.join(", ")}`);
|
|
1080
1100
|
const { store, root } = storeFor();
|
|
1101
|
+
if (opts.private && !store.hasPrivate) {
|
|
1102
|
+
store.close();
|
|
1103
|
+
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
1104
|
+
}
|
|
1081
1105
|
store.json.ensureDirs();
|
|
1082
1106
|
const scope = opts.scope.split(",").map((s) => toPosixTarget(s.trim())).filter(Boolean);
|
|
1083
1107
|
const csv = (s) => (s ? s.split(",").map((x) => x.trim()).filter(Boolean) : []);
|
|
@@ -1107,10 +1131,14 @@ program
|
|
|
1107
1131
|
valid_from: new Date().toISOString(),
|
|
1108
1132
|
valid_to: null,
|
|
1109
1133
|
provenance: { source: "human_confirmed", confidence: 1, evidence: [], last_verified: new Date().toISOString() },
|
|
1110
|
-
});
|
|
1134
|
+
}, opts.private);
|
|
1111
1135
|
store.reindex();
|
|
1112
|
-
|
|
1113
|
-
|
|
1136
|
+
// Public grounding is a publishable artifact. Private rules stay local and
|
|
1137
|
+
// are surfaced by local checks/MCP, never copied into committed agent docs.
|
|
1138
|
+
if (store.captureHome(!!opts.private) === "public")
|
|
1139
|
+
refreshExistingGrounding(root, store);
|
|
1140
|
+
const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${c.id}`);
|
|
1141
|
+
console.log(`✓ recorded ${c.severity} constraint ${c.id}: "${c.statement}" (scope: ${scope.join(", ") || "repo"})${opts.private ? " [private overlay]" : ""}`);
|
|
1114
1142
|
if (derived && c.forbids?.deps.length)
|
|
1115
1143
|
console.log(` ↳ matcher: forbids import of ${c.forbids.deps.join(", ")} (precise, immune to staleness)`);
|
|
1116
1144
|
if (c.severity === "blocking" && !effectiveForbids(c)) {
|
|
@@ -1119,6 +1147,8 @@ program
|
|
|
1119
1147
|
console.log(` ⚠ scope-only — this will downgrade to advisory once a file in scope is changed after today.`);
|
|
1120
1148
|
console.log(` To block the actual violation across the file's life, add --forbid-dep <pkg> (or --forbid-symbol / --match).`);
|
|
1121
1149
|
}
|
|
1150
|
+
if (flush === "pushed")
|
|
1151
|
+
console.log(" ↳ private memory committed + pushed");
|
|
1122
1152
|
store.close();
|
|
1123
1153
|
});
|
|
1124
1154
|
// ---- test (failure-learning loop) -----------------------------------------
|
|
@@ -1127,8 +1157,13 @@ program
|
|
|
1127
1157
|
.description("Run the test suite; capture failures as Bugs (suspects + recurrence → Constraints), mark passing tests' bugs fixed.")
|
|
1128
1158
|
.argument("[cmd...]", "test command to run (default: `npm test`)")
|
|
1129
1159
|
.option("--dry-run", "show what would be captured without writing")
|
|
1160
|
+
.option("--private", "keep captured test failures in the private overlay and use deterministic local synthesis")
|
|
1130
1161
|
.action(async (cmd, opts) => {
|
|
1131
1162
|
const { store, root } = storeFor();
|
|
1163
|
+
if (opts.private && !store.hasPrivate) {
|
|
1164
|
+
store.close();
|
|
1165
|
+
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
1166
|
+
}
|
|
1132
1167
|
store.json.ensureDirs();
|
|
1133
1168
|
// Run as a shell string (not argv) so the npm/test-runner shim resolves on
|
|
1134
1169
|
// Windows and avoids Node's DEP0190 args+shell warning — same lesson as the
|
|
@@ -1155,7 +1190,7 @@ program
|
|
|
1155
1190
|
store.close();
|
|
1156
1191
|
return;
|
|
1157
1192
|
}
|
|
1158
|
-
const cap = await captureTestRun(store, root, { report, status: run.status, cmd: cmdStr, output });
|
|
1193
|
+
const cap = await captureTestRun(store, root, { report, status: run.status, cmd: cmdStr, output, private: opts.private });
|
|
1159
1194
|
for (const { bug, constraint } of cap.results) {
|
|
1160
1195
|
if (constraint)
|
|
1161
1196
|
console.log(` ⚠ ${bug.id} "${bug.title}" → promoted constraint ${constraint.id} [${constraint.severity}]`);
|
|
@@ -1165,6 +1200,8 @@ program
|
|
|
1165
1200
|
for (const b of cap.fixed)
|
|
1166
1201
|
console.log(` ✓ ${b.id} "${b.title}" → fixed (test passing)`);
|
|
1167
1202
|
store.reindex();
|
|
1203
|
+
if (cap.results.length || cap.fixed.length)
|
|
1204
|
+
flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture test results`);
|
|
1168
1205
|
store.close();
|
|
1169
1206
|
const recurrences = cap.results.filter((r) => r.bug.lineage.recurrence_of).length;
|
|
1170
1207
|
const promoted = cap.results.filter((r) => r.constraint).length;
|
|
@@ -1229,6 +1266,7 @@ program
|
|
|
1229
1266
|
.command("check")
|
|
1230
1267
|
.description("Flag changes that touch a do-not-break invariant — the local guardrail AND the CI/PR Constraint Guard. Also flags (advisory) symbols you add that already exist elsewhere — possible re-implementation/sprawl.")
|
|
1231
1268
|
.option("--staged", "check git staged files (default)")
|
|
1269
|
+
.option("--working", "check all working-tree edits vs HEAD (staged, unstaged, and untracked files)")
|
|
1232
1270
|
.option("--commit <sha>", "check a specific commit's files")
|
|
1233
1271
|
.option("--base <ref>", "check a PR/branch: files changed vs <ref> (e.g. origin/main) — for CI")
|
|
1234
1272
|
.option("--strict", "exit non-zero ONLY on a direct, high-confidence, non-stale blocking invariant (near/stale/low-confidence stay advisory)")
|
|
@@ -1236,9 +1274,9 @@ program
|
|
|
1236
1274
|
.option("--blast", "also print the dependency blast radius of the changed files")
|
|
1237
1275
|
.option("--public-only", "exclude the private overlay (HUNCH_PRIVATE_DIR) from the report — use for any output that may be posted publicly (the CI PR comment passes this)")
|
|
1238
1276
|
.action((opts) => {
|
|
1239
|
-
const sources = [opts.commit && "--commit", opts.base && "--base", opts.staged && "--staged"].filter(Boolean);
|
|
1277
|
+
const sources = [opts.commit && "--commit", opts.base && "--base", opts.staged && "--staged", opts.working && "--working"].filter(Boolean);
|
|
1240
1278
|
if (sources.length > 1)
|
|
1241
|
-
return fail(`pick one of --staged / --commit / --base (got ${sources.join(", ")})`);
|
|
1279
|
+
return fail(`pick one of --staged / --working / --commit / --base (got ${sources.join(", ")})`);
|
|
1242
1280
|
const markdown = opts.format === "markdown";
|
|
1243
1281
|
const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], redundant: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
|
|
1244
1282
|
const { store, root } = storeFor();
|
|
@@ -1251,7 +1289,8 @@ program
|
|
|
1251
1289
|
store.reindex(); // blast radius walks the edge graph — make the index current
|
|
1252
1290
|
const files = opts.commit ? commitFiles(opts.commit, root)
|
|
1253
1291
|
: opts.base ? rangeFiles(opts.base, root)
|
|
1254
|
-
:
|
|
1292
|
+
: opts.working ? workingFiles(root)
|
|
1293
|
+
: stagedFiles(root);
|
|
1255
1294
|
if (!files.length) {
|
|
1256
1295
|
console.log(markdown ? renderMarkdown(emptyReport) : "No changed files to check.");
|
|
1257
1296
|
store.close();
|
|
@@ -1261,7 +1300,7 @@ program
|
|
|
1261
1300
|
// code) + REDUNDANT (adds a symbol already defined elsewhere — advisory) + the
|
|
1262
1301
|
// hardened strict gate + causal `why` citations — all assembled by the shared
|
|
1263
1302
|
// store.buildCheckReport (also used by the hunch_merge_verdict tool).
|
|
1264
|
-
const diff = opts.commit ? commitDiff(opts.commit, root) : opts.base ? rangeDiff(opts.base, root) : stagedDiff(root);
|
|
1303
|
+
const diff = opts.commit ? commitDiff(opts.commit, root) : opts.base ? rangeDiff(opts.base, root) : opts.working ? workingDiff(root) : stagedDiff(root);
|
|
1265
1304
|
const report = store.buildCheckReport(files, diff, {
|
|
1266
1305
|
strict: !!opts.strict,
|
|
1267
1306
|
lastChange: (f) => lastChangeDate(f, root),
|
|
@@ -1774,29 +1813,40 @@ program
|
|
|
1774
1813
|
.option("--accept-verified", "batch-accept every Critic-verified, well-grounded draft (>= --min-grounded)")
|
|
1775
1814
|
.option("--reject-duplicates", "batch-reject drafts that near-duplicate an accepted record (deterministic term+file similarity — hygiene, not judgment)")
|
|
1776
1815
|
.option("--min-grounded <n>", "grounded-ness threshold for the ready group / --accept-verified", String(READY_MIN_GROUNDED))
|
|
1816
|
+
.option("--private", "include local private/shared-overlay drafts; terminal output may contain private memory")
|
|
1777
1817
|
.action((opts) => {
|
|
1778
1818
|
const { store, root } = storeFor();
|
|
1779
1819
|
const minGrounded = Number.isFinite(Number(opts.minGrounded)) ? Number(opts.minGrounded) : READY_MIN_GROUNDED;
|
|
1820
|
+
if (opts.private && !store.hasPrivate) {
|
|
1821
|
+
store.close();
|
|
1822
|
+
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
1823
|
+
}
|
|
1824
|
+
const decisions = () => opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
|
|
1825
|
+
let publicGroundingChanged = false;
|
|
1780
1826
|
if (opts.accept) {
|
|
1781
|
-
const d = store.json.get("decisions", opts.accept);
|
|
1827
|
+
const d = opts.private ? store.getRec("decisions", opts.accept) : store.json.get("decisions", opts.accept);
|
|
1782
1828
|
if (!d) {
|
|
1783
1829
|
store.close();
|
|
1784
1830
|
return fail(`decision ${opts.accept} not found`);
|
|
1785
1831
|
}
|
|
1832
|
+
const inPrivate = !!store.getPrivateRec("decisions", d.id);
|
|
1786
1833
|
const { source, armed } = acceptDecision(store, d);
|
|
1787
1834
|
store.reindex();
|
|
1788
|
-
|
|
1835
|
+
if (!inPrivate) {
|
|
1836
|
+
refreshExistingGrounding(root, store);
|
|
1837
|
+
publicGroundingChanged = true;
|
|
1838
|
+
}
|
|
1789
1839
|
console.log(`✓ accepted ${opts.accept} (now ${source}, confidence 0.95${armed ? `, ${armed} tripwire(s) now blocking` : ""})`);
|
|
1790
1840
|
}
|
|
1791
1841
|
else if (opts.reject) {
|
|
1792
|
-
const ok2 = store.json.delete("decisions", opts.reject);
|
|
1842
|
+
const ok2 = opts.private ? store.deleteWhereItLives("decisions", opts.reject) : store.json.delete("decisions", opts.reject);
|
|
1793
1843
|
store.reindex();
|
|
1794
1844
|
console.log(ok2 ? `✓ rejected and removed ${opts.reject}` : `decision ${opts.reject} not found`);
|
|
1795
1845
|
}
|
|
1796
1846
|
else if (opts.rejectDuplicates) {
|
|
1797
1847
|
// Deterministic hygiene, not a trust decision (dec_a466655539 stays intact):
|
|
1798
1848
|
// only drafts, only against ACCEPTED records, conservative threshold.
|
|
1799
|
-
const all =
|
|
1849
|
+
const all = decisions();
|
|
1800
1850
|
const drafts = all.filter((d) => d.status === "proposed" && !d.provenance.source.includes("human_confirmed"));
|
|
1801
1851
|
const dupes = drafts
|
|
1802
1852
|
.map((d) => ({ d, m: draftDuplicateOf(d, all) }))
|
|
@@ -1807,7 +1857,7 @@ program
|
|
|
1807
1857
|
else {
|
|
1808
1858
|
let removed = 0;
|
|
1809
1859
|
for (const { d, m } of dupes) {
|
|
1810
|
-
if (store.json.delete("decisions", d.id))
|
|
1860
|
+
if ((opts.private ? store.deleteWhereItLives("decisions", d.id) : store.json.delete("decisions", d.id)))
|
|
1811
1861
|
removed++;
|
|
1812
1862
|
console.log(` ✗ ${d.id} — "${d.title}"\n duplicate of ${m.of.id} — "${m.of.title}" (${Math.round(m.score * 100)}%)`);
|
|
1813
1863
|
}
|
|
@@ -1818,24 +1868,28 @@ program
|
|
|
1818
1868
|
else if (opts.acceptVerified) {
|
|
1819
1869
|
// Batch path: only Critic-verified, well-grounded drafts qualify — still the
|
|
1820
1870
|
// human-driven accept gate (the operator runs this), just over a safe subset.
|
|
1821
|
-
const proposed =
|
|
1871
|
+
const proposed = decisions().filter((d) => d.status === "proposed");
|
|
1822
1872
|
const { ready } = partitionReview(proposed, minGrounded);
|
|
1823
1873
|
if (!ready.length) {
|
|
1824
1874
|
console.log(`✓ No Critic-verified drafts at grounded ≥ ${minGrounded} to batch-accept.`);
|
|
1825
1875
|
}
|
|
1826
1876
|
else {
|
|
1827
1877
|
let armedTotal = 0;
|
|
1828
|
-
for (const it of ready)
|
|
1878
|
+
for (const it of ready) {
|
|
1879
|
+
if (!store.getPrivateRec("decisions", it.d.id))
|
|
1880
|
+
publicGroundingChanged = true;
|
|
1829
1881
|
armedTotal += acceptDecision(store, it.d).armed;
|
|
1882
|
+
}
|
|
1830
1883
|
store.reindex();
|
|
1831
|
-
|
|
1884
|
+
if (publicGroundingChanged)
|
|
1885
|
+
refreshExistingGrounding(root, store); // committed grounding stays public-only
|
|
1832
1886
|
console.log(`✓ accepted ${ready.length} verified draft(s); ${armedTotal} tripwire(s) now blocking.`);
|
|
1833
1887
|
for (const it of ready)
|
|
1834
1888
|
console.log(` ${it.d.id} grounded=${it.synth.grounded ?? "?"} ${it.d.title}`);
|
|
1835
1889
|
}
|
|
1836
1890
|
}
|
|
1837
1891
|
else {
|
|
1838
|
-
const drafts =
|
|
1892
|
+
const drafts = decisions().filter((d) => d.status === "proposed" || d.provenance.confidence < 0.6);
|
|
1839
1893
|
const { ready, scrutiny } = partitionReview(drafts, minGrounded);
|
|
1840
1894
|
if (!ready.length && !scrutiny.length) {
|
|
1841
1895
|
console.log("✓ No low-confidence drafts to review.");
|
|
@@ -1849,7 +1903,7 @@ program
|
|
|
1849
1903
|
}
|
|
1850
1904
|
if (scrutiny.length) {
|
|
1851
1905
|
console.log(`⚠ ${scrutiny.length} need scrutiny — unverified / low-grounded (lowest confidence first):\n`);
|
|
1852
|
-
const all =
|
|
1906
|
+
const all = decisions();
|
|
1853
1907
|
let dupCount = 0;
|
|
1854
1908
|
for (const it of scrutiny) {
|
|
1855
1909
|
printReviewItem(it);
|
|
@@ -1867,6 +1921,109 @@ program
|
|
|
1867
1921
|
}
|
|
1868
1922
|
store.close();
|
|
1869
1923
|
});
|
|
1924
|
+
// ---- auto-review (harness-driven triage) ----------------------------------
|
|
1925
|
+
/** One line per plan entry. */
|
|
1926
|
+
function printAutoEntry(e) {
|
|
1927
|
+
console.log(` ${e.d.id} ${e.d.title.slice(0, 66)}\n ${dim(e.reason)}`);
|
|
1928
|
+
}
|
|
1929
|
+
program
|
|
1930
|
+
.command("auto-review")
|
|
1931
|
+
.description("Harness-driven draft triage: delegate relevance to the coding-assistant CLI, then dedup, auto-confirm the verified+relevant, and delete duplicates/irrelevant. Dry-run unless --apply.")
|
|
1932
|
+
.option("--apply", "execute the plan (accept/delete). Without it, print the plan and change nothing.")
|
|
1933
|
+
.option("--min-grounded <n>", "grounded-ness threshold for the auto-accept gate", String(READY_MIN_GROUNDED))
|
|
1934
|
+
.option("--min-reject-confidence <n>", "minimum harness confidence to DELETE an irrelevant draft (else kept for a human)", "0.7")
|
|
1935
|
+
.option("--no-llm", "skip the harness judgment (dedup + grounding only — no relevance deletion)")
|
|
1936
|
+
.option("--private", "include local private/shared-overlay drafts; private drafts are never sent to an LLM judge")
|
|
1937
|
+
.action(async (opts) => {
|
|
1938
|
+
const { store, root } = storeFor();
|
|
1939
|
+
try {
|
|
1940
|
+
if (opts.private && !store.hasPrivate)
|
|
1941
|
+
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
1942
|
+
const minGrounded = Number.isFinite(Number(opts.minGrounded)) ? Number(opts.minGrounded) : READY_MIN_GROUNDED;
|
|
1943
|
+
const minRejectConfidence = Number.isFinite(Number(opts.minRejectConfidence)) ? Number(opts.minRejectConfidence) : 0.7;
|
|
1944
|
+
const all = opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
|
|
1945
|
+
// Same draft set `hunch review` / `hunch status` triage.
|
|
1946
|
+
const drafts = all.filter((d) => d.status === "proposed" || d.provenance.confidence < 0.6);
|
|
1947
|
+
if (!drafts.length) {
|
|
1948
|
+
console.log("✓ No drafts to auto-review.");
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
// Delegate relevance to the harness (subscription CLI) — feature-detected,
|
|
1952
|
+
// and any per-draft failure degrades to "not judged" (kept for a human).
|
|
1953
|
+
const verdicts = new Map();
|
|
1954
|
+
if (opts.llm !== false && !opts.private) {
|
|
1955
|
+
const provider = await selectProvider();
|
|
1956
|
+
if (provider.judgeDraft) {
|
|
1957
|
+
// The candidate pool for duplicate_of / restatement: the LIVE, vouched records.
|
|
1958
|
+
const existing = all
|
|
1959
|
+
.filter((d) => d.provenance.source.includes("human_confirmed") && d.status !== "superseded" && d.status !== "rejected")
|
|
1960
|
+
.map((d) => ({ id: d.id, title: d.title, decision: d.decision }));
|
|
1961
|
+
console.log(`Judging ${drafts.length} draft(s) via ${provider.name} (subscription)…`);
|
|
1962
|
+
for (const d of drafts) {
|
|
1963
|
+
try {
|
|
1964
|
+
verdicts.set(d.id, await provider.judgeDraft(d, existing.filter((e) => e.id !== d.id)));
|
|
1965
|
+
}
|
|
1966
|
+
catch {
|
|
1967
|
+
/* transient / unparseable — leave unjudged, planner keeps it for a human */
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
else {
|
|
1972
|
+
console.log(dim("No subscription CLI available — relevance judgment skipped (dedup + grounding only)."));
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
else if (opts.private && opts.llm !== false) {
|
|
1976
|
+
console.log(dim("Private review stays local — harness judgment skipped (dedup + grounding only)."));
|
|
1977
|
+
}
|
|
1978
|
+
const plan = planAutoReview(drafts, all, verdicts, { minGrounded, minRejectConfidence });
|
|
1979
|
+
printAutoReviewPlan(plan);
|
|
1980
|
+
if (!opts.apply) {
|
|
1981
|
+
const n = planMutations(plan);
|
|
1982
|
+
console.log(`\n${dim(`Dry run — nothing changed. Re-run with --apply to ${n ? `apply ${n} change(s)` : "confirm (no changes)"}.`)}`);
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
// Apply: accept the verified+relevant, delete duplicates + irrelevant.
|
|
1986
|
+
let accepted = 0, deleted = 0, armedTotal = 0, publicAccepted = false;
|
|
1987
|
+
for (const e of plan.accept) {
|
|
1988
|
+
if (!store.getPrivateRec("decisions", e.d.id))
|
|
1989
|
+
publicAccepted = true;
|
|
1990
|
+
armedTotal += acceptDecision(store, e.d).armed;
|
|
1991
|
+
accepted++;
|
|
1992
|
+
}
|
|
1993
|
+
for (const e of [...plan.rejectDuplicate, ...plan.rejectIrrelevant]) {
|
|
1994
|
+
if ((opts.private ? store.deleteWhereItLives("decisions", e.d.id) : store.json.delete("decisions", e.d.id)))
|
|
1995
|
+
deleted++;
|
|
1996
|
+
}
|
|
1997
|
+
if (accepted || deleted) {
|
|
1998
|
+
store.reindex();
|
|
1999
|
+
if (publicAccepted)
|
|
2000
|
+
refreshExistingGrounding(root, store); // committed grounding stays public-only
|
|
2001
|
+
}
|
|
2002
|
+
console.log(`\n✓ auto-review applied: ${accepted} accepted${armedTotal ? ` (${armedTotal} tripwire(s) now blocking)` : ""}, ${deleted} deleted, ${plan.keep.length} kept for review.`);
|
|
2003
|
+
}
|
|
2004
|
+
finally {
|
|
2005
|
+
store.close();
|
|
2006
|
+
}
|
|
2007
|
+
});
|
|
2008
|
+
/** Print the four buckets of an auto-review plan (skipping empty ones). */
|
|
2009
|
+
function printAutoReviewPlan(plan) {
|
|
2010
|
+
if (plan.accept.length) {
|
|
2011
|
+
console.log(`\n✓ ACCEPT — verified, grounded, harness-relevant (${plan.accept.length}):`);
|
|
2012
|
+
plan.accept.forEach(printAutoEntry);
|
|
2013
|
+
}
|
|
2014
|
+
if (plan.rejectDuplicate.length) {
|
|
2015
|
+
console.log(`\n✗ DELETE (duplicate) — restates an accepted record (${plan.rejectDuplicate.length}):`);
|
|
2016
|
+
plan.rejectDuplicate.forEach(printAutoEntry);
|
|
2017
|
+
}
|
|
2018
|
+
if (plan.rejectIrrelevant.length) {
|
|
2019
|
+
console.log(`\n✗ DELETE (irrelevant) — harness judged not worth keeping (${plan.rejectIrrelevant.length}):`);
|
|
2020
|
+
plan.rejectIrrelevant.forEach(printAutoEntry);
|
|
2021
|
+
}
|
|
2022
|
+
if (plan.keep.length) {
|
|
2023
|
+
console.log(`\n⏳ KEEP for human review (${plan.keep.length}):`);
|
|
2024
|
+
plan.keep.forEach(printAutoEntry);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
1870
2027
|
// ---- mcp ------------------------------------------------------------------
|
|
1871
2028
|
program
|
|
1872
2029
|
.command("mcp")
|
|
@@ -2014,23 +2171,24 @@ program
|
|
|
2014
2171
|
.description("PR impact: the dependency + memory surface of a change — dependent files reached, invariants direct/near, and the decisions concerned. Read-only, advisory (gating is `hunch check`). Omit base and --commit to inspect staged changes.")
|
|
2015
2172
|
.argument("[base]", "diff against this base ref (e.g. origin/main) for a branch/PR")
|
|
2016
2173
|
.option("--commit <sha>", "impact of a single commit")
|
|
2174
|
+
.option("--working", "impact all working-tree edits vs HEAD (staged, unstaged, and untracked files)")
|
|
2017
2175
|
.action((base, opts) => {
|
|
2018
2176
|
const { store, root } = storeFor();
|
|
2019
2177
|
try {
|
|
2020
|
-
if (base && opts.commit)
|
|
2021
|
-
return fail("Pass
|
|
2178
|
+
if ((base && opts.commit) || (opts.working && (base || opts.commit)))
|
|
2179
|
+
return fail("Pass exactly one of [base] / --commit / --working (or omit all for staged changes).");
|
|
2022
2180
|
if (base && !revExists(base, root))
|
|
2023
2181
|
return fail(`base ref "${base}" does not resolve.`);
|
|
2024
2182
|
if (opts.commit && !revExists(opts.commit, root))
|
|
2025
2183
|
return fail(`commit "${opts.commit}" does not resolve.`);
|
|
2026
2184
|
store.reindex(); // reflect out-of-band JSON edits before reading the graph
|
|
2027
|
-
const files = opts.commit ? commitFiles(opts.commit, root) : base ? rangeFiles(base, root) : stagedFiles(root);
|
|
2028
|
-
const scope = opts.commit ? `commit ${opts.commit}` : base ? `${base}..HEAD` : "staged changes";
|
|
2185
|
+
const files = opts.commit ? commitFiles(opts.commit, root) : base ? rangeFiles(base, root) : opts.working ? workingFiles(root) : stagedFiles(root);
|
|
2186
|
+
const scope = opts.commit ? `commit ${opts.commit}` : base ? `${base}..HEAD` : opts.working ? "working changes" : "staged changes";
|
|
2029
2187
|
if (!files.length) {
|
|
2030
2188
|
console.log(`No changed files in ${scope}.`);
|
|
2031
2189
|
return;
|
|
2032
2190
|
}
|
|
2033
|
-
const diff = opts.commit ? commitDiff(opts.commit, root) : base ? rangeDiff(base, root) : stagedDiff(root);
|
|
2191
|
+
const diff = opts.commit ? commitDiff(opts.commit, root) : base ? rangeDiff(base, root) : opts.working ? workingDiff(root) : stagedDiff(root);
|
|
2034
2192
|
console.log(renderImpact(store.prImpact(files, diff), scope));
|
|
2035
2193
|
}
|
|
2036
2194
|
finally {
|
|
@@ -2238,6 +2396,38 @@ program
|
|
|
2238
2396
|
store.close();
|
|
2239
2397
|
}
|
|
2240
2398
|
});
|
|
2399
|
+
// ---- repair-ref (atomic decision reference correction) --------------------
|
|
2400
|
+
program
|
|
2401
|
+
.command("repair-ref")
|
|
2402
|
+
.description("Atomically repair one exact file reference in a decision's scope and provenance evidence (never changes the decision itself).")
|
|
2403
|
+
.argument("<decision>", "decision id containing the stale reference")
|
|
2404
|
+
.requiredOption("--from <path>", "exact stale path to replace")
|
|
2405
|
+
.requiredOption("--to <path>", "exact current path (use private:<path> for a private-overlay file)")
|
|
2406
|
+
.option("--private", "require the decision to be in the configured private overlay")
|
|
2407
|
+
.action((id, opts) => {
|
|
2408
|
+
const { store } = storeFor();
|
|
2409
|
+
try {
|
|
2410
|
+
if (opts.from === opts.to)
|
|
2411
|
+
return fail("--from and --to must be different paths");
|
|
2412
|
+
if (opts.private && !store.hasPrivate)
|
|
2413
|
+
return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
2414
|
+
// `getRec` is deliberately overlay-first. An explicit --private prevents a
|
|
2415
|
+
// same-id public record from being silently amended instead of private memory.
|
|
2416
|
+
const d = opts.private ? store.getPrivateRec("decisions", id) : store.getRec("decisions", id);
|
|
2417
|
+
if (!d)
|
|
2418
|
+
return fail(`decision "${id}" not found${opts.private ? " in the private overlay" : ""}`);
|
|
2419
|
+
const repaired = repairDecisionReference(d, opts.from, opts.to);
|
|
2420
|
+
if (!repaired)
|
|
2421
|
+
return fail(`decision "${id}" does not contain the exact reference "${opts.from}"`);
|
|
2422
|
+
store.putWhereItLives("decisions", repaired.decision);
|
|
2423
|
+
store.reindex();
|
|
2424
|
+
console.log(`✓ repaired ${id}: ${repaired.relatedFiles} related file reference(s) + ${repaired.evidence} provenance evidence reference(s).`);
|
|
2425
|
+
console.log(` ${opts.from} → ${opts.to}`);
|
|
2426
|
+
}
|
|
2427
|
+
finally {
|
|
2428
|
+
store.close();
|
|
2429
|
+
}
|
|
2430
|
+
});
|
|
2241
2431
|
// ---- compact (bound Hunch growth) -----------------------------------------
|
|
2242
2432
|
program
|
|
2243
2433
|
.command("compact")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { draftDuplicateOf } from "./dupdetect.js";
|
|
2
|
+
import { parseSynth, isReady, READY_MIN_GROUNDED } from "./reviewqueue.js";
|
|
3
|
+
const DEFAULT_MIN_REJECT_CONFIDENCE = 0.7;
|
|
4
|
+
/** Build the plan. `verdicts` maps draft id → harness verdict (absent → the draft
|
|
5
|
+
* was not judged, e.g. no CLI available; it can still be dup-rejected or kept). */
|
|
6
|
+
export function planAutoReview(drafts, allDecisions, verdicts, cfg = {}) {
|
|
7
|
+
const minGrounded = cfg.minGrounded ?? READY_MIN_GROUNDED;
|
|
8
|
+
const minReject = cfg.minRejectConfidence ?? DEFAULT_MIN_REJECT_CONFIDENCE;
|
|
9
|
+
const plan = { accept: [], rejectDuplicate: [], rejectIrrelevant: [], keep: [] };
|
|
10
|
+
for (const d of drafts) {
|
|
11
|
+
const verdict = verdicts.get(d.id);
|
|
12
|
+
const synth = parseSynth(d.provenance?.evidence);
|
|
13
|
+
const grounded = synth.grounded;
|
|
14
|
+
const base = { d, verdict, grounded };
|
|
15
|
+
// 1) Duplicate — deterministic match against accepted records, or the harness
|
|
16
|
+
// naming an existing decision. Deterministic wins first (cheapest, surest).
|
|
17
|
+
const detDup = draftDuplicateOf(d, allDecisions);
|
|
18
|
+
if (detDup) {
|
|
19
|
+
plan.rejectDuplicate.push({ ...base, action: "rejectDuplicate", reason: `near-duplicate of ${detDup.of.id} "${detDup.of.title}" (${Math.round(detDup.score * 100)}%)` });
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (verdict?.duplicate_of && verdict.duplicate_of !== d.id && allDecisions.some((x) => x.id === verdict.duplicate_of)) {
|
|
23
|
+
plan.rejectDuplicate.push({ ...base, action: "rejectDuplicate", reason: `harness: restates ${verdict.duplicate_of} — ${verdict.reason}` });
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
// 2) Confidently-irrelevant — delete only on a strong harness "no".
|
|
27
|
+
if (verdict && !verdict.relevant && verdict.confidence >= minReject) {
|
|
28
|
+
plan.rejectIrrelevant.push({ ...base, action: "rejectIrrelevant", reason: `harness: not relevant (conf ${verdict.confidence}) — ${verdict.reason}` });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
// 3) Accept — ONLY when the Critic verified + grounded it (isReady) AND the
|
|
32
|
+
// harness judged it relevant. The harness can VETO an accept, never create
|
|
33
|
+
// one on its own (dec_a466655539: the human vouch / Critic gate is the floor).
|
|
34
|
+
const ready = isReady(d, synth, minGrounded);
|
|
35
|
+
if (ready && verdict?.relevant) {
|
|
36
|
+
plan.accept.push({ ...base, action: "accept", reason: `verified + grounded ${grounded ?? "?"} ≥ ${minGrounded}, harness-relevant — ${verdict.reason}` });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// 4) Keep for a human — the safe default (unverified, ungrounded, unjudged, or
|
|
40
|
+
// a low-confidence irrelevant call).
|
|
41
|
+
const why = !verdict ? "not judged (no harness)"
|
|
42
|
+
: !ready ? (verdict.relevant ? "relevant but not Critic-verified/grounded — needs human confirm" : `irrelevant but low confidence (${verdict.confidence})`)
|
|
43
|
+
: "kept";
|
|
44
|
+
plan.keep.push({ ...base, action: "keep", reason: why });
|
|
45
|
+
}
|
|
46
|
+
return plan;
|
|
47
|
+
}
|
|
48
|
+
/** Total drafts the plan would mutate (accept + both delete buckets). */
|
|
49
|
+
export function planMutations(plan) {
|
|
50
|
+
return plan.accept.length + plan.rejectDuplicate.length + plan.rejectIrrelevant.length;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=autoreview.js.map
|
package/dist/core/drift.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* `hunch wiki --heal`, never a gate.
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
-
import { join } from "node:path";
|
|
16
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
17
17
|
import { toPosixTarget } from "./paths.js";
|
|
18
18
|
import { currentForTopic, isLive } from "./topics.js";
|
|
19
19
|
import { parseDocAnchors } from "./docanchors.js";
|
|
@@ -36,7 +36,7 @@ export function computeDrift(store, root) {
|
|
|
36
36
|
for (const f of d.related_files ?? []) {
|
|
37
37
|
if (!f || f.includes("*"))
|
|
38
38
|
continue; // skip globs / empties
|
|
39
|
-
if (!
|
|
39
|
+
if (!referenceExists(store, root, d.id, f)) {
|
|
40
40
|
findings.push({ kind: "dead-ref", id: d.id, detail: `references missing file "${f}"` });
|
|
41
41
|
}
|
|
42
42
|
}
|
|
@@ -66,7 +66,7 @@ export function computeDrift(store, root) {
|
|
|
66
66
|
for (const f of d.related_files ?? []) {
|
|
67
67
|
if (!f || f.includes("*") || liveFiles.has(toPosixTarget(f)))
|
|
68
68
|
continue;
|
|
69
|
-
if (!
|
|
69
|
+
if (!referenceExists(store, root, d.id, f))
|
|
70
70
|
continue; // missing file is history → dead-ref's job
|
|
71
71
|
findings.push({
|
|
72
72
|
kind: "anchor-stale",
|
|
@@ -120,6 +120,28 @@ export function computeDrift(store, root) {
|
|
|
120
120
|
findings.push(...computeWikiDrift(store, root));
|
|
121
121
|
return { findings };
|
|
122
122
|
}
|
|
123
|
+
/** Resolve a decision file reference without making private-memory paths depend on
|
|
124
|
+
* the current machine's overlay location. Normal references are code-repo-relative.
|
|
125
|
+
* A `private:<path>` reference is valid only when the decision itself is in the
|
|
126
|
+
* private overlay and resolves from that overlay repo's root. This lets a private
|
|
127
|
+
* decision cite private docs while preventing a public record from silently
|
|
128
|
+
* depending on unsharable local files. */
|
|
129
|
+
function referenceExists(store, root, decisionId, ref) {
|
|
130
|
+
const prefix = "private:";
|
|
131
|
+
if (!ref.startsWith(prefix))
|
|
132
|
+
return existsSync(join(root, ref));
|
|
133
|
+
const privatePath = ref.slice(prefix.length);
|
|
134
|
+
if (!privatePath || isAbsolute(privatePath) || !store.privateDir || !store.getPrivateRec("decisions", decisionId))
|
|
135
|
+
return false;
|
|
136
|
+
const privateRoot = dirname(store.privateDir);
|
|
137
|
+
const candidate = resolve(privateRoot, privatePath);
|
|
138
|
+
// A private-scoped reference is an overlay-repo-relative path, not an escape
|
|
139
|
+
// hatch into arbitrary local files.
|
|
140
|
+
const rel = relative(privateRoot, candidate);
|
|
141
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\\\" : "/"}`) || isAbsolute(rel))
|
|
142
|
+
return false;
|
|
143
|
+
return existsSync(candidate);
|
|
144
|
+
}
|
|
123
145
|
function safeRead(path) {
|
|
124
146
|
try {
|
|
125
147
|
return readFileSync(path, "utf8");
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
function replaceExact(values, from, to) {
|
|
2
|
+
let changed = 0;
|
|
3
|
+
const replaced = values.map((value) => {
|
|
4
|
+
if (value !== from)
|
|
5
|
+
return value;
|
|
6
|
+
changed++;
|
|
7
|
+
return to;
|
|
8
|
+
});
|
|
9
|
+
// A decision may already cite the destination. Keep the reference list a set
|
|
10
|
+
// after the repair so a correction cannot create duplicate scope/evidence.
|
|
11
|
+
return { values: [...new Set(replaced)], changed };
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Return a corrected copy of a decision, or `null` when the source reference is
|
|
15
|
+
* not present. The decision's semantic content and verification timestamp are
|
|
16
|
+
* intentionally preserved: this repairs a locator, it does not re-approve intent.
|
|
17
|
+
*/
|
|
18
|
+
export function repairDecisionReference(decision, from, to) {
|
|
19
|
+
const files = replaceExact(decision.related_files, from, to);
|
|
20
|
+
const evidence = replaceExact(decision.provenance.evidence, from, to);
|
|
21
|
+
if (!files.changed && !evidence.changed)
|
|
22
|
+
return null;
|
|
23
|
+
return {
|
|
24
|
+
decision: {
|
|
25
|
+
...decision,
|
|
26
|
+
related_files: files.values,
|
|
27
|
+
provenance: { ...decision.provenance, evidence: evidence.values },
|
|
28
|
+
},
|
|
29
|
+
relatedFiles: files.changed,
|
|
30
|
+
evidence: evidence.changed,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=refrepair.js.map
|
package/dist/extractors/git.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* No LLM here — just parsing what git already knows. */
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
4
|
import { isAbsolute, resolve, join, basename, dirname } from "node:path";
|
|
5
|
-
import { mkdirSync, rmSync, statSync, realpathSync } from "node:fs";
|
|
5
|
+
import { mkdirSync, rmSync, statSync, realpathSync, readFileSync } from "node:fs";
|
|
6
6
|
function git(args, cwd, maxBuffer = 64 * 1024 * 1024) {
|
|
7
7
|
// stdio: capture stdout, silence stderr (so "no commits yet" etc. don't leak).
|
|
8
8
|
return execFileSync("git", args, {
|
|
@@ -377,6 +377,14 @@ export function stagedFiles(cwd) {
|
|
|
377
377
|
const out = gitSafe(["diff", "--cached", "--name-only", "--diff-filter=ACMR"], cwd);
|
|
378
378
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
379
379
|
}
|
|
380
|
+
/** Files changed anywhere in the working tree compared with HEAD: both staged
|
|
381
|
+
* and unstaged tracked files, plus untracked files. This powers the local,
|
|
382
|
+
* pre-commit Change Gate; it never mutates the index or asks an agent/model. */
|
|
383
|
+
export function workingFiles(cwd) {
|
|
384
|
+
const changed = gitSafe(["diff", "HEAD", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
|
|
385
|
+
const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter(Boolean);
|
|
386
|
+
return [...new Set([...changed, ...untracked])].sort();
|
|
387
|
+
}
|
|
380
388
|
/** Does a ref resolve to a commit in this repo? Lets `--base` fail LOUDLY on an
|
|
381
389
|
* unfetched/typo'd ref instead of silently diffing against nothing (a vacuous
|
|
382
390
|
* CI pass), since the diff helpers below swallow git errors to "". */
|
|
@@ -408,6 +416,28 @@ export function stagedDiff(cwd, maxBytes = 60_000) {
|
|
|
408
416
|
const out = gitSafe(["diff", "--cached", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
|
|
409
417
|
return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
|
|
410
418
|
}
|
|
419
|
+
/** Unified diff of the complete local working tree vs HEAD. Git's normal diff
|
|
420
|
+
* includes both staged and unstaged tracked edits; untracked text files are
|
|
421
|
+
* appended as synthetic additions so guards can also see their added symbols.
|
|
422
|
+
* Binary/unreadable files remain in workingFiles (scope checks still apply) but
|
|
423
|
+
* intentionally contribute no synthetic content to regression analysis. */
|
|
424
|
+
export function workingDiff(cwd, maxBytes = 60_000) {
|
|
425
|
+
let out = gitSafe(["diff", "HEAD", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
|
|
426
|
+
const tracked = new Set(gitSafe(["diff", "HEAD", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean));
|
|
427
|
+
const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter((f) => f && !tracked.has(f));
|
|
428
|
+
for (const file of untracked) {
|
|
429
|
+
try {
|
|
430
|
+
const text = readFileSync(join(cwd, file), "utf8");
|
|
431
|
+
if (text.includes("\0"))
|
|
432
|
+
continue;
|
|
433
|
+
const lines = text.split("\n");
|
|
434
|
+
const add = lines.map((line) => `+${line}`).join("\n");
|
|
435
|
+
out += `${out ? "\n" : ""}diff --git a/${file} b/${file}\nnew file mode 100644\n--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n${add}\n`;
|
|
436
|
+
}
|
|
437
|
+
catch { /* unreadable / directory / binary: scope-only is still safe */ }
|
|
438
|
+
}
|
|
439
|
+
return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
|
|
440
|
+
}
|
|
411
441
|
/** Resolve a time-travel ref (commit / tag / branch / HEAD~n) to the ISO author-
|
|
412
442
|
* date of that commit — the instant valid-time windows are filtered against.
|
|
413
443
|
* Undefined if it can't be resolved (not a git repo, or an unknown ref). Single
|
|
@@ -20,6 +20,10 @@ function block(invocation, opts = {}) {
|
|
|
20
20
|
MARK,
|
|
21
21
|
'if [ -z "$HUNCH_SYNC" ]; then',
|
|
22
22
|
" export HUNCH_SYNC=1",
|
|
23
|
+
// A split-private capture must not make a storage-private promise and then
|
|
24
|
+
// ship the commit diff to a subscription CLI. Shared overlays are a separate
|
|
25
|
+
// team policy, so only the explicit local-only mode forces deterministic.
|
|
26
|
+
...(opts.localOnly ? [" export HUNCH_SYNTH_PROVIDER=deterministic"] : []),
|
|
23
27
|
` ( ${invocation} sync --from-hook --quiet${priv}${commit} >/dev/null 2>&1 || true ) &`,
|
|
24
28
|
"fi",
|
|
25
29
|
ENDMARK,
|
package/dist/mcp/server.js
CHANGED
|
@@ -16,7 +16,7 @@ import { decisionId } from "../core/ids.js";
|
|
|
16
16
|
import { buildCorrectionConstraint } from "../core/correction.js";
|
|
17
17
|
import { knownRepoDeps } from "../synthesis/tripwires.js";
|
|
18
18
|
import { refreshExistingGrounding } from "../integrations/providers.js";
|
|
19
|
-
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, pullHunch } from "../extractors/git.js";
|
|
19
|
+
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, workingFiles, workingDiff, pullHunch } from "../extractors/git.js";
|
|
20
20
|
import { flushCapture } from "../integrations/sync.js";
|
|
21
21
|
import { ensureTeamOverlay } from "../integrations/team.js";
|
|
22
22
|
import { formatContext, formatStructure } from "../core/format.js";
|
|
@@ -446,11 +446,11 @@ export function buildServer(root) {
|
|
|
446
446
|
const resolved = decision.commit ? revParse(decision.commit, root) : null;
|
|
447
447
|
const fullSha = resolved && /^[0-9a-f]{40}$/.test(resolved) ? resolved : null;
|
|
448
448
|
const id = fullSha ? decisionId(fullSha) : decisionId(`manual:${decision.title}`);
|
|
449
|
-
// Preserve the ADR lineage
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
const existing =
|
|
449
|
+
// Preserve the ADR lineage from the SAME home this write will use. A private
|
|
450
|
+
// re-record must retain its own optional fields, but must never inherit a
|
|
451
|
+
// same-id public record (and vice versa).
|
|
452
|
+
const home = store.captureHome(!!decision.private);
|
|
453
|
+
const existing = home === "private" ? store.getPrivateRec("decisions", id) : store.json.get("decisions", id);
|
|
454
454
|
const source = existing && existing.provenance.source.includes("llm_draft")
|
|
455
455
|
? "llm_draft+human_confirmed"
|
|
456
456
|
: "human_confirmed";
|
|
@@ -482,7 +482,6 @@ export function buildServer(root) {
|
|
|
482
482
|
// private:false, so the guard must key its incumbent lookup on HOME, not on
|
|
483
483
|
// the flag — keying on the flag let a shared-mode supersede of a public
|
|
484
484
|
// incumbent pass the guard and then no-op the close (two live decisions).
|
|
485
|
-
const home = store.captureHome(!!decision.private);
|
|
486
485
|
// Decision-grounding uniqueness guard (§4 Enforcement): never create a SECOND
|
|
487
486
|
// live decision for one topic. Exclude ONLY the incumbent this write will
|
|
488
487
|
// actually close — one resolvable in the SAME store the write lands in. A
|
|
@@ -571,7 +570,7 @@ export function buildServer(root) {
|
|
|
571
570
|
// Private corrections go to the overlay (enforced locally via the merged read,
|
|
572
571
|
// never rendered into the public CI comment, which is public-only by construction).
|
|
573
572
|
const home = store.captureHome(!!input.private);
|
|
574
|
-
const existing = home === "private" ?
|
|
573
|
+
const existing = home === "private" ? store.getPrivateRec("constraints", rec.id) : store.json.get("constraints", rec.id);
|
|
575
574
|
if (home === "private")
|
|
576
575
|
store.putPrivate("constraints", rec);
|
|
577
576
|
else
|
|
@@ -600,24 +599,25 @@ export function buildServer(root) {
|
|
|
600
599
|
// -- hunch_merge_verdict (Causal Merge Verdict — read-only, client-agnostic) --
|
|
601
600
|
server.registerTool("hunch_merge_verdict", {
|
|
602
601
|
title: "Causal merge verdict: is this change safe against the recorded WHY?",
|
|
603
|
-
description: "Before opening or merging a PR, replay a diff against engineering memory and return ONE verdict — BLOCK / WARN / PASS. For each invariant DIRECTLY in scope it cites WHY the guard exists (the decision that motivated it + the bug whose root cause spawned it); it also lists invariants reached via blast radius (near, advisory), any deliberately-retired code the diff re-introduces, and symbols the diff adds that are already defined elsewhere in the graph (possible re-implementation/sprawl, advisory). Deterministic, no LLM. Omit base
|
|
602
|
+
description: "Before opening or merging a PR, replay a diff against engineering memory and return ONE verdict — BLOCK / WARN / PASS. For each invariant DIRECTLY in scope it cites WHY the guard exists (the decision that motivated it + the bug whose root cause spawned it); it also lists invariants reached via blast radius (near, advisory), any deliberately-retired code the diff re-introduces, and symbols the diff adds that are already defined elsewhere in the graph (possible re-implementation/sprawl, advisory). Deterministic, no LLM. Omit base, commit, and working to check STAGED changes; pass working:true for all local changes, base (e.g. origin/main) for a PR range, or commit for a single commit. Call this before merging a widely-scoped change.",
|
|
604
603
|
inputSchema: {
|
|
605
604
|
base: z.string().optional().describe("Diff against this base ref (e.g. origin/main) — for a PR/branch."),
|
|
606
605
|
commit: z.string().optional().describe("Diff a single commit (sha/ref). Omit base AND commit to check staged changes."),
|
|
606
|
+
working: z.boolean().optional().describe("Include all working-tree changes vs HEAD (staged, unstaged, and untracked files)."),
|
|
607
607
|
},
|
|
608
|
-
}, async ({ base, commit }) => {
|
|
608
|
+
}, async ({ base, commit, working }) => {
|
|
609
609
|
try {
|
|
610
|
-
if (base
|
|
611
|
-
return err("Pass at most one of base/commit (omit
|
|
610
|
+
if ([base, commit, working].filter(Boolean).length > 1)
|
|
611
|
+
return err("Pass at most one of base/commit/working (omit all to check staged changes).");
|
|
612
612
|
if (base && !revExists(base, root))
|
|
613
613
|
return err(`base ref "${base}" does not resolve (in CI, fetch the base branch first).`);
|
|
614
614
|
if (commit && !revExists(commit, root))
|
|
615
615
|
return err(`commit "${commit}" does not resolve.`);
|
|
616
|
-
const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : stagedFiles(root);
|
|
617
|
-
const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : "staged changes";
|
|
616
|
+
const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : working ? workingFiles(root) : stagedFiles(root);
|
|
617
|
+
const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : working ? "working changes" : "staged changes";
|
|
618
618
|
if (!files.length)
|
|
619
619
|
return ok(`VERDICT: ✅ PASS — no changed files in ${scope}.`);
|
|
620
|
-
const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : stagedDiff(root);
|
|
620
|
+
const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : working ? workingDiff(root) : stagedDiff(root);
|
|
621
621
|
const report = store.buildCheckReport(files, diff, { strict: true, lastChange: (f) => lastChangeDate(f, root) });
|
|
622
622
|
const v = verdict(report);
|
|
623
623
|
const head = v === "block"
|
|
@@ -642,24 +642,25 @@ export function buildServer(root) {
|
|
|
642
642
|
// -- hunch_pr_impact (read-only impact surface — advisory, never gates) ----
|
|
643
643
|
server.registerTool("hunch_pr_impact", {
|
|
644
644
|
title: "PR impact: the dependency + memory surface of a change",
|
|
645
|
-
description: "Given a change (staged, a branch vs base, or a single commit), return its IMPACT SURFACE: the files whose code transitively depends on the changed files, the invariants directly in scope and those reached via blast radius, and the recorded decisions concerning the touched files. Read-only and advisory — use hunch_merge_verdict for the gate. Call before review to know what a PR can break and which recorded intent it touches. Omit base
|
|
645
|
+
description: "Given a change (staged, working tree, a branch vs base, or a single commit), return its IMPACT SURFACE: the files whose code transitively depends on the changed files, the invariants directly in scope and those reached via blast radius, and the recorded decisions concerning the touched files. Read-only and advisory — use hunch_merge_verdict for the gate. Call before review to know what a PR can break and which recorded intent it touches. Omit base, commit, and working for staged changes.",
|
|
646
646
|
inputSchema: {
|
|
647
647
|
base: z.string().optional().describe("Diff against this base ref (e.g. origin/main) — for a PR/branch."),
|
|
648
648
|
commit: z.string().optional().describe("Impact of a single commit (sha/ref). Omit base AND commit for staged changes."),
|
|
649
|
+
working: z.boolean().optional().describe("Include all working-tree changes vs HEAD (staged, unstaged, and untracked files)."),
|
|
649
650
|
},
|
|
650
|
-
}, async ({ base, commit }) => {
|
|
651
|
+
}, async ({ base, commit, working }) => {
|
|
651
652
|
try {
|
|
652
|
-
if (base
|
|
653
|
-
return err("Pass at most one of base/commit (omit
|
|
653
|
+
if ([base, commit, working].filter(Boolean).length > 1)
|
|
654
|
+
return err("Pass at most one of base/commit/working (omit all for staged changes).");
|
|
654
655
|
if (base && !revExists(base, root))
|
|
655
656
|
return err(`base ref "${base}" does not resolve (in CI, fetch the base branch first).`);
|
|
656
657
|
if (commit && !revExists(commit, root))
|
|
657
658
|
return err(`commit "${commit}" does not resolve.`);
|
|
658
|
-
const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : stagedFiles(root);
|
|
659
|
-
const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : "staged changes";
|
|
659
|
+
const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : working ? workingFiles(root) : stagedFiles(root);
|
|
660
|
+
const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : working ? "working changes" : "staged changes";
|
|
660
661
|
if (!files.length)
|
|
661
662
|
return ok(`No changed files in ${scope}.`);
|
|
662
|
-
const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : stagedDiff(root);
|
|
663
|
+
const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : working ? workingDiff(root) : stagedDiff(root);
|
|
663
664
|
return ok(renderImpact(store.prImpact(files, diff), scope));
|
|
664
665
|
}
|
|
665
666
|
catch (e) {
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -98,6 +98,12 @@ export class HunchStore {
|
|
|
98
98
|
getRec(kind, id) {
|
|
99
99
|
return this.privateJson?.get(kind, id) ?? this.json.get(kind, id);
|
|
100
100
|
}
|
|
101
|
+
/** Read a record only from the configured private overlay. Callers that must
|
|
102
|
+
* preserve privacy boundaries (for example, an explicit `--private` repair)
|
|
103
|
+
* should use this instead of overlay-first `getRec`. */
|
|
104
|
+
getPrivateRec(kind, id) {
|
|
105
|
+
return this.privateJson?.get(kind, id);
|
|
106
|
+
}
|
|
101
107
|
/** Update an EXISTING record in the store that holds it — an overlay record must never
|
|
102
108
|
* fork a public copy on update (and vice versa). Falls back to captureHome routing for
|
|
103
109
|
* a record that exists nowhere yet. */
|
|
@@ -109,6 +115,14 @@ export class HunchStore {
|
|
|
109
115
|
return this.json.put(kind, record);
|
|
110
116
|
return this.putCapture(kind, record);
|
|
111
117
|
}
|
|
118
|
+
/** Delete an existing record from its actual home. The review/curation path
|
|
119
|
+
* uses this so rejecting a private draft cannot silently leave it behind or
|
|
120
|
+
* accidentally target a public record with the same id. */
|
|
121
|
+
deleteWhereItLives(kind, id) {
|
|
122
|
+
if (this.privateJson?.get(kind, id))
|
|
123
|
+
return this.privateJson.delete(kind, id);
|
|
124
|
+
return this.json.delete(kind, id);
|
|
125
|
+
}
|
|
112
126
|
/** The private-overlay config from the gitignored `.hunch/local.json` (per-machine,
|
|
113
127
|
* never committed). Tolerant: returns {} on missing/invalid so reads never crash.
|
|
114
128
|
* `autoCommit` is tri-state: true/false when the file says so, undefined when unset.
|
|
@@ -161,6 +175,12 @@ export class HunchStore {
|
|
|
161
175
|
byId.set(r.id, r);
|
|
162
176
|
return [...byId.values()];
|
|
163
177
|
}
|
|
178
|
+
/** Records from exactly one storage home (no public/private union). Capture
|
|
179
|
+
* paths use this for identity/lineage checks so a private record can never
|
|
180
|
+
* inherit or disclose relationships from an identically-shaped public record. */
|
|
181
|
+
recsInHome(kind, home) {
|
|
182
|
+
return home === "private" ? (this.privateJson?.loadAll(kind) ?? []) : this.json.loadAll(kind);
|
|
183
|
+
}
|
|
164
184
|
/** Whether a private overlay store is configured (HUNCH_PRIVATE_DIR is set). */
|
|
165
185
|
get hasPrivate() {
|
|
166
186
|
return !!this.privateJson;
|
|
@@ -883,13 +903,14 @@ export class HunchStore {
|
|
|
883
903
|
* lineage.spawned_constraint, else the source decision's caused_by_bug). Read-only. */
|
|
884
904
|
causalChain(constraintId) {
|
|
885
905
|
const out = { constraint_id: constraintId };
|
|
886
|
-
const
|
|
906
|
+
const get = (kind, id) => this.suppressPrivate ? this.json.get(kind, id) : this.getRec(kind, id);
|
|
907
|
+
const c = get("constraints", constraintId);
|
|
887
908
|
if (!c)
|
|
888
909
|
return out;
|
|
889
|
-
const dec = c.source_decision ?
|
|
910
|
+
const dec = c.source_decision ? get("decisions", c.source_decision) : null;
|
|
890
911
|
if (dec)
|
|
891
912
|
out.decision = { id: dec.id, title: dec.title, decision: dec.decision };
|
|
892
|
-
const bugs = this.recs("bugs");
|
|
913
|
+
const bugs = this.suppressPrivate ? this.json.loadAll("bugs") : this.recs("bugs");
|
|
893
914
|
// Deterministic when several bugs link one constraint (the verdict claims to be
|
|
894
915
|
// deterministic): highest severity first, then lowest id — never filesystem order.
|
|
895
916
|
const SEV = { critical: 3, high: 2, medium: 1, low: 0 };
|
|
@@ -1201,7 +1222,7 @@ export class HunchStore {
|
|
|
1201
1222
|
/** Resolve a veto's causal citation: the bug whose root cause spawned the decision
|
|
1202
1223
|
* (decision → caused_by_bug). Distinct from causalChain, which is constraint-keyed. */
|
|
1203
1224
|
vetoWhy(bugId) {
|
|
1204
|
-
const bug = this.json.get("bugs", bugId);
|
|
1225
|
+
const bug = this.suppressPrivate ? this.json.get("bugs", bugId) : this.getRec("bugs", bugId);
|
|
1205
1226
|
return bug ? { bug: { id: bug.id, title: bug.title, root_cause: bug.root_cause } } : undefined;
|
|
1206
1227
|
}
|
|
1207
1228
|
/** Veto check for a LIVE edit (the agent pre-edit hook): no diff exists yet, so
|
|
@@ -1284,7 +1305,7 @@ export class HunchStore {
|
|
|
1284
1305
|
/** Convenience: load a single entity from JSON by id (any kind). */
|
|
1285
1306
|
resolve(id) {
|
|
1286
1307
|
for (const kind of ENTITY_KINDS) {
|
|
1287
|
-
const rec = this.json.get(kind, id);
|
|
1308
|
+
const rec = this.suppressPrivate ? this.json.get(kind, id) : this.getRec(kind, id);
|
|
1288
1309
|
if (rec)
|
|
1289
1310
|
return { kind, record: rec };
|
|
1290
1311
|
}
|
|
@@ -131,6 +131,20 @@ const BUG_TOOL = {
|
|
|
131
131
|
required: ["title", "symptom", "root_cause", "severity"],
|
|
132
132
|
},
|
|
133
133
|
};
|
|
134
|
+
const RELEVANCE_TOOL = {
|
|
135
|
+
name: "emit_relevance",
|
|
136
|
+
description: "Judge whether an auto-drafted decision is worth keeping in the memory graph.",
|
|
137
|
+
input_schema: {
|
|
138
|
+
type: "object",
|
|
139
|
+
properties: {
|
|
140
|
+
relevant: { type: "boolean", description: "true if this records a REAL, reusable design choice worth keeping. false if it is noise: a mechanical restatement of the diff, a trivial/obvious change, or content unsupported by the evidence." },
|
|
141
|
+
confidence: { type: "number", description: "0..1 confidence in the relevant call. Be honest; low when unsure." },
|
|
142
|
+
duplicate_of: { type: ["string", "null"], description: "id (dec_...) of an existing decision this merely restates, from the EXISTING DECISIONS list. null if none." },
|
|
143
|
+
reason: { type: "string", description: "one short line justifying the call." },
|
|
144
|
+
},
|
|
145
|
+
required: ["relevant", "confidence", "duplicate_of", "reason"],
|
|
146
|
+
},
|
|
147
|
+
};
|
|
134
148
|
const VERIFY_TOOL = {
|
|
135
149
|
name: "emit_verdict",
|
|
136
150
|
description: "Emit a skeptical audit of a synthesized decision against its commit.",
|
|
@@ -217,6 +231,16 @@ class CliSynthProvider {
|
|
|
217
231
|
throw new Error(`${this.name}: no usable verdict JSON in output`);
|
|
218
232
|
return verdict;
|
|
219
233
|
}
|
|
234
|
+
/** Judge whether an auto-drafted decision is worth keeping (for auto-review).
|
|
235
|
+
* Same subscription-only run() path (API keys stripped). Throws on unusable
|
|
236
|
+
* output so the caller can degrade to a keep-for-human verdict. */
|
|
237
|
+
async judgeDraft(draft, existing) {
|
|
238
|
+
const text = await this.run(`${RELEVANCE_SYSTEM}\n\n${relevancePrompt(draft, existing)}\n\n${jsonInstruction(RELEVANCE_TOOL.input_schema)}`);
|
|
239
|
+
const verdict = relevanceFromText(text);
|
|
240
|
+
if (!verdict)
|
|
241
|
+
throw new Error(`${this.name}: no usable relevance JSON in output`);
|
|
242
|
+
return verdict;
|
|
243
|
+
}
|
|
220
244
|
}
|
|
221
245
|
// A model id comes from a HUNCH_*_MODEL env var and ends up as an argv token that,
|
|
222
246
|
// on Windows, pexecIn joins into the cmd.exe line (shell:true, to resolve the npm
|
|
@@ -607,6 +631,40 @@ function verifyPrompt(input, draft) {
|
|
|
607
631
|
`\nReturn grounded (0..1) and the VERBATIM alternatives_rejected / consequences the evidence does NOT support.`,
|
|
608
632
|
].filter(Boolean).join("\n\n");
|
|
609
633
|
}
|
|
634
|
+
const RELEVANCE_SYSTEM = `You are a strict curator for an Engineering Memory OS. You are given ONE auto-drafted
|
|
635
|
+
decision and a list of decisions ALREADY in the graph. Decide if the draft is worth keeping:
|
|
636
|
+
a REAL, reusable design choice (an architectural or policy decision a future engineer would
|
|
637
|
+
want to know). Mark it NOT relevant if it merely restates what the diff mechanically did, is
|
|
638
|
+
trivial/obvious, or is a near-duplicate of an existing decision (name that decision's id in
|
|
639
|
+
duplicate_of). When genuinely unsure, keep it (relevant=true, low confidence) — deletion is
|
|
640
|
+
destructive.`;
|
|
641
|
+
function relevancePrompt(draft, existing) {
|
|
642
|
+
const ex = existing.length
|
|
643
|
+
? existing.map((e) => ` ${e.id}: ${e.title} — ${e.decision.slice(0, 160)}`).join("\n")
|
|
644
|
+
: " (none)";
|
|
645
|
+
return [
|
|
646
|
+
`DRAFT UNDER REVIEW (id ${draft.id}):`,
|
|
647
|
+
` title: ${draft.title}`,
|
|
648
|
+
` decision: ${(draft.decision ?? "").slice(0, 800)}`,
|
|
649
|
+
(draft.alternatives_rejected ?? []).length ? ` alternatives_rejected:\n${(draft.alternatives_rejected ?? []).map((a) => ` - ${a}`).join("\n")}` : "",
|
|
650
|
+
(draft.related_files ?? []).length ? ` related_files: ${(draft.related_files ?? []).join(", ")}` : "",
|
|
651
|
+
`\nEXISTING DECISIONS (candidates for duplicate_of):\n${ex}`,
|
|
652
|
+
`\nReturn relevant, confidence (0..1), duplicate_of (an existing id or null), and a one-line reason.`,
|
|
653
|
+
].filter(Boolean).join("\n\n");
|
|
654
|
+
}
|
|
655
|
+
/** Map model text → RelevanceVerdict, or null when nothing usable parses (→ the
|
|
656
|
+
* caller keeps the draft for a human). Tolerant of missing/loose fields. */
|
|
657
|
+
export function relevanceFromText(text) {
|
|
658
|
+
for (const obj of extractJsonObjects(text)) {
|
|
659
|
+
if (typeof obj.relevant !== "boolean")
|
|
660
|
+
continue; // the one required signal
|
|
661
|
+
const dup = typeof obj.duplicate_of === "string" && obj.duplicate_of.trim() ? obj.duplicate_of.trim() : null;
|
|
662
|
+
const conf = typeof obj.confidence === "number" ? clamp01(obj.confidence) : 0.5;
|
|
663
|
+
const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";
|
|
664
|
+
return { relevant: obj.relevant, confidence: conf, duplicate_of: dup, reason };
|
|
665
|
+
}
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
610
668
|
/** Map model text → VerifyVerdict, or null when nothing usable parses (→ the caller
|
|
611
669
|
* keeps the un-audited draft). Tolerant of arrays-as-strings and missing fields. */
|
|
612
670
|
export function verdictFromText(text) {
|
|
@@ -45,7 +45,11 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
45
45
|
// Seed the id from the COMMIT (stable across runs), not the LLM-generated title
|
|
46
46
|
// (which varies) — so re-syncing a commit updates rather than dupes.
|
|
47
47
|
const id = decisionId(meta.sha);
|
|
48
|
-
|
|
48
|
+
// Check the store this capture WILL write to. Looking only in the public store
|
|
49
|
+
// made private/shared re-syncs re-draft the same commit and let `--force`
|
|
50
|
+
// overwrite a human-confirmed overlay decision.
|
|
51
|
+
const home = store.captureHome(!!opts.private);
|
|
52
|
+
const existing = home === "private" ? store.getPrivateRec("decisions", id) : store.json.get("decisions", id);
|
|
49
53
|
// Never clobber a human-confirmed decision with a low-confidence auto-draft —
|
|
50
54
|
// even under --force. Skip BEFORE synthesizing so we never pay for a draft we'd
|
|
51
55
|
// throw away (the old order drafted first, then discarded it here).
|
|
@@ -85,12 +89,19 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
85
89
|
// back to the normal single-provider path when no CLI is available. Opt-in only.
|
|
86
90
|
// --verify forces the LLM provider (auditing a deterministic draft is pointless) and,
|
|
87
91
|
// like --deep, runs the Critic pass below. Subscription-only throughout (con_2ce3f2a547).
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
// An explicit private capture is storage-private AND local-only by default:
|
|
93
|
+
// never send a sensitive diff to a subscription CLI just to create a draft.
|
|
94
|
+
// Shared mode remains an explicit team policy and keeps its existing provider
|
|
95
|
+
// behavior unless the caller asked for a private capture.
|
|
96
|
+
const localOnly = opts.localOnly ?? !!opts.private;
|
|
97
|
+
const wantVerify = !localOnly && !!(opts.verify || opts.deep);
|
|
98
|
+
const provider = localOnly
|
|
99
|
+
? new DeterministicProvider()
|
|
100
|
+
: opts.deep
|
|
101
|
+
? (await selectEnsemble({ samples: opts.samples })) ?? await selectProvider()
|
|
102
|
+
: opts.force || opts.verify || isSignificant(meta, analysis, codeFiles)
|
|
103
|
+
? await selectProvider()
|
|
104
|
+
: new DeterministicProvider();
|
|
94
105
|
const input = { subject: meta.subject, body: meta.body, files: codeFiles, diff, analysis };
|
|
95
106
|
let draft = await draftDecisionSafe(provider, input);
|
|
96
107
|
// The Critic pass: audit the draft against the commit, PRUNE unsupported alternatives
|
|
@@ -177,7 +188,7 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
177
188
|
return { status: "written", decision, provider: provider.name };
|
|
178
189
|
}
|
|
179
190
|
/** Capture a Bug from a test failure. Suspects are ranked churn×recency×fan-in. */
|
|
180
|
-
export async function recordFailure(store, root, failure) {
|
|
191
|
+
export async function recordFailure(store, root, failure, opts = {}) {
|
|
181
192
|
const symbols = store.json.loadAll("symbols");
|
|
182
193
|
const ranked = rankSuspects(symbols, failure.message);
|
|
183
194
|
// Prefer symbols actually named in the failure — so unrelated failures don't
|
|
@@ -185,7 +196,10 @@ export async function recordFailure(store, root, failure) {
|
|
|
185
196
|
const msg = failure.message.toLowerCase();
|
|
186
197
|
const mentioned = ranked.filter((s) => msg.includes(s.name.toLowerCase()));
|
|
187
198
|
const suspects = (mentioned.length ? mentioned : ranked).slice(0, 6);
|
|
188
|
-
|
|
199
|
+
// A private bug may contain a stack trace, customer data, or secrets. Keep the
|
|
200
|
+
// whole capture local unless the caller deliberately routes it through a shared
|
|
201
|
+
// (non-private) workflow.
|
|
202
|
+
const provider = opts.private ? new DeterministicProvider() : await selectProvider();
|
|
189
203
|
const input = {
|
|
190
204
|
test: failure.test,
|
|
191
205
|
message: failure.message,
|
|
@@ -198,7 +212,8 @@ export async function recordFailure(store, root, failure) {
|
|
|
198
212
|
const id = bugId(failure.test);
|
|
199
213
|
// recurrence = a DIFFERENT prior bug with a similar symptom (not this same one).
|
|
200
214
|
// Query text mirrors the corpus side (title+symptom+root_cause) for symmetry.
|
|
201
|
-
const
|
|
215
|
+
const home = store.captureHome(!!opts.private);
|
|
216
|
+
const prior = findRecurrence(store, `${draft.title} ${draft.symptom} ${draft.root_cause}`, id, home);
|
|
202
217
|
const affectedFiles = [...new Set(suspects.map((s) => s.file))];
|
|
203
218
|
const bug = {
|
|
204
219
|
id,
|
|
@@ -223,12 +238,12 @@ export async function recordFailure(store, root, failure) {
|
|
|
223
238
|
evidence: [`test:${failure.test}`, ...affectedFiles.slice(0, 6)],
|
|
224
239
|
},
|
|
225
240
|
};
|
|
226
|
-
store.putCapture("bugs", bug);
|
|
241
|
+
store.putCapture("bugs", bug, opts.private);
|
|
227
242
|
// Promotion (DESIGN §4): a recurrence or a SUBSTANTIATED high-severity bug raises
|
|
228
243
|
// a regression Constraint to stop it coming back, and bumps fragility.
|
|
229
244
|
let constraint;
|
|
230
245
|
if (shouldPromoteConstraint(draft.severity, bug.root_cause, !!prior)) {
|
|
231
|
-
constraint = promoteConstraint(store, bug);
|
|
246
|
+
constraint = promoteConstraint(store, bug, opts.private);
|
|
232
247
|
bug.lineage.spawned_constraint = constraint.id;
|
|
233
248
|
store.putWhereItLives("bugs", bug); // re-persist with the link, in the same home
|
|
234
249
|
}
|
|
@@ -253,7 +268,7 @@ export async function captureTestRun(store, root, input) {
|
|
|
253
268
|
}
|
|
254
269
|
const results = [];
|
|
255
270
|
for (const f of failures) {
|
|
256
|
-
const r = await recordFailure(store, root, f);
|
|
271
|
+
const r = await recordFailure(store, root, f, { private: input.private });
|
|
257
272
|
results.push({ bug: r.bug, constraint: r.constraint });
|
|
258
273
|
}
|
|
259
274
|
let sha = null;
|
|
@@ -284,7 +299,7 @@ export function shouldPromoteConstraint(severity, rootCause, isRecurrence) {
|
|
|
284
299
|
return severe && rootCause.trim().length > 0;
|
|
285
300
|
}
|
|
286
301
|
/** Turn a bug into an advisory regression constraint scoped to its files. */
|
|
287
|
-
function promoteConstraint(store, bug) {
|
|
302
|
+
function promoteConstraint(store, bug, isPrivate = false) {
|
|
288
303
|
const scope = bug.affected_files.length ? bug.affected_files : ["**"];
|
|
289
304
|
const statement = `Regression guard: "${bug.title}" must not recur.`;
|
|
290
305
|
const con = {
|
|
@@ -304,7 +319,7 @@ function promoteConstraint(store, bug) {
|
|
|
304
319
|
valid_to: null,
|
|
305
320
|
provenance: { source: "derived", confidence: Math.min(0.9, bug.provenance.confidence + 0.2), evidence: [`bug:${bug.id}`] },
|
|
306
321
|
};
|
|
307
|
-
return store.putCapture("constraints", con);
|
|
322
|
+
return store.putCapture("constraints", con, isPrivate);
|
|
308
323
|
}
|
|
309
324
|
/** Bump fragility on components owning the affected files. */
|
|
310
325
|
function raiseFragility(store, files) {
|
|
@@ -366,13 +381,13 @@ export function salientTerms(text) {
|
|
|
366
381
|
/** Recurrence = a DIFFERENT prior bug whose salient terms overlap strongly with
|
|
367
382
|
* this one (in-memory, no FTS/reindex dependency, threshold-gated to avoid the
|
|
368
383
|
* over-broad OR false positives). Returns the best match above threshold. */
|
|
369
|
-
function findRecurrence(store, text, excludeId) {
|
|
384
|
+
function findRecurrence(store, text, excludeId, home) {
|
|
370
385
|
const want = salientTerms(text);
|
|
371
386
|
if (want.size === 0)
|
|
372
387
|
return undefined;
|
|
373
388
|
let best;
|
|
374
389
|
let bestScore = 0;
|
|
375
|
-
for (const b of store.
|
|
390
|
+
for (const b of store.recsInHome("bugs", home)) {
|
|
376
391
|
if (b.id === excludeId)
|
|
377
392
|
continue;
|
|
378
393
|
// symmetric with the query side (which now also includes root_cause)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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).",
|