@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.
- package/dist/cli/index.js +61 -17
- 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/taskReportRender.d.ts +3 -0
- package/dist/core/taskReportRender.js +22 -12
- package/dist/core/verifyLauncher.d.ts +21 -0
- package/dist/core/verifyLauncher.js +37 -0
- package/dist/extractors/indexer.d.ts +5 -0
- package/dist/extractors/indexer.js +56 -11
- 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.d.ts +3 -1
- package/dist/extractors/parse.js +12 -3
- 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/dist/store/stateBinding.js +1 -1
- 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. */
|
|
@@ -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
|
-
|
|
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: ${
|
|
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 · ${
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
81
|
+
lines.push(`Violated ${plain(recordTitle(report, violated))} · rule broken on changed files`);
|
|
72
82
|
if (report.saves.length)
|
|
73
|
-
lines.push(`Saved ${
|
|
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 · ${
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
403
|
+
pushInto(kindNameIndex, key, r);
|
|
366
404
|
}
|
|
367
405
|
for (const ref of k8sReferenceCandidates) {
|
|
368
|
-
const
|
|
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);
|