@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.
- package/dist/cli/index.js +59 -16
- package/dist/constitution/g2BehaviorCandidates.js +6 -1
- package/dist/constitution/g2Candidates.js +11 -1
- package/dist/constitution/structural.js +10 -0
- package/dist/core/delivery.d.ts +34 -0
- package/dist/core/delivery.js +60 -1
- package/dist/core/docanchors.js +181 -11
- package/dist/core/format.js +6 -1
- package/dist/core/glob.d.ts +12 -6
- package/dist/core/glob.js +12 -6
- package/dist/core/hookcache.d.ts +9 -2
- package/dist/core/hookcache.js +10 -3
- package/dist/core/paths.d.ts +21 -0
- package/dist/core/paths.js +39 -1
- package/dist/core/taskDelivery.js +27 -1
- package/dist/core/taskReportHook.d.ts +19 -0
- package/dist/core/taskReportHook.js +40 -4
- package/dist/core/verifyLauncher.d.ts +21 -0
- package/dist/core/verifyLauncher.js +37 -0
- package/dist/extractors/indexer.js +39 -9
- package/dist/extractors/k8sManifest.d.ts +13 -0
- package/dist/extractors/k8sManifest.js +103 -7
- package/dist/extractors/landscapeDiscovery.js +9 -1
- package/dist/extractors/nativeTreeSitter.d.ts +24 -0
- package/dist/extractors/nativeTreeSitter.js +54 -1
- package/dist/extractors/parse.js +6 -1
- package/dist/integrations/claudemd.js +3 -3
- package/dist/integrations/hooks.d.ts +43 -4
- package/dist/integrations/hooks.js +309 -22
- package/dist/mcp/server.js +19 -11
- package/dist/mcp/taskReportTools.d.ts +5 -9
- package/dist/mcp/taskReportTools.js +9 -20
- package/dist/store/changeLedger.d.ts +9 -3
- package/dist/store/changeLedger.js +36 -10
- package/dist/store/hunchStore.d.ts +42 -2
- package/dist/store/hunchStore.js +74 -16
- package/package.json +1 -1
- 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
|
|
88
|
-
* suffix. A real
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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)
|
package/dist/core/hookcache.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/core/hookcache.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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"));
|
package/dist/core/paths.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/paths.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
389
|
+
pushInto(kindNameIndex, key, r);
|
|
366
390
|
}
|
|
367
391
|
for (const ref of k8sReferenceCandidates) {
|
|
368
|
-
const
|
|
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>;
|