@davesheffer/hunch 0.39.0 → 1.0.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 +58 -6
- package/dist/cli/index.js +108 -39
- package/dist/extractors/git.js +27 -5
- 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 +37 -24
- package/dist/store/db.js +43 -5
- package/dist/store/hunchStore.js +68 -14
- package/dist/synthesis/synthesize.js +8 -11
- package/package.json +3 -5
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@davesheffer/hunch)
|
|
5
5
|
[](https://www.npmjs.com/package/@davesheffer/hunch)
|
|
6
6
|
[](LICENSE)
|
|
7
|
-
[](https://nodejs.org)
|
|
8
8
|
[](https://modelcontextprotocol.io)
|
|
9
9
|
|
|
10
10
|
> **A linter checks whether code matches a *pattern*. Hunch checks whether code still matches your *architecture*** —
|
|
@@ -98,10 +98,49 @@ combination is the moat:
|
|
|
98
98
|
The short version: **git tracks *what* changed; Hunch tracks *why*** — locally, durably, and under
|
|
99
99
|
your control, with guards that actually hold the line instead of just suggesting.
|
|
100
100
|
|
|
101
|
+
## Decision-grounding: memory that stays true to the doc
|
|
102
|
+
|
|
103
|
+
Architectural Conformance keeps the *code* honest to the graph (**graph ≠ code**). Decision-grounding
|
|
104
|
+
is its complement — it keeps your *docs* honest to the graph (**doc ≠ graph**). A comment or a README
|
|
105
|
+
says one thing; the decision that actually governs the file says another. Both are "memory that stays
|
|
106
|
+
true"; you want both.
|
|
107
|
+
|
|
108
|
+
The anchor is one optional field. A decision can carry a **`topic`** — the thing it's the current answer
|
|
109
|
+
for (e.g. `"auth.session"`) — and topic gives you a query contract: **current** (the one live answer),
|
|
110
|
+
**history** (the supersede trail), and **rejected** (what was ruled out and why). It's fully
|
|
111
|
+
backward-compatible: `topic` defaults to `null`, there's **no schema bump**, and existing graphs load
|
|
112
|
+
unchanged.
|
|
113
|
+
|
|
114
|
+
- **Read-time grounding.** The pre-edit (PreToolUse) hook now surfaces a file's topic-anchored decisions
|
|
115
|
+
*before* the AI writes — with doc-precedence framing ("follow the graph, not a stale doc") and what each
|
|
116
|
+
decision **rejected**, so the model doesn't happily re-add the approach you already ruled out.
|
|
117
|
+
- **`anchor-stale` drift — deterministic, no guessing.** A new drift kind fires when a file is still
|
|
118
|
+
anchored to a **superseded** decision while a **current** one exists for its topic. It shows up in
|
|
119
|
+
`hunch doctor` and in a CI-gateable `hunch drift`:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
hunch drift # ⛔ exits non-zero on anchor-stale drift or a topic collision (>1 live decision)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
It only fires on **explicit** topic anchors — no semantic guessing, no false positives on prose it can't
|
|
126
|
+
read.
|
|
127
|
+
- **Capture, gated.** `hunch_record_decision` now enforces a store-scoped **uniqueness guard**: it refuses
|
|
128
|
+
a *second* live decision for a topic (you're never silently governed by two). The richer path is the new
|
|
129
|
+
**`hunch_capture_decision`** tool — it returns a one-question-at-a-time grilling protocol plus a
|
|
130
|
+
capture-session token; `record_decision` accepts an optional `capture_token`. Un-token'd writes still
|
|
131
|
+
work, they just get a nudge toward `/capture`. **`hunch_current_decision(topic)`** returns the one answer
|
|
132
|
+
that currently governs a topic.
|
|
133
|
+
- **`hunch reconcile-topics`.** A git merge is the one thing that can create two live decisions for a
|
|
134
|
+
topic. This scans for it and exits non-zero — wire it into a post-merge hook or CI.
|
|
135
|
+
- **`hunch heal`** + the **`/capture`** and **`/heal`** slash commands (scaffolded by `hunch init`) do
|
|
136
|
+
**read-only** doc↔graph reconciliation — they surface the mismatch and never rewrite your prose silently.
|
|
137
|
+
|
|
138
|
+
→ [docs](https://hunch-pi.vercel.app/docs#grounding)
|
|
139
|
+
|
|
101
140
|
## Getting started
|
|
102
141
|
|
|
103
142
|
```bash
|
|
104
|
-
npm install -g @davesheffer/hunch # Node ≥
|
|
143
|
+
npm install -g @davesheffer/hunch # Node ≥ 22.13; puts `hunch` on your PATH
|
|
105
144
|
cd your-repo
|
|
106
145
|
hunch init # scaffold .hunch/, index, install hooks, wire up assistants
|
|
107
146
|
hunch backfill --since 90d # cold start: seed decisions from recent git history
|
|
@@ -182,6 +221,10 @@ defined elsewhere and `hunch check` / the CI guard / `hunch_merge_verdict` flag
|
|
|
182
221
|
existing location. **Advisory** — it never blocks, and it's tuned to stay quiet so a refactor
|
|
183
222
|
that just moves code isn't mistaken for a duplicate. → [docs](https://hunch-pi.vercel.app/docs#redundancy)
|
|
184
223
|
|
|
224
|
+
See also **[Decision-grounding](#decision-grounding-memory-that-stays-true-to-the-doc)** — the doc ≠ graph
|
|
225
|
+
complement: topic anchors, read-time grounding in the pre-edit hook, and a deterministic `anchor-stale`
|
|
226
|
+
drift check (`hunch drift`) that fails CI when a file still points at a superseded decision.
|
|
227
|
+
|
|
185
228
|
Plus the **Regression Guard** (re-adding deliberately-retired code) and the
|
|
186
229
|
**[CI Constraint Guard](https://hunch-pi.vercel.app/docs#ci)** (`hunch ci` — a PR gate that
|
|
187
230
|
comments the affected `con_`/`dec_` ids and fails on a blocking one).
|
|
@@ -207,9 +250,16 @@ Windows / macOS / Linux teammates share one memory with no per-machine fixups.
|
|
|
207
250
|
Memory follows you across every branch and **git worktree**, with no per-worktree setup — a
|
|
208
251
|
fresh `git worktree add` on any branch sees the same decisions, bugs, and invariants. Create one
|
|
209
252
|
already wired in with **`hunch worktree <path> [-b <branch>]`**, or just run `hunch init` / `hunch
|
|
210
|
-
private` once and every worktree picks it up. Parallel worktrees never corrupt or lose memory, and
|
|
253
|
+
shared` (or `hunch private`) once and every worktree picks it up. Parallel worktrees never corrupt or lose memory, and
|
|
211
254
|
`hunch doctor` confirms a worktree is sharing.
|
|
212
255
|
|
|
256
|
+
Need one **single source of truth** for memory in any repo (private or public)?
|
|
257
|
+
Use **`hunch shared --repo <url>`**. Every capture — decisions, bugs, constraints, runbooks —
|
|
258
|
+
routes to one shared overlay repo and, by default, auto-commits + pushes so teammates/other
|
|
259
|
+
worktrees stay in sync automatically. It also publishes a committed **`.hunch/team.json`**
|
|
260
|
+
pointing at the store, so a fresh clone auto-connects on `hunch init` (agents and CI wire up
|
|
261
|
+
the same way via the MCP server) — everyone, on every branch, resolves the same memory.
|
|
262
|
+
|
|
213
263
|
## Private memory (public repo, private context)
|
|
214
264
|
|
|
215
265
|
Open-source your code without open-sourcing your *reasoning*. **`hunch private`** sets up a
|
|
@@ -220,8 +270,10 @@ env var, no shell-profile edit** (and `HUNCH_PRIVATE_DIR` still overrides per-sh
|
|
|
220
270
|
default-off** (no config → fully inert), and **leak-safe by construction**: committed files and
|
|
221
271
|
the CI PR comment render *public-only*, so a private record can't reach a public surface. Record
|
|
222
272
|
sensitive items with `private: true` (`hunch_record_decision` / `hunch_record_correction`);
|
|
223
|
-
post-commit synthesis can route there too
|
|
224
|
-
|
|
273
|
+
post-commit synthesis can route there too. Every capture is **auto-committed by default** to the
|
|
274
|
+
store it lands in — the private repo is committed + pushed; a public capture is committed to
|
|
275
|
+
`.hunch/` only and rides your next push (Hunch never pushes or merges your code branch) —
|
|
276
|
+
recursion-safe, staging only `.hunch/`. Opt out with `--no-auto-commit`.
|
|
225
277
|
|
|
226
278
|
Already published a repo *with* its `.hunch/` memory and want it private after the fact?
|
|
227
279
|
`hunch private --repo <url> --migrate` does it in one shot: it **moves** your existing public
|
|
@@ -268,5 +320,5 @@ memory. → [the docs](https://hunch-pi.vercel.app/docs) for the conceptual mode
|
|
|
268
320
|
|
|
269
321
|
## Develop
|
|
270
322
|
|
|
271
|
-
Hunch is open source — pure TypeScript ESM, Node ≥
|
|
323
|
+
Hunch is open source — pure TypeScript ESM, Node ≥ 22.13, licensed **Apache-2.0**. Contributions
|
|
272
324
|
welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) and the [repo](https://github.com/davesheffer/hunch).
|
package/dist/cli/index.js
CHANGED
|
@@ -28,7 +28,8 @@ import { indexRepo } from "../extractors/indexer.js";
|
|
|
28
28
|
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
29
29
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
30
30
|
import { selectProvider } from "../synthesis/provider.js";
|
|
31
|
-
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, commitAndPushHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree } from "../extractors/git.js";
|
|
31
|
+
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 { writeTeamConfig, ensureTeamOverlay, readTeamConfig } from "../integrations/team.js";
|
|
32
33
|
import { runbookId, decisionId } from "../core/ids.js";
|
|
33
34
|
import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
|
|
34
35
|
import { extractInlineIntent } from "../extractors/comments.js";
|
|
@@ -36,7 +37,7 @@ import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkrepo
|
|
|
36
37
|
import { partitionReview, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
|
|
37
38
|
import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
|
|
38
39
|
import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
|
|
39
|
-
import {
|
|
40
|
+
import { flushCapture } from "../integrations/sync.js";
|
|
40
41
|
import { installMergeDriver } from "../integrations/mergeDriver.js";
|
|
41
42
|
import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS } from "../integrations/gitignore.js";
|
|
42
43
|
import { writeCiWorkflow } from "../integrations/ciAction.js";
|
|
@@ -82,7 +83,7 @@ program
|
|
|
82
83
|
.option("--firmness <level>", "agent-hook firmness: off | advisory | firm | strict")
|
|
83
84
|
.option("--private-sync", "post-commit synthesis writes captured decisions into the overlay repo (HUNCH_PRIVATE_DIR), never the public store")
|
|
84
85
|
.option("--shared-sync", "alias of --private-sync (for teams using one shared overlay repo for any code repo)")
|
|
85
|
-
.option("--auto-commit", "
|
|
86
|
+
.option("--no-auto-commit", "DON'T auto-commit captures (default: ON in every mode — the overlay repo is committed+pushed; the public .hunch/ is committed only and rides your next push)")
|
|
86
87
|
.action((opts) => {
|
|
87
88
|
// Validate --firmness up front, before any side effects (indexing, git hooks,
|
|
88
89
|
// .mcp.json) or opening the store — a bad value must not leave a half-init.
|
|
@@ -91,12 +92,18 @@ program
|
|
|
91
92
|
}
|
|
92
93
|
const root = findRoot();
|
|
93
94
|
const paths = hunchPaths(root);
|
|
95
|
+
// Team auto-discovery FIRST: a committed .hunch/team.json advertises the shared
|
|
96
|
+
// store — a fresh clone wires itself to it before anything reads memory, so every
|
|
97
|
+
// teammate/agent resolves the same single source of truth with zero manual setup.
|
|
98
|
+
const teamWired = ensureTeamOverlay(root);
|
|
94
99
|
const store = new HunchStore(paths);
|
|
95
100
|
openStore = store; // so the top-level error handler closes it on failure
|
|
96
101
|
const inv = resolveInvocation();
|
|
97
102
|
console.log(`🧠 Initializing Hunch at ${root}`);
|
|
98
103
|
store.json.ensureDirs(); // stamps the manifest at the current version when fresh
|
|
99
104
|
console.log(` ✓ .hunch/ scaffolded (schema v${readManifest(paths).schema_version})`);
|
|
105
|
+
if (teamWired)
|
|
106
|
+
console.log(` ✓ connected to the team's shared memory store (from .hunch/team.json) → ${teamWired}`);
|
|
100
107
|
// Exclude the derived SQLite index BEFORE it's written, so the working tree
|
|
101
108
|
// never goes dirty on the MCP server's index writes (which blocks branch
|
|
102
109
|
// switches). The .hunch/*.json graph stays tracked.
|
|
@@ -110,10 +117,22 @@ program
|
|
|
110
117
|
if (res.skipped)
|
|
111
118
|
console.log(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
|
|
112
119
|
}
|
|
120
|
+
// Auto-commit is ON by default in every mode; --no-auto-commit persists the opt-out in
|
|
121
|
+
// the gitignored local.json (merge — never clobber an existing overlay pointer).
|
|
122
|
+
if (opts.autoCommit === false) {
|
|
123
|
+
const localFile = join(paths.hunch, "local.json");
|
|
124
|
+
let existing = {};
|
|
125
|
+
try {
|
|
126
|
+
existing = JSON.parse(readFileSync(localFile, "utf8"));
|
|
127
|
+
}
|
|
128
|
+
catch { /* absent/invalid → fresh */ }
|
|
129
|
+
writeFileAtomic(localFile, JSON.stringify({ ...existing, autoCommit: false }, null, 2) + "\n");
|
|
130
|
+
console.log(" ✓ auto-commit OFF (captures stay uncommitted; commit .hunch/ yourself)");
|
|
131
|
+
}
|
|
113
132
|
if (isGitRepo(root)) {
|
|
114
133
|
const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
|
|
115
134
|
const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit });
|
|
116
|
-
console.log(` ✓ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit
|
|
135
|
+
console.log(` ✓ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`);
|
|
117
136
|
const m = installMergeDriver(root, inv.shell);
|
|
118
137
|
console.log(` ✓ team merge driver ${m.action}`);
|
|
119
138
|
// Auto-install the pre-commit guard by default (advisory: flags invariants
|
|
@@ -164,7 +183,7 @@ program
|
|
|
164
183
|
// Worktree-seamless: register any configured overlay at the git common dir so EVERY
|
|
165
184
|
// worktree of this repo auto-discovers it (also backfills pre-0.32 single-worktree setups),
|
|
166
185
|
// and note when we're initializing inside a linked worktree (memory is shared, not separate).
|
|
167
|
-
if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit)) {
|
|
186
|
+
if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit, store.mode === "shared" ? "shared" : "private")) {
|
|
168
187
|
console.log(` ✓ private overlay registered at the git common dir — shared by every worktree of this repo`);
|
|
169
188
|
}
|
|
170
189
|
if (isLinkedWorktree(root)) {
|
|
@@ -271,7 +290,8 @@ program
|
|
|
271
290
|
.option("--force", "re-synthesize even if a decision already exists for the commit")
|
|
272
291
|
.option("--private", "write the synthesized decision into the configured overlay (HUNCH_PRIVATE_DIR), not the public store")
|
|
273
292
|
.option("--overlay", "alias of --private")
|
|
274
|
-
.option("--commit", "after a capture, also git add+commit
|
|
293
|
+
.option("--commit", "after a capture, also git add+commit the repo the decision landed in (default: follows auto-commit, ON unless opted out) — the overlay is also pushed; the public .hunch/ rides your next push")
|
|
294
|
+
.option("--no-commit", "skip the auto-commit for this capture even when auto-commit is on")
|
|
275
295
|
.option("--deep", "Deep Synthesis: ensemble every available subscription CLI and reconcile their drafts (agreement-weighted, advisory). Slower; subscription-only")
|
|
276
296
|
.option("--verify", "Critic pass: audit the draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra subscription call; advisory)")
|
|
277
297
|
.option("--samples <n>", "self-consistency depth when only one CLI is installed: sample it n times and reconcile (default 2 under --deep)")
|
|
@@ -279,7 +299,9 @@ program
|
|
|
279
299
|
const { store, root } = storeFor();
|
|
280
300
|
if (!isGitRepo(root))
|
|
281
301
|
return opts.quiet ? undefined : fail("sync needs a git repo");
|
|
282
|
-
|
|
302
|
+
// In unified ("shared") mode every capture routes to the overlay — the sync path
|
|
303
|
+
// must agree with captureHome so all writers home records identically.
|
|
304
|
+
const toOverlay = !!(opts.private || opts.overlay || store.unified);
|
|
283
305
|
if (toOverlay && !store.hasPrivate) {
|
|
284
306
|
store.close();
|
|
285
307
|
return opts.quiet ? undefined : fail("--private/--overlay needs HUNCH_PRIVATE_DIR set to an overlay store");
|
|
@@ -296,16 +318,20 @@ program
|
|
|
296
318
|
if (healed.length && !opts.quiet)
|
|
297
319
|
console.log(` ↳ grounding refreshed: ${healed.join(", ")}`);
|
|
298
320
|
}
|
|
299
|
-
//
|
|
300
|
-
//
|
|
321
|
+
// Persist the captured decision in the repo it landed in (private store under
|
|
322
|
+
// --private, else this repo). ON by default (follows auto-commit; --no-commit or
|
|
323
|
+
// `--no-auto-commit` at setup opts out). Best-effort — a non-repo dir / offline push
|
|
301
324
|
// just no-ops. Stage ONLY the hunch dir (never sweep unrelated working-tree
|
|
302
325
|
// changes), and set HUNCH_SYNC=1 so the commit we create can't re-trigger this
|
|
303
|
-
// hook (no recursion
|
|
304
|
-
|
|
326
|
+
// hook (no recursion). The overlay is pushed; the public .hunch/ is committed
|
|
327
|
+
// WITHOUT pushing — auto-pushing the user's code branch would publish their
|
|
328
|
+
// unpushed commits (bug_overlay_clobber lineage).
|
|
329
|
+
const doCommit = opts.commit ?? store.autoCommit;
|
|
330
|
+
const commitTarget = doCommit ? (toOverlay ? store.privateDir : hunchPaths(root).hunch) : undefined;
|
|
305
331
|
if (commitTarget) {
|
|
306
|
-
commitAndPushHunch(commitTarget, `hunch: capture ${r.decision?.id ?? "decision"}
|
|
332
|
+
commitAndPushHunch(commitTarget, `hunch: capture ${r.decision?.id ?? "decision"}`, { push: toOverlay });
|
|
307
333
|
if (!opts.quiet)
|
|
308
|
-
console.log(` ↳ committed + pushed ${r.decision?.id} (${commitTarget})`);
|
|
334
|
+
console.log(` ↳ committed ${toOverlay ? "+ pushed " : ""}${r.decision?.id} (${commitTarget}${toOverlay ? "" : " — rides your next push"})`);
|
|
309
335
|
}
|
|
310
336
|
if (!opts.quiet)
|
|
311
337
|
console.log(`✓ captured decision ${r.decision?.id} via ${r.provider}: "${r.decision?.title}"`);
|
|
@@ -331,17 +357,44 @@ function configureOverlay(dir, opts, mode) {
|
|
|
331
357
|
}
|
|
332
358
|
// 1) resolve the overlay store's hunch dir (holds decisions/, bugs/, …)
|
|
333
359
|
let hunchDir;
|
|
360
|
+
// Anchor the default store at the MAIN worktree root: a linked worktree can be
|
|
361
|
+
// `git worktree remove`d, which would take the store (and every other worktree's
|
|
362
|
+
// absolute pointer to it) down with it. An explicit [dir] still resolves from here.
|
|
363
|
+
const anchor = mainWorktreeRoot(root);
|
|
334
364
|
if (opts.repo) {
|
|
335
|
-
const dest = join(
|
|
365
|
+
const dest = join(anchor, ".hunch-private");
|
|
336
366
|
if (!existsSync(dest)) {
|
|
337
367
|
const r = spawnSync("git", ["clone", opts.repo, dest], { stdio: "inherit" });
|
|
338
368
|
if (r.status !== 0)
|
|
339
369
|
return fail(`git clone failed for ${opts.repo}`);
|
|
340
370
|
}
|
|
371
|
+
else {
|
|
372
|
+
// NEVER silently ignore --repo when the store dir already exists: same remote →
|
|
373
|
+
// freshen; no remote → attach + converge; different remote → refuse loudly.
|
|
374
|
+
const cur = spawnSync("git", ["-C", dest, "remote", "get-url", "origin"], { encoding: "utf8" });
|
|
375
|
+
const existingUrl = cur.status === 0 ? cur.stdout.trim() : "";
|
|
376
|
+
if (existingUrl === opts.repo) {
|
|
377
|
+
pullHunch(join(dest, ".hunch"));
|
|
378
|
+
console.log(` · ${dest} already tracks ${opts.repo} — pulled the latest memory`);
|
|
379
|
+
}
|
|
380
|
+
else if (!existingUrl) {
|
|
381
|
+
if (!isGitRepo(dest))
|
|
382
|
+
spawnSync("git", ["init", "-q", dest], { stdio: "ignore" });
|
|
383
|
+
spawnSync("git", ["-C", dest, "remote", "add", "origin", opts.repo], { stdio: "ignore" });
|
|
384
|
+
spawnSync("git", ["-C", dest, "fetch", "-q", "origin"], { stdio: "ignore" });
|
|
385
|
+
spawnSync("git", ["-C", dest, "merge", "-q", "--no-edit", "--allow-unrelated-histories", "FETCH_HEAD"], { stdio: "ignore" });
|
|
386
|
+
spawnSync("git", ["-C", dest, "push", "-q", "-u", "origin", "HEAD"], { stdio: "ignore" });
|
|
387
|
+
console.log(` · attached the existing local store ${dest} to ${opts.repo} (merged + pushed, best-effort)`);
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
return fail(`${dest} already tracks a DIFFERENT remote:\n current: ${existingUrl}\n requested: ${opts.repo}\n` +
|
|
391
|
+
`Refusing to silently re-point your memory. Move that directory aside, or pass an explicit dir: \`hunch ${commandName} <dir> --repo <url>\`.`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
341
394
|
hunchDir = join(dest, ".hunch");
|
|
342
395
|
}
|
|
343
396
|
else {
|
|
344
|
-
hunchDir = dir ? resolve(root, dir) : join(
|
|
397
|
+
hunchDir = dir ? resolve(root, dir) : join(anchor, ".hunch-private", ".hunch");
|
|
345
398
|
}
|
|
346
399
|
// 2) create the layout (decisions/, manifest, …) so it's queryable immediately
|
|
347
400
|
new JsonStore(hunchPathsForDir(hunchDir)).ensureDirs();
|
|
@@ -367,14 +420,23 @@ function configureOverlay(dir, opts, mode) {
|
|
|
367
420
|
// a store elsewhere on disk. Resolution (env || local.json) re-resolves against root.
|
|
368
421
|
const rel = relative(root, hunchDir);
|
|
369
422
|
const stored = rel && !rel.startsWith("..") && !isAbsolute(rel) ? toPosixTarget(rel) : hunchDir;
|
|
370
|
-
writeFileAtomic(join(paths.hunch, "local.json"), JSON.stringify({ privateDir: stored, autoCommit: !!opts.autoCommit }, null, 2) + "\n");
|
|
423
|
+
writeFileAtomic(join(paths.hunch, "local.json"), JSON.stringify({ privateDir: stored, autoCommit: !!opts.autoCommit, mode }, null, 2) + "\n");
|
|
371
424
|
ensureGitignore(root); // keeps .hunch/local.json + .hunch-private/ out of git
|
|
425
|
+
// SHARED mode with a remote: publish the store's URL in a COMMITTED team.json, so a
|
|
426
|
+
// fresh clone / new teammate / headless agent auto-connects on `hunch init` (or MCP
|
|
427
|
+
// server start) — everyone resolves the same single source of truth. Private mode
|
|
428
|
+
// never publishes its URL.
|
|
429
|
+
let teamNote = "";
|
|
430
|
+
if (mode === "shared" && opts.repo) {
|
|
431
|
+
writeTeamConfig(root, { shared_repo: opts.repo });
|
|
432
|
+
teamNote = " ✓ published .hunch/team.json (commit it) — teammates, worktrees, and agents auto-connect\n";
|
|
433
|
+
}
|
|
372
434
|
// Also register the overlay at the SHARED git common dir, so EVERY worktree of this repo
|
|
373
435
|
// (current + future, any branch) auto-discovers the same memory with zero per-worktree
|
|
374
436
|
// setup. Stored ABSOLUTE — a linked worktree resolves relative paths from its OWN root, so
|
|
375
437
|
// only an absolute path survives the move. Lives under .git/ (never tracked; nothing to ignore).
|
|
376
438
|
let worktreeNote = "";
|
|
377
|
-
if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit)) {
|
|
439
|
+
if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit, mode)) {
|
|
378
440
|
worktreeNote = " ✓ registered in the git common dir — shared by every worktree of this repo, on any branch\n";
|
|
379
441
|
}
|
|
380
442
|
// 4) route post-commit synthesis to the overlay (local hook, never committed)
|
|
@@ -414,10 +476,11 @@ function configureOverlay(dir, opts, mode) {
|
|
|
414
476
|
: `✓ shared overlay enabled → ${hunchDir}\n`;
|
|
415
477
|
const tail = mode === "private"
|
|
416
478
|
? " record sensitive items with private:true (hunch_record_decision / hunch_record_correction)\n override per-shell with HUNCH_PRIVATE_DIR; CI / public PR comments stay public-only."
|
|
417
|
-
: "
|
|
479
|
+
: " UNIFIED: every capture (decisions, bugs, constraints, runbooks) routes HERE — one source of truth\n across branches, worktrees, teammates, and agents. Override per-shell with HUNCH_PRIVATE_DIR if needed.";
|
|
418
480
|
console.log(lead +
|
|
419
481
|
" ✓ recorded in .hunch/local.json (gitignored) — auto-detected, no env var or shell-profile edit\n" +
|
|
420
482
|
worktreeNote +
|
|
483
|
+
teamNote +
|
|
421
484
|
hookNote +
|
|
422
485
|
migrateNote +
|
|
423
486
|
tail);
|
|
@@ -693,10 +756,7 @@ program
|
|
|
693
756
|
provenance: { source: "extracted", confidence: 0.5, evidence: [range] },
|
|
694
757
|
date: now,
|
|
695
758
|
};
|
|
696
|
-
|
|
697
|
-
store.putPrivate("runbooks", rec);
|
|
698
|
-
else
|
|
699
|
-
store.json.put("runbooks", rec);
|
|
759
|
+
store.putCapture("runbooks", rec, opts.private);
|
|
700
760
|
store.reindex();
|
|
701
761
|
console.log(`✓ runbook ${rec.id} — "${rec.task}" (${rec.steps.length} steps, ${rec.files.length} files)${opts.private ? " [private overlay]" : ""}`);
|
|
702
762
|
console.log(dim(" advisory, deterministic draft — refine the steps/gotchas; surfaced via `hunch query` and MCP."));
|
|
@@ -730,10 +790,7 @@ program
|
|
|
730
790
|
retired: { symbols: [], deps: [] },
|
|
731
791
|
provenance: { source: "human_confirmed", confidence: 0.9, evidence: ev }, date: prev?.date ?? now,
|
|
732
792
|
};
|
|
733
|
-
|
|
734
|
-
store.putPrivate("decisions", rec);
|
|
735
|
-
else
|
|
736
|
-
store.json.put("decisions", rec);
|
|
793
|
+
store.putCapture("decisions", rec, opts.private);
|
|
737
794
|
dec++;
|
|
738
795
|
}
|
|
739
796
|
else {
|
|
@@ -749,16 +806,13 @@ program
|
|
|
749
806
|
valid_from: prev?.valid_from ?? now, valid_to: null,
|
|
750
807
|
provenance: { source: "human_confirmed", confidence: 0.9, evidence: ev },
|
|
751
808
|
};
|
|
752
|
-
|
|
753
|
-
store.putPrivate("constraints", rec);
|
|
754
|
-
else
|
|
755
|
-
store.json.put("constraints", rec);
|
|
809
|
+
store.putCapture("constraints", rec, opts.private);
|
|
756
810
|
con++;
|
|
757
811
|
}
|
|
758
812
|
}
|
|
759
813
|
store.reindex();
|
|
760
|
-
if (
|
|
761
|
-
|
|
814
|
+
if (dec || con)
|
|
815
|
+
flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${dec + con} inline intent(s)`);
|
|
762
816
|
if (!intents.length)
|
|
763
817
|
console.log("No `hunch-why:` / `hunch-rule:` comments found.");
|
|
764
818
|
else
|
|
@@ -790,7 +844,7 @@ program
|
|
|
790
844
|
store.json.ensureDirs();
|
|
791
845
|
const now = new Date().toISOString();
|
|
792
846
|
const arrow = opts.assert.startsWith("not-") ? "↛" : "→";
|
|
793
|
-
const d = store.
|
|
847
|
+
const d = store.putCapture("decisions", {
|
|
794
848
|
id: decisionId(`conform:${opts.add}:${opts.subject}:${opts.object ?? ""}`),
|
|
795
849
|
title: opts.add,
|
|
796
850
|
status: "accepted",
|
|
@@ -1023,7 +1077,7 @@ program
|
|
|
1023
1077
|
forbids = deriveForbids(statement, deps.length ? deps : undefined);
|
|
1024
1078
|
derived = !!forbids;
|
|
1025
1079
|
}
|
|
1026
|
-
const c = store.
|
|
1080
|
+
const c = store.putCapture("constraints", {
|
|
1027
1081
|
id: constraintId(statement),
|
|
1028
1082
|
type: opts.type,
|
|
1029
1083
|
statement,
|
|
@@ -1308,7 +1362,7 @@ vetoCmd
|
|
|
1308
1362
|
if ((d.rejected_tripwires?.length ?? 0) > 0)
|
|
1309
1363
|
continue; // never clobber existing tripwires
|
|
1310
1364
|
const tws = draftTripwires(d.alternatives_rejected, d.related_files, knownDeps);
|
|
1311
|
-
store.
|
|
1365
|
+
store.putWhereItLives("decisions", { ...d, rejected_tripwires: tws });
|
|
1312
1366
|
drafted += tws.length;
|
|
1313
1367
|
touched++;
|
|
1314
1368
|
}
|
|
@@ -1569,7 +1623,7 @@ function acceptDecision(store, d) {
|
|
|
1569
1623
|
last_verified: now,
|
|
1570
1624
|
},
|
|
1571
1625
|
}));
|
|
1572
|
-
store.
|
|
1626
|
+
store.putWhereItLives("decisions", { ...d, status: "accepted", rejected_tripwires: confirmedTws, provenance: { ...d.provenance, source, confidence: 0.95, last_verified: now } });
|
|
1573
1627
|
const armed = confirmedTws.filter((tw) => tw.forbids.deps.length || tw.forbids.symbols.length || tw.forbids.patterns.length).length;
|
|
1574
1628
|
return { source, armed };
|
|
1575
1629
|
}
|
|
@@ -1856,9 +1910,24 @@ program
|
|
|
1856
1910
|
}
|
|
1857
1911
|
const c = store.reindex().counts;
|
|
1858
1912
|
console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1913
|
+
// Overlay status speaks the TRUE mode, and a dead pointer is a loud finding, not a
|
|
1914
|
+
// silent empty store: the JSON reader degrades to [] when the target dir is missing,
|
|
1915
|
+
// so this is the one place the loss is visible.
|
|
1916
|
+
if (store.privateDir && !existsSync(store.privateDir)) {
|
|
1917
|
+
console.log(`overlay: ⛔ POINTER IS DEAD → ${store.privateDir} does not exist — shared/private memory is NOT being read.`);
|
|
1918
|
+
console.log(` fix: re-run \`hunch ${store.mode === "shared" ? "shared" : "private"} --repo <url>\` (or restore the directory); the pointer lives in .hunch/local.json / the git common dir`);
|
|
1919
|
+
}
|
|
1920
|
+
else if (store.privateDir) {
|
|
1921
|
+
console.log(store.mode === "shared"
|
|
1922
|
+
? `shared: on → ${store.privateDir} (UNIFIED — every capture routes here; one source of truth across branches, worktrees, teammates, agents)`
|
|
1923
|
+
: `private: on → ${store.privateDir} (local overlay — unioned into queries; never committed or posted publicly)`);
|
|
1924
|
+
}
|
|
1925
|
+
else {
|
|
1926
|
+
const team = readTeamConfig(root);
|
|
1927
|
+
console.log(team
|
|
1928
|
+
? `overlay: off, but .hunch/team.json advertises the team store (${team.shared_repo}) — run \`hunch init\` to auto-connect`
|
|
1929
|
+
: dim(`private: off — run \`hunch shared\` (or \`hunch private\`) to use one overlay repo across teammates/worktrees (or set HUNCH_PRIVATE_DIR)`));
|
|
1930
|
+
}
|
|
1862
1931
|
// Worktree posture: linked worktrees share ONE memory via the git common dir. Only
|
|
1863
1932
|
// surfaced in a linked worktree (no noise in a normal single checkout), so a
|
|
1864
1933
|
// "memory missing here" symptom has an obvious cause + fix.
|
package/dist/extractors/git.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Deterministic git introspection for the extractor + learning loop.
|
|
2
2
|
* No LLM here — just parsing what git already knows. */
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
|
-
import { isAbsolute, resolve, join } from "node:path";
|
|
4
|
+
import { isAbsolute, resolve, join, basename, dirname } from "node:path";
|
|
5
5
|
import { mkdirSync, rmSync, statSync, realpathSync } 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).
|
|
@@ -21,12 +21,28 @@ function gitSafe(args, cwd, maxBuffer) {
|
|
|
21
21
|
export function isGitRepo(cwd) {
|
|
22
22
|
return gitSafe(["rev-parse", "--is-inside-work-tree"], cwd) === "true";
|
|
23
23
|
}
|
|
24
|
+
/** The MAIN worktree's root — the stable anchor for an overlay store. A linked worktree
|
|
25
|
+
* can be `git worktree remove`d, so anything anchored inside it (an overlay clone, an
|
|
26
|
+
* absolute pointer target) silently dies for every OTHER worktree; the main checkout
|
|
27
|
+
* can't be removed. Falls back to `root` when the layout isn't the standard `.git` dir
|
|
28
|
+
* (or not a git repo), preserving today's behavior. */
|
|
29
|
+
export function mainWorktreeRoot(root) {
|
|
30
|
+
const common = gitCommonDir(root);
|
|
31
|
+
if (!common)
|
|
32
|
+
return root;
|
|
33
|
+
const abs = resolve(root, common);
|
|
34
|
+
return basename(abs) === ".git" ? dirname(abs) : root;
|
|
35
|
+
}
|
|
24
36
|
/** Best-effort: stage ONLY the hunch dir, commit, and push the repo it lives in. Shared by
|
|
25
37
|
* the post-commit auto-commit (CLI sync --commit), MCP private writes, and `hunch private
|
|
26
38
|
* --sync`. HUNCH_SYNC=1 stops the created commit from re-triggering the post-commit hook
|
|
27
39
|
* (no recursion). Stages with a pathspec scoped to `hunchDir`, so it never sweeps unrelated
|
|
28
|
-
* working-tree changes. Never throws — a non-repo dir / offline push just no-ops.
|
|
29
|
-
|
|
40
|
+
* working-tree changes. Never throws — a non-repo dir / offline push just no-ops.
|
|
41
|
+
* `push: false` commits WITHOUT merging or pushing — required when hunchDir is the PUBLIC
|
|
42
|
+
* .hunch/ inside the user's code repo: an automatic pull/push there would merge the remote
|
|
43
|
+
* into their working branch and publish their unpushed code commits. The memory commit
|
|
44
|
+
* simply rides the user's next push. */
|
|
45
|
+
export function commitAndPushHunch(hunchDir, message, opts = {}) {
|
|
30
46
|
// Serialize across worktrees: several worktrees auto-committing the SAME overlay repo
|
|
31
47
|
// at once would race git's index.lock. An atomic-mkdir lock lets one proceed; the others
|
|
32
48
|
// skip — safe because each record is already written to disk, so `git add .` here sweeps
|
|
@@ -53,7 +69,13 @@ export function commitAndPushHunch(hunchDir, message) {
|
|
|
53
69
|
execFileSync("git", ["-C", hunchDir, "reset", "-q", "--", "."], { stdio: "ignore", env });
|
|
54
70
|
}
|
|
55
71
|
catch { /* best-effort unstage */ }
|
|
56
|
-
|
|
72
|
+
// Public-store commits (push:false) skip QUIETLY: a non-memory staged set there is
|
|
73
|
+
// usually just the user's own staged work, not a misconfigured overlay — the record
|
|
74
|
+
// stays on disk and the next flush's `git add .` sweeps it up. The overlay path
|
|
75
|
+
// stays loud: there it signals the escaped-to-project-repo misconfiguration.
|
|
76
|
+
if (opts.push !== false) {
|
|
77
|
+
console.error(`hunch: refusing to auto-commit memory at "${hunchDir}" — the staged change includes deletions or non-memory files, so this is not a clean overlay repo. Nothing was committed or pushed. (Use \`hunch shared --repo <url>\` so the overlay is its OWN git repo.)`);
|
|
78
|
+
}
|
|
57
79
|
return;
|
|
58
80
|
}
|
|
59
81
|
// Only sync+push when a memory commit was actually created — never run pull/push against the
|
|
@@ -66,7 +88,7 @@ export function commitAndPushHunch(hunchDir, message) {
|
|
|
66
88
|
committed = true;
|
|
67
89
|
}
|
|
68
90
|
catch { /* nothing staged / not a repo */ }
|
|
69
|
-
if (committed && mergeRemote(hunchDir, env))
|
|
91
|
+
if (committed && opts.push !== false && mergeRemote(hunchDir, env))
|
|
70
92
|
run(["push"]);
|
|
71
93
|
}
|
|
72
94
|
finally {
|
|
@@ -5,4 +5,22 @@ export function flushPrivate(store, message) {
|
|
|
5
5
|
if (store.privateAutoCommit && store.privateDir)
|
|
6
6
|
commitAndPushHunch(store.privateDir, message);
|
|
7
7
|
}
|
|
8
|
+
/** Auto-commit the store a capture landed in. Returns what happened so callers can report it:
|
|
9
|
+
* "pushed" (private overlay, committed + pushed), "committed" (public .hunch/, commit only —
|
|
10
|
+
* rides the next push), or null (auto-commit off / no overlay for a private record). */
|
|
11
|
+
export function flushCapture(store, publicHunchDir, isPrivate, message) {
|
|
12
|
+
// Follow the same routing as HunchStore.captureHome: unified ("shared") mode homes
|
|
13
|
+
// EVERY capture in the overlay, so the flush must go there too — one source of truth.
|
|
14
|
+
if (store.captureHome(isPrivate) === "private") {
|
|
15
|
+
if (store.privateAutoCommit && store.privateDir) {
|
|
16
|
+
commitAndPushHunch(store.privateDir, message);
|
|
17
|
+
return "pushed";
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
if (!store.autoCommit)
|
|
22
|
+
return null;
|
|
23
|
+
commitAndPushHunch(publicHunchDir, message, { push: false });
|
|
24
|
+
return "committed";
|
|
25
|
+
}
|
|
8
26
|
//# sourceMappingURL=sync.js.map
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team discovery for the shared memory store — the COMMITTED half of the resolution chain.
|
|
3
|
+
* `.hunch/local.json` (gitignored, per-machine) says where THIS machine's overlay lives;
|
|
4
|
+
* `.hunch/team.json` (committed, public) says where the TEAM's shared store lives, so a
|
|
5
|
+
* fresh clone / a new teammate / a headless agent can auto-wire without being told.
|
|
6
|
+
* Written ONLY by `hunch shared --repo <url>` — `hunch private` never publishes its URL.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
12
|
+
import { hunchPaths, hunchPathsForDir } from "../core/paths.js";
|
|
13
|
+
import { mainWorktreeRoot } from "../extractors/git.js";
|
|
14
|
+
import { HunchStore } from "../store/hunchStore.js";
|
|
15
|
+
import { JsonStore } from "../store/jsonStore.js";
|
|
16
|
+
import { ensureSharedOverlayPointer } from "./worktree.js";
|
|
17
|
+
/** SECURITY GATE for team.json's URL. team.json is COMMITTED — in a freshly cloned
|
|
18
|
+
* (possibly untrusted) repo it is attacker-controlled, and ensureTeamOverlay auto-clones
|
|
19
|
+
* it on MCP server start. Without this gate a value like `--upload-pack=…` (argument
|
|
20
|
+
* smuggling) or `ext::sh -c …` (git's ext transport) is remote code execution from
|
|
21
|
+
* merely opening a repo. Allow only https:// / ssh:// / git:// / scp-style git@host:path,
|
|
22
|
+
* and never anything that could parse as a git flag. */
|
|
23
|
+
export function safeGitUrl(url) {
|
|
24
|
+
const u = url.trim();
|
|
25
|
+
if (!u || u.startsWith("-"))
|
|
26
|
+
return null; // flag smuggling
|
|
27
|
+
if (/^(https|ssh|git):\/\/[^\s]+$/i.test(u))
|
|
28
|
+
return u;
|
|
29
|
+
if (/^[A-Za-z0-9_.-]+@[A-Za-z0-9_.:-]+:[^\s]+$/.test(u) && !u.includes("::"))
|
|
30
|
+
return u; // scp-like, excludes ext::
|
|
31
|
+
// A plain absolute path (POSIX / Windows drive / UNC) — a network-mount team store or a
|
|
32
|
+
// local test remote. Safe: a local clone never executes hooks or remote helpers. The
|
|
33
|
+
// file:// URL FORM stays rejected (no legitimate team.json uses it; keeps the gate tight).
|
|
34
|
+
if (u.startsWith("/") || /^[A-Za-z]:[\\/]/.test(u) || u.startsWith("\\\\"))
|
|
35
|
+
return u;
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/** The committed team pointer, or null. Tolerant — an invalid file reads as absent, and
|
|
39
|
+
* a URL that fails the safety gate reads as absent too (never propagated to a consumer). */
|
|
40
|
+
export function readTeamConfig(root) {
|
|
41
|
+
try {
|
|
42
|
+
const file = join(hunchPaths(root).hunch, "team.json");
|
|
43
|
+
if (!existsSync(file))
|
|
44
|
+
return null;
|
|
45
|
+
const v = JSON.parse(readFileSync(file, "utf8"));
|
|
46
|
+
const url = typeof v.shared_repo === "string" ? safeGitUrl(v.shared_repo) : null;
|
|
47
|
+
return url ? { shared_repo: url } : null;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Publish the team's shared-store URL (atomic; committed with the repo). */
|
|
54
|
+
export function writeTeamConfig(root, cfg) {
|
|
55
|
+
writeFileAtomic(join(hunchPaths(root).hunch, "team.json"), JSON.stringify(cfg, null, 2) + "\n");
|
|
56
|
+
}
|
|
57
|
+
/** Auto-wire this checkout to the team's shared store advertised in `.hunch/team.json`:
|
|
58
|
+
* clone it to the worktree-stable anchor, and register the gitignored local pointer +
|
|
59
|
+
* the git-common-dir pointer (mode "shared", auto-commit on) so every consumer — CLI,
|
|
60
|
+
* MCP server, hooks, all worktrees — resolves the same single source of truth.
|
|
61
|
+
* No-op (null) when an overlay is already configured, there's no team.json, or the
|
|
62
|
+
* clone fails (best-effort: never throws, never blocks startup). Returns the overlay
|
|
63
|
+
* hunch dir when wired. */
|
|
64
|
+
export function ensureTeamOverlay(root) {
|
|
65
|
+
try {
|
|
66
|
+
if (process.env.HUNCH_PRIVATE_DIR?.trim())
|
|
67
|
+
return null; // explicit env wins
|
|
68
|
+
const team = readTeamConfig(root);
|
|
69
|
+
if (!team)
|
|
70
|
+
return null;
|
|
71
|
+
const probe = new HunchStore(hunchPaths(root));
|
|
72
|
+
const configured = probe.privateDir;
|
|
73
|
+
probe.close();
|
|
74
|
+
if (configured && existsSync(configured))
|
|
75
|
+
return null; // already wired and alive
|
|
76
|
+
const anchor = mainWorktreeRoot(root);
|
|
77
|
+
const dest = join(anchor, ".hunch-private");
|
|
78
|
+
if (!existsSync(dest)) {
|
|
79
|
+
// Defense in depth on top of safeGitUrl: `--` stops flag parsing, the protocol
|
|
80
|
+
// allowlist + ext:: kill-switch block command-running transports, and no terminal
|
|
81
|
+
// prompt means a private remote can't hang a headless MCP/agent start.
|
|
82
|
+
const r = spawnSync("git", ["-c", "protocol.ext.allow=never", "clone", "--", team.shared_repo, dest], {
|
|
83
|
+
stdio: "ignore",
|
|
84
|
+
env: { ...process.env, GIT_ALLOW_PROTOCOL: "https:ssh:git:file", GIT_TERMINAL_PROMPT: "0" },
|
|
85
|
+
});
|
|
86
|
+
if (r.status !== 0)
|
|
87
|
+
return null; // offline / no access — stay unwired, never crash
|
|
88
|
+
}
|
|
89
|
+
const hunchDir = join(dest, ".hunch");
|
|
90
|
+
new JsonStore(hunchPathsForDir(hunchDir)).ensureDirs();
|
|
91
|
+
// Merge into any existing local.json (con_8460b6770f): a per-machine autoCommit
|
|
92
|
+
// opt-out must survive the auto-wiring; an unparseable file is left alone.
|
|
93
|
+
const localFile = join(hunchPaths(root).hunch, "local.json");
|
|
94
|
+
let existing = {};
|
|
95
|
+
if (existsSync(localFile)) {
|
|
96
|
+
try {
|
|
97
|
+
existing = JSON.parse(readFileSync(localFile, "utf8"));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
} // refuse to clobber an unparseable config
|
|
102
|
+
}
|
|
103
|
+
const autoCommit = existing.autoCommit !== false;
|
|
104
|
+
writeFileAtomic(localFile, JSON.stringify({ ...existing, privateDir: hunchDir, autoCommit, mode: "shared" }, null, 2) + "\n");
|
|
105
|
+
ensureSharedOverlayPointer(root, hunchDir, autoCommit, "shared");
|
|
106
|
+
return hunchDir;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
//# sourceMappingURL=team.js.map
|
|
@@ -11,14 +11,16 @@ import { writeFileAtomic } from "../core/io.js";
|
|
|
11
11
|
/** Register the resolved private overlay at the shared git common dir, so every worktree
|
|
12
12
|
* of this repo auto-discovers the same memory. Idempotent (writes only when missing or
|
|
13
13
|
* changed). Stored ABSOLUTE — a worktree resolves relative paths from its OWN root.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
14
|
+
* Carries the overlay MODE so every worktree routes captures identically (shared =
|
|
15
|
+
* unified store, private = split). Returns true once the shared pointer is in place
|
|
16
|
+
* (memory is worktree-shared), false when there's no overlay configured or no git
|
|
17
|
+
* common dir. Reused by `init`/`worktree`/`private`/`shared`. */
|
|
18
|
+
export function ensureSharedOverlayPointer(root, overlayDir, autoCommit, mode = "private") {
|
|
17
19
|
const common = overlayDir ? gitCommonDir(root) : "";
|
|
18
20
|
if (!common || !overlayDir)
|
|
19
21
|
return false;
|
|
20
22
|
const file = join(common, "hunch", "local.json");
|
|
21
|
-
const want = JSON.stringify({ privateDir: resolve(overlayDir), autoCommit }, null, 2) + "\n";
|
|
23
|
+
const want = JSON.stringify({ privateDir: resolve(overlayDir), autoCommit, mode }, null, 2) + "\n";
|
|
22
24
|
try {
|
|
23
25
|
if (!(existsSync(file) && readFileSync(file, "utf8") === want)) {
|
|
24
26
|
mkdirSync(join(common, "hunch"), { recursive: true });
|
package/dist/mcp/server.js
CHANGED
|
@@ -16,7 +16,9 @@ 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,
|
|
19
|
+
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, pullHunch } from "../extractors/git.js";
|
|
20
|
+
import { flushCapture } from "../integrations/sync.js";
|
|
21
|
+
import { ensureTeamOverlay } from "../integrations/team.js";
|
|
20
22
|
import { formatContext } from "../core/format.js";
|
|
21
23
|
import { compareCandidates } from "../core/compare.js";
|
|
22
24
|
import { checkConformance } from "../core/conformance.js";
|
|
@@ -75,6 +77,14 @@ function resolveFiles(store, target) {
|
|
|
75
77
|
return files.size ? [...files] : [toPosixTarget(target)];
|
|
76
78
|
}
|
|
77
79
|
export function buildServer(root) {
|
|
80
|
+
// Team auto-discovery: a committed .hunch/team.json advertises the shared store — a
|
|
81
|
+
// fresh clone (a new teammate, a headless agent, a CI workflow) wires itself BEFORE the
|
|
82
|
+
// store is constructed, so every consumer resolves the same single source of truth.
|
|
83
|
+
// Best-effort: offline / no team.json → proceed exactly as before.
|
|
84
|
+
try {
|
|
85
|
+
ensureTeamOverlay(root);
|
|
86
|
+
}
|
|
87
|
+
catch { /* never block server start */ }
|
|
78
88
|
const store = new HunchStore(hunchPaths(root));
|
|
79
89
|
// Two-way sync (read side): pull the private overlay's remote on startup, so THIS machine's
|
|
80
90
|
// session sees memory captured on other machines/worktrees before we index — making the
|
|
@@ -391,10 +401,11 @@ export function buildServer(root) {
|
|
|
391
401
|
`re-record with supersedes:<id> to replace it (linked, same store), pick a distinct topic to split, or discard this capture.`);
|
|
392
402
|
}
|
|
393
403
|
}
|
|
394
|
-
// Route the write
|
|
395
|
-
// the
|
|
396
|
-
//
|
|
397
|
-
|
|
404
|
+
// Route the write to its ONE home (captureHome): an explicit private:true goes to
|
|
405
|
+
// the overlay (putPrivate throws rather than silently falling public); in unified
|
|
406
|
+
// ("shared") mode EVERY capture goes to the overlay; else the public store.
|
|
407
|
+
const home = store.captureHome(!!decision.private);
|
|
408
|
+
if (home === "private")
|
|
398
409
|
store.putPrivate("decisions", rec);
|
|
399
410
|
else
|
|
400
411
|
store.json.put("decisions", rec);
|
|
@@ -404,16 +415,15 @@ export function buildServer(root) {
|
|
|
404
415
|
// private overlay; a public one in the committed store. A private write never
|
|
405
416
|
// mutates the public store.
|
|
406
417
|
const superseded = decision.supersedes
|
|
407
|
-
? (
|
|
418
|
+
? (home === "private" ? store.supersedePrivate(decision.supersedes, rec) : store.supersede(decision.supersedes, rec))
|
|
408
419
|
: null;
|
|
409
420
|
store.reindex();
|
|
410
|
-
// Auto-flush the
|
|
411
|
-
// record
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
}
|
|
421
|
+
// Auto-flush the store the record landed in (on by default in every mode): a private
|
|
422
|
+
// record commits+pushes its overlay repo; a public one commits .hunch/ in THIS repo
|
|
423
|
+
// (commit only — it rides the user's next push, never auto-pushing their code branch).
|
|
424
|
+
const flush = flushCapture(store, hunchPaths(root).hunch, !!decision.private, `hunch: capture ${id}`);
|
|
425
|
+
const flushed = flush === "pushed" ? ` (committed + pushed to the ${store.mode === "shared" ? "shared team store" : "private repo"})`
|
|
426
|
+
: flush === "committed" ? " (auto-committed to .hunch/ — rides your next push)" : "";
|
|
417
427
|
// Capture-session gate (staged deprecation, §9.3): a token proves an interview
|
|
418
428
|
// preceded the write. No token still writes (non-breaking), but returns a nudge
|
|
419
429
|
// toward /capture so the un-interviewed bypass is visible, not silent. A token
|
|
@@ -426,7 +436,9 @@ export function buildServer(root) {
|
|
|
426
436
|
: "\n\n⚠ Recorded WITHOUT a capture interview. Prefer /capture (hunch_capture_decision), which grills the decision to a resolved state before writing — the graph should hold a well-examined decision, not a guess. (A future major version will require a capture token here.)";
|
|
427
437
|
const supNote = superseded ? ` Superseded ${superseded.id} (window closed at ${rec.valid_from}).` : "";
|
|
428
438
|
const note = decision.commit && !fullSha ? ` (note: commit "${decision.commit}" could not be resolved — recorded as a standalone decision, not linked to a commit)` : "";
|
|
429
|
-
const where = decision.private
|
|
439
|
+
const where = decision.private
|
|
440
|
+
? ` [PRIVATE overlay — not committed to this repo]${flushed}`
|
|
441
|
+
: home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
|
|
430
442
|
return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${supNote}${note}${captureNote}`);
|
|
431
443
|
}
|
|
432
444
|
catch (e) {
|
|
@@ -454,8 +466,9 @@ export function buildServer(root) {
|
|
|
454
466
|
const rec = buildCorrectionConstraint({ ...input, knownDeps: knownRepoDeps(root) }, new Date().toISOString());
|
|
455
467
|
// Private corrections go to the overlay (enforced locally via the merged read,
|
|
456
468
|
// never rendered into the public CI comment, which is public-only by construction).
|
|
457
|
-
const
|
|
458
|
-
|
|
469
|
+
const home = store.captureHome(!!input.private);
|
|
470
|
+
const existing = home === "private" ? undefined : store.json.get("constraints", rec.id);
|
|
471
|
+
if (home === "private")
|
|
459
472
|
store.putPrivate("constraints", rec);
|
|
460
473
|
else
|
|
461
474
|
store.json.put("constraints", rec);
|
|
@@ -464,17 +477,17 @@ export function buildServer(root) {
|
|
|
464
477
|
// Windsurf/AGENTS.md/CLAUDE.md), so a correction captured in one assistant is held
|
|
465
478
|
// by all of them. Public only — a private rule must never render into committed
|
|
466
479
|
// grounding. Refresh-only: it never scaffolds a doc the project opted out of.
|
|
467
|
-
if (
|
|
468
|
-
refreshExistingGrounding(root, store);
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
flushed = " (committed + pushed to the private repo)";
|
|
473
|
-
}
|
|
480
|
+
if (home === "public")
|
|
481
|
+
refreshExistingGrounding(root, store); // overlay rules never render into committed grounding
|
|
482
|
+
const flush = flushCapture(store, hunchPaths(root).hunch, !!input.private, `hunch: capture ${rec.id}`);
|
|
483
|
+
const flushed = flush === "pushed" ? ` (committed + pushed to the ${store.mode === "shared" ? "shared team store" : "private repo"})`
|
|
484
|
+
: flush === "committed" ? " (auto-committed to .hunch/ — rides your next push)" : "";
|
|
474
485
|
const enforce = rec.severity === "blocking"
|
|
475
486
|
? "blocks a DIRECT edit to its scope at strict firmness, and fails a PR whose diff touches that scope (CI guard); blast-radius hits and lower firmness stay advisory"
|
|
476
487
|
: "flags violating edits and PRs (advisory)";
|
|
477
|
-
const where = input.private
|
|
488
|
+
const where = input.private
|
|
489
|
+
? ` [PRIVATE overlay — not committed to this repo]${flushed}`
|
|
490
|
+
: home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
|
|
478
491
|
return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where} It now ${enforce}.`);
|
|
479
492
|
}
|
|
480
493
|
catch (e) {
|
package/dist/store/db.js
CHANGED
|
@@ -1,19 +1,57 @@
|
|
|
1
|
-
/** Thin wrapper around
|
|
2
|
-
import
|
|
1
|
+
/** Thin wrapper around node:sqlite for the derived index. */
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
3
|
import { mkdirSync } from "node:fs";
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
import { SCHEMA_SQL } from "./schema.js";
|
|
6
|
+
/** Load node:sqlite while swallowing ONLY its ExperimentalWarning (Node 22–24 still
|
|
7
|
+
* emits it on module load). Hunch's stderr reaches humans, hooks, and MCP clients on
|
|
8
|
+
* every invocation, so the noise would land everywhere; all other warnings pass through. */
|
|
9
|
+
function loadSqlite() {
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const realEmit = process.emitWarning.bind(process);
|
|
12
|
+
process.emitWarning = ((warning, ...rest) => {
|
|
13
|
+
if (String(warning).includes("SQLite is an experimental feature"))
|
|
14
|
+
return;
|
|
15
|
+
realEmit(warning, ...rest);
|
|
16
|
+
});
|
|
17
|
+
try {
|
|
18
|
+
return require("node:sqlite");
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
process.emitWarning = realEmit;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const sqlite = loadSqlite();
|
|
6
25
|
export function openDb(sqlitePath) {
|
|
7
26
|
mkdirSync(dirname(sqlitePath), { recursive: true });
|
|
8
|
-
const db = new
|
|
9
|
-
db.
|
|
27
|
+
const db = new sqlite.DatabaseSync(sqlitePath);
|
|
28
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
10
29
|
db.exec(SCHEMA_SQL);
|
|
11
30
|
return db;
|
|
12
31
|
}
|
|
13
32
|
/** In-memory db (tests / ephemeral queries). */
|
|
14
33
|
export function openMemoryDb() {
|
|
15
|
-
const db = new
|
|
34
|
+
const db = new sqlite.DatabaseSync(":memory:");
|
|
16
35
|
db.exec(SCHEMA_SQL);
|
|
17
36
|
return db;
|
|
18
37
|
}
|
|
38
|
+
/** Run `fn` inside one transaction: BEGIN → fn → COMMIT, ROLLBACK on throw.
|
|
39
|
+
* (node:sqlite has no better-sqlite3-style transaction() helper.) */
|
|
40
|
+
export function withTx(db, fn) {
|
|
41
|
+
db.exec("BEGIN");
|
|
42
|
+
try {
|
|
43
|
+
const out = fn();
|
|
44
|
+
db.exec("COMMIT");
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
try {
|
|
49
|
+
db.exec("ROLLBACK");
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
/* connection already rolled back */
|
|
53
|
+
}
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
19
57
|
//# sourceMappingURL=db.js.map
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -14,7 +14,7 @@ import { resolve, join } from "node:path";
|
|
|
14
14
|
import { existsSync, readFileSync } from "node:fs";
|
|
15
15
|
import { toPosixTarget, hunchPathsForDir } from "../core/paths.js";
|
|
16
16
|
import { ENTITY_KINDS } from "../core/types.js";
|
|
17
|
-
import { openDb } from "./db.js";
|
|
17
|
+
import { openDb, withTx } from "./db.js";
|
|
18
18
|
import { RESET_SQL, embedHash } from "./schema.js";
|
|
19
19
|
import { selectEmbedder } from "./embedder.js";
|
|
20
20
|
import { JsonStore } from "./jsonStore.js";
|
|
@@ -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
|
}
|
|
@@ -131,7 +188,7 @@ export class HunchStore {
|
|
|
131
188
|
reindex() {
|
|
132
189
|
const db = this.db;
|
|
133
190
|
const counts = {};
|
|
134
|
-
|
|
191
|
+
withTx(db, () => {
|
|
135
192
|
db.exec(RESET_SQL);
|
|
136
193
|
const j = (s) => s; // readability marker for JSON-encoded columns
|
|
137
194
|
// Prepare the FTS insert ONCE (after RESET created the table), not per row.
|
|
@@ -206,7 +263,6 @@ export class HunchStore {
|
|
|
206
263
|
counts.runbooks = runbooks.length;
|
|
207
264
|
void j;
|
|
208
265
|
});
|
|
209
|
-
tx();
|
|
210
266
|
// Reconcile embeddings AFTER the FTS rebuild (model-free): drop vectors whose
|
|
211
267
|
// source doc vanished or whose text changed. Embeddings are NOT in RESET_SQL,
|
|
212
268
|
// so this is what keeps them coherent across the many reindex() call sites.
|
|
@@ -260,14 +316,13 @@ export class HunchStore {
|
|
|
260
316
|
const rows = this.db.prepare(`SELECT ref, doc_hash FROM embeddings`).all();
|
|
261
317
|
const del = this.db.prepare(`DELETE FROM embeddings WHERE ref = ?`);
|
|
262
318
|
let pruned = 0;
|
|
263
|
-
|
|
319
|
+
withTx(this.db, () => {
|
|
264
320
|
for (const r of rows)
|
|
265
321
|
if (live.get(r.ref) !== r.doc_hash) {
|
|
266
322
|
del.run(r.ref);
|
|
267
323
|
pruned++;
|
|
268
324
|
}
|
|
269
325
|
});
|
|
270
|
-
tx();
|
|
271
326
|
return pruned;
|
|
272
327
|
}
|
|
273
328
|
/** Embedding coverage for a model: up-to-date vectors vs total docs (doctor). */
|
|
@@ -300,7 +355,7 @@ export class HunchStore {
|
|
|
300
355
|
for (let i = 0; i < todo.length; i += batchSize) {
|
|
301
356
|
const slice = todo.slice(i, i + batchSize);
|
|
302
357
|
const vecs = await embedder.embed(slice.map((d) => `${d.title}\n${d.body}`));
|
|
303
|
-
|
|
358
|
+
withTx(this.db, () => {
|
|
304
359
|
slice.forEach((d, j) => {
|
|
305
360
|
const v = vecs[j];
|
|
306
361
|
if (v) {
|
|
@@ -309,7 +364,6 @@ export class HunchStore {
|
|
|
309
364
|
}
|
|
310
365
|
});
|
|
311
366
|
});
|
|
312
|
-
tx();
|
|
313
367
|
attempted += slice.length;
|
|
314
368
|
opts.onProgress?.(attempted, todo.length);
|
|
315
369
|
}
|
|
@@ -1110,7 +1164,7 @@ function numEnv(name, dflt) {
|
|
|
1110
1164
|
}
|
|
1111
1165
|
/** Pack a vector's exact bytes for SQLite. Explicit offset+length so a SUBARRAY
|
|
1112
1166
|
* view (byteOffset != 0) writes only its slice, not the whole backing buffer.
|
|
1113
|
-
*
|
|
1167
|
+
* node:sqlite copies on bind, so the returned view never aliases the row. */
|
|
1114
1168
|
function vecToBlob(v) {
|
|
1115
1169
|
return Buffer.from(v.buffer, v.byteOffset, v.byteLength);
|
|
1116
1170
|
}
|
|
@@ -157,12 +157,9 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
157
157
|
},
|
|
158
158
|
date: meta.date, // the commit date
|
|
159
159
|
};
|
|
160
|
-
// Route to the
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
store.putPrivate("decisions", decision);
|
|
164
|
-
else
|
|
165
|
-
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);
|
|
166
163
|
return { status: "written", decision, provider: provider.name };
|
|
167
164
|
}
|
|
168
165
|
/** Capture a Bug from a test failure. Suspects are ranked churn×recency×fan-in. */
|
|
@@ -212,14 +209,14 @@ export async function recordFailure(store, root, failure) {
|
|
|
212
209
|
evidence: [`test:${failure.test}`, ...affectedFiles.slice(0, 6)],
|
|
213
210
|
},
|
|
214
211
|
};
|
|
215
|
-
store.
|
|
212
|
+
store.putCapture("bugs", bug);
|
|
216
213
|
// Promotion (DESIGN §4): a recurrence or a SUBSTANTIATED high-severity bug raises
|
|
217
214
|
// a regression Constraint to stop it coming back, and bumps fragility.
|
|
218
215
|
let constraint;
|
|
219
216
|
if (shouldPromoteConstraint(draft.severity, bug.root_cause, !!prior)) {
|
|
220
217
|
constraint = promoteConstraint(store, bug);
|
|
221
218
|
bug.lineage.spawned_constraint = constraint.id;
|
|
222
|
-
store.
|
|
219
|
+
store.putWhereItLives("bugs", bug); // re-persist with the link, in the same home
|
|
223
220
|
}
|
|
224
221
|
raiseFragility(store, affectedFiles);
|
|
225
222
|
return { status: "written", bug, constraint, provider: provider.name };
|
|
@@ -252,10 +249,10 @@ export async function captureTestRun(store, root, input) {
|
|
|
252
249
|
catch { /* not a git repo / no HEAD — leave null */ }
|
|
253
250
|
const fixed = [];
|
|
254
251
|
for (const name of report.passed) {
|
|
255
|
-
const b = store.
|
|
252
|
+
const b = store.getRec("bugs", bugId(name)); // a unified-mode bug lives in the overlay
|
|
256
253
|
if (b && b.status === "open") {
|
|
257
254
|
const resolved = { ...b, status: "fixed", lineage: { ...b.lineage, fixed_commit: sha } };
|
|
258
|
-
store.
|
|
255
|
+
store.putWhereItLives("bugs", resolved);
|
|
259
256
|
fixed.push(resolved);
|
|
260
257
|
}
|
|
261
258
|
}
|
|
@@ -293,7 +290,7 @@ function promoteConstraint(store, bug) {
|
|
|
293
290
|
valid_to: null,
|
|
294
291
|
provenance: { source: "derived", confidence: Math.min(0.9, bug.provenance.confidence + 0.2), evidence: [`bug:${bug.id}`] },
|
|
295
292
|
};
|
|
296
|
-
return store.
|
|
293
|
+
return store.putCapture("constraints", con);
|
|
297
294
|
}
|
|
298
295
|
/** Bump fragility on components owning the affected files. */
|
|
299
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": "1.0.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).",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"developer-tools"
|
|
36
36
|
],
|
|
37
37
|
"engines": {
|
|
38
|
-
"node": ">=
|
|
38
|
+
"node": ">=22.13.0"
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
41
|
"clean": "node --input-type=commonjs -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
@@ -48,15 +48,13 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
51
|
-
"better-sqlite3": "12.9.0",
|
|
52
51
|
"commander": "^15.0.0",
|
|
53
52
|
"tree-sitter": "0.21.1",
|
|
54
53
|
"tree-sitter-typescript": "^0.23.2",
|
|
55
54
|
"zod": "^4.4.3"
|
|
56
55
|
},
|
|
57
56
|
"devDependencies": {
|
|
58
|
-
"@types/
|
|
59
|
-
"@types/node": "^20.19.0",
|
|
57
|
+
"@types/node": "^22.13.0",
|
|
60
58
|
"tsx": "^4.22.4",
|
|
61
59
|
"typescript": "^5.9.3"
|
|
62
60
|
},
|