@davesheffer/hunch 1.39.3 → 1.40.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 (38) hide show
  1. package/dist/cli/index.js +59 -16
  2. package/dist/constitution/g2BehaviorCandidates.js +6 -1
  3. package/dist/constitution/g2Candidates.js +11 -1
  4. package/dist/constitution/structural.js +10 -0
  5. package/dist/core/delivery.d.ts +34 -0
  6. package/dist/core/delivery.js +60 -1
  7. package/dist/core/docanchors.js +181 -11
  8. package/dist/core/format.js +6 -1
  9. package/dist/core/glob.d.ts +12 -6
  10. package/dist/core/glob.js +12 -6
  11. package/dist/core/hookcache.d.ts +9 -2
  12. package/dist/core/hookcache.js +10 -3
  13. package/dist/core/paths.d.ts +21 -0
  14. package/dist/core/paths.js +39 -1
  15. package/dist/core/taskDelivery.js +27 -1
  16. package/dist/core/taskReportHook.d.ts +19 -0
  17. package/dist/core/taskReportHook.js +40 -4
  18. package/dist/core/verifyLauncher.d.ts +21 -0
  19. package/dist/core/verifyLauncher.js +37 -0
  20. package/dist/extractors/indexer.js +39 -9
  21. package/dist/extractors/k8sManifest.d.ts +13 -0
  22. package/dist/extractors/k8sManifest.js +103 -7
  23. package/dist/extractors/landscapeDiscovery.js +9 -1
  24. package/dist/extractors/nativeTreeSitter.d.ts +24 -0
  25. package/dist/extractors/nativeTreeSitter.js +54 -1
  26. package/dist/extractors/parse.js +6 -1
  27. package/dist/integrations/claudemd.js +3 -3
  28. package/dist/integrations/hooks.d.ts +43 -4
  29. package/dist/integrations/hooks.js +309 -22
  30. package/dist/mcp/server.js +19 -11
  31. package/dist/mcp/taskReportTools.d.ts +5 -9
  32. package/dist/mcp/taskReportTools.js +9 -20
  33. package/dist/store/changeLedger.d.ts +9 -3
  34. package/dist/store/changeLedger.js +36 -10
  35. package/dist/store/hunchStore.d.ts +42 -2
  36. package/dist/store/hunchStore.js +74 -16
  37. package/package.json +1 -1
  38. package/server.json +2 -2
package/dist/core/glob.js CHANGED
@@ -72,7 +72,10 @@ export function pathsRelated(left, right) {
72
72
  * Dockerfile, ...) that has zero tree-sitter symbols but is still a real,
73
73
  * known file. Derived entirely from already-loaded graph data — never the
74
74
  * filesystem — so the answer doesn't depend on untracked working-tree state
