karajan-code 1.59.0 → 2.0.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/README.md +2 -0
- package/package.json +1 -1
- package/scripts/install.js +18 -15
- package/src/agents/aider-agent.js +0 -7
- package/src/agents/claude-agent.js +34 -6
- package/src/agents/codex-agent.js +2 -6
- package/src/agents/gemini-agent.js +0 -4
- package/src/agents/opencode-agent.js +0 -4
- package/src/{becaria → ci}/dispatch.js +13 -13
- package/src/{becaria → ci}/pr-diff.js +1 -1
- package/src/cli.js +3 -2
- package/src/commands/agents.js +0 -1
- package/src/commands/doctor.js +22 -40
- package/src/commands/init.js +8 -8
- package/src/commands/report.js +0 -13
- package/src/commands/review.js +17 -17
- package/src/commands/run.js +1 -2
- package/src/commands/status.js +1 -7
- package/src/config.js +10 -52
- package/src/domains/domain-loader.js +85 -4
- package/src/git/automation.js +9 -9
- package/src/guards/output-guard.js +0 -17
- package/src/mcp/handlers/direct-handlers.js +1 -1
- package/src/mcp/handlers/run-handler.js +1 -2
- package/src/mcp/sovereignty-guard.js +2 -2
- package/src/mcp/tools.js +3 -2
- package/src/orchestrator/brain-coordinator.js +165 -0
- package/src/orchestrator/ci-integration.js +139 -0
- package/src/orchestrator/config-init.js +435 -0
- package/src/orchestrator/direct-actions.js +186 -0
- package/src/orchestrator/feedback-enrichment.js +140 -0
- package/src/orchestrator/feedback-queue.js +152 -0
- package/src/orchestrator/flow-control.js +159 -0
- package/src/orchestrator/post-loop-stages.js +20 -15
- package/src/orchestrator/preflight-checks.js +7 -3
- package/src/orchestrator/role-output-compressor.js +190 -0
- package/src/orchestrator/session-journal.js +344 -0
- package/src/orchestrator/solomon-escalation.js +38 -5
- package/src/orchestrator/solomon-rules.js +32 -33
- package/src/orchestrator/stages/reviewer-stage.js +16 -5
- package/src/orchestrator/stages/sonar-stage.js +24 -1
- package/src/orchestrator/verification-gate.js +146 -0
- package/src/orchestrator.js +435 -661
- package/src/planning-game/client.js +2 -2
- package/src/planning-game/pipeline-adapter.js +1 -1
- package/src/prompts/coder.js +17 -3
- package/src/prompts/reviewer.js +2 -2
- package/src/prompts/rtk-snippet.js +3 -5
- package/src/review/diff-generator.js +66 -17
- package/src/review/snapshot-diff.js +122 -0
- package/src/review/tdd-policy.js +4 -2
- package/src/roles/agent-role.js +135 -0
- package/src/roles/architect-role.js +61 -108
- package/src/roles/audit-role.js +25 -71
- package/src/roles/coder-role.js +32 -57
- package/src/roles/discover-role.js +30 -71
- package/src/roles/hu-reviewer-role.js +51 -99
- package/src/roles/impeccable-role.js +34 -91
- package/src/roles/index.js +2 -0
- package/src/roles/karajan-brain-role.js +134 -0
- package/src/roles/planner-role.js +40 -75
- package/src/roles/refactorer-role.js +22 -64
- package/src/roles/researcher-role.js +52 -118
- package/src/roles/reviewer-role.js +69 -126
- package/src/roles/security-role.js +38 -102
- package/src/roles/solomon-role.js +71 -15
- package/src/roles/tester-role.js +121 -95
- package/src/roles/triage-role.js +50 -111
- package/src/session-store.js +1 -1
- package/src/skills/skill-detector.js +11 -0
- package/src/sonar/config-resolver.js +20 -1
- package/src/sonar/credentials.js +17 -0
- package/src/sonar/project-key.js +1 -1
- package/src/sonar/scanner.js +1 -1
- package/src/utils/banner.js +66 -0
- package/src/utils/display.js +35 -17
- package/src/utils/injection-guard.js +23 -8
- package/src/utils/logger.js +6 -1
- package/src/utils/process.js +3 -4
- package/src/utils/role-assigner.js +131 -0
- package/src/utils/update-check.js +5 -1
- package/src/utils/welcome.js +3 -8
- package/src/webperf/devtools-detect.js +70 -23
- package/templates/roles/coder.md +7 -0
- package/templates/roles/karajan-brain.md +95 -0
- package/templates/roles/solomon.md +67 -106
- package/templates/workflows/automerge.yml +4 -4
- package/templates/workflows/houston-override.yml +4 -4
- package/templates/workflows/{becaria-gateway.yml → kj-ci-gateway.yml} +12 -12
- package/src/proxy/adapters/anthropic.js +0 -131
- package/src/proxy/adapters/gemini.js +0 -106
- package/src/proxy/adapters/index.js +0 -51
- package/src/proxy/adapters/openai.js +0 -71
- package/src/proxy/compression/ai-compressor.js +0 -209
- package/src/proxy/compression/dedup-cache.js +0 -100
- package/src/proxy/compression/deterministic/bash-build.js +0 -60
- package/src/proxy/compression/deterministic/bash-git.js +0 -101
- package/src/proxy/compression/deterministic/bash-infra.js +0 -64
- package/src/proxy/compression/deterministic/bash-misc.js +0 -53
- package/src/proxy/compression/deterministic/bash-pkg.js +0 -49
- package/src/proxy/compression/deterministic/bash-test.js +0 -61
- package/src/proxy/compression/deterministic/glob.js +0 -26
- package/src/proxy/compression/deterministic/grep.js +0 -53
- package/src/proxy/compression/deterministic/index.js +0 -58
- package/src/proxy/compression/deterministic/read.js +0 -22
- package/src/proxy/compression/deterministic/utils.js +0 -33
- package/src/proxy/compression/pipeline.js +0 -270
- package/src/proxy/middleware/prompt-injector.js +0 -165
- package/src/proxy/middleware/response-interceptor.js +0 -223
- package/src/proxy/progress-monitor.js +0 -98
- package/src/proxy/proxy-lifecycle.js +0 -198
- package/src/proxy/proxy-server.js +0 -239
- package/src/proxy/security.js +0 -45
- /package/src/{becaria → ci}/index.js +0 -0
- /package/src/{becaria → ci}/repo.js +0 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Direct actions: commands Karajan Brain can execute without invoking a full role.
|
|
2
|
+
// Keeps the action catalog small, auditable, and safe.
|
|
3
|
+
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} DirectAction
|
|
10
|
+
* @property {string} type - action type identifier
|
|
11
|
+
* @property {Object} params - action-specific parameters
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} ActionResult
|
|
16
|
+
* @property {boolean} ok
|
|
17
|
+
* @property {string} [output]
|
|
18
|
+
* @property {string} [error]
|
|
19
|
+
* @property {string} action
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const ALLOWED_COMMANDS = [
|
|
23
|
+
"npm install", "npm ci", "pnpm install", "yarn install",
|
|
24
|
+
"pip install -r requirements.txt", "poetry install",
|
|
25
|
+
"go mod download", "cargo fetch",
|
|
26
|
+
"bundle install", "composer install", "dotnet restore"
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Validate that a shell command is in the allow-list.
|
|
31
|
+
* Prevents arbitrary command execution.
|
|
32
|
+
*/
|
|
33
|
+
function isCommandAllowed(cmd) {
|
|
34
|
+
if (!cmd || typeof cmd !== "string") return false;
|
|
35
|
+
return ALLOWED_COMMANDS.some(allowed => cmd.trim().startsWith(allowed));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Execute an allowed shell command (install deps, etc.).
|
|
40
|
+
*/
|
|
41
|
+
async function runCommand({ cmd, cwd }) {
|
|
42
|
+
if (!isCommandAllowed(cmd)) {
|
|
43
|
+
return { ok: false, error: `Command not in allow-list: ${cmd}`, action: "run_command" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const output = execSync(cmd, {
|
|
48
|
+
cwd: cwd || process.cwd(),
|
|
49
|
+
encoding: "utf8",
|
|
50
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
51
|
+
timeout: 120_000
|
|
52
|
+
});
|
|
53
|
+
return { ok: true, output: output.trim(), action: "run_command" };
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return { ok: false, error: err.message, action: "run_command" };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create a file with given content (only if it doesn't exist).
|
|
61
|
+
*/
|
|
62
|
+
async function createFile({ filePath, content, cwd, overwrite = false }) {
|
|
63
|
+
try {
|
|
64
|
+
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd || process.cwd(), filePath);
|
|
65
|
+
|
|
66
|
+
// Path traversal guard
|
|
67
|
+
const resolved = path.resolve(fullPath);
|
|
68
|
+
const base = path.resolve(cwd || process.cwd());
|
|
69
|
+
if (!resolved.startsWith(base)) {
|
|
70
|
+
return { ok: false, error: "Path traversal denied", action: "create_file" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
await fs.access(fullPath);
|
|
75
|
+
if (!overwrite) {
|
|
76
|
+
return { ok: false, error: `File already exists: ${filePath}`, action: "create_file" };
|
|
77
|
+
}
|
|
78
|
+
} catch { /* file doesn't exist, proceed */ }
|
|
79
|
+
|
|
80
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
81
|
+
await fs.writeFile(fullPath, content || "", "utf8");
|
|
82
|
+
return { ok: true, output: `Created ${filePath}`, action: "create_file" };
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return { ok: false, error: err.message, action: "create_file" };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Append entries to .gitignore (creates if missing, skips duplicates).
|
|
90
|
+
*/
|
|
91
|
+
async function updateGitignore({ entries, cwd }) {
|
|
92
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
93
|
+
return { ok: false, error: "No entries provided", action: "update_gitignore" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const gitignorePath = path.join(cwd || process.cwd(), ".gitignore");
|
|
98
|
+
let content = "";
|
|
99
|
+
try {
|
|
100
|
+
content = await fs.readFile(gitignorePath, "utf8");
|
|
101
|
+
} catch { /* will create */ }
|
|
102
|
+
|
|
103
|
+
const missing = entries.filter(e => !content.includes(e));
|
|
104
|
+
if (missing.length === 0) {
|
|
105
|
+
return { ok: true, output: "All entries already present", action: "update_gitignore" };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const append = (content && !content.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n";
|
|
109
|
+
await fs.appendFile(gitignorePath, append, "utf8");
|
|
110
|
+
return { ok: true, output: `Added: ${missing.join(", ")}`, action: "update_gitignore" };
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return { ok: false, error: err.message, action: "update_gitignore" };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Stage files with git add.
|
|
118
|
+
*/
|
|
119
|
+
async function gitAdd({ files, cwd }) {
|
|
120
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
121
|
+
return { ok: false, error: "No files to add", action: "git_add" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// Only allow relative paths (no absolute paths, no shell metacharacters)
|
|
126
|
+
for (const f of files) {
|
|
127
|
+
if (typeof f !== "string" || f.includes("..") || /[;&|`$]/.test(f)) {
|
|
128
|
+
return { ok: false, error: `Invalid file path: ${f}`, action: "git_add" };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const args = files.map(f => `"${f}"`).join(" ");
|
|
132
|
+
execSync(`git add ${args}`, {
|
|
133
|
+
cwd: cwd || process.cwd(),
|
|
134
|
+
encoding: "utf8",
|
|
135
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
136
|
+
});
|
|
137
|
+
return { ok: true, output: `Staged ${files.length} file(s)`, action: "git_add" };
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return { ok: false, error: err.message, action: "git_add" };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const ACTION_HANDLERS = {
|
|
144
|
+
run_command: runCommand,
|
|
145
|
+
create_file: createFile,
|
|
146
|
+
update_gitignore: updateGitignore,
|
|
147
|
+
git_add: gitAdd
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Execute a single direct action.
|
|
152
|
+
*/
|
|
153
|
+
export async function executeAction(action, { cwd } = {}) {
|
|
154
|
+
if (!action || typeof action !== "object") {
|
|
155
|
+
return { ok: false, error: "Invalid action", action: "unknown" };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const handler = ACTION_HANDLERS[action.type];
|
|
159
|
+
if (!handler) {
|
|
160
|
+
return { ok: false, error: `Unknown action type: ${action.type}`, action: action.type };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return handler({ ...action.params, cwd: cwd || action.params?.cwd });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Execute a list of actions in sequence. Stops on first failure unless continueOnError.
|
|
168
|
+
*/
|
|
169
|
+
export async function executeActions(actions, { cwd, continueOnError = false } = {}) {
|
|
170
|
+
const results = [];
|
|
171
|
+
for (const action of actions || []) {
|
|
172
|
+
const result = await executeAction(action, { cwd });
|
|
173
|
+
results.push(result);
|
|
174
|
+
if (!result.ok && !continueOnError) break;
|
|
175
|
+
}
|
|
176
|
+
return results;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get list of allowed action types.
|
|
181
|
+
*/
|
|
182
|
+
export function getAllowedActionTypes() {
|
|
183
|
+
return Object.keys(ACTION_HANDLERS);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export { ALLOWED_COMMANDS, isCommandAllowed };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Feedback enrichment: transform vague feedback into actionable instructions.
|
|
2
|
+
// Extracts file hints, line hints, and suggests fixes from feedback text.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extract file path hints from feedback description.
|
|
6
|
+
* Matches patterns like: packages/server/auth.js, src/routes/*, auth/middleware.ts
|
|
7
|
+
*/
|
|
8
|
+
export function extractFileHints(description) {
|
|
9
|
+
if (!description) return [];
|
|
10
|
+
const hints = new Set();
|
|
11
|
+
|
|
12
|
+
// Explicit paths with extensions (supports bare filenames too)
|
|
13
|
+
const pathRe = /\b([\w./_-]+\.(?:js|ts|jsx|tsx|py|go|rs|java|rb|php|cs|swift|dart|html|css|vue|svelte|astro|json|yml|yaml|md|sql))\b/gi;
|
|
14
|
+
const paths = description.match(pathRe) || [];
|
|
15
|
+
for (const p of paths) hints.add(p);
|
|
16
|
+
|
|
17
|
+
// Directory references
|
|
18
|
+
const dirRe = /\b(packages\/[\w-]+|src\/[\w/-]+|tests?\/[\w/-]+|lib\/[\w/-]+)/gi;
|
|
19
|
+
const dirs = description.match(dirRe) || [];
|
|
20
|
+
for (const d of dirs) hints.add(d);
|
|
21
|
+
|
|
22
|
+
return [...hints];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Detect the category of feedback from description keywords.
|
|
27
|
+
*/
|
|
28
|
+
export function detectCategory(description) {
|
|
29
|
+
const text = (description || "").toLowerCase();
|
|
30
|
+
if (/\b(injection|xss|csrf|httponly|sql\s*injection|exposed\s*secret|auth(?:entication)?\s*bypass|authoriza(?:tion)?\s*bypass|cryptograph|token\s*expos)/i.test(text)) return "security";
|
|
31
|
+
if (/\btest|coverage|assert|spec|mock\b/.test(text)) return "tests";
|
|
32
|
+
if (/\bperf(ormance)?|slow|optimize|bottleneck|latency\b/.test(text)) return "performance";
|
|
33
|
+
if (/\b(style|format(?:ting)?|naming|rename|indent|whitespace|comment|jsdoc|cosmetic)\b/.test(text)) return "style";
|
|
34
|
+
if (/\b(bug|incorrect|wrong|fails?|error|crash|broken)\b/.test(text)) return "correctness";
|
|
35
|
+
return "other";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Detect severity from description keywords and existing severity field.
|
|
40
|
+
*/
|
|
41
|
+
export function detectSeverity(description, existingSeverity) {
|
|
42
|
+
if (existingSeverity) return existingSeverity;
|
|
43
|
+
const text = (description || "").toLowerCase();
|
|
44
|
+
if (/\bcritical|blocker|severe|production\s*risk\b/.test(text)) return "critical";
|
|
45
|
+
if (/\b(high|security|vulnerab|breaking|regression|injection|xss|csrf)\b/.test(text)) return "high";
|
|
46
|
+
if (/\blow|minor|cosmetic|nitpick\b/.test(text)) return "low";
|
|
47
|
+
return "medium";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generate actionable instructions from a feedback entry.
|
|
52
|
+
* Adds concrete steps the coder can follow.
|
|
53
|
+
*/
|
|
54
|
+
export function generateActionPlan(entry) {
|
|
55
|
+
const steps = [];
|
|
56
|
+
const cat = entry.category || detectCategory(entry.description);
|
|
57
|
+
|
|
58
|
+
// Always start with locating the problem
|
|
59
|
+
if (entry.file) {
|
|
60
|
+
steps.push(`Open ${entry.file}${entry.line ? ` at line ${entry.line}` : ""}`);
|
|
61
|
+
} else {
|
|
62
|
+
const hints = extractFileHints(entry.description);
|
|
63
|
+
if (hints.length > 0) {
|
|
64
|
+
steps.push(`Look in: ${hints.join(", ")}`);
|
|
65
|
+
} else {
|
|
66
|
+
steps.push("Identify the relevant file(s) using grep/search");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Category-specific actions
|
|
71
|
+
if (cat === "security") {
|
|
72
|
+
steps.push("Apply the security fix (validate input, escape output, use parameterized queries, etc.)");
|
|
73
|
+
} else if (cat === "tests") {
|
|
74
|
+
steps.push("Write the missing test cases covering the scenario described");
|
|
75
|
+
steps.push("Run tests to verify they pass: npm test or equivalent");
|
|
76
|
+
} else if (cat === "correctness") {
|
|
77
|
+
steps.push("Fix the bug described");
|
|
78
|
+
steps.push("Add a regression test");
|
|
79
|
+
} else if (cat === "performance") {
|
|
80
|
+
steps.push("Optimize the identified bottleneck");
|
|
81
|
+
steps.push("Verify improvement with measurements");
|
|
82
|
+
} else if (cat === "style") {
|
|
83
|
+
steps.push("Apply the style fix (run linter if available)");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Include suggested fix if provided
|
|
87
|
+
if (entry.suggestedFix) {
|
|
88
|
+
steps.push(`Suggested approach: ${entry.suggestedFix}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return steps;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Enrich a single feedback entry with file hints, category, severity, and action plan.
|
|
96
|
+
*/
|
|
97
|
+
export function enrichEntry(entry) {
|
|
98
|
+
const description = entry.description || "";
|
|
99
|
+
// Re-detect category when missing or set to default "other"
|
|
100
|
+
const category = (!entry.category || entry.category === "other")
|
|
101
|
+
? detectCategory(description)
|
|
102
|
+
: entry.category;
|
|
103
|
+
const severity = detectSeverity(description, entry.severity);
|
|
104
|
+
const fileHints = entry.file ? [entry.file] : extractFileHints(description);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
...entry,
|
|
108
|
+
category,
|
|
109
|
+
severity,
|
|
110
|
+
fileHints,
|
|
111
|
+
actionPlan: generateActionPlan({ ...entry, category })
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Enrich all entries in a queue in-place.
|
|
117
|
+
*/
|
|
118
|
+
export function enrichQueue(queue) {
|
|
119
|
+
queue.entries = queue.entries.map(enrichEntry);
|
|
120
|
+
return queue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Format enriched entries as actionable coder prompt sections.
|
|
125
|
+
*/
|
|
126
|
+
export function formatEnrichedForCoder(enrichedEntries) {
|
|
127
|
+
if (!enrichedEntries?.length) return "";
|
|
128
|
+
|
|
129
|
+
const sections = [];
|
|
130
|
+
enrichedEntries.forEach((e, i) => {
|
|
131
|
+
const header = `### Issue ${i + 1}: [${e.severity}] ${e.category} — ${e.description}`;
|
|
132
|
+
const location = e.fileHints?.length
|
|
133
|
+
? `**Location hints:** ${e.fileHints.join(", ")}`
|
|
134
|
+
: "**Location:** search the project for relevant files";
|
|
135
|
+
const plan = `**Action plan:**\n${e.actionPlan.map(s => `- ${s}`).join("\n")}`;
|
|
136
|
+
sections.push([header, location, plan].join("\n"));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return sections.join("\n\n");
|
|
140
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Structured feedback queue for inter-role communication.
|
|
2
|
+
// Replaces the flat session.last_reviewer_feedback string with typed messages.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {Object} FeedbackEntry
|
|
6
|
+
* @property {string} source - "reviewer" | "tester" | "security" | "sonar" | "audit" | "brain"
|
|
7
|
+
* @property {string} severity - "critical" | "high" | "medium" | "low"
|
|
8
|
+
* @property {string} category - "security" | "correctness" | "tests" | "style" | "performance" | "other"
|
|
9
|
+
* @property {string} description
|
|
10
|
+
* @property {string} [file]
|
|
11
|
+
* @property {number} [line]
|
|
12
|
+
* @property {string} [suggestedFix]
|
|
13
|
+
* @property {string} [id]
|
|
14
|
+
* @property {number} [iteration] - when this feedback was added
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const SEVERITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
18
|
+
const CATEGORY_ORDER = { security: 0, correctness: 1, tests: 2, performance: 3, style: 4, other: 5 };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create a new empty feedback queue.
|
|
22
|
+
*/
|
|
23
|
+
export function createQueue() {
|
|
24
|
+
return { entries: [] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Add a feedback entry to the queue.
|
|
29
|
+
*/
|
|
30
|
+
export function addEntry(queue, entry) {
|
|
31
|
+
if (!entry || !entry.description) return queue;
|
|
32
|
+
const normalized = {
|
|
33
|
+
source: entry.source || "unknown",
|
|
34
|
+
severity: entry.severity || "medium",
|
|
35
|
+
category: entry.category || "other",
|
|
36
|
+
description: entry.description,
|
|
37
|
+
file: entry.file || null,
|
|
38
|
+
line: entry.line || null,
|
|
39
|
+
suggestedFix: entry.suggestedFix || null,
|
|
40
|
+
id: entry.id || null,
|
|
41
|
+
iteration: entry.iteration || 0
|
|
42
|
+
};
|
|
43
|
+
queue.entries.push(normalized);
|
|
44
|
+
return queue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Add multiple entries at once.
|
|
49
|
+
*/
|
|
50
|
+
export function addEntries(queue, entries) {
|
|
51
|
+
for (const e of entries || []) addEntry(queue, e);
|
|
52
|
+
return queue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Deduplicate entries with the same description + source (keep the most severe).
|
|
57
|
+
*/
|
|
58
|
+
export function deduplicate(queue) {
|
|
59
|
+
const seen = new Map();
|
|
60
|
+
for (const e of queue.entries) {
|
|
61
|
+
const key = `${e.source}::${e.description}`;
|
|
62
|
+
const existing = seen.get(key);
|
|
63
|
+
if (!existing || SEVERITY_ORDER[e.severity] < SEVERITY_ORDER[existing.severity]) {
|
|
64
|
+
seen.set(key, e);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
queue.entries = [...seen.values()];
|
|
68
|
+
return queue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sort entries by priority: category first (security > correctness > ...), then severity.
|
|
73
|
+
*/
|
|
74
|
+
export function prioritize(queue) {
|
|
75
|
+
queue.entries.sort((a, b) => {
|
|
76
|
+
const catDiff = (CATEGORY_ORDER[a.category] ?? 5) - (CATEGORY_ORDER[b.category] ?? 5);
|
|
77
|
+
if (catDiff !== 0) return catDiff;
|
|
78
|
+
return (SEVERITY_ORDER[a.severity] ?? 2) - (SEVERITY_ORDER[b.severity] ?? 2);
|
|
79
|
+
});
|
|
80
|
+
return queue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Format queue as a numbered list for the coder prompt.
|
|
85
|
+
*/
|
|
86
|
+
export function formatForCoder(queue) {
|
|
87
|
+
if (!queue.entries.length) return "";
|
|
88
|
+
|
|
89
|
+
const lines = [];
|
|
90
|
+
queue.entries.forEach((e, i) => {
|
|
91
|
+
const loc = e.file ? ` (${e.file}${e.line ? `:${e.line}` : ""})` : "";
|
|
92
|
+
const tag = `[${e.source}:${e.severity}:${e.category}]`;
|
|
93
|
+
lines.push(`${i + 1}. ${tag}${loc} ${e.description}`);
|
|
94
|
+
if (e.suggestedFix) lines.push(` Fix: ${e.suggestedFix}`);
|
|
95
|
+
});
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Filter entries by category.
|
|
101
|
+
*/
|
|
102
|
+
export function filterByCategory(queue, category) {
|
|
103
|
+
return { entries: queue.entries.filter(e => e.category === category) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Check if queue has any critical/high severity entries.
|
|
108
|
+
*/
|
|
109
|
+
export function hasBlockingIssues(queue) {
|
|
110
|
+
return queue.entries.some(e => e.severity === "critical" || e.severity === "high");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get count of entries by category.
|
|
115
|
+
*/
|
|
116
|
+
export function countByCategory(queue) {
|
|
117
|
+
const counts = {};
|
|
118
|
+
for (const e of queue.entries) {
|
|
119
|
+
counts[e.category] = (counts[e.category] || 0) + 1;
|
|
120
|
+
}
|
|
121
|
+
return counts;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Clear the queue (after coder addresses the feedback).
|
|
126
|
+
*/
|
|
127
|
+
export function clear(queue) {
|
|
128
|
+
queue.entries = [];
|
|
129
|
+
return queue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Serialize queue for persistence in session.
|
|
134
|
+
*/
|
|
135
|
+
export function serialize(queue) {
|
|
136
|
+
return JSON.stringify(queue);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Deserialize queue from session.
|
|
141
|
+
*/
|
|
142
|
+
export function deserialize(str) {
|
|
143
|
+
if (!str) return createQueue();
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(str);
|
|
146
|
+
return { entries: Array.isArray(parsed.entries) ? parsed.entries : [] };
|
|
147
|
+
} catch {
|
|
148
|
+
return createQueue();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export { SEVERITY_ORDER, CATEGORY_ORDER };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session flow control — checkpoints, timeouts, budget checks.
|
|
3
|
+
* Extracted from orchestrator.js — session management, not orchestration.
|
|
4
|
+
*/
|
|
5
|
+
import { markSessionStatus, addCheckpoint } from "../session-store.js";
|
|
6
|
+
import { emitProgress, makeEvent } from "../utils/events.js";
|
|
7
|
+
import { msg, getLang } from "../utils/messages.js";
|
|
8
|
+
|
|
9
|
+
function detectCheckpointProgress(session, lastCheckpointSnapshot) {
|
|
10
|
+
if (!lastCheckpointSnapshot) return true; // First checkpoint — assume progress
|
|
11
|
+
const currentIteration = session.reviewer_retry_count ?? 0;
|
|
12
|
+
const currentStages = Object.keys(session.resolved_policies || {}).length;
|
|
13
|
+
const currentCheckpoints = (session.checkpoints || []).length;
|
|
14
|
+
|
|
15
|
+
const iterationAdvanced = currentIteration !== lastCheckpointSnapshot.iteration;
|
|
16
|
+
const stagesChanged = currentStages !== lastCheckpointSnapshot.stagesCount;
|
|
17
|
+
const checkpointsChanged = currentCheckpoints !== lastCheckpointSnapshot.checkpointsCount;
|
|
18
|
+
|
|
19
|
+
return iterationAdvanced || stagesChanged || checkpointsChanged;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function takeCheckpointSnapshot(session) {
|
|
23
|
+
return {
|
|
24
|
+
iteration: session.reviewer_retry_count ?? 0,
|
|
25
|
+
stagesCount: Object.keys(session.resolved_policies || {}).length,
|
|
26
|
+
checkpointsCount: (session.checkpoints || []).length
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Determine if checkpoint should auto-continue without asking the user.
|
|
32
|
+
* Exported for testing.
|
|
33
|
+
*/
|
|
34
|
+
export function shouldAutoContinueCheckpoint(session, hasProgress) {
|
|
35
|
+
if (hasProgress) {
|
|
36
|
+
session._checkpoint_stall_count = 0;
|
|
37
|
+
return { autoContinue: true, reason: "progress_detected" };
|
|
38
|
+
}
|
|
39
|
+
const wasRateLimited = (session.standby_retry_count || 0) > 0;
|
|
40
|
+
const consecutiveStalls = (session._checkpoint_stall_count || 0) + 1;
|
|
41
|
+
session._checkpoint_stall_count = consecutiveStalls;
|
|
42
|
+
if (wasRateLimited && consecutiveStalls < 3) {
|
|
43
|
+
return { autoContinue: true, reason: "recoverable_stall" };
|
|
44
|
+
}
|
|
45
|
+
return { autoContinue: false, reason: consecutiveStalls >= 3 ? "max_stalls_reached" : "no_progress" };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function handleCheckpoint({ checkpointDisabled, askQuestion, lastCheckpointAt, checkpointIntervalMs, elapsedMinutes, i, config, budgetTracker, stageResults, emitter, eventBase, session, budgetSummary, lastCheckpointSnapshot }) {
|
|
49
|
+
if (checkpointDisabled || !askQuestion || (Date.now() - lastCheckpointAt) < checkpointIntervalMs) {
|
|
50
|
+
return { action: "continue_loop", checkpointDisabled, lastCheckpointAt, lastCheckpointSnapshot };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const elapsedStr = elapsedMinutes.toFixed(1);
|
|
54
|
+
const stagesCompleted = Object.keys(stageResults).join(", ") || "none";
|
|
55
|
+
|
|
56
|
+
const hasProgress = detectCheckpointProgress(session, lastCheckpointSnapshot);
|
|
57
|
+
const newSnapshot = takeCheckpointSnapshot(session);
|
|
58
|
+
const decision = shouldAutoContinueCheckpoint(session, hasProgress);
|
|
59
|
+
|
|
60
|
+
if (decision.autoContinue) {
|
|
61
|
+
emitProgress(
|
|
62
|
+
emitter,
|
|
63
|
+
makeEvent("session:checkpoint", { ...eventBase, iteration: i, stage: "checkpoint" }, {
|
|
64
|
+
message: `Checkpoint: ${decision.reason === "progress_detected" ? "progress detected" : "stall caused by rate limit/cooldown"}, auto-continuing (${elapsedStr} min elapsed)`,
|
|
65
|
+
detail: { elapsed_minutes: Number(elapsedStr), iterations_done: i - 1, stages: stagesCompleted, auto_continued: true, reason: decision.reason }
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
return { action: "continue_loop", checkpointDisabled, lastCheckpointAt: Date.now(), lastCheckpointSnapshot: newSnapshot };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const iterInfo = `${i - 1}/${config.max_iterations} iterations completed`;
|
|
72
|
+
const budgetInfo = budgetTracker.total().cost_usd > 0 ? ` | Budget: $${budgetTracker.total().cost_usd.toFixed(2)}` : "";
|
|
73
|
+
const checkpointMsg = `Checkpoint — ${elapsedStr} min elapsed | ${iterInfo}${budgetInfo} | Stages completed: ${stagesCompleted}. No progress since last checkpoint. What would you like to do?`;
|
|
74
|
+
|
|
75
|
+
emitProgress(
|
|
76
|
+
emitter,
|
|
77
|
+
makeEvent("session:checkpoint", { ...eventBase, iteration: i, stage: "checkpoint" }, {
|
|
78
|
+
message: `Interactive checkpoint at ${elapsedStr} min (stalled)`,
|
|
79
|
+
detail: { elapsed_minutes: Number(elapsedStr), iterations_done: i - 1, stages: stagesCompleted, auto_continued: false }
|
|
80
|
+
})
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const lang = getLang(config);
|
|
84
|
+
const answer = await askQuestion(
|
|
85
|
+
`${checkpointMsg}\n\n${msg("checkpoint_options", lang)}`
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
await addCheckpoint(session, { stage: "interactive-checkpoint", elapsed_minutes: Number(elapsedStr), answer });
|
|
89
|
+
|
|
90
|
+
const trimmedAnswer = (answer || "").trim();
|
|
91
|
+
const isExplicitStop = trimmedAnswer === "4" || trimmedAnswer.toLowerCase().startsWith("stop");
|
|
92
|
+
|
|
93
|
+
if (isExplicitStop) {
|
|
94
|
+
await markSessionStatus(session, "stopped");
|
|
95
|
+
emitProgress(
|
|
96
|
+
emitter,
|
|
97
|
+
makeEvent("session:end", { ...eventBase, iteration: i, stage: "user-stop" }, {
|
|
98
|
+
status: "stopped",
|
|
99
|
+
message: "Session stopped by user at checkpoint",
|
|
100
|
+
detail: { approved: false, reason: "user_stopped", elapsed_minutes: Number(elapsedStr), budget: budgetSummary() }
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
return { action: "stop", result: { approved: false, sessionId: session.id, reason: "user_stopped", elapsed_minutes: Number(elapsedStr) } };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const parsed = parseCheckpointAnswer({ trimmedAnswer, checkpointDisabled, config });
|
|
107
|
+
parsed.lastCheckpointSnapshot = newSnapshot;
|
|
108
|
+
return parsed;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function parseCheckpointAnswer({ trimmedAnswer, checkpointDisabled, config }) {
|
|
112
|
+
if (!trimmedAnswer) {
|
|
113
|
+
return { action: "continue_loop", checkpointDisabled, lastCheckpointAt: Date.now() };
|
|
114
|
+
}
|
|
115
|
+
if (trimmedAnswer === "2" || trimmedAnswer.toLowerCase().startsWith("continue until")) {
|
|
116
|
+
return { action: "continue_loop", checkpointDisabled: true, lastCheckpointAt: Date.now() };
|
|
117
|
+
}
|
|
118
|
+
if (trimmedAnswer === "1" || trimmedAnswer.toLowerCase().includes("5 m")) {
|
|
119
|
+
return { action: "continue_loop", checkpointDisabled, lastCheckpointAt: Date.now() };
|
|
120
|
+
}
|
|
121
|
+
const customMinutes = Number.parseInt(trimmedAnswer.replaceAll(/\D/g, ""), 10);
|
|
122
|
+
if (customMinutes > 0) {
|
|
123
|
+
config.session.checkpoint_interval_minutes = customMinutes;
|
|
124
|
+
return { action: "continue_loop", checkpointDisabled, lastCheckpointAt: Date.now() };
|
|
125
|
+
}
|
|
126
|
+
return { action: "continue_loop", checkpointDisabled, lastCheckpointAt: Date.now() };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function checkSessionTimeout({ askQuestion, elapsedMinutes, config, session, emitter, eventBase, i, budgetSummary }) {
|
|
130
|
+
if (askQuestion || elapsedMinutes <= config.session.max_total_minutes) return;
|
|
131
|
+
|
|
132
|
+
await markSessionStatus(session, "failed");
|
|
133
|
+
emitProgress(
|
|
134
|
+
emitter,
|
|
135
|
+
makeEvent("session:end", { ...eventBase, iteration: i, stage: "timeout" }, {
|
|
136
|
+
status: "fail",
|
|
137
|
+
message: "Session timed out",
|
|
138
|
+
detail: { approved: false, reason: "timeout", budget: budgetSummary() }
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
throw new Error("Session timed out");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function checkBudgetExceeded({ budgetTracker, config, session, emitter, eventBase, i, budgetLimit, budgetSummary }) {
|
|
145
|
+
if (!budgetTracker.isOverBudget(config?.max_budget_usd)) return;
|
|
146
|
+
|
|
147
|
+
await markSessionStatus(session, "failed");
|
|
148
|
+
const totalCost = budgetTracker.total().cost_usd;
|
|
149
|
+
const message = `Budget exceeded: $${totalCost.toFixed(2)} > $${budgetLimit.toFixed(2)}`;
|
|
150
|
+
emitProgress(
|
|
151
|
+
emitter,
|
|
152
|
+
makeEvent("session:end", { ...eventBase, iteration: i, stage: "budget" }, {
|
|
153
|
+
status: "fail",
|
|
154
|
+
message,
|
|
155
|
+
detail: { approved: false, reason: "budget_exceeded", budget: budgetSummary(), max_budget_usd: budgetLimit }
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
throw new Error(message);
|
|
159
|
+
}
|
|
@@ -138,18 +138,20 @@ export async function runTesterStage({ config, logger, emitter, eventBase, sessi
|
|
|
138
138
|
);
|
|
139
139
|
|
|
140
140
|
if (!testerOutput.ok) {
|
|
141
|
-
// Tester
|
|
142
|
-
|
|
143
|
-
logger.warn(`Tester failed (advisory): ${testerOutput.summary}`);
|
|
141
|
+
// Tester failed — blocking, return to coder for correction
|
|
142
|
+
logger.warn(`Tester failed (blocking): ${testerOutput.summary}`);
|
|
144
143
|
emitProgress(
|
|
145
144
|
emitter,
|
|
146
|
-
makeEvent("tester:
|
|
147
|
-
status: "
|
|
148
|
-
message: `Tester
|
|
149
|
-
detail: { summary: testerOutput.summary,
|
|
145
|
+
makeEvent("tester:blocking", { ...eventBase, stage: "tester" }, {
|
|
146
|
+
status: "fail",
|
|
147
|
+
message: `Tester failed — sending back to coder for correction: ${testerOutput.summary}`,
|
|
148
|
+
detail: { summary: testerOutput.summary, blocking: true, provider: testerProvider, executorType: "agent" }
|
|
150
149
|
})
|
|
151
150
|
);
|
|
152
|
-
return {
|
|
151
|
+
return {
|
|
152
|
+
action: "continue",
|
|
153
|
+
stageResult: { ...testerOutput.result, summary: testerOutput.summary, provider: testerProvider }
|
|
154
|
+
};
|
|
153
155
|
}
|
|
154
156
|
|
|
155
157
|
session.tester_retry_count = 0;
|
|
@@ -230,17 +232,20 @@ export async function runSecurityStage({ config, logger, emitter, eventBase, ses
|
|
|
230
232
|
return { action: "ok" };
|
|
231
233
|
}
|
|
232
234
|
|
|
233
|
-
// Non-critical security findings
|
|
234
|
-
logger.warn(`Security failed (
|
|
235
|
+
// Non-critical security findings — blocking, return to coder for correction
|
|
236
|
+
logger.warn(`Security failed (blocking): ${securityOutput.summary}`);
|
|
235
237
|
emitProgress(
|
|
236
238
|
emitter,
|
|
237
|
-
makeEvent("security:
|
|
238
|
-
status: "
|
|
239
|
-
message: `Security
|
|
240
|
-
detail: { summary: securityOutput.summary,
|
|
239
|
+
makeEvent("security:blocking", { ...eventBase, stage: "security" }, {
|
|
240
|
+
status: "fail",
|
|
241
|
+
message: `Security failed — sending back to coder for correction: ${securityOutput.summary}`,
|
|
242
|
+
detail: { summary: securityOutput.summary, blocking: true, provider: securityProvider, executorType: "agent" }
|
|
241
243
|
})
|
|
242
244
|
);
|
|
243
|
-
return {
|
|
245
|
+
return {
|
|
246
|
+
action: "continue",
|
|
247
|
+
stageResult: { ...securityOutput.result, summary: securityOutput.summary, provider: securityProvider }
|
|
248
|
+
};
|
|
244
249
|
}
|
|
245
250
|
|
|
246
251
|
session.security_retry_count = 0;
|