e2e-agent-runner 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # e2e-runner — suseona 작업 에이전트 로컬 러너
2
2
 
3
- 내 컴퓨터의 **Claude Code** 로 `구현 → 테스트 → 리뷰 → 배포` 파이프라인을 자동 수행하는
3
+ 내 컴퓨터의 **Claude Code** 로 `구현 → 테스트 → 리뷰 → 배포` 파이프라인과 웹사이트 기능 지도 추출을 자동 수행하는
4
4
  상주 데몬입니다. 코드는 내 폴더에, 실행은 내 Claude 구독으로 — 서버(e2e)는 오케스트레이션만
5
5
  담당합니다. 아웃바운드 폴링만 쓰므로 인바운드 포트·포트포워딩이 필요 없습니다.
6
6
 
@@ -89,6 +89,20 @@ WantedBy=default.target
89
89
  | `CLAUDE_BIN` | | `claude` | claude CLI 경로(비표준 설치 위치) |
90
90
  | `POLL_S` / `HEARTBEAT_S` | | 3 / 30 | 폴링·하트비트 주기 |
91
91
  | `RUNNER_STEP_TIMEOUT_S` | | 2700 | 단계 타임아웃(서버 watchdog 3600s 보다 작게) |
92
+ | `DISCOVER_TIMEOUT_S` / `MAX_TURNS_DISCOVER` | | 5400 / 500 | 기능 지도 추출 시간·턴 상한 |
93
+ | `E2E_BROWSER_MCP` | | (Playwright) | `off`=브라우저 없이 코드만 / MCP 설정 JSON 경로=사용자 지정 브라우저 MCP |
94
+ | `E2E_PLAYWRIGHT_MCP` | | `@playwright/mcp@0.0.84` | 기능 지도용 Playwright MCP 패키지 스펙 |
95
+ | `E2E_BROWSER_HEADED` | | — | `1`이면 브라우저 창을 띄워 탐색(디버깅) |
96
+
97
+ ## 기능 지도 추출(웹사이트 프로젝트)
98
+
99
+ 웹에서 프로젝트 유형을 **웹사이트**로 지정하면 "기능 지도 추출"을 실행할 수 있습니다. 러너는
100
+ Claude Code 에 **Playwright MCP**(`npx @playwright/mcp`, 격리 프로필·헤드리스)를 붙여 실행하고,
101
+ Claude 는 소스의 라우트 정의를 읽은 뒤 **라이브 사이트에 직접 접속해 화면을 보며** 라우트별 기능과
102
+ 상세 정의(흐름·입력 검증·기대 결과·상태·예외·QA 체크포인트)를 임시 폴더에 기록합니다. 러너가 이를
103
+ 수집해 서버에 보고합니다. 이 단계는 **읽기 전용**(브랜치·커밋 없음)이며 라이브 사이트에서 삭제·결제·
104
+ 발송 같은 파괴적 동작을 하지 않도록 지시됩니다. 첫 실행 시 브라우저 다운로드가 필요할 수 있습니다
105
+ (Claude 가 `browser_install` 로 자동 설치).
92
106
 
93
107
  ## 보안 노트
94
108
 
package/claude.mjs CHANGED
@@ -44,11 +44,13 @@ const BRIEF_MAX = 240; // tool_use 요약 상한
44
44
  * @param {(ev:{kind:string,data:object})=>void} opts.onEvent message/tool 이벤트 콜백
45
45
  * @param {()=>boolean} opts.shouldCancel 취소 플래그 폴링(라인 수신·1s 인터벌)
46
46
  * @param {(sessionId:string)=>void} opts.onSessionId init 이벤트 수신(session 저장·task_meta 전송)
47
+ * @param {string[]} [opts.extraArgs] claude 추가 인자(discover 의 MCP·추가 디렉터리)
47
48
  * @returns {Promise<{ok:boolean, cancelled?:boolean, timedOut?:boolean, finalText?:string,
48
49
  * sessionId?:string|null, usage?:object|null, error?:string|null}>}