75
- * (a deleted-but-still-indexed path stays "real"; issue #299). */
75
+ * (a deleted-but-still-indexed path stays "real"; issue #299). It is therefore
76
+ * only HALF the "is this a real path" question: a real file with no symbols and
77
+ * no covering component is invisible here, so callers OR in `isRepoFile` as a
78
+ * last resort (issue #334) — `HunchStore.isKnownPath` is that composition. */
76
79
  export function isIndexedPath(target, symbolFiles, componentPaths) {
77
80
  for (const f of symbolFiles)
78
81
  if (f === target)
@@ -84,11 +87,14 @@ export function isIndexedPath(target, symbolFiles, componentPaths) {
84
87
  return false;
85
88
  }
86
89
  /** Resolve symbols matching `target`, tiered: exact id > exact name > exact file >
87
- * (only when `target` is NOT a path already known to the index) segment-anchored
88
- * suffix. A real indexed file with zero symbols must return [] rather than fall
89
- * through to the suffix tier, which would leak an unrelated same-basename file's
90
- * records (issue #299) — callers compute `indexed` via `isIndexedPath` first so
91
- * the "is this a real path" question is answered identically everywhere. */
90
+ * (only when `target` is NOT a path already known to be real) segment-anchored
91
+ * suffix. A real file with zero symbols must return [] rather than fall through
92
+ * to the suffix tier, which would leak an unrelated same-basename file's records
93
+ * (issues #299/#334). Callers decide what counts as "real": one that ATTRIBUTES
94
+ * records silently (why(), the pre-edit hook) passes `HunchStore.isKnownPath`, the
95
+ * wider graph-or-working-tree answer that also covers a file about to be created;
96
+ * one that NAMES the file it resolved to (resolveNodeIds, structure()) passes the
97
+ * narrower `isRepoFile`, since glob coverage alone is not existence. */
92
98
  export function matchSymbolsTiered(target, symbols, indexed) {
93
99
  const byId = symbols.find((s) => s.id === target);
94
100
  if (byId)
@@ -1,7 +1,14 @@
1
1
  /** Decide whether this injection should be the FULL grounding block or a delta
2
2
  * one-liner. Records the content hash as a side effect (so the next identical
3
- * call dedups). Never throws. */
4
- export declare function injectionMode(sessionId: string | undefined, key: string, content: string): "full" | "delta";
3
+ * call dedups). Never throws.
4
+ *
5
+ * `hashInput` lets a caller dedup on a STABLE PROJECTION of the block instead
6
+ * of its presentation: some grounding is self-invalidating —
7
+ * serving it writes delivery receipts, and the next call's wording moves
8
+ * ("today" → "delivered today") with no record change, so hashing the rendered
9
+ * text re-sends the full block forever. Callers pass the identity of the
10
+ * underlying records; omitting it hashes `content`, the original contract. */
11
+ export declare function injectionMode(sessionId: string | undefined, key: string, content: string, hashInput?: string): "full" | "delta";
5
12
  /** Forget everything injected into a session. Compaction summarizes injected
6
13
  * grounding out of the agent's context while the dedup map still says
7
14
  * "delivered" — so on PreCompact / SessionStart[source=compact] the map must
@@ -23,8 +23,15 @@ const MAX_KEYS = 300;
23
23
  const SWEEP_AGE_MS = 48 * 3600 * 1000;
24
24
  /** Decide whether this injection should be the FULL grounding block or a delta
25
25
  * one-liner. Records the content hash as a side effect (so the next identical
26
- * call dedups). Never throws. */
27
- export function injectionMode(sessionId, key, content) {
26
+ * call dedups). Never throws.
27
+ *
28
+ * `hashInput` lets a caller dedup on a STABLE PROJECTION of the block instead
29
+ * of its presentation: some grounding is self-invalidating —
30
+ * serving it writes delivery receipts, and the next call's wording moves
31
+ * ("today" → "delivered today") with no record change, so hashing the rendered
32
+ * text re-sends the full block forever. Callers pass the identity of the
33
+ * underlying records; omitting it hashes `content`, the original contract. */
34
+ export function injectionMode(sessionId, key, content, hashInput = content) {
28
35
  try {
29
36
  if (!sessionId || process.env.HUNCH_HOOK_DEDUP === "0")
30
37
  return "full";
@@ -32,7 +39,7 @@ export function injectionMode(sessionId, key, content) {
32
39
  mkdirSync(dir, { recursive: true });
33
40
  sweep(dir);
34
41
  const file = join(dir, `${sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
35
- const hash = createHash("sha256").update(content).digest("hex").slice(0, 16);
42
+ const hash = createHash("sha256").update(hashInput).digest("hex").slice(0, 16);
36
43
  let map;
37
44
  try {
38
45
  const raw = JSON.parse(readFileSync(file, "utf8"));
@@ -27,6 +27,27 @@ export declare function realpathNorm(p: string): string;
27
27
  * caller treats the file as outside the repo, silently dropping all context
28
28
  * (dec_e0a36efbf5). */
29
29
  export declare function repoRelativeTarget(target: string, root: string): string;
30
+ /** True when `target` (already repo-relative POSIX form) names a regular FILE
31
+ * that really exists inside `root`. The LAST-RESORT half of the "is this a real
32
+ * path" question: `isIndexedPath` answers it from graph data alone, but a real
33
+ * working-tree file with zero tree-sitter symbols and no covering component
34
+ * glob is invisible to it, so it fell through to the suffix tier and leaked an
35
+ * unrelated same-basename file's records (issue #334). Callers consult the
36
+ * index FIRST and only fall back here, so a deleted-but-still-indexed path and
37
+ * a time-travel (`asOf`) query keep answering exactly as before.
38
+ *
39
+ * Deliberately narrow: an absolute path (or a Windows drive letter) and any
40
+ * target escaping `root` via ".." are rejected rather than resolved — the
41
+ * caller has already run `repoRelativeTarget`, so anything still absolute is
42
+ * outside the repo. A DIRECTORY is false: directory targets must keep flowing
43
+ * to `structure()`'s dir tier. Any fs error (missing, EACCES, ...) → false.
44
+ *
45
+ * Containment is checked twice: lexically, then again on the REALPATHS. `statSync`
46
+ * follows symlinks, so an in-repo `link -> /outside` would otherwise make
47
+ * `isRepoFile(root, "link/secret.ts")` true and turn this into a one-bit existence
48
+ * oracle for paths outside the repo. A symlinked file pointing at another file
49
+ * INSIDE the repo stays true. */
50
+ export declare function isRepoFile(root: string, target: string): boolean;
30
51
  export interface HunchPaths {
31
52
  /** Repo root (where .hunch/ lives). */
32
53
  root: string;
@@ -1,7 +1,7 @@
1
1
  /** Filesystem layout for the Hunch (DESIGN.md §6 folder structure). */
2
2
  import { join } from "node:path";
3
3
  import { existsSync, realpathSync, statSync } from "node:fs";
4
- import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
4
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
5
  export const HUNCH_DIR = ".hunch";
6
6
  /** Canonicalize a free-form path/target to forward-slash form (Hunch stores every
7
7
  * path with "/" — git emits it on all OSes — so any user- or agent-supplied
@@ -56,6 +56,44 @@ export function repoRelativeTarget(target, root) {
56
56
  return t;
57
57
  return rel;
58
58
  }
59
+ /** True when `target` (already repo-relative POSIX form) names a regular FILE
60
+ * that really exists inside `root`. The LAST-RESORT half of the "is this a real
61
+ * path" question: `isIndexedPath` answers it from graph data alone, but a real
62
+ * working-tree file with zero tree-sitter symbols and no covering component
63
+ * glob is invisible to it, so it fell through to the suffix tier and leaked an
64
+ * unrelated same-basename file's records (issue #334). Callers consult the
65
+ * index FIRST and only fall back here, so a deleted-but-still-indexed path and
66
+ * a time-travel (`asOf`) query keep answering exactly as before.
67
+ *
68
+ * Deliberately narrow: an absolute path (or a Windows drive letter) and any
69
+ * target escaping `root` via ".." are rejected rather than resolved — the
70
+ * caller has already run `repoRelativeTarget`, so anything still absolute is
71
+ * outside the repo. A DIRECTORY is false: directory targets must keep flowing
72
+ * to `structure()`'s dir tier. Any fs error (missing, EACCES, ...) → false.
73
+ *
74
+ * Containment is checked twice: lexically, then again on the REALPATHS. `statSync`
75
+ * follows symlinks, so an in-repo `link -> /outside` would otherwise make
76
+ * `isRepoFile(root, "link/secret.ts")` true and turn this into a one-bit existence
77
+ * oracle for paths outside the repo. A symlinked file pointing at another file
78
+ * INSIDE the repo stays true. */
79
+ export function isRepoFile(root, target) {
80
+ const t = toPosixTarget(target);
81
+ if (!t || isAbsolute(t) || /^[a-zA-Z]:/.test(t))
82
+ return false;
83
+ const abs = resolve(root, t);
84
+ const rel = relative(resolve(root), abs);
85
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
86
+ return false;
87
+ try {
88
+ if (!statSync(abs).isFile())
89
+ return false;
90
+ const realRel = relative(realpathNorm(resolve(root)), realpathSync.native(abs));
91
+ return !!realRel && realRel !== ".." && !realRel.startsWith(`..${sep}`) && !isAbsolute(realRel);
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
59
97
  export function hunchPaths(root) {
60
98
  const hunch = join(root, HUNCH_DIR);
61
99
  return {
@@ -39,15 +39,41 @@ export function taskSelectionSupplements(selection, target) {
39
39
  const parts = selection.mode === "latest"
40
40
  ? `latest ${counts.latest} (ranking off: it lost its evaluation; hunch task rank-eval)`
41
41
  : [counts.latest ? "latest" : null, counts.violation ? "problem" : null, counts.relevant ? `relevant ${counts.relevant}` : null].filter(Boolean).join(" · ");
42
+ // `hash_text`: the IDENTITY of the records this selection picked, for the
43
+ // pre-edit hook's injection dedup. Every presentation field here is volatile
44
+ // between two back-to-back calls with no record change — the slot label and
45
+ // counts move with ranking warmth, and the reason text flips ("today" →
46
+ // "delivered today", or to a different top reason) because serving the block
47
+ // writes delivery receipts the next call's ranking reads back. Hashing the
48
+ // rendered line therefore made the block self-invalidating and re-sent the
49
+ // full 3-4KB grounding.
50
+ //
51
+ // What the identity KEEPS, because each is a property of the records, the
52
+ // repo or the evaluation state and never of a receipt:
53
+ // - header: the target, the picked set of record ids (sorted, so order is
54
+ // not a change), `selection.mode` (ranker vs the "latest" fallback the
55
+ // kill rule imposes — resolved from .hunch/local.json or the rank-eval
56
+ // report), and `selection.more` (gated candidates minus picks; the gates
57
+ // read superseded ids, anchor liveness and file/rule structure — scores
58
+ // only order them, so the count does not move with warmth);
59
+ // - per task: its id, its own content hash, and whether its file anchors
60
+ // are still all alive (`anchorsAlive < 1` — the fact behind the "files
61
+ // since changed" reason), so a picked task whose anchors die mid-session
62
+ // re-sends the full block instead of leaving a silently stale line.
42
63
  return [
43
64
  {
44
65
  id: "recent-tasks", kind: "recent-tasks", priority: 415,
45
66
  text: `RECENT TASKS on ${target} — ${parts} — earlier agent work here, from graph memory (advisory history, not rules): build on what was verified instead of redoing it blind.${selection.more > 0 ? ` ${selection.more} more: hunch task list ${target}.` : ""}`,
67
+ hash_text: `recent-tasks ${target} ${[...selection.picks.map((p) => p.ranked.record.id)].sort().join(",")} mode=${selection.mode ?? "ranked"} more=${selection.more}`,
46
68
  },
47
69
  ...selection.picks.map((p, i) => {
48
70
  const t = p.ranked.record;
49
71
  const reasons = p.ranked.reasons.slice(0, 2).join(" · ");
50
- return { id: t.id, kind: "recent-task", priority: 414 - i, text: `${SLOT_LABEL[p.slot]} ${t.id} · ${t.finished_at.slice(0, 10)} · "${clip(t.title, 80)}" — ${reasons} · ${summarizeRecord(t)}` };
72
+ return {
73
+ id: t.id, kind: "recent-task", priority: 414 - i,
74
+ text: `${SLOT_LABEL[p.slot]} ${t.id} · ${t.finished_at.slice(0, 10)} · "${clip(t.title, 80)}" — ${reasons} · ${summarizeRecord(t)}`,
75
+ hash_text: `${t.id}@${t.report_hash}${p.ranked.anchorsAlive < 1 ? "!stale" : ""}`,
76
+ };
51
77
  }),
52
78
  ];
53
79
  }
@@ -21,6 +21,25 @@ export declare function hookReportTaskId(root: string, provider: HookProvider, e
21
21
  * No raw prompt, host session identifier, or transcript is retained; a repository
22
22
  * that opts in (`taskTitles: "prompt"`) keeps only a bounded first-line title. */
23
23
  export declare function startHookReport(root: string, provider: HookProvider, event: HunchHookInput): string | null;
24
+ /** The hook already opened the task, so the model needs no start call: the only
25
+ * thing start used to supply was verification_argv, and the launcher is printed
26
+ * inline here. Identical in substance for EVERY hook provider that reaches this
27
+ * function; the one variation is capability-driven, never host-named — where the
28
+ * host is not PROVEN to close the task it opened (HOST_CLOSES_TASK) nobody but the
29
+ * next prompt's settle would close it, so finish stays mandatory there. Elsewhere
30
+ * finish is CONDITIONAL: ~87 start/finish round trips a day mostly returned "No
31
+ * task-linked delivery observed", and the host's Stop hook closes the task and
32
+ * shows the evidence either way. FAILS OPEN (con_03a0b94b2e): if the launcher
33
+ * cannot be computed, fall back to asking for the start call — that path is then
34
+ * the only source of both the launcher and the finish instruction, so it carries
35
+ * its own finish sentence. */
36
+ export declare function taskInstruction(task: {
37
+ task_id: string;
38
+ title: string;
39
+ }, cwdLiteral: string, provider: HookProvider, launcher?: () => {
40
+ shell: string;
41
+ note?: string;
42
+ }): string;
24
43
  /** A prompt the host generated to report a background command's completion,
25
44
  * not something the user typed. */
26
45
  export declare function isNotificationPrompt(prompt: string | undefined): boolean;
@@ -8,6 +8,7 @@ import { isCredentialFreeText } from "./types.js";
8
8
  import { aliasReportTask, continuationLinks, finishReportTask, isEmptyTaskReport, latestSessionTask, readTaskReport, recordReportRefusal, reportHash, reportPresentationEnabled, reportTaskExists, resolveReportTask, settleSessionTasks, startReportTask } from "./taskReport.js";
9
9
  import { reportSourceSnapshot } from "./taskReportEvidence.js";
10
10
  import { renderTaskReport } from "./taskReportRender.js";
11
+ import { verificationLauncher } from "./verifyLauncher.js";
11
12
  /** The exact task identity a native host prompt maps to. */
12
13
  export function promptTaskId(root, sessionId, promptId, agentId = null, provider = "claude") {
13
14
  return `htask_${reportHash([canonicalReportRoot(root), provider, sessionId, promptId, agentId]).slice(7, 31)}`;
@@ -15,6 +16,16 @@ export function promptTaskId(root, sessionId, promptId, agentId = null, provider
15
16
  /** Hosts whose hooks deliver a native per-prompt identity (Claude Code's
16
17
  * prompt_id, Codex's turn_id). Others get no task from a hook. */
17
18
  const NATIVE_PROMPT_HOSTS = new Set(["claude", "codex"]);
19
+ /** Hosts PROVEN to close a task they opened, so its evidence is shown without
20
+ * the agent's cooperation and the finish call may be made conditional. A host
21
+ * belongs here only when BOTH hold: (1) `hunch init` wires its stop event, and
22
+ * (2) its stop payload carries the same native prompt identity the task was
23
+ * opened under, so `closeHookTask` actually resolves that task and closes it.
24
+ * (2) is what excludes a host with a stop hook but no native identity: outside
25
+ * NATIVE_PROMPT_HOSTS `nativeHookCwd` returns null on Stop, so nothing is
26
+ * closed and a skipped finish would leak an open task. Everywhere else finish
27
+ * stays mandatory. Adding a host requires proving both, never its name. */
28
+ const HOST_CLOSES_TASK = new Set(["claude", "codex"]);
18
29
  const NATIVE_TASK_TITLE = "Assistant task";
19
30
  const GENERIC_TASK_TITLES = new Set(["Assistant task", "Claude task"]);
20
31
  const TASK_TITLE_MAX = 72;
@@ -133,7 +144,7 @@ export function startHookReport(root, provider, event) {
133
144
  // this prompt's Stop and hook observations report to that task.
134
145
  if (previous && previous.task_id !== id && isNotificationPrompt(event.prompt) && previous.closed_by !== "agent") {
135
146
  aliasReportTask(root, id, previous.task_id);
136
- return taskInstruction(previous, cwdLiteral);
147
+ return taskInstruction(previous, cwdLiteral, provider);
137
148
  }
138
149
  const continued = previous && previous.task_id !== id ? continuationLinks(previous) : null;
139
150
  if (continued)
@@ -154,10 +165,35 @@ export function startHookReport(root, provider, event) {
154
165
  throw error;
155
166
  task = existing;
156
167
  }
157
- return taskInstruction(task, cwdLiteral);
168
+ return taskInstruction(task, cwdLiteral, provider);
158
169
  }
159
- function taskInstruction(task, cwdLiteral) {
160
- return `Hunch has opened this prompt's report: ${task.task_id}. Reuse this exact ID for this prompt. Call hunch_task(action: "start", task_id: "${task.task_id}", title: ${JSON.stringify(task.title)}, cwd: ${cwdLiteral}) to obtain verification_argv; do not create another report. Pass this task_id and cwd: ${cwdLiteral} to hunch_context and decision/correction/finding captures, and pass the same cwd when finishing with hunch_task before responding. A host Stop notice will show the evidence even if no task-linked memory was observed.`;
170
+ /** The hook already opened the task, so the model needs no start call: the only
171
+ * thing start used to supply was verification_argv, and the launcher is printed
172
+ * inline here. Identical in substance for EVERY hook provider that reaches this
173
+ * function; the one variation is capability-driven, never host-named — where the
174
+ * host is not PROVEN to close the task it opened (HOST_CLOSES_TASK) nobody but the
175
+ * next prompt's settle would close it, so finish stays mandatory there. Elsewhere
176
+ * finish is CONDITIONAL: ~87 start/finish round trips a day mostly returned "No
177
+ * task-linked delivery observed", and the host's Stop hook closes the task and
178
+ * shows the evidence either way. FAILS OPEN (con_03a0b94b2e): if the launcher
179
+ * cannot be computed, fall back to asking for the start call — that path is then
180
+ * the only source of both the launcher and the finish instruction, so it carries
181
+ * its own finish sentence. */
182
+ export function taskInstruction(task, cwdLiteral, provider, launcher = verificationLauncher) {
183
+ 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
+ let verify;
185
+ try {
186
+ const l = launcher();
187
+ 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
+ }
189
+ catch {
190
+ return `${head} Call hunch_task(action: "start", task_id: "${task.task_id}", title: ${JSON.stringify(task.title)}, cwd: ${cwdLiteral}) to obtain verification_argv, and finish with hunch_task(action: "finish", task_id, cwd) before responding and show its card.`;
191
+ }
192
+ const used = `this task used Hunch (a hunch_* call on this ID, a task verify check, Hunch hook context you acted on, or an application to claim)`;
193
+ const finish = HOST_CLOSES_TASK.has(provider)
194
+ ? ` ONLY if ${used}, call hunch_task(action: "finish", task_id, cwd) before responding and show its card; otherwise skip it — this host's stop hook closes the task and shows the evidence.`
195
+ : ` No stop hook closes this task, so finish it yourself whether or not ${used}: call hunch_task(action: "finish", task_id, cwd) before responding and show its card.`;
196
+ return `${head}${verify}${finish}`;
161
197
  }
162
198
  /** The session key a hook event maps to: a hash of (root, provider, session,
163
199
  * agent), never the identifier itself. Null without a host session. */
@@ -0,0 +1,21 @@
1
+ /** Structured argv is authoritative; the shell hint uses literal quoting, and
2
+ * `note` disambiguates it where one platform has two incompatible shells. */
3
+ export declare function verificationLauncher(): {
4
+ argv: string[];
5
+ shell: string;
6
+ note: string;
7
+ };
8
+ /** `metaUrl` is the module running (a `.ts` source checkout needs the tsx
9
+ * loader; a published `.js` build needs nothing) and `resolve` is that
10
+ * module's `import.meta.resolve`. Callers in sibling directories (src/core,
11
+ * src/mcp) resolve the same `../cli/index.{ts|js}`, but each must pass ITS OWN
12
+ * import.meta so the dev/published discrimination stays honest. The loader is
13
+ * resolved ONLY on the source path: `import.meta.resolve` throws for a package
14
+ * that is not installed, and `tsx` is a devDependency absent from every
15
+ * published install (#261). `platform` defaults to the running one and exists so
16
+ * the Windows quoting branch is testable from any machine. */
17
+ export declare function verificationLauncherFor(metaUrl: string, resolve: (specifier: string) => string, platform?: NodeJS.Platform): {
18
+ argv: string[];
19
+ shell: string;
20
+ note: string;
21
+ };
@@ -0,0 +1,37 @@
1
+ /** The launcher that runs `hunch task verify` from THIS installation, not a
2
+ * potentially stale global binary. Core, not mcp: the prompt hook prints the
3
+ * command inline (so the model needs no hunch_task start call just to learn it)
4
+ * and a hook must never pull in the MCP SDK. */
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ /** Structured argv is authoritative; the shell hint uses literal quoting, and
7
+ * `note` disambiguates it where one platform has two incompatible shells. */
8
+ export function verificationLauncher() {
9
+ return verificationLauncherFor(import.meta.url, (specifier) => import.meta.resolve(specifier));
10
+ }
11
+ /** `metaUrl` is the module running (a `.ts` source checkout needs the tsx
12
+ * loader; a published `.js` build needs nothing) and `resolve` is that
13
+ * module's `import.meta.resolve`. Callers in sibling directories (src/core,
14
+ * src/mcp) resolve the same `../cli/index.{ts|js}`, but each must pass ITS OWN
15
+ * import.meta so the dev/published discrimination stays honest. The loader is
16
+ * resolved ONLY on the source path: `import.meta.resolve` throws for a package
17
+ * that is not installed, and `tsx` is a devDependency absent from every
18
+ * published install (#261). `platform` defaults to the running one and exists so
19
+ * the Windows quoting branch is testable from any machine. */
20
+ export function verificationLauncherFor(metaUrl, resolve, platform = process.platform) {
21
+ const dev = metaUrl.endsWith(".ts");
22
+ const entry = fileURLToPath(new URL(`../cli/index.${dev ? "ts" : "js"}`, metaUrl));
23
+ // `--import` takes a URL. Converting the resolved loader to a path made Node on
24
+ // Windows reject it ("Received protocol 'c:'"), so every verification launched
25
+ // from a source checkout there failed before running and cards showed no check.
26
+ const loader = dev ? resolve("tsx") : null;
27
+ const argv = [process.execPath, ...(loader ? ["--import", loader.startsWith("file:") ? loader : pathToFileURL(loader).href] : []), entry];
28
+ const win = platform === "win32";
29
+ const quote = (s) => win ? `'${s.replace(/'/g, "''")}'` : `'${s.replace(/'/g, "'\\''")}'`;
30
+ // Windows hosts run either PowerShell or a POSIX shell (Git Bash), and the
31
+ // call operator that PowerShell needs is a syntax error in the other. The hint
32
+ // is quoted for PowerShell and says how to use it in the other; the structured
33
+ // argv stays the unambiguous form.
34
+ const note = win ? ` (PowerShell form; in a POSIX shell such as Git Bash drop the leading "& ")` : "";
35
+ return { argv, shell: `${win ? "& " : ""}${argv.map(quote).join(" ")}`, note };
36
+ }
37
+ //# sourceMappingURL=verifyLauncher.js.map
@@ -11,8 +11,9 @@
11
11
  import { readFileSync } from "node:fs";
12
12
  import { dirname, join, posix } from "node:path";
13
13
  import { parseSource, attributeCalls, attributeRelations, MAX_BODY_TEXT_CHARS } from "./parse.js";
14
+ import { isParserLoadError } from "./nativeTreeSitter.js";
14
15
  import { extractHelmDirectives } from "./helm.js";
15
- import { extractK8sManifest } from "./k8sManifest.js";
16
+ import { extractK8sManifest, namespacesCompatible } from "./k8sManifest.js";
16
17
  import { symbolId, componentId, edgeId, sha1 } from "../core/ids.js";
17
18
  import { externalImportNodeId, externalPackage } from "../core/externalImports.js";
18
19
  import { resolveRelativeImport } from "../core/relativeImports.js";
@@ -101,6 +102,12 @@ export function scanRepo(store, root, opts = {}) {
101
102
  const perFileCalls = [];
102
103
  const perFileImports = [];
103
104
  const perFileRelations = [];
105
+ // `namespace` on all four: a Kubernetes reference only resolves WITHIN a
106
+ // namespace, so a same-named resource in a different one is a different
107
+ // resource (issue #297). null means UNKNOWN (absent/templated/empty), which
108
+ // matches anything -- see namespacesCompatible. All four are in-memory
109
+ // resolution scratch, never persisted: the edges they produce keep their
110
+ // existing shape.
104
111
  const k8sResourceIndex = [];
105
112
  const k8sReferenceCandidates = [];
106
113
  const k8sSelectors = [];
@@ -145,7 +152,16 @@ export function scanRepo(store, root, opts = {}) {
145
152
  try {
146
153
  parsed = parseSource(rel, src);
147
154
  }
148
- catch {
155
+ catch (error) {
156
+ // …but a dead PARSER is not a bad file. The native addons load on first
157
+ // parse, so a broken load (unwritable TMPDIR, missing prebuild, an addon
158
+ // preloaded past the isolation guard) surfaces here and would mark every
159
+ // file parse_failed, after which indexRepo replaces symbols/edges/
160
+ // components with empty arrays and exits 0 — the whole graph silently
161
+ // wiped. Rethrow so the scan dies before its first store write, the way
162
+ // the import-time load did.
163
+ if (isParserLoadError(error))
164
+ throw error;
149
165
  skipped++;
150
166
  issues.push({ path: rel, code: "parse_failed", detail: `${rel} could not be parsed` });
151
167
  noteSkip(rel, "parse_failed");
@@ -248,17 +264,18 @@ export function scanRepo(store, root, opts = {}) {
248
264
  if (!fromId)
249
265
  continue;
250
266
  const scope = chartRoot ?? rel;
251
- k8sResourceIndex.push({ symbolId: fromId, scope, kind: doc.resource.kind, nameKey: nameKeyText(doc.resource.name) });
267
+ const namespace = doc.resource.namespace;
268
+ k8sResourceIndex.push({ symbolId: fromId, scope, kind: doc.resource.kind, nameKey: nameKeyText(doc.resource.name), namespace });
252
269
  for (const ref of doc.references) {
253
270
  k8sReferenceCandidates.push({
254
- fromSymbolId: fromId, scope, refKind: ref.refKind, nameKey: nameKeyText(ref.name),
271
+ fromSymbolId: fromId, scope, refKind: ref.refKind, nameKey: nameKeyText(ref.name), namespace: ref.namespace,
255
272
  reason: `${doc.resource.kind}/${displayNameText(doc.resource.name)} references ${ref.refKind}/${displayNameText(ref.name)}`,
256
273
  });
257
274
  }
258
275
  if (doc.selector)
259
- k8sSelectors.push({ symbolId: fromId, scope, selector: doc.selector });
276
+ k8sSelectors.push({ symbolId: fromId, scope, namespace, selector: doc.selector });
260
277
  if (doc.labels)
261
- k8sWorkloadLabels.push({ symbolId: fromId, scope, labels: doc.labels });
278
+ k8sWorkloadLabels.push({ symbolId: fromId, scope, namespace, labels: doc.labels });
262
279
  }
263
280
  fileSymbols.set(rel, idsInFile);
264
281
  fileSymbolIndexId.set(rel, symbolIndexId);
@@ -359,16 +376,24 @@ export function scanRepo(store, root, opts = {}) {
359
376
  // only, with no concept of Kubernetes kind -- a ConfigMap and a Secret that
360
377
  // happen to share a name would incorrectly conflate. Same ambiguity contract
361
378
  // as resolveName() though: 0 matches or 2+ matches -> no edge, never guess.
379
+ //
380
+ // Namespace is a FILTER applied to the candidate list, not part of the key
381
+ // (issue #297): an unknown namespace on either side must still match, which
382
+ // a key can't express. Filtering before the uniqueness check -- rather than
383
+ // after picking a single candidate -- is what makes "ref in a, candidates in
384
+ // a and b" resolve to a instead of declining as ambiguous, while "ref in a,
385
+ // candidates in a and unknown" correctly stays ambiguous.
362
386
  const kindNameIndex = new Map();
363
387
  for (const r of k8sResourceIndex) {
364
388
  const key = `${r.scope}:${r.kind}:${r.nameKey}`;
365
- pushInto(kindNameIndex, key, r.symbolId);
389
+ pushInto(kindNameIndex, key, r);
366
390
  }
367
391
  for (const ref of k8sReferenceCandidates) {
368
- const candidates = kindNameIndex.get(`${ref.scope}:${ref.refKind}:${ref.nameKey}`) ?? [];
392
+ const byName = kindNameIndex.get(`${ref.scope}:${ref.refKind}:${ref.nameKey}`) ?? [];
393
+ const candidates = byName.filter((c) => namespacesCompatible(ref.namespace, c.namespace));
369
394
  if (candidates.length !== 1)
370
395
  continue; // 0 or 2+ -> ambiguous or absent, don't guess
371
- const toId = candidates[0];
396
+ const toId = candidates[0].symbolId;
372
397
  if (toId === ref.fromSymbolId)
373
398
  continue;
374
399
  addEdge({
@@ -410,6 +435,11 @@ export function scanRepo(store, root, opts = {}) {
410
435
  // ids can never collide today.
411
436
  if (svc.symbolId === wl.symbolId)
412
437
  continue;
438
+ // A Service only ever selects pods in its OWN namespace -- a
439
+ // label-identical workload next door is a different workload (issue
440
+ // #297). Unknown on either side still matches, same rule as Phase 1.
441
+ if (!namespacesCompatible(svc.namespace, wl.namespace))
442
+ continue;
413
443
  const isSubset = Object.entries(svc.selector).every(([k, v]) => wl.labels[k] === v);
414
444
  if (!isSubset)
415
445
  continue;
@@ -37,11 +37,21 @@ interface K8sReferenceCandidate {
37
37
  * value (data-dependent, not statically known). */
38
38
  refKind: string;
39
39
  name: ManifestNameRef;
40
+ /** The namespace this reference resolves IN, or null for "unknown" (see
41
+ * literalNamespace). Kubernetes name references are same-namespace by
42
+ * definition, so this defaults to the referring DOCUMENT's own namespace;
43
+ * the one shape that can legitimately point elsewhere (a Gateway API
44
+ * `backendRefs[].namespace` sibling) overrides it. */
45
+ namespace: string | null;
40
46
  }
41
47
  type ManifestLabelMap = Record<string, string>;
42
48
  interface K8sResourceDoc {
43
49
  kind: string;
44
50
  name: ManifestNameRef;
51
+ /** `metadata.namespace` when it is a plain literal, else null = UNKNOWN (see
52
+ * literalNamespace). Cluster-scoped kinds need no special case: they simply
53
+ * never carry the field, so they are unknown and compatible with anything. */
54
+ namespace: string | null;
45
55
  startChar: number;
46
56
  endChar: number;
47
57
  }
@@ -51,6 +61,9 @@ export interface K8sManifestDocument {
51
61
  selector: ManifestLabelMap | null;
52
62
  labels: ManifestLabelMap | null;
53
63
  }
64
+ /** Namespace compatibility per the product rule: a MISSING (unknown) namespace
65
+ * matches anything; two DIFFERENT literal namespaces block the edge. */
66
+ export declare function namespacesCompatible(a: string | null, b: string | null): boolean;
54
67
  /** Where a kind's pod spec lives -- factored once so container/volume field
55
68
  * paths below aren't hand-duplicated per kind. */
56
69
  export declare const POD_SPEC_PATH_BY_KIND: Record<string, string>;