@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.
@@ -19,15 +19,25 @@ import { installMergeDriver } from "./mergeDriver.js";
19
19
  import { ensureGitignore } from "./gitignore.js";
20
20
  import { resolveInvocation } from "../cli/invocation.js";
21
21
  export const DEFAULT_TEAM_REF = "refs/heads/main";
22
+ // check-ref-format without --branch checks syntax only, independent of the
23
+ // repository, ref existence, or remote state. Retain just its last successful
24
+ // input: one shared sync can otherwise spawn Git dozens of times for the same
25
+ // name. Route/remote validation still runs afresh, and failed invocations retry.
26
+ let lastValidTeamRef;
22
27
  export function safeTeamRef(value) {
23
28
  const ref = value.trim();
24
29
  if (!ref.startsWith("refs/heads/") || ref === "refs/heads/")
25
30
  return null;
31
+ if (ref === lastValidTeamRef)
32
+ return ref;
26
33
  const checked = spawnSync("git", ["check-ref-format", ref], {
27
34
  stdio: "ignore",
28
35
  env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1" },
29
36
  });
30
- return checked.status === 0 ? ref : null;
37
+ if (checked.status !== 0)
38
+ return null;
39
+ lastValidTeamRef = ref;
40
+ return ref;
31
41
  }
32
42
  export function teamSharedRef(team) {
33
43
  return team.shared_ref ?? DEFAULT_TEAM_REF;
@@ -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