karajan-code 1.17.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "1.17.0",
3
+ "version": "1.19.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -2,6 +2,7 @@ import { ClaudeAgent } from "./claude-agent.js";
2
2
  import { CodexAgent } from "./codex-agent.js";
3
3
  import { GeminiAgent } from "./gemini-agent.js";
4
4
  import { AiderAgent } from "./aider-agent.js";
5
+ import { OpenCodeAgent } from "./opencode-agent.js";
5
6
 
6
7
  const agentRegistry = new Map();
7
8
 
@@ -38,3 +39,4 @@ registerAgent("claude", ClaudeAgent, { bin: "claude", installUrl: "https://docs.
38
39
  registerAgent("codex", CodexAgent, { bin: "codex", installUrl: "https://developers.openai.com/codex/cli" });
39
40
  registerAgent("gemini", GeminiAgent, { bin: "gemini", installUrl: "https://github.com/google-gemini/gemini-cli" });
40
41
  registerAgent("aider", AiderAgent, { bin: "aider", installUrl: "https://aider.chat/docs/install.html" });
42
+ registerAgent("opencode", OpenCodeAgent, { bin: "opencode", installUrl: "https://opencode.ai" });
@@ -60,3 +60,4 @@ registerModel("gemini", { provider: "google", pricing: { input_per_million: 1.25
60
60
  registerModel("gemini/pro", { provider: "google", pricing: { input_per_million: 1.25, output_per_million: 5 } });
61
61
  registerModel("gemini/flash", { provider: "google", pricing: { input_per_million: 0.075, output_per_million: 0.3 } });
62
62
  registerModel("aider", { provider: "aider", pricing: { input_per_million: 3, output_per_million: 15 } });
63
+ registerModel("opencode", { provider: "opencode", pricing: { input_per_million: 0, output_per_million: 0 } });
@@ -0,0 +1,33 @@
1
+ import { BaseAgent } from "./base-agent.js";
2
+ import { runCommand } from "../utils/process.js";
3
+ import { resolveBin } from "./resolve-bin.js";
4
+
5
+ export class OpenCodeAgent extends BaseAgent {
6
+ async runTask(task) {
7
+ const role = task.role || "coder";
8
+ const args = ["run"];
9
+ const model = this.getRoleModel(role);
10
+ if (model) args.push("--model", model);
11
+ args.push(task.prompt);
12
+ const res = await runCommand(resolveBin("opencode"), args, {
13
+ onOutput: task.onOutput,
14
+ silenceTimeoutMs: task.silenceTimeoutMs,
15
+ timeout: task.timeoutMs
16
+ });
17
+ return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
18
+ }
19
+
20
+ async reviewTask(task) {
21
+ const role = task.role || "reviewer";
22
+ const args = ["run", "--format", "json"];
23
+ const model = this.getRoleModel(role);
24
+ if (model) args.push("--model", model);
25
+ args.push(task.prompt);
26
+ const res = await runCommand(resolveBin("opencode"), args, {
27
+ onOutput: task.onOutput,
28
+ silenceTimeoutMs: task.silenceTimeoutMs,
29
+ timeout: task.timeoutMs
30
+ });
31
+ return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
32
+ }
33
+ }
package/src/config.js CHANGED
@@ -140,6 +140,25 @@ const DEFAULTS = {
140
140
  max_backoff_ms: 30000,
141
141
  backoff_multiplier: 2,
142
142
  jitter_factor: 0.1
143
+ },
144
+ guards: {
145
+ output: {
146
+ enabled: true,
147
+ patterns: [],
148
+ protected_files: [],
149
+ on_violation: "block"
150
+ },
151
+ perf: {
152
+ enabled: true,
153
+ patterns: [],
154
+ block_on_warning: false,
155
+ frontend_extensions: []
156
+ },
157
+ intent: {
158
+ enabled: false,
159
+ patterns: [],
160
+ confidence_threshold: 0.85
161
+ }
143
162
  }
144
163
  };
145
164
 
@@ -0,0 +1,123 @@
1
+ import { VALID_TASK_TYPES } from "./policy-resolver.js";
2
+
3
+ /**
4
+ * Built-in intent patterns for deterministic pre-triage classification.
5
+ * Each pattern maps keywords/regex to a taskType + complexity level.
6
+ * Evaluated top-down; first match with confidence >= threshold wins.
7
+ */
8
+ const INTENT_PATTERNS = [
9
+ // Documentation-only tasks
10
+ {
11
+ id: "doc-readme",
12
+ keywords: ["readme", "docs", "documentation", "jsdoc", "typedoc", "changelog"],
13
+ taskType: "doc",
14
+ level: "trivial",
15
+ confidence: 0.95,
16
+ message: "Documentation-only task detected",
17
+ },
18
+ // Test-only tasks
19
+ {
20
+ id: "add-tests",
21
+ keywords: ["add test", "write test", "missing test", "test coverage", "add spec", "write spec", "unit test", "integration test"],
22
+ taskType: "add-tests",
23
+ level: "simple",
24
+ confidence: 0.9,
25
+ message: "Test-addition task detected",
26
+ },
27
+ // Refactoring tasks
28
+ {
29
+ id: "refactor",
30
+ keywords: ["refactor", "rename", "extract method", "extract function", "clean up", "cleanup", "reorganize", "restructure", "simplify"],
31
+ taskType: "refactor",
32
+ level: "simple",
33
+ confidence: 0.85,
34
+ message: "Refactoring task detected",
35
+ },
36
+ // Infrastructure / DevOps tasks
37
+ {
38
+ id: "infra-devops",
39
+ keywords: ["ci/cd", "pipeline", "dockerfile", "docker-compose", "kubernetes", "k8s", "terraform", "deploy", "nginx", "github actions", "gitlab ci"],
40
+ taskType: "infra",
41
+ level: "simple",
42
+ confidence: 0.85,
43
+ message: "Infrastructure/DevOps task detected",
44
+ },
45
+ // Trivial fixes (typos, comments, formatting)
46
+ {
47
+ id: "trivial-fix",
48
+ keywords: ["typo", "fix typo", "spelling", "comment", "fix comment", "formatting", "lint", "fix lint", "whitespace"],
49
+ taskType: "sw",
50
+ level: "trivial",
51
+ confidence: 0.9,
52
+ message: "Trivial fix detected",
53
+ },
54
+ ];
55
+
56
+ /**
57
+ * Compile custom intent patterns from config.guards.intent.patterns
58
+ * Custom patterns are evaluated BEFORE built-in ones.
59
+ */
60
+ export function compileIntentPatterns(configGuards) {
61
+ const custom = Array.isArray(configGuards?.intent?.patterns)
62
+ ? configGuards.intent.patterns.map(p => ({
63
+ id: p.id || "custom-intent",
64
+ keywords: Array.isArray(p.keywords) ? p.keywords : [],
65
+ taskType: VALID_TASK_TYPES.has(p.taskType) ? p.taskType : "sw",
66
+ level: p.level || "simple",
67
+ confidence: typeof p.confidence === "number" ? p.confidence : 0.85,
68
+ message: p.message || "Custom intent pattern matched",
69
+ }))
70
+ : [];
71
+
72
+ return [...custom, ...INTENT_PATTERNS];
73
+ }
74
+
75
+ /**
76
+ * Check if a task description matches any of the keywords.
77
+ * Returns true if at least one keyword (case-insensitive substring) appears in the task.
78
+ */
79
+ function matchesKeywords(task, keywords) {
80
+ const lower = task.toLowerCase();
81
+ for (const kw of keywords) {
82
+ if (lower.includes(kw.toLowerCase())) {
83
+ return true;
84
+ }
85
+ }
86
+ return false;
87
+ }
88
+
89
+ /**
90
+ * Classify a task description using deterministic keyword patterns.
91
+ *
92
+ * Returns:
93
+ * { classified: true, taskType, level, confidence, patternId, message }
94
+ * or { classified: false } if no pattern matches above threshold
95
+ */
96
+ export function classifyIntent(task, config = {}) {
97
+ if (!task || typeof task !== "string") {
98
+ return { classified: false };
99
+ }
100
+
101
+ const configGuards = config?.guards || {};
102
+ const threshold = configGuards?.intent?.confidence_threshold ?? 0.85;
103
+ const patterns = compileIntentPatterns(configGuards);
104
+
105
+ for (const pattern of patterns) {
106
+ if (!matchesKeywords(task, pattern.keywords)) continue;
107
+
108
+ if (pattern.confidence >= threshold) {
109
+ return {
110
+ classified: true,
111
+ taskType: pattern.taskType,
112
+ level: pattern.level,
113
+ confidence: pattern.confidence,
114
+ patternId: pattern.id,
115
+ message: pattern.message,
116
+ };
117
+ }
118
+ }
119
+
120
+ return { classified: false };
121
+ }
122
+
123
+ export { INTENT_PATTERNS };
@@ -0,0 +1,158 @@
1
+ import { runCommand } from "../utils/process.js";
2
+
3
+ // Built-in destructive patterns
4
+ const DESTRUCTIVE_PATTERNS = [
5
+ { id: "rm-rf", pattern: /rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+)*-[a-zA-Z]*r/, severity: "critical", message: "Recursive file deletion detected" },
6
+ { id: "drop-table", pattern: /DROP\s+(TABLE|DATABASE|SCHEMA)/i, severity: "critical", message: "SQL destructive operation detected" },
7
+ { id: "git-reset-hard", pattern: /git\s+reset\s+--hard/i, severity: "critical", message: "Hard git reset detected" },
8
+ { id: "git-push-force", pattern: /git\s+push\s+.*--force/i, severity: "critical", message: "Force push detected" },
9
+ { id: "truncate-table", pattern: /TRUNCATE\s+TABLE/i, severity: "critical", message: "SQL truncate detected" },
10
+ { id: "format-disk", pattern: /mkfs\.|fdisk|dd\s+if=/, severity: "critical", message: "Disk format operation detected" },
11
+ ];
12
+
13
+ // Built-in credential patterns
14
+ const CREDENTIAL_PATTERNS = [
15
+ { id: "aws-key", pattern: /AKIA[0-9A-Z]{16}/, severity: "critical", message: "AWS access key exposed" },
16
+ { id: "private-key", pattern: /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/, severity: "critical", message: "Private key exposed" },
17
+ { id: "generic-secret", pattern: /(password|secret|token|api_key|apikey)\s*[:=]\s*["'][^"']{8,}["']/i, severity: "warning", message: "Potential secret/credential exposed" },
18
+ { id: "github-token", pattern: /gh[pousr]_[A-Za-z0-9_]{36,}/, severity: "critical", message: "GitHub token exposed" },
19
+ { id: "npm-token", pattern: /npm_[A-Za-z0-9]{36,}/, severity: "critical", message: "npm token exposed" },
20
+ ];
21
+
22
+ // Default protected files (block if these appear in added/modified lines)
23
+ const DEFAULT_PROTECTED_FILES = [
24
+ ".env",
25
+ ".env.local",
26
+ ".env.production",
27
+ "serviceAccountKey.json",
28
+ "credentials.json",
29
+ ];
30
+
31
+ export function compilePatterns(configGuards) {
32
+ const customPatterns = Array.isArray(configGuards?.output?.patterns)
33
+ ? configGuards.output.patterns.map(p => ({
34
+ id: p.id || "custom",
35
+ pattern: typeof p.pattern === "string" ? new RegExp(p.pattern, p.flags || "") : p.pattern,
36
+ severity: p.severity || "warning",
37
+ message: p.message || "Custom pattern matched",
38
+ }))
39
+ : [];
40
+
41
+ return [...DESTRUCTIVE_PATTERNS, ...CREDENTIAL_PATTERNS, ...customPatterns];
42
+ }
43
+
44
+ export function compileProtectedFiles(configGuards) {
45
+ const custom = Array.isArray(configGuards?.output?.protected_files)
46
+ ? configGuards.output.protected_files
47
+ : [];
48
+ return [...new Set([...DEFAULT_PROTECTED_FILES, ...custom])];
49
+ }
50
+
51
+ /**
52
+ * Parse a unified diff to extract only added lines (lines starting with +, not ++)
53
+ */
54
+ export function extractAddedLines(diff) {
55
+ if (!diff) return [];
56
+ const results = [];
57
+ let currentFile = null;
58
+ let lineNum = 0;
59
+
60
+ for (const line of diff.split("\n")) {
61
+ if (line.startsWith("+++ b/")) {
62
+ currentFile = line.slice(6);
63
+ continue;
64
+ }
65
+ if (line.startsWith("@@ ")) {
66
+ const match = /@@ -\d+(?:,\d+)? \+(\d+)/.exec(line);
67
+ lineNum = match ? Number.parseInt(match[1], 10) - 1 : 0;
68
+ continue;
69
+ }
70
+ if (line.startsWith("+") && !line.startsWith("+++")) {
71
+ lineNum += 1;
72
+ results.push({ file: currentFile, line: lineNum, content: line.slice(1) });
73
+ } else if (!line.startsWith("-")) {
74
+ lineNum += 1;
75
+ }
76
+ }
77
+ return results;
78
+ }
79
+
80
+ /**
81
+ * Check if any modified files are in the protected list
82
+ */
83
+ export function checkProtectedFiles(diff, protectedFiles) {
84
+ const violations = [];
85
+ const modifiedFiles = [];
86
+
87
+ for (const line of diff.split("\n")) {
88
+ if (line.startsWith("+++ b/")) {
89
+ modifiedFiles.push(line.slice(6));
90
+ }
91
+ }
92
+
93
+ for (const file of modifiedFiles) {
94
+ const basename = file.split("/").pop();
95
+ if (protectedFiles.some(pf => file === pf || file.endsWith(`/${pf}`) || basename === pf)) {
96
+ violations.push({
97
+ id: "protected-file",
98
+ severity: "critical",
99
+ file,
100
+ line: 0,
101
+ message: `Protected file modified: ${file}`,
102
+ matchedContent: "",
103
+ });
104
+ }
105
+ }
106
+
107
+ return violations;
108
+ }
109
+
110
+ /**
111
+ * Scan a diff for pattern violations.
112
+ * Returns { pass: boolean, violations: Array<{id, severity, file, line, message, matchedContent}> }
113
+ */
114
+ export function scanDiff(diff, config = {}) {
115
+ if (!diff || typeof diff !== "string") {
116
+ return { pass: true, violations: [] };
117
+ }
118
+
119
+ const configGuards = config?.guards || {};
120
+ const patterns = compilePatterns(configGuards);
121
+ const protectedFiles = compileProtectedFiles(configGuards);
122
+ const addedLines = extractAddedLines(diff);
123
+ const violations = [];
124
+
125
+ // Check patterns against added lines
126
+ for (const { file, line, content } of addedLines) {
127
+ for (const { id, pattern, severity, message } of patterns) {
128
+ if (pattern.test(content)) {
129
+ violations.push({ id, severity, file, line, message, matchedContent: content.trim().slice(0, 200) });
130
+ }
131
+ }
132
+ }
133
+
134
+ // Check protected files
135
+ violations.push(...checkProtectedFiles(diff, protectedFiles));
136
+
137
+ const hasCritical = violations.some(v => v.severity === "critical");
138
+ return { pass: !hasCritical, violations };
139
+ }
140
+
141
+ /**
142
+ * Run output guard on the current git diff.
143
+ * This is the main entry point for the pipeline integration.
144
+ */
145
+ export async function runOutputGuard(config = {}, baseBranch = "main") {
146
+ const diffResult = await runCommand("git", ["diff", `origin/${baseBranch}...HEAD`]);
147
+ if (diffResult.exitCode !== 0) {
148
+ // Fallback: diff against HEAD~1
149
+ const fallback = await runCommand("git", ["diff", "HEAD~1"]);
150
+ if (fallback.exitCode !== 0) {
151
+ return { pass: true, violations: [], error: "Could not generate diff" };
152
+ }
153
+ return scanDiff(fallback.stdout, config);
154
+ }
155
+ return scanDiff(diffResult.stdout, config);
156
+ }
157
+
158
+ export { DESTRUCTIVE_PATTERNS, CREDENTIAL_PATTERNS, DEFAULT_PROTECTED_FILES };
@@ -0,0 +1,126 @@
1
+ const FRONTEND_EXTENSIONS = new Set([".html", ".htm", ".css", ".jsx", ".tsx", ".astro", ".vue", ".svelte"]);
2
+
3
+ // Built-in perf anti-patterns (applied to added lines in frontend files)
4
+ const PERF_PATTERNS = [
5
+ { id: "img-no-dimensions", pattern: /<img\b(?![^>]*\bwidth\b)(?![^>]*\bheight\b)[^>]*>/i, severity: "warning", message: "Image without width/height attributes (causes CLS)" },
6
+ { id: "img-no-lazy", pattern: /<img\b(?![^>]*\bloading\s*=)(?![^>]*\bfetchpriority\s*=)[^>]*>/i, severity: "info", message: "Image without loading=\"lazy\" or fetchpriority (consider lazy loading)" },
7
+ { id: "script-no-defer", pattern: /<script\b(?![^>]*\b(?:defer|async)\b)(?![^>]*type\s*=\s*["']module["'])[^>]*src\s*=/i, severity: "warning", message: "External script without defer/async (render-blocking)" },
8
+ { id: "font-no-display", pattern: /@font-face\s*\{(?![^}]*font-display)/i, severity: "warning", message: "@font-face without font-display (causes FOIT)" },
9
+ { id: "css-import", pattern: /@import\s+(?:url\()?["'](?!.*\.module\.)/i, severity: "info", message: "CSS @import (causes sequential loading, prefer <link>)" },
10
+ { id: "inline-style-large", pattern: /style\s*=\s*["'][^"']{200,}["']/i, severity: "warning", message: "Large inline style (>200 chars, consider external CSS)" },
11
+ { id: "document-write", pattern: /document\.write\s*\(/, severity: "warning", message: "document.write() blocks parsing and degrades performance" },
12
+ ];
13
+
14
+ // Patterns for package.json changes (heavy dependencies added)
15
+ const HEAVY_DEPS = [
16
+ { id: "heavy-moment", pattern: /"moment"/, severity: "info", message: "moment.js added (consider dayjs or date-fns for smaller bundle)" },
17
+ { id: "heavy-lodash", pattern: /"lodash"(?!\/)/, severity: "info", message: "Full lodash added (consider lodash-es or individual imports)" },
18
+ { id: "heavy-jquery", pattern: /"jquery"/, severity: "info", message: "jQuery added (consider native DOM APIs)" },
19
+ ];
20
+
21
+ function getExtension(filePath) {
22
+ if (!filePath) return "";
23
+ const dot = filePath.lastIndexOf(".");
24
+ return dot >= 0 ? filePath.slice(dot).toLowerCase() : "";
25
+ }
26
+
27
+ /**
28
+ * Check if any modified file in the diff is a frontend file
29
+ */
30
+ export function hasFrontendFiles(diff) {
31
+ if (!diff) return false;
32
+ for (const line of diff.split("\n")) {
33
+ if (line.startsWith("+++ b/")) {
34
+ const ext = getExtension(line.slice(6));
35
+ if (FRONTEND_EXTENSIONS.has(ext)) return true;
36
+ }
37
+ }
38
+ return false;
39
+ }
40
+
41
+ /**
42
+ * Extract added lines grouped by file from a unified diff
43
+ */
44
+ function extractAddedLinesByFile(diff) {
45
+ const results = [];
46
+ let currentFile = null;
47
+ let lineNum = 0;
48
+
49
+ for (const line of diff.split("\n")) {
50
+ if (line.startsWith("+++ b/")) {
51
+ currentFile = line.slice(6);
52
+ continue;
53
+ }
54
+ if (line.startsWith("@@ ")) {
55
+ const match = /@@ -\d+(?:,\d+)? \+(\d+)/.exec(line);
56
+ lineNum = match ? Number.parseInt(match[1], 10) - 1 : 0;
57
+ continue;
58
+ }
59
+ if (line.startsWith("+") && !line.startsWith("+++")) {
60
+ lineNum += 1;
61
+ results.push({ file: currentFile, line: lineNum, content: line.slice(1) });
62
+ } else if (!line.startsWith("-")) {
63
+ lineNum += 1;
64
+ }
65
+ }
66
+ return results;
67
+ }
68
+
69
+ /**
70
+ * Scan diff for frontend performance anti-patterns.
71
+ * Returns { pass: boolean, violations: [...], skipped: boolean }
72
+ */
73
+ export function scanPerfDiff(diff, config = {}) {
74
+ if (!diff || typeof diff !== "string") {
75
+ return { pass: true, violations: [], skipped: true };
76
+ }
77
+
78
+ if (!hasFrontendFiles(diff)) {
79
+ return { pass: true, violations: [], skipped: true };
80
+ }
81
+
82
+ const customPatterns = Array.isArray(config?.guards?.perf?.patterns)
83
+ ? config.guards.perf.patterns.map(p => ({
84
+ id: p.id || "custom-perf",
85
+ pattern: typeof p.pattern === "string" ? new RegExp(p.pattern, p.flags || "i") : p.pattern,
86
+ severity: p.severity || "warning",
87
+ message: p.message || "Custom perf pattern matched",
88
+ }))
89
+ : [];
90
+
91
+ const allPatterns = [...PERF_PATTERNS, ...customPatterns];
92
+ const addedLines = extractAddedLinesByFile(diff);
93
+ const violations = [];
94
+
95
+ for (const { file, line, content } of addedLines) {
96
+ const ext = getExtension(file);
97
+ const isFrontend = FRONTEND_EXTENSIONS.has(ext);
98
+ const isPackageJson = file?.endsWith("package.json");
99
+
100
+ if (isFrontend) {
101
+ for (const { id, pattern, severity, message } of allPatterns) {
102
+ if (pattern.test(content)) {
103
+ violations.push({ id, severity, file, line, message, matchedContent: content.trim().slice(0, 200) });
104
+ }
105
+ }
106
+ }
107
+
108
+ if (isPackageJson) {
109
+ for (const { id, pattern, severity, message } of HEAVY_DEPS) {
110
+ if (pattern.test(content)) {
111
+ violations.push({ id, severity, file, line, message, matchedContent: content.trim().slice(0, 200) });
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ // perf-guard is advisory by default — only blocks on critical (none built-in are critical)
118
+ const blockOnWarning = Boolean(config?.guards?.perf?.block_on_warning);
119
+ const hasCritical = violations.some(v => v.severity === "critical");
120
+ const hasWarning = violations.some(v => v.severity === "warning");
121
+ const pass = !hasCritical && !(blockOnWarning && hasWarning);
122
+
123
+ return { pass, violations, skipped: false };
124
+ }
125
+
126
+ export { PERF_PATTERNS, HEAVY_DEPS, FRONTEND_EXTENSIONS };
@@ -23,6 +23,9 @@ import {
23
23
  } from "./git/automation.js";
24
24
  import { resolveRoleMdPath, loadFirstExisting } from "./roles/base-role.js";
25
25
  import { applyPolicies } from "./guards/policy-resolver.js";
26
+ import { scanDiff } from "./guards/output-guard.js";
27
+ import { scanPerfDiff } from "./guards/perf-guard.js";
28
+ import { classifyIntent } from "./guards/intent-guard.js";
26
29
  import { resolveReviewProfile } from "./review/profiles.js";
27
30
  import { CoderRole } from "./roles/coder-role.js";
28
31
  import { invokeSolomon } from "./orchestrator/solomon-escalation.js";
@@ -272,7 +275,7 @@ function applyFlagOverrides(pipelineFlags, flags) {
272
275
 
273
276
  function resolvePipelinePolicies({ flags, config, stageResults, emitter, eventBase, session, pipelineFlags }) {
274
277
  const resolvedPolicies = applyPolicies({
275
- taskType: flags.taskType || config.taskType || stageResults.triage?.taskType || null,
278
+ taskType: flags.taskType || config.taskType || stageResults.triage?.taskType || stageResults.intent?.taskType || null,
276
279
  policies: config.policies,
277
280
  });
278
281
  session.resolved_policies = resolvedPolicies;
@@ -764,6 +767,18 @@ async function handleReviewerRetryAndSolomon({ config, session, emitter, eventBa
764
767
 
765
768
 
766
769
  async function runPreLoopStages({ config, logger, emitter, eventBase, session, flags, pipelineFlags, coderRole, trackBudget, task, askQuestion, pgTaskId, pgProject, stageResults }) {
770
+ // --- Intent classifier (deterministic pre-triage, opt-in) ---
771
+ if (config.guards?.intent?.enabled) {
772
+ const intentResult = classifyIntent(task, config);
773
+ stageResults.intent = intentResult;
774
+ if (intentResult.classified) {
775
+ emitProgress(emitter, makeEvent("intent:classified", { ...eventBase, stage: "intent" }, {
776
+ message: `Intent classified: ${intentResult.taskType} (${intentResult.level}) — ${intentResult.message}`,
777
+ detail: intentResult
778
+ }));
779
+ }
780
+ }
781
+
767
782
  // --- Discover (pre-triage, opt-in) ---
768
783
  if (flags.enableDiscover !== undefined) pipelineFlags.discoverEnabled = Boolean(flags.enableDiscover);
769
784
  if (pipelineFlags.discoverEnabled) {
@@ -811,6 +826,72 @@ async function runCoderAndRefactorerStages({ coderRoleInstance, coderRole, refac
811
826
  return { action: "ok" };
812
827
  }
813
828
 
829
+ async function runGuardStages({ config, logger, emitter, eventBase, session, iteration }) {
830
+ const outputEnabled = config.guards?.output?.enabled !== false;
831
+ const perfEnabled = config.guards?.perf?.enabled !== false;
832
+
833
+ if (!outputEnabled && !perfEnabled) return { action: "ok" };
834
+
835
+ const baseBranch = config.base_branch || "main";
836
+ let diff;
837
+ try {
838
+ const { generateDiff: genDiff, computeBaseRef: compBase } = await import("./review/diff-generator.js");
839
+ const baseRef = await compBase({ baseBranch });
840
+ diff = await genDiff({ baseRef });
841
+ } catch {
842
+ logger.warn("Guards: could not generate diff, skipping");
843
+ return { action: "ok" };
844
+ }
845
+
846
+ if (!diff) return { action: "ok" };
847
+
848
+ if (outputEnabled) {
849
+ const outputResult = scanDiff(diff, config);
850
+ if (outputResult.violations.length > 0) {
851
+ const critical = outputResult.violations.filter(v => v.severity === "critical");
852
+ const warnings = outputResult.violations.filter(v => v.severity === "warning");
853
+ emitProgress(emitter, makeEvent("guard:output", { ...eventBase, stage: "guard" }, {
854
+ message: `Output guard: ${critical.length} critical, ${warnings.length} warnings`,
855
+ detail: { violations: outputResult.violations }
856
+ }));
857
+ logger.info(`Output guard: ${outputResult.violations.length} violation(s) found`);
858
+ for (const v of outputResult.violations) {
859
+ logger.info(` [${v.severity}] ${v.file}:${v.line} — ${v.message}`);
860
+ }
861
+ await addCheckpoint(session, { stage: "guard-output", iteration, pass: outputResult.pass, violations: outputResult.violations.length });
862
+
863
+ if (!outputResult.pass && config.guards.output.on_violation === "block") {
864
+ await markSessionStatus(session, "failed");
865
+ emitProgress(emitter, makeEvent("guard:blocked", { ...eventBase, stage: "guard" }, {
866
+ message: "Output guard blocked: critical violations detected",
867
+ detail: { violations: critical }
868
+ }));
869
+ return {
870
+ action: "return",
871
+ result: { approved: false, sessionId: session.id, reason: "guard_blocked", violations: critical }
872
+ };
873
+ }
874
+ }
875
+ }
876
+
877
+ if (perfEnabled) {
878
+ const perfResult = scanPerfDiff(diff, config);
879
+ if (!perfResult.skipped && perfResult.violations.length > 0) {
880
+ emitProgress(emitter, makeEvent("guard:perf", { ...eventBase, stage: "guard" }, {
881
+ message: `Perf guard: ${perfResult.violations.length} issue(s)`,
882
+ detail: { violations: perfResult.violations }
883
+ }));
884
+ logger.info(`Perf guard: ${perfResult.violations.length} issue(s) found`);
885
+ for (const v of perfResult.violations) {
886
+ logger.info(` [${v.severity}] ${v.file}:${v.line} — ${v.message}`);
887
+ }
888
+ await addCheckpoint(session, { stage: "guard-perf", iteration, pass: perfResult.pass, violations: perfResult.violations.length });
889
+ }
890
+ }
891
+
892
+ return { action: "ok" };
893
+ }
894
+
814
895
  async function runQualityGateStages({ config, logger, emitter, eventBase, session, trackBudget, i, askQuestion, repeatDetector, budgetSummary, sonarState, task, stageResults }) {
815
896
  const tddResult = await runTddCheckStage({ config, logger, emitter, eventBase, session, trackBudget, iteration: i, askQuestion });
816
897
  if (tddResult.action === "pause") return { action: "return", result: tddResult.result };
@@ -947,6 +1028,9 @@ async function runSingleIteration(ctx) {
947
1028
  const crResult = await runCoderAndRefactorerStages({ coderRoleInstance, coderRole, refactorerRole, pipelineFlags, config, logger, emitter, eventBase, session, plannedTask, trackBudget, i });
948
1029
  if (crResult.action === "return" || crResult.action === "retry") return crResult;
949
1030
 
1031
+ const guardResult = await runGuardStages({ config, logger, emitter, eventBase, session, iteration: i });
1032
+ if (guardResult.action === "return") return guardResult;
1033
+
950
1034
  const qgResult = await runQualityGateStages({ config, logger, emitter, eventBase, session, trackBudget, i, askQuestion, repeatDetector, budgetSummary, sonarState, task, stageResults });
951
1035
  if (qgResult.action === "return" || qgResult.action === "continue") return qgResult;
952
1036
 
@@ -5,7 +5,8 @@ const KNOWN_AGENTS = [
5
5
  { name: "claude", install: "npm install -g @anthropic-ai/claude-code" },
6
6
  { name: "codex", install: "npm install -g @openai/codex" },
7
7
  { name: "gemini", install: "npm install -g @anthropic-ai/gemini-code (or check Gemini CLI docs)" },
8
- { name: "aider", install: "pip install aider-chat" }
8
+ { name: "aider", install: "pip install aider-chat" },
9
+ { name: "opencode", install: "curl -fsSL https://opencode.ai/install | bash (or see https://opencode.ai)" }
9
10
  ];
10
11
 
11
12
  export async function checkBinary(name, versionArg = "--version") {
@@ -61,6 +61,39 @@ sonarqube:
61
61
  - "javascript:S1116"
62
62
  - "javascript:S3776"
63
63
 
64
+ # Deterministic Guards
65
+ guards:
66
+ output:
67
+ enabled: true
68
+ # Custom patterns (added to built-in destructive + credential patterns)
69
+ # patterns:
70
+ # - id: custom-pattern
71
+ # pattern: "DANGEROUS_FUNCTION"
72
+ # severity: critical
73
+ # message: "Custom dangerous pattern detected"
74
+ # Protected files (added to built-in: .env, serviceAccountKey.json, etc.)
75
+ # protected_files:
76
+ # - secrets.yml
77
+ # - .env.production
78
+ on_violation: block # block | warn | skip
79
+ perf:
80
+ enabled: true
81
+ block_on_warning: false # true to block on perf warnings (default: advisory only)
82
+ # Custom perf patterns (added to built-in frontend anti-patterns)
83
+ # patterns:
84
+ # - id: custom-perf
85
+ # pattern: "eval\\("
86
+ # severity: warning
87
+ # message: "eval() detected"
88
+ intent:
89
+ enabled: false # enable for deterministic pre-triage classification
90
+ confidence_threshold: 0.85
91
+ # Custom intent patterns (evaluated before built-in)
92
+ # patterns:
93
+ # - keywords: ["readme", "docs", "documentation"]
94
+ # taskType: doc
95
+ # confidence: 0.9
96
+
64
97
  # Git (post-approval)
65
98
  git:
66
99
  auto_commit: false