@kody-ade/kody-engine-lite 0.1.63 → 0.1.65
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/dist/agent-runner.d.ts +4 -0
- package/dist/agent-runner.js +122 -0
- package/dist/bin/cli.js +162 -8
- package/dist/ci/parse-inputs.d.ts +6 -0
- package/dist/ci/parse-inputs.js +76 -0
- package/dist/ci/parse-safety.d.ts +6 -0
- package/dist/ci/parse-safety.js +22 -0
- package/dist/cli/args.d.ts +13 -0
- package/dist/cli/args.js +42 -0
- package/dist/cli/litellm.d.ts +2 -0
- package/dist/cli/litellm.js +85 -0
- package/dist/cli/task-resolution.d.ts +2 -0
- package/dist/cli/task-resolution.js +41 -0
- package/dist/config.d.ts +49 -0
- package/dist/config.js +72 -0
- package/dist/context.d.ts +4 -0
- package/dist/context.js +83 -0
- package/dist/definitions.d.ts +3 -0
- package/dist/definitions.js +59 -0
- package/dist/entry.d.ts +1 -0
- package/dist/entry.js +236 -0
- package/dist/git-utils.d.ts +13 -0
- package/dist/git-utils.js +174 -0
- package/dist/github-api.d.ts +14 -0
- package/dist/github-api.js +114 -0
- package/dist/kody-utils.d.ts +1 -0
- package/dist/kody-utils.js +9 -0
- package/dist/learning/auto-learn.d.ts +2 -0
- package/dist/learning/auto-learn.js +169 -0
- package/dist/logger.d.ts +14 -0
- package/dist/logger.js +51 -0
- package/dist/memory.d.ts +1 -0
- package/dist/memory.js +20 -0
- package/dist/observer.d.ts +9 -0
- package/dist/observer.js +80 -0
- package/dist/pipeline/complexity.d.ts +3 -0
- package/dist/pipeline/complexity.js +12 -0
- package/dist/pipeline/executor-registry.d.ts +3 -0
- package/dist/pipeline/executor-registry.js +20 -0
- package/dist/pipeline/hooks.d.ts +17 -0
- package/dist/pipeline/hooks.js +110 -0
- package/dist/pipeline/questions.d.ts +2 -0
- package/dist/pipeline/questions.js +44 -0
- package/dist/pipeline/runner-selection.d.ts +2 -0
- package/dist/pipeline/runner-selection.js +13 -0
- package/dist/pipeline/state.d.ts +4 -0
- package/dist/pipeline/state.js +37 -0
- package/dist/pipeline.d.ts +3 -0
- package/dist/pipeline.js +213 -0
- package/dist/preflight.d.ts +1 -0
- package/dist/preflight.js +69 -0
- package/dist/retrospective.d.ts +26 -0
- package/dist/retrospective.js +211 -0
- package/dist/stages/agent.d.ts +2 -0
- package/dist/stages/agent.js +94 -0
- package/dist/stages/gate.d.ts +2 -0
- package/dist/stages/gate.js +32 -0
- package/dist/stages/review.d.ts +2 -0
- package/dist/stages/review.js +32 -0
- package/dist/stages/ship.d.ts +3 -0
- package/dist/stages/ship.js +154 -0
- package/dist/stages/verify.d.ts +2 -0
- package/dist/stages/verify.js +94 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.js +1 -0
- package/dist/validators.d.ts +8 -0
- package/dist/validators.js +42 -0
- package/dist/verify-runner.d.ts +11 -0
- package/dist/verify-runner.js +110 -0
- package/kody.config.schema.json +66 -0
- package/package.json +8 -9
- package/prompts/taskify.md +5 -0
- package/templates/kody.yml +6 -1
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { STAGES } from "./definitions.js";
|
|
4
|
+
import { resolveModel } from "./context.js";
|
|
5
|
+
import { getRunnerForStage } from "./pipeline/runner-selection.js";
|
|
6
|
+
import { logger } from "./logger.js";
|
|
7
|
+
// ─── Prompt ─────────────────────────────────────────────────────────────────
|
|
8
|
+
const RETROSPECTIVE_PROMPT = `You are a pipeline retrospective analyst. You observe automated software development pipeline runs and identify flaws, patterns, and improvement opportunities.
|
|
9
|
+
|
|
10
|
+
Output ONLY valid JSON. No markdown fences. No explanation.
|
|
11
|
+
|
|
12
|
+
{
|
|
13
|
+
"observation": "One paragraph: what happened in this run, what went well, what went wrong",
|
|
14
|
+
"patternMatch": "If this matches a pattern seen in previous runs, describe the pattern. Otherwise null",
|
|
15
|
+
"suggestion": "One specific, actionable change to improve pipeline reliability or efficiency",
|
|
16
|
+
"pipelineFlaw": {
|
|
17
|
+
"component": "pipeline component name (e.g., verify, build, autofix, taskify prompt, review prompt, model selection, timeout config)",
|
|
18
|
+
"issue": "concise description of the flaw",
|
|
19
|
+
"evidence": "specific data from this run that supports this conclusion"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
If no pipeline flaw is detected, set "pipelineFlaw" to null.
|
|
24
|
+
|
|
25
|
+
`;
|
|
26
|
+
// ─── Context Collection ─────────────────────────────────────────────────────
|
|
27
|
+
function readArtifact(taskDir, filename, maxChars) {
|
|
28
|
+
const p = path.join(taskDir, filename);
|
|
29
|
+
if (!fs.existsSync(p))
|
|
30
|
+
return null;
|
|
31
|
+
try {
|
|
32
|
+
const content = fs.readFileSync(p, "utf-8");
|
|
33
|
+
return content.length > maxChars
|
|
34
|
+
? content.slice(0, maxChars) + "\n...(truncated)"
|
|
35
|
+
: content;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function computeStageDuration(stage) {
|
|
42
|
+
if (!stage.startedAt)
|
|
43
|
+
return undefined;
|
|
44
|
+
const end = stage.completedAt ?? new Date().toISOString();
|
|
45
|
+
return new Date(end).getTime() - new Date(stage.startedAt).getTime();
|
|
46
|
+
}
|
|
47
|
+
export function collectRunContext(ctx, state, pipelineStartTime) {
|
|
48
|
+
const durationMs = Date.now() - pipelineStartTime;
|
|
49
|
+
const lines = [];
|
|
50
|
+
lines.push(`## This Run`);
|
|
51
|
+
lines.push(`Task: ${state.taskId}`);
|
|
52
|
+
lines.push(`Outcome: ${state.state}`);
|
|
53
|
+
lines.push(`Duration: ${durationMs}ms (${Math.round(durationMs / 1000)}s)`);
|
|
54
|
+
lines.push(`Mode: ${ctx.input.mode}`);
|
|
55
|
+
lines.push(``);
|
|
56
|
+
// Stage results
|
|
57
|
+
lines.push(`### Stage Results`);
|
|
58
|
+
let failedStage;
|
|
59
|
+
for (const def of STAGES) {
|
|
60
|
+
const s = state.stages[def.name];
|
|
61
|
+
const duration = computeStageDuration(s);
|
|
62
|
+
const durationStr = duration != null ? `, ${duration}ms` : "";
|
|
63
|
+
const errorStr = s.error ? ` — ${s.error}` : "";
|
|
64
|
+
lines.push(`${def.name}: ${s.state} (${s.retries} retries${durationStr})${errorStr}`);
|
|
65
|
+
if (s.state === "failed" || s.state === "timeout") {
|
|
66
|
+
failedStage = def.name;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
lines.push(``);
|
|
70
|
+
// Artifacts summary (truncated)
|
|
71
|
+
const artifacts = [
|
|
72
|
+
["task.md", 300],
|
|
73
|
+
["task.json", 500],
|
|
74
|
+
["plan.md", 500],
|
|
75
|
+
["verify.md", 800],
|
|
76
|
+
["review.md", 500],
|
|
77
|
+
["ship.md", 300],
|
|
78
|
+
];
|
|
79
|
+
lines.push(`### Artifacts`);
|
|
80
|
+
for (const [filename, maxChars] of artifacts) {
|
|
81
|
+
const content = readArtifact(ctx.taskDir, filename, maxChars);
|
|
82
|
+
if (content) {
|
|
83
|
+
lines.push(`#### ${filename}`);
|
|
84
|
+
lines.push(content);
|
|
85
|
+
lines.push(``);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return lines.join("\n");
|
|
89
|
+
}
|
|
90
|
+
// ─── Previous Retrospectives ────────────────────────────────────────────────
|
|
91
|
+
function getLogPath(projectDir) {
|
|
92
|
+
return path.join(projectDir, ".kody", "memory", "observer-log.jsonl");
|
|
93
|
+
}
|
|
94
|
+
export function readPreviousRetrospectives(projectDir, limit = 10) {
|
|
95
|
+
const logPath = getLogPath(projectDir);
|
|
96
|
+
if (!fs.existsSync(logPath))
|
|
97
|
+
return [];
|
|
98
|
+
try {
|
|
99
|
+
const content = fs.readFileSync(logPath, "utf-8");
|
|
100
|
+
const lines = content.split("\n").filter(Boolean);
|
|
101
|
+
const entries = [];
|
|
102
|
+
// Take last N lines
|
|
103
|
+
const start = Math.max(0, lines.length - limit);
|
|
104
|
+
for (let i = start; i < lines.length; i++) {
|
|
105
|
+
try {
|
|
106
|
+
entries.push(JSON.parse(lines[i]));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Skip corrupt lines
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return entries;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function formatPreviousEntries(entries) {
|
|
119
|
+
if (entries.length === 0)
|
|
120
|
+
return "No previous runs recorded.";
|
|
121
|
+
return entries.map((e) => {
|
|
122
|
+
const failed = e.failedStage ? ` at ${e.failedStage}` : "";
|
|
123
|
+
const pattern = e.patternMatch ? `Pattern: ${e.patternMatch}` : "Pattern: none";
|
|
124
|
+
const flaw = e.pipelineFlaw ? ` | Flaw: ${e.pipelineFlaw.component} — ${e.pipelineFlaw.issue}` : "";
|
|
125
|
+
return `[${e.timestamp.slice(0, 10)}] ${e.taskId}: ${e.outcome}${failed} — "${e.observation.slice(0, 120)}"\n ${pattern} | Suggestion: "${e.suggestion}"${flaw}`;
|
|
126
|
+
}).join("\n");
|
|
127
|
+
}
|
|
128
|
+
// ─── Append ─────────────────────────────────────────────────────────────────
|
|
129
|
+
export function appendRetrospectiveEntry(projectDir, entry) {
|
|
130
|
+
const logPath = getLogPath(projectDir);
|
|
131
|
+
const dir = path.dirname(logPath);
|
|
132
|
+
if (!fs.existsSync(dir)) {
|
|
133
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
134
|
+
}
|
|
135
|
+
fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
|
|
136
|
+
}
|
|
137
|
+
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
138
|
+
export async function runRetrospective(ctx, state, pipelineStartTime) {
|
|
139
|
+
if (ctx.input.dryRun)
|
|
140
|
+
return;
|
|
141
|
+
try {
|
|
142
|
+
const durationMs = Date.now() - pipelineStartTime;
|
|
143
|
+
const runContext = collectRunContext(ctx, state, pipelineStartTime);
|
|
144
|
+
const previous = readPreviousRetrospectives(ctx.projectDir);
|
|
145
|
+
const previousText = formatPreviousEntries(previous);
|
|
146
|
+
const prompt = RETROSPECTIVE_PROMPT
|
|
147
|
+
+ `## Run Context\n${runContext}\n\n`
|
|
148
|
+
+ `## Previous Retrospectives (last ${previous.length} runs)\n${previousText}\n`;
|
|
149
|
+
const runner = getRunnerForStage(ctx, "taskify");
|
|
150
|
+
const model = resolveModel("cheap");
|
|
151
|
+
const result = await runner.run("retrospective", prompt, model, 30_000, "");
|
|
152
|
+
let observation = "Retrospective analysis unavailable";
|
|
153
|
+
let patternMatch = null;
|
|
154
|
+
let suggestion = "No suggestion";
|
|
155
|
+
let pipelineFlaw = null;
|
|
156
|
+
if (result.outcome === "completed" && result.output) {
|
|
157
|
+
const cleaned = result.output
|
|
158
|
+
.replace(/^```json\s*\n?/m, "")
|
|
159
|
+
.replace(/\n?```\s*$/m, "")
|
|
160
|
+
.trim();
|
|
161
|
+
try {
|
|
162
|
+
const parsed = JSON.parse(cleaned);
|
|
163
|
+
observation = parsed.observation ?? observation;
|
|
164
|
+
patternMatch = parsed.patternMatch ?? null;
|
|
165
|
+
suggestion = parsed.suggestion ?? suggestion;
|
|
166
|
+
if (parsed.pipelineFlaw && parsed.pipelineFlaw.component) {
|
|
167
|
+
pipelineFlaw = {
|
|
168
|
+
component: parsed.pipelineFlaw.component,
|
|
169
|
+
issue: parsed.pipelineFlaw.issue ?? "Unknown",
|
|
170
|
+
evidence: parsed.pipelineFlaw.evidence ?? "",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
logger.warn(" Retrospective: failed to parse LLM output");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Build deterministic fields
|
|
179
|
+
const stageResults = {};
|
|
180
|
+
let failedStage;
|
|
181
|
+
for (const def of STAGES) {
|
|
182
|
+
const s = state.stages[def.name];
|
|
183
|
+
stageResults[def.name] = {
|
|
184
|
+
state: s.state,
|
|
185
|
+
retries: s.retries,
|
|
186
|
+
durationMs: computeStageDuration(s),
|
|
187
|
+
error: s.error,
|
|
188
|
+
};
|
|
189
|
+
if (s.state === "failed" || s.state === "timeout") {
|
|
190
|
+
failedStage = def.name;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const entry = {
|
|
194
|
+
timestamp: new Date().toISOString(),
|
|
195
|
+
taskId: state.taskId,
|
|
196
|
+
outcome: state.state === "completed" ? "completed" : "failed",
|
|
197
|
+
durationMs,
|
|
198
|
+
stageResults,
|
|
199
|
+
failedStage,
|
|
200
|
+
observation,
|
|
201
|
+
patternMatch,
|
|
202
|
+
suggestion,
|
|
203
|
+
pipelineFlaw,
|
|
204
|
+
};
|
|
205
|
+
appendRetrospectiveEntry(ctx.projectDir, entry);
|
|
206
|
+
logger.info(`Retrospective: ${observation.slice(0, 120)}`);
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
logger.warn(`Retrospective failed: ${err instanceof Error ? err.message : err}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { buildFullPrompt, resolveModel } from "../context.js";
|
|
4
|
+
import { validateTaskJson, validatePlanMd, validateReviewMd, stripFences } from "../validators.js";
|
|
5
|
+
import { getProjectConfig } from "../config.js";
|
|
6
|
+
import { getRunnerForStage } from "../pipeline/runner-selection.js";
|
|
7
|
+
import { logger } from "../logger.js";
|
|
8
|
+
function validateStageOutput(stageName, content) {
|
|
9
|
+
switch (stageName) {
|
|
10
|
+
case "taskify":
|
|
11
|
+
return validateTaskJson(content);
|
|
12
|
+
case "plan":
|
|
13
|
+
return validatePlanMd(content);
|
|
14
|
+
case "review":
|
|
15
|
+
return validateReviewMd(content);
|
|
16
|
+
default:
|
|
17
|
+
return { valid: true };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function executeAgentStage(ctx, def) {
|
|
21
|
+
if (ctx.input.dryRun) {
|
|
22
|
+
logger.info(` [dry-run] skipping ${def.name}`);
|
|
23
|
+
return { outcome: "completed", retries: 0 };
|
|
24
|
+
}
|
|
25
|
+
const prompt = buildFullPrompt(def.name, ctx.taskId, ctx.taskDir, ctx.projectDir, ctx.input.feedback);
|
|
26
|
+
const model = resolveModel(def.modelTier, def.name);
|
|
27
|
+
const config = getProjectConfig();
|
|
28
|
+
const runnerName = config.agent.stageRunners?.[def.name] ??
|
|
29
|
+
config.agent.defaultRunner ??
|
|
30
|
+
Object.keys(ctx.runners)[0] ?? "claude";
|
|
31
|
+
logger.info(` runner=${runnerName} model=${model} timeout=${def.timeout / 1000}s`);
|
|
32
|
+
const extraEnv = {};
|
|
33
|
+
if (config.agent.litellmUrl) {
|
|
34
|
+
extraEnv.ANTHROPIC_BASE_URL = config.agent.litellmUrl;
|
|
35
|
+
}
|
|
36
|
+
const runner = getRunnerForStage(ctx, def.name);
|
|
37
|
+
const result = await runner.run(def.name, prompt, model, def.timeout, ctx.taskDir, {
|
|
38
|
+
cwd: ctx.projectDir,
|
|
39
|
+
env: extraEnv,
|
|
40
|
+
});
|
|
41
|
+
if (result.outcome !== "completed") {
|
|
42
|
+
return { outcome: result.outcome, error: result.error, retries: 0 };
|
|
43
|
+
}
|
|
44
|
+
if (def.outputFile && result.output) {
|
|
45
|
+
fs.writeFileSync(path.join(ctx.taskDir, def.outputFile), result.output);
|
|
46
|
+
}
|
|
47
|
+
// Variant file detection: if expected file missing, look for <base>-*<ext> variants
|
|
48
|
+
if (def.outputFile) {
|
|
49
|
+
const outputPath = path.join(ctx.taskDir, def.outputFile);
|
|
50
|
+
if (!fs.existsSync(outputPath)) {
|
|
51
|
+
const ext = path.extname(def.outputFile);
|
|
52
|
+
const base = path.basename(def.outputFile, ext);
|
|
53
|
+
const files = fs.readdirSync(ctx.taskDir);
|
|
54
|
+
const variant = files.find((f) => f.startsWith(base + "-") && f.endsWith(ext));
|
|
55
|
+
if (variant) {
|
|
56
|
+
fs.renameSync(path.join(ctx.taskDir, variant), outputPath);
|
|
57
|
+
logger.info(` Renamed variant ${variant} → ${def.outputFile}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (def.outputFile) {
|
|
62
|
+
const outputPath = path.join(ctx.taskDir, def.outputFile);
|
|
63
|
+
if (fs.existsSync(outputPath)) {
|
|
64
|
+
const content = fs.readFileSync(outputPath, "utf-8");
|
|
65
|
+
const validation = validateStageOutput(def.name, content);
|
|
66
|
+
if (!validation.valid) {
|
|
67
|
+
// Taskify must produce valid JSON — retry once with stricter prompt
|
|
68
|
+
if (def.name === "taskify") {
|
|
69
|
+
logger.warn(` taskify output invalid (${validation.error}), retrying...`);
|
|
70
|
+
const retryPrompt = prompt + "\n\nIMPORTANT: Your previous output was not valid JSON. Output ONLY the raw JSON object. No markdown, no fences, no explanation.";
|
|
71
|
+
const retryResult = await runner.run(def.name, retryPrompt, model, def.timeout, ctx.taskDir, {
|
|
72
|
+
cwd: ctx.projectDir,
|
|
73
|
+
env: extraEnv,
|
|
74
|
+
});
|
|
75
|
+
if (retryResult.outcome === "completed" && retryResult.output) {
|
|
76
|
+
const stripped = stripFences(retryResult.output);
|
|
77
|
+
const retryValidation = validateTaskJson(stripped);
|
|
78
|
+
if (retryValidation.valid) {
|
|
79
|
+
fs.writeFileSync(outputPath, retryResult.output);
|
|
80
|
+
logger.info(` taskify retry produced valid JSON`);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
logger.warn(` taskify retry still invalid: ${retryValidation.error}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
logger.warn(` validation warning: ${validation.error}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { outcome: "completed", outputFile: def.outputFile, retries: 0 };
|
|
94
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { runQualityGates } from "../verify-runner.js";
|
|
4
|
+
import { logger } from "../logger.js";
|
|
5
|
+
export function executeGateStage(ctx, def) {
|
|
6
|
+
if (ctx.input.dryRun) {
|
|
7
|
+
logger.info(` [dry-run] skipping ${def.name}`);
|
|
8
|
+
return { outcome: "completed", retries: 0 };
|
|
9
|
+
}
|
|
10
|
+
const verifyResult = runQualityGates(ctx.taskDir, ctx.projectDir);
|
|
11
|
+
const lines = [
|
|
12
|
+
`# Verification Report\n`,
|
|
13
|
+
`## Result: ${verifyResult.pass ? "PASS" : "FAIL"}\n`,
|
|
14
|
+
];
|
|
15
|
+
if (verifyResult.errors.length > 0) {
|
|
16
|
+
lines.push(`\n## Errors\n`);
|
|
17
|
+
for (const e of verifyResult.errors) {
|
|
18
|
+
lines.push(`- ${e}\n`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (verifyResult.summary.length > 0) {
|
|
22
|
+
lines.push(`\n## Summary\n`);
|
|
23
|
+
for (const s of verifyResult.summary) {
|
|
24
|
+
lines.push(`- ${s}\n`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
fs.writeFileSync(path.join(ctx.taskDir, "verify.md"), lines.join(""));
|
|
28
|
+
return {
|
|
29
|
+
outcome: verifyResult.pass ? "completed" : "failed",
|
|
30
|
+
retries: 0,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { STAGES } from "../definitions.js";
|
|
4
|
+
import { logger } from "../logger.js";
|
|
5
|
+
import { executeAgentStage } from "./agent.js";
|
|
6
|
+
export async function executeReviewWithFix(ctx, def) {
|
|
7
|
+
if (ctx.input.dryRun) {
|
|
8
|
+
return { outcome: "completed", retries: 0 };
|
|
9
|
+
}
|
|
10
|
+
const reviewDef = STAGES.find((s) => s.name === "review");
|
|
11
|
+
const reviewFixDef = STAGES.find((s) => s.name === "review-fix");
|
|
12
|
+
const reviewResult = await executeAgentStage(ctx, reviewDef);
|
|
13
|
+
if (reviewResult.outcome !== "completed") {
|
|
14
|
+
return reviewResult;
|
|
15
|
+
}
|
|
16
|
+
const reviewFile = path.join(ctx.taskDir, "review.md");
|
|
17
|
+
if (!fs.existsSync(reviewFile)) {
|
|
18
|
+
return { outcome: "failed", retries: 0, error: "review.md not found" };
|
|
19
|
+
}
|
|
20
|
+
const content = fs.readFileSync(reviewFile, "utf-8");
|
|
21
|
+
const hasIssues = /\bfail\b/i.test(content) && !/pass/i.test(content);
|
|
22
|
+
if (!hasIssues) {
|
|
23
|
+
return reviewResult;
|
|
24
|
+
}
|
|
25
|
+
logger.info(` review found issues, running review-fix...`);
|
|
26
|
+
const fixResult = await executeAgentStage(ctx, reviewFixDef);
|
|
27
|
+
if (fixResult.outcome !== "completed") {
|
|
28
|
+
return fixResult;
|
|
29
|
+
}
|
|
30
|
+
logger.info(` re-running review after fix...`);
|
|
31
|
+
return executeAgentStage(ctx, reviewDef);
|
|
32
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { execFileSync } from "child_process";
|
|
4
|
+
import { getCurrentBranch, getDefaultBranch, pushBranch, } from "../git-utils.js";
|
|
5
|
+
import { postComment, createPR, } from "../github-api.js";
|
|
6
|
+
import { getProjectConfig } from "../config.js";
|
|
7
|
+
export function buildPrBody(ctx) {
|
|
8
|
+
const sections = [];
|
|
9
|
+
// What and why — from task.json
|
|
10
|
+
const taskJsonPath = path.join(ctx.taskDir, "task.json");
|
|
11
|
+
if (fs.existsSync(taskJsonPath)) {
|
|
12
|
+
try {
|
|
13
|
+
const raw = fs.readFileSync(taskJsonPath, "utf-8");
|
|
14
|
+
const cleaned = raw.replace(/^```json\s*\n?/m, "").replace(/\n?```\s*$/m, "");
|
|
15
|
+
const task = JSON.parse(cleaned);
|
|
16
|
+
if (task.description) {
|
|
17
|
+
sections.push(`## What\n\n${task.description}`);
|
|
18
|
+
}
|
|
19
|
+
if (task.scope?.length) {
|
|
20
|
+
sections.push(`\n## Scope\n\n${task.scope.map((s) => `- \`${s}\``).join("\n")}`);
|
|
21
|
+
}
|
|
22
|
+
sections.push(`\n**Type:** ${task.task_type ?? "unknown"} | **Risk:** ${task.risk_level ?? "unknown"}`);
|
|
23
|
+
}
|
|
24
|
+
catch { /* ignore parse errors */ }
|
|
25
|
+
}
|
|
26
|
+
// Changes — from review.md summary
|
|
27
|
+
const reviewPath = path.join(ctx.taskDir, "review.md");
|
|
28
|
+
if (fs.existsSync(reviewPath)) {
|
|
29
|
+
const review = fs.readFileSync(reviewPath, "utf-8");
|
|
30
|
+
const summaryMatch = review.match(/## Summary\s*\n([\s\S]*?)(?=\n## |\n*$)/);
|
|
31
|
+
if (summaryMatch) {
|
|
32
|
+
const summary = summaryMatch[1].trim();
|
|
33
|
+
if (summary) {
|
|
34
|
+
sections.push(`\n## Changes\n\n${summary}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const verdictMatch = review.match(/## Verdict:\s*(PASS|FAIL)/i);
|
|
38
|
+
if (verdictMatch) {
|
|
39
|
+
sections.push(`\n**Review:** ${verdictMatch[1].toUpperCase() === "PASS" ? "✅ PASS" : "❌ FAIL"}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Verify result
|
|
43
|
+
const verifyPath = path.join(ctx.taskDir, "verify.md");
|
|
44
|
+
if (fs.existsSync(verifyPath)) {
|
|
45
|
+
const verify = fs.readFileSync(verifyPath, "utf-8");
|
|
46
|
+
if (/PASS/i.test(verify))
|
|
47
|
+
sections.push(`**Verify:** ✅ typecheck + tests + lint passed`);
|
|
48
|
+
}
|
|
49
|
+
// Plan — collapsible details
|
|
50
|
+
const planPath = path.join(ctx.taskDir, "plan.md");
|
|
51
|
+
if (fs.existsSync(planPath)) {
|
|
52
|
+
const plan = fs.readFileSync(planPath, "utf-8").trim();
|
|
53
|
+
if (plan) {
|
|
54
|
+
const truncated = plan.length > 800 ? plan.slice(0, 800) + "\n..." : plan;
|
|
55
|
+
sections.push(`\n<details><summary>📋 Implementation plan</summary>\n\n${truncated}\n</details>`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Closes issue
|
|
59
|
+
if (ctx.input.issueNumber) {
|
|
60
|
+
sections.push(`\nCloses #${ctx.input.issueNumber}`);
|
|
61
|
+
}
|
|
62
|
+
sections.push(`\n---\n🤖 Generated by Kody`);
|
|
63
|
+
return sections.join("\n");
|
|
64
|
+
}
|
|
65
|
+
export function executeShipStage(ctx, _def) {
|
|
66
|
+
const shipPath = path.join(ctx.taskDir, "ship.md");
|
|
67
|
+
if (ctx.input.dryRun) {
|
|
68
|
+
fs.writeFileSync(shipPath, "# Ship\n\nShip stage skipped — dry run.\n");
|
|
69
|
+
return { outcome: "completed", outputFile: "ship.md", retries: 0 };
|
|
70
|
+
}
|
|
71
|
+
// Local mode or no issue: skip git push + PR
|
|
72
|
+
if (ctx.input.local && !ctx.input.issueNumber) {
|
|
73
|
+
fs.writeFileSync(shipPath, "# Ship\n\nShip stage skipped — local mode, no issue number.\n");
|
|
74
|
+
return { outcome: "completed", outputFile: "ship.md", retries: 0 };
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const head = getCurrentBranch(ctx.projectDir);
|
|
78
|
+
const base = getDefaultBranch(ctx.projectDir);
|
|
79
|
+
pushBranch(ctx.projectDir);
|
|
80
|
+
// Resolve owner/repo
|
|
81
|
+
const config = getProjectConfig();
|
|
82
|
+
let owner = config.github?.owner;
|
|
83
|
+
let repo = config.github?.repo;
|
|
84
|
+
if (!owner || !repo) {
|
|
85
|
+
try {
|
|
86
|
+
const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
87
|
+
encoding: "utf-8",
|
|
88
|
+
cwd: ctx.projectDir,
|
|
89
|
+
}).trim();
|
|
90
|
+
const match = remoteUrl.match(/github\.com[/:]([^/]+)\/([^/.]+)/);
|
|
91
|
+
if (match) {
|
|
92
|
+
owner = match[1];
|
|
93
|
+
repo = match[2];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Can't determine repo
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Derive PR title from task.json (preferred) or task.md (fallback)
|
|
101
|
+
let title = "Update";
|
|
102
|
+
const TYPE_PREFIX = {
|
|
103
|
+
feature: "feat",
|
|
104
|
+
bugfix: "fix",
|
|
105
|
+
refactor: "refactor",
|
|
106
|
+
docs: "docs",
|
|
107
|
+
chore: "chore",
|
|
108
|
+
};
|
|
109
|
+
const taskJsonPath = path.join(ctx.taskDir, "task.json");
|
|
110
|
+
if (fs.existsSync(taskJsonPath)) {
|
|
111
|
+
try {
|
|
112
|
+
const raw = fs.readFileSync(taskJsonPath, "utf-8");
|
|
113
|
+
const cleaned = raw.replace(/^```json\s*\n?/m, "").replace(/\n?```\s*$/m, "");
|
|
114
|
+
const task = JSON.parse(cleaned);
|
|
115
|
+
const prefix = TYPE_PREFIX[task.task_type] ?? "chore";
|
|
116
|
+
const taskTitle = task.title ?? "Update";
|
|
117
|
+
title = `${prefix}: ${taskTitle}`.slice(0, 72);
|
|
118
|
+
}
|
|
119
|
+
catch { /* fallback below */ }
|
|
120
|
+
}
|
|
121
|
+
if (title === "Update") {
|
|
122
|
+
const taskMdPath = path.join(ctx.taskDir, "task.md");
|
|
123
|
+
if (fs.existsSync(taskMdPath)) {
|
|
124
|
+
const content = fs.readFileSync(taskMdPath, "utf-8");
|
|
125
|
+
const firstLine = content.split("\n").find((l) => l.trim() && !l.startsWith("#") && !l.startsWith("*"));
|
|
126
|
+
if (firstLine)
|
|
127
|
+
title = `chore: ${firstLine.trim()}`.slice(0, 72);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Build rich PR body
|
|
131
|
+
const body = buildPrBody(ctx);
|
|
132
|
+
const pr = createPR(head, base, title, body);
|
|
133
|
+
if (pr) {
|
|
134
|
+
if (ctx.input.issueNumber && !ctx.input.local) {
|
|
135
|
+
try {
|
|
136
|
+
postComment(ctx.input.issueNumber, `🎉 PR created: ${pr.url}`);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Fire and forget
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
fs.writeFileSync(shipPath, `# Ship\n\nPR created: ${pr.url}\nPR #${pr.number}\n`);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
fs.writeFileSync(shipPath, "# Ship\n\nPushed branch but failed to create PR.\n");
|
|
146
|
+
}
|
|
147
|
+
return { outcome: "completed", outputFile: "ship.md", retries: 0 };
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
151
|
+
fs.writeFileSync(shipPath, `# Ship\n\nFailed: ${msg}\n`);
|
|
152
|
+
return { outcome: "failed", retries: 0, error: msg };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { execFileSync } from "child_process";
|
|
4
|
+
import { resolveModel } from "../context.js";
|
|
5
|
+
import { getProjectConfig, FIX_COMMAND_TIMEOUT_MS } from "../config.js";
|
|
6
|
+
import { parseCommand } from "../verify-runner.js";
|
|
7
|
+
import { getRunnerForStage } from "../pipeline/runner-selection.js";
|
|
8
|
+
import { postComment } from "../github-api.js";
|
|
9
|
+
import { diagnoseFailure, getModifiedFiles } from "../observer.js";
|
|
10
|
+
import { logger } from "../logger.js";
|
|
11
|
+
import { executeAgentStage } from "./agent.js";
|
|
12
|
+
import { executeGateStage } from "./gate.js";
|
|
13
|
+
export async function executeVerifyWithAutofix(ctx, def) {
|
|
14
|
+
const maxAttempts = def.maxRetries ?? 2;
|
|
15
|
+
for (let attempt = 0; attempt <= maxAttempts; attempt++) {
|
|
16
|
+
logger.info(` verification attempt ${attempt + 1}/${maxAttempts + 1}`);
|
|
17
|
+
const gateResult = executeGateStage(ctx, def);
|
|
18
|
+
if (gateResult.outcome === "completed") {
|
|
19
|
+
return { ...gateResult, retries: attempt };
|
|
20
|
+
}
|
|
21
|
+
if (attempt < maxAttempts) {
|
|
22
|
+
// Read verify errors for diagnosis
|
|
23
|
+
const verifyPath = path.join(ctx.taskDir, "verify.md");
|
|
24
|
+
const errorOutput = fs.existsSync(verifyPath) ? fs.readFileSync(verifyPath, "utf-8") : "Unknown error";
|
|
25
|
+
// AI diagnosis — classify the failure
|
|
26
|
+
const modifiedFiles = getModifiedFiles(ctx.projectDir);
|
|
27
|
+
const defaultRunner = getRunnerForStage(ctx, "taskify"); // use cheap model
|
|
28
|
+
const diagnosis = await diagnoseFailure("verify", errorOutput, modifiedFiles, defaultRunner, resolveModel("cheap"));
|
|
29
|
+
if (diagnosis.classification === "infrastructure") {
|
|
30
|
+
logger.warn(` Infrastructure issue: ${diagnosis.reason}`);
|
|
31
|
+
if (ctx.input.issueNumber && !ctx.input.local) {
|
|
32
|
+
try {
|
|
33
|
+
postComment(ctx.input.issueNumber, `⚠️ **Infrastructure issue detected:** ${diagnosis.reason}\n\n${diagnosis.resolution}`);
|
|
34
|
+
}
|
|
35
|
+
catch { /* fire-and-forget */ }
|
|
36
|
+
}
|
|
37
|
+
return { outcome: "completed", retries: attempt, error: `Skipped: ${diagnosis.reason}` };
|
|
38
|
+
}
|
|
39
|
+
if (diagnosis.classification === "pre-existing") {
|
|
40
|
+
logger.warn(` Pre-existing issue: ${diagnosis.reason}`);
|
|
41
|
+
return { outcome: "completed", retries: attempt, error: `Skipped: ${diagnosis.reason}` };
|
|
42
|
+
}
|
|
43
|
+
if (diagnosis.classification === "abort") {
|
|
44
|
+
logger.error(` Unrecoverable: ${diagnosis.reason}`);
|
|
45
|
+
return { outcome: "failed", retries: attempt, error: diagnosis.reason };
|
|
46
|
+
}
|
|
47
|
+
// fixable or retry — proceed with autofix
|
|
48
|
+
logger.info(` Diagnosis: ${diagnosis.classification} — ${diagnosis.reason}`);
|
|
49
|
+
const config = getProjectConfig();
|
|
50
|
+
const runFix = (cmd) => {
|
|
51
|
+
if (!cmd)
|
|
52
|
+
return;
|
|
53
|
+
const parts = parseCommand(cmd);
|
|
54
|
+
if (parts.length === 0)
|
|
55
|
+
return;
|
|
56
|
+
try {
|
|
57
|
+
execFileSync(parts[0], parts.slice(1), {
|
|
58
|
+
stdio: "pipe",
|
|
59
|
+
timeout: FIX_COMMAND_TIMEOUT_MS,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Silently ignore fix failures
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
runFix(config.quality.lintFix);
|
|
67
|
+
runFix(config.quality.formatFix);
|
|
68
|
+
if (def.retryWithAgent) {
|
|
69
|
+
// Create new context with diagnosis guidance — don't mutate original
|
|
70
|
+
const autofixCtx = {
|
|
71
|
+
...ctx,
|
|
72
|
+
input: {
|
|
73
|
+
...ctx.input,
|
|
74
|
+
feedback: `${diagnosis.resolution}\n\n${ctx.input.feedback ?? ""}`.trim(),
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
logger.info(` running ${def.retryWithAgent} agent with diagnosis guidance...`);
|
|
78
|
+
await executeAgentStage(autofixCtx, {
|
|
79
|
+
...def,
|
|
80
|
+
name: def.retryWithAgent,
|
|
81
|
+
type: "agent",
|
|
82
|
+
modelTier: "mid",
|
|
83
|
+
timeout: 300_000,
|
|
84
|
+
outputFile: undefined,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
outcome: "failed",
|
|
91
|
+
retries: maxAttempts,
|
|
92
|
+
error: "Verification failed after autofix attempts",
|
|
93
|
+
};
|
|
94
|
+
}
|