Skip to content

Commit c689f6a

Browse files
recuu-pfegclaude
andcommitted
refactor: split cli.ts + agent-templates.ts into modules
cli.ts: 1107 → 142 lines (-87%) agent-templates.ts: 1220 → 545 lines (-55%) Commands (src/commands/): - plan.ts, propose.ts, review.ts, sprint-complete.ts - run-loop.ts (main task execution loop) - shutdown.ts (graceful shutdown state) Handlers (src/handlers/): - git-merge.ts, git-push.ts - spawn-reviewer.ts, review-changes.ts - memory.ts (inject + collect) Utils (src/utils/): - retry-tracker.ts (NEEDS_CHANGES retry logic) No behavior changes. Build succeeds. All 71 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d0c5ec1 commit c689f6a

14 files changed

Lines changed: 1867 additions & 1678 deletions

src/agent-templates.ts

Lines changed: 22 additions & 697 deletions
Large diffs are not rendered by default.

src/cli.ts

Lines changed: 16 additions & 981 deletions
Large diffs are not rendered by default.

src/commands/plan.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* Sprint Plan command (Strategist agent)
3+
*/
4+
5+
import { createApiClient, type Task } from "../api-client.js";
6+
import { resolveModelForRole } from "../agent-engine.js";
7+
import * as ui from "../ui.js";
8+
9+
export async function handleSprintPlan(apiUrl: string, apiKey: string): Promise<void> {
10+
const api = createApiClient(apiUrl, apiKey);
11+
12+
// Update strategist status
13+
try { await api.updateAgent({ name: "strategist", status: "working", activity: "Generating sprint plan..." }); } catch { /* non-fatal */ }
14+
15+
try {
16+
// Gather context from API
17+
const sprintData = await api.fetchSprintData();
18+
const sprint = sprintData.sprint;
19+
if (!sprint) { ui.error("No active sprint found"); return; }
20+
21+
ui.info(`[strategist] Planning Sprint #${sprint.number}...`);
22+
23+
const headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` };
24+
25+
// Fetch backlog, analytics, failures, retro
26+
const [backlogRes, analyticsRes, failuresRes] = await Promise.all([
27+
fetch(`${apiUrl}/api/v1/tasks?sprint=-1`, { headers }).then((r) => r.json()) as Promise<Task[]>,
28+
fetch(`${apiUrl}/api/v1/manager/context`, { headers }).then((r) => r.json()).catch(() => ({})) as Promise<Record<string, unknown>>,
29+
fetch(`${apiUrl}/api/v1/failures/analyze`, { method: "POST", headers }).then((r) => r.json()).catch(() => ({ patterns: [] })) as Promise<{ patterns: Array<{ pattern: string; occurrences: number }> }>,
30+
]);
31+
32+
const backlog = (backlogRes || []).filter((t: Task) => t.status !== "done");
33+
const analytics = (analyticsRes as Record<string, unknown>).analytics as { velocity?: Array<{ sprint: number; points: number }>; quality?: Array<{ sprint: number; avg_score: number }> } | undefined;
34+
35+
const velocityLines = analytics?.velocity?.map((v) => `Sprint #${v.sprint}: ${v.points}SP`).join(", ") || "no data";
36+
const avgVelocity = analytics?.velocity?.length
37+
? Math.round(analytics.velocity.reduce((s, v) => s + v.points, 0) / analytics.velocity.length)
38+
: 20;
39+
const qualityLines = analytics?.quality?.map((q) => `Sprint #${q.sprint}: ${q.avg_score}/100`).join(", ") || "no data";
40+
const failureLines = failuresRes.patterns?.map((p) => `${p.occurrences}x: ${p.pattern}`).join("\n") || "none";
41+
42+
const backlogLines = backlog.map((t) => {
43+
const sp = (t as Record<string, unknown>).story_points ?? "?";
44+
return `- [${t.priority}] ${sp}SP "${t.title}" (id:${t.id.slice(0, 8)})`;
45+
}).join("\n");
46+
47+
// Build prompt for Claude CLI
48+
const systemPrompt = `You are Strategist, a sprint planning agent.
49+
Select 5-7 tasks from the backlog for Sprint #${sprint.number}.
50+
Target ${avgVelocity}SP. Keep reasons brief (max 10 words each).
51+
52+
Velocity: ${velocityLines}
53+
Quality: ${qualityLines}
54+
Failure patterns: ${failureLines}
55+
${(sprint as Record<string, unknown>).goal ? `Sprint Goal: ${(sprint as Record<string, unknown>).goal}` : ""}
56+
57+
Output ONLY valid JSON (no markdown):
58+
{"summary":"brief rationale","tasks":[{"id":"8char","title":"...","reason":"brief"}],"total_sp":N}`;
59+
60+
const userMessage = `Backlog (${backlog.length} tasks):\n${backlogLines}\n\nSelect tasks. JSON only.`;
61+
62+
// Call Claude CLI as Strategist
63+
const { execSync } = await import("node:child_process");
64+
const fullPrompt = `${systemPrompt}\n\n${userMessage}`;
65+
const result = execSync(
66+
`claude --print --model ${resolveModelForRole("strategist")} -p ${JSON.stringify(fullPrompt)}`,
67+
{ stdio: "pipe", timeout: 180_000, maxBuffer: 10 * 1024 * 1024 }
68+
).toString().trim();
69+
70+
// Parse JSON from result
71+
const jsonMatch = result.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/) || result.match(/(\{[\s\S]*\})/);
72+
const plan = JSON.parse(jsonMatch?.[1] || result) as {
73+
summary: string;
74+
tasks: Array<{ id: string; title: string; reason: string }>;
75+
total_sp: number;
76+
};
77+
78+
ui.info(`[strategist] Plan: ${plan.summary}`);
79+
ui.info(`[strategist] ${plan.tasks.length} tasks, ${plan.total_sp}SP`);
80+
for (const t of plan.tasks) {
81+
ui.info(` - ${t.id} ${t.title} (${t.reason})`);
82+
}
83+
84+
// Save to API
85+
const saveRes = await fetch(`${apiUrl}/api/v1/sprints/${sprint.number}/plan`, {
86+
method: "POST", headers,
87+
body: JSON.stringify(plan),
88+
});
89+
const saved = (await saveRes.json()) as { id: string };
90+
ui.info(`[strategist] Plan saved: ${saved.id}`);
91+
ui.info(`[strategist] Approve with: curl -X POST ${apiUrl}/api/v1/sprints/${sprint.number}/plan/${saved.id}/approve`);
92+
93+
} finally {
94+
try { await api.updateAgent({ name: "strategist", status: "idle", activity: "Sprint plan generated" }); } catch { /* non-fatal */ }
95+
}
96+
}

