feat: Implement Pipeline Templates for Company Suite and refine orchestration logic
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1778740761033,
|
||||
"createdAt": 1778742370814,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
"createdAt": 1778740761031,
|
||||
"createdAt": 1778742370811,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
|
||||
"createdAt": 1778740761028,
|
||||
"createdAt": 1778742370809,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "---\nid: stress_conflict_1778740761013\ndate: 2026-05-14T06:39:21.035Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (13ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (3ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (3ms)\n",
|
||||
"createdAt": 1778740761035,
|
||||
"result": "---\nid: stress_conflict_1778742370793\ndate: 2026-05-14T07:06:10.815Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\nFinal report with inconsistencies. This should be long enough to pass validation.\n\nFinal report with inconsistencies. This should be long enough to pass validation.\n\n---\n## 💡 Astra의 선제적 제안 (Proactive Next Actions)\nFinal report with inconsistencies. This should be long enough to pass validation.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `60/100` | ⚠️ Medium |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[PLANNER]** 전략 수립 중... (14ms)\n- **[RESEARCHER]** 핵심 정보 수집 및 분석 중... (3ms)\n- **[WRITER]** 최종 리포트 작성 및 편집 중... (3ms)\n",
|
||||
"createdAt": 1778742370816,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+8
-8
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"missionId": "stress_conflict_1778740761013",
|
||||
"missionId": "stress_conflict_1778742370793",
|
||||
"status": "completed",
|
||||
"startTime": "2026-05-14T06:39:21.013Z",
|
||||
"totalElapsedMs": 23,
|
||||
"startTime": "2026-05-14T07:06:10.793Z",
|
||||
"totalElapsedMs": 24,
|
||||
"results": {
|
||||
"planner": "Detailed Execution Plan: 1. Research 2. Analyze 3. Write report with high quality.",
|
||||
"researcher": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
@@ -16,30 +16,30 @@
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "planner",
|
||||
"durationMs": 13,
|
||||
"durationMs": 14,
|
||||
"message": "전략 수립 중...",
|
||||
"ts": "2026-05-14T06:39:21.026Z"
|
||||
"ts": "2026-05-14T07:06:10.807Z"
|
||||
},
|
||||
{
|
||||
"from": "planner",
|
||||
"to": "researcher",
|
||||
"durationMs": 3,
|
||||
"message": "핵심 정보 수집 및 분석 중...",
|
||||
"ts": "2026-05-14T06:39:21.029Z"
|
||||
"ts": "2026-05-14T07:06:10.810Z"
|
||||
},
|
||||
{
|
||||
"from": "researcher",
|
||||
"to": "writer",
|
||||
"durationMs": 3,
|
||||
"message": "최종 리포트 작성 및 편집 중...",
|
||||
"ts": "2026-05-14T06:39:21.032Z"
|
||||
"ts": "2026-05-14T07:06:10.813Z"
|
||||
},
|
||||
{
|
||||
"from": "writer",
|
||||
"to": "completed",
|
||||
"durationMs": 4,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-05-14T06:39:21.036Z"
|
||||
"ts": "2026-05-14T07:06:10.817Z"
|
||||
}
|
||||
],
|
||||
"resilienceMetrics": {
|
||||
@@ -1,5 +1,15 @@
|
||||
# Astra Patch Notes
|
||||
|
||||
## v2.1.4 (2026-05-14)
|
||||
### 🏢 Enterprise Multi-Agent Suite & Reflector Stabilization
|
||||
- **Company Suite 고도화:** `CEO Planner`, `CEO Reporter` 등 1인 기업 모드를 위한 비즈니스 오케스트레이션 기능을 안정화하고, 부서 간 협업 로직을 강화했습니다.
|
||||
- **Self-Reflection (Reflector) 공식 도입:** Researcher와 Writer 사이에 비평(Critique) 단계를 추가하여, 리서치 결과의 누락이나 모순을 사전에 검증하는 인텔리전스 루프를 완성했습니다.
|
||||
- **지식 자산(ADR/Bug) 체계화:** 신규 ADR 및 버그 레코드를 통합하여 프로젝트의 의사결정 이력과 기술적 부채 관리 능력을 향상시켰습니다.
|
||||
- **인프라 환경 정비:** 프로젝트 루트 및 하위 모듈 간의 환경 설정 충돌을 해결하고 빌드 파이프라인의 견고함을 확보했습니다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## v2.1.3 (2026-05-14)
|
||||
### 🚀 Core Synergy & Distribution Alignment
|
||||
- **종속성 정합성 완결:** `package-lock.json`을 최신 엔진 사양에 맞춰 동기화하여 빌드 시의 의존성 충돌 가능성을 원천 차단했습니다.
|
||||
|
||||
@@ -550,6 +550,121 @@
|
||||
background: var(--accent); border-color: var(--accent); color: #fff;
|
||||
}
|
||||
|
||||
/* ── Pipeline stage cards ───────────────────────────────────── */
|
||||
.pipeline-stage-list {
|
||||
list-style: none; margin: 0; padding: 0;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.pipeline-stage-list:empty::before {
|
||||
content: '아직 단계가 없습니다. "+ Stage 추가" 로 첫 단계를 만드세요.';
|
||||
font-size: 11px; color: var(--text-dim); font-style: italic;
|
||||
padding: 12px 4px; display: block; text-align: center;
|
||||
}
|
||||
.pipeline-stage-card {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
}
|
||||
.pipeline-stage-card.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.pipeline-stage-card.drop-above {
|
||||
border-top: 2px solid var(--accent);
|
||||
}
|
||||
.pipeline-stage-card.drop-below {
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
.psc-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.psc-num {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 22px; height: 22px; border-radius: 50%;
|
||||
background: var(--accent); color: #fff;
|
||||
font-size: 11px; font-weight: 700; flex-shrink: 0;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
.psc-num:active { cursor: grabbing; }
|
||||
.psc-num::after {
|
||||
/* 드래그 가능 힌트. 마우스 호버 시만 살짝 보이게. */
|
||||
content: ''; opacity: 0;
|
||||
}
|
||||
.pipeline-stage-card:hover .psc-num {
|
||||
box-shadow: 0 0 0 2px var(--accent-glow, rgba(99,102,241,0.25));
|
||||
}
|
||||
.psc-label {
|
||||
flex: 1; min-width: 0;
|
||||
font-size: 12px; font-weight: 600;
|
||||
padding: 5px 8px; border-radius: 6px;
|
||||
background: var(--bg); color: var(--text-bright);
|
||||
border: 1px solid var(--border); font-family: inherit;
|
||||
}
|
||||
.psc-controls { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.psc-controls button {
|
||||
font-size: 11px; padding: 3px 8px; border-radius: 5px;
|
||||
background: var(--surface); color: var(--text-primary);
|
||||
border: 1px solid var(--border); cursor: pointer; line-height: 1;
|
||||
}
|
||||
.psc-controls button:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.psc-controls button:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.psc-controls button.del:hover { border-color: var(--error); color: var(--error); }
|
||||
|
||||
.psc-body { display: flex; flex-direction: column; gap: 8px; }
|
||||
.psc-row {
|
||||
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
|
||||
font-size: 10px; color: var(--text-dim);
|
||||
}
|
||||
.psc-row > label { white-space: nowrap; }
|
||||
.psc-row select {
|
||||
font-size: 11px; padding: 4px 6px; border-radius: 5px;
|
||||
background: var(--bg); color: var(--text-primary);
|
||||
border: 1px solid var(--border); min-width: 100px;
|
||||
}
|
||||
.psc-row select option { color: var(--text-primary); background: var(--bg); }
|
||||
|
||||
.psc-field-label { font-size: 10px; color: var(--text-dim); margin-bottom: 2px; }
|
||||
.psc-tokens { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.psc-tokens button {
|
||||
font-size: 10px; padding: 2px 8px; border-radius: 10px;
|
||||
background: var(--bg); color: var(--text-dim);
|
||||
border: 1px dashed var(--border); cursor: pointer;
|
||||
}
|
||||
.psc-tokens button:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.psc-instr {
|
||||
width: 100%; box-sizing: border-box;
|
||||
font-size: 11px; padding: 6px 8px; border-radius: 6px;
|
||||
background: var(--bg); color: var(--text-primary);
|
||||
border: 1px solid var(--border); font-family: inherit;
|
||||
resize: vertical; min-height: 50px;
|
||||
}
|
||||
.psc-loop {
|
||||
font-size: 10px; color: var(--text-dim);
|
||||
border-top: 1px dashed var(--border);
|
||||
padding-top: 6px;
|
||||
}
|
||||
.psc-loop summary {
|
||||
cursor: pointer; user-select: none;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.psc-loop summary:hover { color: var(--accent); }
|
||||
.psc-loop[open] summary { color: var(--text-bright); margin-bottom: 6px; }
|
||||
.psc-loop-grid {
|
||||
display: grid; grid-template-columns: auto 1fr; gap: 6px 10px;
|
||||
align-items: center; padding-left: 14px;
|
||||
}
|
||||
.psc-loop-grid input[type="text"],
|
||||
.psc-loop-grid input[type="number"],
|
||||
.psc-loop-grid select {
|
||||
font-size: 11px; padding: 4px 6px; border-radius: 5px;
|
||||
background: var(--bg); color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.psc-loop-grid input[type="number"] { max-width: 60px; }
|
||||
|
||||
/* Per-phase company turn header in chat. */
|
||||
.company-phase-card {
|
||||
border: 1px solid var(--border);
|
||||
@@ -566,6 +681,15 @@
|
||||
}
|
||||
.company-phase-card .cph-meta { color: var(--text-dim); font-size: 10px; }
|
||||
.company-phase-card.report .cph-head { color: var(--accent); }
|
||||
.company-phase-card.approval {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-glow, rgba(99,102,241,0.08));
|
||||
}
|
||||
.company-phase-card.approval .cph-head { color: var(--accent); }
|
||||
.company-phase-card.approval button {
|
||||
font-size: 11px; padding: 4px 10px; border-radius: 5px; cursor: pointer;
|
||||
}
|
||||
.company-phase-card.approval button:disabled { opacity: 0.55; cursor: default; }
|
||||
|
||||
/* Project Architecture chip — three-state surface above the input. */
|
||||
.arch-chip {
|
||||
|
||||
+13
-11
@@ -224,7 +224,11 @@
|
||||
<div class="map-section-hint">CEO 자유 분배 대신 사용자가 정한 stage 순서대로 dispatch합니다. loop-back 정규식이 매칭되면 이전 stage로 되돌아갑니다 (최대 maxIterations 회).</div>
|
||||
</div>
|
||||
<div class="map-btn-group">
|
||||
<button class="secondary-btn" id="addCompanyPipelineBtn" title="새 파이프라인 추가">+ Pipeline</button>
|
||||
<select id="pipelineTemplateSel" title="템플릿에서 새 파이프라인 만들기"
|
||||
style="padding:3px 6px; font-size:10px; background:var(--surface); color:var(--text-primary); border:1px solid var(--border); border-radius:5px;">
|
||||
<option value="">📋 템플릿에서…</option>
|
||||
</select>
|
||||
<button class="secondary-btn" id="addCompanyPipelineBtn" title="빈 파이프라인부터 시작">+ 빈 Pipeline</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-row" style="margin-top:8px; gap:8px; align-items:center;">
|
||||
@@ -236,26 +240,24 @@
|
||||
<ul id="companyPipelineList" class="map-list" style="margin-top:8px;"></ul>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline editor: hidden until "+ Pipeline" or "Edit" is clicked.
|
||||
MVP is a JSON textarea so we don't have to ship a stage-list
|
||||
reorder widget yet. -->
|
||||
<!-- Pipeline editor — 카드형 stage 에디터.
|
||||
각 stage는 직군 → 담당 cascading + 지시 텍스트 + 재시도 옵션을
|
||||
가진 카드 한 장으로 표현. JSON 직접 편집은 더 이상 불필요. -->
|
||||
<div id="pipelineEditForm" class="map-section company-agent-add-form" data-open="false">
|
||||
<div class="map-section-title" style="margin-bottom:8px;">파이프라인 편집</div>
|
||||
<div class="company-agent-add-grid">
|
||||
<label class="field-label">ID
|
||||
<label class="field-label">ID (한 번 정하면 변경 불가)
|
||||
<input type="text" id="pipelineEditId" placeholder="product-dev-v1" />
|
||||
</label>
|
||||
<label class="field-label">이름
|
||||
<input type="text" id="pipelineEditName" placeholder="제품 개발 v1" />
|
||||
</label>
|
||||
<label class="field-label" style="grid-column:1/-1;">Stages (JSON 배열)
|
||||
<textarea id="pipelineEditStages" rows="14" style="font-family:monospace; font-size:11px;"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="map-section-hint" style="margin-top:6px;">
|
||||
예시:
|
||||
<code style="font-size:10px;">[{"id":"plan","label":"기획","agentId":"writer","instructionTemplate":"{{userPrompt}} 에 대한 기획서 작성"},{"id":"dev","label":"개발","agentId":"developer","instructionTemplate":"다음 기획대로 구현: {{stage.plan}}","loopBackPattern":"버그|오류|fail","loopBackTo":"plan","maxIterations":3}]</code>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:12px; margin-bottom:6px;">
|
||||
<div style="font-size:11px; font-weight:700; color:var(--text-bright);">단계 (Stages)</div>
|
||||
<button class="secondary-btn" id="addStageBtn" style="font-size:10px; padding:3px 8px;">+ Stage 추가</button>
|
||||
</div>
|
||||
<ul id="pipelineStageList" class="pipeline-stage-list"></ul>
|
||||
<div class="editor-actions" style="margin-top:10px;">
|
||||
<button id="cancelPipelineEditBtn">Cancel</button>
|
||||
<button class="primary" id="savePipelineEditBtn">저장</button>
|
||||
|
||||
+523
-17
@@ -827,6 +827,15 @@
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'companyPipelineTemplateContent': {
|
||||
const tpl = msg.value;
|
||||
if (!tpl) { showToast('템플릿을 찾을 수 없습니다.', 'warn'); break; }
|
||||
// 에디터를 미리 채워서 연다 — 사용자는 ID/name/지시문만 다듬으면 됨.
|
||||
if (typeof window.__openPipelineEditorWithTemplate === 'function') {
|
||||
window.__openPipelineEditorWithTemplate(tpl);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'upsertCompanyPipelineResult': {
|
||||
const v = msg.value || {};
|
||||
const errEl = document.getElementById('pipelineEditError');
|
||||
@@ -1627,18 +1636,350 @@
|
||||
};
|
||||
}
|
||||
|
||||
// ── Work Pipeline editor ──
|
||||
// ── Work Pipeline editor (card-based) ──
|
||||
const _activePipelineSel = document.getElementById('activePipelineSel');
|
||||
const _pipelineList = document.getElementById('companyPipelineList');
|
||||
const _addPipelineBtn = document.getElementById('addCompanyPipelineBtn');
|
||||
const _pipelineEditForm = document.getElementById('pipelineEditForm');
|
||||
const _pipelineEditId = document.getElementById('pipelineEditId');
|
||||
const _pipelineEditName = document.getElementById('pipelineEditName');
|
||||
const _pipelineEditStages = document.getElementById('pipelineEditStages');
|
||||
const _pipelineStageList = document.getElementById('pipelineStageList');
|
||||
const _addStageBtn = document.getElementById('addStageBtn');
|
||||
const _pipelineEditError = document.getElementById('pipelineEditError');
|
||||
const _cancelPipelineEditBtn = document.getElementById('cancelPipelineEditBtn');
|
||||
const _savePipelineEditBtn = document.getElementById('savePipelineEditBtn');
|
||||
|
||||
// pipeline 페이로드에 같이 오는 직군별 활성 에이전트 캐시.
|
||||
// 카드의 "직군 → 담당" cascading dropdown이 이걸 참조한다.
|
||||
let _activeAgentsByCategory = {};
|
||||
|
||||
// 현재 편집 중인 stages의 in-memory 표현. 카드 UI는 이 배열만 보고
|
||||
// 다시 그려지므로 텍스트 입력 외 모든 상태 변경(추가/삭제/순서변경/
|
||||
// 직군 변경)이 즉시 re-render를 통해 화면과 데이터를 동기화한다.
|
||||
let _editStages = [];
|
||||
// 드래그 중인 stage의 원본 인덱스. -1 = 드래그 중 아님.
|
||||
// 한 페이지에 에디터 인스턴스는 하나뿐이라 모듈 스코프로 충분.
|
||||
let _draggedStageIndex = -1;
|
||||
|
||||
const _genStageId = (taken) => {
|
||||
const used = new Set(taken);
|
||||
let n = 1;
|
||||
while (used.has(`stage-${n}`)) n++;
|
||||
return `stage-${n}`;
|
||||
};
|
||||
|
||||
const _emptyStage = () => ({
|
||||
id: _genStageId(_editStages.map((s) => s.id)),
|
||||
label: '',
|
||||
roleCategory: 'planner',
|
||||
agentId: '',
|
||||
modelOverride: '',
|
||||
requiresApproval: false,
|
||||
instructionTemplate: '',
|
||||
loopBackPattern: '',
|
||||
loopBackTo: '',
|
||||
maxIterations: 3,
|
||||
});
|
||||
|
||||
const _firstAgentOfCategory = (cat) => {
|
||||
const list = _activeAgentsByCategory[cat] || [];
|
||||
return list[0]?.id || '';
|
||||
};
|
||||
|
||||
const _buildStageCard = (stage, index, total) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'pipeline-stage-card';
|
||||
li.dataset.stageId = stage.id;
|
||||
li.dataset.index = String(index);
|
||||
|
||||
// ── head: number + label input + ↑/↓/🗑 ──
|
||||
const head = document.createElement('div');
|
||||
head.className = 'psc-head';
|
||||
const num = document.createElement('span');
|
||||
num.className = 'psc-num';
|
||||
num.textContent = String(index + 1);
|
||||
num.title = '드래그하여 순서 변경 (↑/↓ 버튼도 가능)';
|
||||
|
||||
// ── 드래그 핸들: 번호 칩만 드래그 시작점이 되도록.
|
||||
// textarea·input 영역에서 드래그가 시작되면 텍스트 선택과 충돌함.
|
||||
// li 자체는 드래그 가능하되 dragstart는 핸들 클릭 후에만 허용.
|
||||
li.draggable = false;
|
||||
let _dragArmed = false;
|
||||
num.addEventListener('mousedown', () => {
|
||||
li.draggable = true;
|
||||
_dragArmed = true;
|
||||
});
|
||||
// mouseup이 어디서든 일어나면 드래그 해제 (drop 후엔 dragend가 처리).
|
||||
const _disarm = () => {
|
||||
if (_dragArmed) { _dragArmed = false; li.draggable = false; }
|
||||
};
|
||||
li.addEventListener('mouseup', _disarm);
|
||||
li.addEventListener('mouseleave', _disarm);
|
||||
|
||||
li.addEventListener('dragstart', (e) => {
|
||||
if (!_dragArmed) { e.preventDefault(); return; }
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// 텍스트 페이로드는 필요 없지만 일부 브라우저가 비어 있으면 무시.
|
||||
try { e.dataTransfer.setData('text/plain', stage.id); } catch {}
|
||||
li.classList.add('dragging');
|
||||
// li reference를 모듈 스코프 변수에 기록 — dragover/drop이 참조.
|
||||
_draggedStageIndex = index;
|
||||
});
|
||||
li.addEventListener('dragend', () => {
|
||||
li.classList.remove('dragging');
|
||||
li.draggable = false;
|
||||
_dragArmed = false;
|
||||
_draggedStageIndex = -1;
|
||||
// 모든 카드의 drop indicator 제거.
|
||||
_pipelineStageList?.querySelectorAll('.drop-above, .drop-below')
|
||||
.forEach((el) => el.classList.remove('drop-above', 'drop-below'));
|
||||
});
|
||||
li.addEventListener('dragover', (e) => {
|
||||
if (_draggedStageIndex < 0 || _draggedStageIndex === index) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
// 카드의 위/아래 절반에 따라 indicator 위치 결정.
|
||||
const rect = li.getBoundingClientRect();
|
||||
const above = (e.clientY - rect.top) < rect.height / 2;
|
||||
li.classList.toggle('drop-above', above);
|
||||
li.classList.toggle('drop-below', !above);
|
||||
});
|
||||
li.addEventListener('dragleave', () => {
|
||||
li.classList.remove('drop-above', 'drop-below');
|
||||
});
|
||||
li.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
li.classList.remove('drop-above', 'drop-below');
|
||||
if (_draggedStageIndex < 0 || _draggedStageIndex === index) return;
|
||||
const rect = li.getBoundingClientRect();
|
||||
const above = (e.clientY - rect.top) < rect.height / 2;
|
||||
const from = _draggedStageIndex;
|
||||
let to = above ? index : index + 1;
|
||||
// splice 보정: from < to 인 경우 to를 한 칸 당겨야 의도한 위치.
|
||||
if (from < to) to -= 1;
|
||||
if (from === to) return;
|
||||
const [moved] = _editStages.splice(from, 1);
|
||||
_editStages.splice(to, 0, moved);
|
||||
_draggedStageIndex = -1;
|
||||
_renderStages();
|
||||
});
|
||||
const lbl = document.createElement('input');
|
||||
lbl.type = 'text';
|
||||
lbl.className = 'psc-label';
|
||||
lbl.placeholder = '단계 이름 (예: 기획 논의)';
|
||||
lbl.value = stage.label || '';
|
||||
lbl.oninput = () => { stage.label = lbl.value; };
|
||||
const ctrls = document.createElement('div');
|
||||
ctrls.className = 'psc-controls';
|
||||
const upBtn = document.createElement('button');
|
||||
upBtn.textContent = '↑'; upBtn.title = '위로'; upBtn.disabled = index === 0;
|
||||
upBtn.onclick = () => { if (index > 0) { [_editStages[index - 1], _editStages[index]] = [_editStages[index], _editStages[index - 1]]; _renderStages(); } };
|
||||
const downBtn = document.createElement('button');
|
||||
downBtn.textContent = '↓'; downBtn.title = '아래로'; downBtn.disabled = index === total - 1;
|
||||
downBtn.onclick = () => { if (index < _editStages.length - 1) { [_editStages[index + 1], _editStages[index]] = [_editStages[index], _editStages[index + 1]]; _renderStages(); } };
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'del'; delBtn.textContent = '🗑'; delBtn.title = '단계 삭제';
|
||||
delBtn.onclick = () => {
|
||||
if (!confirm(`'${stage.label || stage.id}' 단계를 삭제할까요?`)) return;
|
||||
_editStages.splice(index, 1);
|
||||
_renderStages();
|
||||
};
|
||||
ctrls.appendChild(upBtn); ctrls.appendChild(downBtn); ctrls.appendChild(delBtn);
|
||||
head.appendChild(num); head.appendChild(lbl); head.appendChild(ctrls);
|
||||
li.appendChild(head);
|
||||
|
||||
// ── body ──
|
||||
const body = document.createElement('div');
|
||||
body.className = 'psc-body';
|
||||
|
||||
// row: 직군 → 담당
|
||||
const row = document.createElement('div');
|
||||
row.className = 'psc-row';
|
||||
const roleLabel = document.createElement('label');
|
||||
roleLabel.textContent = '직군:';
|
||||
const roleSel = document.createElement('select');
|
||||
for (const cat of _roleCategoryOrder) {
|
||||
if (cat === 'ceo') continue; // stage agent로 CEO는 안 씀
|
||||
const opt = document.createElement('option');
|
||||
opt.value = cat;
|
||||
opt.textContent = _roleCategoryLabels[cat] || cat;
|
||||
roleSel.appendChild(opt);
|
||||
}
|
||||
roleSel.value = stage.roleCategory || 'planner';
|
||||
const agentLabel = document.createElement('label');
|
||||
agentLabel.textContent = '담당:';
|
||||
const agentSel = document.createElement('select');
|
||||
const _refillAgentSel = () => {
|
||||
agentSel.innerHTML = '';
|
||||
const list = _activeAgentsByCategory[roleSel.value] || [];
|
||||
if (list.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = '(이 직군의 활성 에이전트 없음)';
|
||||
agentSel.appendChild(opt);
|
||||
agentSel.disabled = true;
|
||||
} else {
|
||||
agentSel.disabled = false;
|
||||
for (const a of list) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.id;
|
||||
opt.textContent = `${a.emoji} ${a.name}`;
|
||||
agentSel.appendChild(opt);
|
||||
}
|
||||
// 현재 stage의 agentId가 이 직군에 속하면 유지, 아니면 첫 번째.
|
||||
const inList = list.some((a) => a.id === stage.agentId);
|
||||
agentSel.value = inList ? stage.agentId : list[0].id;
|
||||
stage.agentId = agentSel.value;
|
||||
}
|
||||
};
|
||||
_refillAgentSel();
|
||||
roleSel.onchange = () => {
|
||||
stage.roleCategory = roleSel.value;
|
||||
stage.agentId = _firstAgentOfCategory(roleSel.value);
|
||||
_refillAgentSel();
|
||||
};
|
||||
agentSel.onchange = () => { stage.agentId = agentSel.value; };
|
||||
row.appendChild(roleLabel); row.appendChild(roleSel);
|
||||
row.appendChild(agentLabel); row.appendChild(agentSel);
|
||||
|
||||
// 모델 select — 비워두면 에이전트의 모델 override → 글로벌 default 사용.
|
||||
const modelLbl = document.createElement('label'); modelLbl.textContent = '모델:';
|
||||
const stageModelSel = document.createElement('select');
|
||||
stageModelSel.title = '비워두면 담당 에이전트의 모델 설정(또는 글로벌 기본)을 사용';
|
||||
populateAgentModelSelect(stageModelSel, stage.modelOverride || '');
|
||||
// populateAgentModelSelect는 첫 옵션 라벨이 "default (global)"인데
|
||||
// stage 맥락에선 "기본 (에이전트 설정 사용)"이 더 정확. 첫 옵션 텍스트만 교체.
|
||||
if (stageModelSel.options.length > 0 && stageModelSel.options[0].value === '') {
|
||||
stageModelSel.options[0].text = '기본 (에이전트 설정)';
|
||||
}
|
||||
stageModelSel.onchange = () => { stage.modelOverride = stageModelSel.value || ''; };
|
||||
row.appendChild(modelLbl); row.appendChild(stageModelSel);
|
||||
|
||||
body.appendChild(row);
|
||||
|
||||
// ── 승인 게이트 체크박스 ──
|
||||
// 한 stage가 끝난 뒤 사용자가 직접 ✅승인 / ✎수정요청 / 🛑중단 을
|
||||
// 누를 때까지 dispatcher가 대기. 검토 stage(inspector)나 중요 결정
|
||||
// 직전에 켜두면 자동 진행을 막을 수 있다.
|
||||
const approvalWrap = document.createElement('label');
|
||||
approvalWrap.style.cssText = 'display:flex; gap:6px; align-items:center; font-size:10px; color:var(--text-dim); cursor:pointer;';
|
||||
const approvalCb = document.createElement('input');
|
||||
approvalCb.type = 'checkbox';
|
||||
approvalCb.checked = !!stage.requiresApproval;
|
||||
approvalCb.onchange = () => { stage.requiresApproval = approvalCb.checked; };
|
||||
approvalWrap.appendChild(approvalCb);
|
||||
const approvalText = document.createElement('span');
|
||||
approvalText.textContent = '✋ 이 단계 후 내 승인 받기';
|
||||
approvalText.title = '체크하면 stage 완료 후 자동 진행하지 않고 승인/수정요청/중단 버튼이 채팅창에 뜸';
|
||||
approvalWrap.appendChild(approvalText);
|
||||
body.appendChild(approvalWrap);
|
||||
|
||||
// 지시 텍스트 + 토큰 버튼
|
||||
const instrLabelDiv = document.createElement('div');
|
||||
instrLabelDiv.className = 'psc-field-label';
|
||||
instrLabelDiv.textContent = '지시 내용 (담당자에게 전달될 메시지)';
|
||||
body.appendChild(instrLabelDiv);
|
||||
|
||||
const tokens = document.createElement('div');
|
||||
tokens.className = 'psc-tokens';
|
||||
const instr = document.createElement('textarea');
|
||||
instr.className = 'psc-instr';
|
||||
instr.placeholder = '예: {{userPrompt}} 에 대한 기획서 초안을 작성해주세요. 시장 조사 결과({{stage.research}})를 참고하세요.';
|
||||
instr.value = stage.instructionTemplate || '';
|
||||
instr.oninput = () => { stage.instructionTemplate = instr.value; };
|
||||
|
||||
const _insertToken = (token) => {
|
||||
const start = instr.selectionStart ?? instr.value.length;
|
||||
const end = instr.selectionEnd ?? instr.value.length;
|
||||
instr.value = instr.value.slice(0, start) + token + instr.value.slice(end);
|
||||
stage.instructionTemplate = instr.value;
|
||||
instr.focus();
|
||||
instr.selectionStart = instr.selectionEnd = start + token.length;
|
||||
};
|
||||
const mkTokenBtn = (label, token) => {
|
||||
const b = document.createElement('button');
|
||||
b.textContent = label;
|
||||
b.onclick = (e) => { e.preventDefault(); _insertToken(token); };
|
||||
return b;
|
||||
};
|
||||
tokens.appendChild(mkTokenBtn('+ 사용자 요청', '{{userPrompt}}'));
|
||||
tokens.appendChild(mkTokenBtn('+ CEO 브리프', '{{brief}}'));
|
||||
// 이전 stages의 출력을 참조하는 토큰들 — 자기 자신 이후는 제외.
|
||||
for (let i = 0; i < index; i++) {
|
||||
const prev = _editStages[i];
|
||||
const label = prev.label || prev.id;
|
||||
tokens.appendChild(mkTokenBtn(`+ ${label}`, `{{stage.${prev.id}}}`));
|
||||
}
|
||||
body.appendChild(tokens);
|
||||
body.appendChild(instr);
|
||||
|
||||
// ── loop-back details ──
|
||||
const loop = document.createElement('details');
|
||||
loop.className = 'psc-loop';
|
||||
if (stage.loopBackPattern || stage.loopBackTo) loop.open = true;
|
||||
const summary = document.createElement('summary');
|
||||
const hasLoop = !!(stage.loopBackPattern && stage.loopBackTo);
|
||||
summary.textContent = hasLoop
|
||||
? `🔁 재시도 활성: "${stage.loopBackPattern}" 발견 시 → ${_editStages.find((s) => s.id === stage.loopBackTo)?.label || stage.loopBackTo}`
|
||||
: '🔁 재시도 조건 설정하기 (선택)';
|
||||
loop.appendChild(summary);
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'psc-loop-grid';
|
||||
// condition
|
||||
const condLbl = document.createElement('label'); condLbl.textContent = '조건 (regex):';
|
||||
const condInput = document.createElement('input');
|
||||
condInput.type = 'text';
|
||||
condInput.placeholder = '예: 버그|오류|fail|재작업';
|
||||
condInput.value = stage.loopBackPattern || '';
|
||||
condInput.oninput = () => { stage.loopBackPattern = condInput.value; };
|
||||
// target
|
||||
const tgtLbl = document.createElement('label'); tgtLbl.textContent = '되돌아갈 단계:';
|
||||
const tgtSel = document.createElement('select');
|
||||
const emptyOpt = document.createElement('option');
|
||||
emptyOpt.value = ''; emptyOpt.textContent = '(선택 안 함)';
|
||||
tgtSel.appendChild(emptyOpt);
|
||||
for (let i = 0; i < index; i++) {
|
||||
const prev = _editStages[i];
|
||||
const opt = document.createElement('option');
|
||||
opt.value = prev.id;
|
||||
opt.textContent = `${i + 1}. ${prev.label || prev.id}`;
|
||||
tgtSel.appendChild(opt);
|
||||
}
|
||||
tgtSel.value = (stage.loopBackTo && index > 0) ? stage.loopBackTo : '';
|
||||
tgtSel.onchange = () => { stage.loopBackTo = tgtSel.value; };
|
||||
// max
|
||||
const maxLbl = document.createElement('label'); maxLbl.textContent = '최대 반복:';
|
||||
const maxInput = document.createElement('input');
|
||||
maxInput.type = 'number'; maxInput.min = '1'; maxInput.max = '10';
|
||||
maxInput.value = String(stage.maxIterations || 3);
|
||||
maxInput.oninput = () => {
|
||||
const v = parseInt(maxInput.value, 10);
|
||||
stage.maxIterations = Number.isFinite(v) ? Math.max(1, Math.min(10, v)) : 3;
|
||||
};
|
||||
grid.appendChild(condLbl); grid.appendChild(condInput);
|
||||
grid.appendChild(tgtLbl); grid.appendChild(tgtSel);
|
||||
grid.appendChild(maxLbl); grid.appendChild(maxInput);
|
||||
if (index === 0) {
|
||||
const note = document.createElement('div');
|
||||
note.style.cssText = 'grid-column:1/-1; font-style:italic; opacity:0.7;';
|
||||
note.textContent = '첫 단계는 되돌아갈 곳이 없어 재시도 설정이 적용되지 않습니다.';
|
||||
grid.appendChild(note);
|
||||
}
|
||||
loop.appendChild(grid);
|
||||
body.appendChild(loop);
|
||||
|
||||
li.appendChild(body);
|
||||
return li;
|
||||
};
|
||||
|
||||
const _renderStages = () => {
|
||||
if (!_pipelineStageList) return;
|
||||
_pipelineStageList.innerHTML = '';
|
||||
for (let i = 0; i < _editStages.length; i++) {
|
||||
_pipelineStageList.appendChild(_buildStageCard(_editStages[i], i, _editStages.length));
|
||||
}
|
||||
};
|
||||
|
||||
const _openPipelineEditor = (pipeline) => {
|
||||
if (!_pipelineEditForm) return;
|
||||
_pipelineEditForm.setAttribute('data-open', 'true');
|
||||
@@ -1646,17 +1987,39 @@
|
||||
if (pipeline) {
|
||||
if (_pipelineEditId) { _pipelineEditId.value = pipeline.id; _pipelineEditId.disabled = true; }
|
||||
if (_pipelineEditName) _pipelineEditName.value = pipeline.name || '';
|
||||
if (_pipelineEditStages) _pipelineEditStages.value = JSON.stringify(pipeline.stages || [], null, 2);
|
||||
// stages 데이터를 깊은 복사 — 사용자 수정이 cancel 시 안 새도록.
|
||||
_editStages = (pipeline.stages || []).map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label || '',
|
||||
// 기존 데이터에 roleCategory가 없으면 담당 agentId의 직군으로 추정.
|
||||
roleCategory: s.roleCategory || _deriveRoleFromAgent(s.agentId) || 'planner',
|
||||
agentId: s.agentId || '',
|
||||
modelOverride: s.modelOverride || '',
|
||||
requiresApproval: !!s.requiresApproval,
|
||||
instructionTemplate: s.instructionTemplate || '',
|
||||
loopBackPattern: s.loopBackPattern || '',
|
||||
loopBackTo: s.loopBackTo || '',
|
||||
maxIterations: s.maxIterations || 3,
|
||||
}));
|
||||
} else {
|
||||
if (_pipelineEditId) { _pipelineEditId.value = ''; _pipelineEditId.disabled = false; }
|
||||
if (_pipelineEditName) _pipelineEditName.value = '';
|
||||
if (_pipelineEditStages) _pipelineEditStages.value = '[]';
|
||||
_editStages = [];
|
||||
}
|
||||
_renderStages();
|
||||
};
|
||||
|
||||
// 빌트인 + 커스텀 에이전트의 직군 매핑 캐시 (lastCompanyAgentsPayload 활용)
|
||||
const _deriveRoleFromAgent = (agentId) => {
|
||||
if (!agentId || !_lastCompanyAgentsPayload) return null;
|
||||
const agent = (_lastCompanyAgentsPayload.agents || []).find((a) => a.id === agentId);
|
||||
return agent?.roleCategory || null;
|
||||
};
|
||||
|
||||
const _closePipelineEditor = () => {
|
||||
if (_pipelineEditForm) _pipelineEditForm.setAttribute('data-open', 'false');
|
||||
if (_pipelineEditId) _pipelineEditId.disabled = false;
|
||||
_editStages = [];
|
||||
};
|
||||
|
||||
if (_addPipelineBtn) {
|
||||
@@ -1665,31 +2028,75 @@
|
||||
_pipelineEditId?.focus();
|
||||
};
|
||||
}
|
||||
|
||||
// 템플릿에서 새 pipeline 만들기. 선택 시 백엔드에 stages를 요청 →
|
||||
// companyPipelineTemplateContent 응답이 오면 에디터를 미리 채워 연다.
|
||||
const _pipelineTemplateSel = document.getElementById('pipelineTemplateSel');
|
||||
if (_pipelineTemplateSel) {
|
||||
_pipelineTemplateSel.onchange = () => {
|
||||
const tplId = _pipelineTemplateSel.value;
|
||||
if (!tplId) return;
|
||||
vscode.postMessage({ type: 'getCompanyPipelineTemplate', templateId: tplId });
|
||||
// 선택 후 즉시 초기화 — 사용자가 같은 템플릿을 다시 찍어도 onchange가 다시 발화하도록.
|
||||
_pipelineTemplateSel.value = '';
|
||||
};
|
||||
}
|
||||
if (_addStageBtn) {
|
||||
_addStageBtn.onclick = () => {
|
||||
const ns = _emptyStage();
|
||||
ns.agentId = _firstAgentOfCategory(ns.roleCategory);
|
||||
_editStages.push(ns);
|
||||
_renderStages();
|
||||
// 새로 추가한 카드의 라벨 입력으로 포커스 — 이름부터 적게 유도.
|
||||
const newCard = _pipelineStageList?.lastElementChild;
|
||||
newCard?.querySelector('.psc-label')?.focus();
|
||||
};
|
||||
}
|
||||
if (_cancelPipelineEditBtn) {
|
||||
_cancelPipelineEditBtn.onclick = _closePipelineEditor;
|
||||
}
|
||||
if (_savePipelineEditBtn) {
|
||||
_savePipelineEditBtn.onclick = () => {
|
||||
let stages;
|
||||
try {
|
||||
stages = JSON.parse(_pipelineEditStages?.value || '[]');
|
||||
} catch (e) {
|
||||
if (_pipelineEditError) _pipelineEditError.textContent = `Stages JSON 파싱 실패: ${e.message}`;
|
||||
const id = (_pipelineEditId?.value || '').trim().toLowerCase();
|
||||
if (!id) {
|
||||
if (_pipelineEditError) _pipelineEditError.textContent = 'id는 필수입니다 (소문자로 시작, /-/_).';
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(stages)) {
|
||||
if (_pipelineEditError) _pipelineEditError.textContent = 'Stages는 배열이어야 합니다.';
|
||||
// 각 stage 검증: 라벨 + 담당 에이전트 필수.
|
||||
for (let i = 0; i < _editStages.length; i++) {
|
||||
const s = _editStages[i];
|
||||
if (!s.label?.trim()) {
|
||||
if (_pipelineEditError) _pipelineEditError.textContent = `${i + 1}번 단계: 이름이 비어 있습니다.`;
|
||||
return;
|
||||
}
|
||||
if (!s.agentId) {
|
||||
if (_pipelineEditError) _pipelineEditError.textContent = `${i + 1}번 단계: 담당 에이전트를 지정하세요. 해당 직군에 활성 에이전트가 없으면 관리 패널에서 활성화 먼저.`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const def = {
|
||||
id: (_pipelineEditId?.value || '').trim().toLowerCase(),
|
||||
name: (_pipelineEditName?.value || '').trim(),
|
||||
stages,
|
||||
id,
|
||||
name: (_pipelineEditName?.value || '').trim() || id,
|
||||
stages: _editStages.map((s) => {
|
||||
const out = {
|
||||
id: s.id,
|
||||
label: s.label.trim(),
|
||||
agentId: s.agentId,
|
||||
roleCategory: s.roleCategory,
|
||||
instructionTemplate: s.instructionTemplate || '',
|
||||
};
|
||||
if (!def.id) {
|
||||
if (_pipelineEditError) _pipelineEditError.textContent = 'id는 필수입니다 (소문자/숫자/-/_).';
|
||||
return;
|
||||
if (s.modelOverride && s.modelOverride.trim()) {
|
||||
out.modelOverride = s.modelOverride.trim();
|
||||
}
|
||||
if (s.requiresApproval) out.requiresApproval = true;
|
||||
if (s.loopBackPattern && s.loopBackTo) {
|
||||
out.loopBackPattern = s.loopBackPattern;
|
||||
out.loopBackTo = s.loopBackTo;
|
||||
out.maxIterations = s.maxIterations || 3;
|
||||
}
|
||||
return out;
|
||||
}),
|
||||
};
|
||||
vscode.postMessage({ type: 'upsertCompanyPipeline', def });
|
||||
};
|
||||
}
|
||||
@@ -1702,10 +2109,35 @@
|
||||
};
|
||||
}
|
||||
|
||||
let _lastCompanyPipelinesPayload = null;
|
||||
const renderCompanyPipelines = (payload) => {
|
||||
if (!_pipelineList || !_activePipelineSel) return;
|
||||
_lastCompanyPipelinesPayload = payload || null;
|
||||
const pipelines = (payload && payload.pipelines && typeof payload.pipelines === 'object') ? payload.pipelines : {};
|
||||
const activeId = payload && payload.activePipelineId ? payload.activePipelineId : '';
|
||||
// 직군별 에이전트 + 라벨 캐시 갱신. 에디터가 열려 있는 동안
|
||||
// 새 페이로드가 오면 카드의 담당 dropdown도 새 목록 반영.
|
||||
if (payload && payload.activeAgentsByCategory) {
|
||||
_activeAgentsByCategory = payload.activeAgentsByCategory;
|
||||
}
|
||||
if (payload && payload.roleCategoryLabels) _roleCategoryLabels = payload.roleCategoryLabels;
|
||||
if (payload && Array.isArray(payload.roleCategoryOrder)) _roleCategoryOrder = payload.roleCategoryOrder;
|
||||
// 템플릿 드롭다운 채우기.
|
||||
const tplSel = document.getElementById('pipelineTemplateSel');
|
||||
if (tplSel && payload && Array.isArray(payload.templates)) {
|
||||
tplSel.innerHTML = '<option value="">📋 템플릿에서…</option>';
|
||||
for (const t of payload.templates) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.templateId;
|
||||
opt.textContent = `${t.name} (${t.stageCount}단계)`;
|
||||
opt.title = t.description || '';
|
||||
tplSel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
// 에디터가 열려 있으면 stages 다시 그려서 새로운 담당 옵션 반영.
|
||||
if (_pipelineEditForm?.getAttribute('data-open') === 'true' && _editStages.length > 0) {
|
||||
_renderStages();
|
||||
}
|
||||
// active dropdown
|
||||
_activePipelineSel.innerHTML = '<option value="">기본 (CEO 자유 분배)</option>';
|
||||
for (const p of Object.values(pipelines)) {
|
||||
@@ -1759,6 +2191,25 @@
|
||||
// expose for the message handler below
|
||||
window.__renderCompanyPipelines = renderCompanyPipelines;
|
||||
window.__closePipelineEditor = _closePipelineEditor;
|
||||
// 템플릿 stamp 시 호출 — id/name 제안값 + stages를 카드 에디터에 미리 채움.
|
||||
window.__openPipelineEditorWithTemplate = (tpl) => {
|
||||
if (!tpl) return;
|
||||
// suggested id가 이미 존재하면 -2, -3 식으로 충돌 회피.
|
||||
const taken = new Set(Object.keys((_lastCompanyPipelinesPayload?.pipelines) || {}));
|
||||
let id = tpl.suggestedPipelineId || 'pipeline';
|
||||
if (taken.has(id)) {
|
||||
let n = 2;
|
||||
while (taken.has(`${id}-${n}`)) n++;
|
||||
id = `${id}-${n}`;
|
||||
}
|
||||
_openPipelineEditor({
|
||||
id,
|
||||
name: tpl.suggestedPipelineName || id,
|
||||
stages: tpl.stages || [],
|
||||
});
|
||||
// suggested id는 새로 만드는 것이므로 잠가두지 않고 사용자가 바꿀 수 있게.
|
||||
if (_pipelineEditId) _pipelineEditId.disabled = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep the last payload around so we can re-render whenever the
|
||||
@@ -2172,6 +2623,61 @@
|
||||
} else if (ev.phase === 'stage-loop') {
|
||||
card.innerHTML = `<div class="cph-head">🔁 Stage 재시도</div>
|
||||
<div class="cph-meta">${escAttr(ev.from)} → ${escAttr(ev.to)} (반복 ${ev.iteration}회)</div>`;
|
||||
} else if (ev.phase === 'awaiting-approval') {
|
||||
// 승인 게이트 카드 — 사용자가 클릭할 때까지 대기.
|
||||
card.className += ' approval';
|
||||
card.dataset.stageId = ev.stageId;
|
||||
const head = document.createElement('div');
|
||||
head.className = 'cph-head';
|
||||
head.innerHTML = `✋ <strong>${escAttr(ev.stageLabel || ev.stageId)}</strong> 완료 — 검토 후 승인해 주세요`;
|
||||
const actions = document.createElement('div');
|
||||
actions.style.cssText = 'display:flex; gap:6px; margin-top:8px; flex-wrap:wrap;';
|
||||
const approveBtn = document.createElement('button');
|
||||
approveBtn.className = 'send-btn';
|
||||
approveBtn.textContent = '✅ 승인 (다음으로)';
|
||||
approveBtn.onclick = () => {
|
||||
vscode.postMessage({ type: 'respondCompanyApproval', stageId: ev.stageId, decision: 'approve' });
|
||||
actions.querySelectorAll('button').forEach((b) => b.disabled = true);
|
||||
approveBtn.textContent = '✅ 승인됨';
|
||||
};
|
||||
const reviseBtn = document.createElement('button');
|
||||
reviseBtn.className = 'secondary-btn';
|
||||
reviseBtn.textContent = '✎ 수정 요청';
|
||||
reviseBtn.onclick = () => {
|
||||
const comment = prompt('어떤 부분을 수정하면 좋을까요? (담당 에이전트에게 전달됩니다)', '');
|
||||
if (comment === null) return; // 취소
|
||||
vscode.postMessage({ type: 'respondCompanyApproval', stageId: ev.stageId, decision: 'revise', comment });
|
||||
actions.querySelectorAll('button').forEach((b) => b.disabled = true);
|
||||
reviseBtn.textContent = '✎ 수정 요청 전송됨';
|
||||
};
|
||||
const abortBtn = document.createElement('button');
|
||||
abortBtn.className = 'secondary-btn';
|
||||
abortBtn.textContent = '🛑 중단';
|
||||
abortBtn.title = '여기서 파이프라인 중단';
|
||||
abortBtn.onclick = () => {
|
||||
if (!confirm('이 라운드를 여기서 중단할까요? 이미 완료된 stage 결과는 보존됩니다.')) return;
|
||||
vscode.postMessage({ type: 'respondCompanyApproval', stageId: ev.stageId, decision: 'abort' });
|
||||
actions.querySelectorAll('button').forEach((b) => b.disabled = true);
|
||||
abortBtn.textContent = '🛑 중단 요청';
|
||||
};
|
||||
actions.appendChild(approveBtn);
|
||||
actions.appendChild(reviseBtn);
|
||||
actions.appendChild(abortBtn);
|
||||
card.innerHTML = '';
|
||||
card.appendChild(head);
|
||||
card.appendChild(actions);
|
||||
} else if (ev.phase === 'approval-resolved') {
|
||||
// 이전에 그려둔 awaiting-approval 카드를 갱신 — 별도 카드를 또 만들지 않음.
|
||||
const prev = chatEl.querySelector(`.company-phase-card.approval[data-stage-id="${CSS.escape(ev.stageId)}"]`);
|
||||
if (prev) {
|
||||
const tail = document.createElement('div');
|
||||
tail.className = 'cph-meta';
|
||||
tail.style.marginTop = '6px';
|
||||
const label = ev.decision === 'approve' ? '✅ 승인됨' : ev.decision === 'revise' ? '✎ 수정 요청됨' : '🛑 사용자가 중단';
|
||||
tail.textContent = label;
|
||||
prev.appendChild(tail);
|
||||
}
|
||||
return; // 새 카드 안 만듦
|
||||
} else if (ev.phase === 'report-start') {
|
||||
card.innerHTML = '<div class="cph-head">🧭 CEO 종합 보고서 작성 중…</div>';
|
||||
} else if (ev.phase === 'report-done') {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "astra",
|
||||
"displayName": "Astra",
|
||||
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
|
||||
"version": "2.1.3",
|
||||
"version": "2.1.4",
|
||||
"publisher": "g1nation",
|
||||
"license": "MIT",
|
||||
"icon": "assets/icon.png",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 금지):');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) });
|
||||
|
||||
Reference in New Issue
Block a user