@davesheffer/hunch 1.40.0 → 1.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +60 -6
- package/dist/core/paths.d.ts +9 -1
- package/dist/core/paths.js +9 -1
- package/dist/core/taskQuery.d.ts +2 -0
- package/dist/core/taskQuery.js +2 -0
- package/dist/core/taskReportEvidence.js +3 -1
- package/dist/core/taskReportRender.d.ts +3 -0
- package/dist/core/taskReportRender.js +31 -13
- package/dist/extractors/git.d.ts +37 -0
- package/dist/extractors/git.js +56 -1
- package/dist/extractors/indexer.d.ts +5 -0
- package/dist/extractors/indexer.js +17 -2
- package/dist/extractors/parse.d.ts +3 -1
- package/dist/extractors/parse.js +6 -2
- package/dist/integrations/hooks.js +60 -7
- package/dist/integrations/team.js +11 -1
- package/dist/mcp/server.d.ts +125 -0
- package/dist/mcp/server.js +455 -3
- package/dist/store/hunchStore.d.ts +8 -0
- package/dist/store/hunchStore.js +14 -1
- package/dist/store/stateBinding.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/mcp/server.js
CHANGED
|
@@ -30,7 +30,7 @@ import { knownRepoDeps } from "../synthesis/tripwires.js";
|
|
|
30
30
|
import { refreshExistingGrounding } from "../integrations/providers.js";
|
|
31
31
|
import { workspaceLedgerView, renderWorktreeTable, renderBranchTable, workspaceSummaryLine, snapshotHasHome, recordWorkspaceSnapshot, branchRows, worktreeRows } from "../integrations/workspaceLedger.js";
|
|
32
32
|
import { workspacesConfig } from "../core/config.js";
|
|
33
|
-
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeGateDiff, commitFiles, commitGateDiff, stagedFiles, stagedGateDiff, workingFiles, workingGateDiff, pullHunchStatus, sameRemoteUrl, currentBranch } from "../extractors/git.js";
|
|
33
|
+
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeGateDiff, commitFiles, commitGateDiff, stagedFiles, stagedGateDiff, workingFiles, workingGateDiff, pullHunchStatus, sameRemoteUrl, currentBranch, worktreePaths, pathKnownToHistory } from "../extractors/git.js";
|
|
34
34
|
import { flushCapture, flushMemoryHome, pinSharedRemote } from "../integrations/sync.js";
|
|
35
35
|
import { withWriteLock } from "../serve/writelock.js";
|
|
36
36
|
import { advertisedTeamRemoteContract, ensureTeamOverlay, overlayMatchesTeamRemote, readTeamConfig, teamRemoteContract, teamSharedRef } from "../integrations/team.js";
|
|
@@ -73,8 +73,8 @@ import { premiseEscalations } from "../core/premises.js";
|
|
|
73
73
|
import { applyImportedAdrReview, pendingImportedAdrReviews } from "../core/importReview.js";
|
|
74
74
|
import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken, HUMAN_CONFIRMATION_SCHEMA, isHumanConfirmationAnswer } from "../core/capturetoken.js";
|
|
75
75
|
import { randomUUID } from "node:crypto";
|
|
76
|
-
import { existsSync } from "node:fs";
|
|
77
|
-
import { join } from "node:path";
|
|
76
|
+
import { existsSync, statSync, lstatSync } from "node:fs";
|
|
77
|
+
import { join, isAbsolute, resolve, relative, sep, parse, dirname } from "node:path";
|
|
78
78
|
import { initiatorFromClient, withInitiator } from "../synthesis/initiator.js";
|
|
79
79
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
80
80
|
const err = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
@@ -153,6 +153,433 @@ const destinationNote = (destRoot) => {
|
|
|
153
153
|
const branch = currentBranch(destRoot);
|
|
154
154
|
return ` [captured${branch ? ` on branch ${branch}` : ""} in ${destRoot}]`;
|
|
155
155
|
};
|
|
156
|
+
/** The cwd-hint fallback (see `cwdHintField` above) only helps when the CALLER
|
|
157
|
+
* remembers to pass it — a subagent spawned straight into its own linked
|
|
158
|
+
* worktree has no reason to think of the session's "starting directory" as
|
|
159
|
+
* different from its own, so the hint is silently omitted and an auto-committing
|
|
160
|
+
* write lands wherever `root` last was (often the primary checkout, on its
|
|
161
|
+
* default branch). This is a backstop, NOT a complete fix: it only catches a
|
|
162
|
+
* related file that's new/untracked at the resolved root but already exists in
|
|
163
|
+
* a sibling worktree — the common "edited an existing tracked file" case is
|
|
164
|
+
* invisible to a pure existence check (the file exists at every worktree, just
|
|
165
|
+
* with different content).
|
|
166
|
+
*
|
|
167
|
+
* Used by `misrouteGuard` below to cover every auto-committing write tool that
|
|
168
|
+
* names files: hunch_record_decision's related_files, hunch_record_correction's
|
|
169
|
+
* scope_hint_file, hunch_record_finding's affected_files, and nuryel_write's
|
|
170
|
+
* decisions/findings/bugs/constraints facets via `fileEvidenceFor`. Every
|
|
171
|
+
* matching sibling is named as a candidate `cwd` and the write is refused
|
|
172
|
+
* rather than risked; it returns every match rather than the first, since
|
|
173
|
+
* confidently naming just one would let a caller that blindly retries as
|
|
174
|
+
* instructed land in the WRONG worktree — the same failure mode one level
|
|
175
|
+
* removed.
|
|
176
|
+
*
|
|
177
|
+
* Two false-positive guards, both required, not optional hardening:
|
|
178
|
+
* - Empty related_files, or no sibling worktree doing any better, is silent — a
|
|
179
|
+
* decision that legitimately precedes its code (no files yet) is unaffected.
|
|
180
|
+
* - A related_files entry ABSENT at the root is not automatically suspicious: an
|
|
181
|
+
* ordinary delete/rename recorded correctly at the resolved root produces the
|
|
182
|
+
* exact same shape (file gone here, still present in whichever sibling
|
|
183
|
+
* worktree branched before the change) as a genuine misroute.
|
|
184
|
+
* `pathKnownToHistory` distinguishes them — a path this checkout's own HEAD
|
|
185
|
+
* has ever tracked explains its absence without invoking a worktree guess.
|
|
186
|
+
*
|
|
187
|
+
* Coverage boundary: only checks the file evidence passed in THIS call (related_files
|
|
188
|
+
* / scope_hint_file / affected_files), not values inherited from an existing record
|
|
189
|
+
* on re-record/supersede — a call that omits its file field to rely on inheritance
|
|
190
|
+
* won't trip this guard even if it's happening in the wrong worktree. That's a
|
|
191
|
+
* deliberate tradeoff against false positives on stale evidence, not full coverage
|
|
192
|
+
* of every misrouted write.
|
|
193
|
+
*
|
|
194
|
+
* Absolute paths: agents naturally send them (edit-tool payloads and MCP roots
|
|
195
|
+
* are absolute), and a NAIVE `join(dir, "/abs/path")` produces a nonsense
|
|
196
|
+
* concatenated path that exists nowhere — the guard would find nothing to
|
|
197
|
+
* compare against ANY directory and stay silent, reproducing the exact bug the
|
|
198
|
+
* guard exists to catch. `existsUnder` checks an absolute entry AS ITSELF,
|
|
199
|
+
* scoped to whichever directory is under test (root, or each candidate worktree
|
|
200
|
+
* in turn) via `deepestContainer`, not `join()` — so an absolute path naming a
|
|
201
|
+
* file that exists only under a worktree's own tree is direct positive evidence
|
|
202
|
+
* for that worktree specifically, no existence heuristic required. Relativizing
|
|
203
|
+
* it against `root` alone can't represent a SIBLING worktree's absolute path at
|
|
204
|
+
* all: it's never under root's tree, so it relativizes to "../…" and gets
|
|
205
|
+
* dropped — silently reintroducing the bypass one level removed. A NESTED
|
|
206
|
+
* worktree (`root/.worktrees/x`) is the opposite trap: it IS lexically under
|
|
207
|
+
* root's own tree, so a plain "is this path under root" check wrongly
|
|
208
|
+
* attributes it to root — `deepestContainer` picks the MOST SPECIFIC
|
|
209
|
+
* (longest-path) containing worktree, not just any containing ancestor, so root
|
|
210
|
+
* never swallows a worktree nested inside it. An absolute path outside every
|
|
211
|
+
* known worktree matches none and correctly contributes nothing: not "absent
|
|
212
|
+
* here, check the siblings", just nothing to compare. Canonicalized
|
|
213
|
+
* (`canonicalRootPath`, the same symlink/case resolution `resolveActiveRoot`
|
|
214
|
+
* uses above) on both sides before comparing, so a worktree reached through a
|
|
215
|
+
* symlink or a case-different spelling isn't silently missed, and compared by
|
|
216
|
+
* exact path SEGMENT, not string prefix, so a real sibling `foo-other` doesn't
|
|
217
|
+
* false-match `foo` and a file genuinely named `..odd.ts` isn't mistaken for a
|
|
218
|
+
* `..` traversal. A DIRECTORY entry (or the empty-string/"." an agent might
|
|
219
|
+
* send meaning "the repo itself") contributes nothing either: `existsUnder`
|
|
220
|
+
* checks `isFile`, never mere existence, since a directory match would silently
|
|
221
|
+
* disable the guard for every OTHER entry in the same call — and
|
|
222
|
+
* `pathKnownToHistory` (below) confirms a git pathspec matched the EXACT entry,
|
|
223
|
+
* not merely something under/matching it, for the identical reason.
|
|
224
|
+
*
|
|
225
|
+
* A RELATIVE entry containing a ".." segment that escapes root's own tree when
|
|
226
|
+
* resolved against root is the same misroute as a worktree-rooted absolute
|
|
227
|
+
* path, merely spelled relatively — resolved against ROOT specifically (the
|
|
228
|
+
* only base the server actually has; never re-resolved per candidate in the
|
|
229
|
+
* loop below, which would answer a different, meaningless question) and then
|
|
230
|
+
* run through the identical absolute-path containment logic. A relative entry
|
|
231
|
+
* that does NOT escape root's tree keeps the original, intentional
|
|
232
|
+
* multi-location check instead: the SAME relative suffix tried against every
|
|
233
|
+
* candidate directory in turn — that's how a plain "this file's name" evidence
|
|
234
|
+
* has always found a sibling worktree holding a file by that name, and it must
|
|
235
|
+
* keep doing so for a NESTED worktree reached by a non-escaping relative path,
|
|
236
|
+
* whose containing worktree this does not (yet) distinguish from root itself —
|
|
237
|
+
* that residual gap fails OPEN (silently uncaught), never toward a false
|
|
238
|
+
* positive.
|
|
239
|
+
*
|
|
240
|
+
* Exported for direct unit testing — the candidate logic is otherwise reachable
|
|
241
|
+
* only through a full MCP client/server integration test. */
|
|
242
|
+
export const misroutedWorktreeCandidates = (root, relatedFiles) => {
|
|
243
|
+
if (!relatedFiles.length)
|
|
244
|
+
return [];
|
|
245
|
+
const worktrees = worktreePaths(root);
|
|
246
|
+
const canonRoot = canonicalRootPath(root);
|
|
247
|
+
// Resolve against the CANONICAL root, not the raw `root` string. `f` almost
|
|
248
|
+
// always does NOT exist yet (that's the whole point of checking it) --
|
|
249
|
+
// canonicalRootPath's realpath then throws and falls back to the raw,
|
|
250
|
+
// un-resolved path. Resolving against raw `root` first and canonicalizing
|
|
251
|
+
// second means that fallback returns a path still spelled however `root` was
|
|
252
|
+
// spelled -- if `root` itself reaches the repo through a symlink (reachable
|
|
253
|
+
// via `hunch mcp --root <path through a symlink>`, which pins the root and
|
|
254
|
+
// skips the client-root canonicalization path entirely), an ORDINARY relative
|
|
255
|
+
// filename that never escapes root's own tree gets compared against the
|
|
256
|
+
// CANONICAL root and reads as escaping, silently disabling the guard for the
|
|
257
|
+
// exact shape it exists to catch. Resolving against the already-canonical
|
|
258
|
+
// root first means the common non-existent-file case is correctly rooted even
|
|
259
|
+
// when realpath's later canonicalization attempt on the (still nonexistent)
|
|
260
|
+
// resolved path has nothing to resolve and simply returns it unchanged.
|
|
261
|
+
//
|
|
262
|
+
// The escape check below is deliberately LEXICAL (plain resolve(), never
|
|
263
|
+
// canonicalRootPath) on the resolved path: whether a relative entry escapes
|
|
264
|
+
// root is a question about ".." SEGMENTS, answered by pure string math, not
|
|
265
|
+
// about where a SYMLINK at that location happens to point. Canonicalizing
|
|
266
|
+
// (following symlinks) here is the wrong move: a symlink that physically
|
|
267
|
+
// lives inside root but targets a sibling worktree (e.g. a shared cache file)
|
|
268
|
+
// would resolve to a target outside canonRoot, misclassifying an entry that
|
|
269
|
+
// legitimately exists at root as "escaping", and promoting it to an absolute
|
|
270
|
+
// path that deepestContainer then wrongly attributes to the sibling -- a
|
|
271
|
+
// false positive, refusing a write that was already correctly homed.
|
|
272
|
+
// Symlink-following still happens, and is still needed, one step later in
|
|
273
|
+
// deepestContainer/existsUnder, whose job (does this path's CONTENT belong to
|
|
274
|
+
// this specific worktree) is a genuinely different question from whether a
|
|
275
|
+
// relative STRING escapes root.
|
|
276
|
+
const evidence = relatedFiles.filter(Boolean).map((f) => {
|
|
277
|
+
if (isAbsolute(f))
|
|
278
|
+
return f;
|
|
279
|
+
const resolved = resolve(canonRoot, f);
|
|
280
|
+
return isWithin(canonRoot, resolved) ? f : resolved;
|
|
281
|
+
});
|
|
282
|
+
if (!evidence.length)
|
|
283
|
+
return [];
|
|
284
|
+
if (evidence.some((f) => existsUnder(root, f, worktrees)))
|
|
285
|
+
return [];
|
|
286
|
+
if (relatedFiles.some((f) => f && !isAbsolute(f) && pathKnownToHistory(root, f)))
|
|
287
|
+
return [];
|
|
288
|
+
const here = canonRoot;
|
|
289
|
+
const candidates = [];
|
|
290
|
+
for (const candidate of worktrees) {
|
|
291
|
+
if (canonicalRootPath(candidate) === here)
|
|
292
|
+
continue;
|
|
293
|
+
if (evidence.some((f) => existsUnder(candidate, f, worktrees)))
|
|
294
|
+
candidates.push(candidate);
|
|
295
|
+
}
|
|
296
|
+
return candidates;
|
|
297
|
+
};
|
|
298
|
+
/** True when path segment `child` (canonical) lies inside directory `parent`
|
|
299
|
+
* (canonical) — an exact segment boundary, not a string prefix, so a sibling
|
|
300
|
+
* `foo-other` never false-matches `foo` and `parent` itself counts as inside. */
|
|
301
|
+
function isWithin(parent, child) {
|
|
302
|
+
const rel = relative(parent, child);
|
|
303
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
304
|
+
}
|
|
305
|
+
/** The worktree (from `worktrees`, which always includes root itself — see
|
|
306
|
+
* `worktreePaths`) whose OWN directory tree most SPECIFICALLY contains ABSOLUTE
|
|
307
|
+
* path `f` — the deepest/longest match, not merely any containing ancestor. Null
|
|
308
|
+
* when `f` is under none of the known worktrees. `canonicalRootPath` resolves the
|
|
309
|
+
* WHOLE path including `f`'s own final component, so a symlink reached from
|
|
310
|
+
* OUTSIDE a worktree that happens to point INSIDE one is still found (a symlink
|
|
311
|
+
* ALIAS to a whole worktree). This is the CANONICAL (symlink-resolved) ownership
|
|
312
|
+
* lens; `existsUnder` also checks a LEXICAL lens via `lexicalDeepestContainer`
|
|
313
|
+
* below, for the opposite symlink shape this lens alone cannot see. */
|
|
314
|
+
function deepestContainer(f, worktrees) {
|
|
315
|
+
const canonF = canonicalRootPath(f);
|
|
316
|
+
let best = null;
|
|
317
|
+
let bestLen = -1;
|
|
318
|
+
for (const wt of worktrees) {
|
|
319
|
+
const canonWt = canonicalRootPath(wt);
|
|
320
|
+
if (!isWithin(canonWt, canonF))
|
|
321
|
+
continue;
|
|
322
|
+
if (canonWt.length > bestLen) {
|
|
323
|
+
best = wt;
|
|
324
|
+
bestLen = canonWt.length;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return best;
|
|
328
|
+
}
|
|
329
|
+
/** Walks ABSOLUTE path `f` component by component to find its PHYSICAL
|
|
330
|
+
* location: a plain component is appended lexically and never resolved; a
|
|
331
|
+
* ".." only consults the kernel (fully resolving whatever symlink chain led
|
|
332
|
+
* there, via `canonicalRootPath`) when the component it's popping is ITSELF
|
|
333
|
+
* a symlink -- the one case where the kernel's ".."-cancellation point (the
|
|
334
|
+
* symlink's TARGET's parent) differs from the lexical parent. A ".." popping
|
|
335
|
+
* a plain directory stays a lexical `dirname()`, since lexical and kernel
|
|
336
|
+
* resolution already agree there. `usedKernel` tells the caller whether any
|
|
337
|
+
* kernel call actually happened, so it knows whether `path` is canonical (and
|
|
338
|
+
* therefore whether a worktree path must be canonicalized too before
|
|
339
|
+
* comparing) or still purely lexical. Splits on `\` as well as `/` ONLY when
|
|
340
|
+
* the platform separator is itself `\` (win32) -- on POSIX, `\` is an
|
|
341
|
+
* ordinary, legal filename byte, not a separator; unconditionally treating it
|
|
342
|
+
* as one chops a single real directory entry into two synthetic components,
|
|
343
|
+
* which can misattribute a file whose name happens to contain a literal
|
|
344
|
+
* backslash to whichever worktree the FIRST synthetic component's name
|
|
345
|
+
* matches. A win32 path may legitimately use either separator, so both are
|
|
346
|
+
* split there. */
|
|
347
|
+
function walkPhysicalLocation(f) {
|
|
348
|
+
const root = parse(f).root;
|
|
349
|
+
const splitter = sep === "\\" ? /[\\/]/ : "/";
|
|
350
|
+
const segments = f.slice(root.length).split(splitter).filter(Boolean);
|
|
351
|
+
let phys = root || sep;
|
|
352
|
+
let usedKernel = false;
|
|
353
|
+
for (const seg of segments) {
|
|
354
|
+
if (seg === ".")
|
|
355
|
+
continue;
|
|
356
|
+
if (seg !== "..") {
|
|
357
|
+
phys = join(phys, seg);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
let base = phys;
|
|
361
|
+
try {
|
|
362
|
+
if (lstatSync(phys).isSymbolicLink()) {
|
|
363
|
+
base = canonicalRootPath(phys);
|
|
364
|
+
usedKernel = true;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
// unreadable or nonexistent -- fall through to a plain lexical pop
|
|
369
|
+
}
|
|
370
|
+
phys = dirname(base);
|
|
371
|
+
}
|
|
372
|
+
return { path: phys, usedKernel };
|
|
373
|
+
}
|
|
374
|
+
/** The worktree whose own directory tree most specifically contains ABSOLUTE path
|
|
375
|
+
* `f` AS PHYSICALLY PLACED — its own final path component is never resolved
|
|
376
|
+
* through a symlink, unlike `deepestContainer`. A symlink physically sitting
|
|
377
|
+
* INSIDE a worktree, whose target lives elsewhere (e.g. a shared cache file), is
|
|
378
|
+
* still legitimately "at" that worktree: `deepestContainer` alone resolves such
|
|
379
|
+
* an `f` to whichever worktree the symlink's TARGET lives in, misattributing a
|
|
380
|
+
* file that genuinely exists where it's spelled and refusing an
|
|
381
|
+
* already-correctly-homed write. Ranked by the SAME "deepest/longest match" rule
|
|
382
|
+
* as the canonical lens, so a NESTED worktree still correctly out-ranks its own
|
|
383
|
+
* physically-containing parent root here too — this lens does not, on its own,
|
|
384
|
+
* relax that boundary.
|
|
385
|
+
*
|
|
386
|
+
* Delegates to `walkPhysicalLocation` (above), which decides PER PATH COMPONENT
|
|
387
|
+
* whether the kernel needs consulting at all: a ".." only triggers a kernel call
|
|
388
|
+
* when the specific component it pops is itself a symlink, never merely because
|
|
389
|
+
* `f` happens to contain a ".." somewhere. A purely lexical comparison (plain
|
|
390
|
+
* `resolve()`/`relative()`, no realpath anywhere) disagrees with the kernel on
|
|
391
|
+
* any `f` containing symlink-then-"..": direct kernel-level testing (a real
|
|
392
|
+
* `open()`/`readFile()` on a constructed symlink+".." path, comparing file
|
|
393
|
+
* identity, not just `resolve()`/`realpath()` string output) shows the kernel
|
|
394
|
+
* cancels ".." against the parent of the CURRENT LOOKUP DIRECTORY, which after
|
|
395
|
+
* traversing a symlink component is the symlink's TARGET's parent — exactly
|
|
396
|
+
* what `path_resolution(7)` documents. A purely lexical comparison disagrees
|
|
397
|
+
* with the kernel both as a false positive (a symlinked dir physically inside a
|
|
398
|
+
* sibling worktree, entry `<worktree>/cache/../stray.ts` really resolving
|
|
399
|
+
* OUTSIDE every worktree, wrongly attributed to `<worktree>`) and a false
|
|
400
|
+
* negative (an alias symlink at root pointing into a sibling worktree, entry
|
|
401
|
+
* `<root>/alias/../only-in-wt.ts` really resolving INSIDE the sibling, wrongly
|
|
402
|
+
* attributed to nothing). Resolving `dirname(f)` through the kernel
|
|
403
|
+
* UNCONDITIONALLY (regardless of whether "..") is stronger than the problem
|
|
404
|
+
* requires: it follows every symlink in the directory chain even with no ".."
|
|
405
|
+
* present at all, resolving straight through the plain symlinked-DIRECTORY
|
|
406
|
+
* shape this function exists to serve. Gating the kernel resolution on whether
|
|
407
|
+
* `f` contains ANY ".." segment, then resolving the WHOLE directory chain once
|
|
408
|
+
* triggered, is also too coarse: a harmless ".." with no symlink anywhere near
|
|
409
|
+
* it (e.g. `<worktree>/subdir/../shared-src/helper.ts`, where `subdir` is a
|
|
410
|
+
* plain directory and `shared-src` is a symlink) would still route the WHOLE
|
|
411
|
+
* path through the kernel, following `shared-src` and misattributing it. This
|
|
412
|
+
* function decides PER COMPONENT instead: walking `f` left to right, a plain
|
|
413
|
+
* component is appended lexically (never resolved); a ".." only consults the
|
|
414
|
+
* kernel when the component it's popping is ITSELF a symlink — the one case
|
|
415
|
+
* where the kernel's cancellation point differs from the lexical parent. Any
|
|
416
|
+
* symlink NOT immediately followed by a ".." is never resolved (preserving the
|
|
417
|
+
* physical-location contract), and every ".." that actually needs the kernel
|
|
418
|
+
* gets it, regardless of how many harmless ".."s or plain directories surround
|
|
419
|
+
* it. */
|
|
420
|
+
function lexicalDeepestContainer(f, worktrees) {
|
|
421
|
+
const { path: spelled, usedKernel } = walkPhysicalLocation(f);
|
|
422
|
+
let best = null;
|
|
423
|
+
let bestLen = -1;
|
|
424
|
+
for (const wt of worktrees) {
|
|
425
|
+
// `wt` must be canonicalized too, but ONLY to match a canonical `spelled`
|
|
426
|
+
// -- comparing a canonical spelled path against a raw wt string would
|
|
427
|
+
// spuriously fail to match even a worktree with no symlinks in its own
|
|
428
|
+
// path, on any platform where the canonical form differs cosmetically
|
|
429
|
+
// (e.g. a case-preserving vs case-folding mount). `git worktree list`
|
|
430
|
+
// already returns realpaths in every fixture this suite has produced, so
|
|
431
|
+
// this branch is defence-in-depth against a `worktrees` source that one
|
|
432
|
+
// day doesn't.
|
|
433
|
+
const compareWt = usedKernel ? canonicalRootPath(wt) : wt;
|
|
434
|
+
if (!isWithin(compareWt, spelled))
|
|
435
|
+
continue;
|
|
436
|
+
if (compareWt.length > bestLen) {
|
|
437
|
+
best = wt;
|
|
438
|
+
bestLen = compareWt.length;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return best;
|
|
442
|
+
}
|
|
443
|
+
/** True when `p` names an existing FILE — never a directory. `existsSync` alone
|
|
444
|
+
* would be true for a directory too; since a match here short-circuits the whole
|
|
445
|
+
* misroute check (`misroutedWorktreeCandidates`'s early `.some()`), a single
|
|
446
|
+
* DIRECTORY entry among a call's file evidence would silently disable the guard
|
|
447
|
+
* for every other entry in the same call — including the natural "." / "" an
|
|
448
|
+
* agent might send meaning "the repo" (`existsSync(join(dir, ""))` is
|
|
449
|
+
* `existsSync(dir)`, always true). */
|
|
450
|
+
function isFile(p) {
|
|
451
|
+
try {
|
|
452
|
+
return statSync(p).isFile();
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function existsUnder(dir, f, worktrees) {
|
|
459
|
+
if (!f)
|
|
460
|
+
return false; // "" / "./" normalize to "" — nothing to compare, never `dir` itself
|
|
461
|
+
if (isAbsolute(f)) {
|
|
462
|
+
if (!isFile(f))
|
|
463
|
+
return false;
|
|
464
|
+
// `dir` owns `f` if EITHER lens says so, checked independently -- not a single
|
|
465
|
+
// merged ranking across both, which would let a longer-named sibling
|
|
466
|
+
// out-rank root's own genuine lexical claim by string length alone. Each
|
|
467
|
+
// lens resolves nested-vs-parent ambiguity within itself (see each
|
|
468
|
+
// function's own doc comment), so this OR never lets root reclaim a file
|
|
469
|
+
// that legitimately belongs to a worktree nested inside it.
|
|
470
|
+
const canonicalOwner = deepestContainer(f, worktrees);
|
|
471
|
+
if (canonicalOwner && canonicalRootPath(canonicalOwner) === canonicalRootPath(dir))
|
|
472
|
+
return true;
|
|
473
|
+
const lexicalOwner = lexicalDeepestContainer(f, worktrees);
|
|
474
|
+
return !!lexicalOwner && canonicalRootPath(lexicalOwner) === canonicalRootPath(dir);
|
|
475
|
+
}
|
|
476
|
+
return isFile(join(dir, f));
|
|
477
|
+
}
|
|
478
|
+
/** True when `f` names evidence the disambiguation in `guardEvidence` can trust
|
|
479
|
+
* as real: an existing file on disk (absolute, or relative under `root`), or —
|
|
480
|
+
* for a relative entry only — one git already knows was tracked at `root`
|
|
481
|
+
* (covers a since-deleted/renamed file, which has nothing left on disk to
|
|
482
|
+
* check). */
|
|
483
|
+
function namesRealEvidence(root, f) {
|
|
484
|
+
return isFile(isAbsolute(f) ? f : join(root, f)) || (!isAbsolute(f) && pathKnownToHistory(root, f));
|
|
485
|
+
}
|
|
486
|
+
/** Normalizes file evidence for the misroute guard specifically. A literal
|
|
487
|
+
* backslash byte is a legal POSIX filename character, not a path separator,
|
|
488
|
+
* so blindly running every entry through `toPosixTarget` before
|
|
489
|
+
* `misroutedWorktreeCandidates` sees it can rewrite a real file's name into a
|
|
490
|
+
* fake extra path segment. The string alone can't disambiguate "Windows
|
|
491
|
+
* separator" from "literal POSIX byte", so this asks the filesystem AND git
|
|
492
|
+
* history instead (`namesRealEvidence`): when the RAW, un-normalized entry
|
|
493
|
+
* already names a real file, or is a relative path git knows was tracked at
|
|
494
|
+
* `root` (possibly since deleted/renamed), that settles it — the byte was
|
|
495
|
+
* literal — and the raw string is kept as-is, bypassing `toPosixTarget`
|
|
496
|
+
* entirely. Otherwise it normalizes through `toPosixTarget` exactly as
|
|
497
|
+
* before, preserving the legitimate Windows-separator case (a Windows-style
|
|
498
|
+
* path can never collide with a real or once-tracked POSIX file, since `\`
|
|
499
|
+
* cannot appear in a Windows filename to begin with). Scoped to the
|
|
500
|
+
* misroute-guard call sites only — the stored related_files/affected_files
|
|
501
|
+
* fields are still normalized separately, unaffected by this.
|
|
502
|
+
*
|
|
503
|
+
* Known accepted gap: a contrived collision — a file legitimately deleted
|
|
504
|
+
* from `root`'s history AND a genuine Windows-style path meaning something
|
|
505
|
+
* else present in a sibling worktree — resolves in favor of history and
|
|
506
|
+
* fails OPEN (no misroute reported), the same fail-open tradeoff
|
|
507
|
+
* `misroutedWorktreeCandidates` itself already documents elsewhere. */
|
|
508
|
+
export function guardEvidence(root, files) {
|
|
509
|
+
return files.map((f) => {
|
|
510
|
+
const normalized = toPosixTarget(f);
|
|
511
|
+
return f && normalized !== f && namesRealEvidence(root, f) ? f : normalized;
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
/** Shared misroute-guard refusal for the auto-committing write tools that name
|
|
515
|
+
* files (hunch_record_decision, hunch_record_correction, hunch_record_finding,
|
|
516
|
+
* and nuryel_write's decisions/findings/bugs/constraints facets via
|
|
517
|
+
* `fileEvidenceFor`) — one message so every call site stays in lockstep instead
|
|
518
|
+
* of drifting. Returns the refusal ToolResult when `misroutedWorktreeCandidates`
|
|
519
|
+
* finds a better home, else null (proceed as normal). `subject` names what's
|
|
520
|
+
* being recorded, e.g. `"Foo"` or `finding "Foo"`, for the refusal text. `extra`,
|
|
521
|
+
* when given, is appended as a further sentence — for a caller (nuryel_write)
|
|
522
|
+
* whose retry needs more than just `cwd` moved. Callers pass file evidence
|
|
523
|
+
* through `guardEvidence` — `misroutedWorktreeCandidates` itself understands
|
|
524
|
+
* both relative and absolute entries, so no pre-relativization step is needed or
|
|
525
|
+
* correct here (see its doc comment on why relativizing against `root` alone
|
|
526
|
+
* would drop the exact sibling-worktree case this guard exists to catch). */
|
|
527
|
+
function misrouteGuard(root, subject, relatedFiles, extra) {
|
|
528
|
+
const misroutes = misroutedWorktreeCandidates(root, relatedFiles);
|
|
529
|
+
if (!misroutes.length)
|
|
530
|
+
return null;
|
|
531
|
+
const branch = currentBranch(root);
|
|
532
|
+
const plural = misroutes.length > 1;
|
|
533
|
+
const where = plural
|
|
534
|
+
? `they do in these linked worktrees: ${misroutes.join(", ")}`
|
|
535
|
+
: `they do in the linked worktree ${misroutes[0]}`;
|
|
536
|
+
const retry = plural
|
|
537
|
+
? `retry with cwd pointing at whichever of those is actually correct`
|
|
538
|
+
: `retry with cwd:"${misroutes[0]}"`;
|
|
539
|
+
return err(`Refusing to record ${subject} in ${root}${branch ? ` (branch ${branch})` : ""}: ` +
|
|
540
|
+
`none of the files it names exist there, but ${where}. This call is very likely missing the cwd ` +
|
|
541
|
+
`argument — ${retry}, or wherever this work actually happened.${extra ? ` ${extra}` : ""}`);
|
|
542
|
+
}
|
|
543
|
+
/** The file-evidence field, if any, each nuryel_write facet carries: related_files
|
|
544
|
+
* for decisions, affected_files for findings/bugs, and scope (the glob list a
|
|
545
|
+
* constraint applies to — the same role hunch_record_correction's
|
|
546
|
+
* scope_hint_file plays) for constraints. A TOTAL map over StateFacet, not an
|
|
547
|
+
* open-ended ternary chain: adding a facet to STATE_FACETS without adding it
|
|
548
|
+
* here is a compile error, not a silent gap. null means the facet carries no
|
|
549
|
+
* comparable field and is left unguarded rather than inventing one. Exported so
|
|
550
|
+
* tests can assert the mapping directly instead of only probing it through live
|
|
551
|
+
* misroute behavior. */
|
|
552
|
+
export const FILE_EVIDENCE_FIELD = {
|
|
553
|
+
decisions: "related_files",
|
|
554
|
+
findings: "affected_files",
|
|
555
|
+
bugs: "affected_files",
|
|
556
|
+
constraints: "scope",
|
|
557
|
+
receipts: null,
|
|
558
|
+
commitments: null,
|
|
559
|
+
derived: null,
|
|
560
|
+
entities: null,
|
|
561
|
+
relationships: null,
|
|
562
|
+
conventions: null,
|
|
563
|
+
};
|
|
564
|
+
/** `record` is caller-supplied z.record(string, unknown) — defensive by
|
|
565
|
+
* construction, so anything other than an array of strings reads as no
|
|
566
|
+
* evidence instead of throwing. */
|
|
567
|
+
export function fileEvidenceFor(facet, record) {
|
|
568
|
+
const field = FILE_EVIDENCE_FIELD[facet];
|
|
569
|
+
if (!field)
|
|
570
|
+
return [];
|
|
571
|
+
const value = record[field];
|
|
572
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
|
|
573
|
+
}
|
|
574
|
+
/** The misrouteGuard `subject` for a nuryel_write call, matching the
|
|
575
|
+
* "<kind> \"<label>\"" style hunch_record_decision/_correction/_finding use for
|
|
576
|
+
* the same refusal text. Constraints have no title, only a statement. */
|
|
577
|
+
function nuryelWriteSubject(facet, record) {
|
|
578
|
+
const labelField = facet === "decisions" || facet === "findings" || facet === "bugs" ? "title" : facet === "constraints" ? "statement" : null;
|
|
579
|
+
const label = labelField ? record[labelField] : undefined;
|
|
580
|
+
const kind = facet.endsWith("s") ? facet.slice(0, -1) : facet;
|
|
581
|
+
return `${kind}${typeof label === "string" ? ` "${label.slice(0, 60)}"` : ""} (nuryel_write)`;
|
|
582
|
+
}
|
|
156
583
|
/** Where a capture keyed to `home` actually lands: the private overlay directory when
|
|
157
584
|
* one is configured, else the public repo root. Centralizes the branch used at every
|
|
158
585
|
* destination-reporting call site below — `hunch_policy_upgrade_correction` once
|
|
@@ -1550,6 +1977,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1550
1977
|
},
|
|
1551
1978
|
}, async ({ decision, capture_token, task_id }) => {
|
|
1552
1979
|
try {
|
|
1980
|
+
const misroute = misrouteGuard(root, `"${decision.title.slice(0, 60)}"`, guardEvidence(root, decision.related_files ?? []));
|
|
1981
|
+
if (misroute)
|
|
1982
|
+
return misroute;
|
|
1553
1983
|
// Commit-keyed on the CANONICAL full sha (resolved via git rev-parse), so a
|
|
1554
1984
|
// human passing the short sha they see in `commit` produces the SAME id as
|
|
1555
1985
|
// the auto-sync path (which keys on the full sha) — UPGRADING the auto-draft
|
|
@@ -1784,6 +2214,16 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1784
2214
|
try {
|
|
1785
2215
|
if (!input.rule || !input.rule.trim())
|
|
1786
2216
|
return invalid("rule is required — state the invariant in plain words.");
|
|
2217
|
+
// Same misroute guard as hunch_record_decision: a scope_hint_file that exists in a
|
|
2218
|
+
// sibling linked worktree but not here is very likely a subagent that forgot cwd,
|
|
2219
|
+
// about to silently scope-and-commit a constraint against the wrong checkout.
|
|
2220
|
+
// Passed through guardEvidence — misroutedWorktreeCandidates understands absolute
|
|
2221
|
+
// paths itself (see its doc comment). The constraint's own scope glob below is a
|
|
2222
|
+
// DIFFERENT job, still relativized against root by buildCorrectionConstraint
|
|
2223
|
+
// internally — a sibling worktree's file evidence never reaches that path.
|
|
2224
|
+
const correctionMisroute = misrouteGuard(root, `correction "${input.rule.slice(0, 60)}"`, input.scope_hint_file ? guardEvidence(root, [input.scope_hint_file]) : []);
|
|
2225
|
+
if (correctionMisroute)
|
|
2226
|
+
return correctionMisroute;
|
|
1787
2227
|
// root: relativizes an ABSOLUTE scope_hint_file. Agents naturally send absolute
|
|
1788
2228
|
// paths (edit-tool payloads and MCP roots are absolute) and every consumer matches
|
|
1789
2229
|
// repo-relative — without this the rule would be blocking-but-inert and would leak
|
|
@@ -1880,6 +2320,10 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1880
2320
|
return invalid("title is required.");
|
|
1881
2321
|
if (!finding.observation.trim())
|
|
1882
2322
|
return invalid("observation is required — state what you saw.");
|
|
2323
|
+
// Same misroute guard as hunch_record_decision.
|
|
2324
|
+
const findingMisroute = misrouteGuard(root, `finding "${finding.title.slice(0, 60)}"`, guardEvidence(root, finding.affected_files ?? []));
|
|
2325
|
+
if (findingMisroute)
|
|
2326
|
+
return findingMisroute;
|
|
1883
2327
|
const id = findingId(finding.title);
|
|
1884
2328
|
const home = store.captureHome(!!finding.private);
|
|
1885
2329
|
const existing = home === "private" ? store.getPrivateRec("findings", id) : store.json.get("findings", id);
|
|
@@ -2024,6 +2468,14 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
2024
2468
|
outputSchema: WriteResultSchema.shape,
|
|
2025
2469
|
}, async ({ cwd: _cwd, ...input }) => {
|
|
2026
2470
|
try {
|
|
2471
|
+
// Same misroute guard as hunch_record_decision/_correction/_finding. Unlike those
|
|
2472
|
+
// three, nuryel_write's `scope`/`principal.grants` are a repository partition tied
|
|
2473
|
+
// to the CURRENT root: retrying with only `cwd` moved re-homes the store but leaves
|
|
2474
|
+
// the request scoped to the old root's partition, which fails loudly (not silently)
|
|
2475
|
+
// on the retry — misrouteGuard's `extra` spells out the additional step.
|
|
2476
|
+
const misroute = misrouteGuard(root, nuryelWriteSubject(input.facet, input.record), guardEvidence(root, fileEvidenceFor(input.facet, input.record)), "Also move `scope` (and `principal.grants`) to the repository partition for that worktree — nuryel_write's scope does not follow `cwd` automatically.");
|
|
2477
|
+
if (misroute)
|
|
2478
|
+
return misroute;
|
|
2027
2479
|
// Same cross-process lock `hunch serve` takes: a second agent writing over stdio must
|
|
2028
2480
|
// not race the HTTP server between the ledger read and the record write.
|
|
2029
2481
|
const { hunchDir } = stateHomeFor(store, input.scope);
|
|
@@ -505,6 +505,14 @@ export declare class HunchStore {
|
|
|
505
505
|
* newest-first, with its valid-time window and supersession links. Answers
|
|
506
506
|
* "what did we believe, and when/why did it change?" (hunch_timeline). */
|
|
507
507
|
timeline(target: string): Decision[];
|
|
508
|
+
/** Invalidate, don't delete: close an active constraint's valid-time window
|
|
509
|
+
* (status → "retired", valid_to → now) so `hunch check` and the strict hook
|
|
510
|
+
* stop enforcing it while its full history stays on record. The caller has
|
|
511
|
+
* already resolved `existing` (id lookup + already-retired refusal are the
|
|
512
|
+
* CLI's job, not this method's), so there is no internal re-lookup and no
|
|
513
|
+
* nullable return. Writes to whichever store already holds the record
|
|
514
|
+
* (`putWhereItLives`), so a public/private twin can never be forked. */
|
|
515
|
+
retireConstraint(existing: Constraint): Constraint;
|
|
508
516
|
/** Invalidate, don't delete (Zep edge-invalidation): close `oldId`'s valid-time
|
|
509
517
|
* window at the superseding decision's `valid_from`, mark it superseded + linked,
|
|
510
518
|
* and write a `supersedes` edge. Returns the updated old decision, or null if it
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -148,7 +148,10 @@ export class HunchStore {
|
|
|
148
148
|
const distinctNestedRoot = nestedBoundary ? gitWorktreeRoot(publicationProbe) : null;
|
|
149
149
|
if (canonical(candidate) === canonical(this.paths.hunch) ||
|
|
150
150
|
(nestedBoundary && (!distinctNestedRoot || sameGitPublication(distinctNestedRoot, this.paths.root))) ||
|
|
151
|
-
|
|
151
|
+
// If these paths are identical, the nested-boundary clause just ran
|
|
152
|
+
// this exact proof. Different probe paths still need their own check;
|
|
153
|
+
// no result is retained across store opens or publication operations.
|
|
154
|
+
(distinctNestedRoot !== publicationProbe && sameGitPublication(publicationProbe, this.paths.root))) {
|
|
152
155
|
throw new Error(`Unsafe private overlay "${candidate}" shares the public code repository's local or remote publication boundary. ` +
|
|
153
156
|
"Run `hunch private` or `hunch shared --repo <url>` to create a standalone overlay repository.");
|
|
154
157
|
}
|
|
@@ -1846,6 +1849,16 @@ export class HunchStore {
|
|
|
1846
1849
|
timeline(target) {
|
|
1847
1850
|
return this.why(target).decisions.sort((a, b) => (b.valid_from ?? b.date).localeCompare(a.valid_from ?? a.date));
|
|
1848
1851
|
}
|
|
1852
|
+
/** Invalidate, don't delete: close an active constraint's valid-time window
|
|
1853
|
+
* (status → "retired", valid_to → now) so `hunch check` and the strict hook
|
|
1854
|
+
* stop enforcing it while its full history stays on record. The caller has
|
|
1855
|
+
* already resolved `existing` (id lookup + already-retired refusal are the
|
|
1856
|
+
* CLI's job, not this method's), so there is no internal re-lookup and no
|
|
1857
|
+
* nullable return. Writes to whichever store already holds the record
|
|
1858
|
+
* (`putWhereItLives`), so a public/private twin can never be forked. */
|
|
1859
|
+
retireConstraint(existing) {
|
|
1860
|
+
return this.putWhereItLives("constraints", { ...existing, status: "retired", valid_to: new Date().toISOString() });
|
|
1861
|
+
}
|
|
1849
1862
|
/** Invalidate, don't delete (Zep edge-invalidation): close `oldId`'s valid-time
|
|
1850
1863
|
* window at the superseding decision's `valid_from`, mark it superseded + linked,
|
|
1851
1864
|
* and write a `supersedes` edge. Returns the updated old decision, or null if it
|
|
@@ -1036,7 +1036,7 @@ export function subscribeState(store, input, options = {}) {
|
|
|
1036
1036
|
const access = createStateAccess(store, request.principal, options);
|
|
1037
1037
|
const visibilityFiltered = ledger.events.some(e => e.visibility !== undefined) || access.restricted;
|
|
1038
1038
|
const filtered = !!(facets || subjects || visibilityFiltered);
|
|
1039
|
-
const resync = request.after_seq < ledger.floor_seq;
|
|
1039
|
+
const resync = request.after_seq < ledger.floor_seq || request.after_seq > ledger.head_seq;
|
|
1040
1040
|
const after = resync ? ledger.floor_seq : request.after_seq;
|
|
1041
1041
|
const events = ledger.events.filter((e) => e.seq > after
|
|
1042
1042
|
&& (!visibilityFiltered || (visibilityAllows(e, request.principal.id) && access.canRead(findRecord(store, e.record_id)?.record) && access.referencesVisible(e)))
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://www.hunchmemory.com",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.41.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@davesheffer/hunch",
|
|
16
|
-
"version": "1.
|
|
16
|
+
"version": "1.41.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|