@capstone-nomos/cli 0.1.20 → 0.1.21

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.
@@ -23,7 +23,7 @@ import { createInterface } from 'node:readline/promises';
23
23
  import { writeBriefingNotes } from '../bridge/briefing-notes.js';
24
24
  import { ALLOWED_BASH_PREFIXES, buildMcpConfig } from '../bridge/claude-args.js';
25
25
  import { prepareDependencies } from './setup.js';
26
- import { credentialsPath, readCredentials, updateAccessToken, writeCredentials, } from '../bridge/credentials.js';
26
+ import { credentialsPath, nomosHome, readCredentials, updateAccessToken, writeCredentials, } from '../bridge/credentials.js';
27
27
  import { NomosApiError, NomosClient } from '../bridge/nomos-client.js';
28
28
  import { ActivityReporter, ActivityTracker, RunObserver } from './activity.js';
29
29
  import { accountLine, connect, DEFAULT_SERVER, serverCalls } from './connect.js';
@@ -33,6 +33,7 @@ import { deviceLogin } from './device-login.js';
33
33
  import { cliVersion, mcpServerPath } from './paths.js';
34
34
  import { checkTools } from './preflight.js';
35
35
  import { handleNextPmJob } from './pm-worker.js';
36
+ import { experimentCli } from './experiment-command.js';
36
37
  import { acquireHomeLock } from './home-lock.js';
37
38
  import { buildTaskPrompt } from './prompt.js';
38
39
  import { DEFAULT_IDLE_MS, runClaude } from './runner.js';
@@ -710,6 +711,9 @@ async function main() {
710
711
  }
711
712
  if (command === 'pm-worker')
712
713
  return pmWorker();
714
+ // 무인 실험 감독 — 대표 계정으로 서버 API만 부른다. 에이전트가 아니라 폴더 잠금(home-lock)을 잡지 않는다(에이전트와 같은 NOMOS_HOME에서 돌아도 된다).
715
+ if (command === 'experiment')
716
+ return experimentCli(process.argv.slice(3), { command: 'nomos experiment', runsDir: path.join(nomosHome(), 'experiment-runs') });
713
717
  if (command === 'clean') {
714
718
  const { pruned, removed } = cleanWorkspaces();
715
719
  log(`worktree prune: ${pruned.join(', ') || '없음'} / 삭제: ${removed ?? '없음'}`);
@@ -723,6 +727,7 @@ async function main() {
723
727
  ' login [주소] [--name 이름] [--connect-key] 로그인만',
724
728
  ' refresh | once | start | clean | --version',
725
729
  ' pm-worker (대표 전용, 서버가 PM_PROVIDER=relay일 때) PM 모델 호출을 이 노트북의 Claude Code로',
730
+ ' experiment --project <id> --login <대표> --requirements <파일> [--until 24] (대표 전용) 실험 모드 프로젝트 무인 감독 — 인자 없이 실행하면 사용법',
726
731
  '',
727
732
  ].join('\n'));
728
733
  process.exitCode = 1;
