@capstone-nomos/cli 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge/briefing-notes.js +28 -0
- package/dist/bridge/claude-args.js +21 -1
- package/dist/bridge/mcp-server.js +14 -3
- package/dist/bridge/nomos-client.js +16 -0
- package/dist/executor/cli.js +53 -11
- package/dist/executor/pm-worker.js +110 -0
- package/dist/executor/prompt.js +3 -1
- package/dist/executor/stream-source.js +114 -0
- package/dist/executor/task-source.js +6 -4
- package/dist/executor/workspace.js +21 -0
- package/package.json +1 -1
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
// 브리핑으로 프롬프트에 넣은 인계 노트의 id. 제출할 때 "이미 받은 노트"로 서버에 함께 보낸다.
|
|
4
|
+
// 서버는 관련 노트를 전부 확인해야 제출을 받는다(NOTES_UNACKNOWLEDGED). 프롬프트로 전달된 노트는 확인한 것이므로
|
|
5
|
+
// 모델이 id를 옮겨 적게 하지 않고 브릿지가 넣는다 — 모델이 직접 넣는 건 반려 뒤 새로 받은 노트뿐이다.
|
|
6
|
+
// Executor가 작업공간에 쓰고(브리핑 직후), MCP 서버의 submit_artifact가 읽는다. 둘은 다른 프로세스다.
|
|
7
|
+
export const BRIEFING_NOTES_FILE = '.nomos-briefing.json';
|
|
8
|
+
export function writeBriefingNotes(workspaceDir, taskId, noteIds) {
|
|
9
|
+
writeFileSync(path.join(workspaceDir, BRIEFING_NOTES_FILE), `${JSON.stringify({ taskId, noteIds }, null, 2)}\n`);
|
|
10
|
+
}
|
|
11
|
+
// 다른 태스크의 파일이거나 없으면 빈 목록 — 엉뚱한 태스크의 노트를 확인한 것으로 보내지 않는다.
|
|
12
|
+
export function readBriefingNoteIds(workspaceDir, taskId) {
|
|
13
|
+
const file = path.join(workspaceDir, BRIEFING_NOTES_FILE);
|
|
14
|
+
if (!existsSync(file))
|
|
15
|
+
return [];
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(readFileSync(file, 'utf-8'));
|
|
18
|
+
if (parsed.taskId !== taskId || !Array.isArray(parsed.noteIds))
|
|
19
|
+
return [];
|
|
20
|
+
return parsed.noteIds.filter((id) => typeof id === 'string');
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function mergeAcknowledged(briefed, fromModel) {
|
|
27
|
+
return [...new Set([...briefed, ...(fromModel ?? [])])].sort();
|
|
28
|
+
}
|
|
@@ -18,12 +18,32 @@ export function buildClaudeArgs(options) {
|
|
|
18
18
|
if (options.model)
|
|
19
19
|
args.push('--model', options.model);
|
|
20
20
|
args.push('--mcp-config', options.mcpConfigPath, '--strict-mcp-config');
|
|
21
|
+
// 파일 편집은 작업 폴더 안에서 자동 승인한다 — settings.json의 deny(.env·contracts 등)는 여전히 이긴다.
|
|
22
|
+
// 없으면 모델이 계획만 세우고 "쓰기 권한을 승인해 달라"며 커밋 없이 끝난다(실제로 그랬다).
|
|
23
|
+
args.push('--permission-mode', 'acceptEdits');
|
|
21
24
|
// 헤드리스에는 권한 프롬프트에 답할 사람이 없다. 이게 없으면 모델이 도구를 고른 뒤
|
|
22
25
|
// "승인 대기"에서 멈춘다 — 도구를 못 찾는 것과 증상이 달라 헷갈리기 쉽다.
|
|
23
26
|
// 가변 인자를 마지막에 두어 뒤에 아무것도 붙지 않게 한다.
|
|
24
|
-
args.push('--allowedTools', allowedToolNames().join(','));
|
|
27
|
+
args.push('--allowedTools', [...allowedToolNames(), ...allowedBashRules()].join(','));
|
|
25
28
|
return args;
|
|
26
29
|
}
|
|
30
|
+
// 작업에 필요한 셸 명령만 접두사로 허용한다(커밋·시험 실행). **임의 셸을 열지 말 것** — 셸로 파일을 쓰면
|
|
31
|
+
// settings.json의 Edit deny(.env·contracts 등)를 우회한다. 서버의 제출 검증(V3)이 최종 방어선이지만 로컬에서 먼저 막는다.
|
|
32
|
+
export const ALLOWED_BASH_PREFIXES = [
|
|
33
|
+
'git status',
|
|
34
|
+
'git diff',
|
|
35
|
+
'git add',
|
|
36
|
+
'git commit',
|
|
37
|
+
'git log',
|
|
38
|
+
'git rev-parse',
|
|
39
|
+
'npm test',
|
|
40
|
+
'npm run test',
|
|
41
|
+
'npx vitest',
|
|
42
|
+
'node --test',
|
|
43
|
+
];
|
|
44
|
+
export function allowedBashRules() {
|
|
45
|
+
return ALLOWED_BASH_PREFIXES.map((prefix) => `Bash(${prefix}:*)`);
|
|
46
|
+
}
|
|
27
47
|
// 우리 MCP 서버 하나만 담은 설정. --mcp-config로 넘긴다.
|
|
28
48
|
//
|
|
29
49
|
// **토큰을 여기 넣지 않는다.** 이 파일은 Claude Code에 넘기는 설정이고, 자격 증명의 정본은
|
|
@@ -7,6 +7,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { readCredentials, updateAccessToken } from './credentials.js';
|
|
9
9
|
import { NomosClient, PolicyStaleLoopError } from './nomos-client.js';
|
|
10
|
+
import { mergeAcknowledged, readBriefingNoteIds } from './briefing-notes.js';
|
|
10
11
|
import { pushTaskBranch } from './push.js';
|
|
11
12
|
// 자격 증명은 ~/.nomos/credentials가 정본이다(0600). 환경변수는 파일이 없을 때만 쓴다
|
|
12
13
|
// (테스트·일회성 실행용). 둘을 섞으면 재발급 결과가 어디에 저장되는지 흐려진다.
|
|
@@ -69,13 +70,17 @@ server.registerTool('claim_task', {
|
|
|
69
70
|
const workspaceDir = process.env.NOMOS_WORKSPACE_DIR ?? process.cwd();
|
|
70
71
|
server.registerTool('submit_artifact', {
|
|
71
72
|
title: 'Submit an artifact',
|
|
72
|
-
description: 'Submit finished work for a task you have claimed. Commit your work on the task branch first, then provide the commit sha and every path you changed. This tool pushes the task branch before submitting; if the push is refused (wrong branch, commit not on the task branch, remote has diverged) nothing is submitted and you get the reason. The server re-checks the paths against the ownership rules, so listing paths you did not change (or omitting ones you did) will be rejected or will fail verification later.',
|
|
73
|
+
description: 'Submit finished work for a task you have claimed. Commit your work on the task branch first, then provide the commit sha and every path you changed. This tool pushes the task branch before submitting; if the push is refused (wrong branch, commit not on the task branch, remote has diverged) nothing is submitted and you get the reason. The server re-checks the paths against the ownership rules, so listing paths you did not change (or omitting ones you did) will be rejected or will fail verification later. If handover notes related to your task were published after you started, the submission is rejected with NOTES_UNACKNOWLEDGED and the notes are returned: read them, change and commit your work if they affect it, then submit again with their ids in acknowledged_note_ids. Notes that were already in your instructions are acknowledged automatically.',
|
|
73
74
|
inputSchema: {
|
|
74
75
|
task_id: z.string().describe('The claimed task id'),
|
|
75
76
|
commit_sha: z.string().describe('Commit sha containing the work'),
|
|
76
77
|
changed_paths: z.array(z.string()).describe('Repo-relative paths changed by this commit'),
|
|
78
|
+
acknowledged_note_ids: z
|
|
79
|
+
.array(z.string())
|
|
80
|
+
.optional()
|
|
81
|
+
.describe('Ids of handover notes you read after a NOTES_UNACKNOWLEDGED rejection'),
|
|
77
82
|
},
|
|
78
|
-
}, async ({ task_id, commit_sha, changed_paths }) => run(async () => {
|
|
83
|
+
}, async ({ task_id, commit_sha, changed_paths, acknowledged_note_ids }) => run(async () => {
|
|
79
84
|
// push할 브랜치와 막을 브랜치는 서버가 정한다. 모델이 넘긴 값으로 고르지 않는다.
|
|
80
85
|
const briefing = (await client.getBriefing(task_id));
|
|
81
86
|
const push = await pushTaskBranch({
|
|
@@ -88,7 +93,13 @@ server.registerTool('submit_artifact', {
|
|
|
88
93
|
process.stderr.write('[nomos-mcp] origin 없음 — push 건너뜀(서버가 mirror 모드로 로컬에서 읽는다)\n');
|
|
89
94
|
}
|
|
90
95
|
// push가 실패하면(PushRefused) 여기까지 오지 않는다 — 제출하지 않고 run()이 모델에게 사유를 돌려준다.
|
|
91
|
-
|
|
96
|
+
// 브리핑으로 프롬프트에 들어간 노트는 이미 받은 것이다 — Executor가 작업공간에 남긴 id를 함께 보낸다.
|
|
97
|
+
const acknowledgedNoteIds = mergeAcknowledged(readBriefingNoteIds(workspaceDir, task_id), acknowledged_note_ids);
|
|
98
|
+
const artifact = await client.submitArtifact(task_id, {
|
|
99
|
+
commitSha: commit_sha,
|
|
100
|
+
changedPaths: changed_paths,
|
|
101
|
+
...(acknowledgedNoteIds.length === 0 ? {} : { acknowledgedNoteIds }),
|
|
102
|
+
});
|
|
92
103
|
return { ...artifact, push };
|
|
93
104
|
}, 'submit_artifact'));
|
|
94
105
|
server.registerTool('publish_note', {
|
|
@@ -84,6 +84,11 @@ export class NomosClient {
|
|
|
84
84
|
return data.tasks;
|
|
85
85
|
}
|
|
86
86
|
// 프롬프트와 .claude/settings.json을 만드는 데 필요한 것 한 번에.
|
|
87
|
+
// 지금 가져갈 수 있는 태스크(서버가 역할·선행·시작 여부로 거른 것). 웹소켓 푸시와 같은 목록이다.
|
|
88
|
+
async listClaimableTasks() {
|
|
89
|
+
const data = (await this.request('GET', '/api/agents/me/tasks'));
|
|
90
|
+
return data.tasks;
|
|
91
|
+
}
|
|
87
92
|
async getBriefing(taskId) {
|
|
88
93
|
return (await this.request('GET', `/api/tasks/${taskId}/briefing`));
|
|
89
94
|
}
|
|
@@ -93,6 +98,17 @@ export class NomosClient {
|
|
|
93
98
|
async reportBranch(taskId, branchName) {
|
|
94
99
|
await this.request('PATCH', `/api/tasks/${taskId}/branch`, { branchName });
|
|
95
100
|
}
|
|
101
|
+
// 중계 모드 PM 작업(대표 노트북의 pm-worker만).
|
|
102
|
+
async nextPmJob() {
|
|
103
|
+
const data = (await this.request('GET', '/api/pm/jobs/next'));
|
|
104
|
+
return data.job;
|
|
105
|
+
}
|
|
106
|
+
async submitPmJobResult(jobId, result) {
|
|
107
|
+
await this.request('POST', `/api/pm/jobs/${jobId}/result`, result);
|
|
108
|
+
}
|
|
109
|
+
async failPmJob(jobId, message) {
|
|
110
|
+
await this.request('POST', `/api/pm/jobs/${jobId}/failure`, { message });
|
|
111
|
+
}
|
|
96
112
|
async request(method, path, body) {
|
|
97
113
|
const first = await this.send(method, path, body);
|
|
98
114
|
if (!needsRefresh(first))
|
package/dist/executor/cli.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// executor once READY 태스크 하나만 처리하고 종료 (개발·데모용)
|
|
13
13
|
// executor start 10초 폴링
|
|
14
14
|
// executor clean worktree 정리
|
|
15
|
+
// nomos pm-worker (대표 전용, 서버가 PM_PROVIDER=relay일 때) PM의 모델 호출을 이 노트북의 Claude Code로 대신 실행
|
|
15
16
|
//
|
|
16
17
|
// 서버를 옮길 때 baseUrl만 바꾸면 안 된다. 토큰은 발급한 서버의 서명 키와 그 DB의 agents 행에 묶여 있어
|
|
17
18
|
// 다른 서버에서는 전부 401이다. 원격에 가입해 받은 연결 키로 login을 다시 해야 한다.
|
|
@@ -19,19 +20,23 @@ import { writeFileSync } from 'node:fs';
|
|
|
19
20
|
import os from 'node:os';
|
|
20
21
|
import path from 'node:path';
|
|
21
22
|
import { createInterface } from 'node:readline/promises';
|
|
23
|
+
import { writeBriefingNotes } from '../bridge/briefing-notes.js';
|
|
22
24
|
import { buildMcpConfig } from '../bridge/claude-args.js';
|
|
23
25
|
import { credentialsPath, readCredentials, updateAccessToken, writeCredentials, } from '../bridge/credentials.js';
|
|
24
|
-
import { NomosClient } from '../bridge/nomos-client.js';
|
|
26
|
+
import { NomosApiError, NomosClient } from '../bridge/nomos-client.js';
|
|
25
27
|
import { connect, DEFAULT_SERVER, serverCalls } from './connect.js';
|
|
26
28
|
import { deviceLogin } from './device-login.js';
|
|
27
29
|
import { cliVersion, mcpServerPath } from './paths.js';
|
|
28
30
|
import { checkTools } from './preflight.js';
|
|
31
|
+
import { handleNextPmJob } from './pm-worker.js';
|
|
29
32
|
import { buildTaskPrompt } from './prompt.js';
|
|
30
33
|
import { runClaude } from './runner.js';
|
|
34
|
+
import { streamTaskSource } from './stream-source.js';
|
|
31
35
|
import { pollingTaskSource } from './task-source.js';
|
|
32
36
|
import { runLint, runSpecTests } from './verify.js';
|
|
33
37
|
import { ensureRepo } from './repo-checkout.js';
|
|
34
38
|
import { cleanWorkspaces, headSha, prepareWorkspace } from './workspace.js';
|
|
39
|
+
// 푸시가 오지 않아도 이 간격으로 한 번씩 다시 본다(끊겼을 때는 이 간격으로 폴링한다).
|
|
35
40
|
const POLL_INTERVAL_MS = 10_000;
|
|
36
41
|
function log(line) {
|
|
37
42
|
process.stdout.write(`[nomos] ${line}\n`);
|
|
@@ -64,6 +69,8 @@ async function handleTask(client, projectId, task) {
|
|
|
64
69
|
policyHash: briefing.policyHash,
|
|
65
70
|
});
|
|
66
71
|
log(`작업공간 ${workspace.dir} (브랜치 ${workspace.branch})`);
|
|
72
|
+
// 프롬프트에 넣는 노트 = 제출 때 "받은 노트"로 함께 보낼 노트. MCP 서버(submit_artifact)가 읽는다.
|
|
73
|
+
writeBriefingNotes(workspace.dir, task.id, (briefing.notes ?? []).map((n) => n.id));
|
|
67
74
|
if (workspace.createdBranch) {
|
|
68
75
|
await client.reportBranch(task.id, workspace.branch);
|
|
69
76
|
}
|
|
@@ -124,10 +131,10 @@ async function reportBridgeStages(client, taskId, workspaceDir, specTests) {
|
|
|
124
131
|
async function once() {
|
|
125
132
|
const client = createClient();
|
|
126
133
|
const self = await client.describeSelf();
|
|
127
|
-
const source = pollingTaskSource(client
|
|
134
|
+
const source = pollingTaskSource(client);
|
|
128
135
|
const [task] = await source.nextTasks(1);
|
|
129
136
|
if (!task) {
|
|
130
|
-
log('
|
|
137
|
+
log('지금 가져갈 수 있는 태스크가 없다(프로젝트 시작 전이거나, 선행 태스크가 끝나지 않았거나, READY가 없다)');
|
|
131
138
|
return;
|
|
132
139
|
}
|
|
133
140
|
await handleTask(client, self.projectId, task);
|
|
@@ -135,28 +142,42 @@ async function once() {
|
|
|
135
142
|
async function start() {
|
|
136
143
|
const client = createClient();
|
|
137
144
|
const self = await client.describeSelf();
|
|
138
|
-
|
|
145
|
+
// 서버 푸시(웹소켓)로 받는다. 끊겨 있는 동안은 같은 목록을 HTTP로 읽는다.
|
|
146
|
+
const source = streamTaskSource({
|
|
147
|
+
baseUrl: readCredentials().baseUrl,
|
|
148
|
+
accessToken: () => readCredentials().accessToken,
|
|
149
|
+
refresh: async () => {
|
|
150
|
+
if ((await serverCalls({ read: readCredentials, updateAccessToken }).refresh()) === 'rejected') {
|
|
151
|
+
throw new Error('refresh token이 거부됐다 — connect를 다시 실행하라');
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
fallback: () => pollingTaskSource(client).nextTasks(Number.MAX_SAFE_INTEGER),
|
|
155
|
+
log,
|
|
156
|
+
});
|
|
139
157
|
log(`시작 — 프로젝트 ${self.projectId}, 역할 ${self.teamRole}, 동시 실행 ${self.maxConcurrent} (${source.kind})`);
|
|
140
158
|
const inFlight = new Set();
|
|
159
|
+
// 태스크 하나가 끝나면 자리가 난다 — 다음 푸시나 간격을 기다리지 않고 바로 다시 본다.
|
|
160
|
+
let finished = () => { };
|
|
141
161
|
for (;;) {
|
|
142
162
|
try {
|
|
143
163
|
const capacity = self.maxConcurrent - inFlight.size;
|
|
144
164
|
if (capacity > 0) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (inFlight.has(task.id))
|
|
148
|
-
continue;
|
|
165
|
+
// 처리 중인 것도 목록에 남아 있을 수 있으니(수령 전) 그만큼 더 받아 거른다.
|
|
166
|
+
for (const task of (await source.nextTasks(capacity + inFlight.size)).filter((t) => !inFlight.has(t.id)).slice(0, capacity)) {
|
|
149
167
|
inFlight.add(task.id);
|
|
150
168
|
void handleTask(client, self.projectId, task)
|
|
151
169
|
.catch((err) => log(`태스크 ${task.id} 실패: ${err instanceof Error ? err.message : String(err)}`))
|
|
152
|
-
.finally(() =>
|
|
170
|
+
.finally(() => {
|
|
171
|
+
inFlight.delete(task.id);
|
|
172
|
+
finished();
|
|
173
|
+
});
|
|
153
174
|
}
|
|
154
175
|
}
|
|
155
176
|
}
|
|
156
177
|
catch (err) {
|
|
157
|
-
log(
|
|
178
|
+
log(`태스크 목록 실패: ${err instanceof Error ? err.message : String(err)}`);
|
|
158
179
|
}
|
|
159
|
-
await new Promise((resolve) =>
|
|
180
|
+
await Promise.race([source.waitForChange(POLL_INTERVAL_MS), new Promise((resolve) => (finished = resolve))]);
|
|
160
181
|
}
|
|
161
182
|
}
|
|
162
183
|
// 토큰이 헤더로 다니므로 원격은 https만 받는다. 로컬 개발 서버만 예외다.
|
|
@@ -311,6 +332,24 @@ async function refresh() {
|
|
|
311
332
|
log(`재발급은 됐지만 아직 프로젝트에 배정되지 않은 것 같다: ${err instanceof Error ? err.message : String(err)}`);
|
|
312
333
|
}
|
|
313
334
|
}
|
|
335
|
+
const PM_POLL_INTERVAL_MS = 3_000;
|
|
336
|
+
async function pmWorker() {
|
|
337
|
+
const client = createClient();
|
|
338
|
+
log('pm-worker 시작 — 대표가 PM에 계획을 요청하면 여기서 claude가 돈다 (Ctrl+C로 종료)');
|
|
339
|
+
for (;;) {
|
|
340
|
+
try {
|
|
341
|
+
if (await handleNextPmJob(client, log))
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
// 설정이 틀린 경우는 기다려도 풀리지 않는다 — 바로 멈춘다.
|
|
346
|
+
if (err instanceof NomosApiError && (err.code === 'PM_RELAY_DISABLED' || err.code === 'NOT_REPRESENTATIVE'))
|
|
347
|
+
throw err;
|
|
348
|
+
log(`폴링 실패: ${err instanceof Error ? err.message : String(err)}`);
|
|
349
|
+
}
|
|
350
|
+
await new Promise((resolve) => setTimeout(resolve, PM_POLL_INTERVAL_MS));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
314
353
|
async function main() {
|
|
315
354
|
const command = process.argv[2];
|
|
316
355
|
if (command === 'connect')
|
|
@@ -329,6 +368,8 @@ async function main() {
|
|
|
329
368
|
return once();
|
|
330
369
|
if (command === 'start')
|
|
331
370
|
return start();
|
|
371
|
+
if (command === 'pm-worker')
|
|
372
|
+
return pmWorker();
|
|
332
373
|
if (command === 'clean') {
|
|
333
374
|
const { pruned, removed } = cleanWorkspaces();
|
|
334
375
|
log(`worktree prune: ${pruned.join(', ') || '없음'} / 삭제: ${removed ?? '없음'}`);
|
|
@@ -340,6 +381,7 @@ async function main() {
|
|
|
340
381
|
' doctor 설치 상태 점검',
|
|
341
382
|
' login [주소] [--name 이름] [--connect-key] 로그인만',
|
|
342
383
|
' refresh | once | start | clean | --version',
|
|
384
|
+
' pm-worker (대표 전용, 서버가 PM_PROVIDER=relay일 때) PM 모델 호출을 이 노트북의 Claude Code로',
|
|
343
385
|
'',
|
|
344
386
|
].join('\n'));
|
|
345
387
|
process.exitCode = 1;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { resolveClaudeCommand } from './runner.js';
|
|
6
|
+
// 지침·프롬프트는 길어서(이전 초안이 붙는 수정 요청) 명령줄로 넘기지 않는다 — Windows 명령줄은 32k자에서 잘린다.
|
|
7
|
+
// 지침은 파일, 프롬프트는 stdin. 도구는 전부 끄고(--tools ""), 사용자 MCP·세션 기록도 쓰지 않는다.
|
|
8
|
+
export function buildPmClaudeArgs(job, systemPromptFile) {
|
|
9
|
+
return [
|
|
10
|
+
'-p',
|
|
11
|
+
'--output-format',
|
|
12
|
+
'json',
|
|
13
|
+
'--model',
|
|
14
|
+
job.request.model,
|
|
15
|
+
'--effort',
|
|
16
|
+
job.request.effort,
|
|
17
|
+
'--system-prompt-file',
|
|
18
|
+
systemPromptFile,
|
|
19
|
+
'--json-schema',
|
|
20
|
+
JSON.stringify(job.request.jsonSchema),
|
|
21
|
+
'--tools',
|
|
22
|
+
'',
|
|
23
|
+
'--strict-mcp-config',
|
|
24
|
+
'--no-session-persistence',
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
// 출력 → 서버에 돌려줄 결과. 실패면 { error }.
|
|
28
|
+
export function toJobResult(out, requestedModel) {
|
|
29
|
+
if (out.is_error)
|
|
30
|
+
return { error: `claude reported an error (${out.subtype ?? 'unknown'}): ${(out.result ?? '').slice(0, 500)}` };
|
|
31
|
+
const text = out.structured_output !== undefined && out.structured_output !== null
|
|
32
|
+
? JSON.stringify(out.structured_output)
|
|
33
|
+
: (out.result ?? '');
|
|
34
|
+
if (!text)
|
|
35
|
+
return { error: 'claude returned no output' };
|
|
36
|
+
// 실제로 응답한 모델: 출력이 가장 많은 모델(부수 호출에 작은 모델이 섞일 수 있다).
|
|
37
|
+
const served = Object.entries(out.modelUsage ?? {}).sort((a, b) => (b[1].outputTokens ?? 0) - (a[1].outputTokens ?? 0))[0]?.[0] ?? requestedModel;
|
|
38
|
+
// stop_reason은 그대로 넘긴다. Claude Code는 구조화 출력을 도구 호출로 내므로 정상 완료도 'tool_use'로 온다(실측) —
|
|
39
|
+
// 서버는 refusal·max_tokens만 따로 다루므로 그대로 둬도 흐름은 같고, 기록은 실제 값이 남는다.
|
|
40
|
+
return {
|
|
41
|
+
stopReason: out.stop_reason ?? null,
|
|
42
|
+
servedModel: served,
|
|
43
|
+
text,
|
|
44
|
+
usage: {
|
|
45
|
+
inputTokens: out.usage?.input_tokens ?? 0,
|
|
46
|
+
outputTokens: out.usage?.output_tokens ?? 0,
|
|
47
|
+
cacheWriteTokens: out.usage?.cache_creation_input_tokens ?? 0,
|
|
48
|
+
cacheReadTokens: out.usage?.cache_read_input_tokens ?? 0,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const spawnClaude = (args, stdin, cwd) => {
|
|
53
|
+
const { command, commandArgs } = resolveClaudeCommand(args);
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
// shell을 쓰지 않는다 — 인자가 명령으로 해석되지 않게(runner.ts와 같은 이유).
|
|
56
|
+
const child = spawn(command, commandArgs, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
57
|
+
let stdout = '';
|
|
58
|
+
let stderr = '';
|
|
59
|
+
child.stdout.on('data', (c) => (stdout += c.toString()));
|
|
60
|
+
child.stderr.on('data', (c) => (stderr += c.toString()));
|
|
61
|
+
child.once('error', reject);
|
|
62
|
+
child.once('close', (exitCode) => resolve({ exitCode, stdout, stderr }));
|
|
63
|
+
child.stdin.end(stdin);
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
export async function runPmJob(job, run = spawnClaude) {
|
|
67
|
+
// 빈 임시 폴더에서 돈다 — 레포·CLAUDE.md를 읽지 않게. 끝나면 지운다(지침 사본이 남지 않게).
|
|
68
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), 'nomos-pm-'));
|
|
69
|
+
try {
|
|
70
|
+
const systemFile = path.join(dir, 'system.md');
|
|
71
|
+
writeFileSync(systemFile, job.request.system, 'utf8');
|
|
72
|
+
const { exitCode, stdout, stderr } = await run(buildPmClaudeArgs(job, systemFile), job.request.user, dir);
|
|
73
|
+
let out;
|
|
74
|
+
try {
|
|
75
|
+
out = JSON.parse(stdout);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return { error: `claude exited ${exitCode} without JSON output: ${(stderr || stdout).slice(0, 500)}` };
|
|
79
|
+
}
|
|
80
|
+
return toJobResult(out, job.request.model);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
rmSync(dir, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// 작업 하나를 가져와 처리한다. 처리했으면 true.
|
|
87
|
+
export async function handleNextPmJob(client, log, run = spawnClaude) {
|
|
88
|
+
const job = (await client.nextPmJob());
|
|
89
|
+
if (!job)
|
|
90
|
+
return false;
|
|
91
|
+
log(`PM 작업 ${job.id} (계획 ${job.planId}, ${job.purpose}) — claude 실행 중`);
|
|
92
|
+
const started = Date.now();
|
|
93
|
+
let result;
|
|
94
|
+
try {
|
|
95
|
+
result = await runPmJob(job, run);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
result = { error: err instanceof Error ? err.message : String(err) };
|
|
99
|
+
}
|
|
100
|
+
const sec = Math.round((Date.now() - started) / 1000);
|
|
101
|
+
if ('error' in result) {
|
|
102
|
+
log(`실패(${sec}s): ${result.error}`);
|
|
103
|
+
await client.failPmJob(job.id, result.error.slice(0, 2000));
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
log(`완료(${sec}s, 출력 ${result.usage.outputTokens} 토큰) — 서버로 보냄`);
|
|
107
|
+
await client.submitPmJobResult(job.id, result);
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
package/dist/executor/prompt.js
CHANGED
|
@@ -22,7 +22,9 @@ export function buildTaskPrompt(briefing, branch) {
|
|
|
22
22
|
'- 위 목록 밖의 경로는 수정하지 않는다. 비밀 파일(.env, *.pem, *.key, secrets/)은 절대 건드리지 않는다.',
|
|
23
23
|
'- 작업이 끝나면 커밋하고, submit_artifact에 **실제 커밋 sha와 실제로 바꾼 모든 경로**를 넣는다.',
|
|
24
24
|
' 바꾸지 않은 경로를 적거나 sha를 지어내지 않는다 — 서버가 제출 시점에 다시 검증한다.',
|
|
25
|
-
'-
|
|
25
|
+
'- 제출이 NOTES_UNACKNOWLEDGED로 반려되면, 작업 중에 새로 올라온 인계 노트가 함께 온다. 읽고, 작업에 영향이 있으면 고쳐서 커밋한 뒤',
|
|
26
|
+
' submit_artifact를 다시 부르며 그 노트 id를 acknowledged_note_ids에 넣는다. 위 [인계 노트]의 노트는 따로 넣지 않아도 된다.',
|
|
27
|
+
'- 다음 사람이 알아야 할 결정이나 함정이 있으면 publish_note로 남긴다. 다른 사람도 따라야 할 결정(계약·형식)은 DECIDED로 남긴다 — 프로젝트 전체에 전달된다.',
|
|
26
28
|
'- 다른 에이전트의 잘못을 노트에 적지 않는다. 그건 raise_dispute의 몫이다.',
|
|
27
29
|
].join('\n'));
|
|
28
30
|
sections.push(`# 지금 할 일\n태스크 ${task.id}를 claim_task로 받고 위 규칙에 따라 진행하라.`);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export const AGENT_STREAM_PATH = '/api/agents/stream';
|
|
2
|
+
export function streamUrl(baseUrl) {
|
|
3
|
+
const url = new URL(AGENT_STREAM_PATH, baseUrl);
|
|
4
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
5
|
+
return url.toString();
|
|
6
|
+
}
|
|
7
|
+
export function streamTaskSource(deps) {
|
|
8
|
+
const WS = deps.WebSocketImpl ?? globalThis.WebSocket;
|
|
9
|
+
const backoff = deps.backoff ?? { initialMs: 1000, maxMs: 30_000 };
|
|
10
|
+
let latest = [];
|
|
11
|
+
let connected = false;
|
|
12
|
+
let closed = false;
|
|
13
|
+
let delay = backoff.initialMs;
|
|
14
|
+
let refreshedInARow = false;
|
|
15
|
+
let ws = null;
|
|
16
|
+
let reconnectTimer = null;
|
|
17
|
+
const waiters = new Set();
|
|
18
|
+
const wake = () => {
|
|
19
|
+
for (const w of waiters)
|
|
20
|
+
w();
|
|
21
|
+
waiters.clear();
|
|
22
|
+
};
|
|
23
|
+
const schedule = () => {
|
|
24
|
+
if (closed)
|
|
25
|
+
return;
|
|
26
|
+
reconnectTimer = setTimeout(connect, delay);
|
|
27
|
+
delay = Math.min(delay * 2, backoff.maxMs);
|
|
28
|
+
};
|
|
29
|
+
function connect() {
|
|
30
|
+
reconnectTimer = null;
|
|
31
|
+
if (closed)
|
|
32
|
+
return;
|
|
33
|
+
const socket = new WS(streamUrl(deps.baseUrl));
|
|
34
|
+
ws = socket;
|
|
35
|
+
socket.onopen = () => socket.send(JSON.stringify({ type: 'auth', token: deps.accessToken() }));
|
|
36
|
+
socket.onmessage = (ev) => {
|
|
37
|
+
let msg;
|
|
38
|
+
try {
|
|
39
|
+
msg = JSON.parse(String(ev.data));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (msg.type === 'ready') {
|
|
45
|
+
connected = true;
|
|
46
|
+
delay = backoff.initialMs;
|
|
47
|
+
refreshedInARow = false;
|
|
48
|
+
deps.log(`서버 푸시 연결됨 — 프로젝트 ${msg.projectId ?? '?'}`);
|
|
49
|
+
}
|
|
50
|
+
else if (msg.type === 'tasks') {
|
|
51
|
+
latest = msg.tasks ?? [];
|
|
52
|
+
wake();
|
|
53
|
+
}
|
|
54
|
+
else if (msg.type === 'error') {
|
|
55
|
+
deps.log(`서버 푸시 거부: ${msg.code ?? '?'} ${msg.message ?? ''}`.trim());
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
socket.onerror = () => { };
|
|
59
|
+
socket.onclose = (ev) => {
|
|
60
|
+
const wasConnected = connected;
|
|
61
|
+
connected = false;
|
|
62
|
+
ws = null;
|
|
63
|
+
if (closed)
|
|
64
|
+
return;
|
|
65
|
+
if (ev.code === 4403) {
|
|
66
|
+
deps.log('이 에이전트는 프로젝트 멤버가 아니다 — 푸시를 멈춘다(폴링으로 계속 확인한다)');
|
|
67
|
+
wake();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (ev.code === 4401 && !refreshedInARow) {
|
|
71
|
+
refreshedInARow = true;
|
|
72
|
+
deps
|
|
73
|
+
.refresh()
|
|
74
|
+
.then(() => connect())
|
|
75
|
+
.catch((err) => deps.log(`토큰 재발급 실패 — 푸시를 멈춘다: ${err instanceof Error ? err.message : String(err)}`));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (wasConnected)
|
|
79
|
+
deps.log(`서버 푸시 끊김(${ev.code}) — 다시 연결한다. 그동안은 폴링한다`);
|
|
80
|
+
wake();
|
|
81
|
+
schedule();
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
connect();
|
|
85
|
+
return {
|
|
86
|
+
kind: 'push',
|
|
87
|
+
get connected() {
|
|
88
|
+
return connected;
|
|
89
|
+
},
|
|
90
|
+
async nextTasks(limit) {
|
|
91
|
+
// 연결돼 있으면 받은 스냅샷, 아니면 같은 목록을 HTTP로.
|
|
92
|
+
const tasks = connected ? latest : await deps.fallback();
|
|
93
|
+
return tasks.slice(0, limit);
|
|
94
|
+
},
|
|
95
|
+
waitForChange(maxMs) {
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
const done = () => {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
waiters.delete(done);
|
|
100
|
+
resolve();
|
|
101
|
+
};
|
|
102
|
+
const timer = setTimeout(done, maxMs);
|
|
103
|
+
waiters.add(done);
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
close() {
|
|
107
|
+
closed = true;
|
|
108
|
+
if (reconnectTimer)
|
|
109
|
+
clearTimeout(reconnectTimer);
|
|
110
|
+
ws?.close(1000, 'bye');
|
|
111
|
+
wake();
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
// 서버가
|
|
2
|
-
export function pollingTaskSource(client
|
|
1
|
+
// 서버가 이 에이전트의 역할·선행·프로젝트 시작 여부로 거른 목록(GET /api/agents/me/tasks)을 읽는다 — 여기서 다시 거르지 않는다.
|
|
2
|
+
export function pollingTaskSource(client) {
|
|
3
3
|
return {
|
|
4
4
|
kind: 'polling',
|
|
5
5
|
async nextTasks(limit) {
|
|
6
|
-
const rows = await client.
|
|
7
|
-
return rows;
|
|
6
|
+
const rows = await client.listClaimableTasks();
|
|
7
|
+
return rows.slice(0, limit);
|
|
8
8
|
},
|
|
9
|
+
waitForChange: (maxMs) => new Promise((resolve) => setTimeout(resolve, maxMs)),
|
|
10
|
+
close: () => { },
|
|
9
11
|
};
|
|
10
12
|
}
|
|
@@ -43,6 +43,7 @@ export function prepareWorkspace(input) {
|
|
|
43
43
|
const dir = workspaceDir(input.projectId, input.taskId);
|
|
44
44
|
const branch = input.branchName ?? `task/${input.taskId}`;
|
|
45
45
|
const existed = branchExists(input.repoPath, branch);
|
|
46
|
+
excludeNomosFiles(input.repoPath);
|
|
46
47
|
if (!existsSync(dir)) {
|
|
47
48
|
mkdirSync(path.dirname(dir), { recursive: true });
|
|
48
49
|
const args = existed
|
|
@@ -57,6 +58,26 @@ export function prepareWorkspace(input) {
|
|
|
57
58
|
writeFileSync(path.join(claudeDir, '.nomos-policy.json'), `${JSON.stringify({ policyHash: input.policyHash, taskId: input.taskId, generatedAt: new Date().toISOString() }, null, 2)}\n`);
|
|
58
59
|
return { dir, branch, createdBranch: !existed, settingsPath };
|
|
59
60
|
}
|
|
61
|
+
// Executor가 작업공간에 쓰는 파일(MCP 설정·실행 로그·브리핑 노트·정책 표시·settings.json)이 모델의 `git add`에 섞이지 않게 한다.
|
|
62
|
+
// 커밋에 섞이면 신고 경로와 실제 diff가 어긋나 V3가 FAIL을 낸다. info/exclude는 레포 공통이라 모든 worktree에 걸린다.
|
|
63
|
+
export const NOMOS_LOCAL_FILES = ['/.nomos-mcp.json', '/.nomos-run.log', '/.nomos-briefing.json', '/.claude/.nomos-policy.json', '/.claude/settings.json'];
|
|
64
|
+
export function excludeNomosFiles(repoPath) {
|
|
65
|
+
try {
|
|
66
|
+
const commonDir = path.resolve(repoPath, git(repoPath, ['rev-parse', '--git-common-dir']));
|
|
67
|
+
const file = path.join(commonDir, 'info', 'exclude');
|
|
68
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
69
|
+
const current = existsSync(file) ? readFileSync(file, 'utf-8') : '';
|
|
70
|
+
const lines = new Set(current.split(/\r?\n/));
|
|
71
|
+
const missing = NOMOS_LOCAL_FILES.filter((p) => !lines.has(p));
|
|
72
|
+
if (missing.length === 0)
|
|
73
|
+
return;
|
|
74
|
+
const prefix = current.length === 0 || current.endsWith('\n') ? '' : '\n';
|
|
75
|
+
writeFileSync(file, `${current}${prefix}# NOMOS Executor가 작업공간에 쓰는 파일\n${missing.join('\n')}\n`);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// 못 써도 작업은 진행한다 — 섞이면 V3가 잡는다.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
60
81
|
// executor clean — worktree를 정리한다. 남겨두는 게 기본이라 정리는 명시적 명령으로만 한다.
|
|
61
82
|
export function cleanWorkspaces() {
|
|
62
83
|
const pruned = [];
|