@davesheffer/hunch 1.10.0 → 1.10.1
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/dist/cli/index.js +36 -8
- package/dist/core/drift.js +5 -2
- package/dist/core/io.js +64 -9
- package/dist/extractors/git.js +98 -29
- package/dist/extractors/nativeTreeSitter.js +6 -1
- package/dist/extractors/testreport.js +7 -1
- package/dist/integrations/claudemd.js +7 -4
- package/dist/integrations/providers.js +16 -6
- package/dist/integrations/scaffold.js +21 -5
- package/dist/mcp/roots.js +19 -3
- package/dist/mcp/server.js +17 -8
- package/dist/store/compact.js +6 -0
- package/dist/store/hunchStore.js +8 -3
- package/dist/store/jsonStore.js +111 -19
- package/dist/store/privateMigrate.js +12 -0
- package/dist/synthesis/provider.js +29 -3
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -222,12 +222,26 @@ program
|
|
|
222
222
|
if (opts.autoCommit === false) {
|
|
223
223
|
const localFile = join(paths.hunch, "local.json");
|
|
224
224
|
let existing = {};
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
if (existsSync(localFile)) {
|
|
226
|
+
// Same contract as every other writer of this file (issue #40): an
|
|
227
|
+
// unparseable or non-object local.json may hold the private-overlay
|
|
228
|
+
// pointer — rewriting it from a swallowed parse failure silently
|
|
229
|
+
// re-routes private captures into the committed public store.
|
|
230
|
+
try {
|
|
231
|
+
const parsed = JSON.parse(readFileSync(localFile, "utf8"));
|
|
232
|
+
existing = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
existing = null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (existing === null) {
|
|
239
|
+
console.warn(` ⚠ refusing to rewrite ${localFile}: it is not a JSON object and may hold your private-overlay pointer. Fix or remove it, then re-run \`hunch init --no-auto-commit\`.`);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
writeFileAtomic(localFile, JSON.stringify({ ...existing, autoCommit: false }, null, 2) + "\n");
|
|
243
|
+
console.log(" ✓ auto-commit OFF (captures stay uncommitted; commit .hunch/ yourself)");
|
|
227
244
|
}
|
|
228
|
-
catch { /* absent/invalid → fresh */ }
|
|
229
|
-
writeFileAtomic(localFile, JSON.stringify({ ...existing, autoCommit: false }, null, 2) + "\n");
|
|
230
|
-
console.log(" ✓ auto-commit OFF (captures stay uncommitted; commit .hunch/ yourself)");
|
|
231
245
|
}
|
|
232
246
|
if (isGitRepo(root)) {
|
|
233
247
|
const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
|
|
@@ -259,7 +273,10 @@ program
|
|
|
259
273
|
console.log(` ⚠ skipped .mcp.json: ${e.message}`);
|
|
260
274
|
}
|
|
261
275
|
const cmds = writeSlashCommands(root);
|
|
262
|
-
console.log(` ✓ wrote ${cmds.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile)`);
|
|
276
|
+
console.log(` ✓ wrote ${cmds.written.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile, /capture, /heal, /audit)`);
|
|
277
|
+
for (const s of cmds.skipped) {
|
|
278
|
+
console.log(` ⚠ kept your existing ${rel(root, s)} (no hunch:generated marker — delete the file and re-run init to adopt Hunch's version)`);
|
|
279
|
+
}
|
|
263
280
|
const cmd = updateClaudeMd(root, store);
|
|
264
281
|
console.log(` ✓ updated ${rel(root, cmd)} with ambient Hunch context`);
|
|
265
282
|
// Firmness: stamp .hunch/config.json (default advisory) so `hunch hook` reads a
|
|
@@ -374,7 +391,11 @@ program
|
|
|
374
391
|
// commit is drafted — not per-commit, and not under --deep (an ensemble may
|
|
375
392
|
// fan out to several distinct workers, each with its own configuration).
|
|
376
393
|
if (!opts.deep && commits.length > 0) {
|
|
377
|
-
|
|
394
|
+
// { root }: the advisory must resolve the SAME provider the synthesis
|
|
395
|
+
// inside syncCommit resolves (persisted local.json preference included) —
|
|
396
|
+
// rootless resolution could grade a different provider and swallow the
|
|
397
|
+
// num_ctx warning for an actual Ollama run (issue #46).
|
|
398
|
+
const ctxProvider = await selectProvider({ root });
|
|
378
399
|
const ctxWarning = await maybeWarnOllamaContext(ctxProvider.name, process.env);
|
|
379
400
|
if (ctxWarning)
|
|
380
401
|
console.log(ctxWarning);
|
|
@@ -3354,6 +3375,13 @@ const vetoCmd = program
|
|
|
3354
3375
|
store.close();
|
|
3355
3376
|
return fail(`--base ref "${opts.base}" does not resolve. In CI, fetch the base branch first (git fetch origin <branch>).`);
|
|
3356
3377
|
}
|
|
3378
|
+
// Same guard for --commit: an unfetched/mistyped sha would enumerate zero
|
|
3379
|
+
// files and exit 0 — a vacuous pass where CI expects the Decision Guard
|
|
3380
|
+
// to have actually looked (issue #45).
|
|
3381
|
+
if (opts.commit && !revExists(opts.commit, root)) {
|
|
3382
|
+
store.close();
|
|
3383
|
+
return fail(`--commit sha "${opts.commit}" does not resolve. In CI, ensure the commit is fetched (git fetch --depth=... or fetch-depth: 0).`);
|
|
3384
|
+
}
|
|
3357
3385
|
store.reindex();
|
|
3358
3386
|
const files = opts.commit ? commitFiles(opts.commit, root)
|
|
3359
3387
|
: opts.base ? rangeFiles(opts.base, root)
|
|
@@ -3593,7 +3621,7 @@ program
|
|
|
3593
3621
|
firm: "surfaces + warns on a violating edit",
|
|
3594
3622
|
strict: "edit-time DENY + CI guard — the teeth are on",
|
|
3595
3623
|
};
|
|
3596
|
-
console.log(`\nHunch — enforcement status (${root
|
|
3624
|
+
console.log(`\nHunch — enforcement status (${basename(root)})\n`);
|
|
3597
3625
|
console.log(` firmness: ${firmness} ← ${fnote[firmness] ?? ""}\n`);
|
|
3598
3626
|
console.log(` ✓ ARMED ${blocking.length} confirmed blocking invariant(s) — held against every assistant`);
|
|
3599
3627
|
if (blocking.length) {
|
package/dist/core/drift.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* `hunch wiki --heal`, never a gate.
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
-
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
16
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
17
17
|
import { toPosixTarget } from "./paths.js";
|
|
18
18
|
import { currentForTopic, isLive } from "./topics.js";
|
|
19
19
|
import { parseDocAnchors } from "./docanchors.js";
|
|
@@ -163,7 +163,10 @@ function referenceExists(store, root, decisionId, ref) {
|
|
|
163
163
|
// A private-scoped reference is an overlay-repo-relative path, not an escape
|
|
164
164
|
// hatch into arbitrary local files.
|
|
165
165
|
const rel = relative(privateRoot, candidate);
|
|
166
|
-
|
|
166
|
+
// NOTE: sep, not an escaped literal — `"\\\\"` in a template is the TWO-char
|
|
167
|
+
// string `\\`, which `relative()` never produces, silently disabling the
|
|
168
|
+
// containment check on Windows (issue #31).
|
|
169
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
167
170
|
return false;
|
|
168
171
|
return existsSync(candidate);
|
|
169
172
|
}
|
package/dist/core/io.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Durable file writes for the Hunch. */
|
|
2
|
-
import { linkSync,
|
|
2
|
+
import { closeSync, fsyncSync, linkSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
3
4
|
let counter = 0;
|
|
4
5
|
const renameRetryDelaysMs = [10, 20, 40, 80];
|
|
5
6
|
const renameRetryWaiter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
|
|
@@ -7,6 +8,13 @@ const renameRetryWaiter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_
|
|
|
7
8
|
* Write `data` to `file` via a temp file + rename, so an interrupted write can't
|
|
8
9
|
* leave the target truncated (the symbols/edges index is the worst to half-write).
|
|
9
10
|
*
|
|
11
|
+
* Durability (issue #34): the temp file is fsync'd BEFORE the rename, and the
|
|
12
|
+
* parent directory best-effort after it. A process kill was always safe (page
|
|
13
|
+
* cache preserves ordering), but on power loss / OS crash the rename's metadata
|
|
14
|
+
* could reach disk before the temp file's data blocks — leaving the target
|
|
15
|
+
* present but truncated or garbage, the exact state the atomic-write invariant
|
|
16
|
+
* (con_902759b3dc) exists to prevent.
|
|
17
|
+
*
|
|
10
18
|
* Windows caveat: renameSync can't REPLACE a file another process holds open (even
|
|
11
19
|
* for read) — it throws EPERM/EBUSY/EACCES, exactly when the MCP server is reading
|
|
12
20
|
* while a CLI writes. Retry that atomic replacement with bounded backoff. If the
|
|
@@ -17,19 +25,42 @@ const renameRetryWaiter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_
|
|
|
17
25
|
export function writeFileAtomic(file, data) {
|
|
18
26
|
const tmp = `${file}.tmp${process.pid}.${counter++}`;
|
|
19
27
|
try {
|
|
20
|
-
|
|
28
|
+
const fd = openSync(tmp, "w");
|
|
29
|
+
try {
|
|
30
|
+
writeSync(fd, data);
|
|
31
|
+
fsyncSync(fd); // data blocks reach disk before the rename's metadata can
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
closeSync(fd);
|
|
35
|
+
}
|
|
21
36
|
}
|
|
22
37
|
catch (e) {
|
|
23
|
-
|
|
38
|
+
cleanupTmp(tmp);
|
|
24
39
|
throw e;
|
|
25
40
|
}
|
|
26
41
|
try {
|
|
27
42
|
renameWithContentionRetry(tmp, file);
|
|
28
43
|
}
|
|
29
44
|
catch (e) {
|
|
30
|
-
|
|
45
|
+
cleanupTmp(tmp);
|
|
31
46
|
throw e;
|
|
32
47
|
}
|
|
48
|
+
fsyncDirBestEffort(dirname(file));
|
|
49
|
+
}
|
|
50
|
+
/** Persist the rename itself (the directory entry). POSIX semantics; Windows
|
|
51
|
+
* cannot open directories for fsync, so this is a silent no-op there — NTFS
|
|
52
|
+
* journals the metadata on its own schedule. */
|
|
53
|
+
function fsyncDirBestEffort(dir) {
|
|
54
|
+
try {
|
|
55
|
+
const fd = openSync(dir, "r");
|
|
56
|
+
try {
|
|
57
|
+
fsyncSync(fd);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
closeSync(fd);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch { /* platform without directory fsync — best effort by contract */ }
|
|
33
64
|
}
|
|
34
65
|
function renameWithContentionRetry(from, to) {
|
|
35
66
|
for (let attempt = 0;; attempt++) {
|
|
@@ -55,7 +86,7 @@ function isRenameContention(error) {
|
|
|
55
86
|
export function writeFileAtomicIfAbsent(file, data) {
|
|
56
87
|
const tmp = `${file}.tmp${process.pid}.${counter++}`;
|
|
57
88
|
try {
|
|
58
|
-
|
|
89
|
+
writeFileAtomicTmp(tmp, data);
|
|
59
90
|
linkSync(tmp, file);
|
|
60
91
|
return true;
|
|
61
92
|
}
|
|
@@ -65,15 +96,39 @@ export function writeFileAtomicIfAbsent(file, data) {
|
|
|
65
96
|
throw error;
|
|
66
97
|
}
|
|
67
98
|
finally {
|
|
68
|
-
|
|
99
|
+
// Not silently best-effort (issue #38): after linkSync succeeds the target
|
|
100
|
+
// shares the temp inode, so a swallowed unlink failure (a Windows AV scanner
|
|
101
|
+
// or indexer briefly holding tmp) leaves the published file with nlink=2 —
|
|
102
|
+
// which validateExistingFile rejects as hard-linked — plus a stray .tmp.
|
|
103
|
+
cleanupTmp(tmp);
|
|
69
104
|
}
|
|
70
105
|
}
|
|
71
|
-
|
|
106
|
+
/** Write + fsync a fresh temp file (shared by both atomic writers). */
|
|
107
|
+
function writeFileAtomicTmp(tmp, data) {
|
|
108
|
+
const fd = openSync(tmp, "w");
|
|
109
|
+
try {
|
|
110
|
+
writeSync(fd, data);
|
|
111
|
+
fsyncSync(fd);
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
closeSync(fd);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Remove a temp file, riding out a transient external hold (AV/indexer) with one
|
|
118
|
+
* short retry; a persistent failure is REPORTED, never swallowed — a leaked tmp
|
|
119
|
+
* beside a hard-link-published target keeps that target at nlink=2. */
|
|
120
|
+
function cleanupTmp(p) {
|
|
72
121
|
try {
|
|
73
122
|
rmSync(p, { force: true });
|
|
123
|
+
return;
|
|
74
124
|
}
|
|
75
|
-
catch {
|
|
76
|
-
|
|
125
|
+
catch { /* transient hold — retry once below */ }
|
|
126
|
+
Atomics.wait(renameRetryWaiter, 0, 0, 50);
|
|
127
|
+
try {
|
|
128
|
+
rmSync(p, { force: true });
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
console.warn(`[hunch] temp file left behind (its published target keeps nlink=2 until it is removed): ${p} (${e.message})`);
|
|
77
132
|
}
|
|
78
133
|
}
|
|
79
134
|
//# sourceMappingURL=io.js.map
|
package/dist/extractors/git.js
CHANGED
|
@@ -166,6 +166,39 @@ function canonicalPath(path) {
|
|
|
166
166
|
export function gitNullDevice() {
|
|
167
167
|
return process.platform === "win32" ? "NUL" : devNull;
|
|
168
168
|
}
|
|
169
|
+
/** How long a live gitindex.lock can plausibly be held: every Hunch git spawn
|
|
170
|
+
* carries a timeout well under this, so an OLDER lock provably has no living
|
|
171
|
+
* owner in any Hunch flow. */
|
|
172
|
+
const STALE_INDEX_LOCK_MS = 30_000;
|
|
173
|
+
/** Heal a stranded `.git/index.lock` (issue #53). Two ways one appears:
|
|
174
|
+
* (a) THIS call's git was timeout-killed — TerminateProcess on Windows skips
|
|
175
|
+
* git's cleanup, so a lock created at/after this attempt started is ours;
|
|
176
|
+
* (b) a PREVIOUS run crashed/was killed — git then fails FAST forever after,
|
|
177
|
+
* and the best-effort flush paths swallow it, so captures keep "succeeding"
|
|
178
|
+
* while nothing commits. A pre-existing lock older than any live git's
|
|
179
|
+
* possible hold time has no living owner and is safe to remove.
|
|
180
|
+
* Returns true when a lock was removed (a retry is then sensible). */
|
|
181
|
+
function clearStrandedIndexLock(repoDir, env, sinceMs, error) {
|
|
182
|
+
const killed = error?.code === "ETIMEDOUT" || !!error?.signal;
|
|
183
|
+
try {
|
|
184
|
+
const rel = execFileSync("git", ["-C", repoDir, "rev-parse", "--git-path", "index.lock"], {
|
|
185
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 5_000,
|
|
186
|
+
}).trim();
|
|
187
|
+
const lockPath = isAbsolute(rel) ? rel : join(repoDir, rel);
|
|
188
|
+
const mtimeMs = statSync(lockPath).mtimeMs;
|
|
189
|
+
const stranded = killed
|
|
190
|
+
? mtimeMs >= sinceMs // created by the git we just killed
|
|
191
|
+
: mtimeMs <= Date.now() - STALE_INDEX_LOCK_MS; // left behind long before this attempt
|
|
192
|
+
if (!stranded)
|
|
193
|
+
return false;
|
|
194
|
+
rmSync(lockPath, { force: true });
|
|
195
|
+
console.error(`hunch: removed a stranded index.lock at "${repoDir}" (${killed ? "this git operation timed out" : "left by an earlier interrupted git"}); retrying.`);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return false; // no lock present, or git itself unavailable — nothing to heal
|
|
200
|
+
}
|
|
201
|
+
}
|
|
169
202
|
/** Compare physical directory identity before path text. Git for Windows can
|
|
170
203
|
* return an 8.3/short or differently-cased spelling for the same top-level
|
|
171
204
|
* directory that Node reached through its long path. A nonzero file ID keeps
|
|
@@ -515,13 +548,21 @@ export function commitAndPushHunch(hunchDir, message, opts) {
|
|
|
515
548
|
GIT_ATTR_NOSYSTEM: "1",
|
|
516
549
|
});
|
|
517
550
|
const run = (args) => {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
551
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
552
|
+
const startedAt = Date.now();
|
|
553
|
+
try {
|
|
554
|
+
execFileSync("git", ["-C", hunchDir, ...args], { stdio: "ignore", env });
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
// best-effort: nothing staged / not a repo / offline — EXCEPT a
|
|
559
|
+
// stranded index.lock, which would otherwise fail every future
|
|
560
|
+
// flush silently (issue #53); heal it and retry once.
|
|
561
|
+
if (!clearStrandedIndexLock(hunchDir, env, startedAt, error))
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
524
564
|
}
|
|
565
|
+
return false;
|
|
525
566
|
};
|
|
526
567
|
if (opts.push !== false) {
|
|
527
568
|
if (!overlayAttributeSourcesAreSafe(hunchDir, env)) {
|
|
@@ -598,18 +639,29 @@ export function commitAndPushHunch(hunchDir, message, opts) {
|
|
|
598
639
|
if (!hooksDir)
|
|
599
640
|
return null;
|
|
600
641
|
const commitPaths = [...memoryPaths, ...(opts.alsoStage ?? [])];
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
"
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
642
|
+
// One retry after healing a stranded index.lock (issue #53): a lock left by
|
|
643
|
+
// a timeout-killed or crashed git otherwise fails EVERY later flush fast and
|
|
644
|
+
// silently — captures keep reporting success while nothing commits.
|
|
645
|
+
for (let attempt = 0; attempt < 2 && !committed; attempt++) {
|
|
646
|
+
const commitStartedAt = Date.now();
|
|
647
|
+
try {
|
|
648
|
+
execFileSync("git", [
|
|
649
|
+
"-C", hunchDir,
|
|
650
|
+
"-c", `core.hooksPath=${hooksDir}`,
|
|
651
|
+
...(opts.push === false ? [] : ["-c", `core.attributesFile=${gitNullDevice()}`]),
|
|
652
|
+
"-c", "core.autocrlf=false",
|
|
653
|
+
"-c", "commit.gpgsign=false",
|
|
654
|
+
"commit", "--no-gpg-sign", "--only", "-m", message, "--", ...commitPaths,
|
|
655
|
+
], { stdio: "ignore", env, timeout: 15_000 });
|
|
656
|
+
committed = true;
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
// Nothing staged / not a repo stays quiet, as before; only a healed
|
|
660
|
+
// stranded lock earns the single retry.
|
|
661
|
+
if (!clearStrandedIndexLock(hunchDir, env, commitStartedAt, error))
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
611
664
|
}
|
|
612
|
-
catch { /* nothing staged / not a repo */ }
|
|
613
665
|
if (!committed)
|
|
614
666
|
return null;
|
|
615
667
|
if (opts.push !== false) {
|
|
@@ -1123,6 +1175,7 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
|
|
|
1123
1175
|
if (!hooksDir)
|
|
1124
1176
|
return "failed";
|
|
1125
1177
|
const tryGit = (args, timeout = timeoutMs) => {
|
|
1178
|
+
const startedAt = Date.now();
|
|
1126
1179
|
try {
|
|
1127
1180
|
execFileSync("git", [
|
|
1128
1181
|
"-C", hunchDir,
|
|
@@ -1136,7 +1189,10 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
|
|
|
1136
1189
|
});
|
|
1137
1190
|
return true;
|
|
1138
1191
|
}
|
|
1139
|
-
catch {
|
|
1192
|
+
catch (error) {
|
|
1193
|
+
// Same stranding class as the commit path: a timeout-killed merge/fetch
|
|
1194
|
+
// leaves index.lock behind and wedges every later sync (issue #53).
|
|
1195
|
+
clearStrandedIndexLock(hunchDir, env, startedAt, error);
|
|
1140
1196
|
return false;
|
|
1141
1197
|
}
|
|
1142
1198
|
};
|
|
@@ -1522,6 +1578,13 @@ function waitForCommitLockHandoff(lock, first, timeoutMs) {
|
|
|
1522
1578
|
Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()));
|
|
1523
1579
|
attempt = acquireCommitLock(lock);
|
|
1524
1580
|
}
|
|
1581
|
+
// The deadline can expire DURING the final wait+acquire; without this check an
|
|
1582
|
+
// acquire that succeeded on that last iteration returned false while this
|
|
1583
|
+
// process's owner directory held the lock — never released (the caller bails
|
|
1584
|
+
// before its try/finally), wedging every flush in every process until this one
|
|
1585
|
+
// exited (issue #48).
|
|
1586
|
+
if (attempt.state === "acquired")
|
|
1587
|
+
return true;
|
|
1525
1588
|
return false;
|
|
1526
1589
|
}
|
|
1527
1590
|
export function headSha(cwd) {
|
|
@@ -1595,7 +1658,7 @@ export function currentBranch(cwd) {
|
|
|
1595
1658
|
/** Files changed in a single commit. `--root` makes the initial commit (which
|
|
1596
1659
|
* has no parent) report its files as additions instead of returning nothing. */
|
|
1597
1660
|
export function commitFiles(sha, cwd) {
|
|
1598
|
-
const out = gitSafe(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha], cwd);
|
|
1661
|
+
const out = gitSafe(["-c", "core.quotePath=false", "diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha], cwd);
|
|
1599
1662
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
1600
1663
|
}
|
|
1601
1664
|
/** Raw `git log` over `.hunch/`, paired with parseMemoryLog — the memory-move
|
|
@@ -2011,7 +2074,7 @@ export function fileGitMetrics(cwd, want, days = 90) {
|
|
|
2011
2074
|
return out;
|
|
2012
2075
|
// churn — one windowed log; tally each wanted path's appearances (= commits).
|
|
2013
2076
|
if (days > 0) {
|
|
2014
|
-
const raw = gitSafe(["log", `--since=${days}.days.ago`, "--name-only", "--format="], cwd);
|
|
2077
|
+
const raw = gitSafe(["-c", "core.quotePath=false", "log", `--since=${days}.days.ago`, "--name-only", "--format="], cwd);
|
|
2015
2078
|
if (raw) {
|
|
2016
2079
|
for (const line of raw.split("\n")) {
|
|
2017
2080
|
const e = line && out.get(line);
|
|
@@ -2023,7 +2086,7 @@ export function fileGitMetrics(cwd, want, days = 90) {
|
|
|
2023
2086
|
// last commit — one newest-first log; the FIRST time a path appears is its most
|
|
2024
2087
|
// recent commit. NUL-prefixed lines mark commit boundaries; the rest are paths.
|
|
2025
2088
|
// 256MB buffer for the all-history name-only stream on large repos.
|
|
2026
|
-
const raw = gitSafe(["log", "--name-only", "--format=%x00%h"], cwd, 256 * 1024 * 1024);
|
|
2089
|
+
const raw = gitSafe(["-c", "core.quotePath=false", "log", "--name-only", "--format=%x00%h"], cwd, 256 * 1024 * 1024);
|
|
2027
2090
|
if (raw) {
|
|
2028
2091
|
let remaining = out.size;
|
|
2029
2092
|
let sha = "";
|
|
@@ -2044,17 +2107,23 @@ export function fileGitMetrics(cwd, want, days = 90) {
|
|
|
2044
2107
|
}
|
|
2045
2108
|
return out;
|
|
2046
2109
|
}
|
|
2047
|
-
/** Files staged for commit (for `hunch check` pre-commit enforcement).
|
|
2110
|
+
/** Files staged for commit (for `hunch check` pre-commit enforcement).
|
|
2111
|
+
*
|
|
2112
|
+
* Every path enumerator here pins `core.quotePath=false` (issue #50): with
|
|
2113
|
+
* git's default quotePath, any path holding bytes > 0x7F comes back
|
|
2114
|
+
* octal-quoted (`"src/caf\303\251.ts"`), which matches neither the store's
|
|
2115
|
+
* POSIX paths nor constraint scope globs — a blocking constraint over such a
|
|
2116
|
+
* file graded as a vacuous PASS, and its churn/last-commit metrics read zero. */
|
|
2048
2117
|
export function stagedFiles(cwd) {
|
|
2049
|
-
const out = gitSafe(["diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd);
|
|
2118
|
+
const out = gitSafe(["-c", "core.quotePath=false", "diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd);
|
|
2050
2119
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
2051
2120
|
}
|
|
2052
2121
|
/** Files changed anywhere in the working tree compared with HEAD: both staged
|
|
2053
2122
|
* and unstaged tracked files, plus untracked files. This powers the local,
|
|
2054
2123
|
* pre-commit Change Gate; it never mutates the index or asks an agent/model. */
|
|
2055
2124
|
export function workingFiles(cwd) {
|
|
2056
|
-
const changed = gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
|
|
2057
|
-
const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter(Boolean);
|
|
2125
|
+
const changed = gitSafe(["-c", "core.quotePath=false", "diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
|
|
2126
|
+
const untracked = gitSafe(["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter(Boolean);
|
|
2058
2127
|
return [...new Set([...changed, ...untracked])].sort();
|
|
2059
2128
|
}
|
|
2060
2129
|
/** Does a ref resolve to a commit in this repo? Lets `--base` fail LOUDLY on an
|
|
@@ -2066,7 +2135,7 @@ export function revExists(ref, cwd) {
|
|
|
2066
2135
|
/** Files a PR/branch changes vs `base` (3-dot: changes on HEAD since the merge-base,
|
|
2067
2136
|
* i.e. exactly the PR's own commits — the CI Constraint Guard's surface). */
|
|
2068
2137
|
export function rangeFiles(base, cwd, head = "HEAD") {
|
|
2069
|
-
const out = gitSafe(["diff", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
|
|
2138
|
+
const out = gitSafe(["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
|
|
2070
2139
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
2071
2140
|
}
|
|
2072
2141
|
/** Commit subjects on `head` since `base` (2-dot: commits added by the task),
|
|
@@ -2095,8 +2164,8 @@ export function stagedDiff(cwd, maxBytes = 60_000) {
|
|
|
2095
2164
|
* intentionally contribute no synthetic content to regression analysis. */
|
|
2096
2165
|
export function workingDiff(cwd, maxBytes = 60_000) {
|
|
2097
2166
|
let out = gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
|
|
2098
|
-
const tracked = new Set(gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean));
|
|
2099
|
-
const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter((f) => f && !tracked.has(f));
|
|
2167
|
+
const tracked = new Set(gitSafe(["-c", "core.quotePath=false", "diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean));
|
|
2168
|
+
const untracked = gitSafe(["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter((f) => f && !tracked.has(f));
|
|
2100
2169
|
const readWorkingFile = createRepoFileReader(cwd);
|
|
2101
2170
|
for (const file of untracked) {
|
|
2102
2171
|
try {
|
|
@@ -2146,7 +2215,7 @@ export function fixCommits(spec, cwd, max = 200) {
|
|
|
2146
2215
|
}
|
|
2147
2216
|
/** All tracked files matching the given extensions. */
|
|
2148
2217
|
export function trackedFiles(cwd, exts) {
|
|
2149
|
-
const out = gitSafe(["ls-files"], cwd);
|
|
2218
|
+
const out = gitSafe(["-c", "core.quotePath=false", "ls-files"], cwd);
|
|
2150
2219
|
const all = out ? out.split("\n").filter(Boolean) : [];
|
|
2151
2220
|
return all.filter((f) => exts.some((e) => f.endsWith(e)));
|
|
2152
2221
|
}
|
|
@@ -64,7 +64,12 @@ function copyNativeBinding(packageName, copyRoot, nodeGypBuild) {
|
|
|
64
64
|
export function loadNativeTreeSitter() {
|
|
65
65
|
if (runtime)
|
|
66
66
|
return runtime;
|
|
67
|
-
|
|
67
|
+
// Both binding spellings: prebuilds ship as tree-sitter[-typescript|-python].node,
|
|
68
|
+
// while from-source builds are named after the binding.gyp target with
|
|
69
|
+
// underscores (tree_sitter_runtime_binding.node, tree_sitter_python_binding.node,
|
|
70
|
+
// …). Missing the underscore names let an already-loaded source-built addon slip
|
|
71
|
+
// past this guard and defeat the file-lock isolation entirely (issue #52).
|
|
72
|
+
const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /(?:tree-sitter(?:-typescript|-python)?|tree_sitter(?:_[a-z]+)*_binding)\.node$/.test(path)
|
|
68
73
|
&& !new RegExp(`(?:^|[\\\\/])${COPY_PREFIX}\\d+-`).test(path));
|
|
69
74
|
if (preloaded.length) {
|
|
70
75
|
throw new Error(`tree-sitter native addon was loaded before Hunch could isolate it: ${preloaded.join(", ")}`);
|
|
@@ -56,7 +56,8 @@ export function parseTestReport(output) {
|
|
|
56
56
|
// Collect the following more-indented diagnostic block as the message.
|
|
57
57
|
const baseIndent = leadingSpaces(raw);
|
|
58
58
|
const block = [];
|
|
59
|
-
|
|
59
|
+
let j = i + 1;
|
|
60
|
+
for (; j < lines.length; j++) {
|
|
60
61
|
const ln = lines[j];
|
|
61
62
|
if (ln.trim() === "") {
|
|
62
63
|
block.push("");
|
|
@@ -68,6 +69,11 @@ export function parseTestReport(output) {
|
|
|
68
69
|
}
|
|
69
70
|
const diag = block.join("\n").trim();
|
|
70
71
|
failMap.set(name, { test: name, message: diag ? `${name}\n${diag}` : name });
|
|
72
|
+
// Skip the consumed diagnostic block (issue #51): re-visiting it let
|
|
73
|
+
// TAP-looking text QUOTED INSIDE an error message (assertion diffs in this
|
|
74
|
+
// very repo quote "ok N - …" lines) parse as real results — a phantom pass
|
|
75
|
+
// can mark a previously-open bug fixed without any test having re-run.
|
|
76
|
+
i = j - 1;
|
|
71
77
|
}
|
|
72
78
|
// A test can legitimately appear as both (flaky retry) — trust the failure.
|
|
73
79
|
for (const name of failMap.keys())
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* context loaded every session for free"). We own ONLY the region between the
|
|
4
4
|
* HUNCH markers — any user-authored content outside it is preserved verbatim.
|
|
5
5
|
*/
|
|
6
|
-
import { readFileSync,
|
|
7
|
-
import {
|
|
6
|
+
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
8
|
+
import { basename, join, dirname } from "node:path";
|
|
8
9
|
import { wikiSummary } from "../wiki/wiki.js";
|
|
9
10
|
import { PolicyRepository } from "../constitution/repository.js";
|
|
10
11
|
const START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
|
|
@@ -103,12 +104,14 @@ export function upsertSection(file, section, fallbackTitle) {
|
|
|
103
104
|
content = `${fallbackTitle}\n\n${section}\n`;
|
|
104
105
|
}
|
|
105
106
|
mkdirSync(dirname(file), { recursive: true }); // e.g. .github/ for copilot-instructions
|
|
106
|
-
|
|
107
|
+
// Atomic: this file carries the USER'S prose around the managed block — a torn
|
|
108
|
+
// write must not be able to truncate it (issue #43).
|
|
109
|
+
writeFileAtomic(file, content);
|
|
107
110
|
return file;
|
|
108
111
|
}
|
|
109
112
|
/** Insert/replace the HUNCH section in CLAUDE.md, preserving everything else. */
|
|
110
113
|
export function updateClaudeMd(root, store) {
|
|
111
|
-
return upsertSection(join(root, "CLAUDE.md"), renderHunchSection(store, root), `# ${root
|
|
114
|
+
return upsertSection(join(root, "CLAUDE.md"), renderHunchSection(store, root), `# ${basename(root)}`);
|
|
112
115
|
}
|
|
113
116
|
function sev(s) {
|
|
114
117
|
return { blocking: 3, warning: 2, advisory: 1 }[s] ?? 0;
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* Every writer MERGES into existing files (preserving other servers / user prose)
|
|
18
18
|
* and is idempotent, so re-running `hunch init` is safe.
|
|
19
19
|
*/
|
|
20
|
-
import { readFileSync,
|
|
20
|
+
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
21
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
21
22
|
import { homedir } from "node:os";
|
|
22
23
|
import { join, dirname } from "node:path";
|
|
23
24
|
import { renderHunchSection, upsertSection, updateClaudeMd } from "./claudemd.js";
|
|
@@ -135,7 +136,9 @@ function tomlStr(s) {
|
|
|
135
136
|
}
|
|
136
137
|
function writeJson(file, obj) {
|
|
137
138
|
mkdirSync(dirname(file), { recursive: true });
|
|
138
|
-
|
|
139
|
+
// Atomic: these files hold the USER'S merged servers/hooks — a torn write would
|
|
140
|
+
// leave them unparseable, which every writer here then refuses to touch (#43).
|
|
141
|
+
writeFileAtomic(file, JSON.stringify(obj, null, 2) + "\n");
|
|
139
142
|
return file;
|
|
140
143
|
}
|
|
141
144
|
/** Provider hook commands live in tracked config files, so use the structured
|
|
@@ -148,7 +151,14 @@ function hookCommand(inv, provider) {
|
|
|
148
151
|
function isHunchProviderHook(entry) {
|
|
149
152
|
const e = entry && typeof entry === "object" ? entry : null;
|
|
150
153
|
const command = typeof e?.command === "string" ? e.command : "";
|
|
151
|
-
|
|
154
|
+
// Anchored to the exact shape hookCommand() writes — JSON-quoted parts ending
|
|
155
|
+
// in "hook" "--provider" "<name>" — plus a Hunch launcher (the pinned npm
|
|
156
|
+
// package spec, or a quoted …/index.js|ts path for source installs). The old
|
|
157
|
+
// unanchored /index\.(js|ts)/ + /\bhook\b/ pair classified FOREIGN entries
|
|
158
|
+
// like `node ./hook/index.js` as ours and silently deleted them, violating
|
|
159
|
+
// the leave-every-foreign-hook-in-place contract (con_8460b6770f, issue #41).
|
|
160
|
+
return /(?:@davesheffer\/hunch|[\\/]index\.(?:js|ts)")/.test(command)
|
|
161
|
+
&& /\s"hook"(?:\s+"--provider"\s+"[a-z]+")?\s*$/.test(command);
|
|
152
162
|
}
|
|
153
163
|
/** Merge our command entries into a standard `{ hooks: { Event: [] } }` file.
|
|
154
164
|
* We replace only old Hunch commands and leave every foreign hook in place. */
|
|
@@ -255,7 +265,7 @@ export function writeCodexConfig(root, inv) {
|
|
|
255
265
|
}
|
|
256
266
|
base = base.trimEnd();
|
|
257
267
|
mkdirSync(dirname(file), { recursive: true });
|
|
258
|
-
|
|
268
|
+
writeFileAtomic(file, base ? `${base}\n\n${block}\n` : `${block}\n`);
|
|
259
269
|
return file;
|
|
260
270
|
}
|
|
261
271
|
/** AGENTS.md — the cross-tool ambient-instruction standard (Codex and a growing
|
|
@@ -273,7 +283,7 @@ export function writeCursorRule(root, store) {
|
|
|
273
283
|
const file = join(root, ".cursor", "rules", "hunch.mdc");
|
|
274
284
|
const body = `---\ndescription: Hunch engineering memory — consult the hunch_* MCP tools before editing\nalwaysApply: true\n---\n\n${renderHunchSection(store, root)}\n`;
|
|
275
285
|
mkdirSync(dirname(file), { recursive: true });
|
|
276
|
-
|
|
286
|
+
writeFileAtomic(file, body);
|
|
277
287
|
return file;
|
|
278
288
|
}
|
|
279
289
|
/** Windsurf (Cascade): .windsurf/mcp_config.json — same `mcpServers` shape as
|
|
@@ -308,7 +318,7 @@ export function writeWindsurfRule(root, store) {
|
|
|
308
318
|
const file = join(root, ".windsurf", "rules", "hunch.md");
|
|
309
319
|
const body = `---\ntrigger: always_on\ndescription: Hunch engineering memory — consult the hunch_* MCP tools before editing\n---\n\n${renderHunchSection(store, root)}\n`;
|
|
310
320
|
mkdirSync(dirname(file), { recursive: true });
|
|
311
|
-
|
|
321
|
+
writeFileAtomic(file, body);
|
|
312
322
|
return file;
|
|
313
323
|
}
|
|
314
324
|
/** Cursor's hook API is beta, but its project-level config accepts this standard
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* - .mcp.json → registers the `hunch` MCP server with Claude Code
|
|
4
4
|
* - .claude/commands/* → user-triggered slash commands for the §5 workflows
|
|
5
5
|
*/
|
|
6
|
-
import { readFileSync,
|
|
6
|
+
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
7
8
|
import { join, dirname } from "node:path";
|
|
8
9
|
/** Merge a `hunch` server entry into .mcp.json, preserving other servers.
|
|
9
10
|
* A non-empty file we cannot parse THROWS instead of being silently replaced
|
|
@@ -28,7 +29,9 @@ export function writeMcpJson(root, inv) {
|
|
|
28
29
|
}
|
|
29
30
|
json.mcpServers = json.mcpServers ?? {};
|
|
30
31
|
json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
|
|
31
|
-
|
|
32
|
+
// Atomic: .mcp.json holds the user's other servers — a torn write would leave
|
|
33
|
+
// it unparseable, which this writer then refuses to touch (issue #43).
|
|
34
|
+
writeFileAtomic(file, JSON.stringify(json, null, 2) + "\n");
|
|
32
35
|
return file;
|
|
33
36
|
}
|
|
34
37
|
const WHY_CMD = `---
|
|
@@ -168,13 +171,17 @@ export function installClaudeHooks(root, hookCmd) {
|
|
|
168
171
|
if (existed && before === next)
|
|
169
172
|
return { path: file, action: "unchanged" };
|
|
170
173
|
mkdirSync(dirname(file), { recursive: true });
|
|
171
|
-
|
|
174
|
+
writeFileAtomic(file, next);
|
|
172
175
|
return { path: file, action: existed ? "updated" : "created" };
|
|
173
176
|
}
|
|
177
|
+
/** Ownership marker for generated slash commands: its presence means Hunch may
|
|
178
|
+
* refresh the file; deleting the line hands the file to the user for good. */
|
|
179
|
+
const CMD_MARKER = "<!-- hunch:generated — refreshed by hunch init; delete this line to take ownership -->";
|
|
174
180
|
export function writeSlashCommands(root) {
|
|
175
181
|
const dir = join(root, ".claude", "commands");
|
|
176
182
|
mkdirSync(dir, { recursive: true });
|
|
177
183
|
const written = [];
|
|
184
|
+
const skipped = [];
|
|
178
185
|
const files = [
|
|
179
186
|
["hunch-why.md", WHY_CMD],
|
|
180
187
|
["hunch-fix.md", FIX_CMD],
|
|
@@ -185,9 +192,18 @@ export function writeSlashCommands(root) {
|
|
|
185
192
|
];
|
|
186
193
|
for (const [name, body] of files) {
|
|
187
194
|
const p = join(dir, name);
|
|
188
|
-
|
|
195
|
+
// Generic names (capture/heal/audit) are plausibly the USER'S OWN commands;
|
|
196
|
+
// hunch-prefixed names are namespaced ours. Overwrite an existing file only
|
|
197
|
+
// when it carries the ownership marker or the hunch- namespace — never
|
|
198
|
+
// silently replace user content (issue #42). Pre-marker Hunch installs skip
|
|
199
|
+
// once and report; re-adopt by deleting the file and re-running init.
|
|
200
|
+
if (existsSync(p) && !name.startsWith("hunch-") && !readFileSync(p, "utf8").includes("hunch:generated")) {
|
|
201
|
+
skipped.push(p);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
writeFileAtomic(p, `${body}\n${CMD_MARKER}\n`);
|
|
189
205
|
written.push(p);
|
|
190
206
|
}
|
|
191
|
-
return written;
|
|
207
|
+
return { written, skipped };
|
|
192
208
|
}
|
|
193
209
|
//# sourceMappingURL=scaffold.js.map
|
package/dist/mcp/roots.js
CHANGED
|
@@ -5,10 +5,26 @@
|
|
|
5
5
|
* another workspace or linked worktree. MCP roots are the client-neutral protocol
|
|
6
6
|
* mechanism for following that change.
|
|
7
7
|
*/
|
|
8
|
-
import { statSync } from "node:fs";
|
|
8
|
+
import { realpathSync, statSync } from "node:fs";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import { findRoot, HUNCH_DIR, isDir } from "../core/paths.js";
|
|
12
|
+
/** One canonical spelling per directory. `findRoot` only resolve()s, but a
|
|
13
|
+
* client's roots/list URI can spell the same repo differently — VS Code sends
|
|
14
|
+
* a lowercase drive letter (`c:\…`) while the spawn cwd has `C:\…`, and Git
|
|
15
|
+
* for Windows can surface 8.3/short names. Raw string comparison then treats
|
|
16
|
+
* ONE repo as different roots: a full re-prepare (new store + reindex) on
|
|
17
|
+
* every connect, or a false "multiple roots equally plausible" refusal
|
|
18
|
+
* (issue #54). realpathSync.native returns the on-disk spelling for all of
|
|
19
|
+
* these; fall back to the input when the path is transiently unreadable. */
|
|
20
|
+
export function canonicalRootPath(root) {
|
|
21
|
+
try {
|
|
22
|
+
return realpathSync.native(root);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return root;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
12
28
|
function toPath(uri) {
|
|
13
29
|
if (!uri.startsWith("file:"))
|
|
14
30
|
return "";
|
|
@@ -44,12 +60,12 @@ export function resolveActiveRoot(rootUris, fallbackCwd) {
|
|
|
44
60
|
const start = rootStart(toPath(uri));
|
|
45
61
|
if (!start)
|
|
46
62
|
continue;
|
|
47
|
-
const root = findRoot(start);
|
|
63
|
+
const root = canonicalRootPath(findRoot(start));
|
|
48
64
|
if (!candidates.includes(root))
|
|
49
65
|
candidates.push(root);
|
|
50
66
|
}
|
|
51
67
|
if (!candidates.length)
|
|
52
|
-
return findRoot(fallbackCwd);
|
|
68
|
+
return canonicalRootPath(findRoot(fallbackCwd));
|
|
53
69
|
if (candidates.length === 1)
|
|
54
70
|
return candidates[0];
|
|
55
71
|
const withStore = candidates.filter((candidate) => isDir(join(candidate, HUNCH_DIR)));
|
package/dist/mcp/server.js
CHANGED
|
@@ -11,7 +11,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
11
11
|
import { RootsListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
|
|
14
|
-
import { resolveActiveRoot } from "./roots.js";
|
|
14
|
+
import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
|
|
15
15
|
import { HunchStore } from "../store/hunchStore.js";
|
|
16
16
|
import { selectEmbedder } from "../store/embedder.js";
|
|
17
17
|
import { decisionId, findingId } from "../core/ids.js";
|
|
@@ -278,8 +278,11 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
278
278
|
let pendingScheduled = false;
|
|
279
279
|
let closed = false;
|
|
280
280
|
const activateRoot = (next) => {
|
|
281
|
-
|
|
282
|
-
|
|
281
|
+
// canonicalRootPath: a case/8.3 spelling difference must not read as a
|
|
282
|
+
// DIFFERENT repo — that closed the live store and re-prepared everything
|
|
283
|
+
// on every same-repo client connect (issue #54).
|
|
284
|
+
const canonical = canonicalRootPath(findRoot(next));
|
|
285
|
+
if (canonical === canonicalRootPath(root))
|
|
283
286
|
return;
|
|
284
287
|
const prepared = prepareRoot(canonical, explicitOverlay, true);
|
|
285
288
|
const previous = store;
|
|
@@ -386,12 +389,18 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
386
389
|
if (teamAdvertised && !matchesStartupTeamRoute()) {
|
|
387
390
|
return err("The team-memory route changed during refresh. Refusing to serve a stale or redirected graph; reconnect Hunch first.");
|
|
388
391
|
}
|
|
389
|
-
try {
|
|
390
|
-
if (store.sourceStamp() !== indexedSourceStamp)
|
|
391
|
-
refreshIndex();
|
|
392
|
-
}
|
|
393
|
-
catch { /* corrupt/churning local source — serve the last durable indexed view */ }
|
|
394
392
|
}
|
|
393
|
+
// Stamp check in EVERY mode, not only shared: a CLI capture or post-commit
|
|
394
|
+
// hook in another terminal writes JSON that mtime-invalidated loadAll sees
|
|
395
|
+
// immediately, while the SQLite FTS/graph index this long-lived process
|
|
396
|
+
// serves would stay frozen at startup — split-brain answers within one
|
|
397
|
+
// session (JSON-backed tools fresh, query/structure/dependents stale)
|
|
398
|
+
// until restart (issue #49).
|
|
399
|
+
try {
|
|
400
|
+
if (store.sourceStamp() !== indexedSourceStamp)
|
|
401
|
+
refreshIndex();
|
|
402
|
+
}
|
|
403
|
+
catch { /* corrupt/churning local source — serve the last durable indexed view */ }
|
|
395
404
|
const result = await callback(...args);
|
|
396
405
|
if (teamAdvertised && !matchesStartupTeamRoute()) {
|
|
397
406
|
return err("The team-memory route changed while the tool was running. Its startup destination was not published; reconnect Hunch before retrying.");
|
package/dist/store/compact.js
CHANGED
|
@@ -58,6 +58,12 @@ export function planCompaction(input, opts) {
|
|
|
58
58
|
continue; // d is being removed → its references don't count
|
|
59
59
|
if (d.supersedes)
|
|
60
60
|
refDec.add(d.supersedes);
|
|
61
|
+
// superseded_by is a reference too: supersedeIn() sets old.superseded_by
|
|
62
|
+
// without requiring the successor's `supersedes`, so removing a later-
|
|
63
|
+
// rejected successor would leave the surviving record with a dangling
|
|
64
|
+
// pointer AND permanently non-live for its topic (issue #36).
|
|
65
|
+
if (d.superseded_by)
|
|
66
|
+
refDec.add(d.superseded_by);
|
|
61
67
|
if (d.caused_by_bug)
|
|
62
68
|
refBug.add(d.caused_by_bug);
|
|
63
69
|
}
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -760,11 +760,14 @@ export class HunchStore {
|
|
|
760
760
|
const symbols = this.recs("symbols");
|
|
761
761
|
const components = this.recs("components");
|
|
762
762
|
const asOf = opts.asOf;
|
|
763
|
-
|
|
763
|
+
// pathRelated, not bare endsWith: "scenario.ts".endsWith("io.ts") is true,
|
|
764
|
+
// so an unanchored suffix pulled unrelated files' records into why()/the
|
|
765
|
+
// pre-edit grounding block (issue #32). Segment-anchored matching only.
|
|
766
|
+
const matchedSymbols = symbols.filter((s) => s.file === target || s.name === target || s.id === target || pathRelated(s.file, target));
|
|
764
767
|
const symIds = new Set(matchedSymbols.map((s) => s.id));
|
|
765
768
|
const fileSet = new Set(matchedSymbols.map((s) => s.file));
|
|
766
769
|
const isPath = target.includes("/") || target.includes(".");
|
|
767
|
-
const fileMatch = (files) => files.some((f) => f === target || (isPath && (f
|
|
770
|
+
const fileMatch = (files) => files.some((f) => f === target || (isPath && pathRelated(f, target)) || fileSet.has(f));
|
|
768
771
|
return {
|
|
769
772
|
target,
|
|
770
773
|
decisions: decisions.filter((d) => (fileMatch(d.related_files) || d.related_components.some((c) => components.find((x) => x.id === c && fileMatch(x.paths))))
|
|
@@ -1525,7 +1528,9 @@ const RRF_W_GRAPH = numEnv("HUNCH_RRF_W_GRAPH", 0.5);
|
|
|
1525
1528
|
const GRAPH_GAMMA = numEnv("HUNCH_GRAPH_GAMMA", 0.25);
|
|
1526
1529
|
function numEnv(name, dflt) {
|
|
1527
1530
|
const v = Number(process.env[name]);
|
|
1528
|
-
|
|
1531
|
+
// >= 0, not > 0: zero is the documented kill-switch (HUNCH_RRF_W_*=0 disables
|
|
1532
|
+
// a stream); rejecting it silently re-enabled the default weight (issue #33).
|
|
1533
|
+
return Number.isFinite(v) && v >= 0 ? v : dflt;
|
|
1529
1534
|
}
|
|
1530
1535
|
/** Pack a vector's exact bytes for SQLite. Explicit offset+length so a SUBARRAY
|
|
1531
1536
|
* view (byteOffset != 0) writes only its slice, not the whole backing buffer.
|
package/dist/store/jsonStore.js
CHANGED
|
@@ -14,6 +14,9 @@ import { writeFileAtomic } from "../core/io.js";
|
|
|
14
14
|
* constraints) are one file per record so they're cleanly reviewable in PRs. */
|
|
15
15
|
const SINGLE_FILE = { symbols: "index.json", edges: "index.json" };
|
|
16
16
|
const encode = (v) => JSON.stringify(v, null, 2) + "\n";
|
|
17
|
+
// Sleep primitive for the single-file RMW lock's bounded spin (issue #35);
|
|
18
|
+
// same idiom as core/io.ts's rename backoff.
|
|
19
|
+
const RMW_LOCK_WAITER = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
|
|
17
20
|
/** Curated entities are intentionally small, human-reviewable records. Symbols
|
|
18
21
|
* and edges are dense indexes, so they get a much larger but still finite cap. */
|
|
19
22
|
export const MAX_JSON_RECORD_BYTES = 8 * 1024 * 1024;
|
|
@@ -389,6 +392,14 @@ export class JsonStore {
|
|
|
389
392
|
const text = this.readContainedFile(directory, join(directory.lexical, name), this.maxBytes(kind));
|
|
390
393
|
if (text === null)
|
|
391
394
|
continue;
|
|
395
|
+
// A 0-byte per-record file is a merge-driver TOMBSTONE, not a record:
|
|
396
|
+
// git cannot delete through a merge driver, so "both sides deleted" is
|
|
397
|
+
// materialized as an empty %A (issue #37). Human-approved refinement of
|
|
398
|
+
// con_947c578b2c's boundary (2026-08-04): an empty file holds no record
|
|
399
|
+
// to migrate or drop, and Hunch's own atomic writes (con_902759b3dc)
|
|
400
|
+
// never produce one — emptiness is unambiguous, so no warning.
|
|
401
|
+
if (text.trim() === "")
|
|
402
|
+
continue;
|
|
392
403
|
raw = JSON.parse(text);
|
|
393
404
|
}
|
|
394
405
|
catch (e) {
|
|
@@ -403,6 +414,49 @@ export class JsonStore {
|
|
|
403
414
|
}
|
|
404
415
|
return out;
|
|
405
416
|
}
|
|
417
|
+
/** Cross-process mutex for single-file index read-modify-write (issue #35).
|
|
418
|
+
* The long-lived MCP server and CLI hooks write the same `.hunch/` concurrently;
|
|
419
|
+
* two unsynchronized RMWs over index.json each read the same base array and the
|
|
420
|
+
* second rename silently erases the first's record. `mkdirSync` is the atomic
|
|
421
|
+
* acquire (EEXIST = held). A stale lock (killed process) is taken over by age;
|
|
422
|
+
* against a live contender we wait briefly and then proceed WITH a warning —
|
|
423
|
+
* never worse than the historical lockless behavior, and capture paths must not
|
|
424
|
+
* start throwing on lock contention. */
|
|
425
|
+
withSingleFileLock(kind, directory, fn) {
|
|
426
|
+
const lock = join(directory.lexical, ".rmw-lock");
|
|
427
|
+
const deadline = Date.now() + 2_000;
|
|
428
|
+
for (;;) {
|
|
429
|
+
try {
|
|
430
|
+
mkdirSync(lock);
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
try {
|
|
435
|
+
if (Date.now() - lstatSync(lock).mtimeMs > 10_000) {
|
|
436
|
+
rmSync(lock, { recursive: true, force: true }); // no live spawn holds a lock this old
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
continue; /* vanished between attempts — retry the acquire */
|
|
442
|
+
}
|
|
443
|
+
if (Date.now() >= deadline) {
|
|
444
|
+
console.warn(`[hunch] proceeding without the ${kind} index lock (still held: ${lock})`);
|
|
445
|
+
return fn();
|
|
446
|
+
}
|
|
447
|
+
Atomics.wait(RMW_LOCK_WAITER, 0, 0, 25);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
return fn();
|
|
452
|
+
}
|
|
453
|
+
finally {
|
|
454
|
+
try {
|
|
455
|
+
rmSync(lock, { recursive: true, force: true });
|
|
456
|
+
}
|
|
457
|
+
catch { /* stale-takeover reclaims it */ }
|
|
458
|
+
}
|
|
459
|
+
}
|
|
406
460
|
/** Write a single record (validated) to its JSON file / into the index array. */
|
|
407
461
|
put(kind, record) {
|
|
408
462
|
const schema = SCHEMAS[kind];
|
|
@@ -416,11 +470,14 @@ export class JsonStore {
|
|
|
416
470
|
// record can't silently drop schema-invalid / future-schema siblings — the
|
|
417
471
|
// same reason delete() reads raw. Keep the index sorted by id (stable diff,
|
|
418
472
|
// and agrees with the merge driver so a re-index after a merge is a no-op).
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
473
|
+
// Locked: the read-modify-write below is what issue #35 races.
|
|
474
|
+
this.withSingleFileLock(kind, directory, () => {
|
|
475
|
+
const f = this.fileFor(kind, validated.id);
|
|
476
|
+
const arr = this.readRawArray(kind, directory, f).filter((r) => r?.id !== validated.id);
|
|
477
|
+
arr.push(validated);
|
|
478
|
+
arr.sort((a, b) => String(a?.id).localeCompare(String(b?.id)));
|
|
479
|
+
this.writeContainedFile(directory, f, encode(arr), this.maxBytes(kind));
|
|
480
|
+
});
|
|
424
481
|
}
|
|
425
482
|
else {
|
|
426
483
|
this.writeContainedFile(directory, this.fileFor(kind, validated.id), encode(validated), this.maxBytes(kind));
|
|
@@ -446,19 +503,27 @@ export class JsonStore {
|
|
|
446
503
|
this.writeContainedFile(directory, this.fileFor(kind, "index"), encode(validated), this.maxBytes(kind));
|
|
447
504
|
return;
|
|
448
505
|
}
|
|
449
|
-
// One file per record: preflight EVERY existing JSON file before
|
|
506
|
+
// One file per record: preflight EVERY existing JSON file before touching
|
|
450
507
|
// any, so one malicious symlink cannot cause a partially-cleared store.
|
|
451
508
|
const existing = this.jsonFileNames(kind);
|
|
452
509
|
for (const name of existing) {
|
|
453
510
|
this.validateExistingFile(directory, join(directory.lexical, name), this.maxBytes(kind));
|
|
454
511
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
512
|
+
// WRITE-FIRST, delete-stale-LAST (issue #30). The old delete-all-then-rewrite
|
|
513
|
+
// sequence had a crash window in which the kind directory held nothing — and
|
|
514
|
+
// `hunch private --migrate` runs replaceAll on the OVERLAY, so that window
|
|
515
|
+
// covered private-only records existing nowhere else. Now a crash mid-write
|
|
516
|
+
// leaves old ∪ new (same id → same file, so no duplicates), and a crash
|
|
517
|
+
// mid-delete leaves only stale extras — no state loses records.
|
|
518
|
+
const keep = new Set(validated.map((r) => `${r.id}.json`));
|
|
458
519
|
for (const r of validated) {
|
|
459
520
|
const id = r.id;
|
|
460
521
|
this.writeContainedFile(directory, this.fileFor(kind, id), encode(r), this.maxBytes(kind));
|
|
461
522
|
}
|
|
523
|
+
for (const name of existing) {
|
|
524
|
+
if (!keep.has(name))
|
|
525
|
+
this.removeContainedFile(directory, join(directory.lexical, name), this.maxBytes(kind));
|
|
526
|
+
}
|
|
462
527
|
}
|
|
463
528
|
/** Read a single-file index as a raw array (no validation). Missing/empty → [].
|
|
464
529
|
* A non-empty file that fails to parse THROWS — we must never silently treat a
|
|
@@ -482,6 +547,29 @@ export class JsonStore {
|
|
|
482
547
|
get(kind, id) {
|
|
483
548
|
return this.loadAll(kind).find((r) => r.id === id);
|
|
484
549
|
}
|
|
550
|
+
/** On-disk record count, independent of validation: per-record kinds count
|
|
551
|
+
* every non-tombstone .json file (a 0-byte merge tombstone is an intentional
|
|
552
|
+
* absence), single-file kinds count raw array entries (a corrupt index file
|
|
553
|
+
* throws readRawArray's own actionable refusal). Lets a caller about to
|
|
554
|
+
* DELETE the kind — `hunch private --migrate` — prove the validating loader
|
|
555
|
+
* dropped nothing first, instead of silently destroying the records loadAll
|
|
556
|
+
* skipped (issue #29, the same never-silently-drop contract as
|
|
557
|
+
* con_947c578b2c). */
|
|
558
|
+
rawRecordCount(kind) {
|
|
559
|
+
const directory = this.safeKindDirectory(kind, false);
|
|
560
|
+
if (!directory)
|
|
561
|
+
return 0;
|
|
562
|
+
const single = SINGLE_FILE[kind];
|
|
563
|
+
if (single)
|
|
564
|
+
return this.readRawArray(kind, directory, join(directory.lexical, single)).length;
|
|
565
|
+
let count = 0;
|
|
566
|
+
for (const name of this.jsonFileNames(kind)) {
|
|
567
|
+
const text = this.readContainedFile(directory, join(directory.lexical, name), this.maxBytes(kind));
|
|
568
|
+
if (text !== null && text.trim() !== "")
|
|
569
|
+
count++;
|
|
570
|
+
}
|
|
571
|
+
return count;
|
|
572
|
+
}
|
|
485
573
|
/** Remove a record (used by the curate/reject flow). Returns true if removed.
|
|
486
574
|
* For single-file kinds we operate on the RAW JSON array (not the validating
|
|
487
575
|
* loader) so deleting one record can't silently drop schema-invalid siblings. */
|
|
@@ -491,16 +579,20 @@ export class JsonStore {
|
|
|
491
579
|
const directory = this.safeKindDirectory(kind, false);
|
|
492
580
|
if (!directory)
|
|
493
581
|
return false;
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
582
|
+
// Locked like put(): an unsynchronized delete racing a concurrent put
|
|
583
|
+
// over the same index would resurrect or drop records (issue #35).
|
|
584
|
+
return this.withSingleFileLock(kind, directory, () => {
|
|
585
|
+
const f = this.fileFor(kind, "index");
|
|
586
|
+
const arr = this.readRawArray(kind, directory, f);
|
|
587
|
+
if (!this.validateExistingFile(directory, f, this.maxBytes(kind)))
|
|
588
|
+
return false;
|
|
589
|
+
const next = arr.filter((r) => r?.id !== id);
|
|
590
|
+
if (next.length === arr.length)
|
|
591
|
+
return false;
|
|
592
|
+
this.writeContainedFile(directory, f, encode(next), this.maxBytes(kind));
|
|
593
|
+
this.invalidate(kind);
|
|
594
|
+
return true;
|
|
595
|
+
});
|
|
504
596
|
}
|
|
505
597
|
this.assertSafeRecordId(id);
|
|
506
598
|
const directory = this.safeKindDirectory(kind, false);
|
|
@@ -21,6 +21,18 @@ export function movePublicMemoryToPrivate(pub, priv) {
|
|
|
21
21
|
let total = 0;
|
|
22
22
|
for (const kind of ENTITY_KINDS) {
|
|
23
23
|
const pubRecs = pub.loadAll(kind);
|
|
24
|
+
// The validating loader SKIPS corrupt/invalid/future-schema records with a
|
|
25
|
+
// warning — but the CLI empties the public store right after this returns,
|
|
26
|
+
// which would silently DELETE exactly those skipped records (and then
|
|
27
|
+
// untrack + gitignore their only git history). Refuse instead: prove every
|
|
28
|
+
// on-disk record actually loaded before anything becomes deletable
|
|
29
|
+
// (issue #29). A kind whose only records are invalid also stops here, so
|
|
30
|
+
// the "0 loaded → skip merge" path below can never precede a wipe.
|
|
31
|
+
const rawCount = pub.rawRecordCount(kind);
|
|
32
|
+
if (rawCount !== pubRecs.length) {
|
|
33
|
+
throw new Error(`refusing to migrate ${kind}: ${rawCount - pubRecs.length} on-disk record(s) failed to load (see the warnings above) `
|
|
34
|
+
+ `and would be deleted without ever reaching the overlay. Fix or remove them, then re-run \`hunch private --migrate\`.`);
|
|
35
|
+
}
|
|
24
36
|
if (pubRecs.length === 0)
|
|
25
37
|
continue;
|
|
26
38
|
const privRecs = priv.loadAll(kind);
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* Every provider returns the same shape so the rest of the system never knows
|
|
25
25
|
* (or cares) which one ran.
|
|
26
26
|
*/
|
|
27
|
-
import { spawn } from "node:child_process";
|
|
27
|
+
import { execFile, spawn } from "node:child_process";
|
|
28
28
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
29
29
|
import { isIP } from "node:net";
|
|
30
30
|
import { tmpdir } from "node:os";
|
|
@@ -66,6 +66,20 @@ export function pexecIn(cmd, args, opts = {}) {
|
|
|
66
66
|
let err = "";
|
|
67
67
|
let outLen = 0;
|
|
68
68
|
let settled = false;
|
|
69
|
+
// Windows spawns through a cmd.exe wrapper (shell:true), and child.kill()
|
|
70
|
+
// terminates only that wrapper — the actual agent CLI survives as an orphan,
|
|
71
|
+
// still burning the user's subscription after every timeout, accumulating
|
|
72
|
+
// with each post-commit hook fire (issue #44). taskkill /T fells the tree.
|
|
73
|
+
// Best-effort by design: the wrapper kill below still runs either way.
|
|
74
|
+
const killTree = () => {
|
|
75
|
+
if (IS_WIN && child.pid) {
|
|
76
|
+
try {
|
|
77
|
+
execFile("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true }, () => { });
|
|
78
|
+
}
|
|
79
|
+
catch { /* taskkill unavailable — fall through to the wrapper kill */ }
|
|
80
|
+
}
|
|
81
|
+
child.kill();
|
|
82
|
+
};
|
|
69
83
|
const done = (fn) => {
|
|
70
84
|
if (settled)
|
|
71
85
|
return;
|
|
@@ -76,7 +90,7 @@ export function pexecIn(cmd, args, opts = {}) {
|
|
|
76
90
|
};
|
|
77
91
|
const timer = opts.timeout
|
|
78
92
|
? setTimeout(() => {
|
|
79
|
-
|
|
93
|
+
killTree();
|
|
80
94
|
done(() => reject(new Error(`"${cmd}" timed out after ${opts.timeout}ms`)));
|
|
81
95
|
}, opts.timeout)
|
|
82
96
|
: null;
|
|
@@ -84,7 +98,7 @@ export function pexecIn(cmd, args, opts = {}) {
|
|
|
84
98
|
child.stdout.on("data", (d) => {
|
|
85
99
|
outLen += d.length;
|
|
86
100
|
if (outLen > max) {
|
|
87
|
-
|
|
101
|
+
killTree();
|
|
88
102
|
done(() => reject(new Error(`"${cmd}" exceeded maxBuffer (${max} bytes)`)));
|
|
89
103
|
return;
|
|
90
104
|
}
|
|
@@ -323,6 +337,18 @@ class ClaudeCliProvider extends PromptSynthProvider {
|
|
|
323
337
|
const childEnv = { ...process.env };
|
|
324
338
|
delete childEnv.ANTHROPIC_API_KEY;
|
|
325
339
|
delete childEnv.ANTHROPIC_AUTH_TOKEN;
|
|
340
|
+
// Gateway ROUTING is metered per-token exactly like a raw API key: with
|
|
341
|
+
// CLAUDE_CODE_USE_BEDROCK/VERTEX set (common in enterprise shell profiles
|
|
342
|
+
// for interactive use), headless `claude -p` bills AWS/GCP on every
|
|
343
|
+
// significant commit — silently, from a post-commit hook (issue #39,
|
|
344
|
+
// con_2ce3f2a547). Strip the routing switches and their endpoint overrides
|
|
345
|
+
// so the CLI falls through to subscription auth here too.
|
|
346
|
+
delete childEnv.CLAUDE_CODE_USE_BEDROCK;
|
|
347
|
+
delete childEnv.CLAUDE_CODE_USE_VERTEX;
|
|
348
|
+
delete childEnv.ANTHROPIC_BEDROCK_BASE_URL;
|
|
349
|
+
delete childEnv.ANTHROPIC_VERTEX_BASE_URL;
|
|
350
|
+
delete childEnv.ANTHROPIC_VERTEX_PROJECT_ID;
|
|
351
|
+
delete childEnv.CLOUD_ML_REGION;
|
|
326
352
|
// Single-shot text synthesis: no tools, no agentic loop. The prompt carries
|
|
327
353
|
// all needed context inline, so run from a neutral cwd to avoid loading this
|
|
328
354
|
// repo's own hunch MCP server / CLAUDE.md on every commit (cheaper, and no
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.1",
|
|
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.",
|