@kody-ade/engine 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 +322 -0
- package/dist/agent-runner.d.ts +4 -0
- package/dist/agent-runner.js +122 -0
- package/dist/bin/cli.js +11276 -0
- 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 +299 -0
- package/package.json +39 -0
- package/prompts/autofix.md +52 -0
- package/prompts/build.md +26 -0
- package/prompts/decompose.md +77 -0
- package/prompts/plan.md +65 -0
- package/prompts/review-fix.md +27 -0
- package/prompts/review.md +115 -0
- package/prompts/taskify-ticket.md +122 -0
- package/prompts/taskify.md +70 -0
- package/templates/kody-watch.yml +57 -0
- package/templates/kody.yml +450 -0
- package/templates/watch-agents/branch-cleanup/agent.json +7 -0
- package/templates/watch-agents/branch-cleanup/agent.md +13 -0
- package/templates/watch-agents/dependency-checker/agent.json +7 -0
- package/templates/watch-agents/dependency-checker/agent.md +14 -0
- package/templates/watch-agents/readme-health/agent.json +7 -0
- package/templates/watch-agents/readme-health/agent.md +17 -0
- package/templates/watch-agents/stale-pr-reviewer/agent.json +7 -0
- package/templates/watch-agents/stale-pr-reviewer/agent.md +13 -0
- package/templates/watch-agents/todo-scanner/agent.json +7 -0
- package/templates/watch-agents/todo-scanner/agent.md +10 -0
package/dist/pipeline.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { STAGES } from "./definitions.js";
|
|
4
|
+
import { ensureFeatureBranch, syncWithDefault } from "./git-utils.js";
|
|
5
|
+
import { setLifecycleLabel } from "./github-api.js";
|
|
6
|
+
import { logger, ciGroup, ciGroupEnd } from "./logger.js";
|
|
7
|
+
import { loadState, writeState, initState } from "./pipeline/state.js";
|
|
8
|
+
import { filterByComplexity } from "./pipeline/complexity.js";
|
|
9
|
+
import { getExecutor } from "./pipeline/executor-registry.js";
|
|
10
|
+
import { applyPreStageLabel, checkQuestionsAfterStage, autoDetectComplexity, commitAfterStage, postSkippedStagesComment, } from "./pipeline/hooks.js";
|
|
11
|
+
import { autoLearn } from "./learning/auto-learn.js";
|
|
12
|
+
import { runRetrospective } from "./retrospective.js";
|
|
13
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
14
|
+
function ensureFeatureBranchIfNeeded(ctx) {
|
|
15
|
+
if (!ctx.input.issueNumber || ctx.input.dryRun)
|
|
16
|
+
return;
|
|
17
|
+
try {
|
|
18
|
+
const taskMdPath = path.join(ctx.taskDir, "task.md");
|
|
19
|
+
const title = fs.existsSync(taskMdPath)
|
|
20
|
+
? fs.readFileSync(taskMdPath, "utf-8").split("\n")[0].slice(0, 50)
|
|
21
|
+
: ctx.taskId;
|
|
22
|
+
ensureFeatureBranch(ctx.input.issueNumber, title, ctx.projectDir);
|
|
23
|
+
syncWithDefault(ctx.projectDir);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
logger.warn(` Failed to create/sync feature branch: ${err}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// ─── Lock ───────────────────────────────────────────────────────────────────
|
|
30
|
+
function acquireLock(taskDir) {
|
|
31
|
+
const lockPath = path.join(taskDir, ".lock");
|
|
32
|
+
if (fs.existsSync(lockPath)) {
|
|
33
|
+
try {
|
|
34
|
+
const pid = parseInt(fs.readFileSync(lockPath, "utf-8").trim(), 10);
|
|
35
|
+
try {
|
|
36
|
+
process.kill(pid, 0);
|
|
37
|
+
throw new Error(`Pipeline already running (PID ${pid})`);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
if (e.code !== "ESRCH")
|
|
41
|
+
throw e;
|
|
42
|
+
// PID not alive — stale lock, safe to overwrite
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
if (e instanceof Error && e.message.startsWith("Pipeline already"))
|
|
47
|
+
throw e;
|
|
48
|
+
// Corrupt lock file — overwrite
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
fs.writeFileSync(lockPath, String(process.pid));
|
|
52
|
+
}
|
|
53
|
+
function releaseLock(taskDir) {
|
|
54
|
+
try {
|
|
55
|
+
fs.unlinkSync(path.join(taskDir, ".lock"));
|
|
56
|
+
}
|
|
57
|
+
catch { /* ignore */ }
|
|
58
|
+
}
|
|
59
|
+
// ─── Pipeline Loop ──────────────────────────────────────────────────────────
|
|
60
|
+
export async function runPipeline(ctx) {
|
|
61
|
+
acquireLock(ctx.taskDir);
|
|
62
|
+
try {
|
|
63
|
+
return await runPipelineInner(ctx);
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
releaseLock(ctx.taskDir);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function runPipelineInner(ctx) {
|
|
70
|
+
const pipelineStartTime = Date.now();
|
|
71
|
+
let state = loadState(ctx.taskId, ctx.taskDir);
|
|
72
|
+
if (!state) {
|
|
73
|
+
state = initState(ctx.taskId);
|
|
74
|
+
writeState(state, ctx.taskDir);
|
|
75
|
+
}
|
|
76
|
+
// Reset for rerun
|
|
77
|
+
if (state.state !== "running") {
|
|
78
|
+
state.state = "running";
|
|
79
|
+
for (const stage of STAGES) {
|
|
80
|
+
const s = state.stages[stage.name];
|
|
81
|
+
if (s.state === "running" || s.state === "failed" || s.state === "timeout") {
|
|
82
|
+
state.stages[stage.name] = { ...s, state: "pending" };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
writeState(state, ctx.taskDir);
|
|
86
|
+
}
|
|
87
|
+
const fromStage = ctx.input.fromStage;
|
|
88
|
+
let startExecution = !fromStage;
|
|
89
|
+
logger.info(`Pipeline started: ${ctx.taskId}`);
|
|
90
|
+
logger.info(`Stages: ${STAGES.map((s) => s.name).join(" → ")}`);
|
|
91
|
+
if (fromStage)
|
|
92
|
+
logger.info(`Resuming from: ${fromStage}`);
|
|
93
|
+
if (ctx.input.issueNumber && !ctx.input.local) {
|
|
94
|
+
const initialPhase = ctx.input.mode === "rerun" ? "building" : "planning";
|
|
95
|
+
setLifecycleLabel(ctx.input.issueNumber, initialPhase);
|
|
96
|
+
}
|
|
97
|
+
ensureFeatureBranchIfNeeded(ctx);
|
|
98
|
+
let complexity = ctx.input.complexity ?? "high";
|
|
99
|
+
let activeStages = filterByComplexity(STAGES, complexity);
|
|
100
|
+
let skippedStagesCommentPosted = false;
|
|
101
|
+
for (const def of STAGES) {
|
|
102
|
+
// fromStage skip
|
|
103
|
+
if (!startExecution) {
|
|
104
|
+
if (def.name === fromStage) {
|
|
105
|
+
startExecution = true;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (state.stages[def.name].state === "completed") {
|
|
112
|
+
logger.info(`[${def.name}] already completed, skipping`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// Complexity skip
|
|
116
|
+
if (!activeStages.find((s) => s.name === def.name)) {
|
|
117
|
+
logger.info(`[${def.name}] skipped (complexity: ${complexity})`);
|
|
118
|
+
state.stages[def.name] = { state: "completed", retries: 0, outputFile: undefined };
|
|
119
|
+
writeState(state, ctx.taskDir);
|
|
120
|
+
if (!skippedStagesCommentPosted) {
|
|
121
|
+
postSkippedStagesComment(ctx, complexity, activeStages);
|
|
122
|
+
skippedStagesCommentPosted = true;
|
|
123
|
+
}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
ciGroup(`Stage: ${def.name}`);
|
|
127
|
+
state.stages[def.name] = { state: "running", startedAt: new Date().toISOString(), retries: 0 };
|
|
128
|
+
writeState(state, ctx.taskDir);
|
|
129
|
+
logger.info(`[${def.name}] starting...`);
|
|
130
|
+
applyPreStageLabel(ctx, def);
|
|
131
|
+
// Execute stage via registry
|
|
132
|
+
let result;
|
|
133
|
+
try {
|
|
134
|
+
result = await getExecutor(def.name)(ctx, def);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
result = {
|
|
138
|
+
outcome: "failed",
|
|
139
|
+
retries: 0,
|
|
140
|
+
error: error instanceof Error ? error.message : String(error),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
ciGroupEnd();
|
|
144
|
+
if (result.outcome === "completed") {
|
|
145
|
+
state.stages[def.name] = {
|
|
146
|
+
state: "completed",
|
|
147
|
+
completedAt: new Date().toISOString(),
|
|
148
|
+
retries: result.retries,
|
|
149
|
+
outputFile: result.outputFile,
|
|
150
|
+
};
|
|
151
|
+
logger.info(`[${def.name}] ✓ completed`);
|
|
152
|
+
const paused = checkQuestionsAfterStage(ctx, def, state);
|
|
153
|
+
if (paused)
|
|
154
|
+
return paused;
|
|
155
|
+
const detected = autoDetectComplexity(ctx, def);
|
|
156
|
+
if (detected) {
|
|
157
|
+
complexity = detected.complexity;
|
|
158
|
+
activeStages = detected.activeStages;
|
|
159
|
+
}
|
|
160
|
+
commitAfterStage(ctx, def);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
// Failed or timed out
|
|
164
|
+
const isTimeout = result.outcome === "timed_out";
|
|
165
|
+
state.stages[def.name] = {
|
|
166
|
+
state: isTimeout ? "timeout" : "failed",
|
|
167
|
+
retries: result.retries,
|
|
168
|
+
error: isTimeout ? "Stage timed out" : (result.error ?? "Stage failed"),
|
|
169
|
+
};
|
|
170
|
+
state.state = "failed";
|
|
171
|
+
writeState(state, ctx.taskDir);
|
|
172
|
+
logger.error(`[${def.name}] ${isTimeout ? "⏱ timed out" : `✗ failed: ${result.error}`}`);
|
|
173
|
+
if (ctx.input.issueNumber && !ctx.input.local) {
|
|
174
|
+
setLifecycleLabel(ctx.input.issueNumber, "failed");
|
|
175
|
+
}
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
writeState(state, ctx.taskDir);
|
|
179
|
+
}
|
|
180
|
+
const allCompleted = STAGES.every((s) => state.stages[s.name].state === "completed");
|
|
181
|
+
if (allCompleted) {
|
|
182
|
+
state.state = "completed";
|
|
183
|
+
writeState(state, ctx.taskDir);
|
|
184
|
+
logger.info(`Pipeline completed: ${ctx.taskId}`);
|
|
185
|
+
if (ctx.input.issueNumber && !ctx.input.local) {
|
|
186
|
+
setLifecycleLabel(ctx.input.issueNumber, "done");
|
|
187
|
+
}
|
|
188
|
+
autoLearn(ctx);
|
|
189
|
+
}
|
|
190
|
+
await runRetrospective(ctx, state, pipelineStartTime).catch(() => { });
|
|
191
|
+
return state;
|
|
192
|
+
}
|
|
193
|
+
// ─── Status Display ─────────────────────────────────────────────────────────
|
|
194
|
+
export function printStatus(taskId, taskDir) {
|
|
195
|
+
const state = loadState(taskId, taskDir);
|
|
196
|
+
if (!state) {
|
|
197
|
+
console.log(`No status found for task ${taskId}`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
console.log(`\nTask: ${state.taskId}`);
|
|
201
|
+
console.log(`State: ${state.state}`);
|
|
202
|
+
console.log(`Created: ${state.createdAt}`);
|
|
203
|
+
console.log(`Updated: ${state.updatedAt}\n`);
|
|
204
|
+
for (const stage of STAGES) {
|
|
205
|
+
const s = state.stages[stage.name];
|
|
206
|
+
const icon = s.state === "completed" ? "✓" :
|
|
207
|
+
s.state === "failed" ? "✗" :
|
|
208
|
+
s.state === "running" ? "▶" :
|
|
209
|
+
s.state === "timeout" ? "⏱" : "○";
|
|
210
|
+
const extra = s.error ? ` — ${s.error}` : "";
|
|
211
|
+
console.log(` ${icon} ${stage.name}: ${s.state}${extra}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runPreflight(): void;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { execFileSync } from "child_process";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import { logger } from "./logger.js";
|
|
4
|
+
function check(name, fn) {
|
|
5
|
+
try {
|
|
6
|
+
const detail = fn() ?? undefined;
|
|
7
|
+
return { name, ok: true, detail };
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return { name, ok: false };
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function runPreflight() {
|
|
14
|
+
const checks = [
|
|
15
|
+
check("claude CLI", () => {
|
|
16
|
+
const v = execFileSync("claude", ["--version"], {
|
|
17
|
+
encoding: "utf-8",
|
|
18
|
+
timeout: 10_000,
|
|
19
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
20
|
+
}).trim();
|
|
21
|
+
return v;
|
|
22
|
+
}),
|
|
23
|
+
check("git repo", () => {
|
|
24
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
25
|
+
encoding: "utf-8",
|
|
26
|
+
timeout: 5_000,
|
|
27
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
28
|
+
});
|
|
29
|
+
}),
|
|
30
|
+
check("pnpm", () => {
|
|
31
|
+
const v = execFileSync("pnpm", ["--version"], {
|
|
32
|
+
encoding: "utf-8",
|
|
33
|
+
timeout: 5_000,
|
|
34
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
35
|
+
}).trim();
|
|
36
|
+
return v;
|
|
37
|
+
}),
|
|
38
|
+
check("node >= 18", () => {
|
|
39
|
+
const v = execFileSync("node", ["--version"], {
|
|
40
|
+
encoding: "utf-8",
|
|
41
|
+
timeout: 5_000,
|
|
42
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
43
|
+
}).trim();
|
|
44
|
+
const major = parseInt(v.replace("v", "").split(".")[0], 10);
|
|
45
|
+
if (major < 18)
|
|
46
|
+
throw new Error(`Node ${v} < 18`);
|
|
47
|
+
return v;
|
|
48
|
+
}),
|
|
49
|
+
check("gh CLI", () => {
|
|
50
|
+
const v = execFileSync("gh", ["--version"], {
|
|
51
|
+
encoding: "utf-8",
|
|
52
|
+
timeout: 5_000,
|
|
53
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
54
|
+
}).trim().split("\n")[0];
|
|
55
|
+
return v;
|
|
56
|
+
}),
|
|
57
|
+
check("package.json", () => {
|
|
58
|
+
if (!fs.existsSync("package.json"))
|
|
59
|
+
throw new Error("not found");
|
|
60
|
+
}),
|
|
61
|
+
];
|
|
62
|
+
const failed = checks.filter((c) => !c.ok);
|
|
63
|
+
for (const c of checks) {
|
|
64
|
+
logger.info(` ${c.ok ? "✓" : "✗"} ${c.name}${c.detail ? ` (${c.detail})` : ""}`);
|
|
65
|
+
}
|
|
66
|
+
if (failed.length > 0) {
|
|
67
|
+
throw new Error(`Preflight failed: ${failed.map((c) => c.name).join(", ")}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { StageName, PipelineStatus, PipelineContext } from "./types.js";
|
|
2
|
+
export interface RetrospectiveEntry {
|
|
3
|
+
timestamp: string;
|
|
4
|
+
taskId: string;
|
|
5
|
+
outcome: "completed" | "failed";
|
|
6
|
+
durationMs: number;
|
|
7
|
+
stageResults: Record<string, {
|
|
8
|
+
state: string;
|
|
9
|
+
retries: number;
|
|
10
|
+
durationMs?: number;
|
|
11
|
+
error?: string;
|
|
12
|
+
}>;
|
|
13
|
+
failedStage?: StageName;
|
|
14
|
+
observation: string;
|
|
15
|
+
patternMatch: string | null;
|
|
16
|
+
suggestion: string;
|
|
17
|
+
pipelineFlaw: {
|
|
18
|
+
component: string;
|
|
19
|
+
issue: string;
|
|
20
|
+
evidence: string;
|
|
21
|
+
} | null;
|
|
22
|
+
}
|
|
23
|
+
export declare function collectRunContext(ctx: PipelineContext, state: PipelineStatus, pipelineStartTime: number): string;
|
|
24
|
+
export declare function readPreviousRetrospectives(projectDir: string, limit?: number): RetrospectiveEntry[];
|
|
25
|
+
export declare function appendRetrospectiveEntry(projectDir: string, entry: RetrospectiveEntry): void;
|
|
26
|
+
export declare function runRetrospective(ctx: PipelineContext, state: PipelineStatus, pipelineStartTime: number): Promise<void>;
|
|
@@ -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
|
+
}
|