overseer-mcp 0.1.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.
@@ -0,0 +1,144 @@
1
+ import { renderSnapshots } from "./repo/files.js";
2
+ const SHARED_RULES = `You are the expert supervising a less capable executor agent.
3
+ Respond in the same language as the task or question.
4
+ Be decisive and brief: output only the requested contract, with no preamble.
5
+ Demand minimal, surgical changes. Forbid scope creep, broad refactors, renames, new dependencies, and global config edits unless explicitly required.
6
+ You receive summaries, file excerpts, and evidence provided by the executor agent. Work with what is given — do not assume access to the full repo.
7
+ If evidence is insufficient, ask for the missing input instead of guessing.
8
+ Preserve existing behavior. Flag regression risk.
9
+ Prohibitions: no broken behavior, no invented APIs/routes/tables, no data deletion, no design changes unless asked.
10
+ Keep the answer self-sufficient so the executor does not spend tokens rereading the whole project.`;
11
+ const NOTES_GROUND_TRUTH_RULE = `EXECUTOR NOTES and FILE SUMMARIES are the executor's own analysis of the codebase. Use them as your primary context to plan the implementation.
12
+ If the summaries are insufficient to plan safely, name the specific files or details you need under RISKS so the executor can provide them — do not invent contents.`;
13
+ export const GET_PLAN_FULL_SYSTEM_PROMPT = `${SHARED_RULES}
14
+ ${NOTES_GROUND_TRUTH_RULE}
15
+
16
+ Return exactly this full planning contract:
17
+ PLAN
18
+ INTERPRETATION: 1-2 lines restating what the user wants; flag ambiguity.
19
+ GOAL: exact functional objective.
20
+ FILES TO TOUCH: path -> what changes, only what is needed.
21
+ DO NOT TOUCH: explicit out-of-scope items and behavior to preserve.
22
+ STEPS: ordered, executable, minimal steps the executor can follow without extra context.
23
+ CONSTRAINTS: project rules and prohibitions.
24
+ SUCCESS CRITERIA: verifiable acceptance checks.
25
+ RISKS: technical risks, edge cases, regressions to watch.
26
+ VALIDATION: manual and automated checks to run if present.
27
+ ESCALATION: always include this line reminding the executor that for any complex or non-trivial problem, unclear error, or ambiguity that comes up while implementing, it must call the consult_expert MCP tool before improvising, bringing the 2+ candidate options/approaches it is weighing so the expert decides among them.
28
+
29
+ If required input is missing, state it under INTERPRETATION and still give the safest minimal next step.`;
30
+ export const GET_PLAN_LITE_SYSTEM_PROMPT = `${SHARED_RULES}
31
+ ${NOTES_GROUND_TRUTH_RULE}
32
+
33
+ Lite mode: do not analyze every file. Inspect only the minimum context needed to orient the executor.
34
+ Return exactly:
35
+ INTERPRETATION
36
+ FILES/AREAS
37
+ WHAT TO DO
38
+ CONSTRAINTS
39
+ VALIDATION
40
+ ESCALATION: always include this line reminding the executor that for any complex or non-trivial problem, unclear error, or ambiguity that comes up while implementing, it must call the consult_expert MCP tool before improvising, bringing the 2+ candidate options/approaches it is weighing so the expert decides among them.
41
+
42
+ If required input is missing, name the missing input briefly.`;
43
+ export const CONSULT_EXPERT_SYSTEM_PROMPT = `${SHARED_RULES}
44
+
45
+ Return exactly this intervention contract:
46
+ INTERVENTION: PROCEED | DECIDE | FIX | REDIRECT | NEED_INFO
47
+ SUMMARY: 1-2 lines with the verdict.
48
+ ACTION: numbered concrete steps.
49
+ WATCH_OUT: up to 2 bullets, only for real regression or edge risk.
50
+
51
+ Use PROCEED for a correct instinct, DECIDE to choose among options, FIX for root cause and exact fix, REDIRECT for scope or approach correction, and NEED_INFO when missing input prevents a safe answer.
52
+ When OPTIONS CONSIDERED are present, judge each against the real code and pick exactly one decisively (DECIDE), giving a finished answer the executor can act on without further back-and-forth. Reject the discarded options in one line each. Only add an option of your own if every option the executor brought is unsafe.`;
53
+ export const REQUEST_REVIEW_SYSTEM_PROMPT = `${SHARED_RULES}
54
+
55
+ Judge only the real diff and real validation output. Return exactly:
56
+ REVIEW
57
+ VERDICT: APPROVE | REQUEST_CHANGES
58
+ MATCHES_TASK: yes/no + one line
59
+ REGRESSIONS: list or none
60
+ OUT_OF_SCOPE: list or none
61
+ REQUIRED_FIXES: numbered list only when REQUEST_CHANGES
62
+
63
+ If validation or diff evidence is missing, mention the missing input and request changes only when it blocks a safe approval.`;
64
+ function renderOutlines(outlines) {
65
+ return outlines
66
+ .map((entry) => `----- OUTLINE: ${entry.path} -----\n${entry.outline}`)
67
+ .join("\n\n");
68
+ }
69
+ export function buildGetPlanUserPayload(input) {
70
+ const lines = [
71
+ "TASK",
72
+ input.task,
73
+ "",
74
+ "EXECUTOR NOTES",
75
+ input.notes?.trim() || "(none)",
76
+ "",
77
+ "GIT STATUS",
78
+ input.gitStatus || "(empty)",
79
+ "",
80
+ "REPO MAP (tracked file names only, capped)",
81
+ input.repoMap || "(empty)",
82
+ ];
83
+ if (input.fileSummaries) {
84
+ lines.push("", "FILE SUMMARIES (provided by executor)", input.fileSummaries);
85
+ }
86
+ if (input.snapshots.length > 0) {
87
+ const filesHeader = input.autoSelected?.length
88
+ ? `FILES (read from disk; server-selected by relevance: ${input.autoSelected.join(", ")})`
89
+ : "FILES (read from disk)";
90
+ lines.push("", filesHeader, renderSnapshots(input.snapshots));
91
+ }
92
+ if (input.outlines?.length) {
93
+ lines.push("", "OUTLINES (signature-only to save tokens; need a body? Name the file under RISKS so the executor opens it.)", renderOutlines(input.outlines));
94
+ }
95
+ return lines.join("\n");
96
+ }
97
+ export function buildConsultExpertUserPayload(input) {
98
+ const lines = [
99
+ "QUESTION",
100
+ input.question,
101
+ "",
102
+ "OPTIONS CONSIDERED",
103
+ input.optionsConsidered?.length ? input.optionsConsidered.map((option) => `- ${option}`).join("\n") : "(none)",
104
+ ];
105
+ if (input.fileSummaries) {
106
+ lines.push("", "FILE SUMMARIES (provided by executor)", input.fileSummaries);
107
+ }
108
+ if (input.snapshots.length > 0) {
109
+ lines.push("", "FILES (read from disk)", renderSnapshots(input.snapshots));
110
+ }
111
+ if (input.diffSummary) {
112
+ lines.push("", "DIFF SUMMARY (provided by executor)", input.diffSummary);
113
+ }
114
+ if (input.gitDiff) {
115
+ lines.push("", "DIFF (current unstaged changes from git)", input.gitDiff);
116
+ }
117
+ lines.push("", "ERROR OUTPUT", input.errorOutput || "(none)");
118
+ return lines.join("\n");
119
+ }
120
+ export function buildRequestReviewUserPayload(input) {
121
+ const lines = [
122
+ "TASK",
123
+ input.task || "(not provided)",
124
+ ];
125
+ if (input.diffSummary) {
126
+ lines.push("", "DIFF SUMMARY (provided by executor)", input.diffSummary);
127
+ }
128
+ if (input.gitDiff) {
129
+ lines.push("", "UNSTAGED DIFF", input.gitDiff);
130
+ }
131
+ if (input.gitDiffStaged) {
132
+ lines.push("", "STAGED DIFF", input.gitDiffStaged);
133
+ }
134
+ if (input.testOutputSummary) {
135
+ lines.push("", "TEST OUTPUT SUMMARY (provided by executor)", input.testOutputSummary);
136
+ }
137
+ if (input.testOutput) {
138
+ lines.push("", "TEST OUTPUT", input.testOutput);
139
+ }
140
+ return lines.join("\n");
141
+ }
142
+ export function renderTestResult(result) {
143
+ return [`COMMAND: ${result.command}`, `EXIT_CODE: ${result.exitCode}`, "OUTPUT", result.output || "(empty)"].join("\n");
144
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Talks to the Anthropic Messages API directly via native fetch.
3
+ */
4
+ export class AnthropicProvider {
5
+ cfg;
6
+ id;
7
+ constructor(cfg) {
8
+ this.cfg = cfg;
9
+ this.id = `anthropic:${cfg.model}`;
10
+ }
11
+ async complete(req) {
12
+ const base = (this.cfg.baseUrl ?? "https://api.anthropic.com").replace(/\/+$/, "");
13
+ const res = await fetch(`${base}/v1/messages`, {
14
+ method: "POST",
15
+ headers: {
16
+ "content-type": "application/json",
17
+ "x-api-key": this.cfg.apiKey,
18
+ "anthropic-version": "2023-06-01",
19
+ "anthropic-beta": "prompt-caching-2024-07-31",
20
+ },
21
+ body: JSON.stringify({
22
+ model: this.cfg.model,
23
+ max_tokens: this.cfg.maxTokens ?? req.maxTokens ?? 4096,
24
+ temperature: this.cfg.temperature ?? req.temperature ?? 0.2,
25
+ system: [
26
+ {
27
+ type: "text",
28
+ text: req.system,
29
+ cache_control: { type: "ephemeral" },
30
+ },
31
+ ],
32
+ messages: [{ role: "user", content: req.user }],
33
+ }),
34
+ });
35
+ if (!res.ok) {
36
+ const body = await res.text().catch(() => "");
37
+ throw new Error(`Anthropic API error ${res.status}: ${body.slice(0, 500)}`);
38
+ }
39
+ const data = (await res.json());
40
+ const text = (data.content ?? [])
41
+ .filter((b) => b.type === "text" && typeof b.text === "string")
42
+ .map((b) => b.text)
43
+ .join("\n")
44
+ .trim();
45
+ if (text.length === 0) {
46
+ throw new Error("Anthropic provider returned an empty completion");
47
+ }
48
+ return text;
49
+ }
50
+ }
@@ -0,0 +1,52 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * Uses a coding-plan CLI (Codex, Claude Code, ...) as the expert by invoking
4
+ * it as a subprocess. For users who pay via a coding plan and have no API key.
5
+ *
6
+ * The command is fully configurable because each CLI has its own one-shot
7
+ * invocation. Combine system + user into a single prompt and stream it in.
8
+ */
9
+ export class CliProvider {
10
+ cfg;
11
+ id;
12
+ constructor(cfg) {
13
+ this.cfg = cfg;
14
+ this.id = `cli:${cfg.command}`;
15
+ }
16
+ async complete(req) {
17
+ const prompt = `${req.system}\n\n========\n\n${req.user}`;
18
+ const args = this.cfg.promptVia === "arg" ? [...this.cfg.args, prompt] : [...this.cfg.args];
19
+ return await new Promise((resolve, reject) => {
20
+ const child = spawn(this.cfg.command, args, { cwd: this.cfg.cwd, shell: process.platform === "win32" });
21
+ let stdout = "";
22
+ let stderr = "";
23
+ const timer = setTimeout(() => {
24
+ child.kill("SIGKILL");
25
+ reject(new Error(`CLI provider "${this.cfg.command}" timed out after ${this.cfg.timeoutMs}ms`));
26
+ }, this.cfg.timeoutMs);
27
+ child.stdout.on("data", (d) => (stdout += d.toString()));
28
+ child.stderr.on("data", (d) => (stderr += d.toString()));
29
+ child.on("error", (err) => {
30
+ clearTimeout(timer);
31
+ reject(new Error(`Failed to launch CLI provider "${this.cfg.command}": ${err.message}`));
32
+ });
33
+ child.on("close", (code) => {
34
+ clearTimeout(timer);
35
+ if (code !== 0) {
36
+ reject(new Error(`CLI provider exited with code ${code}: ${stderr.slice(0, 500)}`));
37
+ return;
38
+ }
39
+ const text = stdout.trim();
40
+ if (text.length === 0) {
41
+ reject(new Error("CLI provider returned empty output"));
42
+ return;
43
+ }
44
+ resolve(text);
45
+ });
46
+ if (this.cfg.promptVia === "stdin") {
47
+ child.stdin.write(prompt);
48
+ child.stdin.end();
49
+ }
50
+ });
51
+ }
52
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Works with any endpoint that speaks the OpenAI Chat Completions shape:
3
+ * OpenAI, GLM (Zhipu), opencode-compatible gateways, local servers, etc.
4
+ * Uses native fetch — no SDK dependency.
5
+ */
6
+ export class OpenAICompatibleProvider {
7
+ cfg;
8
+ id;
9
+ constructor(cfg) {
10
+ this.cfg = cfg;
11
+ this.id = `openai-compatible:${cfg.model}`;
12
+ }
13
+ async complete(req) {
14
+ const url = `${this.cfg.baseUrl.replace(/\/+$/, "")}/chat/completions`;
15
+ const res = await fetch(url, {
16
+ method: "POST",
17
+ headers: {
18
+ "content-type": "application/json",
19
+ authorization: `Bearer ${this.cfg.apiKey}`,
20
+ },
21
+ body: JSON.stringify({
22
+ model: this.cfg.model,
23
+ temperature: this.cfg.temperature ?? req.temperature ?? 0.2,
24
+ max_tokens: this.cfg.maxTokens ?? req.maxTokens,
25
+ messages: [
26
+ { role: "system", content: req.system },
27
+ { role: "user", content: req.user },
28
+ ],
29
+ }),
30
+ });
31
+ if (!res.ok) {
32
+ const body = await res.text().catch(() => "");
33
+ throw new Error(`OpenAI-compatible API error ${res.status}: ${body.slice(0, 500)}`);
34
+ }
35
+ const data = (await res.json());
36
+ const text = data.choices?.[0]?.message?.content;
37
+ if (typeof text !== "string" || text.length === 0) {
38
+ throw new Error("OpenAI-compatible provider returned an empty completion");
39
+ }
40
+ return text;
41
+ }
42
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The expert backend abstraction.
3
+ *
4
+ * overseer-mcp does not talk to "GPT" or "Claude" directly. It talks to an
5
+ * ExpertProvider. Anything that can take a system+user prompt and return text
6
+ * can be the expert: an OpenAI-compatible API (OpenAI, GLM, opencode, ...),
7
+ * the Anthropic API, or a coding-plan CLI invoked as a subprocess.
8
+ *
9
+ * This is what makes overseer portable: clone it, point it at your own
10
+ * plan/key, and it works with your expert of choice.
11
+ */
12
+ export {};
@@ -0,0 +1,47 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /**
4
+ * Reads files from disk, scoped to the repo root.
5
+ *
6
+ * This is the heart of the anti-bias design: the executor agent says WHICH
7
+ * files matter, but the CONTENT comes from disk here — never from the
8
+ * executor's description. The executor cannot misrepresent what a file says.
9
+ */
10
+ export async function readFiles(repoRoot, filePaths, maxBytes) {
11
+ const root = path.resolve(repoRoot);
12
+ return Promise.all(filePaths.map(async (rel) => {
13
+ const abs = path.resolve(root, rel);
14
+ // Refuse to escape the repo root.
15
+ const within = abs === root || abs.startsWith(root + path.sep);
16
+ if (!within) {
17
+ return { path: rel, error: "refused: path is outside the repo root" };
18
+ }
19
+ try {
20
+ const raw = await readFile(abs);
21
+ if (raw.byteLength > maxBytes) {
22
+ const head = raw.subarray(0, maxBytes).toString("utf8");
23
+ return {
24
+ path: rel,
25
+ content: `${head}\n... (truncated at ${maxBytes} bytes; file is ${raw.byteLength} bytes)`,
26
+ };
27
+ }
28
+ return { path: rel, content: raw.toString("utf8") };
29
+ }
30
+ catch (err) {
31
+ const message = err instanceof Error ? err.message : String(err);
32
+ return { path: rel, error: message };
33
+ }
34
+ }));
35
+ }
36
+ /** Render snapshots as a labelled block for the expert prompt. */
37
+ export function renderSnapshots(snapshots) {
38
+ if (snapshots.length === 0)
39
+ return "(no files provided)";
40
+ return snapshots
41
+ .map((s) => {
42
+ const header = `----- FILE: ${s.path} -----`;
43
+ const body = s.error ? `(could not read: ${s.error})` : s.content ?? "";
44
+ return `${header}\n${body}`;
45
+ })
46
+ .join("\n\n");
47
+ }
@@ -0,0 +1,19 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Walk up from `start` looking for a `.git` entry. Returns the first directory
5
+ * that contains one, or `undefined` if none is found before hitting the root.
6
+ */
7
+ export function findGitRoot(start) {
8
+ let dir = path.resolve(start);
9
+ // Guard against infinite loops on Windows drive roots.
10
+ let prev = "";
11
+ while (dir !== prev) {
12
+ if (existsSync(path.join(dir, ".git"))) {
13
+ return dir;
14
+ }
15
+ prev = dir;
16
+ dir = path.dirname(dir);
17
+ }
18
+ return undefined;
19
+ }
@@ -0,0 +1,63 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const pexec = promisify(execFile);
4
+ const MAX_BUFFER = 16 * 1024 * 1024; // 16 MB
5
+ async function git(cwd, args) {
6
+ try {
7
+ const { stdout } = await pexec("git", args, { cwd, maxBuffer: MAX_BUFFER });
8
+ return stdout;
9
+ }
10
+ catch (err) {
11
+ const message = err instanceof Error ? err.message : String(err);
12
+ return `(git ${args.join(" ")} failed: ${message})`;
13
+ }
14
+ }
15
+ /** Unstaged changes. The single source of truth for "what the executor did". */
16
+ export function gitDiff(cwd) {
17
+ return git(cwd, ["diff"]);
18
+ }
19
+ /** Staged changes. */
20
+ export function gitDiffStaged(cwd) {
21
+ return git(cwd, ["diff", "--staged"]);
22
+ }
23
+ /** Porcelain status — which files changed, added, deleted. */
24
+ export function gitStatus(cwd) {
25
+ return git(cwd, ["status", "--short", "--branch"]);
26
+ }
27
+ /**
28
+ * Tracked files as an array, respecting .gitignore. Capped at `limit` entries.
29
+ * Used both for the planning repo map and for server-side relevance selection.
30
+ */
31
+ export async function gitTrackedFileList(cwd, limit) {
32
+ const out = await git(cwd, ["ls-files"]);
33
+ const lines = out.split("\n").filter(Boolean);
34
+ return lines.slice(0, limit);
35
+ }
36
+ /**
37
+ * Tracked files, respecting .gitignore. Used to give the expert an accurate
38
+ * map of the repo when planning, without reading every byte.
39
+ */
40
+ export async function gitTrackedFiles(cwd, limit) {
41
+ const out = await git(cwd, ["ls-files"]);
42
+ const lines = out.split("\n").filter(Boolean);
43
+ if (lines.length <= limit)
44
+ return lines.join("\n");
45
+ return [...lines.slice(0, limit), `... (${lines.length - limit} more files omitted)`].join("\n");
46
+ }
47
+ /**
48
+ * Files whose tracked content matches any of `terms` (case-insensitive, literal).
49
+ * Returns relative paths. Empty/failed search yields an empty list — callers fall
50
+ * back to path-based relevance so planning still works without grep results.
51
+ */
52
+ export async function gitGrepFiles(cwd, terms) {
53
+ if (terms.length === 0)
54
+ return [];
55
+ const args = ["grep", "-l", "-i", "-I", "-F"];
56
+ for (const term of terms) {
57
+ args.push("-e", term);
58
+ }
59
+ const out = await git(cwd, args);
60
+ if (out.startsWith("(git "))
61
+ return []; // git() error sentinel (e.g. no matches → exit 1)
62
+ return out.split("\n").filter(Boolean);
63
+ }
@@ -0,0 +1,118 @@
1
+ import { gitGrepFiles } from "./git.js";
2
+ /**
3
+ * Server-side relevance selection.
4
+ *
5
+ * The expert plans over the REAL repo (PLAN.md goal). But the executor often
6
+ * cannot name the right `focus_paths` at planning time — that is precisely why
7
+ * it is asking for a plan. So the server picks likely-relevant files itself,
8
+ * from disk + git, and feeds their real content to the expert. The executor's
9
+ * `notes` only steer this ranking; they never replace ground truth.
10
+ */
11
+ const STOPWORDS = new Set([
12
+ // English
13
+ "the", "and", "for", "with", "that", "this", "from", "into", "your", "you", "are",
14
+ "but", "not", "all", "any", "can", "use", "using", "add", "fix", "make", "should",
15
+ "when", "what", "which", "how", "why", "need", "want", "please", "code", "file",
16
+ "files", "function", "change", "changes", "update", "implement", "create",
17
+ // Spanish
18
+ "que", "los", "las", "una", "uno", "del", "con", "por", "para", "como", "esta",
19
+ "este", "esto", "pero", "más", "mas", "hacer", "hace", "tiene", "sea", "ser",
20
+ "agregar", "cambiar", "arreglar", "necesito", "quiero", "archivo", "archivos",
21
+ "función", "funcion", "código", "codigo",
22
+ ]);
23
+ /** Tokenize free text into significant lowercase terms for matching. */
24
+ export function extractTerms(text, max = 12) {
25
+ const seen = new Set();
26
+ const terms = [];
27
+ const tokens = text
28
+ .toLowerCase()
29
+ .split(/[^a-z0-9_]+/i)
30
+ .filter(Boolean);
31
+ for (const token of tokens) {
32
+ if (token.length < 3)
33
+ continue;
34
+ if (STOPWORDS.has(token))
35
+ continue;
36
+ if (seen.has(token))
37
+ continue;
38
+ seen.add(token);
39
+ terms.push(token);
40
+ if (terms.length >= max)
41
+ break;
42
+ }
43
+ return terms;
44
+ }
45
+ /** Lockfiles and similar generated files that never help the expert plan. */
46
+ const NOISE_BASENAMES = new Set([
47
+ "package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml",
48
+ "poetry.lock", "Cargo.lock", "composer.lock", "Gemfile.lock", "go.sum",
49
+ ]);
50
+ /**
51
+ * Generated/non-source artifacts that add tokens without helping the expert
52
+ * plan: anything inside a dot-directory (.agent-results/, .claude/, .qoder/,
53
+ * .github/, ...), lockfiles, sourcemaps and minified bundles. Only applied to
54
+ * the server's own auto-selection — files the executor names in focus_paths are
55
+ * always respected.
56
+ */
57
+ function isNoisePath(file) {
58
+ const norm = file.replace(/\\/g, "/");
59
+ if (/(^|\/)\.[^/]+\//.test(norm))
60
+ return true;
61
+ const base = norm.slice(norm.lastIndexOf("/") + 1);
62
+ if (NOISE_BASENAMES.has(base))
63
+ return true;
64
+ if (/\.(min\.js|map)$/.test(base))
65
+ return true;
66
+ return false;
67
+ }
68
+ /**
69
+ * Rank tracked files by relevance to `terms` and return up to `limit` paths,
70
+ * excluding anything in `exclude`. Combines a content match (git grep) with a
71
+ * path/name match, so a file is surfaced whether the terms appear in its code
72
+ * or in its path. Falls back to path scoring alone if grep yields nothing.
73
+ */
74
+ export async function selectRelevantFiles(repoRoot, tracked, terms, exclude, limit) {
75
+ if (limit <= 0 || terms.length === 0 || tracked.length === 0)
76
+ return [];
77
+ const contentHits = new Set(await gitGrepFiles(repoRoot, terms));
78
+ const scored = [];
79
+ for (const file of tracked) {
80
+ if (exclude.has(file))
81
+ continue;
82
+ if (isNoisePath(file))
83
+ continue;
84
+ const lower = file.toLowerCase();
85
+ let score = 0;
86
+ if (contentHits.has(file))
87
+ score += 3;
88
+ for (const term of terms) {
89
+ if (lower.includes(term))
90
+ score += 2;
91
+ }
92
+ if (score === 0)
93
+ continue;
94
+ scored.push({ path: file, score });
95
+ }
96
+ scored.sort((a, b) => b.score - a.score || a.path.length - b.path.length);
97
+ return scored.slice(0, limit).map((entry) => entry.path);
98
+ }
99
+ /** Declaration-shaped lines across common languages (JS/TS, Python, Go, etc.). */
100
+ const DECLARATION_RE = /^\s*(export\s+)?(default\s+)?(async\s+)?(function|class|interface|type|enum|const|let|var|def|func|public|private|protected|static|module|namespace|struct|impl|fn)\b/;
101
+ /**
102
+ * Cheap structural skim of a file: keeps only declaration-shaped lines with their
103
+ * line numbers, so the expert sees a file's shape (functions, classes, exports)
104
+ * without paying for the full body. Used for likely-relevant files that are not
105
+ * in the top tier that gets full content.
106
+ */
107
+ export function outlineFile(content, maxLines = 60) {
108
+ const lines = content.split("\n");
109
+ const picked = [];
110
+ for (let i = 0; i < lines.length; i++) {
111
+ if (DECLARATION_RE.test(lines[i])) {
112
+ picked.push(`${i + 1}: ${lines[i].trim().slice(0, 200)}`);
113
+ if (picked.length >= maxLines)
114
+ break;
115
+ }
116
+ }
117
+ return picked.length > 0 ? picked.join("\n") : "(no declarations detected)";
118
+ }
@@ -0,0 +1,21 @@
1
+ import { exec } from "node:child_process";
2
+ const MAX_BUFFER = 16 * 1024 * 1024; // 16 MB
3
+ /**
4
+ * Runs the configured validation command (typecheck/lint/test/build) so the
5
+ * reviewer judges real output, not the executor's claim that "tests pass".
6
+ *
7
+ * Opt-in: only runs when OVERSEER_TEST_COMMAND is configured.
8
+ */
9
+ export function runTests(cwd, command, timeoutMs) {
10
+ return new Promise((resolve) => {
11
+ exec(command, { cwd, timeout: timeoutMs, maxBuffer: MAX_BUFFER }, (err, stdout, stderr) => {
12
+ const output = `${stdout}\n${stderr}`.trim();
13
+ const exitCode = err && typeof err.code === "number"
14
+ ? err.code
15
+ : err
16
+ ? 1
17
+ : 0;
18
+ resolve({ command, exitCode, output });
19
+ });
20
+ });
21
+ }
@@ -0,0 +1,44 @@
1
+ import { z } from "zod";
2
+ import { withRepoRoot } from "../config.js";
3
+ import { buildConsultExpertUserPayload, CONSULT_EXPERT_SYSTEM_PROMPT } from "../prompts.js";
4
+ import { gitDiff } from "../repo/git.js";
5
+ import { CONSULT_MAX_TOKENS, errorResult, readCappedFiles, TEMPERATURE, textResult } from "./shared.js";
6
+ export const consultExpertInputSchema = {
7
+ question: z.string().min(1).describe("What the executor needs decided. State the problem and, whenever there is more than one reasonable approach, the candidate options you are weighing."),
8
+ file_summaries: z.string().optional().describe("Executor-provided summaries of relevant file content. When provided, the server skips reading files from disk and forwards these summaries to the expert."),
9
+ diff_summary: z.string().optional().describe("Executor-provided summary of current changes. When provided, the server skips running git diff."),
10
+ file_paths: z.array(z.string()).optional().describe("Relevant files; the server reads them from disk. Ignored when file_summaries is provided."),
11
+ repo_root: z.string().optional().describe("Absolute path to the project root. Overrides the server default."),
12
+ error_output: z.string().optional().describe("Literal error or test output, if any."),
13
+ options_considered: z.array(z.string()).optional().describe("The candidate solutions/approaches you are weighing. Bring 2+ whenever the problem has more than one reasonable path, so the expert picks one decisively instead of inventing an approach from scratch."),
14
+ };
15
+ export async function handleConsultExpert(input, config) {
16
+ try {
17
+ const effective = input.repo_root ? withRepoRoot(config, input.repo_root) : config;
18
+ const snapshots = input.file_summaries
19
+ ? []
20
+ : await readCappedFiles(effective, input.file_paths);
21
+ const diff = input.diff_summary
22
+ ? ""
23
+ : await gitDiff(effective.repoRoot);
24
+ const user = buildConsultExpertUserPayload({
25
+ question: input.question,
26
+ snapshots,
27
+ gitDiff: diff,
28
+ errorOutput: input.error_output,
29
+ optionsConsidered: input.options_considered,
30
+ fileSummaries: input.file_summaries,
31
+ diffSummary: input.diff_summary,
32
+ });
33
+ const text = await effective.provider.complete({
34
+ system: CONSULT_EXPERT_SYSTEM_PROMPT,
35
+ user,
36
+ maxTokens: CONSULT_MAX_TOKENS,
37
+ temperature: TEMPERATURE,
38
+ });
39
+ return textResult(text);
40
+ }
41
+ catch (err) {
42
+ return errorResult("consult_expert", err);
43
+ }
44
+ }