@capstone-nomos/cli 0.1.19 → 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.
@@ -1,4 +1,4 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  // NOMOS의 로컬 상태(자격 증명·작업공간·레포)를 두는 곳. 기본 ~/.nomos.
@@ -26,7 +26,18 @@ export function readCredentials() {
26
26
  export function writeCredentials(credentials) {
27
27
  mkdirSync(credentialsDir(), { recursive: true, mode: 0o700 });
28
28
  const file = credentialsPath();
29
- writeFileSync(file, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
29
+ // 임시 파일에 다 쓴 뒤 이름을 바꾼다 — 읽는 쪽이 반쯤 쓰인 파일을 보지 않게(토큰 재발급마다 쓴다).
30
+ // 같은 NOMOS_HOME을 두 프로세스가 쓰다 "Unexpected end of JSON input"으로 죽은 일이 있었다(운영 실험) — 공유 자체는 executor/home-lock.ts가 막는다.
31
+ const tmp = `${file}.${process.pid}.tmp`;
32
+ writeFileSync(tmp, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
33
+ try {
34
+ renameSync(tmp, file);
35
+ }
36
+ catch {
37
+ // Windows에서 다른 프로세스가 그 순간 파일을 열고 있으면 바꾸기가 거부될 수 있다 — 직접 쓴다.
38
+ writeFileSync(file, readFileSync(tmp), { mode: 0o600 });
39
+ rmSync(tmp, { force: true });
40
+ }
30
41
  try {
31
42
  chmodSync(file, 0o600);
32
43
  }
@@ -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,8 @@ 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';
37
+ import { acquireHomeLock } from './home-lock.js';
36
38
  import { buildTaskPrompt } from './prompt.js';
37
39
  import { DEFAULT_IDLE_MS, runClaude } from './runner.js';
38
40
  import { streamTaskSource } from './stream-source.js';
@@ -41,7 +43,7 @@ import { runLint, runSpecTests } from './verify.js';
41
43
  import { ensureRepo } from './repo-checkout.js';
42
44
  import { runConsult } from './consult.js';
43
45
  import { runContractBatch, runContractDraft } from './contract.js';
44
- import { cleanWorkspaces, headSha, prepareWorkspace } from './workspace.js';
46
+ import { cleanWorkspaces, fetchCommit, headSha, mergePredecessors, prepareWorkspace } from './workspace.js';
45
47
  // 푸시가 오지 않아도 이 간격으로 한 번씩 다시 본다(끊겼을 때는 이 간격으로 폴링한다).
46
48
  const POLL_INTERVAL_MS = 10_000;
47
49
  function log(line) {
@@ -209,17 +211,35 @@ async function handleTask(client, projectId, task) {
209
211
  const briefing = (await client.getBriefing(task.id));
210
212
  // cloneUrl이 없는 옛 서버(브리핑에 필드 자체가 없음)도 github.com/{fullName}으로 받는다.
211
213
  const checkout = ensureRepo({ fullName: briefing.repo.fullName, cloneUrl: briefing.repo.cloneUrl ?? null }, { log });
214
+ // 서버가 만든 시작 기준점이 있으면 거기서 분기한다(선행 코드가 이미 합쳐져 있다). 받지 못하면 기본 브랜치에서 따고 선행을 직접 합친다.
215
+ const serverBase = briefing.base && fetchCommit(checkout.path, briefing.base.branchName, briefing.base.commitSha) ? briefing.base : null;
216
+ if (briefing.base && !serverBase)
217
+ log(`서버 기준점 ${briefing.base.branchName}을 받지 못했다 — 선행을 직접 합친다`);
212
218
  const workspace = prepareWorkspace({
213
219
  repoPath: checkout.path,
214
220
  projectId,
215
221
  taskId: task.id,
216
222
  branchName: briefing.task.branchName,
217
- // CLI가 관리하는 클론은 방금 fetch한 origin/<기본 브랜치>에서 분기한다.
218
- baseBranch: checkout.baseRef(briefing.repo.defaultBranch),
223
+ // CLI가 관리하는 클론은 방금 fetch한 origin/<기본 브랜치>에서 분기한다. 서버 기준점이 있으면 그 커밋에서.
224
+ baseBranch: serverBase?.commitSha ?? checkout.baseRef(briefing.repo.defaultBranch),
219
225
  settings: briefing.claudeSettings,
220
226
  policyHash: briefing.policyHash,
221
227
  });
222
228
  log(`작업공간 ${workspace.dir} (브랜치 ${workspace.branch})`);
229
+ // 선행 작업 위에서 시작한다 — 새로 딴 브랜치일 때만(이어 가는 브랜치에는 이미 들어 있다).
230
+ let predecessorMerge;
231
+ if (workspace.startedFresh && serverBase && (briefing.predecessors?.length ?? 0) > 0) {
232
+ // 서버가 이미 합쳤다 — 프롬프트에 "선행 코드가 들어 있다"만 알린다.
233
+ predecessorMerge = { merged: briefing.predecessors, failed: [] };
234
+ log(` 서버 기준점에서 시작: ${serverBase.branchName} (${serverBase.commitSha.slice(0, 12)}) — 선행 ${briefing.predecessors.length}개 포함`);
235
+ }
236
+ else if (workspace.startedFresh && (briefing.predecessors?.length ?? 0) > 0) {
237
+ predecessorMerge = mergePredecessors(workspace.dir, briefing.predecessors);
238
+ for (const p of predecessorMerge.merged)
239
+ log(` 선행 합침: ${p.title} (${p.commitSha.slice(0, 12)})`);
240
+ for (const f of predecessorMerge.failed)
241
+ log(` 선행 합치지 못함: ${f.predecessor.title} — ${f.reason}`);
242
+ }
223
243
  activeWorkspaces.set(task.id, { dir: workspace.dir, repo: briefing.repo.fullName });
224
244
  // 프롬프트에 넣는 노트·결정 = 제출 때 "받은 것"으로 함께 보낼 노트·결정. MCP 서버(submit_artifact)가 읽는다.
225
245
  writeBriefingNotes(workspace.dir, task.id, (briefing.notes ?? []).map((n) => n.id), (briefing.decisions ?? []).map((d) => d.id));
@@ -250,7 +270,7 @@ async function handleTask(client, projectId, task) {
250
270
  const before = headSha(workspace.dir);
251
271
  const result = await runClaude({
252
272
  workspaceDir: workspace.dir,
253
- prompt: buildTaskPrompt(briefing, workspace.branch, { allowedCommands: ALLOWED_BASH_PREFIXES, setup: setup.steps }),
273
+ prompt: buildTaskPrompt({ ...briefing, predecessorMerge }, workspace.branch, { allowedCommands: ALLOWED_BASH_PREFIXES, setup: setup.steps }),
254
274
  mcpConfigPath,
255
275
  pathPrepend: setup.venvBin,
256
276
  // 질문 중계가 꺼진 프로젝트(또는 옛 서버)면 권한 도구를 붙이지 않는다 — AskUserQuestion이 없어진다.
@@ -337,6 +357,7 @@ async function reportBridgeStages(client, taskId, workspaceDir, specTests) {
337
357
  }
338
358
  }
339
359
  async function once() {
360
+ acquireHomeLock('once');
340
361
  const client = createClient();
341
362
  const self = await client.describeSelf();
342
363
  const source = pollingTaskSource(client);
@@ -349,6 +370,7 @@ async function once() {
349
370
  }
350
371
  // 멈춘 이유를 돌려준다 — connect는 배정이 풀린 경우(해제·프로젝트 중단) 다시 배정을 기다린다.
351
372
  async function start() {
373
+ acquireHomeLock('start');
352
374
  const client = createClient();
353
375
  const self = await client.describeSelf();
354
376
  // 서버 푸시(웹소켓)로 받는다. 끊겨 있는 동안은 같은 목록을 HTTP로 읽는다.
@@ -524,6 +546,8 @@ async function loginWithCode(baseUrl, agentName, code) {
524
546
  log(`${credentialsPath()}에 저장했다. 내 계정이 아니면 바로 Ctrl+C로 멈추고 웹에서 이 에이전트를 연결 해제하라.`);
525
547
  }
526
548
  async function login(argv) {
549
+ // 돌고 있는 에이전트의 자격 증명을 덮어쓰지 않게 — 같은 폴더를 쓰는 프로세스가 있으면 멈춘다.
550
+ acquireHomeLock('login');
527
551
  const args = parseArgs(argv);
528
552
  const baseUrl = normalizeBaseUrl(args.positional ?? args.server);
529
553
  const previous = readCredentials();
@@ -557,6 +581,8 @@ async function connectCommand(argv) {
557
581
  log('상태: nomos status · 끄기: nomos stop · 진행 상황은 웹의 룸에서 본다');
558
582
  return;
559
583
  }
584
+ // 한 NOMOS_HOME = 한 에이전트(home-lock.ts). 백그라운드를 띄우는 부모는 바로 끝나므로 잡지 않고, 실제로 도는 자식이 여기서 잡는다.
585
+ acquireHomeLock('connect');
560
586
  // 배정이 풀리면(해제·프로젝트 중단) 창을 닫지 않고 다시 배정을 기다린다. 연결 해제면 멈춘다.
561
587
  // 1회용 코드는 처음 한 번만 쓴다 — 그 뒤로는 그 코드로 받은 자격 증명이 저장돼 있다.
562
588
  let code = args.code?.trim() || undefined;
@@ -627,6 +653,7 @@ async function refresh() {
627
653
  }
628
654
  const PM_POLL_INTERVAL_MS = 3_000;
629
655
  async function pmWorker() {
656
+ acquireHomeLock('pm-worker');
630
657
  const client = createClient();
631
658
  log('pm-worker 시작 — 대표가 PM에 계획을 요청하면 여기서 claude가 돈다 (Ctrl+C로 종료)');
632
659
  for (;;) {
@@ -684,6 +711,9 @@ async function main() {
684
711
  }
685
712
  if (command === 'pm-worker')
686
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') });
687
717
  if (command === 'clean') {
688
718
  const { pruned, removed } = cleanWorkspaces();
689
719
  log(`worktree prune: ${pruned.join(', ') || '없음'} / 삭제: ${removed ?? '없음'}`);
@@ -697,6 +727,7 @@ async function main() {
697
727
  ' login [주소] [--name 이름] [--connect-key] 로그인만',
698
728
  ' refresh | once | start | clean | --version',
699
729
  ' pm-worker (대표 전용, 서버가 PM_PROVIDER=relay일 때) PM 모델 호출을 이 노트북의 Claude Code로',
730
+ ' experiment --project <id> --login <대표> --requirements <파일> [--until 24] (대표 전용) 실험 모드 프로젝트 무인 감독 — 인자 없이 실행하면 사용법',
700
731
  '',
701
732
  ].join('\n'));
702
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,39 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { nomosHome } from '../bridge/credentials.js';
4
+ export function homeLockPath() {
5
+ return path.join(nomosHome(), 'agent.lock');
6
+ }
7
+ function alive(pid) {
8
+ try {
9
+ process.kill(pid, 0);
10
+ return true;
11
+ }
12
+ catch (err) {
13
+ // 권한이 없을 뿐 살아 있다.
14
+ return err.code === 'EPERM';
15
+ }
16
+ }
17
+ export function readHomeLock() {
18
+ try {
19
+ return JSON.parse(readFileSync(homeLockPath(), 'utf-8'));
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ // 잡지 못하면 이유를 담아 던진다. 끝날 때 자동으로 놓는다.
26
+ export function acquireHomeLock(command) {
27
+ const file = homeLockPath();
28
+ const held = existsSync(file) ? readHomeLock() : null;
29
+ if (held && held.pid !== process.pid && alive(held.pid)) {
30
+ throw new Error(`이 폴더(${nomosHome()})는 다른 nomos 프로세스(pid ${held.pid}, ${held.command}, ${held.startedAt}부터)가 쓰고 있다. ` +
31
+ '한 노트북에서 에이전트를 여럿 돌리려면 창마다 NOMOS_HOME을 다르게 지정한다 — cmd: set NOMOS_HOME=%USERPROFILE%\.nomos-fe / PowerShell: $env:NOMOS_HOME="$HOME\.nomos-fe"');
32
+ }
33
+ mkdirSync(path.dirname(file), { recursive: true });
34
+ writeFileSync(file, `${JSON.stringify({ pid: process.pid, command, startedAt: new Date().toISOString() })}\n`);
35
+ process.once('exit', () => {
36
+ if (readHomeLock()?.pid === process.pid)
37
+ rmSync(file, { force: true });
38
+ });
39
+ }
@@ -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
+ }
@@ -53,6 +53,24 @@ export function buildTaskPrompt(briefing, branch, environment) {
53
53
  ].join('\n'));
54
54
  }
55
55
  // 사람(그 역할 담당·대표)이 룸에서 보낸 메시지 — 이번 실행이 시작되기 전에 온 것만 들어온다(실행 중에는 끼워 넣지 않는다).
56
+ // 선행 작업 위에서 시작했다 — 선행이 만든 코드가 이미 이 브랜치에 있다. 못 합친 선행은 다시 만들지 말고 멈추게 한다
57
+ // (남의 작업을 지어내면 V3·리뷰에서 엉뚱한 diff가 된다 — 운영 실험의 BE도 그래서 멈췄다).
58
+ const pm = briefing.predecessorMerge;
59
+ if (pm && (pm.merged.length > 0 || pm.failed.length > 0)) {
60
+ sections.push([
61
+ '# 선행 작업 위에서 시작한다',
62
+ ...(pm.merged.length > 0
63
+ ? ['이 브랜치에 선행 태스크의 코드가 이미 합쳐져 있다 — 그 위에 이어서 구현한다(다시 만들지 않는다):', ...pm.merged.map((m) => `- ${m.title} (${m.commitSha.slice(0, 12)})`)]
64
+ : []),
65
+ ...(pm.failed.length > 0
66
+ ? [
67
+ '합치지 못한 선행 태스크가 있다 — 그 코드는 이 브랜치에 없다:',
68
+ ...pm.failed.map((f) => `- ${f.predecessor.title}: ${f.reason}`),
69
+ '그 코드가 꼭 필요하면 직접 다시 만들지 말고, 커밋하지 말고 멈춘 뒤 마지막 메시지에 무엇이 없어서 막혔는지 적는다.',
70
+ ]
71
+ : []),
72
+ ].join('\n'));
73
+ }
56
74
  if (briefing.humanMessages && briefing.humanMessages.length > 0) {
57
75
  sections.push([
58
76
  '# 담당자가 보낸 메시지 (팀의 사람이 이 역할 에이전트에게 남긴 지시 — 오래된 것부터)',
@@ -46,6 +46,7 @@ export function prepareWorkspace(input) {
46
46
  const dir = workspaceDir(input.projectId, input.taskId);
47
47
  const branch = input.branchName ?? `task/${input.taskId}`;
48
48
  const existed = branchExists(input.repoPath, branch);
49
+ let startedFresh = false;
49
50
  excludeNomosFiles(input.repoPath);
50
51
  if (!existsSync(dir)) {
51
52
  mkdirSync(path.dirname(dir), { recursive: true });
@@ -57,6 +58,7 @@ export function prepareWorkspace(input) {
57
58
  : refExists(input.repoPath, remote)
58
59
  ? ['worktree', 'add', '-b', branch, dir, `origin/${branch}`]
59
60
  : ['worktree', 'add', '-b', branch, dir, input.baseBranch];
61
+ startedFresh = args.at(-1) === input.baseBranch;
60
62
  git(input.repoPath, args);
61
63
  }
62
64
  const claudeDir = path.join(dir, '.claude');
@@ -64,7 +66,58 @@ export function prepareWorkspace(input) {
64
66
  const settingsPath = path.join(claudeDir, 'settings.json');
65
67
  writeFileSync(settingsPath, `${JSON.stringify(input.settings, null, 2)}\n`);
66
68
  writeFileSync(path.join(claudeDir, '.nomos-policy.json'), `${JSON.stringify({ policyHash: input.policyHash, taskId: input.taskId, generatedAt: new Date().toISOString() }, null, 2)}\n`);
67
- return { dir, branch, createdBranch: !existed, settingsPath };
69
+ return { dir, branch, createdBranch: !existed, startedFresh, settingsPath };
70
+ }
71
+ const SHA = /^[0-9a-f]{7,64}$/i;
72
+ const BRANCH = /^[A-Za-z0-9._/-]+$/;
73
+ // 원격 브랜치의 커밋을 이 레포로 받아 둔다(서버가 만든 시작 기준점, 선행 브랜치). 이미 있거나 받았으면 true.
74
+ export function fetchCommit(repoPath, branchName, commitSha) {
75
+ if (!SHA.test(commitSha))
76
+ return false;
77
+ if (refExists(repoPath, `${commitSha}^{commit}`))
78
+ return true;
79
+ if (branchName && BRANCH.test(branchName) && !branchName.startsWith('-')) {
80
+ try {
81
+ execFileSync('git', ['fetch', '--quiet', 'origin', `refs/heads/${branchName}`], { cwd: repoPath, stdio: 'ignore' });
82
+ }
83
+ catch {
84
+ // origin이 없거나(로컬 레포) 브랜치가 없다.
85
+ }
86
+ }
87
+ return refExists(repoPath, `${commitSha}^{commit}`);
88
+ }
89
+ // 새로 딴 태스크 브랜치에 선행 커밋을 오래된 것부터 합친다(앞서면 빨리 감기, 아니면 병합 커밋). 모델이 아니라 Executor가 한다 —
90
+ // 모델의 git은 merge·cherry-pick이 막혀 있다. 커밋이 없으면 선행 브랜치를 받아 온다. 충돌이나 실패는 되돌리고 건너뛴다 —
91
+ // 실행을 막으면 태스크가 READY로 남아 같은 실패를 되풀이하므로, 프롬프트에 "합치지 못했다"를 적고 모델이 멈출지 판단하게 한다.
92
+ // 서버의 V3는 같은 커밋들을 비교 기준으로 써서 선행의 파일을 이 태스크의 변경으로 세지 않는다.
93
+ export function mergePredecessors(dir, predecessors) {
94
+ const result = { merged: [], failed: [] };
95
+ for (const p of predecessors) {
96
+ if (!SHA.test(p.commitSha)) {
97
+ result.failed.push({ predecessor: p, reason: '커밋 형식이 아니다' });
98
+ continue;
99
+ }
100
+ if (!fetchCommit(dir, p.branchName, p.commitSha)) {
101
+ result.failed.push({ predecessor: p, reason: `커밋 ${p.commitSha.slice(0, 12)}을 찾지 못했다(선행 브랜치 ${p.branchName ?? '?'}가 push되지 않았거나 지워졌다)` });
102
+ continue;
103
+ }
104
+ try {
105
+ execFileSync('git', ['merge', '--no-edit', '-m', `nomos: 선행 태스크 합침 — ${p.title.slice(0, 120)}`, p.commitSha], { cwd: dir, stdio: 'pipe' });
106
+ result.merged.push(p);
107
+ }
108
+ catch (err) {
109
+ try {
110
+ execFileSync('git', ['merge', '--abort'], { cwd: dir, stdio: 'ignore' });
111
+ }
112
+ catch {
113
+ // 병합이 시작되지도 않았다(신원 미설정 등) — 되돌릴 것이 없다.
114
+ }
115
+ // 충돌 안내(CONFLICT ...)는 stdout으로, 그 밖의 실패는 stderr로 나온다.
116
+ const stderr = `${String(err.stdout ?? '')}\n${String(err.stderr ?? '')}`.trim();
117
+ result.failed.push({ predecessor: p, reason: /conflict/i.test(stderr) ? '병합 충돌' : `병합 실패: ${stderr.split('\n')[0]?.slice(0, 200) ?? ''}` });
118
+ }
119
+ }
120
+ return result;
68
121
  }
69
122
  // Executor가 작업공간에 쓰는 파일(MCP 설정·실행 로그·브리핑 노트·정책 표시·settings.json)이 모델의 `git add`에 섞이지 않게 한다.
70
123
  // 커밋에 섞이면 신고 경로와 실제 diff가 어긋나 V3가 FAIL을 낸다. info/exclude는 레포 공통이라 모든 worktree에 걸린다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capstone-nomos/cli",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "NOMOS 에이전트 CLI — 노트북의 Claude Code를 NOMOS 서버에 연결하고 배정된 태스크를 실행한다",
5
5
  "type": "module",
6
6
  "bin": {