feat: Implement Pipeline Templates for Company Suite and refine orchestration logic

This commit is contained in:
2026-05-14 17:36:15 +09:00
parent 618b8d5b34
commit 75d7e6b83a
19 changed files with 1181 additions and 50 deletions
+48 -1
View File
@@ -145,6 +145,51 @@ function _extractFirstBalancedObject(s: string): string | null {
* state. Tasks targeting unknown / inactive agents are dropped, and Korean
* nicknames are rewritten to canonical ids.
*/
/**
* Post-hoc 직군 가드. The LLM is supposed to follow the "planner before
* developer" rule encoded in the planner prompt, but small models break it
* routinely. We re-check the parsed plan and inject a planner task at the
* front when build/design tasks were scheduled without any prior planning
* step. Returns the original plan when no fix is needed.
*
* This is best-effort, not authoritative — for a strict guarantee, the user
* should activate a pipeline (which is deterministic). The fallback agent
* for the auto-inserted planner step is the highest-priority active
* planner; if no planner direction is active we leave the plan as-is so
* the user isn't silently force-fed into a workflow they didn't set up.
*/
function _applyRoleGuard(
plan: CompanyTaskPlan,
state: CompanyState,
userPrompt: string,
): CompanyTaskPlan {
if (plan.tasks.length === 0) return plan;
const roleOf = (agentId: string): string => resolveAgent(state, agentId)?.roleCategory ?? 'support';
// 가드: 첫 build/design task 이전에 planner / researcher / inspector step이 하나도 없다.
const buildIdx = plan.tasks.findIndex((t) => {
const r = roleOf(t.agent);
return r === 'developer' || r === 'designer';
});
if (buildIdx === -1) return plan; // build/design task 없음 → 가드 불필요
const hasPriorContext = plan.tasks.slice(0, buildIdx).some((t) => {
const r = roleOf(t.agent);
return r === 'planner' || r === 'researcher' || r === 'inspector';
});
if (hasPriorContext) return plan; // 이미 사전 정리 task 존재
// 사용자가 명시적으로 "기획 없이 바로", "지금 당장 코드만" 라고 했다면 가드 skip
if (/(기획\s*없이|바로\s*코드|기획\s*X|간단히|빨리)/i.test(userPrompt)) return plan;
// planner 직군의 첫 활성 에이전트를 골라 정리 task 한 줄 prepend.
const plannerAgent = listAllAgents(state)
.find((a) => a.roleCategory === 'planner' && (a.id === 'ceo' || state.activeAgentIds.includes(a.id)));
if (!plannerAgent) return plan; // planner 활성 없음 → 강제 못 함
const inserted = {
agent: plannerAgent.id,
task: `사용자 요청을 한 번 정리한 뒤 다음 단계로 넘기세요. 요청: "${userPrompt.slice(0, 200)}". 다음 사항을 한 단락으로 정리: (1) 무엇을 만드는가, (2) 누가 쓰는가, (3) 성공 기준 1~2개.`,
};
logInfo('ceoPlanner: role guard inserted planner task.', { agent: plannerAgent.id });
return { brief: plan.brief, tasks: [inserted, ...plan.tasks] };
}
export function normalizePlan(plan: CompanyTaskPlan, state: CompanyState): CompanyTaskPlan {
const out: CompanyTaskPlan = { brief: plan.brief, tasks: [] };
const dropped: string[] = [];
@@ -209,11 +254,13 @@ export async function runCeoPlanner(
return { plan: { brief: raw.trim(), tasks: [] }, parsed: false, raw };
}
const plan = normalizePlan(parsed, state);
const normalized = normalizePlan(parsed, state);
const plan = _applyRoleGuard(normalized, state, userPrompt);
logInfo('ceoPlanner: parsed plan.', {
briefChars: plan.brief.length,
taskCount: plan.tasks.length,
agents: plan.tasks.map((t) => t.agent),
roleGuardApplied: plan.tasks.length !== normalized.tasks.length,
});
return { plan, parsed: true, raw };
}
+9
View File
@@ -116,6 +116,15 @@ function _normalizeStage(raw: unknown): PipelineStage | null {
id, label, agentId,
instructionTemplate: typeof r.instructionTemplate === 'string' ? r.instructionTemplate : '',
};
if (typeof r.roleCategory === 'string' && VALID_ROLE_CATEGORIES.has(r.roleCategory as AgentRoleCategory)) {
out.roleCategory = r.roleCategory;
}
if (typeof r.modelOverride === 'string' && r.modelOverride.trim()) {
out.modelOverride = r.modelOverride.trim();
}
if (r.requiresApproval === true) {
out.requiresApproval = true;
}
if (typeof r.loopBackPattern === 'string' && r.loopBackPattern.trim()) {
out.loopBackPattern = r.loopBackPattern.trim();
}
+83 -3
View File
@@ -69,6 +69,17 @@ const PEER_OUTPUT_BUDGET = 1500;
* the same channel can carry CEO/agent/report messages without per-type
* postMessage plumbing.
*/
/**
* User's decision after a stage with `requiresApproval=true` finishes.
* - 'approve' → proceed to next stage as-is
* - 'revise' → re-run the same stage; comment is prepended to its instruction
* - 'abort' → end the turn (same as hitting Stop)
*/
export type ApprovalDecision =
| { kind: 'approve' }
| { kind: 'revise'; comment: string }
| { kind: 'abort' };
export type CompanyTurnEvent =
| { phase: 'plan-start' }
| { phase: 'plan-ready'; plan: CompanyTaskPlan; parsed: boolean; raw: string }
@@ -81,6 +92,13 @@ export type CompanyTurnEvent =
* (재시도 N차)" in the chat.
*/
| { phase: 'stage-loop'; from: string; to: string; iteration: number }
/**
* Manual approval gate. Emitted right before the dispatcher awaits the
* user's decision. Webview surfaces 승인/수정요청/중단 buttons.
*/
| { phase: 'awaiting-approval'; stageId: string; stageLabel: string; index: number; total: number }
/** Resolved approval — purely informational for the chat log. */
| { phase: 'approval-resolved'; stageId: string; decision: 'approve' | 'revise' | 'abort' }
| { phase: 'report-start' }
| { phase: 'report-done'; report: string; ok: boolean }
/**
@@ -125,6 +143,17 @@ export interface DispatcherDeps {
signal?: AbortSignal;
/** Optional event sink for the webview. Receives events synchronously. */
onEvent?: CompanyTurnEmitter;
/**
* Manual-approval bridge. When a pipeline stage has `requiresApproval`,
* the dispatcher emits `phase: 'awaiting-approval'` and then *awaits*
* this promise. The host (SidebarChatProvider) is responsible for:
* 1. Storing a resolver tied to the emitted stageId
* 2. Surfacing approval buttons in the webview chat
* 3. Resolving the promise when the user clicks one of them
* 4. Resolving with `{ kind: 'abort' }` if the turn is cancelled
* (so the dispatcher doesn't hang forever)
*/
awaitApproval?: (ctx: { stageId: string; stageLabel: string }) => Promise<ApprovalDecision>;
}
/**
@@ -287,6 +316,8 @@ async function _dispatchOne(
earlierOutputs: AgentTurnOutput[],
state: ReturnType<typeof readCompanyState>,
deps: DispatcherDeps,
/** Pipeline stage override — wins over the agent's own model override. */
stageModelOverride?: string,
): Promise<AgentTurnOutput> {
const startedAt = Date.now();
const def = resolveAgent(state, agentId);
@@ -360,7 +391,10 @@ async function _dispatchOne(
brainContext, // injected as `[SECOND BRAIN CONTEXT]` block
knowledgeMixPolicy: policyBlock, // injected as `[KNOWLEDGE MIX POLICY]` block
});
const model = modelForAgent(state, agentId, deps.defaultModel);
// 우선순위: stage > agent > global default.
const model = (stageModelOverride && stageModelOverride.trim())
? stageModelOverride.trim()
: modelForAgent(state, agentId, deps.defaultModel);
try {
const result = await deps.ai.chat({
@@ -455,14 +489,22 @@ async function _runPipeline(
const latestByStage: Record<string, AgentTurnOutput> = {};
const iterations: Record<string, number> = {};
const total = pipeline.stages.length;
// Per-stage extra instruction injected by user revision requests. Cleared
// after the stage re-runs successfully so it doesn't pollute the rest of
// the pipeline.
const revisionNotes: Record<string, string> = {};
let i = 0;
let stepIndex = 0;
while (i < pipeline.stages.length) {
if (isAborted()) return { outputs, aborted: 'aborted-mid-pipeline' };
const stage = pipeline.stages[i];
const task = _renderStageInstruction(stage, userPrompt, brief, latestByStage);
const baseTask = _renderStageInstruction(stage, userPrompt, brief, latestByStage);
const note = revisionNotes[stage.id];
const task = note
? `[사용자 수정 요청]\n${note}\n\n위 피드백을 반드시 반영하세요.\n\n[원래 지시]\n${baseTask}`
: baseTask;
emit({ phase: 'agent-start', agentId: stage.agentId, task, index: stepIndex, total });
const turn = await _dispatchOne(stage.agentId, task, outputs, state, deps);
const turn = await _dispatchOne(stage.agentId, task, outputs, state, deps, stage.modelOverride);
outputs.push(turn);
latestByStage[stage.id] = turn;
writeAgentOutput(sessionDir, turn);
@@ -472,6 +514,44 @@ async function _runPipeline(
);
emit({ phase: 'agent-done', agentId: stage.agentId, output: turn, index: stepIndex, total });
stepIndex++;
// Successful run consumed the revision note (if any) — clear it.
if (!turn.error) delete revisionNotes[stage.id];
// ── Manual approval gate ──
// After agent-done emits, before loop-back / next stage advance,
// give the user a chance to inspect and approve. We only fire the
// gate when (a) the stage opted in, (b) we have an awaitApproval
// bridge from the host, and (c) the stage didn't error out
// (errored stages would loop forever waiting for "approval" of a
// failure — the user should just hit Stop).
if (stage.requiresApproval && deps.awaitApproval && !turn.error) {
emit({
phase: 'awaiting-approval',
stageId: stage.id,
stageLabel: stage.label || stage.id,
index: stepIndex - 1,
total,
});
let decision: ApprovalDecision;
try {
decision = await deps.awaitApproval({ stageId: stage.id, stageLabel: stage.label || stage.id });
} catch {
// 호스트가 에러를 던지면 안전하게 중단 — 무한 대기 방지.
decision = { kind: 'abort' };
}
if (isAborted()) return { outputs, aborted: 'aborted-mid-approval' };
emit({ phase: 'approval-resolved', stageId: stage.id, decision: decision.kind });
if (decision.kind === 'abort') {
return { outputs, aborted: 'aborted-by-user-at-approval' };
}
if (decision.kind === 'revise') {
revisionNotes[stage.id] = decision.comment || '(추가 코멘트 없음)';
// 같은 stage 재실행 — i를 그대로 두고 continue.
continue;
}
// 'approve' → 아래 loop-back/다음 stage 진행 로직으로 자연히 fall-through.
}
// Loop-back evaluation. We only loop on *successful* responses with
// non-empty body — an error or empty response would loop forever.
if (stage.loopBackTo && stage.loopBackPattern && !turn.error && turn.response.trim()) {
+4
View File
@@ -50,6 +50,9 @@ export type {
export { ROLE_CATEGORY_LABELS, ROLE_CATEGORY_ORDER } from './types';
export { PIPELINE_TEMPLATES, getPipelineTemplate } from './pipelineTemplates';
export type { PipelineTemplate } from './pipelineTemplates';
export type {
CompanyAgentDef,
CompanyState,
@@ -64,6 +67,7 @@ export {
} from './dispatcher';
export type {
ApprovalDecision,
CompanyTurnEvent,
CompanyTurnEmitter,
DispatcherDeps,
+211
View File
@@ -0,0 +1,211 @@
/**
* Built-in pipeline templates for 1인 기업 모드.
*
* These are *blueprints*, not data — they're surfaced in the manage panel's
* "템플릿에서 추가" dropdown so a non-developer user can stamp out a
* working pipeline in one click and then tweak the labels / 지시 / agent
* assignments in the card editor. Once stamped, the pipeline lives in
* `state.pipelines` like any other; templates themselves stay read-only in
* code so a future Astra version can ship improved defaults without
* trampling user edits.
*
* Why ship a template at all: the user's own description of the desired
* workflow ("작업 수락 → 기획 → 시장 조사 → … → QA → 배포") is exactly the
* shape this codifies. Without the template they'd have to author 13
* cards from scratch the first time they open the editor — which is
* exactly the friction we're trying to remove.
*/
import { PipelineDef } from './types';
export interface PipelineTemplate {
/** Stable id used by the UI dropdown and `applyTemplate` call. */
templateId: string;
/** Korean display name shown in the dropdown. */
name: string;
/** One-line description of when this template fits. */
description: string;
/**
* Default pipeline id when stamping. The UI will suggest this and let
* the user override before saving so two stamps don't collide.
*/
suggestedPipelineId: string;
/** Default human-readable name for the stamped pipeline. */
suggestedPipelineName: string;
/** Stage definitions — same shape as a saved PipelineDef.stages. */
stages: PipelineDef['stages'];
}
/**
* "풀 프로덕트 개발" — the user's described workflow, codified:
* 1. 작업 수락 — CEO 브리프 (자동, 별도 stage 아님)
* 2. 기획 논의 — planner
* 3. 시장 조사 — researcher
* 4. 트렌드 조사 — researcher
* 5. 방향성 정의 — planner
* 6. 기획문서 작성 — planner
* 7. 기획문서 검토 — inspector (재작업 발견 시 → 6번 loop-back)
* 8. 기획문서 최종본 — planner
* 9. 개발 설계 — developer
* 10. 설계 검토 — inspector (재작업 발견 시 → 9번 loop-back)
* 11. 개발 진행 — developer
* 12. QA 진행 — qa (버그 발견 시 → 11번 loop-back)
* 13. 라이브 배포 — developer
*
* 1번 "작업 수락"은 별도 stage가 아니라 CEO의 브리프 발신 — 모든 pipeline
* turn은 자동으로 brief를 생성해 `{{brief}}` 토큰으로 사용 가능하다.
*
* 지시 템플릿은 작은 LLM이 가장 자주 어기는 두 가지 — (a) 기획 문맥 잊기,
* (b) 단순 코드만 던지기 — 를 강하게 누른다. 사용자가 처음 보면 길어
* 보일 수 있는데, "이 단계에서 정확히 뭘 해야 하나" 가이드가 필요한
* 작은 모델 (gemma e2b·4b 등)에서 결과 품질을 크게 좌우한다.
*/
const FULL_PRODUCT_DEV: PipelineTemplate = {
templateId: 'full-product-dev',
name: '풀 프로덕트 개발 (13단계)',
description: '기획 → 리서치 → 기획서 → 검토 → 설계 → 개발 → QA → 배포. 기획 누락 없이 풀 사이클을 도는 표준 워크플로.',
suggestedPipelineId: 'product-dev',
suggestedPipelineName: '제품 개발 파이프라인',
stages: [
{
id: 'plan-discuss',
label: '기획 논의',
agentId: 'writer',
roleCategory: 'planner',
instructionTemplate:
'사용자 요청: {{userPrompt}}\n\n' +
'이번 작업의 목표·사용자·성공 기준을 정리하세요. 결정 사항이 아니라 *논의 정리* 단계입니다.\n' +
'- 누구를 위한 결과물인가\n- 핵심 가치 한 줄\n- 우리가 모르는 것 / 더 알아봐야 할 것\n- 위험 요소',
},
{
id: 'market-research',
label: '시장 조사',
agentId: 'researcher',
roleCategory: 'researcher',
instructionTemplate:
'기획 논의 정리: {{stage.plan-discuss}}\n\n' +
'위 맥락에서 *시장 측면*을 조사하세요. 추측 금지, 데이터/사례 기반.\n' +
'- 비슷한 시도가 이미 있나 (3개 이상)\n- 시장 크기·고객 페르소나\n- 가격대·수익화 패턴\n결과는 "출처(또는 일반론임을 명시)" 표시.',
},
{
id: 'trend-research',
label: '트렌드 조사',
agentId: 'researcher',
roleCategory: 'researcher',
instructionTemplate:
'기획 논의: {{stage.plan-discuss}}\n시장 조사 결과: {{stage.market-research}}\n\n' +
'*최근 트렌드*를 조사하세요. 6개월 이내 변화를 우선.\n' +
'- 떠오르는 키워드/패턴\n- 사용자 기대치 변화\n- 우리가 활용할 수 있는 기술/문화 신호',
},
{
id: 'direction',
label: '방향성 정의',
agentId: 'writer',
roleCategory: 'planner',
instructionTemplate:
'기획 논의: {{stage.plan-discuss}}\n시장: {{stage.market-research}}\n트렌드: {{stage.trend-research}}\n\n' +
'위 3개를 종합해 *우리가 갈 방향*을 한 문단으로 결론지어요.\n포함:\n' +
'- 무엇을 만들 것인가 (제품 한 줄 설명)\n- 누가 첫 사용자인가\n- 안 만들 것 (out of scope)\n- 성공 판단 기준 1~3가지',
},
{
id: 'plan-draft',
label: '기획문서 초안',
agentId: 'writer',
roleCategory: 'planner',
instructionTemplate:
'방향성: {{stage.direction}}\n\n' +
'이 방향에 맞는 *기획서 초안*을 마크다운으로 작성하세요.\n' +
'필수 섹션:\n' +
'## 배경\n## 목표\n## 핵심 사용자 시나리오 (3개 이상, 구체적)\n## 주요 기능 목록\n## 비기능 요구사항\n## 측정 지표 (KPI)\n## 미래 확장 / 비-목표\n\n' +
'아직 *최종본 아님* — 검토자가 피드백 줄 수 있도록 가정·미확정 사항을 명시하세요.',
},
{
id: 'plan-review',
label: '기획문서 검토',
agentId: 'inspector',
roleCategory: 'inspector',
instructionTemplate:
'검토 대상: {{stage.plan-draft}}\n\n' +
'*감리* 관점에서 기획서를 검토하세요. 칭찬 X, 구체적 피드백 O.\n' +
'확인 사항:\n- 시나리오가 구체적인가 ("사용자가 X를 한다" vs "사용자가 잘 쓴다")\n' +
'- 기능 vs 시나리오 매핑이 1:1로 되는가\n- 측정 가능한 성공 기준이 있는가\n- 빠진 케이스/엣지 케이스\n\n' +
'결론은 반드시 "✅ 승인" 또는 "❌ 재작업 필요: <항목 나열>" 로 시작.',
loopBackPattern: '재작업 필요|reject|보완 필요',
loopBackTo: 'plan-draft',
maxIterations: 3,
},
{
id: 'plan-final',
label: '기획문서 최종본',
agentId: 'writer',
roleCategory: 'planner',
instructionTemplate:
'초안: {{stage.plan-draft}}\n검토 피드백: {{stage.plan-review}}\n\n' +
'피드백을 모두 반영해 *최종 기획서*를 다시 쓰세요. 다음 단계(개발 설계)에서 그대로 사양서로 쓸 정도로 명확해야 합니다.',
},
{
id: 'dev-design',
label: '개발 설계',
agentId: 'developer',
roleCategory: 'developer',
instructionTemplate:
'최종 기획서: {{stage.plan-final}}\n\n' +
'이 기획서 기준으로 *개발 설계 문서*를 작성하세요. 아직 코드는 쓰지 않습니다.\n' +
'포함:\n## 데이터 모델\n## 컴포넌트/모듈 분할\n## 외부 의존성\n## 단계별 구현 순서 (체크리스트)\n## 테스트 전략\n## 알려진 리스크',
},
{
id: 'design-review',
label: '설계 검토',
agentId: 'inspector',
roleCategory: 'inspector',
instructionTemplate:
'설계 문서: {{stage.dev-design}}\n기획서: {{stage.plan-final}}\n\n' +
'설계가 기획 의도를 모두 커버하는지 *감리*하세요. 누락된 시나리오·과도한 over-engineering이 있나.\n' +
'결론은 "✅ 승인" 또는 "❌ 재작업 필요: ..." 로 시작.',
loopBackPattern: '재작업 필요|reject|보완 필요',
loopBackTo: 'dev-design',
maxIterations: 2,
},
{
id: 'dev-impl',
label: '개발 진행',
agentId: 'developer',
roleCategory: 'developer',
instructionTemplate:
'설계: {{stage.dev-design}}\n기획서: {{stage.plan-final}}\n\n' +
'설계대로 *실제 코드를 작성*하세요. 반드시 ConnectAI 액션 태그(`<create_file>`, `<edit_file>`, `<run_command>`)를 사용해 디스크에 떨어지도록.\n' +
'코드 블록만 보여주고 "생성 완료"라고 말하면 디스크엔 아무것도 안 만들어집니다. 작성 후 자가 검증 한 줄.',
},
{
id: 'qa',
label: 'QA 진행',
agentId: 'qa',
roleCategory: 'qa',
instructionTemplate:
'구현 결과: {{stage.dev-impl}}\n기획서: {{stage.plan-final}}\n\n' +
'*테스트 시나리오를 직접 실행*해 기능을 검증하세요. 케이스별로 PASS/FAIL 명확히 적고, 실패 시 재현 방법을 적어요.\n' +
'결론은 "✅ 모든 케이스 통과" 또는 "❌ 버그 발견: ..." 로 시작 (loop-back regex가 이걸 봅니다).',
loopBackPattern: '버그 발견|❌|버그|오류|실패',
loopBackTo: 'dev-impl',
maxIterations: 4,
},
{
id: 'deploy',
label: '라이브 배포',
agentId: 'developer',
roleCategory: 'developer',
instructionTemplate:
'QA 통과 결과: {{stage.qa}}\n\n' +
'배포 절차를 *실행*하세요. README 갱신, 버전 태깅, 배포 스크립트 실행 등 필요한 명령은 `<run_command>` 로.\n' +
'마지막에 배포된 상태 요약과 사용자에게 안내할 한 줄 (📝 다음).',
},
],
};
/** Read-only registry of templates the UI surfaces. Add more here later. */
export const PIPELINE_TEMPLATES: PipelineTemplate[] = [
FULL_PRODUCT_DEV,
];
export function getPipelineTemplate(id: string): PipelineTemplate | undefined {
return PIPELINE_TEMPLATES.find((t) => t.templateId === id);
}
+29 -2
View File
@@ -16,8 +16,8 @@
* memory/decisions and passes them in), which keeps it trivial to test.
*/
import { COMPANY_AGENTS } from './agents';
import { listAllAgents, resolveAgent, resolveAgentPrompt } from './companyConfig';
import { CompanyState } from './types';
import { listAllAgents, listActiveAgentsByCategory, resolveAgent, resolveAgentPrompt } from './companyConfig';
import { CompanyState, ROLE_CATEGORY_LABELS } from './types';
export interface SpecialistPromptInputs {
/** Active agent id. Must exist in `COMPANY_AGENTS`. */
@@ -192,6 +192,33 @@ export function buildPlannerSystemPrompt(
const allIds = listAllAgents(state).map((a) => a.id);
const inactive = allIds.filter((id) => !active.has(id));
const tail: string[] = [];
// ── 직군별 활성 에이전트 카탈로그 + 직군 가드 ───────────────────────────
// 작은 LLM (gemma e2b/4b 등) 은 사용자가 "코딩해줘" 한 마디만 해도
// developer로 직행하는 anchor bias가 강하다. 직군 가드 룰을 명시해서
// "개발 task가 있으려면 그 전에 planner task가 반드시 있어야 한다"
// 를 박아둔다. 100% 작동하진 않지만 도움이 됨. 더 확실히 가드하려면
// 사용자가 pipeline을 켜면 된다 — 그쪽은 deterministic.
const buckets = listActiveAgentsByCategory(state);
const usedCats = (Object.keys(buckets) as (keyof typeof buckets)[])
.filter((cat) => cat !== 'ceo' && buckets[cat].length > 0);
if (usedCats.length > 0) {
tail.push('');
tail.push('## 직군(role) 기반 작업 분배 규칙');
tail.push('이번 라운드에 활성화된 직군별 에이전트:');
for (const cat of usedCats) {
const label = ROLE_CATEGORY_LABELS[cat] || cat;
const names = buckets[cat].map((a) => `${a.emoji}${a.name}(${a.id})`).join(', ');
tail.push(`- **${label}** [${cat}]: ${names}`);
}
tail.push('');
tail.push('🛑 **직군 순서 가드 (반드시 준수)**:');
tail.push('1. `developer` 직군에게 코드 작성 task를 줄 때는 그 *전에* `planner` 직군의 task가 tasks 배열 안에 반드시 하나 이상 있어야 합니다. 사용자가 "지금 당장 만들어"라고 명시했어도 마찬가지 — 한 줄짜리 기획 정리도 planner task로 먼저 넣으세요.');
tail.push('2. `developer`·`designer`에게 task를 주는데 사용자 요청이 모호하다면, 그 전에 `researcher` 또는 `planner` task로 *맥락을 정리*하는 step을 먼저 넣으세요.');
tail.push('3. `qa`·`inspector` 직군은 *반드시 검토 대상이 되는 산출물 task가 같은 라운드에 존재할 때만* 호출하세요. 검토할 게 없는데 QA만 호출하면 빈 검토가 됩니다.');
tail.push('4. 사용자가 "기획 없이 바로 코드"라고 명시적으로 말한 경우에만 1번 규칙을 우회 가능 — 그 외엔 위반 금지.');
}
if (inactive.length > 0) {
tail.push('');
tail.push('현재 비활성화된 에이전트 (절대 dispatch 금지):');
+25
View File
@@ -182,6 +182,31 @@ export interface PipelineStage {
label: string;
/** Which agent runs this stage. Must resolve via `resolveAgent`. */
agentId: string;
/**
* 직군 hint stored at save time. Lets the editor re-open with the
* correct 직군 dropdown without having to re-derive it from agentId
* (handy when the user later changes the agent's 직군 override). The
* dispatcher itself doesn't read this — it goes straight from agentId
* to `resolveAgent`.
*/
roleCategory?: string;
/**
* Stage-level model override. Empty / missing → fall back to the
* agent's own model override → global default. Use case: light planning
* stages on a fast model (e.g. gemma e2b), heavy implementation stages
* on a strong model (e.g. qwen 14b). LM Studio's lifecycle manager
* swaps the model when the next stage's effective model differs.
*/
modelOverride?: string;
/**
* Manual approval gate. When true, the dispatcher pauses after this
* stage completes and waits for the user to click 승인 / 수정요청 /
* 중단 in the chat. 수정요청은 같은 stage를 사용자 코멘트와 함께
* 다시 실행. The pause is bounded by the existing turn-abort signal
* — if the user kills the turn instead of clicking, the wait resolves
* to "aborted" cleanly.
*/
requiresApproval?: boolean;
/**
* Instruction template. Tokens substituted before dispatch:
* - `{{userPrompt}}` — what the user typed
+29
View File
@@ -316,6 +316,35 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any
if (result.ok) await provider._sendCompanyPipelines();
return true;
}
case 'getCompanyPipelineTemplate': {
// Returns a template's stages so the editor can pre-fill the form.
const { getPipelineTemplate } = await import('../features/company');
const tplId = typeof data.templateId === 'string' ? data.templateId : '';
const tpl = getPipelineTemplate(tplId);
provider._view?.webview.postMessage({
type: 'companyPipelineTemplateContent',
value: tpl ? {
templateId: tpl.templateId,
suggestedPipelineId: tpl.suggestedPipelineId,
suggestedPipelineName: tpl.suggestedPipelineName,
stages: tpl.stages,
} : null,
});
return true;
}
case 'respondCompanyApproval': {
// Webview의 승인 카드 버튼 클릭 → dispatcher의 await 해제.
// payload: { stageId, decision: 'approve' | 'revise' | 'abort', comment? }
const stageId = typeof data.stageId === 'string' ? data.stageId : '';
const decision = typeof data.decision === 'string' ? data.decision : '';
if (!stageId || !['approve', 'revise', 'abort'].includes(decision)) return true;
let payload: any;
if (decision === 'approve') payload = { kind: 'approve' };
else if (decision === 'abort') payload = { kind: 'abort' };
else payload = { kind: 'revise', comment: typeof data.comment === 'string' ? data.comment : '' };
provider.resolveApprovalGate(stageId, payload);
return true;
}
case 'setActiveCompanyPipeline': {
const { setActivePipeline } = await import('../features/company');
const pid = typeof data.pipelineId === 'string' && data.pipelineId.trim()
+57
View File
@@ -45,6 +45,7 @@ import {
ROLE_CATEGORY_LABELS,
ROLE_CATEGORY_ORDER,
resolveAgent,
PIPELINE_TEMPLATES,
} from './features/company';
import { AIService } from './core/services';
@@ -102,6 +103,21 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
*/
private _companyAbort?: AbortController;
/**
* Open approval gates. The dispatcher emits `phase: 'awaiting-approval'`
* for stages with `requiresApproval`, and waits on a Promise this map
* stores. The webview surfaces 승인 / 수정요청 / 중단 buttons; clicks
* route through `chatHandlers.respondCompanyApproval` which calls
* `resolveApprovalGate(stageId, decision)` here.
*
* Keyed by stageId — only one approval may be pending per stage at a
* time (sequential dispatch), but multiple stages across the same turn
* each get their own entry as they hit their gate. On turn abort we
* resolve all outstanding entries with `{ kind: 'abort' }` so the
* dispatcher unblocks cleanly.
*/
private _pendingApprovals = new Map<string, (d: import('./features/company/dispatcher').ApprovalDecision) => void>();
/** Active file-system watcher for the project-architecture auto-update. Disposed on switch/detach. */
private _archWatcher?: vscode.FileSystemWatcher;
/** Debounce timer for the architecture watcher. */
@@ -1475,6 +1491,25 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
abortCompanyTurn(): boolean {
if (!this._companyAbort) return false;
this._companyAbort.abort();
// 승인 게이트 대기 중인 모든 stage를 'abort'로 해제. 안 하면 dispatcher가
// 영원히 await 상태로 남아 turn이 절대 종료 안 됨.
for (const resolve of this._pendingApprovals.values()) {
try { resolve({ kind: 'abort' }); } catch { /* noop */ }
}
this._pendingApprovals.clear();
return true;
}
/**
* Called by chatHandlers when the user clicks an approval card button.
* Resolves the dispatcher's awaitApproval promise for `stageId`. Idempotent
* — extra clicks after the first one are silently dropped.
*/
resolveApprovalGate(stageId: string, decision: import('./features/company/dispatcher').ApprovalDecision): boolean {
const resolve = this._pendingApprovals.get(stageId);
if (!resolve) return false;
this._pendingApprovals.delete(stageId);
try { resolve(decision); } catch { /* noop */ }
return true;
}
@@ -1495,6 +1530,16 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
for (const [cat, defs] of Object.entries(byCategory)) {
slimByCategory[cat] = defs.map((d) => ({ id: d.id, name: d.name, emoji: d.emoji }));
}
// 템플릿 카탈로그 — 가벼운 메타데이터만 (stages는 stamp 시점에 한 번
// 더 요청). UI는 dropdown 옵션 텍스트만 필요.
const templates = PIPELINE_TEMPLATES.map((t) => ({
templateId: t.templateId,
name: t.name,
description: t.description,
stageCount: t.stages.length,
suggestedPipelineId: t.suggestedPipelineId,
suggestedPipelineName: t.suggestedPipelineName,
}));
this._view.webview.postMessage({
type: 'companyPipelines',
value: {
@@ -1503,6 +1548,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
roleCategoryLabels: ROLE_CATEGORY_LABELS,
roleCategoryOrder: ROLE_CATEGORY_ORDER,
activeAgentsByCategory: slimByCategory,
templates,
},
});
}
@@ -1630,6 +1676,17 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
executeActionTags: (text) => this._agent.executeActionTagsOnText(text),
signal: abort.signal,
onEvent: emit,
// 승인 게이트 bridge — dispatcher가 호출하면 Promise를 만들어
// resolver를 _pendingApprovals에 보관 후 await. 사용자가 카드 버튼을
// 누르면 chatHandlers가 resolveApprovalGate(stageId, decision)을 호출
// 하고 그 resolve가 이 await을 풀어준다.
awaitApproval: ({ stageId }) => new Promise((resolve) => {
if (abort.signal.aborted) {
resolve({ kind: 'abort' });
return;
}
this._pendingApprovals.set(stageId, resolve);
}),
});
} catch (e: any) {
logError('company.runTurn: unexpected failure.', { error: e?.message ?? String(e) });