@kodrunhq/opencode-autopilot 0.1.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/LICENSE +21 -0
- package/README.md +1 -0
- package/assets/agents/placeholder-agent.md +13 -0
- package/assets/commands/configure.md +17 -0
- package/assets/commands/new-agent.md +16 -0
- package/assets/commands/new-command.md +15 -0
- package/assets/commands/new-skill.md +15 -0
- package/assets/commands/review-pr.md +49 -0
- package/assets/skills/.gitkeep +0 -0
- package/assets/skills/coding-standards/SKILL.md +327 -0
- package/package.json +52 -0
- package/src/agents/autopilot.ts +42 -0
- package/src/agents/documenter.ts +44 -0
- package/src/agents/index.ts +49 -0
- package/src/agents/metaprompter.ts +50 -0
- package/src/agents/pipeline/index.ts +25 -0
- package/src/agents/pipeline/oc-architect.ts +49 -0
- package/src/agents/pipeline/oc-challenger.ts +44 -0
- package/src/agents/pipeline/oc-critic.ts +42 -0
- package/src/agents/pipeline/oc-explorer.ts +46 -0
- package/src/agents/pipeline/oc-implementer.ts +56 -0
- package/src/agents/pipeline/oc-planner.ts +45 -0
- package/src/agents/pipeline/oc-researcher.ts +46 -0
- package/src/agents/pipeline/oc-retrospector.ts +42 -0
- package/src/agents/pipeline/oc-reviewer.ts +44 -0
- package/src/agents/pipeline/oc-shipper.ts +42 -0
- package/src/agents/pr-reviewer.ts +74 -0
- package/src/agents/researcher.ts +43 -0
- package/src/config.ts +168 -0
- package/src/index.ts +152 -0
- package/src/installer.ts +130 -0
- package/src/orchestrator/arena.ts +41 -0
- package/src/orchestrator/artifacts.ts +28 -0
- package/src/orchestrator/confidence.ts +59 -0
- package/src/orchestrator/fallback/chat-message-handler.ts +49 -0
- package/src/orchestrator/fallback/error-classifier.ts +148 -0
- package/src/orchestrator/fallback/event-handler.ts +235 -0
- package/src/orchestrator/fallback/fallback-config.ts +16 -0
- package/src/orchestrator/fallback/fallback-manager.ts +323 -0
- package/src/orchestrator/fallback/fallback-state.ts +120 -0
- package/src/orchestrator/fallback/index.ts +11 -0
- package/src/orchestrator/fallback/message-replay.ts +40 -0
- package/src/orchestrator/fallback/resolve-chain.ts +34 -0
- package/src/orchestrator/fallback/tool-execute-handler.ts +44 -0
- package/src/orchestrator/fallback/types.ts +46 -0
- package/src/orchestrator/handlers/architect.ts +114 -0
- package/src/orchestrator/handlers/build.ts +363 -0
- package/src/orchestrator/handlers/challenge.ts +41 -0
- package/src/orchestrator/handlers/explore.ts +9 -0
- package/src/orchestrator/handlers/index.ts +21 -0
- package/src/orchestrator/handlers/plan.ts +35 -0
- package/src/orchestrator/handlers/recon.ts +40 -0
- package/src/orchestrator/handlers/retrospective.ts +123 -0
- package/src/orchestrator/handlers/ship.ts +38 -0
- package/src/orchestrator/handlers/types.ts +31 -0
- package/src/orchestrator/lesson-injection.ts +80 -0
- package/src/orchestrator/lesson-memory.ts +110 -0
- package/src/orchestrator/lesson-schemas.ts +24 -0
- package/src/orchestrator/lesson-types.ts +6 -0
- package/src/orchestrator/phase.ts +76 -0
- package/src/orchestrator/plan.ts +43 -0
- package/src/orchestrator/schemas.ts +86 -0
- package/src/orchestrator/skill-injection.ts +52 -0
- package/src/orchestrator/state.ts +80 -0
- package/src/orchestrator/types.ts +20 -0
- package/src/review/agent-catalog.ts +439 -0
- package/src/review/agents/auth-flow-verifier.ts +47 -0
- package/src/review/agents/code-quality-auditor.ts +51 -0
- package/src/review/agents/concurrency-checker.ts +47 -0
- package/src/review/agents/contract-verifier.ts +45 -0
- package/src/review/agents/database-auditor.ts +47 -0
- package/src/review/agents/dead-code-scanner.ts +47 -0
- package/src/review/agents/go-idioms-auditor.ts +46 -0
- package/src/review/agents/index.ts +82 -0
- package/src/review/agents/logic-auditor.ts +47 -0
- package/src/review/agents/product-thinker.ts +49 -0
- package/src/review/agents/python-django-auditor.ts +46 -0
- package/src/review/agents/react-patterns-auditor.ts +46 -0
- package/src/review/agents/red-team.ts +49 -0
- package/src/review/agents/rust-safety-auditor.ts +46 -0
- package/src/review/agents/scope-intent-verifier.ts +45 -0
- package/src/review/agents/security-auditor.ts +47 -0
- package/src/review/agents/silent-failure-hunter.ts +45 -0
- package/src/review/agents/spec-checker.ts +45 -0
- package/src/review/agents/state-mgmt-auditor.ts +46 -0
- package/src/review/agents/test-interrogator.ts +43 -0
- package/src/review/agents/type-soundness.ts +46 -0
- package/src/review/agents/wiring-inspector.ts +46 -0
- package/src/review/cross-verification.ts +71 -0
- package/src/review/finding-builder.ts +74 -0
- package/src/review/fix-cycle.ts +146 -0
- package/src/review/memory.ts +114 -0
- package/src/review/pipeline.ts +258 -0
- package/src/review/report.ts +141 -0
- package/src/review/sanitize.ts +8 -0
- package/src/review/schemas.ts +75 -0
- package/src/review/selection.ts +98 -0
- package/src/review/severity.ts +71 -0
- package/src/review/stack-gate.ts +127 -0
- package/src/review/types.ts +43 -0
- package/src/templates/agent-template.ts +47 -0
- package/src/templates/command-template.ts +29 -0
- package/src/templates/skill-template.ts +42 -0
- package/src/tools/confidence.ts +93 -0
- package/src/tools/create-agent.ts +81 -0
- package/src/tools/create-command.ts +74 -0
- package/src/tools/create-skill.ts +74 -0
- package/src/tools/forensics.ts +88 -0
- package/src/tools/orchestrate.ts +310 -0
- package/src/tools/phase.ts +92 -0
- package/src/tools/placeholder.ts +11 -0
- package/src/tools/plan.ts +56 -0
- package/src/tools/review.ts +295 -0
- package/src/tools/state.ts +112 -0
- package/src/utils/fs-helpers.ts +39 -0
- package/src/utils/gitignore.ts +27 -0
- package/src/utils/paths.ts +17 -0
- package/src/utils/validators.ts +57 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-project review memory persistence.
|
|
3
|
+
*
|
|
4
|
+
* Stores recent findings and false positives at
|
|
5
|
+
* {projectRoot}/.opencode-autopilot/review-memory.json
|
|
6
|
+
*
|
|
7
|
+
* Memory is pruned on load to cap storage and remove stale entries.
|
|
8
|
+
* All writes are atomic (tmp file + rename) to prevent corruption.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { ensureDir, isEnoentError } from "../utils/fs-helpers";
|
|
14
|
+
import { reviewMemorySchema } from "./schemas";
|
|
15
|
+
import type { ReviewMemory } from "./types";
|
|
16
|
+
|
|
17
|
+
export type { ReviewMemory };
|
|
18
|
+
|
|
19
|
+
const MEMORY_FILE = "review-memory.json";
|
|
20
|
+
const MAX_FINDINGS = 100;
|
|
21
|
+
const MAX_FALSE_POSITIVES = 50;
|
|
22
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a valid empty memory object.
|
|
26
|
+
*/
|
|
27
|
+
export function createEmptyMemory(): ReviewMemory {
|
|
28
|
+
return reviewMemorySchema.parse({
|
|
29
|
+
schemaVersion: 1,
|
|
30
|
+
projectProfile: { stacks: [], lastDetectedAt: "" },
|
|
31
|
+
recentFindings: [],
|
|
32
|
+
falsePositives: [],
|
|
33
|
+
lastReviewedAt: null,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Load review memory from disk.
|
|
39
|
+
* Returns null if file doesn't exist (first run).
|
|
40
|
+
* Returns null on malformed JSON (SyntaxError) or invalid schema (ZodError)
|
|
41
|
+
* to allow recovery rather than crashing the pipeline.
|
|
42
|
+
* Prunes on load to cap storage.
|
|
43
|
+
*/
|
|
44
|
+
export async function loadReviewMemory(projectRoot: string): Promise<ReviewMemory | null> {
|
|
45
|
+
const memoryPath = join(projectRoot, ".opencode-autopilot", MEMORY_FILE);
|
|
46
|
+
try {
|
|
47
|
+
const raw = await readFile(memoryPath, "utf-8");
|
|
48
|
+
const parsed = JSON.parse(raw);
|
|
49
|
+
const validated = reviewMemorySchema.parse(parsed);
|
|
50
|
+
return pruneMemory(validated);
|
|
51
|
+
} catch (error: unknown) {
|
|
52
|
+
if (isEnoentError(error)) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
// Recover from malformed JSON or schema violations instead of crashing
|
|
56
|
+
if (
|
|
57
|
+
error instanceof SyntaxError ||
|
|
58
|
+
(error !== null && typeof error === "object" && "issues" in error)
|
|
59
|
+
) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Save review memory to disk with atomic write.
|
|
68
|
+
* Validates through schema before writing (bidirectional validation).
|
|
69
|
+
* Uses tmp file + rename to prevent corruption.
|
|
70
|
+
*/
|
|
71
|
+
export async function saveReviewMemory(memory: ReviewMemory, projectRoot: string): Promise<void> {
|
|
72
|
+
const validated = reviewMemorySchema.parse(memory);
|
|
73
|
+
const dir = join(projectRoot, ".opencode-autopilot");
|
|
74
|
+
await ensureDir(dir);
|
|
75
|
+
const memoryPath = join(dir, MEMORY_FILE);
|
|
76
|
+
const tmpPath = `${memoryPath}.tmp.${Date.now()}`;
|
|
77
|
+
await writeFile(tmpPath, JSON.stringify(validated, null, 2), "utf-8");
|
|
78
|
+
await rename(tmpPath, memoryPath);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Prune memory to cap storage and remove stale entries.
|
|
83
|
+
* Pure function -- returns new frozen object, never mutates.
|
|
84
|
+
*
|
|
85
|
+
* - recentFindings: cap at 100 (keep newest -- later entries are newer)
|
|
86
|
+
* - falsePositives: cap at 50 (keep newest by markedAt date)
|
|
87
|
+
* - falsePositives: remove entries older than 30 days
|
|
88
|
+
*/
|
|
89
|
+
export function pruneMemory(memory: ReviewMemory): ReviewMemory {
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
|
|
92
|
+
// Prune false positives older than 30 days first, then cap
|
|
93
|
+
const freshFalsePositives = memory.falsePositives.filter(
|
|
94
|
+
(fp) => now - new Date(fp.markedAt).getTime() < THIRTY_DAYS_MS,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Sort by markedAt descending (newest first) then take the first MAX
|
|
98
|
+
const sortedFP = [...freshFalsePositives].sort(
|
|
99
|
+
(a, b) => new Date(b.markedAt).getTime() - new Date(a.markedAt).getTime(),
|
|
100
|
+
);
|
|
101
|
+
const cappedFP = sortedFP.slice(0, MAX_FALSE_POSITIVES);
|
|
102
|
+
|
|
103
|
+
// Cap recentFindings (later entries are newer, keep the tail)
|
|
104
|
+
const cappedFindings =
|
|
105
|
+
memory.recentFindings.length > MAX_FINDINGS
|
|
106
|
+
? memory.recentFindings.slice(memory.recentFindings.length - MAX_FINDINGS)
|
|
107
|
+
: [...memory.recentFindings];
|
|
108
|
+
|
|
109
|
+
return Object.freeze({
|
|
110
|
+
...memory,
|
|
111
|
+
recentFindings: [...cappedFindings],
|
|
112
|
+
falsePositives: [...cappedFP],
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review pipeline state machine.
|
|
3
|
+
*
|
|
4
|
+
* 4-stage flow:
|
|
5
|
+
* Stage 1: Dispatch specialist agents with diff
|
|
6
|
+
* Stage 2: Cross-verification (agents review each other's findings)
|
|
7
|
+
* Stage 3: Red-team + product-thinker with all accumulated findings
|
|
8
|
+
* Stage 4: Final report (or fix cycle if CRITICAL findings with actionable fixes)
|
|
9
|
+
*
|
|
10
|
+
* The pipeline returns dispatch instructions -- it does NOT dispatch agents itself.
|
|
11
|
+
* The orchestrator is responsible for sending prompts to agents and collecting results.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { ALL_REVIEW_AGENTS, STAGE3_AGENTS } from "./agents/index";
|
|
15
|
+
import { buildCrossVerificationPrompts, condenseFinding } from "./cross-verification";
|
|
16
|
+
|
|
17
|
+
/** Derived set of stage-3 agent names — avoids hardcoding names in pipeline logic. */
|
|
18
|
+
const STAGE3_NAMES: ReadonlySet<string> = new Set(STAGE3_AGENTS.map((a) => a.name));
|
|
19
|
+
|
|
20
|
+
import { buildFixInstructions, determineFixableFindings } from "./fix-cycle";
|
|
21
|
+
import { buildReport } from "./report";
|
|
22
|
+
import { reviewFindingSchema } from "./schemas";
|
|
23
|
+
import type { ReviewFinding, ReviewReport, ReviewState } from "./types";
|
|
24
|
+
|
|
25
|
+
export type { ReviewState };
|
|
26
|
+
|
|
27
|
+
import { sanitizeTemplateContent } from "./sanitize";
|
|
28
|
+
|
|
29
|
+
/** Result of a pipeline step -- either dispatch more agents or return the final report. */
|
|
30
|
+
export interface ReviewStageResult {
|
|
31
|
+
readonly action: "dispatch" | "complete" | "error";
|
|
32
|
+
readonly stage?: number;
|
|
33
|
+
readonly agents?: readonly { readonly name: string; readonly prompt: string }[];
|
|
34
|
+
readonly report?: ReviewReport;
|
|
35
|
+
readonly message?: string;
|
|
36
|
+
readonly state?: ReviewState;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse findings from raw LLM output (which may contain markdown, prose, code blocks).
|
|
41
|
+
*
|
|
42
|
+
* Handles:
|
|
43
|
+
* - {"findings": [...]} wrapper
|
|
44
|
+
* - Raw array [{...}, ...]
|
|
45
|
+
* - JSON embedded in markdown code blocks
|
|
46
|
+
* - Prose with embedded JSON
|
|
47
|
+
*
|
|
48
|
+
* Sets agent field to agentName if missing from individual findings.
|
|
49
|
+
* Validates each finding through reviewFindingSchema, discards invalid ones.
|
|
50
|
+
*/
|
|
51
|
+
export function parseAgentFindings(raw: string, agentName: string): readonly ReviewFinding[] {
|
|
52
|
+
const findings: ReviewFinding[] = [];
|
|
53
|
+
|
|
54
|
+
// Try to extract JSON from the raw text
|
|
55
|
+
const jsonStr = extractJson(raw);
|
|
56
|
+
if (jsonStr === null) return Object.freeze(findings);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(jsonStr);
|
|
60
|
+
const items = Array.isArray(parsed) ? parsed : parsed?.findings;
|
|
61
|
+
|
|
62
|
+
if (!Array.isArray(items)) return Object.freeze(findings);
|
|
63
|
+
|
|
64
|
+
for (const item of items) {
|
|
65
|
+
// Set agent field if missing
|
|
66
|
+
const withAgent = item.agent ? item : { ...item, agent: agentName };
|
|
67
|
+
const result = reviewFindingSchema.safeParse(withAgent);
|
|
68
|
+
if (result.success) {
|
|
69
|
+
findings.push(result.data);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// JSON parse failed -- return empty
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return Object.freeze(findings);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extract the first JSON object or array from raw text.
|
|
81
|
+
* Looks for:
|
|
82
|
+
* 1. JSON inside markdown code blocks
|
|
83
|
+
* 2. {"findings": ...} pattern
|
|
84
|
+
* 3. Raw array [{...}]
|
|
85
|
+
*/
|
|
86
|
+
function extractJson(raw: string): string | null {
|
|
87
|
+
// Try markdown code block extraction first
|
|
88
|
+
const codeBlockMatch = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
|
|
89
|
+
if (codeBlockMatch) {
|
|
90
|
+
return codeBlockMatch[1].trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Try to find {"findings": ...} or [{...}]
|
|
94
|
+
const objectStart = raw.indexOf("{");
|
|
95
|
+
const arrayStart = raw.indexOf("[");
|
|
96
|
+
|
|
97
|
+
if (objectStart === -1 && arrayStart === -1) return null;
|
|
98
|
+
|
|
99
|
+
// Pick whichever comes first
|
|
100
|
+
const start =
|
|
101
|
+
objectStart === -1
|
|
102
|
+
? arrayStart
|
|
103
|
+
: arrayStart === -1
|
|
104
|
+
? objectStart
|
|
105
|
+
: Math.min(objectStart, arrayStart);
|
|
106
|
+
|
|
107
|
+
// Find matching close bracket (string-literal-aware depth tracking)
|
|
108
|
+
let depth = 0;
|
|
109
|
+
let inString = false;
|
|
110
|
+
let escaped = false;
|
|
111
|
+
for (let i = start; i < raw.length; i++) {
|
|
112
|
+
const ch = raw[i];
|
|
113
|
+
if (escaped) {
|
|
114
|
+
escaped = false;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (ch === "\\" && inString) {
|
|
118
|
+
escaped = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (ch === '"') {
|
|
122
|
+
inString = !inString;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (inString) continue;
|
|
126
|
+
if (ch === "{" || ch === "[") depth++;
|
|
127
|
+
if (ch === "}" || ch === "]") depth--;
|
|
128
|
+
if (depth === 0) {
|
|
129
|
+
return raw.slice(start, i + 1);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Advance the pipeline from the current stage to the next.
|
|
138
|
+
*
|
|
139
|
+
* @param findingsJson - Raw JSON string of findings from the current stage's agents
|
|
140
|
+
* @param currentState - Current pipeline state
|
|
141
|
+
* @returns Next stage's dispatch instructions or final report
|
|
142
|
+
*/
|
|
143
|
+
export function advancePipeline(
|
|
144
|
+
findingsJson: string,
|
|
145
|
+
currentState: ReviewState,
|
|
146
|
+
agentName = "unknown",
|
|
147
|
+
): ReviewStageResult {
|
|
148
|
+
// Parse new findings
|
|
149
|
+
const newFindings = parseAgentFindings(findingsJson, agentName);
|
|
150
|
+
const accumulated = [...currentState.accumulatedFindings, ...newFindings];
|
|
151
|
+
|
|
152
|
+
const nextStage = currentState.stage + 1;
|
|
153
|
+
|
|
154
|
+
switch (currentState.stage) {
|
|
155
|
+
case 1: {
|
|
156
|
+
// Stage 1 -> 2: Build cross-verification prompts from all selected agents (excluding stage 3)
|
|
157
|
+
const agents = ALL_REVIEW_AGENTS.filter(
|
|
158
|
+
(a) => currentState.selectedAgentNames.includes(a.name) && !STAGE3_NAMES.has(a.name),
|
|
159
|
+
);
|
|
160
|
+
const findingsByAgent = groupFindingsByAgent(accumulated);
|
|
161
|
+
const sanitizedScope = sanitizeTemplateContent(currentState.scope);
|
|
162
|
+
const prompts = buildCrossVerificationPrompts(agents, findingsByAgent, sanitizedScope);
|
|
163
|
+
const newState: ReviewState = {
|
|
164
|
+
...currentState,
|
|
165
|
+
stage: nextStage,
|
|
166
|
+
accumulatedFindings: accumulated,
|
|
167
|
+
};
|
|
168
|
+
return Object.freeze({
|
|
169
|
+
action: "dispatch" as const,
|
|
170
|
+
stage: nextStage,
|
|
171
|
+
agents: prompts,
|
|
172
|
+
state: newState,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
case 2: {
|
|
177
|
+
// Stage 2 -> 3: Build red-team + product-thinker prompts
|
|
178
|
+
const condensed = sanitizeTemplateContent(accumulated.map(condenseFinding).join("\n"));
|
|
179
|
+
const sanitizedScope2 = sanitizeTemplateContent(currentState.scope);
|
|
180
|
+
const stage3Prompts = STAGE3_AGENTS.map((agent) => ({
|
|
181
|
+
name: agent.name,
|
|
182
|
+
prompt: agent.prompt
|
|
183
|
+
.replace("{{DIFF}}", sanitizedScope2)
|
|
184
|
+
.replace("{{PRIOR_FINDINGS}}", condensed)
|
|
185
|
+
.replace("{{MEMORY}}", ""),
|
|
186
|
+
}));
|
|
187
|
+
const newState: ReviewState = {
|
|
188
|
+
...currentState,
|
|
189
|
+
stage: nextStage,
|
|
190
|
+
accumulatedFindings: accumulated,
|
|
191
|
+
};
|
|
192
|
+
return Object.freeze({
|
|
193
|
+
action: "dispatch" as const,
|
|
194
|
+
stage: nextStage,
|
|
195
|
+
agents: Object.freeze(stage3Prompts.map((p) => Object.freeze(p))),
|
|
196
|
+
state: newState,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
case 3: {
|
|
201
|
+
// Stage 3 -> 4 or complete: Check for actionable CRITICAL fixes
|
|
202
|
+
const fixResult = determineFixableFindings(accumulated);
|
|
203
|
+
if (fixResult.fixable.length > 0) {
|
|
204
|
+
// Build fix-cycle prompts for agents whose findings are fixable
|
|
205
|
+
const allAgents = ALL_REVIEW_AGENTS.filter(
|
|
206
|
+
(a) => currentState.selectedAgentNames.includes(a.name) || STAGE3_NAMES.has(a.name),
|
|
207
|
+
);
|
|
208
|
+
const sanitizedScope3 = sanitizeTemplateContent(currentState.scope);
|
|
209
|
+
const fixAgents = buildFixInstructions(fixResult.fixable, allAgents, sanitizedScope3);
|
|
210
|
+
const newState: ReviewState = {
|
|
211
|
+
...currentState,
|
|
212
|
+
stage: nextStage,
|
|
213
|
+
accumulatedFindings: accumulated,
|
|
214
|
+
};
|
|
215
|
+
return Object.freeze({
|
|
216
|
+
action: "dispatch" as const,
|
|
217
|
+
stage: nextStage,
|
|
218
|
+
message: "Fix cycle: CRITICAL findings with actionable suggestions detected.",
|
|
219
|
+
agents: Object.freeze(fixAgents),
|
|
220
|
+
state: newState,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
// No fix cycle needed -- complete
|
|
224
|
+
const report = buildReport(accumulated, currentState.scope, currentState.selectedAgentNames);
|
|
225
|
+
return Object.freeze({ action: "complete" as const, report });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
case 4: {
|
|
229
|
+
// Stage 4 -> complete: Build final report with all findings
|
|
230
|
+
const report = buildReport(accumulated, currentState.scope, currentState.selectedAgentNames);
|
|
231
|
+
return Object.freeze({ action: "complete" as const, report });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
default:
|
|
235
|
+
return Object.freeze({
|
|
236
|
+
action: "error" as const,
|
|
237
|
+
message: `Unknown stage: ${currentState.stage}`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Group findings by agent name.
|
|
244
|
+
*/
|
|
245
|
+
function groupFindingsByAgent(
|
|
246
|
+
findings: readonly ReviewFinding[],
|
|
247
|
+
): ReadonlyMap<string, readonly ReviewFinding[]> {
|
|
248
|
+
const grouped = new Map<string, ReviewFinding[]>();
|
|
249
|
+
for (const f of findings) {
|
|
250
|
+
const group = grouped.get(f.agent);
|
|
251
|
+
if (group) {
|
|
252
|
+
group.push(f);
|
|
253
|
+
} else {
|
|
254
|
+
grouped.set(f.agent, [f]);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return grouped;
|
|
258
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report builder for the review engine.
|
|
3
|
+
*
|
|
4
|
+
* Aggregates findings into a structured, deduplicated, severity-sorted report.
|
|
5
|
+
* Groups findings by file, sorts by severity within each group, and computes
|
|
6
|
+
* summary counts and verdict.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { reviewReportSchema } from "./schemas";
|
|
10
|
+
import { compareSeverity } from "./severity";
|
|
11
|
+
import type { ReviewFinding, ReviewReport, Severity, Verdict } from "./types";
|
|
12
|
+
|
|
13
|
+
export const SEVERITY_ORDER = Object.freeze({
|
|
14
|
+
CRITICAL: 0,
|
|
15
|
+
HIGH: 1,
|
|
16
|
+
MEDIUM: 2,
|
|
17
|
+
LOW: 3,
|
|
18
|
+
} as const);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Deduplicate findings by agent:file:line key.
|
|
22
|
+
* On collision (same agent, file, line), keeps the higher severity version.
|
|
23
|
+
* Returns a new array (no mutation).
|
|
24
|
+
*/
|
|
25
|
+
export function deduplicateFindings(findings: readonly ReviewFinding[]): readonly ReviewFinding[] {
|
|
26
|
+
const deduped = new Map<string, ReviewFinding>();
|
|
27
|
+
|
|
28
|
+
for (const finding of findings) {
|
|
29
|
+
const key = `${finding.agent}:${finding.file}:${finding.line ?? 0}`;
|
|
30
|
+
const existing = deduped.get(key);
|
|
31
|
+
|
|
32
|
+
if (existing === undefined) {
|
|
33
|
+
deduped.set(key, finding);
|
|
34
|
+
} else {
|
|
35
|
+
// compareSeverity returns negative if first arg is higher severity
|
|
36
|
+
if (compareSeverity(finding.severity, existing.severity) < 0) {
|
|
37
|
+
deduped.set(key, finding);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return [...deduped.values()];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Determine the verdict based on findings.
|
|
47
|
+
*/
|
|
48
|
+
function determineVerdict(findings: readonly ReviewFinding[]): Verdict {
|
|
49
|
+
if (findings.length === 0) return "CLEAN";
|
|
50
|
+
|
|
51
|
+
const hasCritical = findings.some((f) => f.severity === "CRITICAL");
|
|
52
|
+
if (hasCritical) return "BLOCKED";
|
|
53
|
+
|
|
54
|
+
const hasHigh = findings.some((f) => f.severity === "HIGH");
|
|
55
|
+
if (hasHigh) return "CONCERNS";
|
|
56
|
+
|
|
57
|
+
const hasMedium = findings.some((f) => f.severity === "MEDIUM");
|
|
58
|
+
if (hasMedium) return "CONCERNS";
|
|
59
|
+
|
|
60
|
+
return "APPROVED";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build a summary string with severity counts.
|
|
65
|
+
*/
|
|
66
|
+
function buildSummary(findings: readonly ReviewFinding[]): string {
|
|
67
|
+
const counts: Record<Severity, number> = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
|
|
68
|
+
for (const f of findings) {
|
|
69
|
+
counts[f.severity] += 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const total = findings.length;
|
|
73
|
+
if (total === 0) return "No findings. Code looks clean.";
|
|
74
|
+
|
|
75
|
+
const parts: string[] = [];
|
|
76
|
+
if (counts.CRITICAL > 0) parts.push(`${counts.CRITICAL} CRITICAL`);
|
|
77
|
+
if (counts.HIGH > 0) parts.push(`${counts.HIGH} HIGH`);
|
|
78
|
+
if (counts.MEDIUM > 0) parts.push(`${counts.MEDIUM} MEDIUM`);
|
|
79
|
+
if (counts.LOW > 0) parts.push(`${counts.LOW} LOW`);
|
|
80
|
+
|
|
81
|
+
return `${total} findings: ${parts.join(", ")}.`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build a structured review report from findings.
|
|
86
|
+
*
|
|
87
|
+
* 1. Deduplicates findings
|
|
88
|
+
* 2. Groups by file, sorts by severity within each file (CRITICAL first)
|
|
89
|
+
* 3. Computes summary counts and verdict
|
|
90
|
+
* 4. Validates through reviewReportSchema
|
|
91
|
+
* 5. Returns frozen report
|
|
92
|
+
*/
|
|
93
|
+
export function buildReport(
|
|
94
|
+
findings: readonly ReviewFinding[],
|
|
95
|
+
scope: string,
|
|
96
|
+
agentsRan: readonly string[],
|
|
97
|
+
): ReviewReport {
|
|
98
|
+
// 1. Deduplicate
|
|
99
|
+
const deduped = deduplicateFindings(findings);
|
|
100
|
+
|
|
101
|
+
// 2. Group by file, sort by severity within each file
|
|
102
|
+
const byFile = new Map<string, ReviewFinding[]>();
|
|
103
|
+
for (const f of deduped) {
|
|
104
|
+
const group = byFile.get(f.file);
|
|
105
|
+
if (group) {
|
|
106
|
+
group.push(f);
|
|
107
|
+
} else {
|
|
108
|
+
byFile.set(f.file, [f]);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Sort within each file group, then flatten
|
|
113
|
+
const sorted: ReviewFinding[] = [];
|
|
114
|
+
const sortedFileKeys = [...byFile.keys()].sort();
|
|
115
|
+
for (const file of sortedFileKeys) {
|
|
116
|
+
const group = byFile.get(file);
|
|
117
|
+
if (group) {
|
|
118
|
+
group.sort((a, b) => compareSeverity(a.severity, b.severity));
|
|
119
|
+
sorted.push(...group);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 3. Compute verdict and summary
|
|
124
|
+
const verdict = determineVerdict(sorted);
|
|
125
|
+
const summary = buildSummary(sorted);
|
|
126
|
+
|
|
127
|
+
// 4. Validate through schema
|
|
128
|
+
const report = reviewReportSchema.parse({
|
|
129
|
+
verdict,
|
|
130
|
+
findings: sorted,
|
|
131
|
+
agentResults: [],
|
|
132
|
+
scope,
|
|
133
|
+
agentsRan: [...agentsRan],
|
|
134
|
+
totalDurationMs: 0,
|
|
135
|
+
completedAt: new Date().toISOString(),
|
|
136
|
+
summary,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// 5. Return frozen
|
|
140
|
+
return Object.freeze(report);
|
|
141
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip {{PLACEHOLDER}} tokens from untrusted content before template substitution.
|
|
3
|
+
* Prevents template injection where diff content or prior findings could contain
|
|
4
|
+
* {{PRIOR_FINDINGS}} or similar tokens that get substituted in a subsequent .replace() call.
|
|
5
|
+
*/
|
|
6
|
+
export function sanitizeTemplateContent(content: string): string {
|
|
7
|
+
return content.replace(/\{\{[A-Z_]+\}\}/g, "[REDACTED]");
|
|
8
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const SEVERITIES = Object.freeze(["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const);
|
|
4
|
+
|
|
5
|
+
export const VERDICTS = Object.freeze(["CLEAN", "APPROVED", "CONCERNS", "BLOCKED"] as const);
|
|
6
|
+
|
|
7
|
+
export const severitySchema = z.enum(SEVERITIES);
|
|
8
|
+
|
|
9
|
+
export const verdictSchema = z.enum(VERDICTS);
|
|
10
|
+
|
|
11
|
+
export const reviewFindingSchema = z.object({
|
|
12
|
+
severity: severitySchema,
|
|
13
|
+
domain: z.string().max(128),
|
|
14
|
+
title: z.string().max(512),
|
|
15
|
+
file: z.string().max(512),
|
|
16
|
+
line: z.number().int().positive().optional(),
|
|
17
|
+
agent: z.string().max(128),
|
|
18
|
+
source: z.enum(["phase1", "cross-verification", "product-review", "red-team"]),
|
|
19
|
+
evidence: z.string().max(4096),
|
|
20
|
+
problem: z.string().max(2048),
|
|
21
|
+
fix: z.string().max(2048),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const agentResultSchema = z.object({
|
|
25
|
+
agent: z.string().max(128),
|
|
26
|
+
category: z.enum(["core", "parallel", "sequenced"]),
|
|
27
|
+
findings: z.array(reviewFindingSchema).max(500).default([]),
|
|
28
|
+
durationMs: z.number(),
|
|
29
|
+
completedAt: z.string().max(128),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const reviewReportSchema = z.object({
|
|
33
|
+
verdict: verdictSchema,
|
|
34
|
+
findings: z.array(reviewFindingSchema).max(500),
|
|
35
|
+
agentResults: z.array(agentResultSchema).max(500),
|
|
36
|
+
scope: z.string().max(128).default("unknown"),
|
|
37
|
+
agentsRan: z.array(z.string().max(128)).max(32).default([]),
|
|
38
|
+
totalDurationMs: z.number(),
|
|
39
|
+
completedAt: z.string().max(128),
|
|
40
|
+
summary: z.string().max(4096),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const falsePositiveSchema = z.object({
|
|
44
|
+
finding: reviewFindingSchema,
|
|
45
|
+
reason: z.string().max(1024),
|
|
46
|
+
markedAt: z.string().max(128),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const reviewMemorySchema = z.object({
|
|
50
|
+
schemaVersion: z.literal(1),
|
|
51
|
+
projectProfile: z.object({
|
|
52
|
+
stacks: z.array(z.string().max(128)).max(32),
|
|
53
|
+
lastDetectedAt: z.string().max(128),
|
|
54
|
+
}),
|
|
55
|
+
recentFindings: z.array(reviewFindingSchema).max(100),
|
|
56
|
+
falsePositives: z.array(falsePositiveSchema).max(50),
|
|
57
|
+
lastReviewedAt: z.string().max(128).nullable(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const reviewStateSchema = z.object({
|
|
61
|
+
stage: z.number().int().min(1).max(5),
|
|
62
|
+
selectedAgentNames: z.array(z.string().max(128)).max(32),
|
|
63
|
+
accumulatedFindings: z.array(reviewFindingSchema).max(500),
|
|
64
|
+
scope: z.string().max(4096),
|
|
65
|
+
startedAt: z.string().max(128),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const reviewConfigSchema = z.object({
|
|
69
|
+
parallel: z.boolean().default(true),
|
|
70
|
+
maxFixAttempts: z.number().int().min(0).max(10).default(3),
|
|
71
|
+
severityThreshold: severitySchema.default("MEDIUM"),
|
|
72
|
+
enableCrossVerification: z.boolean().default(true),
|
|
73
|
+
enableRedTeam: z.boolean().default(true),
|
|
74
|
+
enableProductReview: z.boolean().default(true),
|
|
75
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-pass deterministic agent selection for the review pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Pass 1: Stack gate -- agents with empty relevantStacks always pass;
|
|
5
|
+
* agents with non-empty relevantStacks require at least one match.
|
|
6
|
+
* Pass 2: Diff relevance scoring -- currently used for future ordering,
|
|
7
|
+
* all stack-passing agents run regardless of score.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Minimal agent shape needed for selection (compatible with ReviewAgent from agents/). */
|
|
11
|
+
interface SelectableAgent {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly prompt: string;
|
|
14
|
+
readonly relevantStacks: readonly string[];
|
|
15
|
+
readonly [key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Analysis of changed files (simplified DiffAnalysis). */
|
|
19
|
+
export interface DiffAnalysisInput {
|
|
20
|
+
readonly hasTests: boolean;
|
|
21
|
+
readonly hasAuth: boolean;
|
|
22
|
+
readonly hasConfig: boolean;
|
|
23
|
+
readonly fileCount: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SelectionResult {
|
|
27
|
+
readonly selected: readonly SelectableAgent[];
|
|
28
|
+
readonly excluded: readonly { readonly agent: string; readonly reason: string }[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Deterministic two-pass agent selection.
|
|
33
|
+
*
|
|
34
|
+
* @param detectedStacks - Stack tags detected in the project (e.g., ["node", "typescript"])
|
|
35
|
+
* @param diffAnalysis - Analysis of changed files
|
|
36
|
+
* @param agents - All candidate agents
|
|
37
|
+
* @returns Frozen SelectionResult with selected and excluded lists
|
|
38
|
+
*/
|
|
39
|
+
export function selectAgents(
|
|
40
|
+
detectedStacks: readonly string[],
|
|
41
|
+
diffAnalysis: DiffAnalysisInput,
|
|
42
|
+
agents: readonly SelectableAgent[],
|
|
43
|
+
): SelectionResult {
|
|
44
|
+
const stackSet = new Set(detectedStacks);
|
|
45
|
+
const selected: SelectableAgent[] = [];
|
|
46
|
+
const excluded: { readonly agent: string; readonly reason: string }[] = [];
|
|
47
|
+
|
|
48
|
+
for (const agent of agents) {
|
|
49
|
+
// Pass 1: Stack gate
|
|
50
|
+
if (agent.relevantStacks.length === 0) {
|
|
51
|
+
// Universal agent -- always passes
|
|
52
|
+
selected.push(agent);
|
|
53
|
+
} else if (agent.relevantStacks.some((s) => stackSet.has(s))) {
|
|
54
|
+
// Gated agent with at least one matching stack
|
|
55
|
+
selected.push(agent);
|
|
56
|
+
} else {
|
|
57
|
+
// Gated agent with no matching stack
|
|
58
|
+
const stackList = detectedStacks.length > 0 ? detectedStacks.join(", ") : "none";
|
|
59
|
+
excluded.push(
|
|
60
|
+
Object.freeze({
|
|
61
|
+
agent: agent.name,
|
|
62
|
+
reason: `Stack gate: ${agent.relevantStacks.join(", ")} not in [${stackList}]`,
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Pass 2: Compute relevance scores (stored for future ordering, no filtering)
|
|
69
|
+
// Scores are intentionally not used for filtering yet
|
|
70
|
+
for (const agent of selected) {
|
|
71
|
+
computeDiffRelevance(agent, diffAnalysis);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return Object.freeze({
|
|
75
|
+
selected: Object.freeze(selected),
|
|
76
|
+
excluded: Object.freeze(excluded),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Compute diff-based relevance score for an agent.
|
|
82
|
+
* Base score of 1.0 with bonuses for specific agent-analysis matches.
|
|
83
|
+
* Used for future prioritization/ordering, not for filtering.
|
|
84
|
+
*/
|
|
85
|
+
export function computeDiffRelevance(agent: SelectableAgent, analysis: DiffAnalysisInput): number {
|
|
86
|
+
let score = 1.0;
|
|
87
|
+
|
|
88
|
+
if (agent.name === "security-auditor") {
|
|
89
|
+
if (analysis.hasAuth) score += 0.5;
|
|
90
|
+
if (analysis.hasConfig) score += 0.3;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (agent.name === "test-interrogator") {
|
|
94
|
+
if (!analysis.hasTests) score += 0.5;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return score;
|
|
98
|
+
}
|