@davesheffer/hunch 1.9.2 → 1.9.4

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 CHANGED
@@ -16,10 +16,9 @@ strict enforcement.
16
16
  **Memory is the input. The product boundary is the receipt:** relevant evidence before an edit,
17
17
  then a deterministic check of the change against the rules your team has explicitly trusted.
18
18
 
19
- > **New in v1.9.2:** Matrix mode gives the whole team one live, private Git memory across fresh
20
- > clones, worktrees, CLI checks, and MCP assistants. npm publishes with short-lived OIDC
21
- > credentials, while the editor companion ships as one immutable, publicly verified Open VSX
22
- > artifact.
19
+ > **New in v1.9.4:** MCP connections stay collision-safe across repositories and simultaneous
20
+ > captures, while generated MCP, hook, plugin, and CI commands pin the exact npm release that
21
+ > created them.
23
22
 
24
23
  ## Start in five minutes
25
24
 
@@ -82,7 +81,7 @@ Git repo that every teammate can access, install the Matrix release on team mach
82
81
  have one maintainer run:
83
82
 
84
83
  ```bash
85
- npm i -g @davesheffer/hunch@1.9.2
84
+ npm i -g @davesheffer/hunch@1.9.4
86
85
  hunch shared --repo git@github.com:acme/project-hunch-memory.git
87
86
  git add .gitignore .hunch/team.json
88
87
  git commit -m "chore: connect shared Hunch memory"
@@ -97,7 +96,7 @@ printed by Hunch. Omit `--migrate` for a new setup.
97
96
  After the pointer commit lands, teammates need Hunch installed and Git access to the memory repo:
98
97
 
99
98
  ```bash
100
- npm i -g @davesheffer/hunch@1.9.2
99
+ npm i -g @davesheffer/hunch@1.9.4
101
100
  git pull
102
101
  hunch init
103
102
  hunch doctor
@@ -6,11 +6,16 @@
6
6
  * same import-safety to be unit-testable. */
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { probeOllamaNumCtx } from "../synthesis/provider.js";
9
- /** Published package name used for OS-agnostic invocations (see below). */
10
- const PKG = "@davesheffer/hunch";
9
+ import { HUNCH_NPX_PACKAGE_SPEC } from "../core/version.js";
11
10
  export function dim(s) {
12
11
  return `\x1b[2m${s}\x1b[0m`;
13
12
  }
13
+ /** Portable invocation written into committed MCP/provider configuration.
14
+ * Keep it independently testable so the distribution pin cannot silently
15
+ * regress to npm's moving latest tag. */
16
+ export function publishedMcpInvocation() {
17
+ return { command: "npx", args: ["-y", `--package=${HUNCH_NPX_PACKAGE_SPEC}`, "hunch"] };
18
+ }
14
19
  /** The doctor command's synthesis-status line(s) for a resolved provider.
15
20
  * Exported for testing — the previous version (a bare provider-name switch,
16
21
  * before the resolveSynthesisProvider preference system existed) had zero
@@ -65,15 +70,16 @@ export function resolveInvocation() {
65
70
  // Running from an installed copy (global, local, or npx cache — i.e. NOT a
66
71
  // source checkout we're hacking on). The MCP/provider config files we write
67
72
  // are committed and shared across a team via git, so they must NOT embed this
68
- // machine's absolute path or OS-specific separators. Reference Hunch by its
69
- // published package name instead, which `npx` resolves the same on any OS and
70
- // any clone. The git hook lives in per-machine .git/hooks (never committed),
71
- // so it keeps the PATH-robust absolute-node invocation below.
73
+ // machine's absolute path or OS-specific separators. Reference the exact
74
+ // published Hunch package instead, which `npx` resolves the same on any OS
75
+ // and any clone without floating to a newer release. The git hook lives in
76
+ // per-machine .git/hooks (never committed), so it keeps the PATH-robust
77
+ // absolute-node invocation below.
72
78
  const installed = !isDev && entry.replace(/\\/g, "/").includes("/node_modules/");
73
79
  if (installed) {
74
80
  return {
75
81
  shell: `${q(process.execPath)} ${q(entry)}`,
76
- mcp: { command: "npx", args: ["-y", PKG] },
82
+ mcp: publishedMcpInvocation(),
77
83
  };
78
84
  }
79
85
  if (isDev) {
@@ -53,16 +53,16 @@ export function hunchPathsForDir(hunchDir) {
53
53
  * WITHOUT `.hunch` stops the walk: an ancestor `.hunch` above the repo
54
54
  * boundary belongs to some other scope (e.g. a stray ~/.hunch) and must never
55
55
  * hijack a fresh repo — init would scaffold, index, and scan OUTSIDE the repo. */