src/commands/propose.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* Propose command (Strategist agent — analyze data sources and suggest improvement tasks)
3+
*/
4+
5+
import { createApiClient, type Task } from "../api-client.js";
6+
import { resolveModelForRole } from "../agent-engine.js";
7+
import * as ui from "../ui.js";
8+
9+
export async function handlePropose(apiUrl: string, apiKey: string): Promise<void> {
10+
const api = createApiClient(apiUrl, apiKey);
11+
try { await api.updateAgent({ name: "strategist", status: "working", activity: "Analyzing data for improvement proposals..." }); } catch { /* non-fatal */ }
12+
13+
try {
14+
ui.info("[strategist] Analyzing data sources for improvement proposals...");
15+
const headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` };
16+
17+
// Gather data sources
18+
const [failuresRes, backlogRes, analyticsRes] = await Promise.all([
19+
fetch(`${apiUrl}/api/v1/failures/analyze`, { method: "POST", headers }).then((r) => r.json()).catch(() => ({ patterns: [] })) as Promise<{ patterns: Array<{ pattern: string; occurrences: number; suggested_rule: { title: string; content: string } }> }>,
20+
fetch(`${apiUrl}/api/v1/tasks?sprint=-1`, { headers }).then((r) => r.json()).catch(() => []) as Promise<Task[]>,
21+
fetch(`${apiUrl}/api/v1/manager/context`, { headers }).then((r) => r.json()).catch(() => ({})) as Promise<Record<string, unknown>>,
22+
]);
23+
24+
const failureLines = failuresRes.patterns?.map((p) => `${p.occurrences}x: ${p.pattern}`).join("\n") || "none";
25+
const backlogTitles = (backlogRes || []).filter((t: Task) => t.status !== "done").map((t) => `- ${t.title}`).join("\n");
26+
const analytics = (analyticsRes as Record<string, unknown>).analytics as { quality?: Array<{ sprint: number; avg_score: number }> } | undefined;
27+
const qualityLines = analytics?.quality?.map((q) => `Sprint #${q.sprint}: ${q.avg_score}/100`).join(", ") || "no data";
28+
29+
const prompt = `You are Strategist. Analyze the data below and propose 3-5 improvement tasks.
30+
Each proposal should fix a recurring problem or improve quality/efficiency.
31+
Do NOT propose tasks that already exist in the backlog.
32+
33+
Failure patterns:
34+
${failureLines}
35+
36+
Quality trend: ${qualityLines}
37+
38+
Existing backlog (do NOT duplicate):
39+
${backlogTitles}
40+
41+
Output ONLY valid JSON (no markdown):
42+
[{"title":"...","description":"brief","priority":"p1/p2/p3","type":"bug/chore/feature","story_points":N,"source":"failure_db/review/analytics","reasoning":"why this matters"}]`;
43+
44+
const { execSync } = await import("node:child_process");
45+
const result = execSync(
46+
`claude --print --model ${resolveModelForRole("strategist")} -p ${JSON.stringify(prompt)}`,
47+
{ stdio: "pipe", timeout: 180_000, maxBuffer: 10 * 1024 * 1024 }
48+
).toString().trim();
49+
50+
// Parse JSON
51+
const jsonMatch = result.match(/```(?:json)?\s*(\[[\s\S]*?\])\s*```/) || result.match(/(\[[\s\S]*\])/);
52+
const proposalList = JSON.parse(jsonMatch?.[1] || result) as Array<Record<string, string>>;
53+
54+
ui.info(`[strategist] Generated ${proposalList.length} proposals:`);
55+
for (const p of proposalList) {
56+
ui.info(` - [${p.priority}] ${p.title} (${p.source})`);
57+
ui.info(` ${p.reasoning}`);
58+
}
59+
60+
// Save to API
61+
try {
62+
const saveRes = await fetch(`${apiUrl}/api/v1/proposals/batch`, {
63+
method: "POST", headers,
64+
body: JSON.stringify(proposalList),
65+
});
66+
if (saveRes.ok) {
67+
const saved = (await saveRes.json()) as { created: number };
68+
ui.info(`[strategist] ${saved.created} proposals saved. Approve in dashboard > Backlog > Proposals.`);
69+
} else {
70+
ui.warn(`[strategist] Failed to save proposals: ${saveRes.status} ${saveRes.statusText}`);
71+
}
72+
} catch (saveErr) {
73+
ui.warn(`[strategist] Failed to save proposals: ${saveErr}`);
74+
}
75+
76+
} finally {
77+
try { await api.updateAgent({ name: "strategist", status: "idle", activity: `Proposals generated` }); } catch { /* non-fatal */ }
78+
}
79+
}

