@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 +5 -6
- package/dist/cli/invocation.js +13 -7
- package/dist/core/paths.js +8 -8
- package/dist/core/version.js +9 -0
- package/dist/integrations/ciAction.js +2 -2
- package/dist/mcp/roots.js +58 -0
- package/dist/mcp/server.js +280 -96
- package/package.json +1 -1
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.
|
|
20
|
-
>
|
|
21
|
-
>
|
|
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.
|
|
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.
|
|
99
|
+
npm i -g @davesheffer/hunch@1.9.4
|
|
101
100
|
git pull
|
|
102
101
|
hunch init
|
|
103
102
|
hunch doctor
|
package/dist/cli/invocation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
69
|
-
// published package
|
|
70
|
-
// any clone. The git hook lives in
|
|
71
|
-
// so it keeps the PATH-robust
|
|
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:
|
|
82
|
+
mcp: publishedMcpInvocation(),
|
|
77
83
|
};
|
|
78
84
|
}
|
|
79
85
|
if (isDev) {
|
package/dist/core/paths.js
CHANGED
|
@@ -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
|
package/dist/core/version.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
218
|
+
throw error;
|
|
146
219
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
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
|
|
1295
|
-
const
|
|
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
|
|
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.
|
|
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.",
|