49
50
  */
50
51
  export function runClaude(opts) {
51
52
  const { cwd, prompt, resumeSessionId, maxTurns, timeoutS, onEvent, shouldCancel, onSessionId } = opts;
53
+ const extraArgs = opts.extraArgs ?? [];
52
54
  return new Promise((resolve) => {
53
55
  const args = [
54
56
  '-p', prompt,
@@ -57,11 +59,15 @@ export function runClaude(opts) {
57
59
  '--permission-mode', 'bypassPermissions',
58
60
  '--max-turns', String(maxTurns),
59
61
  ...(resumeSessionId ? ['--resume', resumeSessionId] : []),
62
+ // 페이즈별 추가 플래그(discover: --mcp-config 브라우저 MCP·--add-dir 산출 폴더).
63
+ // 가변 인자 플래그(--mcp-config/--add-dir)가 프롬프트를 삼키지 않도록 항상 마지막에 둔다.
64
+ ...extraArgs,
60
65
  ];
61
66
  // detached(POSIX)=신규 프로세스 그룹 — 취소·타임아웃이 claude 가 띄운 도구 자식(bash 등)까지
62
67
  // 종료시킨다. detached 로 파이프(stdio)는 그대로 동작한다.
68
+ // stdin 은 닫는다 — 파이프로 열려 있으면 claude -p 가 입력을 3s 기다린 뒤 경고하고 진행한다.
63
69
  const child = spawn(CLAUDE_BIN, args, {
64
- cwd, env: process.env, detached: process.platform !== 'win32',
70
+ cwd, env: process.env, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'],
65
71
  });
66
72
  let settled = false;
67
73
  let cancelled = false;
package/discover.mjs ADDED
@@ -0,0 +1,221 @@
1
+ // 기능 지도(Feature Map) 추출 — discover 페이즈 실행기.
2
+ //
3
+ // Claude Code 를 프로젝트 폴더(소스 코드 정찰)에서 실행하되, 브라우저 MCP(Playwright)를 붙여
4
+ // 라이브 사이트에 직접 접속해 화면(스크린샷·접근성 스냅샷)을 보며 기능을 확인하게 한다.
5
+ // Claude 는 러너가 만든 임시 산출 폴더에 라우트별 JSON(routes/NNN.json)과 map.json 을
6
+ // 점진적으로 기록하고, 러너는 종료 후(시간 초과·실패 포함) 기록분을 수집해 artifact 로 보고한다.
7
+ //
8
+ // 읽기 전용 페이즈: git 브랜치·프리플라이트·커밋 없음. 저장소가 수정되면 경고 이벤트만 남긴다
9
+ // (변경을 되돌리지 않는다 — 사용자 파일일 수 있으므로 판단은 사용자에게).
10
+ //
11
+ // env:
12
+ // E2E_BROWSER_MCP off=브라우저 없이(코드만) | <JSON 파일 경로>=사용자 MCP 설정 | 미설정=Playwright MCP
13
+ // E2E_PLAYWRIGHT_MCP Playwright MCP 패키지 스펙(기본 @playwright/mcp@0.0.84 — 고정 버전)
14
+ // E2E_BROWSER_HEADED=1 브라우저 창 표시(디버깅)
15
+ // E2E_KEEP_FM_OUT=1 산출 폴더 보존(디버깅)
16
+ import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, existsSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { execFile } from 'node:child_process';
20
+ import { promisify } from 'node:util';
21
+
22
+ const exec = promisify(execFile);
23
+
24
+ const PLAYWRIGHT_MCP = process.env.E2E_PLAYWRIGHT_MCP || '@playwright/mcp@0.0.84';
25
+ const ARTIFACT_MAX_BYTES = 6 * 1024 * 1024; // step_result 본문 상한(서버가 다시 정규화·절단)
26
+ const ROUTE_FILE_MAX_BYTES = 1024 * 1024;
27
+
28
+ /** 산출 폴더 준비 + 브라우저 MCP 설정 → { outDir, routesDir, resultsDir, extraArgs, browser, promptAddon, cleanup }
29
+ * results=true(QA 페이즈)면 results/ 폴더도 만들고 산출 안내에 포함한다. */
30
+ export function prepareDiscover({ siteUrl, results = false }) {
31
+ const outDir = mkdtempSync(join(tmpdir(), 'e2e-fm-'));
32
+ const routesDir = join(outDir, 'routes');
33
+ const resultsDir = join(outDir, 'results');
34
+ const shotsDir = join(outDir, 'shots');
35
+ mkdirSync(routesDir, { recursive: true });
36
+ mkdirSync(shotsDir, { recursive: true });
37
+ if (results) mkdirSync(resultsDir, { recursive: true });
38
+
39
+ const mode = (process.env.E2E_BROWSER_MCP || '').trim();
40
+ let mcpPath = null;
41
+ let browser = 'playwright';
42
+ if (mode === 'off') {
43
+ browser = 'off';
44
+ } else if (mode && existsSync(mode)) {
45
+ mcpPath = mode; // 사용자 제공 MCP 설정(예: 사내 프록시·기존 Chrome 프로필)
46
+ browser = 'custom';
47
+ } else {
48
+ const pwArgs = [
49
+ '-y', PLAYWRIGHT_MCP,
50
+ '--isolated', // 매 실행 깨끗한 프로필 — 사용자 브라우저 세션·쿠키와 격리
51
+ '--viewport-size', '1440,900',
52
+ '--output-dir', shotsDir,
53
+ ...(process.env.E2E_BROWSER_HEADED === '1' ? [] : ['--headless']),
54
+ ];
55
+ // MCP 서버의 cwd 를 스크린샷 폴더로 고정 — Playwright MCP 는 상대 filename 을 자기 cwd(=claude cwd=
56
+ // 사용자 저장소) 기준으로 저장해 읽기 전용 탐색이 저장소를 오염시킨다(실검증에서 발견). mcp 설정엔
57
+ // cwd 필드가 없으므로 셸 래퍼로 이동 후 exec 한다(경로는 위치 인자로 전달 — 따옴표 주입 없음).
58
+ const server = process.platform === 'win32'
59
+ ? { command: 'cmd', args: ['/c', `cd /d "${shotsDir}" && npx ${pwArgs.map((a) => `"${a}"`).join(' ')}`] }
60
+ : { command: 'sh', args: ['-c', 'cd "$0" && exec npx "$@"', shotsDir, ...pwArgs] };
61
+ mcpPath = join(outDir, 'mcp.json');
62
+ writeFileSync(mcpPath, JSON.stringify({ mcpServers: { playwright: server } }, null, 2));
63
+ }
64
+
65
+ const extraArgs = ['--add-dir', outDir];
66
+ if (mcpPath) extraArgs.unshift('--mcp-config', mcpPath);
67
+
68
+ const promptAddon = [
69
+ '',
70
+ '# 산출물 위치(러너 지정 — 이 경로에만 쓰기)',
71
+ `- 라우트 파일 폴더: \`${routesDir}\` (예: ${join(routesDir, '001.json')})`,
72
+ `- 서비스 요약 파일: \`${join(outDir, 'map.json')}\``,
73
+ ...(results ? [
74
+ `- QA 결과 파일 폴더: \`${resultsDir}\` (예: ${join(resultsDir, '001-<id8>.json')})`,
75
+ `- 추가 산출 파일(QA 계획·재검증 페이즈): \`${join(outDir, 'plan.json')}\` · \`${join(outDir, 'retest.json')}\``,
76
+ ] : []),
77
+ `- 스크린샷 등 브라우저 산출물은 \`${shotsDir}\` 에 저장됩니다(저장소에 저장 금지).`,
78
+ '',
79
+ '# 브라우저 도구',
80
+ browser === 'off'
81
+ ? '- 이 머신은 브라우저 도구가 비활성화되어 있습니다(E2E_BROWSER_MCP=off). 코드 기반으로 추출하고 coverage.browser 를 "unavailable" 로 기록하세요.'
82
+ : [
83
+ `- 브라우저 MCP 도구(mcp__playwright__browser_* 등)를 사용해 ${siteUrl || '대상 사이트'} 에 직접 접속하세요.`,
84
+ ' browser_navigate 로 이동 → browser_take_screenshot 으로 화면을 직접 보고 → browser_snapshot 으로 요소를 확인 →',
85
+ ' browser_click/browser_type 등으로 비파괴적 상호작용을 확인합니다. 화면 폭 확인은 browser_resize.',
86
+ `- browser_take_screenshot 의 filename 을 지정할 때는 반드시 절대 경로(${shotsDir} 아래)를 쓰거나 생략하세요 — 저장소 폴더에 이미지를 저장하면 안 됩니다.`,
87
+ '- 브라우저 실행 파일이 없다는 오류가 나면 browser_install 도구를 한 번 실행한 뒤 다시 시도하세요.',
88
+ '- 브라우저 도구가 끝내 동작하지 않으면 코드 기반으로 계속하고 coverage.browser 를 "unavailable" 로 기록하세요.',
89
+ ].join('\n'),
90
+ ].join('\n');
91
+
92
+ const cleanup = () => {
93
+ if (process.env.E2E_KEEP_FM_OUT === '1') return;
94
+ try {
95
+ rmSync(outDir, { recursive: true, force: true });
96
+ } catch {
97
+ /* 임시 폴더 — OS 가 정리 */
98
+ }
99
+ };
100
+
101
+ return { outDir, routesDir, resultsDir, extraArgs, browser, promptAddon, cleanup };
102
+ }
103
+
104
+ /** 기록된 라우트 파일 수(진행률 이벤트용). */
105
+ export function countRouteFiles(routesDir) {
106
+ try {
107
+ return readdirSync(routesDir).filter((f) => f.endsWith('.json')).length;
108
+ } catch {
109
+ return 0;
110
+ }
111
+ }
112
+
113
+ function readJson(path, maxBytes) {
114
+ const size = statSync(path).size;
115
+ if (size > maxBytes) throw new Error(`파일 과대(${size}B)`);
116
+ return JSON.parse(readFileSync(path, 'utf8'));
117
+ }
118
+
119
+ /** 최종 보고 텍스트의 마지막 ```json 블록 — 파일 기록이 전혀 없을 때의 폴백. */
120
+ function fallbackFromText(text) {
121
+ if (!text) return null;
122
+ const blocks = [...text.matchAll(/```json\s*([\s\S]*?)```/g)];
123
+ for (let i = blocks.length - 1; i >= 0; i--) {
124
+ try {
125
+ const v = JSON.parse(blocks[i][1]);
126
+ if (v && Array.isArray(v.routes)) return v;
127
+ if (v && typeof v.path === 'string') return { routes: [v] };
128
+ if (v && Array.isArray(v.results)) return { results: v.results };
129
+ if (v && Array.isArray(v.items)) return { plan: v };
130
+ } catch {
131
+ /* 다음 블록 */
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /** 폴더의 *.json 을 정렬 순서로 파싱 적재(parse 실패는 errors 기록). */
138
+ function readJsonDir(dir, list, errors) {
139
+ let files = [];
140
+ try {
141
+ files = readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
142
+ } catch {
143
+ return;
144
+ }
145
+ for (const f of files) {
146
+ try {
147
+ const v = readJson(join(dir, f), ROUTE_FILE_MAX_BYTES);
148
+ if (Array.isArray(v)) list.push(...v);
149
+ else list.push(v);
150
+ } catch (e) {
151
+ errors.push(`${f} 파싱 실패: ${e.message}`.slice(0, 300));
152
+ }
153
+ }
154
+ }
155
+
156
+ /** 산출 폴더 수집 → artifact {map, routes[], results[], plan, retest, partial, errors[], browser}.
157
+ * feature_map 은 routes/map 을, QA 페이즈는 results/plan/retest 를 기록한다(서버가 페이즈별 해석). */
158
+ export function collectArtifact({ outDir, routesDir, resultsDir, browser, finalText, partial }) {
159
+ const errors = [];
160
+ let map = null;
161
+ const mapPath = join(outDir, 'map.json');
162
+ if (existsSync(mapPath)) {
163
+ try {
164
+ map = readJson(mapPath, ROUTE_FILE_MAX_BYTES);
165
+ } catch (e) {
166
+ errors.push(`map.json 파싱 실패: ${e.message}`.slice(0, 300));
167
+ }
168
+ } else if (!resultsDir) {
169
+ errors.push('map.json 없음(요약·커버리지 누락)');
170
+ }
171
+
172
+ const routes = [];
173
+ readJsonDir(routesDir, routes, errors);
174
+ const results = [];
175
+ if (resultsDir) readJsonDir(resultsDir, results, errors);
176
+
177
+ let plan = null;
178
+ let retest = null;
179
+ for (const [name, key] of [['plan.json', 'plan'], ['retest.json', 'retest']]) {
180
+ const p = join(outDir, name);
181
+ if (existsSync(p)) {
182
+ try {
183
+ const v = readJson(p, ROUTE_FILE_MAX_BYTES);
184
+ if (key === 'plan') plan = v; else retest = v;
185
+ } catch (e) {
186
+ errors.push(`${name} 파싱 실패: ${e.message}`.slice(0, 300));
187
+ }
188
+ }
189
+ }
190
+
191
+ if (!routes.length && !results.length && !plan && !retest) {
192
+ const fb = fallbackFromText(finalText);
193
+ if (fb) {
194
+ if (fb.routes) routes.push(...fb.routes);
195
+ if (fb.results) results.push(...fb.results);
196
+ if (fb.map && !map) map = fb.map;
197
+ errors.push('산출 파일이 없어 최종 보고의 JSON 블록을 사용');
198
+ }
199
+ }
200
+
201
+ const artifact = { map, routes, results, plan, retest, partial: Boolean(partial), errors, browser };
202
+ // 본문 상한 — 초과 시 뒤쪽 라우트/결과부터 제외(Claude 는 핵심 항목부터 기록한다).
203
+ let size = Buffer.byteLength(JSON.stringify(artifact));
204
+ while (size > ARTIFACT_MAX_BYTES && (artifact.routes.length > 1 || artifact.results.length > 1)) {
205
+ if (artifact.routes.length >= artifact.results.length) artifact.routes.pop();
206
+ else artifact.results.pop();
207
+ artifact.partial = true;
208
+ size = Buffer.byteLength(JSON.stringify(artifact));
209
+ }
210
+ return artifact;
211
+ }
212
+
213
+ /** 저장소 변경 감지(읽기 전용 위반 경고용) — git 저장소가 아니면 null. */
214
+ export async function porcelain(cwd) {
215
+ try {
216
+ const { stdout } = await exec('git', ['status', '--porcelain'], { cwd, maxBuffer: 8 * 1024 * 1024 });
217
+ return stdout.trim();
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "e2e-agent-runner",
3
- "version": "1.0.2",
4
- "description": "suseona e2e 작업 에이전트 로컬 러너 — 내 컴퓨터의 Claude Code 로 구현→테스트→리뷰→배포를 자동 수행",
3
+ "version": "1.2.0",
4
+ "description": "suseona e2e 작업 에이전트 로컬 러너 — 내 컴퓨터의 Claude Code 로 구현→테스트→리뷰→배포와 웹사이트 기능 지도 추출을 자동 수행",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
7
7
  "private": false,
@@ -17,6 +17,7 @@
17
17
  "api.mjs",
18
18
  "claude.mjs",
19
19
  "git.mjs",
20
+ "discover.mjs",
20
21
  "README.md"
21
22
  ],
22
23
  "repository": {
package/runner.mjs CHANGED
@@ -17,6 +17,7 @@ import { fileURLToPath } from 'node:url';
17
17
  import { dirname, join } from 'node:path';
18
18
  import { api, runnerIdFromToken, sleep } from './api.mjs';
19
19
  import { parsePhaseMarker, probeClaude, runClaude } from './claude.mjs';
20
+ import { collectArtifact, countRouteFiles, porcelain, prepareDiscover } from './discover.mjs';
20
21
  import {
21
22
  commitCount, ensureTaskBranch, escapeTaskBranch, GitError, mergeToBase, preflight, probeFolder, taskBranchName,
22
23
  } from './git.mjs';
@@ -49,6 +50,9 @@ const STEP_TIMEOUT_S = num(process.env.RUNNER_STEP_TIMEOUT_S, 2700); // 서버 w
49
50
  const DEPLOY_TIMEOUT_S = num(process.env.DEPLOY_TIMEOUT_S, 600);
50
51
  const MAX_TURNS_IMPLEMENT = num(process.env.MAX_TURNS_IMPLEMENT, 150);
51
52
  const MAX_TURNS_OTHER = num(process.env.MAX_TURNS_OTHER, 60);
53
+ const MAX_TURNS_DISCOVER = num(process.env.MAX_TURNS_DISCOVER, 500);
54
+ // 기능 지도는 사이트 규모에 비례해 길다 — 서버 와치독은 하트비트(30s)로 갱신되므로 이보다 길어도 안전.
55
+ const DISCOVER_TIMEOUT_S = num(process.env.DISCOVER_TIMEOUT_S, 5400);
52
56
  const BUSY_PROBE_POLL_S = 10; // 단계 실행 중 경로 확인(probe) 폴링 주기
53
57
 
54
58
  function num(v, dflt) {
@@ -162,6 +166,8 @@ async function handleStep({ step, task, project }) {
162
166
  result = await runImplement({ step, task, project, buf, cancelled });
163
167
  } else if (step.phase === 'deploy') {
164
168
  result = await runDeploy({ step, task, project, buf, cancelled });
169
+ } else if (step.phase === 'discover' || step.phase.startsWith('qa_')) {
170
+ result = await runDiscover({ step, task, project, buf, cancelled });
165
171
  } else {
166
172
  // test / remediate / review — 서버 프롬프트(prompt_md)·세션(resume) 그대로 실행.
167
173
  result = await runPhase({ step, task, project, buf, cancelled });
@@ -178,6 +184,8 @@ async function handleStep({ step, task, project }) {
178
184
  cancelled: Boolean(cancelledNow),
179
185
  // implement 전용 — 작업 브랜치 커밋 수(무변경 단축 신호). 다른 페이즈는 미첨부.
180
186
  commits: result.commits,
187
+ // discover 전용 — 기능 지도 산출물(서버가 정규화·기록).
188
+ artifact: result.artifact,
181
189
  usage: result.usage ?? undefined,
182
190
  });
183
191
  log(`단계 종결: ${task.id.slice(0, 8)}/${step.phase} → ${cancelledNow ? 'cancelled' : result.ok ? 'done' : 'fail'}`);
@@ -214,6 +222,67 @@ async function runImplement({ step, task, project, buf, cancelled }) {
214
222
  return { ...result, commits };
215
223
  }
216
224
 
225
+ /** discover — 기능 지도 추출·QA 페이즈(qa_run/qa_plan/qa_verify — 읽기 전용). 브랜치·프리플라이트
226
+ * 없이 프로젝트 폴더에서 claude 를 브라우저 MCP 와 함께 실행하고, 임시 산출 폴더의 기록분을
227
+ * artifact 로 수집해 보고한다. 시간 초과·실패여도 수집분이 있으면 보고(서버가 partial 로 보존).
228
+ * qa_plan 은 직전 qa_run 세션을 resume 한다(문제를 직접 관찰한 컨텍스트 위에서 계획). */
229
+ async function runDiscover({ step, task, project, buf, cancelled }) {
230
+ if (!state.claudeBin) throw new Error('claude CLI 를 찾을 수 없습니다 — CLAUDE_BIN 확인 후 러너를 재시작하세요.');
231
+ const isQa = step.phase.startsWith('qa_');
232
+ const prep = prepareDiscover({ siteUrl: project.site_url, results: isQa });
233
+ log(`${isQa ? 'QA' : '기능 지도'} 탐색 시작: ${project.site_url ?? '(사이트 없음)'} — browser=${prep.browser}, out=${prep.outDir}`);
234
+ buf.push(step.id, 'status', { event: 'fm_started', phase: step.phase, browser: prep.browser });
235
+ const before = await porcelain(project.folder_path);
236
+ let lastCount = 0;
237
+ const progress = setInterval(() => {
238
+ const n = countRouteFiles(prep.routesDir) + countRouteFiles(prep.resultsDir);
239
+ if (n !== lastCount) {
240
+ lastCount = n;
241
+ buf.push(step.id, 'status', { event: 'fm_progress', phase: step.phase, files: n });
242
+ }
243
+ }, 10_000);
244
+ try {
245
+ const result = await runClaude({
246
+ cwd: project.folder_path,
247
+ prompt: `${step.prompt_md}\n${prep.promptAddon}\n`,
248
+ resumeSessionId: step.phase === 'qa_plan' ? step.session_id : null,
249
+ maxTurns: MAX_TURNS_DISCOVER,
250
+ timeoutS: DISCOVER_TIMEOUT_S,
251
+ extraArgs: prep.extraArgs,
252
+ onEvent: (ev) => buf.push(step.id, ev.kind, ev.data),
253
+ shouldCancel: cancelled,
254
+ onSessionId: (sessionId) => {
255
+ buf.taskMeta = { session_id: sessionId };
256
+ },
257
+ });
258
+ const flag = parsePhaseMarker(result.finalText);
259
+ const artifact = collectArtifact({
260
+ outDir: prep.outDir,
261
+ routesDir: prep.routesDir,
262
+ browser: prep.browser,
263
+ finalText: result.finalText,
264
+ partial: !result.ok || flag !== 'pass',
265
+ });
266
+ buf.push(step.id, 'status', {
267
+ event: 'fm_collected', phase: step.phase,
268
+ routes: artifact.routes.length, results: artifact.results.length, errors: artifact.errors.slice(0, 5),
269
+ });
270
+ const after = await porcelain(project.folder_path);
271
+ if (before !== null && after !== null && after !== before) {
272
+ buf.push(step.id, 'status', {
273
+ event: 'repo_modified',
274
+ note: '읽기 전용 탐색 중 저장소가 변경되었습니다 — git status 로 확인하세요.',
275
+ files: after.split('\n').slice(0, 8),
276
+ });
277
+ }
278
+ log(`기능 지도 수집: 라우트 ${artifact.routes.length}개, 오류 ${artifact.errors.length}건`);
279
+ return { ...result, flag, artifact };
280
+ } finally {
281
+ clearInterval(progress);
282
+ prep.cleanup();
283
+ }
284
+ }
285
+
217
286
  /** test/remediate/review 공통 — prompt_md + resume 세션. */
218
287
  async function runPhase({ step, task, project, buf, cancelled, extraMeta }) {
219
288
  if (!state.claudeBin) throw new Error('claude CLI 를 찾을 수 없습니다 — CLAUDE_BIN 확인 후 러너를 재시작하세요.');