@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/README.md
CHANGED
|
@@ -98,6 +98,45 @@ 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
|
|
@@ -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
|
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";
|
|
@@ -50,6 +51,7 @@ import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpol
|
|
|
50
51
|
import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
|
|
51
52
|
import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
|
|
52
53
|
import { computeDrift } from "../core/drift.js";
|
|
54
|
+
import { topicCollisions, renderGrounding } from "../core/topics.js";
|
|
53
55
|
import { compareCandidates } from "../core/compare.js";
|
|
54
56
|
import { checkConformance } from "../core/conformance.js";
|
|
55
57
|
import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
|
|
@@ -81,7 +83,7 @@ program
|
|
|
81
83
|
.option("--firmness <level>", "agent-hook firmness: off | advisory | firm | strict")
|
|
82
84
|
.option("--private-sync", "post-commit synthesis writes captured decisions into the overlay repo (HUNCH_PRIVATE_DIR), never the public store")
|
|
83
85
|
.option("--shared-sync", "alias of --private-sync (for teams using one shared overlay repo for any code repo)")
|
|
84
|
-
.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)")
|
|
85
87
|
.action((opts) => {
|
|
86
88
|
// Validate --firmness up front, before any side effects (indexing, git hooks,
|
|
87
89
|
// .mcp.json) or opening the store — a bad value must not leave a half-init.
|
|
@@ -90,12 +92,18 @@ program
|
|
|
90
92
|
}
|
|
91
93
|
const root = findRoot();
|
|
92
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);
|
|
93
99
|
const store = new HunchStore(paths);
|
|
94
100
|
openStore = store; // so the top-level error handler closes it on failure
|
|
95
101
|
const inv = resolveInvocation();
|
|
96
102
|
console.log(`🧠 Initializing Hunch at ${root}`);
|
|
97
103
|
store.json.ensureDirs(); // stamps the manifest at the current version when fresh
|
|
98
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}`);
|
|
99
107
|
// Exclude the derived SQLite index BEFORE it's written, so the working tree
|
|
100
108
|
// never goes dirty on the MCP server's index writes (which blocks branch
|
|
101
109
|
// switches). The .hunch/*.json graph stays tracked.
|
|
@@ -109,10 +117,22 @@ program
|
|
|
109
117
|
if (res.skipped)
|
|
110
118
|
console.log(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
|
|
111
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
|
+
}
|
|
112
132
|
if (isGitRepo(root)) {
|
|
113
133
|
const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
|
|
114
134
|
const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit });
|
|
115
|
-
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" : ""}`);
|
|
116
136
|
const m = installMergeDriver(root, inv.shell);
|
|
117
137
|
console.log(` ✓ team merge driver ${m.action}`);
|
|
118
138
|
// Auto-install the pre-commit guard by default (advisory: flags invariants
|
|
@@ -163,7 +183,7 @@ program
|
|
|
163
183
|
// Worktree-seamless: register any configured overlay at the git common dir so EVERY
|
|
164
184
|
// worktree of this repo auto-discovers it (also backfills pre-0.32 single-worktree setups),
|
|
165
185
|
// and note when we're initializing inside a linked worktree (memory is shared, not separate).
|
|
166
|
-
if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit)) {
|
|
186
|
+
if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit, store.mode === "shared" ? "shared" : "private")) {
|
|
167
187
|
console.log(` ✓ private overlay registered at the git common dir — shared by every worktree of this repo`);
|
|
168
188
|
}
|
|
169
189
|
if (isLinkedWorktree(root)) {
|
|
@@ -270,7 +290,8 @@ program
|
|
|
270
290
|
.option("--force", "re-synthesize even if a decision already exists for the commit")
|
|
271
291
|
.option("--private", "write the synthesized decision into the configured overlay (HUNCH_PRIVATE_DIR), not the public store")
|
|
272
292
|
.option("--overlay", "alias of --private")
|
|
273
|
-
.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")
|
|
274
295
|
.option("--deep", "Deep Synthesis: ensemble every available subscription CLI and reconcile their drafts (agreement-weighted, advisory). Slower; subscription-only")
|
|
275
296
|
.option("--verify", "Critic pass: audit the draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra subscription call; advisory)")
|
|
276
297
|
.option("--samples <n>", "self-consistency depth when only one CLI is installed: sample it n times and reconcile (default 2 under --deep)")
|
|
@@ -278,7 +299,9 @@ program
|
|
|
278
299
|
const { store, root } = storeFor();
|
|
279
300
|
if (!isGitRepo(root))
|
|
280
301
|
return opts.quiet ? undefined : fail("sync needs a git repo");
|
|
281
|
-
|
|
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);
|
|
282
305
|
if (toOverlay && !store.hasPrivate) {
|
|
283
306
|
store.close();
|
|
284
307
|
return opts.quiet ? undefined : fail("--private/--overlay needs HUNCH_PRIVATE_DIR set to an overlay store");
|
|
@@ -295,16 +318,20 @@ program
|
|
|
295
318
|
if (healed.length && !opts.quiet)
|
|
296
319
|
console.log(` ↳ grounding refreshed: ${healed.join(", ")}`);
|
|
297
320
|
}
|
|
298
|
-
//
|
|
299
|
-
//
|
|
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
|
|
300
324
|
// just no-ops. Stage ONLY the hunch dir (never sweep unrelated working-tree
|
|
301
325
|
// changes), and set HUNCH_SYNC=1 so the commit we create can't re-trigger this
|
|
302
|
-
// hook (no recursion
|
|
303
|
-
|
|
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;
|
|
304
331
|
if (commitTarget) {
|
|
305
|
-
commitAndPushHunch(commitTarget, `hunch: capture ${r.decision?.id ?? "decision"}
|
|
332
|
+
commitAndPushHunch(commitTarget, `hunch: capture ${r.decision?.id ?? "decision"}`, { push: toOverlay });
|
|
306
333
|
if (!opts.quiet)
|
|
307
|
-
console.log(` ↳ committed + pushed ${r.decision?.id} (${commitTarget})`);
|
|
334
|
+
console.log(` ↳ committed ${toOverlay ? "+ pushed " : ""}${r.decision?.id} (${commitTarget}${toOverlay ? "" : " — rides your next push"})`);
|
|
308
335
|
}
|
|
309
336
|
if (!opts.quiet)
|
|
310
337
|
console.log(`✓ captured decision ${r.decision?.id} via ${r.provider}: "${r.decision?.title}"`);
|
|
@@ -330,17 +357,44 @@ function configureOverlay(dir, opts, mode) {
|
|
|
330
357
|
}
|
|
331
358
|
// 1) resolve the overlay store's hunch dir (holds decisions/, bugs/, …)
|
|
332
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);
|
|
333
364
|
if (opts.repo) {
|
|
334
|
-
const dest = join(
|
|
365
|
+
const dest = join(anchor, ".hunch-private");
|
|
335
366
|
if (!existsSync(dest)) {
|
|
336
367
|
const r = spawnSync("git", ["clone", opts.repo, dest], { stdio: "inherit" });
|
|
337
368
|
if (r.status !== 0)
|
|
338
369
|
return fail(`git clone failed for ${opts.repo}`);
|
|
339
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
|
+
}
|
|
340
394
|
hunchDir = join(dest, ".hunch");
|
|
341
395
|
}
|
|
342
396
|
else {
|
|
343
|
-
hunchDir = dir ? resolve(root, dir) : join(
|
|
397
|
+
hunchDir = dir ? resolve(root, dir) : join(anchor, ".hunch-private", ".hunch");
|
|
344
398
|
}
|
|
345
399
|
// 2) create the layout (decisions/, manifest, …) so it's queryable immediately
|
|
346
400
|
new JsonStore(hunchPathsForDir(hunchDir)).ensureDirs();
|
|
@@ -366,14 +420,23 @@ function configureOverlay(dir, opts, mode) {
|
|
|
366
420
|
// a store elsewhere on disk. Resolution (env || local.json) re-resolves against root.
|
|
367
421
|
const rel = relative(root, hunchDir);
|
|
368
422
|
const stored = rel && !rel.startsWith("..") && !isAbsolute(rel) ? toPosixTarget(rel) : hunchDir;
|
|
369
|
-
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");
|
|
370
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
|
+
}
|
|
371
434
|
// Also register the overlay at the SHARED git common dir, so EVERY worktree of this repo
|
|
372
435
|
// (current + future, any branch) auto-discovers the same memory with zero per-worktree
|
|
373
436
|
// setup. Stored ABSOLUTE — a linked worktree resolves relative paths from its OWN root, so
|
|
374
437
|
// only an absolute path survives the move. Lives under .git/ (never tracked; nothing to ignore).
|
|
375
438
|
let worktreeNote = "";
|
|
376
|
-
if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit)) {
|
|
439
|
+
if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit, mode)) {
|
|
377
440
|
worktreeNote = " ✓ registered in the git common dir — shared by every worktree of this repo, on any branch\n";
|
|
378
441
|
}
|
|
379
442
|
// 4) route post-commit synthesis to the overlay (local hook, never committed)
|
|
@@ -413,10 +476,11 @@ function configureOverlay(dir, opts, mode) {
|
|
|
413
476
|
: `✓ shared overlay enabled → ${hunchDir}\n`;
|
|
414
477
|
const tail = mode === "private"
|
|
415
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."
|
|
416
|
-
: "
|
|
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.";
|
|
417
480
|
console.log(lead +
|
|
418
481
|
" ✓ recorded in .hunch/local.json (gitignored) — auto-detected, no env var or shell-profile edit\n" +
|
|
419
482
|
worktreeNote +
|
|
483
|
+
teamNote +
|
|
420
484
|
hookNote +
|
|
421
485
|
migrateNote +
|
|
422
486
|
tail);
|
|
@@ -692,10 +756,7 @@ program
|
|
|
692
756
|
provenance: { source: "extracted", confidence: 0.5, evidence: [range] },
|
|
693
757
|
date: now,
|
|
694
758
|
};
|
|
695
|
-
|
|
696
|
-
store.putPrivate("runbooks", rec);
|
|
697
|
-
else
|
|
698
|
-
store.json.put("runbooks", rec);
|
|
759
|
+
store.putCapture("runbooks", rec, opts.private);
|
|
699
760
|
store.reindex();
|
|
700
761
|
console.log(`✓ runbook ${rec.id} — "${rec.task}" (${rec.steps.length} steps, ${rec.files.length} files)${opts.private ? " [private overlay]" : ""}`);
|
|
701
762
|
console.log(dim(" advisory, deterministic draft — refine the steps/gotchas; surfaced via `hunch query` and MCP."));
|
|
@@ -721,7 +782,7 @@ program
|
|
|
721
782
|
const id = decisionId(`inline:${it.file}:${it.text}`);
|
|
722
783
|
const prev = store.recs("decisions").find((d) => d.id === id); // preserve window for idempotent re-capture
|
|
723
784
|
const rec = {
|
|
724
|
-
id, title: it.text, status: "accepted",
|
|
785
|
+
id, title: it.text, topic: prev?.topic ?? null, status: "accepted",
|
|
725
786
|
context: `Captured from an inline hunch-why comment (${it.file}:${it.line}).`,
|
|
726
787
|
decision: it.text, consequences: [], alternatives_rejected: [], rejected_tripwires: [],
|
|
727
788
|
related_components: [], related_files: [it.file], supersedes: null, superseded_by: null,
|
|
@@ -729,10 +790,7 @@ program
|
|
|
729
790
|
retired: { symbols: [], deps: [] },
|
|
730
791
|
provenance: { source: "human_confirmed", confidence: 0.9, evidence: ev }, date: prev?.date ?? now,
|
|
731
792
|
};
|
|
732
|
-
|
|
733
|
-
store.putPrivate("decisions", rec);
|
|
734
|
-
else
|
|
735
|
-
store.json.put("decisions", rec);
|
|
793
|
+
store.putCapture("decisions", rec, opts.private);
|
|
736
794
|
dec++;
|
|
737
795
|
}
|
|
738
796
|
else {
|
|
@@ -748,16 +806,13 @@ program
|
|
|
748
806
|
valid_from: prev?.valid_from ?? now, valid_to: null,
|
|
749
807
|
provenance: { source: "human_confirmed", confidence: 0.9, evidence: ev },
|
|
750
808
|
};
|
|
751
|
-
|
|
752
|
-
store.putPrivate("constraints", rec);
|
|
753
|
-
else
|
|
754
|
-
store.json.put("constraints", rec);
|
|
809
|
+
store.putCapture("constraints", rec, opts.private);
|
|
755
810
|
con++;
|
|
756
811
|
}
|
|
757
812
|
}
|
|
758
813
|
store.reindex();
|
|
759
|
-
if (
|
|
760
|
-
|
|
814
|
+
if (dec || con)
|
|
815
|
+
flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${dec + con} inline intent(s)`);
|
|
761
816
|
if (!intents.length)
|
|
762
817
|
console.log("No `hunch-why:` / `hunch-rule:` comments found.");
|
|
763
818
|
else
|
|
@@ -789,7 +844,7 @@ program
|
|
|
789
844
|
store.json.ensureDirs();
|
|
790
845
|
const now = new Date().toISOString();
|
|
791
846
|
const arrow = opts.assert.startsWith("not-") ? "↛" : "→";
|
|
792
|
-
const d = store.
|
|
847
|
+
const d = store.putCapture("decisions", {
|
|
793
848
|
id: decisionId(`conform:${opts.add}:${opts.subject}:${opts.object ?? ""}`),
|
|
794
849
|
title: opts.add,
|
|
795
850
|
status: "accepted",
|
|
@@ -1022,7 +1077,7 @@ program
|
|
|
1022
1077
|
forbids = deriveForbids(statement, deps.length ? deps : undefined);
|
|
1023
1078
|
derived = !!forbids;
|
|
1024
1079
|
}
|
|
1025
|
-
const c = store.
|
|
1080
|
+
const c = store.putCapture("constraints", {
|
|
1026
1081
|
id: constraintId(statement),
|
|
1027
1082
|
type: opts.type,
|
|
1028
1083
|
statement,
|
|
@@ -1307,7 +1362,7 @@ vetoCmd
|
|
|
1307
1362
|
if ((d.rejected_tripwires?.length ?? 0) > 0)
|
|
1308
1363
|
continue; // never clobber existing tripwires
|
|
1309
1364
|
const tws = draftTripwires(d.alternatives_rejected, d.related_files, knownDeps);
|
|
1310
|
-
store.
|
|
1365
|
+
store.putWhereItLives("decisions", { ...d, rejected_tripwires: tws });
|
|
1311
1366
|
drafted += tws.length;
|
|
1312
1367
|
touched++;
|
|
1313
1368
|
}
|
|
@@ -1534,6 +1589,11 @@ program
|
|
|
1534
1589
|
const items = retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ");
|
|
1535
1590
|
text += `\n\n⚠ Deliberately RETIRED from this file — do not re-introduce without cause: ${items}.`;
|
|
1536
1591
|
}
|
|
1592
|
+
// Decision-grounding (§3): for topic-anchored decisions governing this file, state
|
|
1593
|
+
// the current decision assertively (graph over any stale doc) + what it rejected.
|
|
1594
|
+
const grounding = renderGrounding(ctx.decisions);
|
|
1595
|
+
if (grounding)
|
|
1596
|
+
text += `\n\n${grounding}`;
|
|
1537
1597
|
emitContext("PreToolUse", text);
|
|
1538
1598
|
}
|
|
1539
1599
|
catch {
|
|
@@ -1563,7 +1623,7 @@ function acceptDecision(store, d) {
|
|
|
1563
1623
|
last_verified: now,
|
|
1564
1624
|
},
|
|
1565
1625
|
}));
|
|
1566
|
-
store.
|
|
1626
|
+
store.putWhereItLives("decisions", { ...d, status: "accepted", rejected_tripwires: confirmedTws, provenance: { ...d.provenance, source, confidence: 0.95, last_verified: now } });
|
|
1567
1627
|
const armed = confirmedTws.filter((tw) => tw.forbids.deps.length || tw.forbids.symbols.length || tw.forbids.patterns.length).length;
|
|
1568
1628
|
return { source, armed };
|
|
1569
1629
|
}
|
|
@@ -1680,6 +1740,80 @@ program
|
|
|
1680
1740
|
}
|
|
1681
1741
|
store.close();
|
|
1682
1742
|
});
|
|
1743
|
+
// ---- reconcile-topics (decision-grounding §4 Enforcement) -----------------
|
|
1744
|
+
program
|
|
1745
|
+
.command("reconcile-topics")
|
|
1746
|
+
.description("Find topics with more than one live decision (the invariant a git merge can violate) and surface them for human resolution. Exits non-zero if any collision exists — wire into a post-merge hook or CI.")
|
|
1747
|
+
.action(() => {
|
|
1748
|
+
const { store } = storeFor();
|
|
1749
|
+
try {
|
|
1750
|
+
const collisions = topicCollisions(store.recs("decisions"));
|
|
1751
|
+
if (collisions.size === 0) {
|
|
1752
|
+
console.log("✓ No topic collisions — every topic has at most one live decision.");
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
console.error(`⚠ ${collisions.size} topic(s) have more than one live decision — the graph cannot say which is current. Resolve each (supersede one, or split the topic):\n`);
|
|
1756
|
+
for (const [topic, decs] of collisions) {
|
|
1757
|
+
console.error(` topic "${topic}":`);
|
|
1758
|
+
for (const d of decs)
|
|
1759
|
+
console.error(` - ${d.id} — "${d.title}" (${d.status})`);
|
|
1760
|
+
}
|
|
1761
|
+
console.error(`\nResolve: re-record one with supersedes:<other-id> to link it over the other, or give one a distinct topic to split.`);
|
|
1762
|
+
process.exitCode = 1;
|
|
1763
|
+
}
|
|
1764
|
+
finally {
|
|
1765
|
+
store.close();
|
|
1766
|
+
}
|
|
1767
|
+
});
|
|
1768
|
+
// ---- drift (doc≠graph detector; advisory + CI-gateable) -------------------
|
|
1769
|
+
program
|
|
1770
|
+
.command("drift")
|
|
1771
|
+
.description("Detect memory drift: dead refs, dangling supersedes, stale 'proposed' docs, and doc≠graph anchor-stale (a file still anchored to a superseded decision). Exits non-zero on any anchor-stale drift or topic collision — the doc≠graph gate.")
|
|
1772
|
+
.action(() => {
|
|
1773
|
+
const { store, root } = storeFor();
|
|
1774
|
+
try {
|
|
1775
|
+
const { findings } = computeDrift(store, root);
|
|
1776
|
+
const collisions = topicCollisions(store.recs("decisions"));
|
|
1777
|
+
if (!findings.length && collisions.size === 0) {
|
|
1778
|
+
console.log("✓ No drift — memory is in sync with the code/docs.");
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
for (const f of findings.slice(0, 50))
|
|
1782
|
+
console.log(`· [${f.kind}] ${f.id} — ${f.detail}`);
|
|
1783
|
+
for (const [topic, decs] of collisions)
|
|
1784
|
+
console.log(`· [topic-collision] "${topic}" has ${decs.length} live decisions: ${decs.map((d) => d.id).join(", ")} — run \`hunch reconcile-topics\``);
|
|
1785
|
+
const anchor = findings.filter((f) => f.kind === "anchor-stale").length;
|
|
1786
|
+
console.log(`\n${findings.length} finding(s)${anchor ? `, ${anchor} doc≠graph (anchor-stale)` : ""}${collisions.size ? `, ${collisions.size} topic-collision(s)` : ""}.`);
|
|
1787
|
+
if (anchor || collisions.size)
|
|
1788
|
+
process.exitCode = 1;
|
|
1789
|
+
}
|
|
1790
|
+
finally {
|
|
1791
|
+
store.close();
|
|
1792
|
+
}
|
|
1793
|
+
});
|
|
1794
|
+
// ---- heal (decision-grounded drift reconciliation front door) -------------
|
|
1795
|
+
program
|
|
1796
|
+
.command("heal")
|
|
1797
|
+
.description("Decision-grounded drift reconciliation: report doc≠graph anchor-stale sections with the current decision to reconcile toward. Read-only — proposes, never rewrites. Escalate to /capture only if the DECISION (not the doc) is stale.")
|
|
1798
|
+
.action(() => {
|
|
1799
|
+
const { store, root } = storeFor();
|
|
1800
|
+
try {
|
|
1801
|
+
const anchor = computeDrift(store, root).findings.filter((f) => f.kind === "anchor-stale");
|
|
1802
|
+
if (!anchor.length) {
|
|
1803
|
+
console.log("✓ No doc≠graph drift to heal — every anchored view matches its current decision.");
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
console.log(`${anchor.length} anchored section(s) drifted from the graph:\n`);
|
|
1807
|
+
for (const f of anchor)
|
|
1808
|
+
console.log(`· ${f.detail}`);
|
|
1809
|
+
console.log(`\nHeal A (doc stale): edit each file to match its CURRENT decision — a prose fix.`);
|
|
1810
|
+
console.log(`Heal B (decision stale): only if the DECISION is wrong now, run /capture (hunch_capture_decision) to supersede it, then re-derive the doc.`);
|
|
1811
|
+
console.log(`Hunch never rewrites prose for you; this is a read-only reconciliation report.`);
|
|
1812
|
+
}
|
|
1813
|
+
finally {
|
|
1814
|
+
store.close();
|
|
1815
|
+
}
|
|
1816
|
+
});
|
|
1683
1817
|
// ---- compact (bound Hunch growth) -----------------------------------------
|
|
1684
1818
|
program
|
|
1685
1819
|
.command("compact")
|
|
@@ -1776,9 +1910,24 @@ program
|
|
|
1776
1910
|
}
|
|
1777
1911
|
const c = store.reindex().counts;
|
|
1778
1912
|
console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
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
|
+
}
|
|
1782
1931
|
// Worktree posture: linked worktrees share ONE memory via the git common dir. Only
|
|
1783
1932
|
// surfaced in a linked worktree (no noise in a normal single checkout), so a
|
|
1784
1933
|
// "memory missing here" symptom has an obvious cause + fix.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture-session tokens (decision-grounding, DESIGN §5 Stage 1 / §9.3).
|
|
3
|
+
*
|
|
4
|
+
* hunch_capture_decision issues a short-lived token; the commit path consumes it, so a
|
|
5
|
+
* decision written through the capture front door is provably the tail of an interview
|
|
6
|
+
* — the identity-principle guard against a silent, un-interviewed write. In-memory (the
|
|
7
|
+
* MCP server is long-lived); tokens are one-time-use and expire so an abandoned
|
|
8
|
+
* interview can't leak. Absence of a token never BLOCKS a write yet (staged
|
|
9
|
+
* deprecation §9.3) — the caller decides how to treat an un-gated write.
|
|
10
|
+
*/
|
|
11
|
+
const CAPTURE_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 min
|
|
12
|
+
const sessions = new Map(); // token -> issuedAt (epoch ms)
|
|
13
|
+
/** Issue a token stamped `now` (epoch ms). Prunes expired tokens first so the map can't
|
|
14
|
+
* grow unbounded across a long server life. `mint` supplies the random id (injectable
|
|
15
|
+
* for tests); the call site passes crypto.randomUUID. */
|
|
16
|
+
export function issueCaptureToken(mint, now) {
|
|
17
|
+
for (const [tok, at] of sessions)
|
|
18
|
+
if (now - at > CAPTURE_TOKEN_TTL_MS)
|
|
19
|
+
sessions.delete(tok);
|
|
20
|
+
const token = mint();
|
|
21
|
+
sessions.set(token, now);
|
|
22
|
+
return token;
|
|
23
|
+
}
|
|
24
|
+
/** Consume a token iff it is a live, unexpired capture session. One-time use: a second
|
|
25
|
+
* consume of the same token returns false. */
|
|
26
|
+
export function consumeCaptureToken(token, now) {
|
|
27
|
+
if (!token)
|
|
28
|
+
return false;
|
|
29
|
+
const at = sessions.get(token);
|
|
30
|
+
if (at === undefined)
|
|
31
|
+
return false;
|
|
32
|
+
sessions.delete(token);
|
|
33
|
+
return now - at <= CAPTURE_TOKEN_TTL_MS;
|
|
34
|
+
}
|
|
35
|
+
export { CAPTURE_TOKEN_TTL_MS };
|
|
36
|
+
//# sourceMappingURL=capturetoken.js.map
|
package/dist/core/drift.js
CHANGED
|
@@ -10,12 +10,19 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
12
12
|
import { join, extname } from "node:path";
|
|
13
|
+
import { toPosixTarget } from "./paths.js";
|
|
14
|
+
import { currentForTopic, isLive } from "./topics.js";
|
|
13
15
|
const STALE_MARKER = /\b(proposed|not yet implemented|no code yet)\b/i;
|
|
14
16
|
const SRC_REF = /\bsrc\/[A-Za-z0-9_\-/]+\.ts\b/g;
|
|
15
17
|
export function computeDrift(store, root) {
|
|
16
18
|
const findings = [];
|
|
17
19
|
const decisions = store.recs("decisions");
|
|
18
20
|
const byId = new Map(decisions.map((d) => [d.id, d]));
|
|
21
|
+
// Files any LIVE decision (any topic) still claims. A file governed by a live decision
|
|
22
|
+
// is NOT orphaned to a stale one — only a file listed solely by superseded decisions is
|
|
23
|
+
// anchor-stale. Keeps the doc≠graph gate's false-positive rate ~zero: a routine
|
|
24
|
+
// narrowing supersession (successor lists fewer files) never flags files still governed.
|
|
25
|
+
const liveFiles = new Set(decisions.filter(isLive).flatMap((d) => (d.related_files ?? []).map(toPosixTarget)));
|
|
19
26
|
for (const d of decisions) {
|
|
20
27
|
// 1. DEAD-REFERENCE — only for in-force decisions; a superseded one referencing
|
|
21
28
|
// a since-deleted file is legitimate history, not drift.
|
|
@@ -44,6 +51,26 @@ export function computeDrift(store, root) {
|
|
|
44
51
|
});
|
|
45
52
|
}
|
|
46
53
|
}
|
|
54
|
+
// 4. ANCHOR-STALE (doc≠graph, decision-grounding) — a derived view still anchored
|
|
55
|
+
// to a SUPERSEDED decision while a current one exists for the same topic. Fully
|
|
56
|
+
// deterministic: fires only on the explicit topic anchor + a live successor
|
|
57
|
+
// (never a semantic guess), and only for a file NO live decision claims. Advisory.
|
|
58
|
+
if (d.topic && (d.status === "superseded" || d.superseded_by)) {
|
|
59
|
+
const current = currentForTopic(decisions, d.topic);
|
|
60
|
+
if (current && current.id !== d.id) {
|
|
61
|
+
for (const f of d.related_files ?? []) {
|
|
62
|
+
if (!f || f.includes("*") || liveFiles.has(toPosixTarget(f)))
|
|
63
|
+
continue;
|
|
64
|
+
if (!existsSync(join(root, f)))
|
|
65
|
+
continue; // missing file is history → dead-ref's job
|
|
66
|
+
findings.push({
|
|
67
|
+
kind: "anchor-stale",
|
|
68
|
+
id: d.id,
|
|
69
|
+
detail: `"${f}" is anchored to superseded decision ${d.id} (topic "${d.topic}"); the current decision is ${current.id} — "${current.title}". Reconcile the file with the current decision.`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
47
74
|
}
|
|
48
75
|
// 3. DOC-STALE — a doc that still advertises "proposed / not implemented" while
|
|
49
76
|
// referencing code that exists. Heuristic + advisory; scoped to the repo's own
|