@davesheffer/hunch 1.40.1 → 1.41.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.
@@ -8,8 +8,133 @@
8
8
  */
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { HunchStore } from "../store/hunchStore.js";
11
+ import { type StateFacet } from "../core/stateContract.js";
11
12
  import type { Symbol } from "../core/types.js";
12
13
  export declare const publicationVocabulary: (hunchDir: string) => RegExp[];
14
+ /** The cwd-hint fallback (see `cwdHintField` above) only helps when the CALLER
15
+ * remembers to pass it — a subagent spawned straight into its own linked
16
+ * worktree has no reason to think of the session's "starting directory" as
17
+ * different from its own, so the hint is silently omitted and an auto-committing
18
+ * write lands wherever `root` last was (often the primary checkout, on its
19
+ * default branch). This is a backstop, NOT a complete fix: it only catches a
20
+ * related file that's new/untracked at the resolved root but already exists in
21
+ * a sibling worktree — the common "edited an existing tracked file" case is
22
+ * invisible to a pure existence check (the file exists at every worktree, just
23
+ * with different content).
24
+ *
25
+ * Used by `misrouteGuard` below to cover every auto-committing write tool that
26
+ * names files: hunch_record_decision's related_files, hunch_record_correction's
27
+ * scope_hint_file, hunch_record_finding's affected_files, and nuryel_write's
28
+ * decisions/findings/bugs/constraints facets via `fileEvidenceFor`. Every
29
+ * matching sibling is named as a candidate `cwd` and the write is refused
30
+ * rather than risked; it returns every match rather than the first, since
31
+ * confidently naming just one would let a caller that blindly retries as
32
+ * instructed land in the WRONG worktree — the same failure mode one level
33
+ * removed.
34
+ *
35
+ * Two false-positive guards, both required, not optional hardening:
36
+ * - Empty related_files, or no sibling worktree doing any better, is silent — a
37
+ * decision that legitimately precedes its code (no files yet) is unaffected.
38
+ * - A related_files entry ABSENT at the root is not automatically suspicious: an
39
+ * ordinary delete/rename recorded correctly at the resolved root produces the
40
+ * exact same shape (file gone here, still present in whichever sibling
41
+ * worktree branched before the change) as a genuine misroute.
42
+ * `pathKnownToHistory` distinguishes them — a path this checkout's own HEAD
43
+ * has ever tracked explains its absence without invoking a worktree guess.
44
+ *
45
+ * Coverage boundary: only checks the file evidence passed in THIS call (related_files
46
+ * / scope_hint_file / affected_files), not values inherited from an existing record
47
+ * on re-record/supersede — a call that omits its file field to rely on inheritance
48
+ * won't trip this guard even if it's happening in the wrong worktree. That's a
49
+ * deliberate tradeoff against false positives on stale evidence, not full coverage
50
+ * of every misrouted write.
51
+ *
52
+ * Absolute paths: agents naturally send them (edit-tool payloads and MCP roots
53
+ * are absolute), and a NAIVE `join(dir, "/abs/path")` produces a nonsense
54
+ * concatenated path that exists nowhere — the guard would find nothing to
55
+ * compare against ANY directory and stay silent, reproducing the exact bug the
56
+ * guard exists to catch. `existsUnder` checks an absolute entry AS ITSELF,
57
+ * scoped to whichever directory is under test (root, or each candidate worktree
58
+ * in turn) via `deepestContainer`, not `join()` — so an absolute path naming a
59
+ * file that exists only under a worktree's own tree is direct positive evidence
60
+ * for that worktree specifically, no existence heuristic required. Relativizing
61
+ * it against `root` alone can't represent a SIBLING worktree's absolute path at
62
+ * all: it's never under root's tree, so it relativizes to "../…" and gets
63
+ * dropped — silently reintroducing the bypass one level removed. A NESTED
64
+ * worktree (`root/.worktrees/x`) is the opposite trap: it IS lexically under
65
+ * root's own tree, so a plain "is this path under root" check wrongly
66
+ * attributes it to root — `deepestContainer` picks the MOST SPECIFIC
67
+ * (longest-path) containing worktree, not just any containing ancestor, so root
68
+ * never swallows a worktree nested inside it. An absolute path outside every
69
+ * known worktree matches none and correctly contributes nothing: not "absent
70
+ * here, check the siblings", just nothing to compare. Canonicalized
71
+ * (`canonicalRootPath`, the same symlink/case resolution `resolveActiveRoot`
72
+ * uses above) on both sides before comparing, so a worktree reached through a
73
+ * symlink or a case-different spelling isn't silently missed, and compared by
74
+ * exact path SEGMENT, not string prefix, so a real sibling `foo-other` doesn't
75
+ * false-match `foo` and a file genuinely named `..odd.ts` isn't mistaken for a
76
+ * `..` traversal. A DIRECTORY entry (or the empty-string/"." an agent might
77
+ * send meaning "the repo itself") contributes nothing either: `existsUnder`
78
+ * checks `isFile`, never mere existence, since a directory match would silently
79
+ * disable the guard for every OTHER entry in the same call — and
80
+ * `pathKnownToHistory` (below) confirms a git pathspec matched the EXACT entry,
81
+ * not merely something under/matching it, for the identical reason.
82
+ *
83
+ * A RELATIVE entry containing a ".." segment that escapes root's own tree when
84
+ * resolved against root is the same misroute as a worktree-rooted absolute
85
+ * path, merely spelled relatively — resolved against ROOT specifically (the
86
+ * only base the server actually has; never re-resolved per candidate in the
87
+ * loop below, which would answer a different, meaningless question) and then
88
+ * run through the identical absolute-path containment logic. A relative entry
89
+ * that does NOT escape root's tree keeps the original, intentional
90
+ * multi-location check instead: the SAME relative suffix tried against every
91
+ * candidate directory in turn — that's how a plain "this file's name" evidence
92
+ * has always found a sibling worktree holding a file by that name, and it must
93
+ * keep doing so for a NESTED worktree reached by a non-escaping relative path,
94
+ * whose containing worktree this does not (yet) distinguish from root itself —
95
+ * that residual gap fails OPEN (silently uncaught), never toward a false
96
+ * positive.
97
+ *
98
+ * Exported for direct unit testing — the candidate logic is otherwise reachable
99
+ * only through a full MCP client/server integration test. */
100
+ export declare const misroutedWorktreeCandidates: (root: string, relatedFiles: readonly string[]) => string[];
101
+ /** Normalizes file evidence for the misroute guard specifically. A literal
102
+ * backslash byte is a legal POSIX filename character, not a path separator,
103
+ * so blindly running every entry through `toPosixTarget` before
104
+ * `misroutedWorktreeCandidates` sees it can rewrite a real file's name into a
105
+ * fake extra path segment. The string alone can't disambiguate "Windows
106
+ * separator" from "literal POSIX byte", so this asks the filesystem AND git
107
+ * history instead (`namesRealEvidence`): when the RAW, un-normalized entry
108
+ * already names a real file, or is a relative path git knows was tracked at
109
+ * `root` (possibly since deleted/renamed), that settles it — the byte was
110
+ * literal — and the raw string is kept as-is, bypassing `toPosixTarget`
111
+ * entirely. Otherwise it normalizes through `toPosixTarget` exactly as
112
+ * before, preserving the legitimate Windows-separator case (a Windows-style
113
+ * path can never collide with a real or once-tracked POSIX file, since `\`
114
+ * cannot appear in a Windows filename to begin with). Scoped to the
115
+ * misroute-guard call sites only — the stored related_files/affected_files
116
+ * fields are still normalized separately, unaffected by this.
117
+ *
118
+ * Known accepted gap: a contrived collision — a file legitimately deleted
119
+ * from `root`'s history AND a genuine Windows-style path meaning something
120
+ * else present in a sibling worktree — resolves in favor of history and
121
+ * fails OPEN (no misroute reported), the same fail-open tradeoff
122
+ * `misroutedWorktreeCandidates` itself already documents elsewhere. */
123
+ export declare function guardEvidence(root: string, files: readonly string[]): string[];
124
+ /** The file-evidence field, if any, each nuryel_write facet carries: related_files
125
+ * for decisions, affected_files for findings/bugs, and scope (the glob list a
126
+ * constraint applies to — the same role hunch_record_correction's
127
+ * scope_hint_file plays) for constraints. A TOTAL map over StateFacet, not an
128
+ * open-ended ternary chain: adding a facet to STATE_FACETS without adding it
129
+ * here is a compile error, not a silent gap. null means the facet carries no
130
+ * comparable field and is left unguarded rather than inventing one. Exported so
131
+ * tests can assert the mapping directly instead of only probing it through live
132
+ * misroute behavior. */
133
+ export declare const FILE_EVIDENCE_FIELD: Record<StateFacet, string | null>;
134
+ /** `record` is caller-supplied z.record(string, unknown) — defensive by
135
+ * construction, so anything other than an array of strings reads as no
136
+ * evidence instead of throwing. */
137
+ export declare function fileEvidenceFor(facet: StateFacet, record: Record<string, unknown>): string[];
13
138
  /** Resolve a free-form target (symbol id / name / file path) to symbol records.
14
139
  * Tiered exact-id > exact-name > exact-file > segment-anchored-suffix matching,
15
140
  * shared with `HunchStore.why()` via `matchSymbolsTiered` so the two don't drift
@@ -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);