From 19d587d9c586dc48ee8b222fae00a4c1c8b58538 Mon Sep 17 00:00:00 2001 From: StatPan Date: Sun, 19 Apr 2026 12:00:55 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20Phase=206=20review=20follow-up=20?= =?UTF-8?q?=E2=80=94=20extract=20shared=20util,=20remove=20GET=20side-effe?= =?UTF-8?q?ct,=20fix=20dead=20branch,=20add=20needs-answer=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 7 +++--- src/client/canvas/AgentCanvas.tsx | 2 +- src/server/routes/agent.test.ts | 11 +++++++++ src/server/routes/agent.ts | 41 ++----------------------------- src/server/routes/tree.ts | 14 +---------- src/server/utils/projectGroup.ts | 12 +++++++++ 6 files changed, 31 insertions(+), 56 deletions(-) create mode 100644 src/server/utils/projectGroup.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2616598..0f1e7c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,9 +35,10 @@ Phase 6: `GET /api/agent/tree` 감독용 조회 API + SSE 재연결/오류복구 이 세션(PM)의 역할은 분석·계획·검토다. 코드 편집은 Dev 세션에 위임한다. - **분석**: 관련 파일을 Read로 읽어 현재 구조와 맥락을 파악한다 -- **스펙 작성**: 대상 파일·변경 내용·이유·완료 기준을 포함한 구현 스펙을 작성한다 -- **Dev 세션 호출**: `cd {project_dir} && claude -p "{스펙}" --model sonnet --output-format json --dangerously-skip-permissions` -- **결과 검토**: 변경된 파일을 Read로 확인하여 스펙 준수 여부와 코드 품질을 검토한다 +- **PRD 수신**: 오케스트레이터로부터 PRD(`~/workspace/statpan_docs/projects/_ORCHESTRATION/templates/PRD.md` 양식)를 받는다. PRD에는 목표 상태·Acceptance Criteria·Scope 경계가 포함된다. +- **Tech Spec 작성**: PRD를 기반으로 `~/workspace/statpan_docs/projects/_ORCHESTRATION/templates/TECH_SPEC.md` 양식에 맞춰 Tech Spec을 작성한다. 저장 위치: `~/workspace/statpan_docs/projects/agentree/TechSpecs/` +- **Dev 세션 호출**: Tech Spec 전문을 전달한다. `cd {project_dir} && claude -p "$(cat {tech_spec_path})" --model sonnet --output-format json --dangerously-skip-permissions` +- **결과 검토**: Tech Spec의 Acceptance Criteria 항목별 evidence를 확인한다. evidence 없는 완료 보고는 불인정한다. 코드를 직접 Edit/Write/Bash로 수정하지 않는다. diff --git a/src/client/canvas/AgentCanvas.tsx b/src/client/canvas/AgentCanvas.tsx index bf81f06..8ef6d06 100644 --- a/src/client/canvas/AgentCanvas.tsx +++ b/src/client/canvas/AgentCanvas.tsx @@ -322,7 +322,7 @@ export function AgentCanvas() { {sseStatus !== 'connected' && (
{ expect(body.pendingQuestions[0].requestId).toBe('q-1') }) + it('overrides session status when a pending question exists', async () => { + vi.mocked(getPendingQuestions).mockReturnValue([ + { requestId: 'q-1', sessionId: 'sess-1', message: 'Which branch?', metadata: {} }, + ]) + const res = await app.request('/api/agent/tree') + expect(res.status).toBe(200) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const body = await res.json() as any + expect(body.sessions.find((s: { id: string }) => s.id === 'sess-1')?.status).toBe('needs-answer') + }) + it('overrides session status when a pending permission exists', async () => { vi.mocked(getPendingPermissions).mockReturnValue([ { requestId: 'req-1', sessionId: 'sess-1', message: 'Allow file write?', metadata: {} }, diff --git a/src/server/routes/agent.ts b/src/server/routes/agent.ts index aa5e336..a6ad24d 100644 --- a/src/server/routes/agent.ts +++ b/src/server/routes/agent.ts @@ -1,23 +1,11 @@ import { Hono } from 'hono' -import { getAllCanvasNodes, getAllProjects, getAllSessionRelations, getAllTaskInvocations, getForkRelationMap, findOrCreateProject, setCanvasNodeProject } from '../db/index.js' +import { getAllCanvasNodes, getAllProjects, getAllSessionRelations, getAllTaskInvocations, getForkRelationMap } from '../db/index.js' import { opencodeAdapter } from '../opencode/index.js' import { getPendingPermissions, getPendingQuestions } from '../sse/broadcaster.js' +import { projectGroupFromDirectory } from '../utils/projectGroup.js' export const agentRouter = new Hono() -function projectGroupFromDirectory(directory: string): string { - const marker = '/workspace/' - const index = directory.indexOf(marker) - const normalized = index >= 0 ? directory.slice(index + marker.length) : directory.replace(/^\/+/, '') - const parts = normalized.split('/').filter(Boolean) - if (parts.length === 0) return 'workspace' - const bucketPrefixes = new Set(['apps', 'research', 'pypi_lib', 'libs', 'infra', 'skills', 'mcps', 'anal-repo']) - if (parts.length >= 2 && bucketPrefixes.has(parts[0])) { - return `${parts[0]}/${parts[1]}` - } - return parts[0] -} - /** * GET /api/agent/tree * Compact supervisor-agent view of the session tree. @@ -41,16 +29,6 @@ agentRouter.get('/api/agent/tree', async (c) => { const sessions = sessionsResult.value const statusBySession = statusResult.status === 'fulfilled' ? statusResult.value : {} - // Auto-create/resolve projects (same logic as tree.ts) - const projectByDirectoryKey = new Map() - for (const session of sessions) { - const dirKey = projectGroupFromDirectory(session.directory) - if (!projectByDirectoryKey.has(dirKey)) { - const proj = findOrCreateProject(dirKey) - projectByDirectoryKey.set(dirKey, proj) - } - } - const canvasBySession = new Map( getAllCanvasNodes().map((node) => [ node.session_id, @@ -63,21 +41,6 @@ agentRouter.get('/api/agent/tree', async (c) => { ]), ) - for (const session of sessions) { - const dirKey = projectGroupFromDirectory(session.directory) - const proj = projectByDirectoryKey.get(dirKey) - if (!proj) continue - const canvas = canvasBySession.get(session.id) - if (!canvas?.projectId) { - setCanvasNodeProject(session.id, proj.id) - if (canvas) { - canvas.projectId = proj.id - } else { - canvasBySession.set(session.id, { label: null, pinned: false, detached: false, projectId: proj.id }) - } - } - } - const relations = getAllSessionRelations() const taskInvocations = getAllTaskInvocations() const projects = getAllProjects() diff --git a/src/server/routes/tree.ts b/src/server/routes/tree.ts index 63836ca..fc1b12e 100644 --- a/src/server/routes/tree.ts +++ b/src/server/routes/tree.ts @@ -2,22 +2,10 @@ import { Hono } from 'hono' import { getAllCanvasNodes, getAllProjects, getForkRelationMap, getAllSessionRelations, getAllTaskInvocations, findOrCreateProject, setCanvasNodeProject } from '../db/index.js' import type { ProjectRow } from '../db/schema.js' import { opencodeAdapter } from '../opencode/index.js' +import { projectGroupFromDirectory } from '../utils/projectGroup.js' export const treeRouter = new Hono() -function projectGroupFromDirectory(directory: string): string { - const marker = '/workspace/' - const index = directory.indexOf(marker) - const normalized = index >= 0 ? directory.slice(index + marker.length) : directory.replace(/^\/+/, '') - const parts = normalized.split('/').filter(Boolean) - if (parts.length === 0) return 'workspace' - const bucketPrefixes = new Set(['apps', 'research', 'pypi_lib', 'libs', 'infra', 'skills', 'mcps', 'anal-repo']) - if (parts.length >= 2 && bucketPrefixes.has(parts[0])) { - return `${parts[0]}/${parts[1]}` - } - return parts[0] -} - treeRouter.get('/api/tree', async (c) => { const [sessionsResult, statusResult, compatResult] = await Promise.allSettled([ opencodeAdapter.listSessions(), diff --git a/src/server/utils/projectGroup.ts b/src/server/utils/projectGroup.ts new file mode 100644 index 0000000..24e5cdf --- /dev/null +++ b/src/server/utils/projectGroup.ts @@ -0,0 +1,12 @@ +export function projectGroupFromDirectory(directory: string): string { + const marker = '/workspace/' + const index = directory.indexOf(marker) + const normalized = index >= 0 ? directory.slice(index + marker.length) : directory.replace(/^\/+/, '') + const parts = normalized.split('/').filter(Boolean) + if (parts.length === 0) return 'workspace' + const bucketPrefixes = new Set(['apps', 'research', 'pypi_lib', 'libs', 'infra', 'skills', 'mcps', 'anal-repo']) + if (parts.length >= 2 && bucketPrefixes.has(parts[0])) { + return `${parts[0]}/${parts[1]}` + } + return parts[0] +}