@davesheffer/hunch 0.38.3 → 0.40.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 +55 -3
- package/dist/cli/index.js +189 -40
- package/dist/core/capturetoken.js +36 -0
- package/dist/core/drift.js +27 -0
- package/dist/core/topics.js +81 -0
- package/dist/core/types.js +7 -0
- package/dist/extractors/git.js +27 -5
- package/dist/integrations/scaffold.js +24 -0
- package/dist/integrations/sync.js +18 -0
- package/dist/integrations/team.js +112 -0
- package/dist/integrations/worktree.js +6 -4
- package/dist/mcp/server.js +128 -26
- package/dist/store/hunchStore.js +72 -6
- package/dist/synthesis/synthesize.js +11 -11
- package/package.json +1 -1
package/dist/store/hunchStore.js
CHANGED
|
@@ -33,8 +33,21 @@ export class HunchStore {
|
|
|
33
33
|
/** The resolved private-overlay hunch dir (from env or .hunch/local.json), or undefined
|
|
34
34
|
* when no overlay is configured. Surfaced so `hunch doctor` reflects the true state. */
|
|
35
35
|
privateDir;
|
|
36
|
-
/** Whether
|
|
37
|
-
* `
|
|
36
|
+
/** Whether captures auto-commit the store they land in — ON by default in EVERY mode;
|
|
37
|
+
* `--no-auto-commit` (hunch init/private/shared) persists `autoCommit: false` in
|
|
38
|
+
* local.json to opt out. Read by the MCP write tools and `hunch sync`. */
|
|
39
|
+
autoCommit;
|
|
40
|
+
/** How memory is homed: "public" (no overlay — the repo-tracked .hunch/ is the one store),
|
|
41
|
+
* "private" (overlay holds ONLY private:true records; public records stay committed here),
|
|
42
|
+
* or "shared" (the overlay IS the store — every capture routes there, one source of truth
|
|
43
|
+
* across branches, worktrees, teammates, and agents). Absent `mode` in an existing
|
|
44
|
+
* config reads as "private" — no behavior change on upgrade. */
|
|
45
|
+
mode;
|
|
46
|
+
/** mode === "shared" with a configured overlay: ALL captures route to the overlay. */
|
|
47
|
+
unified;
|
|
48
|
+
/** autoCommit AND a private overlay is configured: private writes auto commit+push the
|
|
49
|
+
* overlay repo. (Public writes auto-commit the repo-tracked .hunch/ WITHOUT pushing —
|
|
50
|
+
* see commitAndPushHunch push:false / bug_overlay_clobber.) */
|
|
38
51
|
privateAutoCommit;
|
|
39
52
|
/** When true, recs() ignores the private overlay (public-only). Set transiently by
|
|
40
53
|
* buildCheckReport({publicOnly}) so any PUBLICLY-POSTED report (the CI PR comment)
|
|
@@ -54,10 +67,51 @@ export class HunchStore {
|
|
|
54
67
|
this.privateDir = resolve(this.paths.root, priv);
|
|
55
68
|
this.privateJson = new JsonStore(hunchPathsForDir(this.privateDir));
|
|
56
69
|
}
|
|
57
|
-
|
|
70
|
+
// Auto-commit is ON unless explicitly opted out (`autoCommit: false` in local.json).
|
|
71
|
+
// An absent local.json (plain `hunch init`, or an env-configured overlay) defaults ON.
|
|
72
|
+
this.autoCommit = local.autoCommit !== false;
|
|
73
|
+
this.privateAutoCommit = !!(priv && this.autoCommit);
|
|
74
|
+
// Mode: no overlay → "public". With an overlay, an absent `mode` reads as "private"
|
|
75
|
+
// (the pre-mode behavior — split routing), so existing setups are unchanged on upgrade;
|
|
76
|
+
// only an explicit `hunch shared` opts a repo into unified routing.
|
|
77
|
+
this.mode = priv ? (local.mode ?? "private") : "public";
|
|
78
|
+
this.unified = this.mode === "shared" && !!this.privateJson;
|
|
79
|
+
}
|
|
80
|
+
/** Where a capture belongs: an explicit private:true always goes to the overlay
|
|
81
|
+
* (putPrivate throws rather than silently landing public when none is configured);
|
|
82
|
+
* otherwise the overlay in unified ("shared") mode, else the public store. ONE home
|
|
83
|
+
* per record — the single-source-of-truth contract. */
|
|
84
|
+
captureHome(isPrivate = false) {
|
|
85
|
+
if (isPrivate)
|
|
86
|
+
return "private";
|
|
87
|
+
return this.unified ? "private" : "public";
|
|
88
|
+
}
|
|
89
|
+
/** Write a capture to its ONE home (see captureHome). Every capture path — MCP tools,
|
|
90
|
+
* post-commit synthesis, inline intents, record-constraint/conform, runbooks — funnels
|
|
91
|
+
* through here so all modes, branches, worktrees, teams, and agents agree on where
|
|
92
|
+
* memory lives. */
|
|
93
|
+
putCapture(kind, record, isPrivate = false) {
|
|
94
|
+
return this.captureHome(isPrivate) === "private" ? this.putPrivate(kind, record) : this.json.put(kind, record);
|
|
95
|
+
}
|
|
96
|
+
/** Read a record by id from wherever it lives (private overlay wins on collision). */
|
|
97
|
+
getRec(kind, id) {
|
|
98
|
+
return this.privateJson?.get(kind, id) ?? this.json.get(kind, id);
|
|
99
|
+
}
|
|
100
|
+
/** Update an EXISTING record in the store that holds it — an overlay record must never
|
|
101
|
+
* fork a public copy on update (and vice versa). Falls back to captureHome routing for
|
|
102
|
+
* a record that exists nowhere yet. */
|
|
103
|
+
putWhereItLives(kind, record) {
|
|
104
|
+
const id = record.id;
|
|
105
|
+
if (this.privateJson?.get(kind, id))
|
|
106
|
+
return this.putPrivate(kind, record);
|
|
107
|
+
if (this.json.get(kind, id))
|
|
108
|
+
return this.json.put(kind, record);
|
|
109
|
+
return this.putCapture(kind, record);
|
|
58
110
|
}
|
|
59
111
|
/** The private-overlay config from the gitignored `.hunch/local.json` (per-machine,
|
|
60
|
-
* never committed). Tolerant: returns {} on missing/invalid so reads never crash.
|
|
112
|
+
* never committed). Tolerant: returns {} on missing/invalid so reads never crash.
|
|
113
|
+
* `autoCommit` is tri-state: true/false when the file says so, undefined when unset.
|
|
114
|
+
* `mode` records HOW the overlay was set up ("private" split vs "shared" unified). */
|
|
61
115
|
localConfig() {
|
|
62
116
|
const read = (file) => {
|
|
63
117
|
try {
|
|
@@ -65,7 +119,8 @@ export class HunchStore {
|
|
|
65
119
|
return {};
|
|
66
120
|
const v = JSON.parse(readFileSync(file, "utf8"));
|
|
67
121
|
const privateDir = typeof v.privateDir === "string" && v.privateDir.trim() ? v.privateDir.trim() : undefined;
|
|
68
|
-
|
|
122
|
+
const mode = v.mode === "private" || v.mode === "shared" ? v.mode : undefined;
|
|
123
|
+
return { privateDir, autoCommit: typeof v.autoCommit === "boolean" ? v.autoCommit : undefined, mode };
|
|
69
124
|
}
|
|
70
125
|
catch {
|
|
71
126
|
return {};
|
|
@@ -82,8 +137,10 @@ export class HunchStore {
|
|
|
82
137
|
const common = gitCommonDir(this.paths.root);
|
|
83
138
|
if (common) {
|
|
84
139
|
const shared = read(join(common, "hunch", "local.json"));
|
|
140
|
+
// A per-worktree `autoCommit: false` (hunch init --no-auto-commit) is an explicit
|
|
141
|
+
// local opt-out — it must survive the fall-through to the shared overlay pointer.
|
|
85
142
|
if (shared.privateDir)
|
|
86
|
-
return shared;
|
|
143
|
+
return { ...shared, autoCommit: perWorktree.autoCommit ?? shared.autoCommit };
|
|
87
144
|
}
|
|
88
145
|
return perWorktree;
|
|
89
146
|
}
|
|
@@ -767,6 +824,15 @@ export class HunchStore {
|
|
|
767
824
|
supersede(oldId, by) {
|
|
768
825
|
return this.supersedeIn(this.json, oldId, by);
|
|
769
826
|
}
|
|
827
|
+
/** Look up a decision by id in a SPECIFIC store — the public store, or the private
|
|
828
|
+
* overlay when `priv` is true — NOT the union. The capture guard uses this to know
|
|
829
|
+
* whether a supersede will actually close its target: `supersede`/`supersedePrivate`
|
|
830
|
+
* each look in only one store, so a cross-store supersede silently no-ops and would
|
|
831
|
+
* leave two live decisions on one topic. Returns undefined if absent (or no overlay). */
|
|
832
|
+
decisionInStore(id, priv) {
|
|
833
|
+
const store = priv ? this.privateJson : this.json;
|
|
834
|
+
return store?.get("decisions", id);
|
|
835
|
+
}
|
|
770
836
|
/** Private-overlay counterpart of `supersede`: close + link the old decision inside
|
|
771
837
|
* the HUNCH_PRIVATE_DIR store, so a PRIVATE decision can supersede another private
|
|
772
838
|
* one (the MCP record path is private→private). A private write never mutates the
|
|
@@ -121,6 +121,9 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
121
121
|
const decision = {
|
|
122
122
|
id,
|
|
123
123
|
title: draft.title,
|
|
124
|
+
// Auto-synthesized decisions are un-anchored (topic null) — a topic is a human
|
|
125
|
+
// act, never a machine guess. Preserve one an earlier human capture attached.
|
|
126
|
+
topic: existing?.topic ?? null,
|
|
124
127
|
status: existing?.status === "accepted" ? "accepted" : "proposed",
|
|
125
128
|
context: draft.context + constraintNote,
|
|
126
129
|
decision: draft.decision,
|
|
@@ -154,12 +157,9 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
154
157
|
},
|
|
155
158
|
date: meta.date, // the commit date
|
|
156
159
|
};
|
|
157
|
-
// Route to the
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
store.putPrivate("decisions", decision);
|
|
161
|
-
else
|
|
162
|
-
store.json.put("decisions", decision);
|
|
160
|
+
// Route to the record's ONE home: the overlay when asked (--private) or in unified
|
|
161
|
+
// ("shared") mode; else the public store. Same contract as every other capture path.
|
|
162
|
+
store.putCapture("decisions", decision, opts.private);
|
|
163
163
|
return { status: "written", decision, provider: provider.name };
|
|
164
164
|
}
|
|
165
165
|
/** Capture a Bug from a test failure. Suspects are ranked churn×recency×fan-in. */
|
|
@@ -209,14 +209,14 @@ export async function recordFailure(store, root, failure) {
|
|
|
209
209
|
evidence: [`test:${failure.test}`, ...affectedFiles.slice(0, 6)],
|
|
210
210
|
},
|
|
211
211
|
};
|
|
212
|
-
store.
|
|
212
|
+
store.putCapture("bugs", bug);
|
|
213
213
|
// Promotion (DESIGN §4): a recurrence or a SUBSTANTIATED high-severity bug raises
|
|
214
214
|
// a regression Constraint to stop it coming back, and bumps fragility.
|
|
215
215
|
let constraint;
|
|
216
216
|
if (shouldPromoteConstraint(draft.severity, bug.root_cause, !!prior)) {
|
|
217
217
|
constraint = promoteConstraint(store, bug);
|
|
218
218
|
bug.lineage.spawned_constraint = constraint.id;
|
|
219
|
-
store.
|
|
219
|
+
store.putWhereItLives("bugs", bug); // re-persist with the link, in the same home
|
|
220
220
|
}
|
|
221
221
|
raiseFragility(store, affectedFiles);
|
|
222
222
|
return { status: "written", bug, constraint, provider: provider.name };
|
|
@@ -249,10 +249,10 @@ export async function captureTestRun(store, root, input) {
|
|
|
249
249
|
catch { /* not a git repo / no HEAD — leave null */ }
|
|
250
250
|
const fixed = [];
|
|
251
251
|
for (const name of report.passed) {
|
|
252
|
-
const b = store.
|
|
252
|
+
const b = store.getRec("bugs", bugId(name)); // a unified-mode bug lives in the overlay
|
|
253
253
|
if (b && b.status === "open") {
|
|
254
254
|
const resolved = { ...b, status: "fixed", lineage: { ...b.lineage, fixed_commit: sha } };
|
|
255
|
-
store.
|
|
255
|
+
store.putWhereItLives("bugs", resolved);
|
|
256
256
|
fixed.push(resolved);
|
|
257
257
|
}
|
|
258
258
|
}
|
|
@@ -290,7 +290,7 @@ function promoteConstraint(store, bug) {
|
|
|
290
290
|
valid_to: null,
|
|
291
291
|
provenance: { source: "derived", confidence: Math.min(0.9, bug.provenance.confidence + 0.2), evidence: [`bug:${bug.id}`] },
|
|
292
292
|
};
|
|
293
|
-
return store.
|
|
293
|
+
return store.putCapture("constraints", con);
|
|
294
294
|
}
|
|
295
295
|
/** Bump fragility on components owning the affected files. */
|
|
296
296
|
function raiseFragility(store, files) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.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).",
|