feat(pipeline): C stage-chaining + D code block extraction
C - Stage 간 결과 전달:
- InvokeRequest schema + sister-agent types: priorStages[] field
- runner.ts: accumulates stage outputs as pipeline progresses
extractStageText() pulls summary from each HandoffMessage
each subsequent invoke gets priorStages[{stage, text}, ...]
- prompts.ts: priorStages rendered as "# 앞 단계(들)의 결과물" section
manager/principal/lead/junior 모두 볼 수 있음
- spawn.ts: forwards ctx.req.priorStages into doWork() at every level
이제 하랑이 plan 결과가 나랑이 implement 의 프롬프트에 포함되고,
나랑이 결과가 다랑이 review 에, 다랑이 결과가 이랑이 deploy 에 전달됨.
실제 파이프라인으로 이어짐.
D - 코드 블록 추출 + 파일 저장:
- sister-agent/src/code-extractor.ts — 마크다운 코드 블록 파서
form 지원: ```lang / ```lang:path / ```lang path=... / ```src/file.ext
path sanitize (.., leading /, absolute 차단)
LANG_TO_EXT 25+ 매핑
- spawn.ts: maybeExtractFiles() — implement stage junior 에만 적용
{workspaceDir}/files/{relpath} 로 저장
sub_task_events.completed.extractedFiles 에 path 목록 포함
- prompts.ts: implement junior 프롬프트에 파일 경로 명시 형식 강제
"```html:src/index.html" 예시 포함
이제 하랑이가 계획한 내용 기반으로 나랑이가 실제로 코드 파일을 LXC 파일시스템에 저장함.
This commit is contained in:
@@ -1 +1 @@
|
||||
1775814166
|
||||
1775819769
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"timestamp": "2026-04-10T09:46:21Z",
|
||||
"changed_file": "/home/erang/hanarang-rails/src/orchestrator/runner.ts",
|
||||
"timestamp": "2026-04-10T11:16:09Z",
|
||||
"changed_file": "src/code-extractor.ts",
|
||||
"test_command": "npm test",
|
||||
"related_test": "",
|
||||
"recommendation": "テストの実行を推奨します"
|
||||
|
||||
177
sister-agent/src/code-extractor.ts
Normal file
177
sister-agent/src/code-extractor.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, normalize, sep } from "node:path";
|
||||
|
||||
export interface ExtractedFile {
|
||||
path: string; // normalized relative path (e.g. "src/index.html")
|
||||
lang: string; // language tag from the fence
|
||||
content: string; // file contents
|
||||
absPath?: string; // populated after write
|
||||
}
|
||||
|
||||
const LANG_TO_EXT: Record<string, string> = {
|
||||
html: "html",
|
||||
htm: "html",
|
||||
xml: "xml",
|
||||
svg: "svg",
|
||||
css: "css",
|
||||
scss: "scss",
|
||||
sass: "sass",
|
||||
javascript: "js",
|
||||
js: "js",
|
||||
jsx: "jsx",
|
||||
typescript: "ts",
|
||||
ts: "ts",
|
||||
tsx: "tsx",
|
||||
json: "json",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
toml: "toml",
|
||||
ini: "ini",
|
||||
python: "py",
|
||||
py: "py",
|
||||
ruby: "rb",
|
||||
rb: "rb",
|
||||
rust: "rs",
|
||||
rs: "rs",
|
||||
go: "go",
|
||||
java: "java",
|
||||
kotlin: "kt",
|
||||
kt: "kt",
|
||||
swift: "swift",
|
||||
c: "c",
|
||||
"c++": "cpp",
|
||||
cpp: "cpp",
|
||||
cxx: "cpp",
|
||||
cs: "cs",
|
||||
csharp: "cs",
|
||||
php: "php",
|
||||
sh: "sh",
|
||||
bash: "sh",
|
||||
shell: "sh",
|
||||
zsh: "sh",
|
||||
fish: "fish",
|
||||
sql: "sql",
|
||||
markdown: "md",
|
||||
md: "md",
|
||||
dockerfile: "dockerfile",
|
||||
makefile: "mk",
|
||||
prisma: "prisma",
|
||||
graphql: "graphql",
|
||||
env: "env",
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse markdown code fences out of an LLM response.
|
||||
*
|
||||
* Supported fence header forms:
|
||||
* ```html
|
||||
* ```html:index.html
|
||||
* ```html path=src/index.html
|
||||
* ```src/index.html (no lang, filename only)
|
||||
* ```ts title=src/main.ts
|
||||
*/
|
||||
export function extractCodeBlocks(text: string): ExtractedFile[] {
|
||||
const files: ExtractedFile[] = [];
|
||||
const re = /```([^\n`]*)\n([\s\S]*?)\n```/g;
|
||||
let match: RegExpExecArray | null;
|
||||
let anonCounter = 0;
|
||||
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const header = (match[1] ?? "").trim();
|
||||
const content = match[2] ?? "";
|
||||
|
||||
const parsed = parseHeader(header);
|
||||
if (!parsed) continue;
|
||||
|
||||
let path = parsed.path;
|
||||
if (!path) {
|
||||
anonCounter += 1;
|
||||
const ext = LANG_TO_EXT[parsed.lang] ?? "txt";
|
||||
path = `block-${String(anonCounter).padStart(2, "0")}.${ext}`;
|
||||
}
|
||||
|
||||
// Normalize and sanitize path — strip leading /, resolve ., block ..
|
||||
const cleanPath = sanitizePath(path);
|
||||
if (!cleanPath) continue;
|
||||
|
||||
files.push({ path: cleanPath, lang: parsed.lang, content });
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function parseHeader(header: string): { lang: string; path: string } | null {
|
||||
if (header.length === 0) return null;
|
||||
|
||||
// form: "html:src/index.html"
|
||||
const colonIdx = header.indexOf(":");
|
||||
if (colonIdx > 0 && !header.slice(0, colonIdx).includes(" ")) {
|
||||
const lang = header.slice(0, colonIdx).toLowerCase();
|
||||
const rest = header.slice(colonIdx + 1).trim();
|
||||
if (looksLikePath(rest)) {
|
||||
return { lang, path: rest };
|
||||
}
|
||||
}
|
||||
|
||||
// form: "html path=src/index.html" or "ts title=src/main.ts"
|
||||
const kvMatch = header.match(/^(\w+)\s+(?:path|title|file)=(\S+)/i);
|
||||
if (kvMatch) {
|
||||
return { lang: kvMatch[1]!.toLowerCase(), path: kvMatch[2]! };
|
||||
}
|
||||
|
||||
// form: "src/index.html" (path only, no lang)
|
||||
if (looksLikePath(header) && !/^\w+$/.test(header)) {
|
||||
const ext = header.split(".").pop()?.toLowerCase() ?? "";
|
||||
return { lang: ext, path: header };
|
||||
}
|
||||
|
||||
// form: "html" (bare lang, no path)
|
||||
const lang = header.split(/\s+/)[0]?.toLowerCase() ?? "";
|
||||
if (lang.length === 0) return null;
|
||||
return { lang, path: "" };
|
||||
}
|
||||
|
||||
function looksLikePath(s: string): boolean {
|
||||
if (s.length === 0) return false;
|
||||
if (s.includes(" ")) return false;
|
||||
// Has an extension OR a slash
|
||||
return /\.[a-z0-9]{1,6}$/i.test(s) || s.includes("/");
|
||||
}
|
||||
|
||||
function sanitizePath(p: string): string | null {
|
||||
const normalized = normalize(p).replace(/^(?:\.\.(?:\/|\\))+/, "");
|
||||
if (
|
||||
normalized.startsWith(sep) ||
|
||||
normalized.startsWith("/") ||
|
||||
normalized.includes("..")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save extracted files to the given directory under a `files/` subdir.
|
||||
* Returns the same list with absPath populated.
|
||||
*/
|
||||
export async function saveExtractedFiles(
|
||||
baseDir: string,
|
||||
files: ExtractedFile[],
|
||||
): Promise<ExtractedFile[]> {
|
||||
if (files.length === 0) return files;
|
||||
const targetRoot = join(baseDir, "files");
|
||||
await mkdir(targetRoot, { recursive: true });
|
||||
|
||||
const saved: ExtractedFile[] = [];
|
||||
for (const f of files) {
|
||||
const absPath = join(targetRoot, f.path);
|
||||
try {
|
||||
await mkdir(dirname(absPath), { recursive: true });
|
||||
await writeFile(absPath, f.content, "utf8");
|
||||
saved.push({ ...f, absPath });
|
||||
} catch {
|
||||
// skip — best effort
|
||||
}
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export interface PromptContext {
|
||||
taskDescription: string;
|
||||
prevStageOutput?: string;
|
||||
parentTitle?: string;
|
||||
priorStages?: Array<{ stage: string; text: string }>;
|
||||
}
|
||||
|
||||
const STAGE_KOREAN: Record<string, string> = {
|
||||
@@ -63,15 +64,24 @@ export function buildPrompt(ctx: PromptContext): string {
|
||||
if (ctx.parentTitle) {
|
||||
lines.push(`상위 작업: ${ctx.parentTitle}`);
|
||||
}
|
||||
if (ctx.priorStages && ctx.priorStages.length > 0) {
|
||||
lines.push("");
|
||||
lines.push(`# 앞 단계(들)의 결과물 — 반드시 참고해서 일관되게 이어가`);
|
||||
for (const ps of ctx.priorStages) {
|
||||
lines.push("");
|
||||
lines.push(`## ${STAGE_KOREAN[ps.stage] ?? ps.stage} 단계 결과`);
|
||||
lines.push(ps.text.slice(0, 2500));
|
||||
}
|
||||
}
|
||||
if (ctx.prevStageOutput) {
|
||||
lines.push("");
|
||||
lines.push(`# 이전 단계 결과 (참고)`);
|
||||
lines.push(ctx.prevStageOutput.slice(0, 4000));
|
||||
lines.push(`# 직전 상위 노드(같은 stage) 의 지시`);
|
||||
lines.push(ctx.prevStageOutput.slice(0, 2000));
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(`# 출력 형식`);
|
||||
lines.push(roleOutputHint(ctx.role, ctx.stage));
|
||||
lines.push(`반드시 한국어로 답해. 200-400자 내외로 핵심만.`);
|
||||
lines.push(`반드시 한국어로 답해. 핵심만 간결하게.`);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -91,7 +101,15 @@ function roleOutputHint(role: Role, stage: PromptContext["stage"]): string {
|
||||
return `이 프로젝트의 핵심 plan 을 마크다운 bullet 형식으로 작성해.`;
|
||||
}
|
||||
if (stage === "implement") {
|
||||
return `요구된 코드/파일/내용을 그대로 작성해. 코드면 코드 블록으로.`;
|
||||
return [
|
||||
`요구된 코드/파일을 실제로 작성해.`,
|
||||
`각 파일을 코드 블록으로 감싸고, **반드시 다음 형식으로 파일 경로를 명시**해:`,
|
||||
"```html:src/index.html",
|
||||
"<!DOCTYPE html>...",
|
||||
"```",
|
||||
`경로는 프로젝트 루트 기준 상대 경로. 언어 태그 콜론 뒤에 경로.`,
|
||||
`여러 파일이 필요하면 각각 별도 블록으로. 설명은 최소화.`,
|
||||
].join("\n");
|
||||
}
|
||||
if (stage === "review") {
|
||||
return `위 결과물을 평가하고 APPROVE 또는 REQUEST_CHANGES 로 시작해서 이유를 한 문단.`;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import type { RailsClient } from "./rails-client.js";
|
||||
import { callLlm } from "./llm.js";
|
||||
import { buildPrompt } from "./prompts.js";
|
||||
import { extractCodeBlocks, saveExtractedFiles } from "./code-extractor.js";
|
||||
|
||||
const USE_REAL_LLM = process.env["RAILS_USE_REAL_LLM"] !== "false";
|
||||
const WORKSPACE_ROOT =
|
||||
@@ -78,6 +79,7 @@ export async function executeInvocation(
|
||||
stage: req.stage,
|
||||
taskTitle: req.task.title,
|
||||
taskDescription: req.task.description,
|
||||
priorStages: req.priorStages,
|
||||
});
|
||||
await persistResult(rails, managerId, managerWork);
|
||||
const managerPath = await writeOutputFile(
|
||||
@@ -179,6 +181,7 @@ async function runSpawnNode(
|
||||
taskDescription: spawnPlan.rationale,
|
||||
parentTitle: ctx.req.task.title,
|
||||
prevStageOutput: parentOutput,
|
||||
priorStages: ctx.req.priorStages,
|
||||
});
|
||||
await persistResult(ctx.rails, id, work);
|
||||
const filePath = await writeOutputFile(
|
||||
@@ -189,6 +192,9 @@ async function runSpawnNode(
|
||||
work.text,
|
||||
);
|
||||
|
||||
// Extract code blocks and save as real files (implement stage junior)
|
||||
const extractedFiles = await maybeExtractFiles(ctx, spawnPlan.role, work.text);
|
||||
|
||||
// Spawn grandchildren (if any) in parallel
|
||||
let childTexts: string[] = [];
|
||||
if (spawnPlan.subBreakdown && spawnPlan.subBreakdown.length > 0) {
|
||||
@@ -207,11 +213,40 @@ async function runSpawnNode(
|
||||
ok: work.ok,
|
||||
file: filePath,
|
||||
childCount: childTexts.length,
|
||||
extractedFiles: extractedFiles.map((f) => ({ path: f.path, lang: f.lang })),
|
||||
});
|
||||
|
||||
return [work.text, ...childTexts].filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ```lang:path code blocks from text and save them to the pipeline
|
||||
* workspace. Only runs for implement-stage junior nodes to keep things scoped.
|
||||
*/
|
||||
async function maybeExtractFiles(
|
||||
ctx: RunContext,
|
||||
role: Role,
|
||||
text: string,
|
||||
): Promise<Array<{ path: string; lang: string; absPath?: string }>> {
|
||||
if (!text) return [];
|
||||
// Only juniors in implement stage actually produce code artifacts.
|
||||
if (role !== "junior") return [];
|
||||
if (ctx.req.stage !== "implement") return [];
|
||||
|
||||
const blocks = extractCodeBlocks(text);
|
||||
if (blocks.length === 0) return [];
|
||||
|
||||
const saved = await saveExtractedFiles(ctx.workspaceDir, blocks);
|
||||
return saved.map((f) => {
|
||||
const base: { path: string; lang: string; absPath?: string } = {
|
||||
path: f.path,
|
||||
lang: f.lang,
|
||||
};
|
||||
if (f.absPath !== undefined) base.absPath = f.absPath;
|
||||
return base;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Call LLM (or stub) to produce the node's work output.
|
||||
*/
|
||||
@@ -223,6 +258,7 @@ async function doWork(args: {
|
||||
taskDescription: string;
|
||||
prevStageOutput?: string;
|
||||
parentTitle?: string;
|
||||
priorStages?: Array<{ stage: string; text: string }>;
|
||||
}): Promise<{ ok: boolean; text: string; error?: string }> {
|
||||
if (!USE_REAL_LLM) {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
@@ -239,6 +275,7 @@ async function doWork(args: {
|
||||
prevStageOutput: args.prevStageOutput,
|
||||
}),
|
||||
...(args.parentTitle !== undefined && { parentTitle: args.parentTitle }),
|
||||
...(args.priorStages !== undefined && { priorStages: args.priorStages }),
|
||||
});
|
||||
|
||||
const result = await callLlm({
|
||||
|
||||
@@ -4,6 +4,13 @@ import { z } from "zod";
|
||||
export const Role = z.enum(["manager", "principal", "lead", "junior"]);
|
||||
export type Role = z.infer<typeof Role>;
|
||||
|
||||
// ── Prior stage outputs (for chaining) ──
|
||||
export const PriorStageOutput = z.object({
|
||||
stage: z.enum(["plan", "implement", "review", "deploy"]),
|
||||
text: z.string(),
|
||||
});
|
||||
export type PriorStageOutput = z.infer<typeof PriorStageOutput>;
|
||||
|
||||
// ── Incoming invoke from rails ──
|
||||
export const InvokeRequest = z.object({
|
||||
pipelineId: z.string(),
|
||||
@@ -14,6 +21,7 @@ export const InvokeRequest = z.object({
|
||||
description: z.string().default(""),
|
||||
workdir: z.string().default(""),
|
||||
}),
|
||||
priorStages: z.array(PriorStageOutput).default([]),
|
||||
timeoutMs: z.number().int().positive().default(600_000),
|
||||
railsApiUrl: z.string().url(),
|
||||
agentName: z.string().default(""),
|
||||
|
||||
@@ -77,6 +77,12 @@ export const HandoffMessage = z.discriminatedUnion("stage", [
|
||||
|
||||
export type HandoffMessage = z.infer<typeof HandoffMessage>;
|
||||
|
||||
export const PriorStageOutput = z.object({
|
||||
stage: z.enum(["plan", "implement", "review", "deploy"]),
|
||||
text: z.string(),
|
||||
});
|
||||
export type PriorStageOutput = z.infer<typeof PriorStageOutput>;
|
||||
|
||||
export const InvokeRequest = z.object({
|
||||
pipelineId: z.string(),
|
||||
contractId: z.string().default(""),
|
||||
@@ -88,6 +94,7 @@ export const InvokeRequest = z.object({
|
||||
description: z.string().default(""),
|
||||
workdir: z.string().default(""),
|
||||
}),
|
||||
priorStages: z.array(PriorStageOutput).default([]),
|
||||
timeoutMs: z.number().int().positive().default(30_000),
|
||||
structuredOutput: z.literal(true).default(true),
|
||||
});
|
||||
|
||||
@@ -49,6 +49,12 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
|
||||
let transitions = 1;
|
||||
const TERMINAL: PipelineState[] = ["done", "escalated", "aborted"];
|
||||
|
||||
// Accumulate stage outputs so each stage can see what the previous ones produced.
|
||||
const priorStages: Array<{
|
||||
stage: "plan" | "implement" | "review" | "deploy";
|
||||
text: string;
|
||||
}> = [];
|
||||
|
||||
while (!TERMINAL.includes(result.state)) {
|
||||
if (opts.signal?.aborted) {
|
||||
result = await sendEvent(pipelineId, {
|
||||
@@ -89,6 +95,7 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
|
||||
description: opts.requirements,
|
||||
workdir: process.cwd(),
|
||||
},
|
||||
priorStages,
|
||||
timeoutMs: opts.config.agents[stage]?.timeoutMs ?? 600_000,
|
||||
structuredOutput: true,
|
||||
};
|
||||
@@ -102,6 +109,11 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
|
||||
);
|
||||
|
||||
if (retryResult.ok && retryResult.value) {
|
||||
// Extract the text output for the next stage
|
||||
const stageText = extractStageText(retryResult.value);
|
||||
if (stageText) {
|
||||
priorStages.push({ stage, text: stageText });
|
||||
}
|
||||
const event = handoffToEvent(retryResult.value);
|
||||
result = await sendEvent(pipelineId, event);
|
||||
transitions += 1;
|
||||
@@ -174,6 +186,36 @@ function mapStateToStage(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a text summary from a HandoffMessage for stage chaining.
|
||||
* sister-agent buildSuccessResult packs summary into selfTestReport/verificationResults.
|
||||
*/
|
||||
function extractStageText(h: HandoffMessage): string {
|
||||
switch (h.stage) {
|
||||
case "plan":
|
||||
if (h.payload) {
|
||||
return `plan dir: ${h.payload.planDir}, sprint: ${h.payload.sprintId}`;
|
||||
}
|
||||
return "";
|
||||
case "implement": {
|
||||
const summary =
|
||||
(h.payload?.selfTestReport as { summary?: string } | undefined)?.summary;
|
||||
return summary ?? "";
|
||||
}
|
||||
case "review": {
|
||||
if (h.payload?.issues && h.payload.issues.length > 0) {
|
||||
return `Review ${h.verdict}: ${JSON.stringify(h.payload.issues).slice(0, 2000)}`;
|
||||
}
|
||||
return `Review verdict: ${h.verdict}`;
|
||||
}
|
||||
case "deploy": {
|
||||
const summary =
|
||||
(h.payload?.verificationResults as { summary?: string } | undefined)?.summary;
|
||||
return summary ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handoffToEvent(h: HandoffMessage): PipelineEvent {
|
||||
switch (h.stage) {
|
||||
case "plan":
|
||||
|
||||
Reference in New Issue
Block a user