@davesheffer/hunch 1.39.3 → 1.40.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.
Files changed (43) hide show
  1. package/dist/cli/index.js +61 -17
  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/taskReportRender.d.ts +3 -0
  19. package/dist/core/taskReportRender.js +22 -12
  20. package/dist/core/verifyLauncher.d.ts +21 -0
  21. package/dist/core/verifyLauncher.js +37 -0
  22. package/dist/extractors/indexer.d.ts +5 -0
  23. package/dist/extractors/indexer.js +56 -11
  24. package/dist/extractors/k8sManifest.d.ts +13 -0
  25. package/dist/extractors/k8sManifest.js +103 -7
  26. package/dist/extractors/landscapeDiscovery.js +9 -1
  27. package/dist/extractors/nativeTreeSitter.d.ts +24 -0
  28. package/dist/extractors/nativeTreeSitter.js +54 -1
  29. package/dist/extractors/parse.d.ts +3 -1
  30. package/dist/extractors/parse.js +12 -3
  31. package/dist/integrations/claudemd.js +3 -3
  32. package/dist/integrations/hooks.d.ts +43 -4
  33. package/dist/integrations/hooks.js +309 -22
  34. package/dist/mcp/server.js +19 -11
  35. package/dist/mcp/taskReportTools.d.ts +5 -9
  36. package/dist/mcp/taskReportTools.js +9 -20
  37. package/dist/store/changeLedger.d.ts +9 -3
  38. package/dist/store/changeLedger.js +36 -10
  39. package/dist/store/hunchStore.d.ts +42 -2
  40. package/dist/store/hunchStore.js +74 -16
  41. package/dist/store/stateBinding.js +1 -1
  42. package/package.json +1 -1
  43. 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. */
@@ -1,5 +1,8 @@
1
1
  import { type LessonHistory, type TaskReport } from "./taskReport.js";
2
2
  export declare function writeTaskReportHtml(root: string, taskId: string, publicOnly?: boolean): string;
3
+ /** Delivered lessons are named on the card up to this many; beyond it the
4
+ * count of the rest is stated, never a silent cut. */
5
+ export declare const CARD_RECALLED_LIMIT = 8;
3
6
  /** One short line for the first time a lesson reaches a task; null when every
4
7
  * delivered revision was already seen in this task. Never a banner per delivery. */
