@davesheffer/hunch 1.10.0 → 1.10.2
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 +90 -32
- package/dist/core/conformance.js +19 -5
- package/dist/core/constraintmatch.js +11 -3
- package/dist/core/docscan.js +10 -0
- package/dist/core/drift.js +5 -2
- package/dist/core/io.js +64 -9
- package/dist/extractors/diff.js +13 -5
- package/dist/extractors/git.js +114 -29
- package/dist/extractors/languages.js +41 -28
- package/dist/extractors/nativeTreeSitter.js +6 -1
- package/dist/extractors/testreport.js +7 -1
- package/dist/integrations/claudemd.js +18 -4
- package/dist/integrations/providers.js +55 -17
- package/dist/integrations/scaffold.js +21 -5
- package/dist/mcp/roots.js +19 -3
- package/dist/mcp/server.js +26 -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/dist/wiki/wiki.js +89 -9
- package/package.json +1 -1
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.");
|
|
@@ -1518,6 +1527,7 @@ export function buildServerWithRootControl(initialRoot) {
|
|
|
1518
1527
|
server,
|
|
1519
1528
|
getRoot: () => root,
|
|
1520
1529
|
setRoot,
|
|
1530
|
+
cancelPendingRoot: () => { pendingRoot = null; },
|
|
1521
1531
|
};
|
|
1522
1532
|
}
|
|
1523
1533
|
/** Back-compatible server construction for tests and callers that do not need
|
|
@@ -1546,6 +1556,14 @@ export function wireClientRoots(control, fallback) {
|
|
|
1546
1556
|
return;
|
|
1547
1557
|
const next = resolveActiveRoot((response?.roots ?? []).map((root) => root.uri), fallback);
|
|
1548
1558
|
if (!next) {
|
|
1559
|
+
// Cancel a swap parked by an EARLIER generation. setRoot defers when a tool
|
|
1560
|
+
// request is in flight; if the client's advertised set has since become
|
|
1561
|
+
// ambiguous, applying that stale swap once the request drains would re-home
|
|
1562
|
+
// to a repo the client no longer unambiguously advertises — writing (and
|
|
1563
|
+
// auto-committing) a capture into the wrong repository, which is precisely
|
|
1564
|
+
// what this refusal exists to prevent. Without this the message below was
|
|
1565
|
+
// also a lie: the root did NOT stay put.
|
|
1566
|
+
control.cancelPendingRoot();
|
|
1549
1567
|
console.error("[hunch-mcp] multiple client roots are equally plausible; keeping the current Hunch root");
|
|
1550
1568
|
return;
|
|
1551
1569
|
}
|
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/dist/wiki/wiki.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { createHash } from "node:crypto";
|
|
30
30
|
import { existsSync, readFileSync, mkdirSync, rmSync } from "node:fs";
|
|
31
|
-
import { join, dirname } from "node:path";
|
|
31
|
+
import { join, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
32
32
|
import { writeFileAtomic } from "../core/io.js";
|
|
33
33
|
import { compareCodeUnits } from "../core/canonicalOrder.js";
|
|
34
34
|
import { hunchPaths, toPosixTarget } from "../core/paths.js";
|
|
@@ -36,15 +36,61 @@ import { isLive } from "../core/topics.js";
|
|
|
36
36
|
import { scanRepoDocs } from "../core/docscan.js";
|
|
37
37
|
import { adoptedSlug, adoptionHash, renderAdoptedDoc } from "./adopt.js";
|
|
38
38
|
import { assembleGraphData, renderGraphPage } from "./graph.js";
|
|
39
|
+
/** A wiki directory that is safe to join against the pages root: relative, POSIX,
|
|
40
|
+
* no trailing slash, and free of any "." / ".." / empty segment. `.` would alias
|
|
41
|
+
* the pages root itself (clobbering tracked files like README.md) and `..` writes
|
|
42
|
+
* the rendered graph — which for the private home is the FULL overlay union —
|
|
43
|
+
* outside the repository entirely. Returns undefined for anything unsafe. */
|
|
44
|
+
function validDir(d) {
|
|
45
|
+
if (!d)
|
|
46
|
+
return undefined;
|
|
47
|
+
const v = toPosixTarget(d).replace(/\/+$/, "");
|
|
48
|
+
if (!v || isAbsolute(v) || /^[a-zA-Z]:/.test(v))
|
|
49
|
+
return undefined;
|
|
50
|
+
if (v.split("/").some((seg) => seg === "" || seg === "." || seg === ".."))
|
|
51
|
+
return undefined;
|
|
52
|
+
return v;
|
|
53
|
+
}
|
|
39
54
|
/** Normalize a --dir override: POSIX separators, no trailing slash — the dir is
|
|
40
|
-
* a committed manifest key prefix, so it must hash identically on every OS.
|
|
41
|
-
|
|
55
|
+
* a committed manifest key prefix, so it must hash identically on every OS.
|
|
56
|
+
* An unsafe override THROWS rather than silently falling back, so a rejected flag
|
|
57
|
+
* can never quietly write somewhere else. */
|
|
58
|
+
const normDir = (d) => {
|
|
59
|
+
if (d === undefined)
|
|
60
|
+
return undefined;
|
|
61
|
+
const v = validDir(d);
|
|
62
|
+
if (!v) {
|
|
63
|
+
throw new Error(`refusing --dir ${JSON.stringify(d)}: the wiki directory must be a relative path inside the repository (no "." or ".." segments, not absolute).`);
|
|
64
|
+
}
|
|
65
|
+
return v;
|
|
66
|
+
};
|
|
67
|
+
/** Resolve a manifest page key to an absolute path INSIDE this home's page
|
|
68
|
+
* directory, or null when it escapes.
|
|
69
|
+
*
|
|
70
|
+
* Both inputs that reach the join are untrusted. `.hunch/wiki-manifest.json` is a
|
|
71
|
+
* COMMITTED file (so CI can gate on it), which makes every page KEY inside it
|
|
72
|
+
* PR- and merge-influenceable — and `wikiStatus` classifies any key no current
|
|
73
|
+
* artifact claims as an orphan to be deleted. Without this check a key like
|
|
74
|
+
* "../../id_rsa" turned a plain `hunch wiki` into arbitrary file deletion, with
|
|
75
|
+
* `hunch drift` politely instructing the maintainer to run it. Re-checked at BOTH
|
|
76
|
+
* the write and the delete site, so a manifest written before this guard existed
|
|
77
|
+
* still cannot escape. */
|
|
78
|
+
function containedPagePath(home, page) {
|
|
79
|
+
if (!page || page.includes("\0") || isAbsolute(page) || /^[a-zA-Z]:/.test(page))
|
|
80
|
+
return null;
|
|
81
|
+
const baseDir = resolve(join(home.pagesRoot, home.dir));
|
|
82
|
+
const abs = resolve(join(home.pagesRoot, ...page.split("/")));
|
|
83
|
+
const rel = relative(baseDir, abs);
|
|
84
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
85
|
+
return null;
|
|
86
|
+
return abs;
|
|
87
|
+
}
|
|
42
88
|
export function publicHome(root, dirOverride) {
|
|
43
89
|
const manifestPath = join(hunchPaths(root).hunch, "wiki-manifest.json");
|
|
44
90
|
return {
|
|
45
91
|
kind: "public",
|
|
46
92
|
pagesRoot: root,
|
|
47
|
-
dir: normDir(dirOverride) ?? readWikiManifestAt(manifestPath)?.dir ?? "wiki",
|
|
93
|
+
dir: normDir(dirOverride) ?? validDir(readWikiManifestAt(manifestPath)?.dir) ?? "wiki",
|
|
48
94
|
manifestPath,
|
|
49
95
|
source: "public",
|
|
50
96
|
};
|
|
@@ -60,7 +106,7 @@ export function privateHome(store, dirOverride) {
|
|
|
60
106
|
return {
|
|
61
107
|
kind: "private",
|
|
62
108
|
pagesRoot: dirname(store.privateDir),
|
|
63
|
-
dir: normDir(dirOverride) ?? readWikiManifestAt(manifestPath)?.dir ?? "wiki",
|
|
109
|
+
dir: normDir(dirOverride) ?? validDir(readWikiManifestAt(manifestPath)?.dir) ?? "wiki",
|
|
64
110
|
manifestPath,
|
|
65
111
|
source: "all",
|
|
66
112
|
};
|
|
@@ -515,6 +561,11 @@ export function wikiStatus(store, home, srcRoot) {
|
|
|
515
561
|
for (const [page, p] of Object.entries(manifest?.pages ?? {})) {
|
|
516
562
|
if (expected.has(page))
|
|
517
563
|
continue;
|
|
564
|
+
// The manifest is committed, so a key is attacker-influenceable through an
|
|
565
|
+
// ordinary PR — and reaching this list means "delete this file". Only a key
|
|
566
|
+
// that resolves inside this home's page directory may be classified at all.
|
|
567
|
+
if (!containedPagePath(home, page))
|
|
568
|
+
continue;
|
|
518
569
|
(p.component.startsWith(ADOPTED_PREFIX) ? adoptionOrphans : orphans).push(page);
|
|
519
570
|
}
|
|
520
571
|
return { home, entries, docs, adoptions, adoptionOrphans, decisions, specs, index, now, graph, repoWide, orphans };
|
|
@@ -556,7 +607,10 @@ export async function generateWiki(store, srcRoot, home, opts) {
|
|
|
556
607
|
/** Written-bytes ledger — the hand-edit tripwire recorded per page. */
|
|
557
608
|
const bytesByPage = new Map();
|
|
558
609
|
const put = (page, content) => {
|
|
559
|
-
|
|
610
|
+
const abs = containedPagePath(home, page);
|
|
611
|
+
if (!abs)
|
|
612
|
+
throw new Error(`refusing to write wiki page ${JSON.stringify(page)}: it resolves outside ${join(home.pagesRoot, home.dir)}`);
|
|
613
|
+
writeFileAtomic(abs, content);
|
|
560
614
|
bytesByPage.set(page, sha16(content));
|
|
561
615
|
written.push(page);
|
|
562
616
|
};
|
|
@@ -614,8 +668,16 @@ export async function generateWiki(store, srcRoot, home, opts) {
|
|
|
614
668
|
const removed = [];
|
|
615
669
|
for (const [pages, why] of [[status.orphans, "component gone"], [status.adoptionOrphans, "original healed or removed — copy retired"]]) {
|
|
616
670
|
for (const page of pages) {
|
|
671
|
+
// Re-assert containment at the DELETE site too: wikiStatus already filters
|
|
672
|
+
// escaping manifest keys, but this is the primitive that removes files, so it
|
|
673
|
+
// must not depend on an upstream filter having run.
|
|
674
|
+
const abs = containedPagePath(home, page);
|
|
675
|
+
if (!abs) {
|
|
676
|
+
log(` ⚠ ${page} — refusing to remove a manifest entry that resolves outside ${join(home.pagesRoot, home.dir)}`);
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
617
679
|
try {
|
|
618
|
-
rmSync(
|
|
680
|
+
rmSync(abs, { force: true });
|
|
619
681
|
}
|
|
620
682
|
catch {
|
|
621
683
|
/* best effort */
|
|
@@ -627,8 +689,26 @@ export async function generateWiki(store, srcRoot, home, opts) {
|
|
|
627
689
|
if (written.length || removed.length) {
|
|
628
690
|
const pages = {};
|
|
629
691
|
const entry = (page, component, hash, state) => {
|
|
630
|
-
const
|
|
631
|
-
|
|
692
|
+
const prev = prior?.pages[page];
|
|
693
|
+
const justWritten = bytesByPage.get(page);
|
|
694
|
+
// Carry the prior entry forward ONLY for a fresh page this run did not rewrite.
|
|
695
|
+
// A full regen (`only: "all"`) rewrites every page, so reusing the prior entry
|
|
696
|
+
// there recorded the OLD bytes against freshly written content — making the
|
|
697
|
+
// hand-edit tripwire fire on Hunch's own output, so `hunch wiki --check` exited
|
|
698
|
+
// 1 immediately after a successful `hunch wiki` and the tripwire's signal for a
|
|
699
|
+
// REAL hand edit was destroyed.
|
|
700
|
+
if (state === "fresh" && justWritten === undefined && prev) {
|
|
701
|
+
pages[page] = prev;
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
pages[page] = {
|
|
705
|
+
component,
|
|
706
|
+
hash,
|
|
707
|
+
// Keep the original stamp for an unchanged-but-rewritten page so a full regen
|
|
708
|
+
// stays byte-idempotent in git.
|
|
709
|
+
generated: state === "fresh" && prev ? prev.generated : opts.now,
|
|
710
|
+
bytes: justWritten ?? prev?.bytes,
|
|
711
|
+
};
|
|
632
712
|
};
|
|
633
713
|
for (const e of status.entries)
|
|
634
714
|
entry(e.page, e.pack.component.id, e.hash, e.state);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.2",
|
|
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.",
|