@davesheffer/hunch 1.41.5 → 1.42.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.
Files changed (47) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/index.js +402 -154
  3. package/dist/constitution/experiment.d.ts +3 -3
  4. package/dist/constitution/g3.d.ts +1 -1
  5. package/dist/core/docanchors.js +2 -1
  6. package/dist/core/eol.d.ts +10 -0
  7. package/dist/core/eol.js +23 -0
  8. package/dist/core/footprint.d.ts +15 -0
  9. package/dist/core/footprint.js +167 -0
  10. package/dist/core/groundingLag.d.ts +15 -0
  11. package/dist/core/groundingLag.js +27 -0
  12. package/dist/core/groundingMerge.js +8 -6
  13. package/dist/core/hookText.d.ts +4 -0
  14. package/dist/core/hookText.js +8 -0
  15. package/dist/core/pipeline.d.ts +28 -0
  16. package/dist/core/pipeline.js +50 -0
  17. package/dist/core/shellwrites.d.ts +7 -0
  18. package/dist/core/shellwrites.js +131 -0
  19. package/dist/core/siblingfix.d.ts +124 -0
  20. package/dist/core/siblingfix.js +814 -0
  21. package/dist/core/taskReportHook.d.ts +1 -1
  22. package/dist/core/taskReportHook.js +19 -3
  23. package/dist/extractors/git.d.ts +31 -0
  24. package/dist/extractors/git.js +180 -1
  25. package/dist/extractors/k8sManifest.js +15 -42
  26. package/dist/extractors/nativeTreeSitter.d.ts +2 -1
  27. package/dist/extractors/nativeTreeSitter.js +128 -30
  28. package/dist/integrations/claudemd.d.ts +20 -2
  29. package/dist/integrations/claudemd.js +79 -53
  30. package/dist/integrations/gitignore.js +2 -1
  31. package/dist/integrations/hooks.js +5 -4
  32. package/dist/integrations/providers.d.ts +9 -5
  33. package/dist/integrations/providers.js +34 -22
  34. package/dist/integrations/team.d.ts +24 -4
  35. package/dist/integrations/team.js +154 -16
  36. package/dist/integrations/worktree.d.ts +3 -2
  37. package/dist/integrations/worktree.js +7 -4
  38. package/dist/mcp/server.d.ts +489 -0
  39. package/dist/mcp/server.js +208 -62
  40. package/dist/mcp/taskReportTools.js +12 -9
  41. package/dist/mcp/toolset.d.ts +11 -1
  42. package/dist/mcp/toolset.js +28 -8
  43. package/dist/store/hunchStore.d.ts +25 -1
  44. package/dist/store/hunchStore.js +146 -21
  45. package/dist/store/jsonStore.js +25 -4
  46. package/package.json +1 -1
  47. package/server.json +2 -2
@@ -39,7 +39,7 @@ export declare function taskInstruction(task: {
39
39
  }, cwdLiteral: string, provider: HookProvider, launcher?: () => {
40
40
  shell: string;
41
41
  note?: string;
42
- }): string;
42
+ }, form?: "full" | "compact"): string;
43
43
  /** A prompt the host generated to report a background command's completion,
44
44
  * not something the user typed. */
45
45
  export declare function isNotificationPrompt(prompt: string | undefined): boolean;
