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:
2026-04-10 20:16:47 +09:00
parent 6a8599e0b3
commit 1c25fb3b5b
8 changed files with 296 additions and 7 deletions

View File

@@ -1 +1 @@
1775814166
1775819769

View File

@@ -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": "テストの実行を推奨します"

View 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;
}

View File

@@ -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 로 시작해서 이유를 한 문단.`;

View File

@@ -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({

View File

@@ -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(""),

View File

@@ -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),
});

View File

@@ -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":