@@ -0,0 +1,218 @@
1
+ // 무인 실험 감독(034) — 실험 모드 프로젝트에 요구사항을 넣고, 서버가 사람 없이 진행하는 것을 지켜보며 회차를 반복한다.
2
+ // question-harvest(033)를 대체한다(그 반복 로직을 흡수했다).
3
+ // 진입점은 둘이고 이 함수 한 벌을 쓴다: CLI `nomos experiment …`(서버 레포 없이 npx로 — 상태 파일 <NOMOS_HOME>/experiment-runs/)와
4
+ // 레포의 `npm run experiment:run -- …`(scripts/experiment-run.ts — 상태 파일 scripts/data/experiment-runs/).
5
+ // 대표 계정(사람 토큰)으로만 서버 API를 부른다 — 에이전트 자격 증명(credentials)은 읽지도 쓰지도 않고, 폴더 잠금(home-lock)도 잡지 않는다.
6
+ //
7
+ // nomos experiment --project <id> --login <대표 loginId> --requirements <파일.json> --repeat 3
8
+ // nomos experiment ... --dry-run # 확인만(요청하지 않는다)
9
+ // nomos experiment ... --run e202611021530 # 끊긴 실행을 이어 간다(상태 파일 <run>.json)
10
+ // nomos experiment --project <id> --login <대표> --report --run <이름> # 단계별·요구사항·회차별 질문 수, 자동 처리 건수, 라우터 분포
11
+ // --server <주소>(기본 운영) --interval <초>(30) --round-timeout <분>(90) --planning-timeout <분>(120)
12
+ // --offline <분>(10) --unclaimed <분>(15) --failures <K>(3)
13
+ // --until <시간> 이 시간 동안 회차를 늘려 가며 계속 돈다(요구사항을 한 바퀴씩, 바퀴마다 --repeat회 더). 마감은 상태 파일에 남아 이어 가도 늘지 않는다. --keep-going을 함께 켠다.
14
+ // --keep-going 멈춤·일시 오류에도 끝내지 않고 알린 뒤 --retry-wait <분>(5) 기다렸다가 이어 간다(설정 문제만 끝낸다). 24시간 무인 실행용.
15
+ //
16
+ // 요구사항 파일: [{ "key": "study-list", "instruction": "대표가 PM에게 하듯 자연어 요구", "repeat"?: 3 }] — 예시 scripts/examples/experiment-requirements.json.
17
+ // 자동으로 풀 수 없는 고장(로그인 만료·노트북 꺼짐·예산 소진 등)은 멈추고 원인과 할 일을 한 줄로 출력한다(종료 코드 3).
18
+ // EXPERIMENT_ALERT_WEBHOOK이 있으면 같은 문장을 { text }로 POST한다(슬랙 호환). 비밀값은 싣지 않는다.
19
+ // 대표 권한 HTTP만 쓴다. 비밀번호는 NOMOS_PASSWORD 또는 입력 창, 받은 토큰이 있으면 NOMOS_TOKEN. 규칙과 이유는 experiment-supervisor.ts.
20
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
21
+ import path from 'node:path';
22
+ import { setTimeout as sleep } from 'node:timers/promises';
23
+ import { DEFAULT_SERVER } from './connect.js';
24
+ import { DEFAULTS, emptyState, experimentReport, formatExperimentReport, HaltError, newRunId, requirementsSchema, RUN_ID, Supervisor, superviseWithRetry, } from './experiment-supervisor.js';
25
+ import { NomosHttp, signIn } from './human-http.js';
26
+ // 사용법 문구의 명령 이름(진입점마다 다르다).
27
+ let commandName = 'nomos experiment';
28
+ function out(line = '') {
29
+ process.stdout.write(`${line}\n`);
30
+ }
31
+ // 오래 도는 감독의 출력에는 시각을 붙인다 — 몇 시간 뒤 로그를 볼 때 언제 일어난 일인지 알 수 있게.
32
+ function stamped(line = '') {
33
+ const t = new Date();
34
+ const pad = (n) => String(n).padStart(2, '0');
35
+ out(line ? `${pad(t.getMonth() + 1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())} ${line}` : '');
36
+ }
37
+ function usage(message) {
38
+ if (message)
39
+ process.stderr.write(`${message}\n`);
40
+ process.stderr.write(`사용법: ${commandName} --project <id> --login <대표 loginId> --requirements <파일.json> [--repeat N] [--run <이름>] [--dry-run]\n` +
41
+ ` ${commandName} --project <id> --login <대표 loginId> --report --run <이름>\n` +
42
+ ' [--server <주소>] [--interval <초>] [--round-timeout <분>] [--planning-timeout <분>] [--offline <분>] [--unclaimed <분>] [--failures <K>]\n' +
43
+ ' [--until <시간>] [--keep-going] [--retry-wait <분>]\n');
44
+ process.exit(2);
45
+ }
46
+ function parseArgs(argv) {
47
+ const value = (i) => {
48
+ const v = argv[i + 1];
49
+ if (!v || v.startsWith('--'))
50
+ usage(`${argv[i]}에 값이 없다`);
51
+ return v;
52
+ };
53
+ const positive = (raw, flag) => {
54
+ const n = Number(raw);
55
+ if (!Number.isInteger(n) || n < 1)
56
+ usage(`${flag}는 1 이상의 정수다`);
57
+ return n;
58
+ };
59
+ let project;
60
+ const a = {
61
+ server: DEFAULT_SERVER,
62
+ repeat: 1,
63
+ dryRun: false,
64
+ report: false,
65
+ intervalSec: DEFAULTS.pollMs / 1000,
66
+ roundTimeoutMin: DEFAULTS.roundTimeoutMs / 60_000,
67
+ planningTimeoutMin: DEFAULTS.planningTimeoutMs / 60_000,
68
+ offlineMin: DEFAULTS.offlineMs / 60_000,
69
+ unclaimedMin: DEFAULTS.unclaimedMs / 60_000,
70
+ failures: DEFAULTS.failureLimit,
71
+ keepGoing: false,
72
+ retryWaitMin: 5,
73
+ };
74
+ for (let i = 0; i < argv.length; i += 1) {
75
+ const arg = argv[i];
76
+ if (arg === '--server')
77
+ a.server = value(i++);
78
+ else if (arg === '--project')
79
+ project = value(i++);
80
+ else if (arg === '--login')
81
+ a.login = value(i++);
82
+ else if (arg === '--requirements')
83
+ a.requirements = value(i++);
84
+ else if (arg === '--repeat')
85
+ a.repeat = positive(value(i++), '--repeat');
86
+ else if (arg === '--run')
87
+ a.run = value(i++);
88
+ else if (arg === '--interval')
89
+ a.intervalSec = positive(value(i++), '--interval');
90
+ else if (arg === '--round-timeout')
91
+ a.roundTimeoutMin = positive(value(i++), '--round-timeout');
92
+ else if (arg === '--planning-timeout')
93
+ a.planningTimeoutMin = positive(value(i++), '--planning-timeout');
94
+ else if (arg === '--offline')
95
+ a.offlineMin = positive(value(i++), '--offline');
96
+ else if (arg === '--unclaimed')
97
+ a.unclaimedMin = positive(value(i++), '--unclaimed');
98
+ else if (arg === '--failures')
99
+ a.failures = positive(value(i++), '--failures');
100
+ else if (arg === '--until')
101
+ a.untilHours = positive(value(i++), '--until');
102
+ else if (arg === '--keep-going')
103
+ a.keepGoing = true;
104
+ else if (arg === '--retry-wait')
105
+ a.retryWaitMin = positive(value(i++), '--retry-wait');
106
+ else if (arg === '--dry-run')
107
+ a.dryRun = true;
108
+ else if (arg === '--report')
109
+ a.report = true;
110
+ else
111
+ usage(`모르는 인자: ${arg}`);
112
+ }
113
+ if (!project)
114
+ usage('--project가 필요하다');
115
+ if (a.run !== undefined && !RUN_ID.test(a.run))
116
+ usage('--run은 영문·숫자·하이픈 24자 이내다');
117
+ if (a.report && !a.run)
118
+ usage('--report에는 --run이 필요하다(상태 파일로 요구사항·회차를 안다)');
119
+ if (!a.report && !a.requirements)
120
+ usage('--requirements가 필요하다');
121
+ // 하루짜리 실행이 고장 한 번에 끝나면 --until의 의미가 없다.
122
+ if (a.untilHours !== undefined)
123
+ a.keepGoing = true;
124
+ return { ...a, project, server: a.server.replace(/\/+$/, '') };
125
+ }
126
+ function fileStore(runsDir, run) {
127
+ const file = path.join(runsDir, `${run}.json`);
128
+ return {
129
+ async load() {
130
+ try {
131
+ return JSON.parse(await readFile(file, 'utf8'));
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ },
137
+ async save(state) {
138
+ await mkdir(runsDir, { recursive: true });
139
+ // 임시 파일에 쓰고 바꿔 끼운다 — 쓰는 도중 꺼지면(정전·강제 종료) 상태 파일이 반쯤 남아 이어 가지 못한다.
140
+ const tmp = `${file}.tmp`;
141
+ await writeFile(tmp, `${JSON.stringify(state, null, 2)}\n`);
142
+ await rename(tmp, file);
143
+ },
144
+ };
145
+ }
146
+ async function alert(text) {
147
+ const url = process.env.EXPERIMENT_ALERT_WEBHOOK;
148
+ if (!url)
149
+ return;
150
+ try {
151
+ await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: `[NOMOS 무인 실험] ${text}` }) });
152
+ }
153
+ catch (err) {
154
+ process.stderr.write(`알림 웹훅 실패: ${err.message}\n`);
155
+ }
156
+ }
157
+ // 자동으로 풀 수 없는 멈춤은 종료 코드 3, 그 밖의 실패는 1, 사용법 오류는 2.
158
+ export async function experimentCli(argv, opts) {
159
+ commandName = opts.command;
160
+ try {
161
+ await runExperiment(argv, opts.runsDir);
162
+ }
163
+ catch (err) {
164
+ const message = err.message;
165
+ if (err instanceof HaltError) {
166
+ process.stderr.write(`멈춤: ${message}\n`);
167
+ await alert(message);
168
+ process.exit(3);
169
+ }
170
+ process.stderr.write(`실패: ${message}\n`);
171
+ process.exit(1);
172
+ }
173
+ }
174
+ async function runExperiment(argv, runsDir) {
175
+ const args = parseArgs(argv);
176
+ const api = new NomosHttp(args.server);
177
+ out(`서버: ${args.server}`);
178
+ await signIn(api, args.login);
179
+ if (args.report) {
180
+ const state = (await fileStore(runsDir, args.run).load()) ?? emptyState(args.run, args.project);
181
+ for (const line of formatExperimentReport(await experimentReport(api, args.project, state)))
182
+ out(line);
183
+ return;
184
+ }
185
+ let raw;
186
+ try {
187
+ raw = JSON.parse(await readFile(args.requirements, 'utf8'));
188
+ }
189
+ catch (err) {
190
+ throw new Error(`${args.requirements}를 읽지 못했다: ${err.message}`);
191
+ }
192
+ const parsed = requirementsSchema.safeParse(raw);
193
+ if (!parsed.success)
194
+ throw new Error(`${args.requirements} 형식이 틀렸다:\n${parsed.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n')}`);
195
+ const run = args.run ?? newRunId(new Date());
196
+ const io = { log: stamped, sleep: (ms) => sleep(ms).then(() => undefined), now: () => Date.now() };
197
+ const make = () => new Supervisor(api, {
198
+ projectId: args.project,
199
+ requirements: parsed.data,
200
+ repeat: args.repeat,
201
+ run,
202
+ dryRun: args.dryRun,
203
+ pollMs: args.intervalSec * 1000,
204
+ roundTimeoutMs: args.roundTimeoutMin * 60_000,
205
+ planningTimeoutMs: args.planningTimeoutMin * 60_000,
206
+ offlineMs: args.offlineMin * 60_000,
207
+ unclaimedMs: args.unclaimedMin * 60_000,
208
+ failureLimit: args.failures,
209
+ ...(args.untilHours !== undefined ? { untilMs: args.untilHours * 3_600_000 } : {}),
210
+ }, fileStore(runsDir, run), io);
211
+ if (args.keepGoing && !args.dryRun) {
212
+ stamped(`--keep-going: 멈춰도 ${args.retryWaitMin}분 뒤 이어 간다(Ctrl+C로 끝낸다). 끝낸 뒤에도 --run ${run}으로 이어 갈 수 있다`);
213
+ await superviseWithRetry(make, io, { retryWaitMs: args.retryWaitMin * 60_000, notify: alert });
214
+ }
215
+ else {
216
+ await make().run();
217
+ }
218
+ }
@@ -0,0 +1,605 @@
1
+ import { z } from 'zod';
2
+ // 무인 실험 감독(034) — experiment-command.ts(CLI `nomos experiment`·레포 `npm run experiment:run`)가 쓴다. question-harvest(033)의 반복 로직을 흡수했다.
3
+ // CLI 패키지에 실려 나가므로 서버 코드(config·domain)를 import하지 않는다 — 상태는 전부 서버 API로 읽는다.
4
+ //
5
+ // 서버(실험 모드)가 사람 결정 지점을 자동으로 통과시키므로 감독은 "요청 → 기다림 → 정리 → 다음 회차"만 한다:
6
+ // 요구사항마다 기능 명세 요청(POST /pm/features) → 서버가 질문·자동 답·재작성·적용·계약·태스크 나누기까지 진행 → 시작 전이면 시작 →
7
+ // 그 태스크가 끝나기를 기다린다(r1) → 회차마다 같은 태스크 묶음을 [exp <run> r<n>] 꼬리표로 다시 만들어 돌린다.
8
+ // 태스크 정리: BLOCKED(AGENT_STOPPED) → 재개 1회, 또 멈추면 취소 / ESCALATED → 취소 / 회차 시간 초과 → 취소할 수 있는 것은 취소하고 다음 회차.
9
+ // 승인 대기(AWAITING_APPROVAL)는 서버가 자동 승인하므로 기다리기만 한다. G3는 서버도 감독도 건드리지 않는다.
10
+ //
11
+ // 자동으로 풀 수 없는 것은 **멈추고 알린다**(HaltError — 원인과 사람이 할 일 한 줄, 종료 코드 3):
12
+ // 같은 실패 K번 연속 · 역할 에이전트 offline · PM 워커 offline · PM 예산 소진 · READY 태스크를 아무도 안 가져감 · 계약 포기(태스크 나누기가 막힌다).
13
+ // 진행 상태는 상태 파일(<run>.json — CLI는 <NOMOS_HOME>/experiment-runs/, 레포는 scripts/data/experiment-runs/, 커밋하지 않음)에 남겨 같은 --run으로 다시 실행하면 이어서 돈다.
14
+ // 서버 판정을 흉내 내지 않는다 — 상태는 전부 API로 읽는다.
15
+ export const requirementsSchema = z
16
+ .array(z
17
+ .object({
18
+ key: z.string().trim().regex(/^[A-Za-z0-9_-]{1,32}$/, '영문·숫자·_·- 32자 이내'),
19
+ // 대표가 PM에게 하듯 자연어 요구.
20
+ instruction: z.string().trim().min(1).max(4000),
21
+ // 이 요구사항만 회차 수를 따로 정할 때(없으면 --repeat).
22
+ repeat: z.number().int().min(1).max(50).optional(),
23
+ })
24
+ .strict())
25
+ .min(1)
26
+ .max(20)
27
+ .refine((items) => new Set(items.map((i) => i.key)).size === items.length, '같은 key가 두 번 있다');
28
+ export const RUN_ID = /^[A-Za-z0-9-]{1,24}$/;
29
+ export function newRunId(now) {
30
+ const pad = (n) => String(n).padStart(2, '0');
31
+ return `e${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}`;
32
+ }
33
+ const TAG = /\s*\[exp ([A-Za-z0-9-]+) r(\d+)\]$/;
34
+ export function roundTitle(title, run, round) {
35
+ return round === 1 ? title : `${title} [exp ${run} r${round}]`;
36
+ }
37
+ export function parseRoundTitle(title) {
38
+ const m = TAG.exec(title);
39
+ return m ? { base: title.slice(0, m.index), run: m[1], round: Number(m[2]) } : null;
40
+ }
41
+ export function emptyState(run, projectId) {
42
+ return { run, projectId, requirements: {}, counters: { resumed: 0, cancelled: 0, timedOutRounds: 0, skippedRequirements: 0 }, resumedTaskIds: [] };
43
+ }
44
+ // permanent: 기다려도 저절로 풀리지 않는 설정 문제(실험 모드 아님·프로젝트가 끝남) — --keep-going도 다시 시도하지 않는다.
45
+ export class HaltError extends Error {
46
+ permanent;
47
+ constructor(message, permanent = false) {
48
+ super(message);
49
+ this.permanent = permanent;
50
+ }
51
+ }
52
+ export const DEFAULTS = {
53
+ pollMs: 30_000,
54
+ roundTimeoutMs: 90 * 60_000,
55
+ planningTimeoutMs: 120 * 60_000,
56
+ offlineMs: 10 * 60_000,
57
+ unclaimedMs: 15 * 60_000,
58
+ failureLimit: 3,
59
+ };
60
+ const FINISHED = new Set(['DONE', 'CANCELLED']);
61
+ const CANCELLABLE = new Set(['READY', 'BLOCKED', 'ESCALATED']);
62
+ const IN_FLIGHT = new Set(['CLAIMED', 'IN_PROGRESS', 'VERIFYING', 'AWAITING_APPROVAL']);
63
+ // 고장 감지 — 감독이 한 번 확인할 때마다 부른다. 자동으로 풀 수 없으면 HaltError(사람이 할 일 한 줄).
64
+ export class HealthMonitor {
65
+ api;
66
+ opts;
67
+ io;
68
+ offlineSince = new Map();
69
+ workerOfflineSince = null;
70
+ readySince = new Map();
71
+ lastFailure = null;
72
+ constructor(api, opts, io) {
73
+ this.api = api;
74
+ this.opts = opts;
75
+ this.io = io;
76
+ }
77
+ // 같은 실패가 연속 K번이면 멈춘다. 성공(태스크 DONE·초안 적용)이 오면 센 것을 비운다.
78
+ failure(key, advice) {
79
+ this.lastFailure = this.lastFailure?.key === key ? { key, count: this.lastFailure.count + 1 } : { key, count: 1 };
80
+ if (this.lastFailure.count >= this.opts.failureLimit) {
81
+ throw new HaltError(`같은 실패(${key})가 연속 ${this.lastFailure.count}번 났다 — ${advice}`);
82
+ }
83
+ }
84
+ success() {
85
+ this.lastFailure = null;
86
+ }
87
+ async check(tasks, projectStatus) {
88
+ const now = this.io.now();
89
+ const [status, rooms] = await Promise.all([
90
+ this.api.call('GET', `/projects/${this.opts.projectId}/pm/status`),
91
+ this.api.call('GET', `/projects/${this.opts.projectId}/rooms`),
92
+ ]);
93
+ if (status.spentUsd >= status.budgetUsd) {
94
+ throw new HaltError(`PM 예산이 다 떨어졌다($${status.spentUsd.toFixed(2)} / $${status.budgetUsd}) — 대표가 프로젝트 PM 예산을 늘린 뒤 같은 명령을 다시 실행`);
95
+ }
96
+ if (status.reason === 'WORKER_OFFLINE') {
97
+ this.workerOfflineSince ??= now;
98
+ if (now - this.workerOfflineSince >= this.opts.offlineMs) {
99
+ throw new HaltError(`대표 노트북의 pm-worker가 ${Math.round((now - this.workerOfflineSince) / 60000)}분째 응답하지 않는다 — 노트북이 꺼졌거나 절전 중이거나 Claude 로그인이 만료됐을 수 있다. pm-worker를 다시 띄운 뒤 같은 명령을 다시 실행`);
100
+ }
101
+ }
102
+ else {
103
+ this.workerOfflineSince = null;
104
+ }
105
+ for (const room of rooms.rooms) {
106
+ if (!room.agent)
107
+ continue;
108
+ if (room.agent.online) {
109
+ this.offlineSince.delete(room.role);
110
+ continue;
111
+ }
112
+ const since = this.offlineSince.get(room.role) ?? now;
113
+ this.offlineSince.set(room.role, since);
114
+ if (now - since >= this.opts.offlineMs) {
115
+ throw new HaltError(`${room.role} 에이전트가 ${Math.round((now - since) / 60000)}분째 연결돼 있지 않다 — ${room.role} 노트북이 꺼졌거나 절전 중일 수 있다. ` +
116
+ '그 노트북에서 npx @capstone-nomos/cli@latest connect --background 후 같은 명령을 다시 실행');
117
+ }
118
+ }
119
+ // READY인데 아무도 가져가지 않는다 — 진행 중인 태스크가 하나도 없을 때만 본다(선행을 기다리는 READY를 고장으로 세지 않게).
120
+ if (projectStatus !== 'active')
121
+ return;
122
+ const busy = tasks.some((t) => IN_FLIGHT.has(t.state));
123
+ for (const t of tasks) {
124
+ if (t.state !== 'READY' || busy) {
125
+ this.readySince.delete(t.id);
126
+ continue;
127
+ }
128
+ const since = this.readySince.get(t.id) ?? now;
129
+ this.readySince.set(t.id, since);
130
+ if (now - since >= this.opts.unclaimedMs) {
131
+ throw new HaltError(`READY 태스크 "${t.title}"(${t.teamRole ?? '역할 없음'})를 ${Math.round((now - since) / 60000)}분 동안 아무도 가져가지 않았다 — ` +
132
+ `${t.teamRole ?? '그 역할'} 노트북의 connect가 돌고 있는지, Claude 로그인·사용량 한도를 확인한 뒤 같은 명령을 다시 실행`);
133
+ }
134
+ }
135
+ }
136
+ }
137
+ // ── 감독 ──
138
+ export class Supervisor {
139
+ api;
140
+ opts;
141
+ store;
142
+ io;
143
+ health;
144
+ state;
145
+ constructor(api, opts, store, io) {
146
+ this.api = api;
147
+ this.opts = opts;
148
+ this.store = store;
149
+ this.io = io;
150
+ this.health = new HealthMonitor(api, opts, io);
151
+ }
152
+ // 담당이 있어야 하는 역할 = 프로젝트 레포의 경로 규칙이 소유자로 지정한 역할. BE 레포만 넣은 프로젝트는 FE 태스크가 생기지 않으므로 FE 담당을 요구하지 않는다.
153
+ // 레포 목록을 모르면(옛 서버) 두 역할 모두.
154
+ async requiredRoles(detail) {
155
+ if (!detail.repos || detail.repos.length === 0)
156
+ return ['FRONTEND', 'BACKEND'];
157
+ const roles = new Set();
158
+ for (const repo of detail.repos) {
159
+ const { paths } = await this.api.call('GET', `/repos/${repo.id}/paths`);
160
+ for (const p of paths)
161
+ if (p.ownerRole)
162
+ roles.add(p.ownerRole);
163
+ }
164
+ return ['FRONTEND', 'BACKEND'].filter((r) => roles.has(r));
165
+ }
166
+ async preflight() {
167
+ const detail = await this.api.call('GET', `/projects/${this.opts.projectId}`);
168
+ const problems = [];
169
+ if (detail.project.questionMode !== 'experiment') {
170
+ problems.push(`실험 모드가 아니다(questionMode=${detail.project.questionMode ?? '없음'}) — 시작 전에 PATCH /projects/:id/settings { questionMode: 'experiment' }`);
171
+ }
172
+ if (!['planning', 'active'].includes(detail.project.status))
173
+ problems.push(`프로젝트 상태가 ${detail.project.status}다(planning·active만)`);
174
+ for (const role of await this.requiredRoles(detail)) {
175
+ const m = detail.members.find((x) => x.teamRole === role);
176
+ if (!m)
177
+ problems.push(`${role} 담당이 배정되지 않았다`);
178
+ else if (!m.agentId)
179
+ problems.push(`${role} 담당의 에이전트가 연결돼 있지 않다 — 그 노트북에서 connect`);
180
+ }
181
+ const status = await this.api.call('GET', `/projects/${this.opts.projectId}/pm/status`);
182
+ if (!status.ready)
183
+ problems.push(`PM을 부를 수 없다(${status.reason}) — ${status.reason === 'WORKER_OFFLINE' ? '대표 노트북에서 pm-worker를 띄운다' : '서버의 PM 설정을 확인한다'}`);
184
+ const permanent = detail.project.questionMode !== 'experiment' || !['planning', 'active'].includes(detail.project.status);
185
+ if (problems.length > 0)
186
+ throw new HaltError(`시작할 수 없다:\n${problems.map((p) => ` - ${p}`).join('\n')}`, permanent);
187
+ return detail;
188
+ }
189
+ async run() {
190
+ const detail = await this.preflight();
191
+ this.state = (await this.store.load()) ?? emptyState(this.opts.run, this.opts.projectId);
192
+ if (this.state.projectId !== this.opts.projectId)
193
+ throw new HaltError(`상태 파일의 프로젝트(${this.state.projectId})가 --project와 다르다 — 다른 --run을 쓴다`);
194
+ this.io.log(`프로젝트: ${detail.project.name} (${detail.project.status}) · 실행 이름 ${this.opts.run} — 끊기면 --run ${this.opts.run}으로 이어 간다`);
195
+ if (this.opts.dryRun) {
196
+ for (const r of this.opts.requirements) {
197
+ const s = this.state.requirements[r.key];
198
+ this.io.log(` [${r.key}] ${s ? `이어 감(${s.status})` : '새로'} · 회차 ${r.repeat ?? this.opts.repeat} · "${r.instruction.slice(0, 80)}"`);
199
+ }
200
+ this.io.log('--dry-run: 아무것도 요청하지 않았다');
201
+ return this.state;
202
+ }
203
+ if (this.opts.untilMs !== undefined) {
204
+ if (this.state.untilAt === undefined) {
205
+ this.state.untilAt = this.io.now() + this.opts.untilMs;
206
+ await this.save();
207
+ }
208
+ await this.runUntil(this.state.untilAt);
209
+ }
210
+ else {
211
+ for (const r of this.opts.requirements)
212
+ await this.runRequirement(r, r.repeat ?? this.opts.repeat);
213
+ }
214
+ this.io.log(`다 돌았다 — npm run experiment:run -- --project ${this.opts.projectId} --report --run ${this.opts.run}`);
215
+ return this.state;
216
+ }
217
+ pastDeadline() {
218
+ return this.state.untilAt !== undefined && this.io.now() >= this.state.untilAt;
219
+ }
220
+ // --until: 요구사항을 차례로 한 바퀴(각 repeat회) 돈 뒤, 마감 전이면 다음 바퀴에서 회차를 repeat만큼 더 늘린다(r3, r4, …).
221
+ // 같은 요구사항만 몇 시간 도는 대신 바퀴마다 모든 요구사항이 고르게 쌓인다. 마감이 지나면 새 회차를 시작하지 않는다(돌던 회차는 끝까지 — 회차 시간 제한이 있다).
222
+ async runUntil(untilAt) {
223
+ this.io.log(`마감 ${new Date(untilAt).toISOString()}까지 회차를 늘려 가며 돈다`);
224
+ for (let lap = 1; !this.pastDeadline(); lap += 1) {
225
+ for (const r of this.opts.requirements) {
226
+ if (this.pastDeadline())
227
+ break;
228
+ await this.runRequirement(r, (r.repeat ?? this.opts.repeat) * lap);
229
+ }
230
+ // 모든 요구사항이 포기됐다면 바퀴를 돌아도 할 일이 없다 — 빈 바퀴로 마감까지 헛돌지 않는다.
231
+ if (this.opts.requirements.every((r) => this.state.requirements[r.key]?.status === 'skipped')) {
232
+ this.io.log('돌릴 수 있는 요구사항이 없다(전부 포기) — 마감 전이지만 끝낸다');
233
+ return;
234
+ }
235
+ }
236
+ this.io.log('마감이 지났다 — 새 회차를 시작하지 않는다');
237
+ }
238
+ async save() {
239
+ await this.store.save(this.state);
240
+ }
241
+ reqState(key) {
242
+ this.state.requirements[key] ??= { status: 'pending', rounds: {} };
243
+ return this.state.requirements[key];
244
+ }
245
+ // upTo회차까지 돌린다.
246
+ async runRequirement(r, upTo) {
247
+ const s = this.reqState(r.key);
248
+ const roundsDone = Array.from({ length: upTo }, (_, i) => s.rounds[String(i + 1)]?.done === true).every(Boolean);
249
+ if (s.status === 'skipped' || (s.status === 'done' && roundsDone)) {
250
+ this.io.log(`[${r.key}] 이미 ${s.status === 'done' ? '끝났다' : `포기했다(${s.skipReason})`} — 건너뜀`);
251
+ return;
252
+ }
253
+ if (!s.rootPlanId) {
254
+ s.rootPlanId = await this.requestFeature(r);
255
+ s.status = 'planning';
256
+ await this.save();
257
+ this.io.log(`[${r.key}] 기능 명세 요청 ${s.rootPlanId}`);
258
+ }
259
+ if (!s.template) {
260
+ const planned = await this.waitPlanning(r, s);
261
+ if (!planned) {
262
+ s.status = 'skipped';
263
+ this.state.counters.skippedRequirements += 1;
264
+ await this.save();
265
+ return;
266
+ }
267
+ }
268
+ s.status = 'running';
269
+ await this.save();
270
+ await this.ensureStarted();
271
+ for (let round = 1; round <= upTo; round += 1) {
272
+ const rs = s.rounds[String(round)];
273
+ if (rs?.done)
274
+ continue;
275
+ if (this.pastDeadline())
276
+ return; // 마감 — 남은 회차는 시작하지 않는다(running으로 남는다)
277
+ await this.runRound(r, s, round);
278
+ }
279
+ s.status = 'done';
280
+ await this.save();
281
+ }
282
+ async requestFeature(r) {
283
+ for (;;) {
284
+ try {
285
+ const plan = await this.api.call('POST', `/projects/${this.opts.projectId}/pm/features`, { instruction: r.instruction });
286
+ return plan.id;
287
+ }
288
+ catch (err) {
289
+ const message = err.message;
290
+ if (message.includes('PM_PLAN_IN_PROGRESS')) {
291
+ await this.io.sleep(this.opts.pollMs); // 다른 초안이 작성 중 — 끝나면 다시
292
+ continue;
293
+ }
294
+ if (message.includes('PM_BUDGET_EXCEEDED'))
295
+ throw new HaltError('PM 예산이 다 떨어졌다 — 대표가 프로젝트 PM 예산을 늘린 뒤 같은 명령을 다시 실행');
296
+ throw err;
297
+ }
298
+ }
299
+ }
300
+ // 기능 명세 체인 적용 → 계약 확정 → 태스크 나누기 적용까지(전부 서버가 자동으로 한다). 태스크 묶음(r1)을 template으로 남긴다.
301
+ // 포기(서버가 다시 시켜도 끝내지 못함)면 false.
302
+ async waitPlanning(r, s) {
303
+ const started = this.io.now();
304
+ let failedSince = null;
305
+ // 계획 단계는 몇십 분씩 걸릴 수 있다 — 무엇을 기다리는지 바뀔 때마다(그리고 5분마다) 한 줄 남긴다. 조용하면 멈춘 것과 구분이 안 된다.
306
+ let lastStage = '';
307
+ let lastStageAt = 0;
308
+ const report = (stage) => {
309
+ if (stage === lastStage && this.io.now() - lastStageAt < 5 * 60_000)
310
+ return;
311
+ lastStage = stage;
312
+ lastStageAt = this.io.now();
313
+ this.io.log(` [${r.key}] 계획 대기 ${Math.round((this.io.now() - started) / 60_000)}분 — ${stage}`);
314
+ };
315
+ for (;;) {
316
+ const plans = (await this.api.call('GET', `/projects/${this.opts.projectId}/pm/plans`)).plans;
317
+ const chain = chainOf(plans, s.rootPlanId);
318
+ const applied = chain.find((p) => p.status === 'applied');
319
+ const leaf = chain.find((p) => !plans.some((c) => c.parentPlanId === p.id));
320
+ if (!applied) {
321
+ report(`기능 명세 초안 ${chain.length}판째 ${leaf?.status ?? '?'}${leaf?.error ? `(${leaf.error.reason})` : ''}${leaf?.status === 'ready' ? ' — 질문 답·자동 적용 대기' : ''}`);
322
+ }
323
+ if (!applied && leaf?.status === 'failed') {
324
+ if (leaf.error?.reason === 'budget')
325
+ throw new HaltError('PM 예산이 다 떨어졌다 — 대표가 프로젝트 PM 예산을 늘린 뒤 같은 명령을 다시 실행');
326
+ // 서버는 실패하면 바로 다시 낸다 — 다음 판이 두 번 확인하는 동안 생기지 않으면 포기한 것이다(EXPERIMENT_SKIPPED).
327
+ failedSince ??= this.io.now();
328
+ if (this.io.now() - failedSince >= this.opts.pollMs * 2) {
329
+ s.skipReason = `기능 명세 초안 실패(${leaf.error?.reason ?? '?'})`;
330
+ this.io.log(`[${r.key}] 포기 — ${s.skipReason}`);
331
+ this.health.failure(`plan_failed:${leaf.error?.reason ?? '?'}`, 'PM이 같은 이유로 계속 실패한다 — 서버 로그와 PM 설정(모델·키·pm-worker)을 확인한 뒤 같은 명령을 다시 실행');
332
+ return false;
333
+ }
334
+ }
335
+ else {
336
+ failedSince = null;
337
+ }
338
+ if (applied) {
339
+ s.featureKeys = (applied.featureDraft?.features ?? []).map((f) => f.featureKey);
340
+ const template = await this.templateFor(plans, s.featureKeys);
341
+ if (template) {
342
+ s.template = template;
343
+ s.rounds['1'] = { taskIds: [], done: false };
344
+ this.health.success();
345
+ await this.save();
346
+ this.io.log(`[${r.key}] 계획 끝 — 기능 ${s.featureKeys.join(', ')}, 태스크 ${template.length}개`);
347
+ return true;
348
+ }
349
+ const contracts = (await this.api.call('GET', `/projects/${this.opts.projectId}/contracts`)).contracts.filter((c) => s.featureKeys.includes(c.featureKey));
350
+ const failed = contracts.filter((c) => c.status === 'failed');
351
+ const split = plans.filter((p) => p.kind === 'tasks').sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
352
+ report(`명세 적용됨(${s.featureKeys.join(', ')}) · 계약 ${contracts.map((c) => `${c.featureKey}=${c.status}`).join(', ') || '없음'} · ` +
353
+ `최근 태스크 나누기 ${split ? `${split.status}${split.error ? `(${split.error.reason})` : ''}` : '없음'}`);
354
+ if (failed.length > 0 && failedSince === null)
355
+ failedSince = this.io.now();
356
+ if (failed.length > 0 && this.io.now() - (failedSince ?? 0) >= this.opts.pollMs * 2) {
357
+ throw new HaltError(`API 계약 ${failed.map((c) => c.featureKey).join(', ')}을(를) 자동으로 다시 시켜도 쓰지 못했다 — 태스크 나누기가 막힌다. ` +
358
+ 'BE 노트북의 connect·Claude 로그인을 확인하고 웹에서 계약 수정 요청을 누른 뒤 같은 명령을 다시 실행');
359
+ }
360
+ }
361
+ if (this.io.now() - started >= this.opts.planningTimeoutMs) {
362
+ throw new HaltError(`[${r.key}] 계획(기능 명세 → 계약 → 태스크 나누기)이 ${Math.round(this.opts.planningTimeoutMs / 60000)}분 안에 끝나지 않았다 — 웹에서 계획·계약 상태를 확인`);
363
+ }
364
+ await this.health.check([], 'planning');
365
+ await this.io.sleep(this.opts.pollMs);
366
+ }
367
+ }
368
+ // 이 요구사항의 기능에 붙은 태스크 나누기(적용된 것)를 찾아 태스크 묶음을 만든다. 아직이면 null.
369
+ async templateFor(plans, featureKeys) {
370
+ const split = plans
371
+ .filter((p) => p.kind === 'tasks' && p.status === 'applied')
372
+ .find((p) => (p.draft?.tasks ?? []).some((t) => t.spec !== null && featureKeys.includes(t.spec)));
373
+ if (!split?.draft?.tasks)
374
+ return null;
375
+ const tasks = (await this.api.call('GET', `/projects/${this.opts.projectId}/tasks?limit=200`)).tasks;
376
+ const byRef = new Map(split.draft.tasks.map((t) => [t.ref, t.title]));
377
+ const template = [];
378
+ for (const t of split.draft.tasks) {
379
+ const real = tasks.find((x) => x.title === t.title);
380
+ if (!real)
381
+ return null;
382
+ template.push({
383
+ title: t.title,
384
+ teamRole: real.teamRole,
385
+ kind: real.kind,
386
+ repoId: real.repoId,
387
+ specId: real.specId,
388
+ dependsOn: t.dependsOn.map((d) => byRef.get(d)).filter(Boolean),
389
+ });
390
+ }
391
+ return template;
392
+ }
393
+ async ensureStarted() {
394
+ const detail = await this.api.call('GET', `/projects/${this.opts.projectId}`);
395
+ if (detail.project.status === 'planning') {
396
+ await this.api.call('POST', `/projects/${this.opts.projectId}/start`);
397
+ this.io.log('프로젝트를 시작했다(G1)');
398
+ }
399
+ }
400
+ async runRound(r, s, round) {
401
+ const template = s.template;
402
+ const rs = (s.rounds[String(round)] ??= { taskIds: [], done: false });
403
+ let tasks = (await this.api.call('GET', `/projects/${this.opts.projectId}/tasks?limit=200`)).tasks;
404
+ // r1은 PM이 만든 그 태스크, r2부터는 꼬리표를 붙여 새로 만든다(이미 있는 제목은 건너뛴다 — 같은 --run으로 다시 돌린 경우).
405
+ const idByTitle = new Map();
406
+ for (const t of template) {
407
+ const title = roundTitle(t.title, this.opts.run, round);
408
+ const existing = tasks.find((x) => x.title === title);
409
+ if (existing) {
410
+ idByTitle.set(t.title, existing.id);
411
+ continue;
412
+ }
413
+ const created = await this.api.call('POST', `/projects/${this.opts.projectId}/tasks`, {
414
+ title,
415
+ teamRole: t.teamRole,
416
+ kind: t.kind,
417
+ repoId: t.repoId,
418
+ specId: t.specId,
419
+ dependsOn: t.dependsOn.map((d) => idByTitle.get(d)).filter((x) => x !== undefined),
420
+ });
421
+ idByTitle.set(t.title, created.id);
422
+ this.io.log(` [${r.key}] r${round} 만듦 ${title}`);
423
+ }
424
+ rs.taskIds = [...idByTitle.values()];
425
+ await this.save();
426
+ this.io.log(`[${r.key}] r${round} 실행 중 (${rs.taskIds.length}개)`);
427
+ const started = this.io.now();
428
+ for (;;) {
429
+ tasks = (await this.api.call('GET', `/projects/${this.opts.projectId}/tasks?limit=200`)).tasks;
430
+ const mine = tasks.filter((t) => rs.taskIds.includes(t.id));
431
+ for (const t of mine)
432
+ await this.tidy(t);
433
+ const refreshed = (await this.api.call('GET', `/projects/${this.opts.projectId}/tasks?limit=200`)).tasks;
434
+ const open = refreshed.filter((t) => rs.taskIds.includes(t.id) && !FINISHED.has(t.state));
435
+ if (open.length === 0)
436
+ break;
437
+ if (this.io.now() - started >= this.opts.roundTimeoutMs) {
438
+ for (const t of open.filter((x) => CANCELLABLE.has(x.state)))
439
+ await this.cancel(t, `r${round} 시간 초과`);
440
+ const left = open.filter((x) => !CANCELLABLE.has(x.state));
441
+ if (left.length > 0)
442
+ this.io.log(` ! r${round} 시간 초과 — 진행 중이라 취소하지 못한 태스크: ${left.map((t) => `${t.title}(${t.state})`).join(', ')}`);
443
+ rs.timedOut = true;
444
+ this.state.counters.timedOutRounds += 1;
445
+ break;
446
+ }
447
+ const detail = await this.api.call('GET', `/projects/${this.opts.projectId}`);
448
+ await this.health.check(refreshed, detail.project.status);
449
+ await this.io.sleep(this.opts.pollMs);
450
+ }
451
+ rs.done = true;
452
+ await this.save();
453
+ this.io.log(`[${r.key}] r${round} 끝`);
454
+ }
455
+ // 태스크 정리 규칙.
456
+ async tidy(t) {
457
+ if (t.state === 'DONE') {
458
+ this.health.success();
459
+ return;
460
+ }
461
+ const role = t.teamRole ?? '역할 없음';
462
+ if (t.state === 'BLOCKED' && t.blockedReason === 'AGENT_STOPPED') {
463
+ if (!this.state.resumedTaskIds.includes(t.id)) {
464
+ await this.api.call('POST', `/tasks/${t.id}/resume`, { note: '실험 감독 자동 재개(1회)' });
465
+ this.state.resumedTaskIds.push(t.id);
466
+ this.state.counters.resumed += 1;
467
+ await this.save();
468
+ this.io.log(` 재개 ${t.title}`);
469
+ }
470
+ else {
471
+ await this.cancel(t, '재개 뒤에도 멈춤');
472
+ }
473
+ // 역할을 가리지 않고 센다 — 로그인 만료·사용량 한도는 FE·BE 태스크가 번갈아 멈추는 모양으로도 나온다.
474
+ this.health.failure('task_stopped', `${role} 노트북의 Claude 로그인이 만료됐거나 사용량 한도에 닿았을 수 있다 — claude 로그인·사용량을 확인한 뒤 같은 명령을 다시 실행`);
475
+ return;
476
+ }
477
+ if (t.state === 'ESCALATED') {
478
+ await this.cancel(t, '재시도 소진(ESCALATED)');
479
+ this.health.failure('task_escalated', `${role} 태스크가 계속 검증·반려로 끝난다 — 명세·경로 소유권·검사기 설정을 확인한 뒤 같은 명령을 다시 실행`);
480
+ }
481
+ }
482
+ async cancel(t, why) {
483
+ await this.api.call('POST', `/tasks/${t.id}/cancel`, { reason: `실험 감독: ${why}` });
484
+ this.state.counters.cancelled += 1;
485
+ await this.save();
486
+ this.io.log(` 취소 ${t.title} (${why})`);
487
+ }
488
+ }
489
+ // --keep-going(24시간 무인용): 멈춤(HaltError)·일시 오류가 나도 끝내지 않고 알린 뒤 기다렸다가 같은 상태 파일로 이어 간다.
490
+ // 노트북 재연결·pm-worker 재시작·예산 증액처럼 사람이 고치면 다음 시도에서 저절로 이어진다. 기다려도 풀리지 않는 설정 문제(permanent)만 끝낸다.
491
+ // 감독은 매 시도 새로 만든다(make) — 고장 감지의 연속 실패 수·offline 시각을 이전 시도에서 끌고 오지 않는다.
492
+ export async function superviseWithRetry(make, io, opts) {
493
+ let lastNotified = '';
494
+ for (let attempt = 1;; attempt += 1) {
495
+ try {
496
+ return await make().run();
497
+ }
498
+ catch (err) {
499
+ if (err instanceof HaltError && err.permanent)
500
+ throw err;
501
+ const message = err.message;
502
+ const waitMin = Math.round(opts.retryWaitMs / 60_000);
503
+ io.log(`! ${err instanceof HaltError ? '멈춤' : '오류'}(${attempt}번째): ${message}`);
504
+ io.log(` ${waitMin}분 뒤 이어 간다(--keep-going) — 그 사이 위 안내대로 고치면 된다`);
505
+ // 같은 문장을 계속 보내지 않는다 — 몇 시간 같은 고장이면 알림이 쌓인다.
506
+ if (message !== lastNotified) {
507
+ lastNotified = message;
508
+ await opts.notify(`${message} — ${waitMin}분 뒤 자동으로 이어 간다`);
509
+ }
510
+ await io.sleep(opts.retryWaitMs);
511
+ }
512
+ }
513
+ }
514
+ function chainOf(plans, rootId) {
515
+ const ids = new Set([rootId]);
516
+ let grew = true;
517
+ while (grew) {
518
+ grew = false;
519
+ for (const p of plans)
520
+ if (p.parentPlanId && ids.has(p.parentPlanId) && !ids.has(p.id))
521
+ (ids.add(p.id), (grew = true));
522
+ }
523
+ return plans.filter((p) => ids.has(p.id));
524
+ }
525
+ export function buildExperimentReport(input) {
526
+ const byStage = {};
527
+ for (const item of input.dataset)
528
+ byStage[item.stage] = (byStage[item.stage] ?? 0) + 1;
529
+ const titleOf = new Map(input.tasks.map((t) => [t.id, t.title]));
530
+ const perTitle = new Map();
531
+ for (const item of input.dataset) {
532
+ const title = item.input.task?.title;
533
+ if (title)
534
+ perTitle.set(title, (perTitle.get(title) ?? 0) + 1);
535
+ }
536
+ const requirements = Object.entries(input.state.requirements).map(([key, s]) => {
537
+ const keys = new Set(s.featureKeys ?? []);
538
+ const planningQuestions = input.dataset.filter((d) => d.stage !== 'implementation' && d.input.spec && keys.has(d.input.spec.featureKey)).length;
539
+ const rounds = Object.entries(s.rounds)
540
+ .map(([n, rs]) => {
541
+ const counts = rs.taskIds.map((id) => perTitle.get(titleOf.get(id) ?? '') ?? 0);
542
+ return { round: Number(n), tasks: rs.taskIds.length, tasksWithQuestions: counts.filter((c) => c > 0).length, questions: counts.reduce((a, b) => a + b, 0) };
543
+ })
544
+ .sort((a, b) => a.round - b.round);
545
+ return { key, status: s.status, planningQuestions, rounds };
546
+ });
547
+ const routers = {};
548
+ const count = (router, target) => {
549
+ const k = target ?? '(실패)';
550
+ routers[router] ??= {};
551
+ routers[router][k] = (routers[router][k] ?? 0) + 1;
552
+ };
553
+ const rowOf = new Map(input.report.rows.map((r) => [r.questionId, r]));
554
+ for (const item of input.dataset) {
555
+ const row = rowOf.get(item.id);
556
+ if (row && Object.keys(row.decisions).length > 0)
557
+ for (const [router, target] of Object.entries(row.decisions))
558
+ count(router, target);
559
+ else
560
+ count(item.primary.router, item.primary.target);
561
+ }
562
+ return {
563
+ questions: input.dataset.length,
564
+ byStage,
565
+ requirements,
566
+ automatic: {
567
+ planRetries: input.plans.filter((p) => p.revisionCause === 'retry').length,
568
+ contractRetries: input.contracts.reduce((a, c) => a + (c.experimentRetries ?? 0), 0),
569
+ ...input.state.counters,
570
+ },
571
+ routers,
572
+ };
573
+ }
574
+ const pct = (a, b) => (b === 0 ? '-' : `${Math.round((a / b) * 100)}%`);
575
+ export function formatExperimentReport(r) {
576
+ const lines = [];
577
+ lines.push(`질문 ${r.questions}건 (질문 행 기준) · 단계별: ${Object.entries(r.byStage).map(([s, n]) => `${s} ${n}`).join(' · ') || '없음'}`);
578
+ lines.push('');
579
+ lines.push('요구사항별 (기획 단계 질문 / 회차: 태스크 · 질문한 태스크 = 발생률 · 질문 수 · 태스크당 질문)');
580
+ for (const req of r.requirements) {
581
+ lines.push(` [${req.key}] ${req.status} · 기획 단계 질문 ${req.planningQuestions}`);
582
+ for (const x of req.rounds) {
583
+ lines.push(` r${x.round}: ${x.tasks} · ${x.tasksWithQuestions} = ${pct(x.tasksWithQuestions, x.tasks)} · ${x.questions} · ${(x.questions / Math.max(1, x.tasks)).toFixed(2)}`);
584
+ }
585
+ }
586
+ lines.push('');
587
+ const a = r.automatic;
588
+ lines.push(`자동 처리: 초안 재요청 ${a.planRetries} · 계약 재시도 ${a.contractRetries} · 재개 ${a.resumed} · 취소 ${a.cancelled} · 시간 초과 회차 ${a.timedOutRounds} · 포기한 요구사항 ${a.skippedRequirements}`);
589
+ lines.push('');
590
+ lines.push('라우터별 판정 분포 (primary + shadow + baseline, 대상별 건수)');
591
+ for (const [router, targets] of Object.entries(r.routers).sort((x, y) => x[0].localeCompare(y[0]))) {
592
+ lines.push(` ${router}: ${Object.entries(targets).sort((x, y) => y[1] - x[1]).map(([t, n]) => `${t} ${n}`).join(' · ')}`);
593
+ }
594
+ return lines;
595
+ }
596
+ export async function experimentReport(api, projectId, state) {
597
+ const [dataset, report, tasks, plans, contracts] = await Promise.all([
598
+ api.call('GET', `/projects/${projectId}/routing-dataset`),
599
+ api.call('GET', `/projects/${projectId}/routing-report`),
600
+ api.call('GET', `/projects/${projectId}/tasks?limit=200`),
601
+ api.call('GET', `/projects/${projectId}/pm/plans`),
602
+ api.call('GET', `/projects/${projectId}/contracts`),
603
+ ]);
604
+ return buildExperimentReport({ state, dataset: dataset.items, report, tasks: tasks.tasks, plans: plans.plans, contracts: contracts.contracts });
605
+ }
@@ -0,0 +1,142 @@
1
+ // 사람(대표) 계정으로 서버 API를 부르는 HTTP 도우미 — 서버 코드(env.ts·DB)를 거치지 않는다. 에이전트 쪽 nomos-client(bridge)와 달리 사람 토큰이다.
2
+ // CLI의 무인 실험 감독(nomos experiment)과 레포 스크립트(question-probe·routing-report·experiment-run, scripts/lib/nomos-http.ts가 다시 내보낸다)가 같이 쓴다.
3
+ // 인증: NOMOS_TOKEN(이미 받은 사람 토큰) 또는 --login + NOMOS_PASSWORD(없으면 화면에 안 보이는 입력 창). 토큰·비밀번호는 출력하지 않는다.
4
+ // 입력한 글자를 화면에 찍지 않는 비밀번호 입력.
5
+ export async function readHidden(prompt) {
6
+ const stdin = process.stdin;
7
+ if (!stdin.isTTY)
8
+ throw new Error('비밀번호를 입력받을 터미널이 없다 — NOMOS_PASSWORD 또는 NOMOS_TOKEN을 설정하라');
9
+ process.stdout.write(prompt);
10
+ stdin.setRawMode(true);
11
+ stdin.resume();
12
+ stdin.setEncoding('utf8');
13
+ return new Promise((resolve, reject) => {
14
+ let buf = '';
15
+ const onData = (chunk) => {
16
+ for (const ch of chunk) {
17
+ if (ch === '\r' || ch === '\n') {
18
+ cleanup();
19
+ process.stdout.write('\n');
20
+ resolve(buf);
21
+ return;
22
+ }
23
+ if (ch === '\u0003') {
24
+ cleanup();
25
+ reject(new Error('취소했다'));
26
+ return;
27
+ }
28
+ if (ch === '\u007f' || ch === '\b')
29
+ buf = buf.slice(0, -1);
30
+ else
31
+ buf += ch;
32
+ }
33
+ };
34
+ const cleanup = () => {
35
+ stdin.off('data', onData);
36
+ stdin.setRawMode(false);
37
+ stdin.pause();
38
+ };
39
+ stdin.on('data', onData);
40
+ });
41
+ }
42
+ // 일시적인 실패(연결 끊김·응답 없음·배포 중 502/503/504)에는 GET만 다시 시도한다. POST 등은 서버가 이미 처리했을 수 있어(태스크·기능 명세가 두 번 생긴다) 다시 보내지 않는다 —
43
+ // 그런 실패는 감독의 --keep-going이 상태 파일에서 이어 가며 메운다(이미 만든 것은 다시 만들지 않는다).
44
+ const TRANSIENT_STATUS = new Set([502, 503, 504]);
45
+ const GET_RETRY_DELAYS_MS = [2_000, 5_000, 15_000, 30_000, 60_000];
46
+ export class NomosHttpError extends Error {
47
+ status;
48
+ constructor(status, message) {
49
+ super(message);
50
+ this.status = status;
51
+ }
52
+ }
53
+ export class NomosHttp {
54
+ base;
55
+ token;
56
+ retryDelaysMs;
57
+ relogin = null;
58
+ constructor(base, token = '', retryDelaysMs = GET_RETRY_DELAYS_MS) {
59
+ this.base = base;
60
+ this.token = token;
61
+ this.retryDelaysMs = retryDelaysMs;
62
+ }
63
+ setToken(token) {
64
+ this.token = token;
65
+ }
66
+ // 사람 토큰은 24시간이면 만료된다 — 하루 넘게 도는 감독이 그 순간 죽지 않게, 401이면 같은 아이디·비밀번호로 다시 로그인하고 원 요청을 1회 재시도한다.
67
+ // 비밀번호는 이 프로세스 메모리에만 있다(파일·로그에 남기지 않는다).
68
+ setRelogin(relogin) {
69
+ this.relogin = relogin;
70
+ }
71
+ async call(method, path, body) {
72
+ try {
73
+ return await this.callWithRetry(method, path, body);
74
+ }
75
+ catch (err) {
76
+ if (!(err instanceof NomosHttpError) || err.status !== 401 || !this.relogin || path === '/auth/login')
77
+ throw err;
78
+ await this.relogin();
79
+ return this.callWithRetry(method, path, body);
80
+ }
81
+ }
82
+ async callWithRetry(method, path, body) {
83
+ for (let attempt = 0;; attempt += 1) {
84
+ try {
85
+ return await this.once(method, path, body);
86
+ }
87
+ catch (err) {
88
+ const transient = !(err instanceof NomosHttpError) || TRANSIENT_STATUS.has(err.status);
89
+ const delay = this.retryDelaysMs[attempt];
90
+ if (method !== 'GET' || !transient || delay === undefined)
91
+ throw err;
92
+ process.stderr.write(` (일시 오류, ${Math.round(delay / 1000)}초 뒤 다시: ${err.message.slice(0, 160)})\n`);
93
+ await new Promise((r) => setTimeout(r, delay));
94
+ }
95
+ }
96
+ }
97
+ async once(method, path, body) {
98
+ const res = await fetch(`${this.base}/api${path}`, {
99
+ method,
100
+ headers: {
101
+ 'Content-Type': 'application/json',
102
+ ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
103
+ },
104
+ body: body === undefined ? undefined : JSON.stringify(body),
105
+ // 응답 없이 매달린 연결이 감독을 영원히 붙잡지 않게(감독은 long-poll API를 부르지 않는다).
106
+ signal: AbortSignal.timeout(60_000),
107
+ });
108
+ const text = await res.text();
109
+ let json = null;
110
+ try {
111
+ json = text ? JSON.parse(text) : null;
112
+ }
113
+ catch {
114
+ // HTML 오류 페이지 등
115
+ }
116
+ if (!res.ok) {
117
+ const err = json?.error;
118
+ const detail = err?.details ? ` ${JSON.stringify(err.details)}` : '';
119
+ throw new NomosHttpError(res.status, `${method} ${path} → ${res.status} ${err?.code ?? ''} ${err?.message ?? text.slice(0, 200)}${detail}`);
120
+ }
121
+ return json.data;
122
+ }
123
+ }
124
+ export async function signIn(api, loginId) {
125
+ const preset = process.env.NOMOS_TOKEN;
126
+ if (preset) {
127
+ api.setToken(preset); // 받은 토큰은 다시 발급할 수단이 없다 — 만료되면 401로 멈춘다.
128
+ return;
129
+ }
130
+ if (!loginId)
131
+ throw new Error('--login이 필요하다 (또는 NOMOS_TOKEN)');
132
+ let password = process.env.NOMOS_PASSWORD ?? (await readHidden(`${loginId} 비밀번호: `));
133
+ // 붙여 넣은 명령 끝의 줄바꿈이 입력 창에 그대로 들어가 빈 비밀번호가 나간 일이 있었다(400 VALIDATION_ERROR) — 비었으면 다시 묻는다.
134
+ while (password === '' && process.env.NOMOS_PASSWORD === undefined)
135
+ password = await readHidden(`비밀번호가 비었다. ${loginId} 비밀번호: `);
136
+ const login = async () => {
137
+ const auth = await api.call('POST', '/auth/login', { loginId, password });
138
+ api.setToken(auth.accessToken);
139
+ };
140
+ await login();
141
+ api.setRelogin(login);
142
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capstone-nomos/cli",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "NOMOS 에이전트 CLI — 노트북의 Claude Code를 NOMOS 서버에 연결하고 배정된 태스크를 실행한다",
5
5
  "type": "module",
6
6
  "bin": {