@@ -9,6 +9,7 @@ import { aliasReportTask, continuationLinks, finishReportTask, isEmptyTaskReport
9
9
  import { reportSourceSnapshot } from "./taskReportEvidence.js";
10
10
  import { renderTaskReport } from "./taskReportRender.js";
11
11
  import { verificationLauncher } from "./verifyLauncher.js";
12
+ import { injectionMode } from "./hookcache.js";
12
13
  /** The exact task identity a native host prompt maps to. */
13
14
  export function promptTaskId(root, sessionId, promptId, agentId = null, provider = "claude") {
14
15
  return `htask_${reportHash([canonicalReportRoot(root), provider, sessionId, promptId, agentId]).slice(7, 31)}`;
@@ -144,7 +145,7 @@ export function startHookReport(root, provider, event) {
144
145
  // this prompt's Stop and hook observations report to that task.
145
146
  if (previous && previous.task_id !== id && isNotificationPrompt(event.prompt) && previous.closed_by !== "agent") {
146
147
  aliasReportTask(root, id, previous.task_id);
147
- return taskInstruction(previous, cwdLiteral, provider);
148
+ return sessionTaskInstruction(previous, cwdLiteral, provider, event.session_id);
148
149
  }
149
150
  const continued = previous && previous.task_id !== id ? continuationLinks(previous) : null;
150
151
  if (continued)
@@ -165,7 +166,18 @@ export function startHookReport(root, provider, event) {
165
166
  throw error;
166
167
  task = existing;
167
168
  }
168
- return taskInstruction(task, cwdLiteral, provider);
169
+ return sessionTaskInstruction(task, cwdLiteral, provider, event.session_id);
170
+ }
171
+ /** The generic rules are the same on every prompt of a session; only the ID and
172
+ * the launcher line change. Print them in full once per session (and again after
173
+ * compaction, which resets the dedup map), then a compact line that still carries
174
+ * everything the prompt needs: its ID, cwd, verify command, no-start and finish
175
+ * rule. The dedup key hashes the full wording for a placeholder task, so a new
176
+ * launcher or rule text is delivered in full again. Fail-open: any cache error
177
+ * yields the full form (injectionMode never throws and defaults to "full"). */
178
+ function sessionTaskInstruction(task, cwdLiteral, provider, sessionId) {
179
+ const rules = taskInstruction({ task_id: "htask_" + "0".repeat(24), title: NATIVE_TASK_TITLE }, "\"<cwd>\"", provider);
180
+ return taskInstruction(task, cwdLiteral, provider, verificationLauncher, injectionMode(sessionId, "prompt-task-rules", rules) === "delta" ? "compact" : "full");
169
181
  }
170
182
  /** The hook already opened the task, so the model needs no start call: the only
171
183
  * thing start used to supply was verification_argv, and the launcher is printed
@@ -179,11 +191,15 @@ export function startHookReport(root, provider, event) {
179
191
  * cannot be computed, fall back to asking for the start call — that path is then
180
192
  * the only source of both the launcher and the finish instruction, so it carries
181
193
  * its own finish sentence. */
182
- export function taskInstruction(task, cwdLiteral, provider, launcher = verificationLauncher) {
194
+ export function taskInstruction(task, cwdLiteral, provider, launcher = verificationLauncher, form = "full") {
183
195
  const head = `Hunch has already opened this prompt's report: ${task.task_id}. Reuse this exact ID; never open another report. Pass this task_id and cwd: ${cwdLiteral} to hunch_context and decision/correction/finding captures.`;
184
196
  let verify;
185
197
  try {
186
198
  const l = launcher();
199
+ if (form === "compact") {
200
+ const finishRule = HOST_CLOSES_TASK.has(provider) ? "finish only if this task used Hunch" : `finish it yourself with hunch_task(action: "finish", task_id, cwd)`;
201
+ return `Hunch report for this prompt: ${task.task_id} (cwd: ${cwdLiteral}). Use it instead of any earlier ID. Never call hunch_task start. Checks: ${l.shell} task verify ${task.task_id} -- <command> [arguments]${l.note ?? ""}. Same rules as this session's first report; ${finishRule}.`;
202
+ }
187
203
  verify = ` Never call hunch_task start for it. For checks, run: ${l.shell} task verify ${task.task_id} -- <command> [arguments]${l.note ?? ""}. Default budget 15 min; add --timeout <seconds> before -- for longer suites.`;
188
204
  }
189
205
  catch {
@@ -19,6 +19,12 @@ export declare function gitWorktreeRoot(cwd: string): string | null;
19
19
  * `.hunch-private/.hunch` can never stage or commit into the code repository. */
20
20
  export declare function isGitRepoRoot(cwd: string): boolean;
21
21
  export declare function gitNullDevice(): string;
22
+ /** Compare physical directory identity before path text. Git for Windows can
23
+ * return an 8.3/short or differently-cased spelling for the same top-level
24
+ * directory that Node reached through its long path. A nonzero file ID keeps
25
+ * this exact even on case-sensitive Windows directories; canonical text is a
26
+ * conservative fallback for filesystems that do not expose stable IDs. */
27
+ export declare function sameFilesystemEntry(left: string, right: string): boolean;
22
28
  /** Whether two paths resolve to the same repository identity. Comparing only
23
29
  * worktree roots is insufficient: linked worktrees have different roots but
24
30
  * share one Git common directory and therefore one publishable history. */
@@ -156,6 +162,31 @@ export declare function gitDir(cwd: string): string;
156
162
  * `gitDir`, which is per-worktree). Absolute, so callers can anchor worktree-shared
157
163
  * state (the private-overlay pointer) at one stable place. "" when not a git repo. */
158
164
  export declare function gitCommonDir(cwd: string): string;
165
+ /** The git common dir for `cwd`, but only when Git reached it through the worktree's own
166
+ * `.git` entry and that entry is one Git itself set up (gitDirServesWorktree). Checkout
167
+ * content can never supply a `.git` entry through Git (Git refuses to track `.git`), but
168
+ * an extracted archive can, so the entry alone proves nothing. A directory that merely
169
+ * looks like a repository (HEAD/objects/refs/config committed as ordinary files) is never
170
+ * accepted, whichever Git version runs. "" when there is no such repo. */
171
+ export declare function checkoutCommonDir(cwd: string): string;
172
+ /** Hunch's back-link inside a separate git dir, naming the one checkout it serves. Git
173
+ * records nothing for `git init --separate-git-dir` (no `core.worktree`, no worktree
174
+ * registry entry), so without this a `.git` file could name any repository's git dir. */
175
+ export declare function separateGitDirLink(gitdir: string): string;
176
+ /** Explicit setup (`hunch private` / `hunch shared` / `hunch worktree`) run in a checkout
177
+ * created by `git init --separate-git-dir`: record that this checkout owns its git dir,
178
+ * then resolve it through checkoutCommonDir. Called ONLY from a command the user ran in
179
+ * this checkout — never while opening a store, so repository content can never claim a
180
+ * git dir. Refuses another checkout's own `.git` directory, a git dir inside the
181
+ * checkout, a symlinked `.git`, and a git dir whose back-link names a different checkout
182
+ * whose `.git` file still names it. "" when this is not such a layout. */
183
+ export declare function claimSeparateGitDir(cwd: string): string;
184
+ /** The separate git dir claimSeparateGitDir WOULD claim for `cwd`, without writing
185
+ * anything (setup snapshots it for rollback). null when the layout is not claimable. */
186
+ export declare function separateGitDirCandidate(cwd: string): {
187
+ gitDir: string;
188
+ top: string;
189
+ } | null;
159
190
  /** True when `cwd` is inside a LINKED worktree (not the main checkout): its own git
160
191
  * dir differs from the shared common dir. Used by `hunch doctor` and setup messaging. */
161
192
  export declare function isLinkedWorktree(cwd: string): boolean;
@@ -7,6 +7,7 @@ import { isAbsolute, resolve, join, basename, dirname, relative, sep, posix } fr
7
7
  import { mkdtempSync, openSync, closeSync, readSync, mkdirSync, rmSync, statSync, lstatSync, realpathSync, readFileSync, renameSync, readdirSync, existsSync } from "node:fs";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { MEMLOG_FORMAT } from "../core/memorylog.js";
10
+ import { writeFileAtomic } from "../core/io.js";
10
11
  import { hunchAttributesAreSafe, hunchTreeAttributesAreSafe, safeOverlayTree } from "../core/overlaySafety.js";
11
12
  import { createRepoFileReader } from "../core/safeRepoFile.js";
12
13
  import { DIFF_TRUNCATED_LINE } from "./diff.js";
@@ -227,7 +228,7 @@ function clearStrandedIndexLock(repoDir, env, sinceMs, error) {
227
228
  * directory that Node reached through its long path. A nonzero file ID keeps
228
229
  * this exact even on case-sensitive Windows directories; canonical text is a
229
230
  * conservative fallback for filesystems that do not expose stable IDs. */
230
- function sameFilesystemEntry(left, right) {
231
+ export function sameFilesystemEntry(left, right) {
231
232
  try {
232
233
  const leftStat = statSync(left, { bigint: true });
233
234
  const rightStat = statSync(right, { bigint: true });
@@ -1916,6 +1917,184 @@ export function gitCommonDir(cwd) {
1916
1917
  return "";
1917
1918
  return isAbsolute(p) ? p : resolve(cwd, p);
1918
1919
  }
1920
+ /** The git common dir for `cwd`, but only when Git reached it through the worktree's own
1921
+ * `.git` entry and that entry is one Git itself set up (gitDirServesWorktree). Checkout
1922
+ * content can never supply a `.git` entry through Git (Git refuses to track `.git`), but
1923
+ * an extracted archive can, so the entry alone proves nothing. A directory that merely
1924
+ * looks like a repository (HEAD/objects/refs/config committed as ordinary files) is never
1925
+ * accepted, whichever Git version runs. "" when there is no such repo. */
1926
+ export function checkoutCommonDir(cwd) {
1927
+ // Isolated env: a hook's exported GIT_DIR/GIT_COMMON_DIR must not redirect resolution
1928
+ // away from `cwd`'s own layout.
1929
+ const [top, own, commonOut] = gitSafeIsolated(["-c", "safe.bareRepository=explicit", "rev-parse", "--show-toplevel", "--absolute-git-dir", "--git-common-dir"], cwd).split("\n");
1930
+ if (!top || !own || !commonOut)
1931
+ return "";
1932
+ const common = isAbsolute(commonOut) ? commonOut : resolve(cwd, commonOut);
1933
+ const dotGit = join(top, ".git");
1934
+ try {
1935
+ // lstat: Git never creates `.git` as a symlink, so a linked `.git` is treated like a
1936
+ // `.git` file — it must be vouched for by the repository it reaches.
1937
+ const entry = lstatSync(dotGit);
1938
+ const stat = entry.isSymbolicLink() ? statSync(dotGit) : entry;
1939
+ const indirect = entry.isSymbolicLink() || stat.isFile();
1940
+ if (stat.isDirectory()) {
1941
+ if (!sameFilesystemEntry(own, dotGit))
1942
+ return "";
1943
+ }
1944
+ else if (stat.isFile()) {
1945
+ const named = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(dotGit, "utf8"))?.[1];
1946
+ if (!named || !sameFilesystemEntry(own, resolve(top, named)))
1947
+ return "";
1948
+ }
1949
+ else {
1950
+ return "";
1951
+ }
1952
+ if (!gitDirServesWorktree(own, common, top, dotGit, indirect, entry.isSymbolicLink()))
1953
+ return "";
1954
+ }
1955
+ catch {
1956
+ return "";
1957
+ }
1958
+ if (!insideDirectory(cwd, top))
1959
+ return "";
1960
+ return canonicalPath(common);
1961
+ }
1962
+ /** Whether `path` physically is `dir` or lies below it, by filesystem identity rather than
1963
+ * path text, so a case variant or firmlink spelling of the same directory still counts.
1964
+ * The walk starts from the resolved path: a symlink inside `dir` that leads elsewhere does
1965
+ * not count as inside. */
1966
+ function insideDirectory(path, dir) {
1967
+ for (let at = canonicalPath(path);; at = dirname(at)) {
1968
+ if (sameFilesystemEntry(at, dir))
1969
+ return true;
1970
+ if (dirname(at) === at)
1971
+ return false;
1972
+ }
1973
+ }
1974
+ /** Whether Git itself set up `gitdir` to serve the worktree at `top`, so that `common` is
1975
+ * genuinely this checkout's repository:
1976
+ * - `gitdir` IS the common dir (no `commondir` indirection): a real `.git` directory, or,
1977
+ * reached through a `.git` file or symlink, a repository whose `core.worktree` resolves
1978
+ * to `top` (a submodule). `git init --separate-git-dir` sets no `core.worktree`, so that
1979
+ * layout is accepted only through a `.git` FILE (never a symlink) whose git dir lies
1980
+ * outside the checkout and carries Hunch's back-link naming `top` — written only by
1981
+ * explicit setup run in that checkout (claimSeparateGitDir);
1982
+ * - otherwise `gitdir` must be registered in the common dir's own `worktrees/` directory,
1983
+ * its `gitdir` back-link must name `top/.git`, and that `.git` must not be a symlink.
1984
+ * A git dir anywhere else can name any repository through a `commondir` file, so it is
1985
+ * never accepted. */
1986
+ function gitDirServesWorktree(gitdir, common, top, dotGit, indirect, symlinked) {
1987
+ if (sameFilesystemEntry(gitdir, common)) {
1988
+ if (!indirect)
1989
+ return true;
1990
+ const worktree = gitSafeIsolated(["config", "--file", join(gitdir, "config"), "--get", "core.worktree"], gitdir);
1991
+ if (worktree)
1992
+ return sameFilesystemEntry(resolve(gitdir, worktree), top);
1993
+ return !symlinked && separateGitDirLinksTo(gitdir, top);
1994
+ }
1995
+ // A linked worktree's back-link names its own `.git` file, and a symlink resolves to the
1996
+ // very file it points at, so the back-link cannot vouch for a symlinked `.git`.
1997
+ if (symlinked)
1998
+ return false;
1999
+ const registry = dirname(canonicalPath(gitdir));
2000
+ if (basename(registry) !== "worktrees" || !sameFilesystemEntry(dirname(registry), common))
2001
+ return false;
2002
+ try {
2003
+ const backLink = readFileSync(join(gitdir, "gitdir"), "utf8").trim();
2004
+ if (!backLink)
2005
+ return false;
2006
+ // Same file AND same parent directory: a hard link to another worktree's `.git` file
2007
+ // shares its inode but not its directory. Identity, not path text, so case variants
2008
+ // and firmlink spellings that Git may have written still match.
2009
+ const named = resolve(gitdir, backLink);
2010
+ return basename(named) === ".git" && sameFilesystemEntry(named, dotGit) && sameFilesystemEntry(dirname(named), top);
2011
+ }
2012
+ catch {
2013
+ return false;
2014
+ }
2015
+ }
2016
+ /** Hunch's back-link inside a separate git dir, naming the one checkout it serves. Git
2017
+ * records nothing for `git init --separate-git-dir` (no `core.worktree`, no worktree
2018
+ * registry entry), so without this a `.git` file could name any repository's git dir. */
2019
+ export function separateGitDirLink(gitdir) {
2020
+ return join(gitdir, "hunch", "checkout-root");
2021
+ }
2022
+ /** Whether a separate git dir is vouched for `top`: it lies outside the checkout (so no
2023
+ * checkout or archive content can supply it or its back-link) and its Hunch back-link
2024
+ * names `top`. */
2025
+ function separateGitDirLinksTo(gitdir, top) {
2026
+ if (insideDirectory(gitdir, top))
2027
+ return false;
2028
+ try {
2029
+ const linked = readFileSync(separateGitDirLink(gitdir), "utf8").trim();
2030
+ return !!linked && isAbsolute(linked) && sameFilesystemEntry(linked, top);
2031
+ }
2032
+ catch {
2033
+ return false;
2034
+ }
2035
+ }
2036
+ /** Explicit setup (`hunch private` / `hunch shared` / `hunch worktree`) run in a checkout
2037
+ * created by `git init --separate-git-dir`: record that this checkout owns its git dir,
2038
+ * then resolve it through checkoutCommonDir. Called ONLY from a command the user ran in
2039
+ * this checkout — never while opening a store, so repository content can never claim a
2040
+ * git dir. Refuses another checkout's own `.git` directory, a git dir inside the
2041
+ * checkout, a symlinked `.git`, and a git dir whose back-link names a different checkout
2042
+ * whose `.git` file still names it. "" when this is not such a layout. */
2043
+ export function claimSeparateGitDir(cwd) {
2044
+ const candidate = separateGitDirCandidate(cwd);
2045
+ if (!candidate)
2046
+ return "";
2047
+ try {
2048
+ const link = separateGitDirLink(candidate.gitDir);
2049
+ mkdirSync(dirname(link), { recursive: true });
2050
+ writeFileAtomic(link, `${canonicalPath(candidate.top)}\n`);
2051
+ }
2052
+ catch {
2053
+ return "";
2054
+ }
2055
+ return checkoutCommonDir(cwd);
2056
+ }
2057
+ /** The separate git dir claimSeparateGitDir WOULD claim for `cwd`, without writing
2058
+ * anything (setup snapshots it for rollback). null when the layout is not claimable. */
2059
+ export function separateGitDirCandidate(cwd) {
2060
+ const [top, own, commonOut] = gitSafeIsolated(["-c", "safe.bareRepository=explicit", "rev-parse", "--show-toplevel", "--absolute-git-dir", "--git-common-dir"], cwd).split("\n");
2061
+ if (!top || !own || !commonOut)
2062
+ return null;
2063
+ const common = isAbsolute(commonOut) ? commonOut : resolve(cwd, commonOut);
2064
+ if (!sameFilesystemEntry(own, common) || !insideDirectory(cwd, top))
2065
+ return null;
2066
+ const dotGit = join(top, ".git");
2067
+ const namesOwn = (file) => {
2068
+ try {
2069
+ if (!lstatSync(file).isFile())
2070
+ return false;
2071
+ const named = /^gitdir:\s*(.+?)\s*$/m.exec(readFileSync(file, "utf8"))?.[1];
2072
+ return !!named && sameFilesystemEntry(own, resolve(dirname(file), named));
2073
+ }
2074
+ catch {
2075
+ return false;
2076
+ }
2077
+ };
2078
+ if (!namesOwn(dotGit))
2079
+ return null;
2080
+ if (gitSafeIsolated(["config", "--file", join(own, "config"), "--get", "core.worktree"], own))
2081
+ return null;
2082
+ if (insideDirectory(own, top))
2083
+ return null;
2084
+ try {
2085
+ const parentDotGit = join(dirname(canonicalPath(own)), ".git");
2086
+ if (lstatSync(parentDotGit).isDirectory() && sameFilesystemEntry(parentDotGit, own))
2087
+ return null;
2088
+ }
2089
+ catch { /* not another checkout's embedded .git directory */ }
2090
+ try {
2091
+ const prior = readFileSync(separateGitDirLink(own), "utf8").trim();
2092
+ if (prior && !sameFilesystemEntry(prior, top) && namesOwn(join(prior, ".git")))
2093
+ return null;
2094
+ }
2095
+ catch { /* no back-link yet */ }
2096
+ return { gitDir: canonicalPath(own), top };
2097
+ }
1919
2098
  /** True when `cwd` is inside a LINKED worktree (not the main checkout): its own git
1920
2099
  * dir differs from the shared common dir. Used by `hunch doctor` and setup messaging. */
1921
2100
  export function isLinkedWorktree(cwd) {
@@ -1,25 +1,4 @@
1
- /**
2
- * Deterministic text scan for Kubernetes manifest cross-resource references —
3
- * NOT a tree-sitter walk. Confirmed directly (not assumed): parsing a realistic
4
- * templated Deployment with this repo's own tree-sitter-yaml bundle showed a
5
- * same-line templated scalar (`name: {{ include "x" . }}`) parses cleanly, but
6
- * a block-form injection on its own line (`labels:\n {{- include ... }}` — the
7
- * pattern real charts use for metadata.labels and spec.selector) collapses the
8
- * WHOLE REST OF THE DOCUMENT'S error recovery into a flat, unstructured ERROR
9
- * node. Real charts place such an injection early (right after metadata.name),
10
- * so it would poison parsing for every field-path this module needs further
11
- * down the same document. parseSource() (parse.ts) also never exposes its
12
- * tree-sitter tree to callers at all -- helm.ts's own text-scan precedent
13
- * exists for the same reason.
14
- *
15
- * This is a line-oriented, indentation-tracking scanner (not flat regex, unlike
16
- * helm.ts's `{{ }}`-action scan) because K8s field-paths are nested block
17
- * structure (`spec.template.spec.containers[].env[].valueFrom.secretKeyRef.name`)
18
- * that a flat scan can't reconstruct. It never interprets `{{ }}` as YAML syntax
19
- * at all -- a `{{ ... }}` on a line's value side is just that line's raw value
20
- * text, same-line or block-form alike -- so it is immune to the exact failure
21
- * mode that broke tree-sitter.
22
- */
1
+ import { sourceLines } from "../core/eol.js";
23
2
  // ReplicaSet is included alongside the pod-spec-embedding/networking kinds
24
3
  // specifically because it's the dominant real-world bearer of
25
4
  // ownerReferences (Deployment -> ReplicaSet -> Pod is the common chain) --
@@ -42,14 +21,9 @@ function wildcardPath(path) {
42
21
  function framePath(frames) {
43
22
  return frames.map((f) => f.key).filter(Boolean).join(".").replace(/\.\[/g, "[");
44
23
  }
45
- // `\r?` before `$`: without it, a CRLF-terminated line (the Git-for-Windows
46
- // `core.autocrlf=true` default -- every .yaml file in a Windows checkout) never
47
- // matches at all, since JS `.` never matches `\r` and `$` (no /m flag) only
48
- // matches at the true end of the string. That silently zeroes out this whole
49
- // module's output on any Windows clone, with no error. `.*?` (lazy, not `.*`
50
- // greedy) so `\r?` gets first claim on a trailing `\r` instead of the value
51
- // capture swallowing it.
52
- const KEY_LINE = /^(\s*)(-\s+)?([A-Za-z0-9_.\/-]+):[ \t]*(.*?)\r?$/;
24
+ // sourceLines removes line terminators while retaining offsets in the original
25
+ // source, so every line parser sees the same content on LF, CRLF, and CR input.
26
+ const KEY_LINE = /^(\s*)(-\s+)?([A-Za-z0-9_.\/-]+):[ \t]*(.*?)$/;
53
27
  // A line that is ENTIRELY a `{{ ... }}` template action (no `key:` prefix at
54
28
  // all) -- e.g. a block-form injection appearing as a SIBLING after other
55
29
  // literal keys under the same mapping (`app: my-app` then, on its own later
@@ -70,7 +44,7 @@ const BARE_TEMPLATE_LINE = /^\s*(?:-\s+)?\{\{[\s\S]*$/;
70
44
  // branch, which used ORDINARY popping and lost the list-item frame entirely
71
45
  // -- reparenting the item's real children one level up (`containers.name`
72
46
  // instead of `containers[0].name`), silently dropping every reference under it.
73
- const BARE_LIST_MARKER = /^(\s*)-[ \t]*\r?$/;
47
+ const BARE_LIST_MARKER = /^(\s*)-[ \t]*$/;
74
48
  // A list item whose ENTIRE content is a block-scalar header (`- |`, `- |-`,
75
49
  // `- >`, `- |2`, `- | # comment`) -- the idiomatic way a chart inlines a shell
76
50
  // script into `args:`/`command:`. The item's value is opaque TEXT, not YAML,
@@ -84,7 +58,7 @@ const BARE_LIST_MARKER = /^(\s*)-[ \t]*\r?$/;
84
58
  // match. The rest is captured (not `[|>]` inline) so the header can be run
85
59
  // through stripTrailingComment before BLOCK_SCALAR_HEADER, exactly as a
86
60
  // key line's value is.
87
- const BLOCK_SCALAR_LIST_ITEM = /^(\s*)-[ \t]+(.*?)\r?$/;
61
+ const BLOCK_SCALAR_LIST_ITEM = /^(\s*)-[ \t]+(.*?)$/;
88
62
  // A YAML block-scalar header (`|`, `>`, plus an optional chomping indicator
89
63
  // `+`/`-` and/or an explicit indent digit, in either order: `|`, `|-`, `>+`,
90
64
  // `|2`, `|2-`, `|-2`). When a key's value is JUST this header, the real
@@ -161,7 +135,6 @@ function scanFieldPaths(text, baseChar) {
161
135
  // (selector/labels) cares, and it checks this set itself.
162
136
  const valuelessKeyParents = new Set();
163
137
  const stack = [];
164
- let charOffset = baseChar;
165
138
  // Indent of the `-` of a `- |` list item whose block-scalar body is being
166
139
  // skipped, or null when no such skip is active. The body ends at the first
167
140
  // line indented at or shallower than that dash (YAML's own rule for where a
@@ -229,13 +202,11 @@ function scanFieldPaths(text, baseChar) {
229
202
  unresolvedContainers.add(framePath(stack.slice(0, depth)));
230
203
  }
231
204
  };
232
- for (const line of text.split("\n")) {
233
- const lineStartChar = charOffset;
234
- charOffset += line.length + 1; // +1 for the \n split() consumed
205
+ for (const { content: line, start } of sourceLines(text)) {
206
+ const lineStartChar = baseChar + start;
235
207
  // Inside a `- |` item's body: these lines are script/config TEXT, so they
236
- // must never be read as field structure. Runs after the charOffset
237
- // bookkeeping above so skipped lines still advance the offset exactly --
238
- // every atChar/endChar after the block scalar depends on it.
208
+ // must never be read as field structure. sourceLines retains original
209
+ // offsets even when skipped lines use CRLF.
239
210
  if (blockScalarSkipIndent !== null) {
240
211
  // A blank line is part of the block scalar (YAML lets a scalar body
241
212
  // contain empty lines at any indent), never its terminator.
@@ -671,12 +642,14 @@ function buildDocument(text, docStartChar, entries, unresolvedContainers, valuel
671
642
  // deliberately NOT a separator -- real YAML doesn't treat it as one either,
672
643
  // and it still isn't here: the dashes must be followed by whitespace or end
673
644
  // of line.
674
- const DOC_SEPARATOR = /^(?:---(?:[ \t]+.*)?|\.\.\.)[ \t]*\r?$/m;
645
+ const DOC_SEPARATOR = /^(?:---(?:[ \t]+.*)?|\.\.\.)[ \t]*$/;
675
646
  export function extractK8sManifest(source) {
676
647
  const docs = [];
677
648
  const boundaries = [];
678
- for (const m of source.matchAll(new RegExp(DOC_SEPARATOR, "gm")))
679
- boundaries.push(m.index, m.index + m[0].length);
649
+ for (const line of sourceLines(source)) {
650
+ if (DOC_SEPARATOR.test(line.content))
651
+ boundaries.push(line.start, line.end);
652
+ }
680
653
  const starts = [0, ...boundaries.filter((_, i) => i % 2 === 1)];
681
654
  const ends = [...boundaries.filter((_, i) => i % 2 === 0), source.length];
682
655
  for (let i = 0; i < starts.length; i++) {
@@ -33,7 +33,8 @@ export declare class NativeTreeSitterLoadError extends Error {
33
33
  * survives both. */
34
34
  export declare function isParserLoadError(e: unknown): boolean;
35
35
  /** Load all native tree-sitter addons (the parser runtime + every grammar) from
36
- * process-owned temp copies. Windows keeps loaded `.node` files locked for the
36
+ * content-addressed copies in a per-user temp cache — never from the installed
37
+ * package. Windows keeps loaded `.node` files locked for the
37
38
  * process lifetime; redirecting the upstream loaders means npm can replace the
38
39
  * installed package during an active MCP session without killing that session
39
40
  * or falling back to a stale binary. */
@@ -1,9 +1,15 @@
1
1
  import { createRequire } from "node:module";
2
- import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { basename, dirname, join } from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { copyFileSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync } from "node:fs";
4
+ import { tmpdir, userInfo } from "node:os";
5
+ import { basename, dirname, join, relative, sep } from "node:path";
5
6
  const runtimeRequire = createRequire(import.meta.url);
7
+ /** Legacy per-process copy dirs (`hunch-tree-sitter-<pid>-*`), pruned when dead. */
6
8
  const COPY_PREFIX = "hunch-tree-sitter-";
9
+ /** Per-user cache of content-addressed copies (`hunch-tree-sitter-cache-<user>`). */
10
+ const CACHE_PREFIX = "hunch-tree-sitter-cache-";
11
+ /** A hash dir no process has used for this long is pruned. */
12
+ const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
7
13
  const NATIVE_PACKAGES = [
8
14
  "tree-sitter",
9
15
  "tree-sitter-typescript",
@@ -84,21 +90,116 @@ function removeStaleCopies() {
84
90
  function environmentKey(packageName) {
85
91
  return `${packageName.toUpperCase().replaceAll("-", "_")}_PREBUILD`;
86
92
  }
87
- function copyNativeBinding(packageName, copyRoot, nodeGypBuild) {
93
+ /** The per-user copy cache. A binary copied to a fresh path costs a first-load
94
+ * assessment (macOS checks every never-seen dylib: ~2s per addon, six addons)
95
+ * on every process; the same bytes at a path already loaded once cost ~0ms. So
96
+ * copies are content-addressed and reused across processes instead of made per
97
+ * process. The directory is private to the user: an addon dlopen'd from a
98
+ * location another account can write would be code execution as this user. */
99
+ function cacheRoot() {
100
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
101
+ let user = String(uid ?? "");
102
+ if (!user) {
103
+ try {
104
+ user = userInfo().username;
105
+ }
106
+ catch {
107
+ user = "user";
108
+ }
109
+ }
110
+ const root = join(tmpdir(), `${CACHE_PREFIX}${user.replace(/[^A-Za-z0-9_.-]/g, "_")}`);
111
+ mkdirSync(root, { recursive: true, mode: 0o700 });
112
+ const st = lstatSync(root);
113
+ if (!st.isDirectory() || st.isSymbolicLink())
114
+ throw new Error(`tree-sitter copy cache is not a directory: ${root}`);
115
+ // POSIX: refuse a cache another account owns or can write into. (Windows has
116
+ // no uid and a per-user TEMP; its ACLs are not mode bits.)
117
+ if (uid !== null && (st.uid !== uid || (st.mode & 0o022) !== 0)) {
118
+ throw new Error(`tree-sitter copy cache is not private to this user: ${root}`);
119
+ }
120
+ return root;
121
+ }
122
+ function sha256(bytes) {
123
+ return createHash("sha256").update(bytes).digest("hex");
124
+ }
125
+ function copyNativeBinding(packageName, root, nodeGypBuild) {
88
126
  const packageRoot = dirname(runtimeRequire.resolve(`${packageName}/package.json`));
89
127
  const source = nodeGypBuild.path(packageRoot);
90
- const packageCopy = join(copyRoot, packageName);
128
+ const bytes = readFileSync(source);
129
+ const digest = sha256(bytes);
130
+ const hashDir = join(root, digest.slice(0, 32));
131
+ const packageCopy = join(hashDir, packageName);
91
132
  const normalized = source.replaceAll("\\", "/");
92
133
  const prebuild = /\/prebuilds\/([^/]+)\/[^/]+$/.exec(normalized);
93
134
  const destination = prebuild
94
135
  ? join(packageCopy, "prebuilds", prebuild[1], basename(source))
95
136
  : join(packageCopy, "build", "Release", basename(source));
96
- mkdirSync(dirname(destination), { recursive: true });
97
- copyFileSync(source, destination);
137
+ // Reuse only bytes that still hash to the installed binary: a truncated or
138
+ // replaced copy is rewritten, never loaded.
139
+ const intact = () => {
140
+ try {
141
+ return sha256(readFileSync(destination)) === digest;
142
+ }
143
+ catch {
144
+ return false;
145
+ }
146
+ };
147
+ // Mark the hash dir in use BEFORE verifying it, so a concurrent prune
148
+ // cannot remove it between the check and the dlopen.
149
+ try {
150
+ const now = new Date();
151
+ utimesSync(hashDir, now, now);
152
+ }
153
+ catch { /* not created yet */ }
154
+ if (!intact()) {
155
+ mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
156
+ // Atomic: a concurrent process sees the old file or the complete new one.
157
+ const partial = `${destination}.${process.pid}.${Date.now()}.tmp`;
158
+ try {
159
+ copyFileSync(source, partial);
160
+ renameSync(partial, destination);
161
+ }
162
+ catch (error) {
163
+ try {
164
+ rmSync(partial, { force: true });
165
+ }
166
+ catch { /* best effort */ }
167
+ // Windows refuses to replace a copy another process has loaded; that copy
168
+ // is then the same bytes, which is all this needs.
169
+ if (!intact())
170
+ throw error;
171
+ }
172
+ if (!intact())
173
+ throw new Error(`tree-sitter copy does not match its source: ${destination}`);
174
+ }
98
175
  return packageCopy;
99
176
  }
177
+ /** Hash dirs unused for CACHE_TTL_MS belong to replaced installs. Deleting one
178
+ * that is still loaded fails on Windows (retried by a later process) and is
179
+ * harmless on POSIX (the mapping outlives the directory entry). */
180
+ function pruneCache(root, keep) {
181
+ let entries;
182
+ try {
183
+ entries = readdirSync(root, { withFileTypes: true });
184
+ }
185
+ catch {
186
+ return;
187
+ }
188
+ const cutoff = Date.now() - CACHE_TTL_MS;
189
+ for (const entry of entries) {
190
+ if (!entry.isDirectory() || keep.has(entry.name))
191
+ continue;
192
+ const dir = join(root, entry.name);
193
+ try {
194
+ if (statSync(dir).mtimeMs < cutoff)
195
+ rmSync(dir, { recursive: true, force: true, maxRetries: 2 });
196
+ }
197
+ catch { /* in use or already gone */ }
198
+ }
199
+ }
100
200
  /** Load all native tree-sitter addons (the parser runtime + every grammar) from
101
- * process-owned temp copies. Windows keeps loaded `.node` files locked for the
201
+ * content-addressed copies in a per-user temp cache — never from the installed
202
+ * package. Windows keeps loaded `.node` files locked for the
102
203
  * process lifetime; redirecting the upstream loaders means npm can replace the
103
204
  * installed package during an active MCP session without killing that session
104
205
  * or falling back to a stale binary. */
@@ -126,25 +227,33 @@ function loadRuntime() {
126
227
  // already-loaded source-built addon slip past this guard and defeat the
127
228
  // file-lock isolation entirely (issue #52).
128
229
  const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /(?:tree-sitter(?:-typescript|-python|-go|-php|-yaml)?|tree_sitter(?:_[a-z]+)*_binding)\.node$/.test(path)
129
- && !new RegExp(`(?:^|[\\\\/])${COPY_PREFIX}\\d+-`).test(path));
230
+ && !new RegExp(`(?:^|[\\\\/])(?:${COPY_PREFIX}\\d+-|${CACHE_PREFIX})`).test(path));
130
231
  if (preloaded.length) {
131
232
  throw new Error(`tree-sitter native addon was loaded before Hunch could isolate it: ${preloaded.join(", ")}`);
132
233
  }
133
234
  removeStaleCopies();
134
- const copyRoot = mkdtempSync(join(tmpdir(), `${COPY_PREFIX}${process.pid}-`));
235
+ // A refused cache (another account's or a group-writable dir squatting the
236
+ // predictable name) must not kill parsing: fall back to a private
237
+ // per-process copy dir, which removeStaleCopies prunes once the pid is gone.
238
+ let root;
239
+ let shared = true;
240
+ try {
241
+ root = cacheRoot();
242
+ }
243
+ catch {
244
+ root = mkdtempSync(join(tmpdir(), `${COPY_PREFIX}${process.pid}-`));
245
+ shared = false;
246
+ }
135
247
  const previous = new Map();
248
+ const used = new Set();
136
249
  try {
137
- // Resolved INSIDE the try: if node-gyp-build cannot be resolved (a partial
138
- // install, npm mid-swap) the catch below still removes the copy dir we just
139
- // created. Outside it, every retry — and failure is deliberately not
140
- // memoized, so a long-lived MCP server retries forever — leaked one empty
141
- // hunch-tree-sitter-<pid>-* dir that removeStaleCopies can never prune,
142
- // because it only prunes dirs whose pid is dead.
143
250
  const nodeGypBuild = runtimeRequire("node-gyp-build");
144
251
  for (const packageName of NATIVE_PACKAGES) {
145
252
  const key = environmentKey(packageName);
146
253
  previous.set(key, process.env[key]);
147
- process.env[key] = copyNativeBinding(packageName, copyRoot, nodeGypBuild);
254
+ const packageCopy = copyNativeBinding(packageName, root, nodeGypBuild);
255
+ used.add(relative(root, packageCopy).split(sep)[0]);
256
+ process.env[key] = packageCopy;
148
257
  }
149
258
  const Parser = runtimeRequire("tree-sitter");
150
259
  const languages = runtimeRequire("tree-sitter-typescript");
@@ -154,13 +263,6 @@ function loadRuntime() {
154
263
  const yaml = runtimeRequire("@tree-sitter-grammars/tree-sitter-yaml");
155
264
  runtime = { Parser, typescript: languages.typescript, tsx: languages.tsx, python, go, php: php.php, yaml };
156
265
  }
157
- catch (error) {
158
- try {
159
- rmSync(copyRoot, { recursive: true, force: true });
160
- }
161
- catch { /* best effort */ }
162
- throw error;
163
- }
164
266
  finally {
165
267
  for (const [key, value] of previous) {
166
268
  if (value === undefined)
@@ -169,12 +271,8 @@ function loadRuntime() {
169
271
  process.env[key] = value;
170
272
  }
171
273
  }
172
- process.once("exit", () => {
173
- try {
174
- rmSync(copyRoot, { recursive: true, force: true });
175
- }
176
- catch { /* next process prunes it */ }
177
- });
274
+ if (shared)
275
+ pruneCache(root, used);
178
276
  return runtime;
179
277
  }
180
278
  //# sourceMappingURL=nativeTreeSitter.js.map