src/commands/review.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* Review command
3+
*/
4+
5+
import { createApiClient, type Task } from "../api-client.js";
6+
import { resolveModelForRole } from "../agent-engine.js";
7+
import * as ui from "../ui.js";
8+
9+
export async function handleReview(apiUrl: string, apiKey: string, taskId?: string, skills?: string[]): Promise<void> {
10+
const api = createApiClient(apiUrl, apiKey);
11+
const { spawn } = await import("node:child_process");
12+
const { execSync: revExec } = await import("node:child_process");
13+
14+
ui.intro();
15+
const s = ui.createSpinner();
16+
17+
// Get task to review
18+
let task: Task;
19+
if (taskId) {
20+
s.start(`Fetching task ${taskId}...`);
21+
const tasks = await api.fetchTasks();
22+
const found = tasks.find((t: Task) => t.id.startsWith(taskId));
23+
if (!found) { s.stop("Not found"); ui.error(`Task ${taskId} not found`); process.exit(1); }
24+
task = found;
25+
} else {
26+
s.start("Finding latest review task...");
27+
const tasks = await api.fetchTasks();
28+
const reviewTask = tasks.find((t: Task) => t.status === "review");
29+
if (!reviewTask) { s.stop("None"); ui.error("No tasks in review status"); process.exit(1); }
30+
task = reviewTask;
31+
}
32+
s.stop(`Reviewing: ${task.title}`);
33+
34+
// Get diff
35+
const cwd = process.cwd();
36+
let diffRef = "HEAD~1..HEAD";
37+
try {
38+
const parents = revExec("git cat-file -p HEAD", { cwd, stdio: "pipe" }).toString();
39+
const parentCount = (parents.match(/^parent /gm) || []).length;
40+
if (parentCount === 0) diffRef = "--root HEAD";
41+
} catch { /* default */ }
42+
43+
// Build reviewer prompt
44+
const { PROMPT_TEMPLATES } = await import("../prompts/templates.js");
45+
const { interpolate } = await import("../agent-templates.js");
46+
const taskType = (task as Record<string, unknown>).type as string || "implementation";
47+
const typeHints = JSON.parse(PROMPT_TEMPLATES["reviewer-type-hints"] || "{}") as Record<string, string>;
48+
49+
// Fetch playbook rules including skill rules matching task labels or --skill args
50+
const reviewTags = skills || (() => {
51+
const raw = (task as Record<string, unknown>).labels;
52+
if (Array.isArray(raw)) return raw as string[];
53+
if (typeof raw === "string") { try { return JSON.parse(raw) as string[]; } catch { return []; } }
54+
return [];
55+
})();
56+
let customRules = "";
57+
try { customRules = await api.fetchPlaybookPrompt("reviewer", reviewTags) || ""; } catch { /* non-fatal */ }
58+
if (reviewTags.length > 0) {
59+
ui.info(`[review] Tags for skill matching: ${reviewTags.join(", ")}`);
60+
}
61+
62+
const reviewSystem = interpolate(PROMPT_TEMPLATES["reviewer-system"] || "", {
63+
projectName: cwd.split("/").pop() || "unknown",
64+
language: "English",
65+
taskTitle: task.title,
66+
taskType,
67+
taskDescription: task.description || "(no description)",
68+
taskTypeHint: typeHints[taskType] || typeHints.implementation || "",
69+
customReviewRules: customRules ? `\n${customRules}` : "",
70+
});
71+
const outputFormat = PROMPT_TEMPLATES["reviewer-output-format"] || '{"verdict":"APPROVE or NEEDS_CHANGES"}';
72+
73+
const prompt = `${reviewSystem}\n\nRun: git diff ${diffRef}\nRun: npm test 2>&1 | tail -20\n\nThen output verdict.\n\n${outputFormat}`;
74+
75+
s.start("Running Reviewer agent...");
76+
const result = await new Promise<string>((resolve) => {
77+
const env = { ...process.env };
78+
delete env.CLAUDECODE;
79+
const child = spawn("claude", ["--print", "--model", resolveModelForRole("reviewer"), "--max-turns", "5", prompt], {
80+
env, cwd, stdio: ["ignore", "pipe", "pipe"], timeout: 300_000,
81+
});
82+
let out = "";
83+
child.stdout?.on("data", (chunk: Buffer) => { out += chunk.toString(); });
84+
child.on("close", () => resolve(out));
85+
child.on("error", () => resolve(""));
86+
});
87+
s.stop("Review complete");
88+
89+
// Parse and display
90+
const match = result.match(/COMPLETION_JSON:(\{[\s\S]*?\})\s*$/m)
91+
|| result.match(/```json\s*(\{[\s\S]*?"verdict"[\s\S]*?\})\s*```/m)
92+
|| result.match(/(\{[\s\S]*?"verdict"\s*:\s*"(?:APPROVE|NEEDS_CHANGES)"[\s\S]*?\})\s*$/m);
93+
if (match) {
94+
try {
95+
const report = JSON.parse(match[1]);
96+
console.log("\n--- Review Report ---");
97+
console.log(`Verdict: ${report.verdict}`);
98+
console.log(`Requirement: ${report.requirement_match}`);
99+
console.log(`Quality: ${report.code_quality}`);
100+
console.log(`Tests: ${report.test_coverage}`);
101+
console.log(`Risks: ${report.risks}`);
102+
103+
// Save to API
104+
try {
105+
await fetch(`${apiUrl}/api/v1/tasks/${task.id}/review-report`, {
106+
method: "POST",
107+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
108+
body: JSON.stringify(report),
109+
});
110+
console.log("\nReview saved to API.");
111+
} catch { /* non-fatal */ }
112+
} catch {
113+
console.log("\n--- Raw Review Output ---");
114+
console.log(result.slice(-1000));
115+
}
116+
} else {
117+
console.log("\n--- Raw Output (no structured review) ---");
118+
console.log(result.slice(-1000));
119+
}
120+
}

0 commit comments

Comments
 (0)