@capstone-nomos/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,350 @@
1
+ #!/usr/bin/env node
2
+ // Executor — 팀원 노트북에서 도는 프로그램. npm 패키지 @capstone-nomos/cli로 배포된다(bin: nomos).
3
+ // 서버 주소는 ~/.nomos/credentials의 baseUrl에서 온다.
4
+ //
5
+ // nomos connect [--server 주소] [--name 이름]
6
+ // 웹 안내의 한 줄. 로그인(필요하면) → 배정 대기(자동 재발급) → start. --server 기본은 운영 주소.
7
+ // nomos doctor [--json] git·claude·자격 증명·MCP 서버 파일 점검
8
+ // nomos login [baseUrl] [--name 이름] [--connect-key]
9
+ // 브라우저 승인으로 연결해 자격 증명을 새로 쓴다(기본). 브라우저가 없는 환경(SSH)은
10
+ // --connect-key 또는 NOMOS_CONNECT_KEY로 가입 때 받은 연결 키를 쓴다.
11
+ // executor refresh 토큰 재발급 (프로젝트에 배정된 뒤 한 번)
12
+ // executor once READY 태스크 하나만 처리하고 종료 (개발·데모용)
13
+ // executor start 10초 폴링
14
+ // executor clean worktree 정리
15
+ //
16
+ // 서버를 옮길 때 baseUrl만 바꾸면 안 된다. 토큰은 발급한 서버의 서명 키와 그 DB의 agents 행에 묶여 있어
17
+ // 다른 서버에서는 전부 401이다. 원격에 가입해 받은 연결 키로 login을 다시 해야 한다.
18
+ import { writeFileSync } from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { createInterface } from 'node:readline/promises';
22
+ import { buildMcpConfig } from '../bridge/claude-args.js';
23
+ import { credentialsPath, readCredentials, updateAccessToken, writeCredentials, } from '../bridge/credentials.js';
24
+ import { NomosClient } from '../bridge/nomos-client.js';
25
+ import { connect, DEFAULT_SERVER, serverCalls } from './connect.js';
26
+ import { deviceLogin } from './device-login.js';
27
+ import { cliVersion, mcpServerPath } from './paths.js';
28
+ import { checkTools } from './preflight.js';
29
+ import { buildTaskPrompt } from './prompt.js';
30
+ import { runClaude } from './runner.js';
31
+ import { pollingTaskSource } from './task-source.js';
32
+ import { runLint, runSpecTests } from './verify.js';
33
+ import { ensureRepo } from './repo-checkout.js';
34
+ import { cleanWorkspaces, headSha, prepareWorkspace } from './workspace.js';
35
+ const POLL_INTERVAL_MS = 10_000;
36
+ function log(line) {
37
+ process.stdout.write(`[nomos] ${line}\n`);
38
+ }
39
+ function createClient() {
40
+ const credentials = readCredentials();
41
+ if (!credentials) {
42
+ throw new Error(`${credentialsPath()}가 없습니다. 먼저 connect(또는 login)를 실행하세요`);
43
+ }
44
+ return new NomosClient({
45
+ baseUrl: credentials.baseUrl,
46
+ tokens: { accessToken: credentials.accessToken, refreshToken: credentials.refreshToken },
47
+ // 재발급 결과를 파일에 되돌려 쓴다. 없으면 다음 실행이 또 401로 시작한다.
48
+ onTokensChanged: (tokens) => updateAccessToken(tokens.accessToken),
49
+ });
50
+ }
51
+ async function handleTask(client, projectId, task) {
52
+ log(`태스크 ${task.id} — ${task.title}`);
53
+ const briefing = (await client.getBriefing(task.id));
54
+ // cloneUrl이 없는 옛 서버(브리핑에 필드 자체가 없음)도 github.com/{fullName}으로 받는다.
55
+ const checkout = ensureRepo({ fullName: briefing.repo.fullName, cloneUrl: briefing.repo.cloneUrl ?? null }, { log });
56
+ const workspace = prepareWorkspace({
57
+ repoPath: checkout.path,
58
+ projectId,
59
+ taskId: task.id,
60
+ branchName: briefing.task.branchName,
61
+ // CLI가 관리하는 클론은 방금 fetch한 origin/<기본 브랜치>에서 분기한다.
62
+ baseBranch: checkout.baseRef(briefing.repo.defaultBranch),
63
+ settings: briefing.claudeSettings,
64
+ policyHash: briefing.policyHash,
65
+ });
66
+ log(`작업공간 ${workspace.dir} (브랜치 ${workspace.branch})`);
67
+ if (workspace.createdBranch) {
68
+ await client.reportBranch(task.id, workspace.branch);
69
+ }
70
+ // MCP 설정은 작업공간마다 둔다. 비밀값은 없다 — 자격 증명은 ~/.nomos/credentials가 정본이다.
71
+ const mcpConfigPath = path.join(workspace.dir, '.nomos-mcp.json');
72
+ writeFileSync(mcpConfigPath,
73
+ // 작업공간 경로를 넘겨야 submit_artifact가 이 worktree의 태스크 브랜치를 push한다.
74
+ buildMcpConfig({ serverPath: mcpServerPath(), workspaceDir: workspace.dir }));
75
+ const before = headSha(workspace.dir);
76
+ const result = await runClaude({
77
+ workspaceDir: workspace.dir,
78
+ prompt: buildTaskPrompt(briefing, workspace.branch),
79
+ mcpConfigPath,
80
+ });
81
+ const committed = headSha(workspace.dir) !== before;
82
+ // 자동 재시도는 넣지 않는다. tasks.retry_count는 서버가 관리하는 값이고,
83
+ // Executor가 멋대로 돌리면 M4(재작업률)가 오염된다.
84
+ if (result.outcome === 'completed' && committed) {
85
+ log(`완료 (${Math.round(result.durationMs / 1000)}초). 커밋 있음 — 제출은 모델이 이미 했을 것`);
86
+ await reportBridgeStages(client, task.id, workspace.dir, briefing.specTests);
87
+ }
88
+ else if (result.outcome === 'completed') {
89
+ log(`정상 종료했지만 커밋이 없다. 태스크는 그대로 둔다. 로그: ${result.logPath}`);
90
+ }
91
+ else if (result.outcome === 'timeout') {
92
+ log(`30분 초과로 종료했다. 재시도하지 않는다. 로그: ${result.logPath}`);
93
+ }
94
+ else {
95
+ log(`비정상 종료 (exit=${result.exitCode}). 재시도하지 않는다. 로그: ${result.logPath}`);
96
+ }
97
+ }
98
+ // 모델이 submit_artifact를 불렀다면 산출물이 생겼을 것이다. 그 위에 V2·V4를 얹는다.
99
+ // 제출이 없으면(모델이 도구를 안 불렀거나 거부당했으면) 붙일 곳이 없으므로 건너뛴다.
100
+ async function reportBridgeStages(client, taskId, workspaceDir, specTests) {
101
+ const [latest] = await client.listArtifacts(taskId);
102
+ if (!latest) {
103
+ log('산출물이 없다 — V2·V4를 붙일 곳이 없어 건너뛴다');
104
+ return;
105
+ }
106
+ const artifactId = String(latest.id);
107
+ const reports = [
108
+ await runSpecTests(workspaceDir, specTests),
109
+ await runLint(workspaceDir),
110
+ ];
111
+ for (const report of reports) {
112
+ log(`${report.stage} ${report.result} (${Math.round(report.durationMs / 1000)}초)`);
113
+ try {
114
+ const summary = (await client.reportVerification(artifactId, report));
115
+ log(` 보고됨 — 결론 ${summary.outcome ?? '?'} / 태스크 ${summary.taskState ?? '?'}`);
116
+ }
117
+ catch (err) {
118
+ // 보고 실패로 작업 자체를 되돌리지 않는다. 서버에는 아직 결과가 없으므로
119
+ // 태스크는 VERIFYING에 머물고, 사람이 보면 무엇이 빠졌는지 알 수 있다.
120
+ log(` 보고 실패: ${err instanceof Error ? err.message : String(err)}`);
121
+ }
122
+ }
123
+ }
124
+ async function once() {
125
+ const client = createClient();
126
+ const self = await client.describeSelf();
127
+ const source = pollingTaskSource(client, self.projectId);
128
+ const [task] = await source.nextTasks(1);
129
+ if (!task) {
130
+ log('READY 태스크가 없다');
131
+ return;
132
+ }
133
+ await handleTask(client, self.projectId, task);
134
+ }
135
+ async function start() {
136
+ const client = createClient();
137
+ const self = await client.describeSelf();
138
+ const source = pollingTaskSource(client, self.projectId);
139
+ log(`시작 — 프로젝트 ${self.projectId}, 역할 ${self.teamRole}, 동시 실행 ${self.maxConcurrent} (${source.kind})`);
140
+ const inFlight = new Set();
141
+ for (;;) {
142
+ try {
143
+ const capacity = self.maxConcurrent - inFlight.size;
144
+ if (capacity > 0) {
145
+ for (const task of await source.nextTasks(capacity)) {
146
+ // 이미 처리 중인 태스크는 건너뛴다. 폴링은 같은 목록을 여러 번 준다.
147
+ if (inFlight.has(task.id))
148
+ continue;
149
+ inFlight.add(task.id);
150
+ void handleTask(client, self.projectId, task)
151
+ .catch((err) => log(`태스크 ${task.id} 실패: ${err instanceof Error ? err.message : String(err)}`))
152
+ .finally(() => inFlight.delete(task.id));
153
+ }
154
+ }
155
+ }
156
+ catch (err) {
157
+ log(`폴링 실패: ${err instanceof Error ? err.message : String(err)}`);
158
+ }
159
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
160
+ }
161
+ }
162
+ // 토큰이 헤더로 다니므로 원격은 https만 받는다. 로컬 개발 서버만 예외다.
163
+ function normalizeBaseUrl(raw) {
164
+ const url = new URL(raw ?? DEFAULT_SERVER);
165
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
166
+ if (url.protocol !== 'https:' && !local) {
167
+ throw new Error(`원격 서버는 https여야 한다: ${raw}`);
168
+ }
169
+ return url.origin;
170
+ }
171
+ async function postJson(url, body) {
172
+ const res = await fetch(url, {
173
+ method: 'POST',
174
+ headers: { 'Content-Type': 'application/json' },
175
+ body: JSON.stringify(body),
176
+ });
177
+ const json = (await res.json().catch(() => ({})));
178
+ if (!res.ok || !json.data) {
179
+ throw new Error(`${res.status} ${json.error?.code ?? ''} ${json.error?.message ?? ''}`.trim());
180
+ }
181
+ return json.data;
182
+ }
183
+ function parseArgs(argv) {
184
+ const out = { positional: undefined, server: undefined, name: undefined, connectKey: false, json: false };
185
+ for (let i = 0; i < argv.length; i += 1) {
186
+ const arg = argv[i];
187
+ if (arg === '--name')
188
+ out.name = argv[(i += 1)];
189
+ else if (arg === '--server')
190
+ out.server = argv[(i += 1)];
191
+ else if (arg === '--connect-key')
192
+ out.connectKey = true;
193
+ else if (arg === '--json')
194
+ out.json = true;
195
+ else
196
+ out.positional ??= arg;
197
+ }
198
+ return out;
199
+ }
200
+ // 서버에 붙어 ~/.nomos/credentials를 새로 쓴다. 기본은 브라우저 승인,
201
+ // 연결 키를 명시했거나(--connect-key) 환경변수로 줬으면 기존 경로(SSH 등 브라우저가 없는 환경).
202
+ async function loginTo(baseUrl, agentName, viaConnectKey) {
203
+ if (!viaConnectKey && process.env.NOMOS_CONNECT_KEY === undefined) {
204
+ const { credentials, account } = await deviceLogin({ baseUrl, agentName });
205
+ writeCredentials(credentials);
206
+ // 연결된 계정을 반드시 보여 준다 — 내가 아닌 계정이면 누군가 내 코드를 승인한 것이다.
207
+ const org = account.orgName ? ` · 조직 ${account.orgName}` : ' · 조직 없음';
208
+ log(`연결됨: ${account.nickname ?? account.loginId ?? '?'} (${account.loginId ?? '?'})${org} — 에이전트 ${agentName}`);
209
+ log(`${credentialsPath()}에 저장했다. 내 계정이 아니면 바로 웹에서 이 에이전트를 확인하라.`);
210
+ return;
211
+ }
212
+ // 연결 키는 NOMOS_CONNECT_KEY로도 받는다 — 인자로 받으면 셸 기록에 남는다.
213
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
214
+ try {
215
+ const connectKey = (process.env.NOMOS_CONNECT_KEY ?? (await rl.question('연결 키 (가입할 때 받은 값): '))).trim();
216
+ rl.close();
217
+ const data = await postJson(`${baseUrl}/api/agents/connect`, {
218
+ connectKey,
219
+ agentName,
220
+ harness: 'claude-code',
221
+ skills: [],
222
+ maxConcurrent: 2,
223
+ });
224
+ writeCredentials({
225
+ baseUrl,
226
+ accessToken: String(data.accessToken),
227
+ refreshToken: String(data.refreshToken),
228
+ agentId: String(data.agentId),
229
+ });
230
+ log(`${credentialsPath()}에 저장했다 (에이전트 ${String(data.agentId)})`);
231
+ }
232
+ finally {
233
+ rl.close();
234
+ }
235
+ }
236
+ async function login(argv) {
237
+ const args = parseArgs(argv);
238
+ const baseUrl = normalizeBaseUrl(args.positional ?? args.server);
239
+ const previous = readCredentials();
240
+ if (previous && previous.baseUrl !== baseUrl) {
241
+ log(`기존 자격 증명(${previous.baseUrl})을 ${baseUrl}용으로 교체한다`);
242
+ }
243
+ await loginTo(baseUrl, args.name?.trim() || os.hostname(), args.connectKey);
244
+ log('대표에게 이 에이전트를 프로젝트에 배정해 달라고 한 뒤 connect를 실행하라(배정되면 자동으로 시작한다)');
245
+ }
246
+ async function connectCommand(argv) {
247
+ const args = parseArgs(argv);
248
+ const server = normalizeBaseUrl(args.server ?? args.positional ?? process.env.NOMOS_SERVER);
249
+ const agentName = args.name?.trim() || os.hostname();
250
+ log(`NOMOS CLI ${cliVersion()} — 서버 ${server}`);
251
+ const missing = checkTools().filter((t) => !t.ok);
252
+ if (missing.length > 0) {
253
+ for (const t of missing)
254
+ log(`${t.name}을 실행할 수 없다: ${t.detail}\n → ${t.hint}`);
255
+ throw new Error('필요한 도구가 없어 시작하지 않는다');
256
+ }
257
+ await connect({
258
+ server,
259
+ agentName,
260
+ readCredentials,
261
+ login: () => loginTo(server, agentName, args.connectKey),
262
+ ...serverCalls({ read: readCredentials, updateAccessToken }),
263
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
264
+ log,
265
+ });
266
+ await start();
267
+ }
268
+ async function doctor(argv) {
269
+ const { json } = parseArgs(argv);
270
+ let mcp;
271
+ try {
272
+ mcp = { ok: true, detail: mcpServerPath() };
273
+ }
274
+ catch (err) {
275
+ mcp = { ok: false, detail: err instanceof Error ? err.message : String(err) };
276
+ }
277
+ let server = null;
278
+ try {
279
+ server = readCredentials()?.baseUrl ?? null;
280
+ }
281
+ catch {
282
+ server = null;
283
+ }
284
+ const report = { version: cliVersion(), node: process.version, mcpServer: mcp, tools: checkTools(), server };
285
+ if (json) {
286
+ process.stdout.write(`${JSON.stringify(report)}\n`);
287
+ }
288
+ else {
289
+ log(`버전 ${report.version} (node ${report.node})`);
290
+ log(`MCP 서버: ${mcp.ok ? 'OK' : '없음'} — ${mcp.detail}`);
291
+ for (const t of report.tools)
292
+ log(`${t.name}: ${t.ok ? t.detail : `없음 — ${t.hint}`}`);
293
+ log(`연결된 서버: ${server ?? '없음 (connect를 실행하라)'}`);
294
+ }
295
+ // 패키지 자체가 깨진 경우만 실패로 끝낸다. git·claude가 없는 건 안내로 충분하다.
296
+ if (!mcp.ok)
297
+ process.exitCode = 1;
298
+ }
299
+ // 배정 전에 받은 토큰에는 project_id가 없다. 배정 뒤 한 번 재발급해야 태스크 API를 쓸 수 있다.
300
+ async function refresh() {
301
+ const credentials = readCredentials();
302
+ if (!credentials)
303
+ throw new Error(`${credentialsPath()}가 없다. 먼저 connect(또는 login)를 실행하라`);
304
+ const data = await postJson(`${credentials.baseUrl}/api/agents/token/refresh`, { refreshToken: credentials.refreshToken });
305
+ updateAccessToken(String(data.accessToken));
306
+ try {
307
+ const self = await createClient().describeSelf();
308
+ log(`재발급 완료 — 프로젝트 ${self.projectId}, 역할 ${self.teamRole ?? '없음'}`);
309
+ }
310
+ catch (err) {
311
+ log(`재발급은 됐지만 아직 프로젝트에 배정되지 않은 것 같다: ${err instanceof Error ? err.message : String(err)}`);
312
+ }
313
+ }
314
+ async function main() {
315
+ const command = process.argv[2];
316
+ if (command === 'connect')
317
+ return connectCommand(process.argv.slice(3));
318
+ if (command === 'doctor')
319
+ return doctor(process.argv.slice(3));
320
+ if (command === '--version' || command === '-v' || command === 'version') {
321
+ process.stdout.write(`${cliVersion()}\n`);
322
+ return;
323
+ }
324
+ if (command === 'login')
325
+ return login(process.argv.slice(3));
326
+ if (command === 'refresh')
327
+ return refresh();
328
+ if (command === 'once')
329
+ return once();
330
+ if (command === 'start')
331
+ return start();
332
+ if (command === 'clean') {
333
+ const { pruned, removed } = cleanWorkspaces();
334
+ log(`worktree prune: ${pruned.join(', ') || '없음'} / 삭제: ${removed ?? '없음'}`);
335
+ return;
336
+ }
337
+ process.stderr.write([
338
+ '사용법: nomos <명령>',
339
+ ' connect [--server 주소] [--name 이름] 로그인 → 배정 대기 → 작업 시작 (처음이라면 이것만)',
340
+ ' doctor 설치 상태 점검',
341
+ ' login [주소] [--name 이름] [--connect-key] 로그인만',
342
+ ' refresh | once | start | clean | --version',
343
+ '',
344
+ ].join('\n'));
345
+ process.exitCode = 1;
346
+ }
347
+ main().catch((err) => {
348
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
349
+ process.exitCode = 1;
350
+ });
@@ -0,0 +1,74 @@
1
+ // nomos connect — 웹 안내 한 줄(npx @capstone-nomos/cli connect)로 끝까지 가게 한다.
2
+ // 자격 증명 없음·다른 서버 것·더 이상 안 통함 → 브라우저 승인 로그인
3
+ // 프로젝트 배정 전 → 안내하고 주기적으로 토큰을 재발급해 배정을 확인(사람이 하던 executor refresh를 자동으로)
4
+ // 배정됨 → 호출부가 start(폴링)로 넘어간다
5
+ // 테스트할 수 있게 입출력은 전부 바깥에서 받는다.
6
+ export const DEFAULT_SERVER = 'https://nomos-team.duckdns.org';
7
+ export async function connect(deps) {
8
+ const current = deps.readCredentials();
9
+ if (!current || current.baseUrl !== deps.server) {
10
+ if (current)
11
+ deps.log(`기존 자격 증명(${current.baseUrl})을 ${deps.server}용으로 교체한다`);
12
+ await deps.login();
13
+ }
14
+ else if ((await deps.refresh()) === 'rejected') {
15
+ deps.log('저장된 자격 증명이 더 이상 통하지 않는다 — 다시 로그인한다');
16
+ await deps.login();
17
+ }
18
+ let announced = false;
19
+ for (;;) {
20
+ const self = await deps.describeSelf();
21
+ if (self) {
22
+ deps.log(`배정 확인 — 프로젝트 ${self.projectId}, 역할 ${self.teamRole ?? '없음'}`);
23
+ return self;
24
+ }
25
+ if (!announced) {
26
+ deps.log(`아직 프로젝트에 배정되지 않았다. 대표에게 에이전트 "${deps.agentName}"의 배정을 요청하라.`);
27
+ deps.log('배정되면 자동으로 시작한다 — 이 창을 켜 두면 된다 (Ctrl+C로 종료)');
28
+ announced = true;
29
+ }
30
+ await deps.sleep(deps.intervalMs ?? 15_000);
31
+ // 배정 전에 받은 토큰에는 project_id가 없다. 재발급해야 배정이 토큰에 실린다.
32
+ if ((await deps.refresh()) === 'rejected') {
33
+ deps.log('자격 증명이 거부됐다 — 다시 로그인한다');
34
+ await deps.login();
35
+ }
36
+ }
37
+ }
38
+ export function serverCalls(store, fetchImpl = fetch) {
39
+ return {
40
+ // 4xx는 "이 자격 증명은 더 이상 안 통한다"(지워진 에이전트·다른 서버 DB) — 다시 로그인할 일이다. 5xx·네트워크 오류는 throw.
41
+ async refresh() {
42
+ const credentials = store.read();
43
+ if (!credentials)
44
+ return 'rejected';
45
+ const res = await fetchImpl(`${credentials.baseUrl}/api/agents/token/refresh`, {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify({ refreshToken: credentials.refreshToken }),
49
+ });
50
+ if (res.status >= 400 && res.status < 500)
51
+ return 'rejected';
52
+ const json = (await res.json().catch(() => ({})));
53
+ if (!res.ok || !json.data?.accessToken)
54
+ throw new Error(`토큰 재발급 실패: ${res.status}`);
55
+ store.updateAccessToken(json.data.accessToken);
56
+ return 'ok';
57
+ },
58
+ // 방금 재발급한 토큰으로 묻는다. 배정 전이면 서버가 403 NOT_PROJECT_MEMBER — null.
59
+ async describeSelf() {
60
+ const credentials = store.read();
61
+ if (!credentials)
62
+ throw new Error('자격 증명이 없다');
63
+ const res = await fetchImpl(`${credentials.baseUrl}/api/agents/me`, {
64
+ headers: { Authorization: `Bearer ${credentials.accessToken}` },
65
+ });
66
+ const json = (await res.json().catch(() => ({})));
67
+ if (res.status === 403 && json.error?.code === 'NOT_PROJECT_MEMBER')
68
+ return null;
69
+ if (!res.ok || !json.data)
70
+ throw new Error(`${res.status} ${json.error?.code ?? ''} ${json.error?.message ?? ''}`.trim());
71
+ return json.data;
72
+ },
73
+ };
74
+ }
@@ -0,0 +1,85 @@
1
+ import { spawn } from 'node:child_process';
2
+ export class DeviceLoginError extends Error {
3
+ }
4
+ async function post(fetchImpl, url, body) {
5
+ const res = await fetchImpl(url, {
6
+ method: 'POST',
7
+ headers: { 'Content-Type': 'application/json' },
8
+ body: JSON.stringify(body),
9
+ });
10
+ const json = (await res.json().catch(() => ({})));
11
+ if (!res.ok || !json.data) {
12
+ throw new DeviceLoginError(`${res.status} ${json.error?.code ?? ''} ${json.error?.message ?? ''}`.trim());
13
+ }
14
+ return json.data;
15
+ }
16
+ // 브라우저 열기. shell을 쓰지 않는다(Windows에서 shell: true면 URL의 & 등이 명령으로 해석된다 — runner.ts와 같은 이유).
17
+ // 실패해도 괜찮다 — 출력한 주소를 사용자가 직접 연다.
18
+ export function openInBrowser(url) {
19
+ const [command, args] = process.platform === 'darwin'
20
+ ? ['open', [url]]
21
+ : process.platform === 'win32'
22
+ ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
23
+ : ['xdg-open', [url]];
24
+ return new Promise((resolve) => {
25
+ try {
26
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
27
+ child.once('error', () => resolve(false));
28
+ child.once('spawn', () => {
29
+ child.unref();
30
+ resolve(true);
31
+ });
32
+ }
33
+ catch {
34
+ resolve(false);
35
+ }
36
+ });
37
+ }
38
+ export async function deviceLogin(deps) {
39
+ const fetchImpl = deps.fetchImpl ?? fetch;
40
+ const openUrl = deps.openUrl ?? openInBrowser;
41
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
42
+ const out = deps.out ?? ((line) => process.stdout.write(`${line}\n`));
43
+ const started = await post(fetchImpl, `${deps.baseUrl}/api/agents/device/start`, {
44
+ agentName: deps.agentName,
45
+ harness: 'claude-code',
46
+ skills: [],
47
+ maxConcurrent: 2,
48
+ });
49
+ const deviceCode = String(started.deviceCode);
50
+ const complete = String(started.verificationUriComplete);
51
+ let intervalSec = Number(started.interval) || 5;
52
+ const deadline = Date.now() + (Number(started.expiresIn) || 600) * 1000;
53
+ out(`브라우저에서 승인하세요: ${complete}`);
54
+ out(`코드: ${String(started.userCode)} (웹 화면의 코드와 같은지 확인하세요)`);
55
+ if (!(await openUrl(complete)))
56
+ out('브라우저를 열지 못했습니다 — 위 주소를 직접 여세요.');
57
+ while (Date.now() < deadline) {
58
+ await sleep(intervalSec * 1000);
59
+ const polled = await post(fetchImpl, `${deps.baseUrl}/api/agents/device/poll`, { deviceCode });
60
+ switch (polled.status) {
61
+ case 'pending':
62
+ continue;
63
+ case 'slow_down':
64
+ intervalSec = Number(polled.interval) || intervalSec + 5;
65
+ continue;
66
+ case 'denied':
67
+ throw new DeviceLoginError('승인이 거부됐습니다. 직접 실행한 게 맞다면 login을 다시 실행하세요.');
68
+ case 'expired':
69
+ throw new DeviceLoginError('코드가 만료됐습니다(10분). login을 다시 실행하세요.');
70
+ case 'approved':
71
+ return {
72
+ credentials: {
73
+ baseUrl: deps.baseUrl,
74
+ accessToken: String(polled.accessToken),
75
+ refreshToken: String(polled.refreshToken),
76
+ agentId: String(polled.agentId),
77
+ },
78
+ account: polled.account,
79
+ };
80
+ default:
81
+ throw new DeviceLoginError(`알 수 없는 응답: ${JSON.stringify(polled)}`);
82
+ }
83
+ }
84
+ throw new DeviceLoginError('코드가 만료됐습니다(10분). login을 다시 실행하세요.');
85
+ }
@@ -0,0 +1,27 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ // 이 CLI는 npm 패키지(@capstone-nomos/cli)로 배포되어 **아무 폴더에서나** 실행된다.
4
+ // 그래서 파일 위치는 실행 폴더(process.cwd())가 아니라 이 파일 위치(import.meta.url) 기준으로 찾는다.
5
+ // 예전 path.resolve('dist/bridge/mcp-server.js')는 backend 폴더에서 실행할 때만 맞았다.
6
+ // Claude Code가 띄우는 MCP 서버(submit_artifact 등). process.execPath로 실행하므로 컴파일된 .js여야 한다.
7
+ export function mcpServerPath() {
8
+ // 패키지·서버 빌드(dist/executor/cli.js) → dist/bridge/mcp-server.js
9
+ const sibling = fileURLToPath(new URL('../bridge/mcp-server.js', import.meta.url));
10
+ if (existsSync(sibling))
11
+ return sibling;
12
+ // 소스에서 tsx로 실행(npm run executor, src/executor/cli.ts) → 서버 빌드 결과 dist/bridge/mcp-server.js
13
+ const built = fileURLToPath(new URL('../../dist/bridge/mcp-server.js', import.meta.url));
14
+ if (existsSync(built))
15
+ return built;
16
+ throw new Error(`MCP 서버 파일을 찾지 못했다(${sibling}). 소스에서 실행 중이면 npm run build를 먼저 하라`);
17
+ }
18
+ // 패키지 버전. 패키지에서는 packages/cli/package.json, 소스에서는 backend의 package.json이 읽힌다.
19
+ export function cliVersion() {
20
+ try {
21
+ const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf-8'));
22
+ return pkg.version ?? 'unknown';
23
+ }
24
+ catch {
25
+ return 'unknown';
26
+ }
27
+ }
@@ -0,0 +1,22 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { resolveClaudeCommand } from './runner.js';
3
+ function versionOf(command, args) {
4
+ // shell을 쓰지 않는다(runner.ts와 같은 이유). 실행 파일이 없으면 error가 채워진다.
5
+ const r = spawnSync(command, args, { encoding: 'utf-8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'] });
6
+ if (r.error || r.status !== 0)
7
+ return { ok: false, detail: r.error?.message ?? (r.stderr || `exit ${r.status}`).trim() };
8
+ return { ok: true, detail: r.stdout.trim().split('\n')[0] ?? '' };
9
+ }
10
+ export function checkTools() {
11
+ const git = versionOf('git', ['--version']);
12
+ const claudeCmd = resolveClaudeCommand(['--version']);
13
+ const claude = versionOf(claudeCmd.command, claudeCmd.commandArgs);
14
+ return [
15
+ { name: 'git', ...git, hint: 'https://git-scm.com 에서 설치하세요' },
16
+ {
17
+ name: 'claude',
18
+ ...claude,
19
+ hint: 'Claude Code를 설치하고(npm install -g @anthropic-ai/claude-code) 터미널에서 claude를 한 번 실행해 로그인하세요',
20
+ },
21
+ ];
22
+ }
@@ -0,0 +1,30 @@
1
+ export function buildTaskPrompt(briefing, branch) {
2
+ const { task, repo, spec } = briefing;
3
+ const sections = [];
4
+ sections.push([
5
+ '# 태스크',
6
+ `id: ${task.id}`,
7
+ `제목: ${task.title}`,
8
+ `역할: ${task.teamRole ?? '제한 없음'}`,
9
+ `레포: ${repo.fullName}`,
10
+ `브랜치: ${branch} (이미 이 워크트리에서 체크아웃돼 있다)`,
11
+ ].join('\n'));
12
+ if (spec) {
13
+ sections.push(`# 명세 ${spec.featureKey} — ${spec.title}\n${spec.content}`);
14
+ }
15
+ if (briefing.notesBlock) {
16
+ sections.push(briefing.notesBlock);
17
+ }
18
+ sections.push(['# 수정할 수 있는 경로', ...briefing.writablePaths.map((p) => `- ${p.pathPattern}`)].join('\n'));
19
+ sections.push([
20
+ '# 규칙',
21
+ '- 작업을 시작하기 전에 claim_task를 부르고 성공을 확인한다. 실패하면 파일을 건드리지 않는다.',
22
+ '- 위 목록 밖의 경로는 수정하지 않는다. 비밀 파일(.env, *.pem, *.key, secrets/)은 절대 건드리지 않는다.',
23
+ '- 작업이 끝나면 커밋하고, submit_artifact에 **실제 커밋 sha와 실제로 바꾼 모든 경로**를 넣는다.',
24
+ ' 바꾸지 않은 경로를 적거나 sha를 지어내지 않는다 — 서버가 제출 시점에 다시 검증한다.',
25
+ '- 다음 사람이 알아야 할 결정이나 함정이 있으면 publish_note로 남긴다.',
26
+ '- 다른 에이전트의 잘못을 노트에 적지 않는다. 그건 raise_dispute의 몫이다.',
27
+ ].join('\n'));
28
+ sections.push(`# 지금 할 일\n태스크 ${task.id}를 claim_task로 받고 위 규칙에 따라 진행하라.`);
29
+ return sections.join('\n\n');
30
+ }
@@ -0,0 +1,74 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { readRepoMap, repoMapPath } from './workspace.js';
6
+ export function managedReposRoot() {
7
+ return path.join(os.homedir(), '.nomos', 'repos');
8
+ }
9
+ // owner/repo. 클론 경로(~/.nomos/repos/<owner>/<repo>)가 되므로 '.'·'..' 세그먼트는 받지 않는다(폴더 밖으로 나간다).
10
+ const FULL_NAME = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
11
+ function isFullName(value) {
12
+ return FULL_NAME.test(value) && value.split('/').every((part) => part !== '.' && part !== '..');
13
+ }
14
+ // 서버가 준 clone_url을 그대로 git에 넘기지 않는다. 서버도 검증하지만(validateCloneUrl) 이건 사용자 노트북에서 도는 명령이다.
15
+ // https(자격 증명 없는)·로컬 절대 경로·file://만. `-`로 시작(옵션 주입)·ext::·ssh·http는 거부.
16
+ export function resolveCloneSource(fullName, cloneUrl) {
17
+ if (cloneUrl === null) {
18
+ if (!isFullName(fullName))
19
+ throw new Error(`레포 이름이 owner/repo 형식이 아니다: ${fullName}`);
20
+ return `https://github.com/${fullName}.git`;
21
+ }
22
+ const value = cloneUrl.trim();
23
+ if (value.startsWith('-') || value.includes('::'))
24
+ throw new Error(`받을 수 없는 clone 주소: ${cloneUrl}`);
25
+ if (/^https:\/\//i.test(value)) {
26
+ const url = new URL(value);
27
+ if (url.username || url.password)
28
+ throw new Error('clone 주소에 자격 증명이 들어 있다 — 받지 않는다');
29
+ return value;
30
+ }
31
+ if (/^file:\/\//i.test(value) || path.isAbsolute(value))
32
+ return value;
33
+ throw new Error(`받을 수 없는 clone 주소(https·로컬 경로만): ${cloneUrl}`);
34
+ }
35
+ function isGitRepo(dir) {
36
+ return existsSync(path.join(dir, '.git'));
37
+ }
38
+ const defaultGit = (args, cwd) => {
39
+ execFileSync('git', args, { cwd, stdio: 'inherit' });
40
+ };
41
+ export function ensureRepo(repo, deps = {}) {
42
+ const log = deps.log ?? (() => { });
43
+ const git = deps.git ?? defaultGit;
44
+ const mapped = (deps.readMap ?? readRepoMap)()[repo.fullName];
45
+ if (mapped) {
46
+ if (!isGitRepo(mapped))
47
+ throw new Error(`${repoMapPath()}의 "${repo.fullName}" 경로(${mapped})가 git 레포가 아니다`);
48
+ return { path: mapped, managed: false, baseRef: (b) => b };
49
+ }
50
+ if (!isFullName(repo.fullName))
51
+ throw new Error(`레포 이름이 owner/repo 형식이 아니다: ${repo.fullName}`);
52
+ const [owner, name] = repo.fullName.split('/');
53
+ const dir = path.join(deps.root ?? managedReposRoot(), owner, name);
54
+ if (isGitRepo(dir)) {
55
+ log(`레포 최신화: ${repo.fullName}`);
56
+ git(['fetch', '--prune', 'origin'], dir);
57
+ }
58
+ else {
59
+ if (existsSync(dir))
60
+ throw new Error(`${dir}가 있지만 git 레포가 아니다. 지우고 다시 실행하라`);
61
+ const source = resolveCloneSource(repo.fullName, repo.cloneUrl);
62
+ log(`레포 받는 중: ${repo.fullName} → ${dir}`);
63
+ mkdirSync(path.dirname(dir), { recursive: true });
64
+ try {
65
+ // `--`로 주소가 옵션으로 해석되지 않게 한 겹 더 막는다(서버 mirror 검사기와 같은 방식).
66
+ git(['clone', '--', source, dir], path.dirname(dir));
67
+ }
68
+ catch (err) {
69
+ throw new Error(`${repo.fullName}을 받지 못했다. 이 노트북의 git이 그 레포에 접근할 수 있어야 한다(gh auth login 또는 Git Credential Manager). ` +
70
+ `이미 받아 둔 레포를 쓰려면 ${repoMapPath()}에 {"${repo.fullName}": "경로"}를 적어라. (${err instanceof Error ? err.message : String(err)})`);
71
+ }
72
+ }
73
+ return { path: dir, managed: true, baseRef: (b) => `origin/${b}` };
74
+ }