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.
Files changed (115) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -1
  3. package/scripts/install.js +18 -15
  4. package/src/agents/aider-agent.js +0 -7
  5. package/src/agents/claude-agent.js +34 -6
  6. package/src/agents/codex-agent.js +2 -6
  7. package/src/agents/gemini-agent.js +0 -4
  8. package/src/agents/opencode-agent.js +0 -4
  9. package/src/{becaria → ci}/dispatch.js +13 -13
  10. package/src/{becaria → ci}/pr-diff.js +1 -1
  11. package/src/cli.js +3 -2
  12. package/src/commands/agents.js +0 -1
  13. package/src/commands/doctor.js +22 -40
  14. package/src/commands/init.js +8 -8
  15. package/src/commands/report.js +0 -13
  16. package/src/commands/review.js +17 -17
  17. package/src/commands/run.js +1 -2
  18. package/src/commands/status.js +1 -7
  19. package/src/config.js +10 -52
  20. package/src/domains/domain-loader.js +85 -4
  21. package/src/git/automation.js +9 -9
  22. package/src/guards/output-guard.js +0 -17
  23. package/src/mcp/handlers/direct-handlers.js +1 -1
  24. package/src/mcp/handlers/run-handler.js +1 -2
  25. package/src/mcp/sovereignty-guard.js +2 -2
  26. package/src/mcp/tools.js +3 -2
  27. package/src/orchestrator/brain-coordinator.js +165 -0
  28. package/src/orchestrator/ci-integration.js +139 -0
  29. package/src/orchestrator/config-init.js +435 -0
  30. package/src/orchestrator/direct-actions.js +186 -0
  31. package/src/orchestrator/feedback-enrichment.js +140 -0
  32. package/src/orchestrator/feedback-queue.js +152 -0
  33. package/src/orchestrator/flow-control.js +159 -0
  34. package/src/orchestrator/post-loop-stages.js +20 -15
  35. package/src/orchestrator/preflight-checks.js +7 -3
  36. package/src/orchestrator/role-output-compressor.js +190 -0
  37. package/src/orchestrator/session-journal.js +344 -0
  38. package/src/orchestrator/solomon-escalation.js +38 -5
  39. package/src/orchestrator/solomon-rules.js +32 -33
  40. package/src/orchestrator/stages/reviewer-stage.js +16 -5
  41. package/src/orchestrator/stages/sonar-stage.js +24 -1
  42. package/src/orchestrator/verification-gate.js +146 -0
  43. package/src/orchestrator.js +435 -661
  44. package/src/planning-game/client.js +2 -2
  45. package/src/planning-game/pipeline-adapter.js +1 -1
  46. package/src/prompts/coder.js +17 -3
  47. package/src/prompts/reviewer.js +2 -2
  48. package/src/prompts/rtk-snippet.js +3 -5
  49. package/src/review/diff-generator.js +66 -17
  50. package/src/review/snapshot-diff.js +122 -0
  51. package/src/review/tdd-policy.js +4 -2
  52. package/src/roles/agent-role.js +135 -0
  53. package/src/roles/architect-role.js +61 -108
  54. package/src/roles/audit-role.js +25 -71
  55. package/src/roles/coder-role.js +32 -57
  56. package/src/roles/discover-role.js +30 -71
  57. package/src/roles/hu-reviewer-role.js +51 -99
  58. package/src/roles/impeccable-role.js +34 -91
  59. package/src/roles/index.js +2 -0
  60. package/src/roles/karajan-brain-role.js +134 -0
  61. package/src/roles/planner-role.js +40 -75
  62. package/src/roles/refactorer-role.js +22 -64
  63. package/src/roles/researcher-role.js +52 -118
  64. package/src/roles/reviewer-role.js +69 -126
  65. package/src/roles/security-role.js +38 -102
  66. package/src/roles/solomon-role.js +71 -15
  67. package/src/roles/tester-role.js +121 -95
  68. package/src/roles/triage-role.js +50 -111
  69. package/src/session-store.js +1 -1
  70. package/src/skills/skill-detector.js +11 -0
  71. package/src/sonar/config-resolver.js +20 -1
  72. package/src/sonar/credentials.js +17 -0
  73. package/src/sonar/project-key.js +1 -1
  74. package/src/sonar/scanner.js +1 -1
  75. package/src/utils/banner.js +66 -0
  76. package/src/utils/display.js +35 -17
  77. package/src/utils/injection-guard.js +23 -8
  78. package/src/utils/logger.js +6 -1
  79. package/src/utils/process.js +3 -4
  80. package/src/utils/role-assigner.js +131 -0
  81. package/src/utils/update-check.js +5 -1
  82. package/src/utils/welcome.js +3 -8
  83. package/src/webperf/devtools-detect.js +70 -23
  84. package/templates/roles/coder.md +7 -0
  85. package/templates/roles/karajan-brain.md +95 -0
  86. package/templates/roles/solomon.md +67 -106
  87. package/templates/workflows/automerge.yml +4 -4
  88. package/templates/workflows/houston-override.yml +4 -4
  89. package/templates/workflows/{becaria-gateway.yml → kj-ci-gateway.yml} +12 -12
  90. package/src/proxy/adapters/anthropic.js +0 -131
  91. package/src/proxy/adapters/gemini.js +0 -106
  92. package/src/proxy/adapters/index.js +0 -51
  93. package/src/proxy/adapters/openai.js +0 -71
  94. package/src/proxy/compression/ai-compressor.js +0 -209
  95. package/src/proxy/compression/dedup-cache.js +0 -100
  96. package/src/proxy/compression/deterministic/bash-build.js +0 -60
  97. package/src/proxy/compression/deterministic/bash-git.js +0 -101
  98. package/src/proxy/compression/deterministic/bash-infra.js +0 -64
  99. package/src/proxy/compression/deterministic/bash-misc.js +0 -53
  100. package/src/proxy/compression/deterministic/bash-pkg.js +0 -49
  101. package/src/proxy/compression/deterministic/bash-test.js +0 -61
  102. package/src/proxy/compression/deterministic/glob.js +0 -26
  103. package/src/proxy/compression/deterministic/grep.js +0 -53
  104. package/src/proxy/compression/deterministic/index.js +0 -58
  105. package/src/proxy/compression/deterministic/read.js +0 -22
  106. package/src/proxy/compression/deterministic/utils.js +0 -33
  107. package/src/proxy/compression/pipeline.js +0 -270
  108. package/src/proxy/middleware/prompt-injector.js +0 -165
  109. package/src/proxy/middleware/response-interceptor.js +0 -223
  110. package/src/proxy/progress-monitor.js +0 -98
  111. package/src/proxy/proxy-lifecycle.js +0 -198
  112. package/src/proxy/proxy-server.js +0 -239
  113. package/src/proxy/security.js +0 -45
  114. /package/src/{becaria → ci}/index.js +0 -0
  115. /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 findings are advisory when reviewer already approved.
142
- // Auto-continue with a warning — no human escalation needed.
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:auto-continue", { ...eventBase, stage: "tester" }, {
147
- status: "warn",
148
- message: `Tester issues are advisory (reviewer approved), continuing: ${testerOutput.summary}`,
149
- detail: { summary: testerOutput.summary, auto_continued: true, provider: testerProvider, executorType: "agent" }
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 { action: "ok", stageResult: { ok: false, summary: testerOutput.summary || "Tester issues (advisory)", auto_continued: true } };
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 are advisory when reviewer already approved.
234
- logger.warn(`Security failed (advisory): ${securityOutput.summary}`);
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:auto-continue", { ...eventBase, stage: "security" }, {
238
- status: "warn",
239
- message: `Security issues are advisory (reviewer approved), continuing: ${securityOutput.summary}`,
240
- detail: { summary: securityOutput.summary, auto_continued: true }
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 { action: "ok", stageResult: { ok: false, summary: securityOutput.summary || "Security issues (advisory)", auto_continued: true } };
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;