s-app-scoder-agent 0.8.21 → 0.9.3

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,201 @@
1
+ /**
2
+ * v2 (3.2): AGENTS.md hierarchy — project/global conventions injected into
3
+ * the system prompt instead of being rediscovered every run.
4
+ *
5
+ * Sources (in order of increasing specificity):
6
+ * 1. ~/.scoder/AGENTS.md — user-global conventions
7
+ * 2. <workdir>/AGENTS.md — repo root conventions
8
+ * 3. <dir>/AGENTS.md — nearest file walking up from each focus
9
+ * file mentioned in the task (e.g. src/tui/app.tsx → src/tui/, src/, root)
10
+ *
11
+ * Budgets: global + root ≤ 1200 chars each, nested ≤ 800 chars each,
12
+ * total block ≤ 4000 chars. Content is wrapped in a clearly-delimited block
13
+ * and marked "(project conventions — apply unless the task says otherwise)".
14
+ */
15
+ import { existsSync, readFileSync, statSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { basename, dirname, join, normalize, relative, resolve, sep } from "node:path";
18
+ const GLOBAL_BUDGET = 1200;
19
+ const ROOT_BUDGET = 1200;
20
+ const NESTED_BUDGET = 800;
21
+ const TOTAL_BUDGET = 4000;
22
+ function readBudget(file, budget) {
23
+ try {
24
+ const st = statSync(file);
25
+ if (!st.isFile())
26
+ return null;
27
+ const raw = readFileSync(file, "utf8").trim();
28
+ if (!raw)
29
+ return null;
30
+ return raw.length <= budget ? raw : `${raw.slice(0, budget)}\n… [truncated — ${raw.length - budget} more chars]`;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ const ROOT_NAMES = ["AGENTS.md", "AGENTS.local.md"];
37
+ function rootGuide(workdir) {
38
+ const out = [];
39
+ const globalFile = join(homedir(), ".scoder", "AGENTS.md");
40
+ const global = readBudget(globalFile, GLOBAL_BUDGET);
41
+ if (global)
42
+ out.push({ source: globalFile, content: global });
43
+ for (const name of ROOT_NAMES) {
44
+ const f = join(workdir, name);
45
+ const content = readBudget(f, ROOT_BUDGET);
46
+ if (content) {
47
+ out.push({ source: f, content });
48
+ break;
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+ /** Nearest AGENTS.md at or above `focusFile`, stopping at workdir root. */
54
+ function nearestGuide(workdir, focusFile) {
55
+ let dir = dirname(resolve(workdir, focusFile));
56
+ const rootAbs = resolve(workdir);
57
+ for (;;) {
58
+ const root = normalize(rootAbs);
59
+ const current = normalize(dir);
60
+ if (current !== root && !current.startsWith(`${root}${sep}`))
61
+ return null;
62
+ for (const name of ROOT_NAMES) {
63
+ const f = join(dir, name);
64
+ const content = readBudget(f, NESTED_BUDGET);
65
+ if (content)
66
+ return { source: f, content };
67
+ }
68
+ if (normalize(dir) === normalize(rootAbs))
69
+ return null;
70
+ const parent = dirname(dir);
71
+ if (parent === dir)
72
+ return null;
73
+ dir = parent;
74
+ }
75
+ }
76
+ /** Extract path-like tokens from a task description (relative file mentions). */
77
+ export function extractFocusFiles(task, workdir) {
78
+ const FILE_EXTS = new Set([
79
+ "ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "go", "rs", "java", "rb", "php", "cs",
80
+ "json", "yaml", "yml", "toml", "md", "sql", "sh", "css", "scss", "vue", "svelte", "html",
81
+ ]);
82
+ const out = new Set();
83
+ const rx = /(?:\.\.?\/|[\w-]+\/)*[\w-]+\.[A-Za-z][\w]{0,9}/g;
84
+ for (const m of task.matchAll(rx)) {
85
+ const p = m[0];
86
+ const ext = p.slice(p.lastIndexOf(".") + 1).toLowerCase();
87
+ if (!FILE_EXTS.has(ext))
88
+ continue;
89
+ if (/^https?:/i.test(p))
90
+ continue;
91
+ const abs = resolve(workdir, p);
92
+ const rel = relative(workdir, abs);
93
+ if (rel.startsWith("..") || /^[a-zA-Z]:/.test(rel))
94
+ continue;
95
+ if (existsSync(abs))
96
+ out.add(rel.split(sep).join("/"));
97
+ if (out.size >= 5)
98
+ break;
99
+ }
100
+ return [...out];
101
+ }
102
+ /**
103
+ * Collect the AGENTS.md chain for a task. `focusFiles` are relative paths
104
+ * (typically extracted via extractFocusFiles). Order: global → root → nearest
105
+ * nested (deduped by source). Total size capped at TOTAL_BUDGET.
106
+ */
107
+ export function collectAgentGuides(workdir, focusFiles = []) {
108
+ const guides = [];
109
+ const seen = new Set();
110
+ const push = (g) => {
111
+ if (seen.has(g.source))
112
+ return false;
113
+ seen.add(g.source);
114
+ guides.push(g);
115
+ return true;
116
+ };
117
+ for (const g of rootGuide(workdir))
118
+ push(g);
119
+ for (const f of focusFiles) {
120
+ const g = nearestGuide(workdir, f);
121
+ if (g)
122
+ push(g);
123
+ }
124
+ // Total budget cap — drop the oldest (least specific) first.
125
+ let total = guides.reduce((n, g) => n + g.content.length, 0);
126
+ while (guides.length > 1 && total > TOTAL_BUDGET) {
127
+ const dropped = guides.shift();
128
+ if (!dropped)
129
+ break;
130
+ total -= dropped.content.length;
131
+ }
132
+ return guides;
133
+ }
134
+ /**
135
+ * Template written by /init — AGENTS.md starter. Detects project identity and
136
+ * build/test commands from common manifest files; falls back to placeholders.
137
+ */
138
+ export function agentsMdTemplate(workdir) {
139
+ let name = basename(workdir);
140
+ let build = "npm run build";
141
+ let test = "npm test";
142
+ try {
143
+ const pkg = JSON.parse(readFileSync(join(workdir, "package.json"), "utf8"));
144
+ if (pkg.name)
145
+ name = pkg.name;
146
+ if (pkg.scripts?.build)
147
+ build = "npm run build";
148
+ if (pkg.scripts?.test)
149
+ test = "npm test";
150
+ }
151
+ catch {
152
+ // not a node project — keep generic placeholders
153
+ if (existsSync(join(workdir, "go.mod"))) {
154
+ build = "go build ./...";
155
+ test = "go test ./...";
156
+ }
157
+ else if (existsSync(join(workdir, "Cargo.toml"))) {
158
+ build = "cargo build";
159
+ test = "cargo test";
160
+ }
161
+ else if (existsSync(join(workdir, "pyproject.toml"))) {
162
+ build = "python -m compileall .";
163
+ test = "pytest";
164
+ }
165
+ else if (existsSync(join(workdir, "Makefile"))) {
166
+ build = "make";
167
+ test = "make test";
168
+ }
169
+ }
170
+ return `# ${name} — AGENTS.md
171
+
172
+ Conventions for AI coding agents working in this repository. Keep this file
173
+ accurate: update it when the build, tests, or architecture change.
174
+
175
+ ## Project overview
176
+ <!-- One paragraph: what this project is, its main stack, and its layout. -->
177
+
178
+ ## Commands
179
+ - Build: \`${build}\`
180
+ - Test: \`${test}\`
181
+ - Lint / typecheck: <!-- e.g. npm run lint && npm run typecheck -->
182
+
183
+ ## Architecture map
184
+ <!-- Where the important things live: entry points, modules, data flow. -->
185
+
186
+ ## Conventions
187
+ <!-- Code style, error-handling patterns, naming, test style, commit style. -->
188
+
189
+ ## Guardrails
190
+ <!-- What agents must never do here (generated files, migrations, secrets…). -->
191
+ `;
192
+ }
193
+ /** Format the AGENTS.md block for the system prompt ("" when nothing found). */
194
+ export function buildAgentGuideBlock(workdir, task) {
195
+ const guides = collectAgentGuides(workdir, extractFocusFiles(task, workdir));
196
+ if (guides.length === 0)
197
+ return "";
198
+ const parts = guides.map((g) => `--- ${relative(workdir, g.source).split(sep).join("/") || g.source} ---\n${g.content}`);
199
+ return (`\n\nAGENTS.md (project conventions — apply unless the task explicitly says otherwise):\n` +
200
+ parts.join("\n\n"));
201
+ }
package/dist/agents.js CHANGED
@@ -1,14 +1,29 @@
1
1
  /**
2
- * Agents (I-06) — .scoder/agents/<name>.md definitions + built-ins.
2
+ * Agents (I-06 + v2 profiles) — .scoder/agents/<name>.md definitions + built-ins.
3
3
  *
4
4
  * Frontmatter semantics from opencode /v2/docs/agents (concept only —
5
5
  * prompt bodies are scoder's own). An agent def is: description, mode
6
- * (primary|subagent|all), model override, steps, prompt body.
6
+ * (primary|subagent|all), model override, steps, prompt body — plus v2:
7
+ * tool whitelist, permission mode, temperature, reasoning, maxTokens,
8
+ * skills pre-staging, color.
7
9
  */
8
10
  import { existsSync, readFileSync, readdirSync } from "node:fs";
9
11
  import { homedir } from "node:os";
10
12
  import { join } from "node:path";
11
13
  import { parseFrontmatter } from "./skills.js";
14
+ /** Read-only tools an agent may use — mutating specs are filtered out. */
15
+ export const READ_ONLY_TOOL_SPECS = [
16
+ "read_file",
17
+ "list_files",
18
+ "grep",
19
+ "glob",
20
+ "repo_map",
21
+ "web_fetch",
22
+ "load_skill",
23
+ "write_todos",
24
+ ];
25
+ /** Specs a readonly primary/subagent never sees (defense in depth vs permission alone). */
26
+ export const MUTATING_TOOL_SPECS = ["write_file", "edit_file", "apply_patch", "run_command"];
12
27
  /** Built-in agents — lowest priority; .md files override by name. */
13
28
  export const BUILTIN_AGENTS = [
14
29
  {
@@ -22,14 +37,41 @@ export const BUILTIN_AGENTS = [
22
37
  description: "Read-only planner — investigates and proposes an implementation plan, never edits code.",
23
38
  mode: "primary",
24
39
  prompt: "You are a planning agent. Investigate the codebase (read files, search, list) and produce a concrete, step-by-step implementation plan. You MUST NOT modify any files or run mutating commands — report the plan and stop. Structure: goal, findings (files/lines), plan steps ordered, risks, and the exact first step to take.",
40
+ permission: "readonly",
41
+ tools: READ_ONLY_TOOL_SPECS,
42
+ reasoning: "on",
25
43
  },
26
44
  {
27
45
  name: "explore",
28
46
  description: "Read-only subagent — searches and summarizes the codebase, returns findings.",
29
47
  mode: "subagent",
30
48
  prompt: "You are an exploration subagent. Answer the given question about this codebase using read-only tools (list_files, read_file, run_command with read-only commands). Be terse and factual; cite file paths. Do not modify anything.",
49
+ permission: "readonly",
50
+ tools: READ_ONLY_TOOL_SPECS,
51
+ },
52
+ {
53
+ name: "review",
54
+ description: "Code reviewer — reviews diffs for correctness, security and style. Never edits.",
55
+ mode: "all",
56
+ prompt: "You are a code review agent. Review the requested diff or files for correctness bugs, security issues, and violations of the project's conventions. Use read-only tools only. Report findings as a ranked list: [blocking] / [important] / [nit] with file:line citations and a concrete fix for each. End with an overall verdict: APPROVE / REQUEST CHANGES / NEEDS DISCUSSION. Do NOT modify any files.",
57
+ permission: "readonly",
58
+ tools: READ_ONLY_TOOL_SPECS,
31
59
  },
32
60
  ];
61
+ const parsePermissions = (v) => v === "auto" || v === "ask" || v === "readonly" ? v : undefined;
62
+ const parseReasoning = (v) => v === "off" || v === "on" ? v : undefined;
63
+ const parseList = (v) => {
64
+ if (!v)
65
+ return undefined;
66
+ const out = (Array.isArray(v) ? v : v.split(",")).map((s) => String(s).trim()).filter(Boolean);
67
+ return out.length > 0 ? out : undefined;
68
+ };
69
+ const parseNumber = (v) => {
70
+ if (v === undefined)
71
+ return undefined;
72
+ const n = Number(v);
73
+ return Number.isFinite(n) ? n : undefined;
74
+ };
33
75
  /** Scan .scoder/agents/*.md (project) + ~/.scoder/agents/*.md (global). */
34
76
  export function discoverAgents(workdir) {
35
77
  const out = new Map();
@@ -51,10 +93,18 @@ export function discoverAgents(workdir) {
51
93
  description: meta.description ?? "",
52
94
  mode: mode,
53
95
  model: meta.model || undefined,
54
- steps: meta.steps ? Number(meta.steps) || undefined : undefined,
96
+ steps: parseNumber(meta.steps),
55
97
  prompt: body,
56
98
  hidden: meta.hidden === "true",
57
99
  disabled: meta.disabled === "true",
100
+ tools: parseList(meta.tools),
101
+ permission: parsePermissions(meta.permission),
102
+ temperature: parseNumber(meta.temperature),
103
+ reasoning: parseReasoning(meta.reasoning),
104
+ // frontmatter keys are lowercased by parseFrontmatter
105
+ maxTokens: parseNumber(meta.maxtokens),
106
+ skills: parseList(meta.skills),
107
+ color: meta.color || undefined,
58
108
  });
59
109
  }
60
110
  catch {
@@ -68,3 +118,26 @@ export function discoverAgents(workdir) {
68
118
  export function findAgent(workdir, name) {
69
119
  return discoverAgents(workdir).find((a) => a.name === name) ?? null;
70
120
  }
121
+ /** Primary agents user-selectable via --agent / /agent (mode primary|all). */
122
+ export function selectableAgents(workdir) {
123
+ return discoverAgents(workdir).filter((a) => a.mode !== "subagent" && !a.hidden);
124
+ }
125
+ /**
126
+ * v2: filter a tool list for an agent def.
127
+ * - def.tools set → whitelist (unknown names dropped, reported by caller);
128
+ * - def.permission "readonly" → drop mutating specs (defense in depth);
129
+ * - otherwise the list is unchanged.
130
+ */
131
+ export function filterToolNamesForDef(names, def) {
132
+ if (!def)
133
+ return names;
134
+ if (def.tools && def.tools.length > 0) {
135
+ const allow = new Set(def.tools.map((t) => t.toLowerCase()));
136
+ return names.filter((n) => allow.has(n.toLowerCase()));
137
+ }
138
+ if (def.permission === "readonly") {
139
+ const blocked = new Set(MUTATING_TOOL_SPECS);
140
+ return names.filter((n) => !blocked.has(n.toLowerCase()));
141
+ }
142
+ return names;
143
+ }
@@ -79,11 +79,16 @@ export function extractSummaryInput(task, messages) {
79
79
  lines.push(`Files modified: ${[...files].join(", ")}`);
80
80
  return lines.join("\n");
81
81
  }
82
- /** Message sent to the summarizer LLM. */
82
+ /** Message sent to the summarizer LLM (v2: structured template, not prose). */
83
83
  export const COMPACTION_INSTRUCTION = "Summarize the conversation above for a coding agent that will CONTINUE this task. " +
84
- "Include: (1) the original task, (2) what has been done so far and why, " +
85
- "(3) files created/modified, (4) current build/test state, (5) exact next steps. " +
86
- "Be factual and terse. Max 300 words.";
84
+ "Use a STRUCTURED template with exactly these sections (skip a section only if empty):\n" +
85
+ "GOAL: one line — the original task.\n" +
86
+ "STATE: what is done and what is not (build/test status).\n" +
87
+ "CHANGED: files created/modified and why.\n" +
88
+ "DECISIONS: decisions made and their rationale (keep every decision).\n" +
89
+ "FAILURES: errors hit and what did NOT work (so they are not repeated).\n" +
90
+ "NEXT: the exact next steps, in order.\n" +
91
+ "Be factual and terse — max 400 words. Preserve file paths, commands and error codes verbatim.";
87
92
  /** Build the post-compaction message list. */
88
93
  export function applyCompaction(messages, summary, cfg) {
89
94
  const cut = findCutIndex(messages, cfg);
@@ -104,6 +109,9 @@ export function applyCompaction(messages, summary, cfg) {
104
109
  arguments: tc.arguments.length > 120 ? tc.arguments.slice(0, 120) + "…" : tc.arguments,
105
110
  })),
106
111
  });
112
+ for (const tc of m.tool_calls) {
113
+ skeletons.push({ role: "tool", tool_call_id: tc.id, content: "[compacted — tool result omitted]" });
114
+ }
107
115
  }
108
116
  }
109
117
  const summaryMsg = {
package/dist/config.js CHANGED
@@ -53,6 +53,9 @@ function mergeRc(global, project) {
53
53
  if (global.mcpServers || project.mcpServers) {
54
54
  merged.mcpServers = { ...global.mcpServers, ...project.mcpServers };
55
55
  }
56
+ if (global.routing || project.routing) {
57
+ merged.routing = { ...global.routing, ...project.routing };
58
+ }
56
59
  if (global.modelPricing || project.modelPricing) {
57
60
  merged.modelPricing = { ...global.modelPricing, ...project.modelPricing };
58
61
  }
@@ -66,6 +69,7 @@ function mergeRc(global, project) {
66
69
  mode: project.permissions?.mode ?? global.permissions?.mode,
67
70
  allowCommands: concat(global.permissions?.allowCommands, project.permissions?.allowCommands),
68
71
  denyCommands: concat(global.permissions?.denyCommands, project.permissions?.denyCommands),
72
+ rules: concat(global.permissions?.rules, project.permissions?.rules),
69
73
  };
70
74
  }
71
75
  return merged;
@@ -176,7 +180,7 @@ export function loadConfig(overrides = {}) {
176
180
  process.env.OPENAI_API_KEY ??
177
181
  rc.apiKey ??
178
182
  "",
179
- model: overrides.model ?? process.env.SCODER_MODEL ?? process.env.OPENAI_MODEL ?? rc.model ?? (modelFromSlash || (isAnthropic ? ANTHROPIC_DEFAULT_MODEL : DEFAULT_MODEL)),
183
+ model: modelFromSlash || (overrides.model ?? process.env.SCODER_MODEL ?? process.env.OPENAI_MODEL ?? rc.model ?? (isAnthropic ? ANTHROPIC_DEFAULT_MODEL : DEFAULT_MODEL)),
180
184
  providerDef,
181
185
  contextWindow: providerDef?.contextWindow,
182
186
  workdir,
@@ -208,6 +212,12 @@ export function loadConfig(overrides = {}) {
208
212
  sandbox: rc.sandbox,
209
213
  limits: parseLimits(rc.limits),
210
214
  snapshots: rc.snapshots,
215
+ agent: overrides.agent ?? process.env.SCODER_AGENT ?? rc.agent,
216
+ routing: rc.routing,
217
+ repoMap: rc.repoMap,
218
+ promptVersion: rc.promptVersion,
219
+ verifyCmds: rc.verifyCmds,
220
+ selfReview: rc.selfReview,
211
221
  modelPricing: parsePricing(rc.modelPricing),
212
222
  };
213
223
  // Secrets hygiene: warn if apiKey is set in the rc file (risk of accidental commit)
@@ -266,7 +276,11 @@ export function loadSavedApprovals(workdir) {
266
276
  if (!existsSync(p))
267
277
  return [];
268
278
  try {
269
- return parsePermissionRules(JSON.parse(readFileSync(p, "utf8")), p) ?? [];
279
+ const raw = JSON.parse(readFileSync(p, "utf8"));
280
+ const rules = raw && typeof raw === "object" && !Array.isArray(raw) && "rules" in raw
281
+ ? raw.rules
282
+ : raw;
283
+ return parsePermissionRules(rules, p) ?? [];
270
284
  }
271
285
  catch {
272
286
  return [];
package/dist/context.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { encode } from "gpt-tokenizer";
2
- import { readFileSync, readdirSync, statSync } from "node:fs";
3
- import { resolve } from "node:path";
2
+ import { readFileSync, readdirSync, statSync, realpathSync } from "node:fs";
3
+ import { resolve, relative, isAbsolute } from "node:path";
4
4
  const MAX_MESSAGES = 120; // keep history under control for long runs
5
5
  /**
6
6
  * Default history token budget when compaction is disabled (I-10:
@@ -137,7 +137,19 @@ export function expandMentions(task, workdir) {
137
137
  if (idx >= 0)
138
138
  out = (out.slice(0, idx) + " " + out.slice(idx + token.length)).replace(/ {2,}/g, " ");
139
139
  const abs = resolve(workdir, path);
140
- if (!abs.startsWith(resolve(workdir))) {
140
+ const root = resolve(workdir);
141
+ const lexical = relative(root, abs);
142
+ let contained = !lexical.startsWith("..") && !isAbsolute(lexical);
143
+ try {
144
+ const realRoot = realpathSync(root);
145
+ const realAbs = realpathSync(abs);
146
+ const realRel = relative(realRoot, realAbs);
147
+ contained = contained && !realRel.startsWith("..") && !isAbsolute(realRel);
148
+ }
149
+ catch {
150
+ // Missing paths are retained as a diagnostic; existing paths are checked above.
151
+ }
152
+ if (!contained) {
141
153
  blocks.push(`@${path}: path escapes working directory — skipped.`);
142
154
  continue;
143
155
  }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * v2 (3.5): Diagnostics — turn raw build/test/lint logs into structured
3
+ * {file, line, severity, code, message} records, plus an error classifier
4
+ * that maps a failure to a recovery strategy for the agent.
5
+ *
6
+ * Supported: tsc (both formats), eslint stylish/compact, pytest (tracebacks +
7
+ * FAILED lines), go test, cargo, make. Unknown formats fall back to a generic
8
+ * "error-ish line with a file(:line)? prefix" pattern. Output is deduplicated
9
+ * and capped (30 diagnostics) so the agent gets a fixable list, not a log.
10
+ */
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ const MAX_DIAGNOSTICS = 30;
14
+ function add(out, seen, d) {
15
+ const key = `${d.file}:${d.line}:${d.code}:${d.message.slice(0, 80)}`;
16
+ if (seen.has(key))
17
+ return;
18
+ seen.add(key);
19
+ if (out.length < MAX_DIAGNOSTICS)
20
+ out.push(d);
21
+ }
22
+ /** Parse a command output into structured diagnostics (best-effort). */
23
+ export function parseDiagnostics(output) {
24
+ const out = [];
25
+ const seen = new Set();
26
+ let currentFile = null;
27
+ let pendingCargo = null;
28
+ for (const rawLine of output.split("\n")) {
29
+ const line = rawLine.trimEnd();
30
+ let m;
31
+ // ── cargo (real output): error line, then " --> file:line" on the NEXT line.
32
+ if (pendingCargo) {
33
+ m = /^\s*-->\s+([^\s:]+):(\d+)(?::\d+)?$/.exec(line);
34
+ if (m) {
35
+ add(out, seen, { file: m[1], line: Number(m[2]), severity: "error", code: pendingCargo.code, message: pendingCargo.message, source: "cargo" });
36
+ pendingCargo = null;
37
+ continue;
38
+ }
39
+ pendingCargo = null; // the error had no location line — drop it
40
+ }
41
+ // ── tsc (modern): src/foo.ts(12,5): error TS2322: message
42
+ m = /^([^\s()][^()]*)\((\d+),(\d+)\):\s*(error|warning)\s+(TS\d+):\s*(.+)$/.exec(line);
43
+ if (m) {
44
+ add(out, seen, { file: m[1], line: Number(m[2]), severity: m[4] === "warning" ? "warning" : "error", code: m[5], message: m[6].trim(), source: "tsc" });
45
+ continue;
46
+ }
47
+ // ── tsc (new style): src/foo.ts:12:5 - error TS2322: message
48
+ m = /^([^\s:][^:]*):(\d+):(\d+)\s+-\s+(error|warning)\s+(TS\d+):\s*(.+)$/.exec(line);
49
+ if (m) {
50
+ add(out, seen, { file: m[1], line: Number(m[2]), severity: m[4] === "warning" ? "warning" : "error", code: m[5], message: m[6].trim(), source: "tsc" });
51
+ continue;
52
+ }
53
+ // ── cargo: error[E0308]: msg --> src/main.rs:10:5
54
+ m = /^error(?:\[(E\d+)\])?:\s*(.+)\s+-->\s+([^\s:]+):(\d+)(?::\d+)?$/.exec(line);
55
+ if (m) {
56
+ add(out, seen, { file: m[3], line: Number(m[4]), severity: "error", code: m[1] ?? null, message: m[2].trim(), source: "cargo" });
57
+ continue;
58
+ }
59
+ // ── cargo (2-line): "error[E0308]: msg" standing alone → expect --> next line.
60
+ m = /^error(?:\[(E\d+)\])?:\s*(.+)$/.exec(line);
61
+ if (m) {
62
+ pendingCargo = { code: m[1] ?? null, message: m[2].trim() };
63
+ continue;
64
+ }
65
+ // ── go vet/test: file.go:123: message (also covers "# package" headers)
66
+ m = /^([^\s:][^:()]*(?:\.go|\.py|\.rs|\.ts|\.tsx|\.js|\.mjs)):(\d+):\s+(.+)$/.exec(line);
67
+ if (m && /error|fail|cannot|undefined|invalid|unexpected|panic/i.test(m[3])) {
68
+ add(out, seen, { file: m[1], line: Number(m[2]), severity: "error", code: null, message: m[3].trim(), source: "generic" });
69
+ continue;
70
+ }
71
+ // ── eslint stylish: "✖ N problems" ignored; file header then indented rows
72
+ m = /^\s*(\d+):(\d+)\s+(error|warning)\s+(.+?)\s{2,}([\w-]+)$/.exec(line);
73
+ if (m && currentFile) {
74
+ add(out, seen, { file: currentFile, line: Number(m[1]), severity: m[3], code: m[5], message: m[4].trim(), source: "eslint" });
75
+ continue;
76
+ }
77
+ // eslint file header: /abs/path/file.ts (a bare path on its own line)
78
+ if (/^([\w./-]+\.(?:ts|tsx|js|jsx|mjs|cjs|vue|svelte))\s*$/.test(line.trim())) {
79
+ currentFile = line.trim();
80
+ continue;
81
+ }
82
+ // ── pytest: FAILED test_x.py::test_name - assert ...
83
+ m = /^FAILED\s+([^\s:]+)(?:::[^\s]+)?(?:\s+-\s+(.+))?$/.exec(line);
84
+ if (m) {
85
+ add(out, seen, { file: m[1], line: null, severity: "error", code: "FAILED", message: (m[2] ?? "test failed").trim(), source: "pytest" });
86
+ continue;
87
+ }
88
+ // pytest traceback frame: E AssertionError: ...
89
+ m = /^E\s+(.+)$/.exec(line);
90
+ if (m) {
91
+ add(out, seen, { file: "(pytest)", line: null, severity: "error", code: null, message: m[1].trim().slice(0, 200), source: "pytest" });
92
+ continue;
93
+ }
94
+ // ── go test: --- FAIL: TestName
95
+ m = /^--- FAIL:\s+([\w/]+)/.exec(line);
96
+ if (m) {
97
+ add(out, seen, { file: "(go test)", line: null, severity: "error", code: "FAIL", message: `test failed: ${m[1]}`, source: "go" });
98
+ continue;
99
+ }
100
+ // ── make: make: *** [target] Error 2
101
+ m = /^make(?:\[\d+\])?: \*\*\* \[([^\]]+)\] Error (\d+)/.exec(line);
102
+ if (m) {
103
+ add(out, seen, { file: "Makefile", line: null, severity: "error", code: `make:${m[2]}`, message: `target failed: ${m[1]}`, source: "make" });
104
+ continue;
105
+ }
106
+ }
107
+ return out;
108
+ }
109
+ /** Compact one-line-per-diagnostic rendering (prompt + UI friendly). */
110
+ export function formatDiagnostics(diags) {
111
+ if (diags.length === 0)
112
+ return "";
113
+ const lines = diags.map((d) => `- ${d.file}${d.line != null ? `:${d.line}` : ""} [${d.severity}${d.code ? ` ${d.code}` : ""}] ${d.message}`);
114
+ return `Diagnostics (${diags.length}):\n${lines.join("\n")}`;
115
+ }
116
+ /** Classify an error into a recovery strategy for the agent. */
117
+ export function classifyError(text) {
118
+ const t = text.toLowerCase();
119
+ const has = (...pats) => pats.some((p) => t.includes(p));
120
+ if (has("permission denied", "eacces", "denied by policy", "access is denied", "unauthorized", "403")) {
121
+ return { kind: "permission", strategy: "do NOT retry the same command — use a safer alternative or report the limitation to the user." };
122
+ }
123
+ if (has("enotfound", "econnrefused", "etimedout", "network", "fetch failed", "eai_again", "econnreset", "401", "rate limit", "429")) {
124
+ return { kind: "network", strategy: "check connectivity and credentials first (web_fetch/env), wait or pick another source; do not loop blindly." };
125
+ }
126
+ if (has("syntaxerror", "unexpected token", "unexpected end of", "parse error", "unterminated")) {
127
+ return { kind: "syntax", strategy: "read the file around the indicated line and fix the syntax error directly." };
128
+ }
129
+ if (/ts\d{3,5}|type .{0,40}is not assignable|type mismatch|expected type|no compatible/.test(t)) {
130
+ return { kind: "type", strategy: "read the failing type declaration and the usage site — fix the type, don't cast blindly." };
131
+ }
132
+ if (has("assertionerror", "assert failed", "expected", "to equal", "failed assert", "--- fail")) {
133
+ return { kind: "test", strategy: "read the failing test, understand what it expects, then fix the implementation (or the test only if it is provably wrong)." };
134
+ }
135
+ if (has("typeerror", "referenceerror", "rangeerror", "panic", "nil pointer", "null pointer", "segmentation", "enoent")) {
136
+ return { kind: "runtime", strategy: "read the stack trace, find the failing frame, and fix the root cause." };
137
+ }
138
+ return { kind: "unknown", strategy: "read the full error carefully; try a different approach or ask the user." };
139
+ }
140
+ /**
141
+ * Detect verify commands for the workdir from manifests. rc override via
142
+ * `verifyCmds` wins (empty string disables one).
143
+ */
144
+ export function detectVerifyCmds(workdir) {
145
+ const out = {};
146
+ try {
147
+ const pkgPath = join(workdir, "package.json");
148
+ if (existsSync(pkgPath)) {
149
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
150
+ const s = pkg.scripts ?? {};
151
+ if (s.build)
152
+ out.build = "npm run build";
153
+ if (s.test)
154
+ out.test = "npm test";
155
+ if (s.lint)
156
+ out.lint = "npm run lint";
157
+ if (s.typecheck)
158
+ out.typecheck = "npm run typecheck";
159
+ else if (s["type-check"])
160
+ out.typecheck = "npm run type-check";
161
+ }
162
+ }
163
+ catch { /* malformed package.json — fall through */ }
164
+ try {
165
+ if (existsSync(join(workdir, "pyproject.toml")))
166
+ out.test = out.test ?? "pytest";
167
+ if (existsSync(join(workdir, "Cargo.toml"))) {
168
+ out.build = out.build ?? "cargo build";
169
+ out.test = out.test ?? "cargo test";
170
+ }
171
+ if (existsSync(join(workdir, "go.mod"))) {
172
+ out.build = out.build ?? "go build ./...";
173
+ out.test = out.test ?? "go test ./...";
174
+ }
175
+ const makefile = join(workdir, "Makefile");
176
+ if (existsSync(makefile)) {
177
+ const mk = readFileSync(makefile, "utf8");
178
+ if (/^build:/m.test(mk))
179
+ out.build = out.build ?? "make";
180
+ if (/^test:/m.test(mk))
181
+ out.test = out.test ?? "make test";
182
+ if (/^lint:/m.test(mk))
183
+ out.lint = out.lint ?? "make lint";
184
+ }
185
+ }
186
+ catch { /* best-effort */ }
187
+ return out;
188
+ }