56
+ export function isDir(path) {
57
+ try {
58
+ return statSync(path).isDirectory();
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
56
64
  export function findRoot(start = process.cwd()) {
57
65
  let cur = resolve(start);
58
- const isDir = (p) => {
59
- try {
60
- return statSync(p).isDirectory();
61
- }
62
- catch {
63
- return false;
64
- }
65
- };
66
66
  for (;;) {
67
67
  if (isDir(join(cur, HUNCH_DIR)))
68
68
  return cur; // a `.hunch` regular file is not a root
@@ -16,4 +16,13 @@ export const HUNCH_VERSION = (() => {
16
16
  return "0.0.0";
17
17
  }
18
18
  })();
19
+ /** Exact public npm package consumed by generated CI and shared MCP/provider
20
+ * configs. A floating package name would let one committed configuration run
21
+ * different Hunch semantics as npm's latest release changes. */
22
+ export const HUNCH_PACKAGE_SPEC = `@davesheffer/hunch@${HUNCH_VERSION}`;
23
+ /** npm alias used by npx launchers. Giving the fetched package a distinct local
24
+ * alias prevents npm exec from treating this repository (which has the same
25
+ * package name) as satisfying the request and then falling through to an older
26
+ * global `hunch` executable. */
27
+ export const HUNCH_NPX_PACKAGE_SPEC = `hunch-exact@npm:${HUNCH_PACKAGE_SPEC}`;
19
28
  //# sourceMappingURL=version.js.map
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { mkdirSync, writeFileSync, existsSync } from "node:fs";
10
10
  import { join } from "node:path";
11
- import { HUNCH_VERSION } from "../core/version.js";
11
+ import { HUNCH_PACKAGE_SPEC } from "../core/version.js";
12
12
  // `\${{ … }}` keeps GitHub Actions expressions literal inside this template
13
13
  // literal (a bare `${` would be JS interpolation).
14
14
  export function ciWorkflowYaml() {
@@ -44,7 +44,7 @@ jobs:
44
44
  # Pin the same release that generated this file so every assistant and CI
45
45
  # evaluate the graph with identical semantics. Dependabot/Renovate (or a
46
46
  # deliberate hunch-ci refresh) can advance this in a reviewed change.
47
- run: npm install -g @davesheffer/hunch@${HUNCH_VERSION}
47
+ run: npm install -g ${HUNCH_PACKAGE_SPEC}
48
48
 
49
49
  - name: Fetch the PR base branch
50
50
  # checkout sets up no origin/<base> tracking ref; create it explicitly so
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Resolve MCP client roots to the one repository this server may safely serve.
3
+ *
4
+ * A server process keeps the cwd it was spawned with, while the client can move to
5
+ * another workspace or linked worktree. MCP roots are the client-neutral protocol
6
+ * mechanism for following that change.
7
+ */
8
+ import { statSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { findRoot, HUNCH_DIR, isDir } from "../core/paths.js";
12
+ function toPath(uri) {
13
+ if (!uri.startsWith("file:"))
14
+ return "";
15
+ try {
16
+ return fileURLToPath(uri);
17
+ }
18
+ catch {
19
+ return "";
20
+ }
21
+ }
22
+ function rootStart(path) {
23
+ try {
24
+ const stat = statSync(path);
25
+ if (stat.isDirectory())
26
+ return path;
27
+ if (stat.isFile())
28
+ return dirname(path);
29
+ }
30
+ catch {
31
+ // Missing/inaccessible roots are unusable.
32
+ }
33
+ return "";
34
+ }
35
+ /**
36
+ * Returns null when several advertised repositories are equally plausible.
37
+ * The roots protocol exposes a set of URI/name pairs, not an "active root" bit;
38
+ * choosing the first Hunch store in that case could silently write repo B's
39
+ * decision into repo A.
40
+ */
41
+ export function resolveActiveRoot(rootUris, fallbackCwd) {
42
+ const candidates = [];
43
+ for (const uri of rootUris) {
44
+ const start = rootStart(toPath(uri));
45
+ if (!start)
46
+ continue;
47
+ const root = findRoot(start);
48
+ if (!candidates.includes(root))
49
+ candidates.push(root);
50
+ }
51
+ if (!candidates.length)
52
+ return findRoot(fallbackCwd);
53
+ if (candidates.length === 1)
54
+ return candidates[0];
55
+ const withStore = candidates.filter((candidate) => isDir(join(candidate, HUNCH_DIR)));
56
+ return withStore.length === 1 ? withStore[0] : null;
57
+ }
58
+ //# sourceMappingURL=roots.js.map
@@ -8,8 +8,10 @@
8
8
  */
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
+ import { RootsListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
11
12
  import { z } from "zod";
12
13
  import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
14
+ import { resolveActiveRoot } from "./roots.js";
13
15
  import { HunchStore } from "../store/hunchStore.js";
14
16
  import { selectEmbedder } from "../store/embedder.js";
15
17
  import { decisionId } from "../core/ids.js";
@@ -121,14 +123,41 @@ function resolveFiles(store, target) {
121
123
  const files = new Set(resolveSymbols(store, target).map((s) => s.file));
122
124
  return files.size ? [...files] : [toPosixTarget(target)];
123
125
  }
124
- export function buildServer(root) {
126
+ function pullBackoff(status, finishedAt, failures) {
127
+ if (status === "updated" || status === "current") {
128
+ return { consecutivePullFailures: 0, nextRemotePullAt: finishedAt + 1_000 };
129
+ }
130
+ if (status === "busy") {
131
+ return { consecutivePullFailures: failures, nextRemotePullAt: finishedAt + 100 };
132
+ }
133
+ if (status === "unconfigured") {
134
+ return { consecutivePullFailures: 0, nextRemotePullAt: finishedAt + 30_000 };
135
+ }
136
+ const consecutivePullFailures = Math.min(failures + 1, 6);
137
+ return {
138
+ consecutivePullFailures,
139
+ nextRemotePullAt: finishedAt + Math.min(30_000, 1_000 * (2 ** (consecutivePullFailures - 1))),
140
+ };
141
+ }
142
+ function rebuildFreshIndex(store) {
143
+ for (let attempt = 0; attempt < 2; attempt++) {
144
+ const before = store.sourceStamp();
145
+ store.reindexFresh();
146
+ const after = store.sourceStamp();
147
+ if (before === after)
148
+ return after;
149
+ }
150
+ return undefined;
151
+ }
152
+ /** Prepare and validate a complete root context before publishing it to handlers.
153
+ * A failed re-home therefore leaves the previous graph fully active. */
154
+ function prepareRoot(root, explicitOverlay, requireIndex) {
125
155
  // Team auto-discovery: a committed .hunch/team.json advertises the shared store — a
126
156
  // fresh clone (a new teammate, a headless agent, a CI workflow) wires itself BEFORE the
127
157
  // store is constructed, so every consumer resolves the same single source of truth.
128
158
  // Once that declaration is present it is fail-closed: starting against the public
129
159
  // graph after an invalid config, failed first clone, or dead pointer would let both
130
160
  // reads and writes silently escape the team's memory spine.
131
- const explicitOverlay = !!process.env.HUNCH_PRIVATE_DIR?.trim();
132
161
  const teamFile = join(hunchPaths(root).hunch, "team.json");
133
162
  const teamAdvertised = !explicitOverlay && existsSync(teamFile);
134
163
  const startupTeamConfig = teamAdvertised ? readTeamConfig(root) : null;
@@ -137,18 +166,70 @@ export function buildServer(root) {
137
166
  }
138
167
  ensureTeamOverlay(root);
139
168
  const store = new HunchStore(hunchPaths(root));
140
- if (teamAdvertised && (store.mode !== "shared"
141
- || !store.privateDir
142
- || !existsSync(store.privateDir)
143
- || !overlayMatchesTeamRemote(root, join(store.privateDir, "..")))) {
169
+ try {
170
+ if (teamAdvertised && (store.mode !== "shared"
171
+ || !store.privateDir
172
+ || !existsSync(store.privateDir)
173
+ || !overlayMatchesTeamRemote(root, join(store.privateDir, "..")))) {
174
+ throw new Error("the advertised team memory store is unavailable or tracks a different remote; refusing to start MCP on another graph");
175
+ }
176
+ const startupTeamRoute = teamAdvertised && store.privateDir
177
+ ? teamRemoteContract(root, join(store.privateDir, ".."))
178
+ : null;
179
+ if (startupTeamRoute)
180
+ pinSharedRemote(store, startupTeamRoute);
181
+ let nextRemotePullAt = 0;
182
+ let consecutivePullFailures = 0;
183
+ if (store.privateDir) {
184
+ try {
185
+ const status = pullHunchStatus(store.privateDir, {
186
+ timeoutMs: 5_000,
187
+ remote: startupTeamRoute ?? advertisedTeamRemoteContract(root, join(store.privateDir, "..")),
188
+ });
189
+ ({ nextRemotePullAt, consecutivePullFailures } = pullBackoff(status, Date.now(), 0));
190
+ }
191
+ catch {
192
+ // Offline / no remote — proceed with the validated local store.
193
+ }
194
+ }
195
+ let indexedSourceStamp;
196
+ try {
197
+ indexedSourceStamp = rebuildFreshIndex(store);
198
+ }
199
+ catch (error) {
200
+ if (requireIndex)
201
+ throw error;
202
+ console.error("[hunch-mcp] reindex on startup failed:", error.message);
203
+ }
204
+ return {
205
+ root,
206
+ teamFile,
207
+ teamAdvertised,
208
+ startupTeamConfig,
209
+ startupTeamRoute,
210
+ store,
211
+ nextRemotePullAt,
212
+ consecutivePullFailures,
213
+ indexedSourceStamp,
214
+ };
215
+ }
216
+ catch (error) {
144
217
  store.close();
145
- throw new Error("the advertised team memory store is unavailable or tracks a different remote; refusing to start MCP on another graph");
218
+ throw error;
146
219
  }
147
- const startupTeamRoute = teamAdvertised && store.privateDir
148
- ? teamRemoteContract(root, join(store.privateDir, ".."))
149
- : null;
150
- if (startupTeamRoute)
151
- pinSharedRemote(store, startupTeamRoute);
220
+ }
221
+ export function buildServerWithRootControl(initialRoot) {
222
+ const explicitOverlay = !!process.env.HUNCH_PRIVATE_DIR?.trim();
223
+ const initial = prepareRoot(initialRoot, explicitOverlay, false);
224
+ let root = initial.root;
225
+ let teamFile = initial.teamFile;
226
+ let teamAdvertised = initial.teamAdvertised;
227
+ let startupTeamConfig = initial.startupTeamConfig;
228
+ let startupTeamRoute = initial.startupTeamRoute;
229
+ let store = initial.store;
230
+ let nextRemotePullAt = initial.nextRemotePullAt;
231
+ let consecutivePullFailures = initial.consecutivePullFailures;
232
+ let indexedSourceStamp = initial.indexedSourceStamp;
152
233
  const matchesStartupTeamRoute = () => {
153
234
  if (!teamAdvertised || !store.privateDir || !startupTeamConfig || !startupTeamRoute)
154
235
  return !teamAdvertised;
@@ -165,24 +246,9 @@ export function buildServer(root) {
165
246
  // session sees memory captured on other machines/worktrees before we index — making the
166
247
  // overlay genuinely one source of truth. Remote calls are bounded; request-time failures
167
248
  // back off exponentially instead of freezing every tool on the same unavailable remote.
168
- let nextRemotePullAt = 0;
169
- let consecutivePullFailures = 0;
170
249
  const notePull = (status, finishedAt) => {
171
- if (status === "updated" || status === "current") {
172
- consecutivePullFailures = 0;
173
- nextRemotePullAt = finishedAt + 1_000;
174
- }
175
- else if (status === "busy") {
176
- nextRemotePullAt = finishedAt + 100;
177
- }
178
- else if (status === "unconfigured") {
179
- consecutivePullFailures = 0;
180
- nextRemotePullAt = finishedAt + 30_000;
181
- }
182
- else {
183
- consecutivePullFailures = Math.min(consecutivePullFailures + 1, 6);
184
- nextRemotePullAt = finishedAt + Math.min(30_000, 1_000 * (2 ** (consecutivePullFailures - 1)));
185
- }
250
+ ({ nextRemotePullAt, consecutivePullFailures } =
251
+ pullBackoff(status, finishedAt, consecutivePullFailures));
186
252
  };
187
253
  const pullTeamMemory = (force = false) => {
188
254
  if (!store.privateDir)
@@ -195,86 +261,146 @@ export function buildServer(root) {
195
261
  remote: startupTeamRoute ?? advertisedTeamRemoteContract(root, join(store.privateDir, "..")),
196
262
  }), Date.now());
197
263
  };
198
- if (store.privateDir) {
199
- try {
200
- pullTeamMemory(true);
201
- }
202
- catch { /* offline / no remote — proceed with local */ }
203
- }
204
264
  // A source stamp is acknowledged ONLY after a stable, successful rebuild. If
205
265
  // another process changes the atomic JSON tree during the rebuild, retry once;
206
266
  // continued churn leaves the marker unset so the next request tries again.
207
- let indexedSourceStamp;
208
267
  const refreshIndex = () => {
209
- for (let attempt = 0; attempt < 2; attempt++) {
210
- const before = store.sourceStamp();
211
- store.reindexFresh();
212
- const after = store.sourceStamp();
213
- if (before === after) {
214
- indexedSourceStamp = after;
215
- return;
216
- }
217
- }
218
- indexedSourceStamp = undefined;
268
+ indexedSourceStamp = rebuildFreshIndex(store);
219
269
  };
220
- // Ensure the SQLite index reflects the JSON source of truth on startup.
221
- try {
222
- refreshIndex();
223
- }
224
- catch (e) {
225
- console.error("[hunch-mcp] reindex on startup failed:", e.message);
226
- }
227
270
  // Resolve the embedder ONCE for this long-lived process (never throws; null when
228
271
  // the optional model isn't installed). The model then loads lazily on the first
229
272
  // hunch_query and stays warm — and hybridSearch degrades to FTS until then.
230
273
  const embedderReady = selectEmbedder();
231
274
  const server = new McpServer({ name: "hunch", version: HUNCH_VERSION });
275
+ let activeRequests = 0;
276
+ let pendingRoot = null;
277
+ let pendingScheduled = false;
278
+ let closed = false;
279
+ const activateRoot = (next) => {
280
+ const canonical = findRoot(next);
281
+ if (canonical === root)
282
+ return;
283
+ const prepared = prepareRoot(canonical, explicitOverlay, true);
284
+ const previous = store;
285
+ root = prepared.root;
286
+ teamFile = prepared.teamFile;
287
+ teamAdvertised = prepared.teamAdvertised;
288
+ startupTeamConfig = prepared.startupTeamConfig;
289
+ startupTeamRoute = prepared.startupTeamRoute;
290
+ store = prepared.store;
291
+ nextRemotePullAt = prepared.nextRemotePullAt;
292
+ consecutivePullFailures = prepared.consecutivePullFailures;
293
+ indexedSourceStamp = prepared.indexedSourceStamp;
294
+ previous.close();
295
+ console.error(`[hunch-mcp] serving Hunch at ${root} (client root)`);
296
+ };
297
+ const applyPendingRoot = () => {
298
+ pendingScheduled = false;
299
+ if (closed || activeRequests || !pendingRoot)
300
+ return;
301
+ const next = pendingRoot;
302
+ pendingRoot = null;
303
+ try {
304
+ activateRoot(next);
305
+ }
306
+ catch (error) {
307
+ console.error(`[hunch-mcp] client root change refused: ${error.message}`);
308
+ }
309
+ };
310
+ const schedulePendingRoot = () => {
311
+ if (closed || activeRequests || !pendingRoot || pendingScheduled)
312
+ return;
313
+ pendingScheduled = true;
314
+ queueMicrotask(applyPendingRoot);
315
+ };
316
+ const setRoot = (next) => {
317
+ if (closed)
318
+ throw new Error("MCP server is closed");
319
+ const canonical = findRoot(next);
320
+ if (canonical === root) {
321
+ pendingRoot = null;
322
+ return;
323
+ }
324
+ if (activeRequests) {
325
+ pendingRoot = canonical;
326
+ return;
327
+ }
328
+ pendingRoot = null;
329
+ activateRoot(canonical);
330
+ };
331
+ const dispose = () => {
332
+ if (closed)
333
+ return;
334
+ closed = true;
335
+ pendingRoot = null;
336
+ store.close();
337
+ };
338
+ const underlyingClose = server.close.bind(server);
339
+ server.close = async () => {
340
+ try {
341
+ await underlyingClose();
342
+ }
343
+ finally {
344
+ dispose();
345
+ }
346
+ };
347
+ const priorOnClose = server.server.onclose;
348
+ server.server.onclose = () => {
349
+ dispose();
350
+ priorOnClose?.();
351
+ };
232
352
  const registerTool = server.registerTool.bind(server);
233
353
  server.registerTool = ((name, config, callback) => registerTool(name, config, async (...args) => {
234
- // Routing is live state, not a startup constant. A branch switch or
235
- // `hunch shared` can add/remove team.json while this stdio process remains
236
- // alive; serving the old store after that boundary would write the wrong
237
- // graph. Refuse and require a reconnect instead of attempting an in-place
238
- // HunchStore swap while requests may be active.
239
- // The explicit process overlay intentionally outranks committed team
240
- // discovery for this process, both at startup and at every later request.
241
- const teamFileNow = !explicitOverlay && existsSync(teamFile);
242
- if (teamFileNow !== teamAdvertised) {
243
- return err("The committed team-memory routing changed after this MCP process started. Reconnect Hunch before reading or writing memory.");
244
- }
245
- const currentTeamConfig = teamFileNow ? readTeamConfig(root) : null;
246
- if (teamAdvertised && !matchesStartupTeamRoute()) {
247
- return err("The team-memory URL or branch changed after this MCP process started. Refusing the old graph; reconnect Hunch first.");
248
- }
249
- if (teamFileNow && (!currentTeamConfig
250
- || store.mode !== "shared"
251
- || !store.privateDir
252
- || !overlayMatchesTeamRemote(root, join(store.privateDir, "..")))) {
253
- return err("The committed team memory destination is invalid or no longer matches this process. Refusing the stale graph; reconnect Hunch first.");
254
- }
255
- if (store.mode === "shared" && store.privateDir) {
256
- try {
257
- pullTeamMemory();
354
+ activeRequests++;
355
+ try {
356
+ // Routing is live state, not a startup constant. A branch switch or
357
+ // `hunch shared` can add/remove team.json while this stdio process remains
358
+ // alive; serving the old store after that boundary would write the wrong
359
+ // graph. Protocol-driven root swaps are prepared atomically and deferred
360
+ // until this request count reaches zero, so every handler sees one stable
361
+ // root/store/route epoch for its complete execution.
362
+ const teamFileNow = !explicitOverlay && existsSync(teamFile);
363
+ if (teamFileNow !== teamAdvertised) {
364
+ return err("The committed team-memory routing changed after this MCP process started. Reconnect Hunch before reading or writing memory.");
258
365
  }
259
- catch { /* offline / lock held / invalid remote — use local */ }
260
- // Recompute the full semantic + physical snapshot after the synchronous
261
- // network seam. A paired team.json/origin change can occur while fetch is
262
- // blocked; serving after that race would attach the old checkout to a new
263
- // destination even though the pull itself correctly refused.
366
+ const currentTeamConfig = teamFileNow ? readTeamConfig(root) : null;
264
367
  if (teamAdvertised && !matchesStartupTeamRoute()) {
265
- return err("The team-memory route changed during refresh. Refusing to serve a stale or redirected graph; reconnect Hunch first.");
368
+ return err("The team-memory URL or branch changed after this MCP process started. Refusing the old graph; reconnect Hunch first.");
266
369
  }
267
- try {
268
- if (store.sourceStamp() !== indexedSourceStamp)
269
- refreshIndex();
370
+ if (teamFileNow && (!currentTeamConfig
371
+ || store.mode !== "shared"
372
+ || !store.privateDir
373
+ || !overlayMatchesTeamRemote(root, join(store.privateDir, "..")))) {
374
+ return err("The committed team memory destination is invalid or no longer matches this process. Refusing the stale graph; reconnect Hunch first.");
270
375
  }
271
- catch { /* corrupt/churning local source — serve the last durable indexed view */ }
376
+ if (store.mode === "shared" && store.privateDir) {
377
+ try {
378
+ pullTeamMemory();
379
+ }
380
+ catch { /* offline / lock held / invalid remote — use local */ }
381
+ // Recompute the full semantic + physical snapshot after the synchronous
382
+ // network seam. A paired team.json/origin change can occur while fetch is
383
+ // blocked; serving after that race would attach the old checkout to a new
384
+ // destination even though the pull itself correctly refused.
385
+ if (teamAdvertised && !matchesStartupTeamRoute()) {
386
+ return err("The team-memory route changed during refresh. Refusing to serve a stale or redirected graph; reconnect Hunch first.");
387
+ }
388
+ try {
389
+ if (store.sourceStamp() !== indexedSourceStamp)
390
+ refreshIndex();
391
+ }
392
+ catch { /* corrupt/churning local source — serve the last durable indexed view */ }
393
+ }
394
+ const result = await callback(...args);
395
+ if (teamAdvertised && !matchesStartupTeamRoute()) {
396
+ return err("The team-memory route changed while the tool was running. Its startup destination was not published; reconnect Hunch before retrying.");
397
+ }
398
+ return result;
272
399
  }
273
- const result = await callback(...args);
274
- if (teamAdvertised && !matchesStartupTeamRoute()) {
275
- return err("The team-memory route changed while the tool was running. Its startup destination was not published; reconnect Hunch before retrying.");
400
+ finally {
401
+ activeRequests--;
402
+ schedulePendingRoot();
276
403
  }
277
- return result;
278
404
  }));
279
405
  // -- hunch_query ----------------------------------------------------------
280
406
  server.registerTool("hunch_query", {
@@ -613,6 +739,23 @@ export function buildServer(root) {
613
739
  // same-id public record (and vice versa).
614
740
  const home = store.captureHome(!!decision.private);
615
741
  const existing = home === "private" ? store.getPrivateRec("decisions", id) : store.json.get("decisions", id);
742
+ // A commit-keyed id intentionally lets a human capture upgrade the machine draft
743
+ // for that commit. Once the slot is human-confirmed, however, a differently
744
+ // identified decision must never reuse it: that would silently replace the first
745
+ // ADR while reporting success (issue #23). Topic is the canonical identity when
746
+ // both sides have one; otherwise a matching title permits anchoring/refining the
747
+ // same record without blocking the existing draft-upgrade contract.
748
+ const sameHumanIdentity = existing?.topic && decision.topic
749
+ ? decision.topic === existing.topic
750
+ : existing?.title === decision.title;
751
+ const conflictsWithHuman = !!existing?.provenance.source.includes("human_confirmed")
752
+ && !sameHumanIdentity;
753
+ if (conflictsWithHuman) {
754
+ return err(`Decision id ${id} already identifies a different human-confirmed decision: ` +
755
+ `"${existing.title}"${existing.topic ? ` (topic "${existing.topic}")` : ""}. ` +
756
+ `Refusing to overwrite it with "${decision.title}"${decision.topic ? ` (topic "${decision.topic}")` : ""}. ` +
757
+ "Record the additional decision without commit, or reuse the incumbent topic/title when refining the same decision.");
758
+ }
616
759
  const source = existing && existing.provenance.source.includes("llm_draft")
617
760
  ? "llm_draft+human_confirmed"
618
761
  : "human_confirmed";
@@ -1280,7 +1423,16 @@ export function buildServer(root) {
1280
1423
  return err(`Failed to materialize G2 behavior policies: ${e.message}`);
1281
1424
  }
1282
1425
  });
1283
- return server;
1426
+ return {
1427
+ server,
1428
+ getRoot: () => root,
1429
+ setRoot,
1430
+ };
1431
+ }
1432
+ /** Back-compatible server construction for tests and callers that do not need
1433
+ * to drive roots directly. The server still owns and closes its active store. */
1434
+ export function buildServer(root) {
1435
+ return buildServerWithRootControl(root).server;
1284
1436
  }
1285
1437
  function provLine(record) {
1286
1438
  const p = record?.provenance;
@@ -1289,12 +1441,44 @@ function provLine(record) {
1289
1441
  const v = p.last_verified ? `, verified ${p.last_verified.slice(0, 10)}` : "";
1290
1442
  return `\n ⟨${p.source ?? "?"}, confidence ${p.confidence ?? "?"}${v}⟩`;
1291
1443
  }
1444
+ /** Query client roots after initialization and follow later list changes.
1445
+ * Generation ordering prevents a slow stale roots/list response from winning. */
1446
+ export function wireClientRoots(control, fallback) {
1447
+ let generation = 0;
1448
+ const syncRoots = async () => {
1449
+ const mine = ++generation;
1450
+ try {
1451
+ if (!control.server.server.getClientCapabilities()?.roots)
1452
+ return;
1453
+ const response = await control.server.server.listRoots();
1454
+ if (mine !== generation)
1455
+ return;
1456
+ const next = resolveActiveRoot((response?.roots ?? []).map((root) => root.uri), fallback);
1457
+ if (!next) {
1458
+ console.error("[hunch-mcp] multiple client roots are equally plausible; keeping the current Hunch root");
1459
+ return;
1460
+ }
1461
+ control.setRoot(next);
1462
+ }
1463
+ catch (error) {
1464
+ // A client without roots support keeps the spawn root. A client-provided
1465
+ // root that fails fail-closed validation is also refused without taking
1466
+ // down the existing, already-validated graph.
1467
+ if (mine === generation) {
1468
+ console.error(`[hunch-mcp] could not apply client roots: ${error.message}`);
1469
+ }
1470
+ }
1471
+ };
1472
+ control.server.server.oninitialized = () => { void syncRoots(); };
1473
+ control.server.server.setNotificationHandler(RootsListChangedNotificationSchema, async () => { await syncRoots(); });
1474
+ }
1292
1475
  /** Start the stdio server (called by `hunch mcp`). */
1293
1476
  export async function startServer(cwd = process.cwd()) {
1294
- const root = findRoot(cwd);
1295
- const server = buildServer(root);
1477
+ const fallback = findRoot(cwd);
1478
+ const control = buildServerWithRootControl(fallback);
1479
+ wireClientRoots(control, fallback);
1296
1480
  const transport = new StdioServerTransport();
1297
- await server.connect(transport);
1298
- console.error(`[hunch-mcp] serving Hunch at ${root} over stdio`);
1481
+ await control.server.connect(transport);
1482
+ console.error(`[hunch-mcp] serving Hunch over stdio (spawn root ${control.getRoot()}; resolving client roots…)`);
1299
1483
  }
1300
1484
  //# sourceMappingURL=server.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.9.2",
3
+ "version": "1.9.4",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
6
  "description": "Engineering memory and a deterministic Change Gate for AI-assisted codebases: decisions, rejected approaches, constraints, and bug lineage become portable context and opt-in enforcement for every MCP assistant.",