5
8
  export declare function renderRecalledLine(fresh: readonly {
@@ -21,8 +21,15 @@ export function writeTaskReportHtml(root, taskId, publicOnly = false) {
21
21
  writeFileAtomic(file, publicOnly ? renderPublicTaskReportHtml(publicTaskReport(root, taskId)) : renderTaskReportHtml(report, undefined, histories));
22
22
  return file;
23
23
  }
24
+ /** Card text is copied verbatim into an agent's final response, so every
25
+ * field is shown whole: a title cut mid-word with an ellipsis reads as a
26
+ * truncated tool result and the agent refuses to reproduce the card. Every
27
+ * rendered field is schema-bounded (task title and check label ≤ 200
28
+ * characters, record titles ≤ 500), so whole never means unbounded. */
24
29
  function plain(value) { return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim(); }
25
- function clip(value, size = 66) { const chars = [...plain(value)]; return chars.length > size ? chars.slice(0, size - 1).join("") + "…" : chars.join(""); }
30
+ /** Delivered lessons are named on the card up to this many; beyond it the
31
+ * count of the rest is stated, never a silent cut. */
32
+ export const CARD_RECALLED_LIMIT = 8;
26
33
  function uniqueRecords(report) {
27
34
  return [...new Map(report.deliveries.flatMap(d => d.records).map(r => [`${r.kind}:${r.record_id}:${r.content_hash}`, r])).values()];
28
35
  }
@@ -42,41 +49,44 @@ export function renderRecalledLine(fresh) {
42
49
  if (!fresh.length)
43
50
  return null;
44
51
  const rest = fresh.length - 1;
45
- return `Hunch recalled: ${clip(fresh[0].title, 90)}${rest ? ` (+${rest} more lesson${rest === 1 ? "" : "s"})` : ""}`;
52
+ return `Hunch recalled: ${plain(fresh[0].title)}${rest ? ` (+${rest} more lesson${rest === 1 ? "" : "s"})` : ""}`;
46
53
  }
47
54
  export function renderTaskReport(report) {
48
55
  const records = uniqueRecords(report);
49
- const lines = [`Hunch · ${clip(report.task.title)}`, `Task ${report.task.task_id} · ${report.task.state}`];
56
+ const lines = [`Hunch · ${plain(report.task.title)}`, `Task ${report.task.task_id} · ${report.task.state}`];
50
57
  if (!report.deliveries.length)
51
58
  lines.push("No task-linked delivery observed; connection/use is unverified.");
52
59
  else if (!records.length)
53
60
  lines.push(report.coverage === "no-relevant-memory" ? "No relevant memory returned for this task." : "Memory delivered; exact record snapshots unavailable.");
54
61
  else {
55
- lines.push(`Recalled ${clip(records[0].title, 65)}`);
56
- if (records.length > 1)
57
- lines.push(` ${records.length - 1} more lesson(s) in the evidence view`);
62
+ const named = records.slice(0, CARD_RECALLED_LIMIT);
63
+ lines.push(`Recalled ${plain(named[0].title)}`);
64
+ for (const record of named.slice(1))
65
+ lines.push(` ${plain(record.title)}`);
66
+ if (records.length > named.length)
67
+ lines.push(` ${records.length - named.length} more lesson(s) in the evidence view`);
58
68
  }
59
69
  const standing = ruleStanding(report);
60
70
  const violated = standing.find(r => r.outcome === "violated");
61
71
  const held = standing.find(r => r.outcome === "satisfied" && r.current);
62
72
  if (report.claims.length) {
63
73
  const claim = report.claims.find(c => c.supported_by) ?? report.claims[0];
64
- lines.push(`Applied ${clip(claim.action, 42)} · ${claim.supported_by ? "rule-supported" : "agent-reported"}`);
74
+ lines.push(`Applied ${plain(claim.action)} · ${claim.supported_by ? "rule-supported" : "agent-reported"}`);
65
75
  }
66
76
  else if (held)
67
- lines.push(`Conformed ${clip(recordTitle(report, held), 34)} · rule held on ${held.files.length} changed file(s)`);
77
+ lines.push(`Conformed ${plain(recordTitle(report, held))} · rule held on ${held.files.length} changed file(s)`);
68
78
  else if (records.length)
69
79
  lines.push("Impact Memory delivered; contribution unverified.");
70
80
  if (violated)
71
- lines.push(`Violated ${clip(recordTitle(report, violated), 34)} · rule broken on changed files`);
81
+ lines.push(`Violated ${plain(recordTitle(report, violated))} · rule broken on changed files`);
72
82
  if (report.saves.length)
73
- lines.push(`Saved ${clip(report.saves[0].record.title, 38)} · ${report.saves[0].durability}`);
83
+ lines.push(`Saved ${plain(report.saves[0].record.title)} · ${report.saves[0].durability}`);
74
84
  if (report.refusals.length)
75
- lines.push(`Guarded Denial emitted · ${clip(report.refusals[0].record_id, 40)}`);
85
+ lines.push(`Guarded Denial emitted · ${plain(report.refusals[0].record_id)}`);
76
86
  const check = report.checks.at(-1);
77
87
  if (check) {
78
88
  const state = check.cancelled ? "cancelled" : check.timed_out ? "timed out" : check.exit_code === 0 ? "passed" : "failed";
79
- lines.push(`Checked ${clip(check.label, 28)} · ${state}${check.current ? " · current source snapshot" : " · current source unverified"}`);
89
+ lines.push(`Checked ${plain(check.label)} · ${state}${check.current ? " · current source snapshot" : " · current source unverified"}`);
80
90
  }
81
91
  else
82
92
  lines.push("Checked No independent command result recorded.");
@@ -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
@@ -46,6 +46,11 @@ export interface IndexRepoOptions {
46
46
  * false satisfied receipt. Call this after scanRepo when the consumer cannot
47
47
  * represent partial-graph uncertainty directly. */
48
48
  export declare function assertCompleteRepoScan(scan: RepoScan): void;
49
+ /** A whole-language failure can be a broken grammar, not a set of bad files.
50
+ * Refuse the entire publication so cross-language edges and curated components
51
+ * remain consistent with the previous symbols. Read-only scans retain coverage
52
+ * and issues for diagnostics; a valid empty scan is still publishable. */
53
+ export declare function assertNoTotalParseFailure(scan: RepoScan): void;
49
54
  /** Derive the current repository graph without writing JSON or rebuilding SQLite.
50
55
  * Read-only gates use this so checking changed code can never rewrite or publish
51
56
  * the durable graph merely by inspecting it. Existing public graph records remain
@@ -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";
@@ -34,6 +35,20 @@ export function assertCompleteRepoScan(scan) {
34
35
  const more = scan.issues.length > 5 ? ` (+${scan.issues.length - 5} more)` : "";
35
36
  throw new Error(`incomplete semantic source scan rejected ${scan.issues.length} file(s): ${sample}${more}`);
36
37
  }
38
+ /** A whole-language failure can be a broken grammar, not a set of bad files.
39
+ * Refuse the entire publication so cross-language edges and curated components
40
+ * remain consistent with the previous symbols. Read-only scans retain coverage
41
+ * and issues for diagnostics; a valid empty scan is still publishable. */
42
+ export function assertNoTotalParseFailure(scan) {
43
+ const failed = scan.result.coverage.filter((item) => item.eligible > 0 && item.reasons.parse_failed === item.eligible);
44
+ if (!failed.length)
45
+ return;
46
+ const details = failed.map((item) => {
47
+ const first = scan.issues.find((issue) => issue.code === "parse_failed" && languageFor(issue.path)?.id === item.language);
48
+ return `${item.language}: all ${item.eligible} eligible file(s) failed to parse; ${first?.detail ?? "unknown parse failure"}`;
49
+ });
50
+ throw new Error(`index refused — ${details.join("; ")}. Previous graph preserved.`);
51
+ }
37
52
  /** Derive the current repository graph without writing JSON or rebuilding SQLite.
38
53
  * Read-only gates use this so checking changed code can never rewrite or publish
39
54
  * the durable graph merely by inspecting it. Existing public graph records remain
@@ -101,6 +116,12 @@ export function scanRepo(store, root, opts = {}) {
101
116
  const perFileCalls = [];
102
117
  const perFileImports = [];
103
118
  const perFileRelations = [];
119
+ // `namespace` on all four: a Kubernetes reference only resolves WITHIN a
120
+ // namespace, so a same-named resource in a different one is a different
121
+ // resource (issue #297). null means UNKNOWN (absent/templated/empty), which
122
+ // matches anything -- see namespacesCompatible. All four are in-memory
123
+ // resolution scratch, never persisted: the edges they produce keep their
124
+ // existing shape.
104
125
  const k8sResourceIndex = [];
105
126
  const k8sReferenceCandidates = [];
106
127
  const k8sSelectors = [];
@@ -143,11 +164,20 @@ export function scanRepo(store, root, opts = {}) {
143
164
  // one bad/oversized file must never abort the whole index run
144
165
  let parsed;
145
166
  try {
146
- parsed = parseSource(rel, src);
167
+ parsed = parseSource(rel, src, { throwOnParseError: true });
147
168
  }
148
- catch {
169
+ catch (error) {
170
+ // …but a dead PARSER is not a bad file. The native addons load on first
171
+ // parse, so a broken load (unwritable TMPDIR, missing prebuild, an addon
172
+ // preloaded past the isolation guard) surfaces here and would mark every
173
+ // file parse_failed, after which indexRepo replaces symbols/edges/
174
+ // components with empty arrays and exits 0 — the whole graph silently
175
+ // wiped. Rethrow so the scan dies before its first store write, the way
176
+ // the import-time load did.
177
+ if (isParserLoadError(error))
178
+ throw error;
149
179
  skipped++;
150
- issues.push({ path: rel, code: "parse_failed", detail: `${rel} could not be parsed` });
180
+ issues.push({ path: rel, code: "parse_failed", detail: `${rel} could not be parsed: ${error instanceof Error ? error.message : String(error)}` });
151
181
  noteSkip(rel, "parse_failed");
152
182
  continue;
153
183
  }
@@ -248,17 +278,18 @@ export function scanRepo(store, root, opts = {}) {
248
278
  if (!fromId)
249
279
  continue;
250
280
  const scope = chartRoot ?? rel;
251
- k8sResourceIndex.push({ symbolId: fromId, scope, kind: doc.resource.kind, nameKey: nameKeyText(doc.resource.name) });
281
+ const namespace = doc.resource.namespace;
282
+ k8sResourceIndex.push({ symbolId: fromId, scope, kind: doc.resource.kind, nameKey: nameKeyText(doc.resource.name), namespace });
252
283
  for (const ref of doc.references) {
253
284
  k8sReferenceCandidates.push({
254
- fromSymbolId: fromId, scope, refKind: ref.refKind, nameKey: nameKeyText(ref.name),
285
+ fromSymbolId: fromId, scope, refKind: ref.refKind, nameKey: nameKeyText(ref.name), namespace: ref.namespace,
255
286
  reason: `${doc.resource.kind}/${displayNameText(doc.resource.name)} references ${ref.refKind}/${displayNameText(ref.name)}`,
256
287
  });
257
288
  }
258
289
  if (doc.selector)
259
- k8sSelectors.push({ symbolId: fromId, scope, selector: doc.selector });
290
+ k8sSelectors.push({ symbolId: fromId, scope, namespace, selector: doc.selector });
260
291
  if (doc.labels)
261
- k8sWorkloadLabels.push({ symbolId: fromId, scope, labels: doc.labels });
292
+ k8sWorkloadLabels.push({ symbolId: fromId, scope, namespace, labels: doc.labels });
262
293
  }
263
294
  fileSymbols.set(rel, idsInFile);
264
295
  fileSymbolIndexId.set(rel, symbolIndexId);
@@ -359,16 +390,24 @@ export function scanRepo(store, root, opts = {}) {
359
390
  // only, with no concept of Kubernetes kind -- a ConfigMap and a Secret that
360
391
  // happen to share a name would incorrectly conflate. Same ambiguity contract
361
392
  // as resolveName() though: 0 matches or 2+ matches -> no edge, never guess.
393
+ //
394
+ // Namespace is a FILTER applied to the candidate list, not part of the key
395
+ // (issue #297): an unknown namespace on either side must still match, which
396
+ // a key can't express. Filtering before the uniqueness check -- rather than
397
+ // after picking a single candidate -- is what makes "ref in a, candidates in
398
+ // a and b" resolve to a instead of declining as ambiguous, while "ref in a,
399
+ // candidates in a and unknown" correctly stays ambiguous.
362
400
  const kindNameIndex = new Map();
363
401
  for (const r of k8sResourceIndex) {
364
402
  const key = `${r.scope}:${r.kind}:${r.nameKey}`;
365
- pushInto(kindNameIndex, key, r.symbolId);
403
+ pushInto(kindNameIndex, key, r);
366
404
  }
367
405
  for (const ref of k8sReferenceCandidates) {
368
- const candidates = kindNameIndex.get(`${ref.scope}:${ref.refKind}:${ref.nameKey}`) ?? [];
406
+ const byName = kindNameIndex.get(`${ref.scope}:${ref.refKind}:${ref.nameKey}`) ?? [];
407
+ const candidates = byName.filter((c) => namespacesCompatible(ref.namespace, c.namespace));
369
408
  if (candidates.length !== 1)
370
409
  continue; // 0 or 2+ -> ambiguous or absent, don't guess
371
- const toId = candidates[0];
410
+ const toId = candidates[0].symbolId;
372
411
  if (toId === ref.fromSymbolId)
373
412
  continue;
374
413
  addEdge({
@@ -410,6 +449,11 @@ export function scanRepo(store, root, opts = {}) {
410
449
  // ids can never collide today.
411
450
  if (svc.symbolId === wl.symbolId)
412
451
  continue;
452
+ // A Service only ever selects pods in its OWN namespace -- a
453
+ // label-identical workload next door is a different workload (issue
454
+ // #297). Unknown on either side still matches, same rule as Phase 1.
455
+ if (!namespacesCompatible(svc.namespace, wl.namespace))
456
+ continue;
413
457
  const isSubset = Object.entries(svc.selector).every(([k, v]) => wl.labels[k] === v);
414
458
  if (!isSubset)
415
459
  continue;
@@ -589,6 +633,7 @@ export function indexRepo(store, root, opts = {}) {
589
633
  ? { kind: "commit", ref: "HEAD" }
590
634
  : opts.source;
591
635
  const scan = scanRepo(store, root, { churn: opts.churn, source });
636
+ assertNoTotalParseFailure(scan);
592
637
  if (opts.requireComplete)
593
638
  assertCompleteRepoScan(scan);
594
639
  store.json.replaceAll("symbols", scan.symbols);