@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.
package/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # @capstone-nomos/cli
2
+
3
+ NOMOS 에이전트 CLI. 내 노트북의 Claude Code를 NOMOS 서버에 연결하고, 대표가 배정한 태스크를 받아 실행합니다.
4
+
5
+ ```bash
6
+ npx @capstone-nomos/cli@latest connect
7
+ ```
8
+
9
+ 1. 브라우저가 열리면 NOMOS 웹에서 **승인**을 누릅니다(웹에 로그인돼 있어야 합니다).
10
+ 2. 대표가 이 에이전트를 프로젝트에 배정할 때까지 기다립니다. 배정되면 자동으로 시작합니다 — 창을 켜 두세요.
11
+ 3. 태스크의 레포는 `~/.nomos/repos/<조직>/<레포>`에 자동으로 받습니다. 비공개 레포는 이 노트북의 git이 접근할 수 있어야 합니다
12
+ (`gh auth login` 또는 Git Credential Manager).
13
+
14
+ ## 필요한 것
15
+
16
+ - Node.js 22 이상
17
+ - git
18
+ - [Claude Code](https://www.npmjs.com/package/@anthropic-ai/claude-code) — 설치 후 터미널에서 `claude`를 한 번 실행해 로그인
19
+
20
+ ## 명령
21
+
22
+ | 명령 | 하는 일 |
23
+ |---|---|
24
+ | `connect [--server 주소] [--name 이름]` | 로그인(필요하면) → 배정 대기 → 작업 시작. 처음이라면 이것만 |
25
+ | `doctor` | 설치 상태 점검(git·claude·연결된 서버) |
26
+ | `login [주소] [--connect-key]` | 로그인만. 브라우저가 없는 환경(SSH)은 `--connect-key`로 가입 때 받은 연결 키를 씁니다 |
27
+ | `start` · `once` · `refresh` · `clean` | 폴링 실행 · 태스크 하나만 · 토큰 재발급 · 작업공간 정리 |
28
+
29
+ 자격 증명은 `~/.nomos/credentials`(0600), 작업공간은 `~/.nomos/workspaces/`에 둡니다.
@@ -0,0 +1,45 @@
1
+ // 헤드리스 Claude Code를 띄울 때의 인자. 순수 함수로 둔 이유는 MCP 격리가
2
+ // "플래그 하나 빠지면 조용히 무너지는" 종류의 설정이기 때문이다 — 테스트로 고정한다.
3
+ // MCP 설정에 쓰는 서버 이름. 도구의 전체 이름이 여기서 파생되므로 한 곳에만 둔다.
4
+ export const MCP_SERVER_NAME = 'nomos';
5
+ // 서버가 노출하는 도구. Claude Code에서는 mcp__<서버명>__<도구명> 형태로 불린다.
6
+ export const NOMOS_TOOLS = ['claim_task', 'submit_artifact', 'publish_note', 'read_notes'];
7
+ export function allowedToolNames() {
8
+ return NOMOS_TOOLS.map((tool) => `mcp__${MCP_SERVER_NAME}__${tool}`);
9
+ }
10
+ // --strict-mcp-config가 없으면 사용자의 ~/.claude.json에 등록된 MCP 서버가 함께 로드된다.
11
+ // GitHub MCP가 살아 있으면 에이전트가 submit_artifact를 건너뛰고 직접 push할 수 있고,
12
+ // 그러면 제출 시점 경로 검증(V3)이 아무것도 못 막는다. 두 플래그는 항상 함께 간다.
13
+ export function buildClaudeArgs(options) {
14
+ // 프롬프트는 반드시 -p의 **값**으로 준다. 맨 뒤에 위치 인자로 붙이면
15
+ // --allowedTools가 가변 인자라 프롬프트를 도구 이름으로 삼켜버리고,
16
+ // claude는 "Input must be provided…"로 죽는다 (실제로 겪었다).
17
+ const args = ['-p', options.prompt, '--output-format', 'stream-json', '--verbose'];
18
+ if (options.model)
19
+ args.push('--model', options.model);
20
+ args.push('--mcp-config', options.mcpConfigPath, '--strict-mcp-config');
21
+ // 헤드리스에는 권한 프롬프트에 답할 사람이 없다. 이게 없으면 모델이 도구를 고른 뒤
22
+ // "승인 대기"에서 멈춘다 — 도구를 못 찾는 것과 증상이 달라 헷갈리기 쉽다.
23
+ // 가변 인자를 마지막에 두어 뒤에 아무것도 붙지 않게 한다.
24
+ args.push('--allowedTools', allowedToolNames().join(','));
25
+ return args;
26
+ }
27
+ // 우리 MCP 서버 하나만 담은 설정. --mcp-config로 넘긴다.
28
+ //
29
+ // **토큰을 여기 넣지 않는다.** 이 파일은 Claude Code에 넘기는 설정이고, 자격 증명의 정본은
30
+ // ~/.nomos/credentials다(0600). 예전에는 env에 평문으로 박았는데, 재발급된 토큰을 되돌려 쓸 곳이 없어
31
+ // 매 실행이 401 한 번을 먹고 시작했다.
32
+ //
33
+ // workspaceDir은 submit_artifact가 push할 작업공간이다. MCP 서버의 cwd가 작업공간이라는 보장이 없으므로
34
+ // 명시한다(경로일 뿐 비밀값이 아니다). 없으면 MCP 서버는 자기 cwd를 쓴다 — 수동 테스트용.
35
+ export function buildMcpConfig(input) {
36
+ return JSON.stringify({
37
+ mcpServers: {
38
+ [MCP_SERVER_NAME]: {
39
+ command: input.nodePath ?? process.execPath,
40
+ args: [input.serverPath],
41
+ ...(input.workspaceDir === undefined ? {} : { env: { NOMOS_WORKSPACE_DIR: input.workspaceDir } }),
42
+ },
43
+ },
44
+ }, null, 2);
45
+ }
@@ -0,0 +1,38 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ export function credentialsDir() {
5
+ return path.join(os.homedir(), '.nomos');
6
+ }
7
+ export function credentialsPath() {
8
+ return path.join(credentialsDir(), 'credentials');
9
+ }
10
+ export function readCredentials() {
11
+ const file = credentialsPath();
12
+ if (!existsSync(file))
13
+ return null;
14
+ const parsed = JSON.parse(readFileSync(file, 'utf-8'));
15
+ if (!parsed.baseUrl || !parsed.accessToken || !parsed.refreshToken || !parsed.agentId) {
16
+ throw new Error(`${file}에 baseUrl·accessToken·refreshToken·agentId가 모두 있어야 합니다`);
17
+ }
18
+ return parsed;
19
+ }
20
+ // 0600으로 쓴다. Windows에서는 chmod가 사실상 무시되지만, 같은 코드가 리눅스 배포에서도 돌아야 한다.
21
+ export function writeCredentials(credentials) {
22
+ mkdirSync(credentialsDir(), { recursive: true, mode: 0o700 });
23
+ const file = credentialsPath();
24
+ writeFileSync(file, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
25
+ try {
26
+ chmodSync(file, 0o600);
27
+ }
28
+ catch {
29
+ // Windows에서는 실패할 수 있다. 파일 내용이 더 중요하므로 막지 않는다.
30
+ }
31
+ }
32
+ // 재발급된 access token을 파일에 되돌려 쓴다. 이게 없으면 다음 실행이 또 401로 시작한다.
33
+ export function updateAccessToken(accessToken) {
34
+ const current = readCredentials();
35
+ if (!current)
36
+ return;
37
+ writeCredentials({ ...current, accessToken });
38
+ }
@@ -0,0 +1,130 @@
1
+ // NOMOS MCP 서버. 헤드리스 Claude Code가 이 서버의 도구로만 태스크를 잡고 산출물을 낸다.
2
+ //
3
+ // stdout은 MCP 프로토콜(JSON-RPC) 전용이다. 로그는 반드시 stderr로만 쓴다 —
4
+ // stdout에 한 줄이라도 섞이면 클라이언트가 프로토콜 파싱에 실패한다.
5
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
+ import { z } from 'zod';
8
+ import { readCredentials, updateAccessToken } from './credentials.js';
9
+ import { NomosClient, PolicyStaleLoopError } from './nomos-client.js';
10
+ import { pushTaskBranch } from './push.js';
11
+ // 자격 증명은 ~/.nomos/credentials가 정본이다(0600). 환경변수는 파일이 없을 때만 쓴다
12
+ // (테스트·일회성 실행용). 둘을 섞으면 재발급 결과가 어디에 저장되는지 흐려진다.
13
+ function resolveCredentials() {
14
+ const stored = readCredentials();
15
+ if (stored)
16
+ return stored;
17
+ const { NOMOS_BASE_URL, NOMOS_ACCESS_TOKEN, NOMOS_REFRESH_TOKEN } = process.env;
18
+ if (!NOMOS_BASE_URL || !NOMOS_ACCESS_TOKEN || !NOMOS_REFRESH_TOKEN) {
19
+ process.stderr.write('[nomos-mcp] ~/.nomos/credentials가 없고 NOMOS_* 환경변수도 없습니다\n');
20
+ process.exit(1);
21
+ }
22
+ return { baseUrl: NOMOS_BASE_URL, accessToken: NOMOS_ACCESS_TOKEN, refreshToken: NOMOS_REFRESH_TOKEN };
23
+ }
24
+ const credentials = resolveCredentials();
25
+ const client = new NomosClient({
26
+ baseUrl: credentials.baseUrl,
27
+ tokens: { accessToken: credentials.accessToken, refreshToken: credentials.refreshToken },
28
+ // 재발급은 조용히 일어난다 — 모델은 401을 보지 못하고 도구가 그냥 성공한 것으로 본다.
29
+ // 파일에 되돌려 써야 다음 실행이 401 없이 시작한다.
30
+ onTokensChanged: (tokens) => {
31
+ updateAccessToken(tokens.accessToken);
32
+ process.stderr.write('[nomos-mcp] policy_stale — 토큰 재발급 후 원 요청을 1회 재시도합니다\n');
33
+ },
34
+ });
35
+ function ok(text) {
36
+ return { content: [{ type: 'text', text }] };
37
+ }
38
+ // 도구 실패는 예외로 던지지 않고 isError로 돌려준다. 모델이 이유를 읽고 다음 행동을 고를 수 있어야 한다.
39
+ // 다만 policy_stale 루프는 다르다 — 모델이 재시도해서 될 일이 아니므로 멈추라고 명시한다.
40
+ async function run(fn, label) {
41
+ try {
42
+ return ok(`${label}: ${JSON.stringify(await fn())}`);
43
+ }
44
+ catch (err) {
45
+ if (err instanceof PolicyStaleLoopError) {
46
+ process.stderr.write(`[nomos-mcp] ${err.message}\n`);
47
+ return {
48
+ content: [
49
+ {
50
+ type: 'text',
51
+ text: '경로 규칙이 계속 바뀌고 있어 작업을 멈춥니다. 재시도하지 말고 사람에게 알리세요.',
52
+ },
53
+ ],
54
+ isError: true,
55
+ };
56
+ }
57
+ const message = err instanceof Error ? err.message : String(err);
58
+ process.stderr.write(`[nomos-mcp] ${label} failed: ${message}\n`);
59
+ return { content: [{ type: 'text', text: `${label} failed: ${message}` }], isError: true };
60
+ }
61
+ }
62
+ const server = new McpServer({ name: 'nomos', version: '0.1.0' });
63
+ server.registerTool('claim_task', {
64
+ title: 'Claim a task',
65
+ description: 'Claim a NOMOS task before doing any work on it. You must call this and get a success result before editing any file. Fails if someone else already claimed it, if its dependencies are not DONE, or if the task belongs to another team role.',
66
+ inputSchema: { task_id: z.string().describe('The task id to claim') },
67
+ }, async ({ task_id }) => run(() => client.claimTask(task_id), 'claim_task'));
68
+ // submit_artifact가 push할 작업공간. Executor가 MCP 설정의 env로 넘긴다.
69
+ const workspaceDir = process.env.NOMOS_WORKSPACE_DIR ?? process.cwd();
70
+ server.registerTool('submit_artifact', {
71
+ 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
+ inputSchema: {
74
+ task_id: z.string().describe('The claimed task id'),
75
+ commit_sha: z.string().describe('Commit sha containing the work'),
76
+ changed_paths: z.array(z.string()).describe('Repo-relative paths changed by this commit'),
77
+ },
78
+ }, async ({ task_id, commit_sha, changed_paths }) => run(async () => {
79
+ // push할 브랜치와 막을 브랜치는 서버가 정한다. 모델이 넘긴 값으로 고르지 않는다.
80
+ const briefing = (await client.getBriefing(task_id));
81
+ const push = await pushTaskBranch({
82
+ workspaceDir,
83
+ commitSha: commit_sha,
84
+ taskBranch: briefing.task.branchName,
85
+ protectedBranches: [briefing.repo.defaultBranch, briefing.repo.devBranch],
86
+ });
87
+ if (!push.pushed) {
88
+ process.stderr.write('[nomos-mcp] origin 없음 — push 건너뜀(서버가 mirror 모드로 로컬에서 읽는다)\n');
89
+ }
90
+ // push가 실패하면(PushRefused) 여기까지 오지 않는다 — 제출하지 않고 run()이 모델에게 사유를 돌려준다.
91
+ const artifact = await client.submitArtifact(task_id, { commitSha: commit_sha, changedPaths: changed_paths });
92
+ return { ...artifact, push };
93
+ }, 'submit_artifact'));
94
+ server.registerTool('publish_note', {
95
+ title: 'Publish a handover note',
96
+ description: 'Record what the next person needs to know about the task you claimed: what you implemented, what you decided, a gotcha you hit, or a deviation from the spec. This is a record, not a chat message — there is no reply and no recipient. Keep each key point to one short sentence; the server rejects notes that are too long instead of truncating them. Claims about another agent belong in raise_dispute, not here.',
97
+ inputSchema: {
98
+ task_id: z.string().describe('The claimed task id'),
99
+ kind: z.enum(['IMPLEMENTED', 'DECIDED', 'GOTCHA', 'DEVIATION']).describe('Note kind'),
100
+ headline: z.string().describe('One line, 60 characters or fewer'),
101
+ key_points: z.array(z.string()).describe('1-5 points, each 120 characters or fewer'),
102
+ affects: z
103
+ .array(z.string())
104
+ .optional()
105
+ .describe('Paths this affects. Required for DEVIATION. May point outside your own paths'),
106
+ supersedes: z.string().optional().describe('Note id this one corrects'),
107
+ },
108
+ }, async ({ task_id, kind, headline, key_points, affects, supersedes }) => run(() => client.publishNote(task_id, {
109
+ kind,
110
+ headline,
111
+ keyPoints: key_points,
112
+ affects: affects ?? [],
113
+ ...(supersedes === undefined ? {} : { supersedes }),
114
+ }), 'publish_note'));
115
+ server.registerTool('read_notes', {
116
+ title: 'Read handover notes',
117
+ description: 'Read handover notes for the project. Notes relevant to your task are already in your prompt, so use this only when you need more — older notes, or notes for a different feature.',
118
+ inputSchema: {
119
+ project_id: z.string().describe('Project id'),
120
+ spec_id: z.string().optional().describe('Only notes for this feature spec'),
121
+ since_seq: z.number().optional().describe('Only notes after this sequence number'),
122
+ limit: z.number().optional().describe('Up to 50, default 20'),
123
+ },
124
+ }, async ({ project_id, spec_id, since_seq, limit }) => run(() => client.readNotes(project_id, {
125
+ ...(spec_id === undefined ? {} : { specId: spec_id }),
126
+ ...(since_seq === undefined ? {} : { sinceSeq: since_seq }),
127
+ ...(limit === undefined ? {} : { limit }),
128
+ }), 'read_notes'));
129
+ await server.connect(new StdioServerTransport());
130
+ process.stderr.write('[nomos-mcp] ready\n');
@@ -0,0 +1,161 @@
1
+ // 브릿지가 NOMOS 서버를 부를 때 쓰는 클라이언트. MCP 서버가 이걸 통해 도구를 실행한다.
2
+ //
3
+ // 핵심 책임 하나: 401 policy_stale 처리. 경로 규칙이나 정책이 바뀌면 이미 발급된 토큰은
4
+ // 옛 스냅샷 기준이 되어 거부된다. 그때 refresh로 재발급하고 원 요청을 **1회만** 재시도한다.
5
+ // 재발급 후에도 stale이면 루프이므로 즉시 멈춘다 — 무한 재시도가 이 흐름에서 가장 흔한 사고다.
6
+ // 재발급 직후에도 정책이 또 바뀐 경우. 사용자에게 알리고 멈춰야 하는 신호다.
7
+ export class PolicyStaleLoopError extends Error {
8
+ constructor() {
9
+ super('policy changed again right after refresh; stopping instead of retrying');
10
+ this.name = 'PolicyStaleLoopError';
11
+ }
12
+ }
13
+ export class NomosApiError extends Error {
14
+ status;
15
+ code;
16
+ details;
17
+ // details는 메시지에도 붙인다. MCP 도구는 err.message만 모델에게 보여주므로,
18
+ // 여기서 빼면 NOTE_INVALID의 위반 목록이 모델에 닿지 않고 같은 요청이 반복된다.
19
+ constructor(status, code, message, details) {
20
+ super(details === undefined ? message : `${message} ${JSON.stringify(details)}`);
21
+ this.name = 'NomosApiError';
22
+ this.status = status;
23
+ this.code = code;
24
+ if (details !== undefined)
25
+ this.details = details;
26
+ }
27
+ }
28
+ export class NomosClient {
29
+ baseUrl;
30
+ fetchImpl;
31
+ onTokensChanged;
32
+ current;
33
+ // 진단용. 통합 테스트가 "정확히 한 번만 재발급했는가"를 확인한다.
34
+ refreshCount = 0;
35
+ constructor(options) {
36
+ this.baseUrl = options.baseUrl.replace(/\/$/, '');
37
+ this.fetchImpl = options.fetchImpl ?? fetch;
38
+ this.onTokensChanged = options.onTokensChanged;
39
+ this.current = options.tokens;
40
+ }
41
+ get tokens() {
42
+ return this.current;
43
+ }
44
+ async claimTask(taskId) {
45
+ return (await this.request('POST', `/api/tasks/${taskId}/claim`));
46
+ }
47
+ async submitArtifact(taskId, input) {
48
+ return (await this.request('POST', `/api/tasks/${taskId}/artifacts`, input));
49
+ }
50
+ // 브릿지 단계(V2·V4) 보고. 서버가 판정하는 V1A·V1B·V3는 여기로 보낼 수 없다 — 403이 돌아온다.
51
+ async reportVerification(artifactId, input) {
52
+ return (await this.request('POST', `/api/artifacts/${artifactId}/verifications`, input));
53
+ }
54
+ // 모델이 MCP 도구로 제출한 산출물의 id를 Executor가 찾는 경로. 최신이 앞에 온다.
55
+ async listArtifacts(taskId) {
56
+ const data = (await this.request('GET', `/api/tasks/${taskId}/artifacts`));
57
+ return data.artifacts;
58
+ }
59
+ async publishNote(taskId, input) {
60
+ return (await this.request('POST', `/api/tasks/${taskId}/notes`, input));
61
+ }
62
+ async readNotes(projectId, filter = {}) {
63
+ const params = new URLSearchParams();
64
+ if (filter.specId !== undefined)
65
+ params.set('spec_id', filter.specId);
66
+ if (filter.sinceSeq !== undefined)
67
+ params.set('since_seq', String(filter.sinceSeq));
68
+ if (filter.limit !== undefined)
69
+ params.set('limit', String(filter.limit));
70
+ const query = params.toString();
71
+ const path = `/api/projects/${projectId}/notes${query ? `?${query}` : ''}`;
72
+ const data = (await this.request('GET', path));
73
+ return data.notes;
74
+ }
75
+ // Executor 폴링용. 에이전트 토큰이면 서버가 자기 역할로 강제 필터한다.
76
+ async listTasks(projectId, filter = {}) {
77
+ const params = new URLSearchParams();
78
+ if (filter.state !== undefined)
79
+ params.set('state', filter.state);
80
+ if (filter.limit !== undefined)
81
+ params.set('limit', String(filter.limit));
82
+ const query = params.toString();
83
+ const data = (await this.request('GET', `/api/projects/${projectId}/tasks${query ? `?${query}` : ''}`));
84
+ return data.tasks;
85
+ }
86
+ // 프롬프트와 .claude/settings.json을 만드는 데 필요한 것 한 번에.
87
+ async getBriefing(taskId) {
88
+ return (await this.request('GET', `/api/tasks/${taskId}/briefing`));
89
+ }
90
+ async describeSelf() {
91
+ return (await this.request('GET', '/api/agents/me'));
92
+ }
93
+ async reportBranch(taskId, branchName) {
94
+ await this.request('PATCH', `/api/tasks/${taskId}/branch`, { branchName });
95
+ }
96
+ async request(method, path, body) {
97
+ const first = await this.send(method, path, body);
98
+ if (!needsRefresh(first))
99
+ return unwrap(first);
100
+ // 여기서부터가 재시도 경로. 재발급 → 원 요청 1회.
101
+ await this.refresh();
102
+ const second = await this.send(method, path, body);
103
+ // 재발급 직후에도 같은 이유로 막히면 루프다. 두 번째 재발급은 하지 않는다.
104
+ if (needsRefresh(second))
105
+ throw new PolicyStaleLoopError();
106
+ return unwrap(second);
107
+ }
108
+ async send(method, path, body) {
109
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
110
+ method,
111
+ headers: {
112
+ Authorization: `Bearer ${this.current.accessToken}`,
113
+ ...(method === 'GET' ? {} : { 'Content-Type': 'application/json' }),
114
+ },
115
+ ...(method === 'GET' ? {} : { body: JSON.stringify(body ?? {}) }),
116
+ });
117
+ return { status: res.status, body: (await res.json()) };
118
+ }
119
+ async refresh() {
120
+ this.refreshCount += 1;
121
+ const res = await this.fetchImpl(`${this.baseUrl}/api/agents/token/refresh`, {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/json' },
124
+ body: JSON.stringify({ refreshToken: this.current.refreshToken }),
125
+ });
126
+ const body = (await res.json());
127
+ if (res.status !== 200) {
128
+ throw toApiError({ status: res.status, body });
129
+ }
130
+ const data = body.data;
131
+ if (!data?.accessToken) {
132
+ throw new NomosApiError(res.status, 'INVALID_REFRESH_RESPONSE', 'refresh returned no access token');
133
+ }
134
+ this.current = { ...this.current, accessToken: data.accessToken };
135
+ this.onTokensChanged?.(this.current);
136
+ }
137
+ }
138
+ function errorOf(res) {
139
+ const error = res.body.error;
140
+ return typeof error === 'object' && error !== null ? error : {};
141
+ }
142
+ // 재발급으로 풀 수 있는 401인가.
143
+ // POLICY_STALE 경로 규칙·정책이 바뀌어 토큰이 옛 스냅샷 기준이 됐다
144
+ // UNAUTHENTICATED access token이 만료됐다 (1시간)
145
+ // 둘 다 refresh 한 번으로 풀린다. 만료를 빼놓으면 Executor가 한 시간 뒤부터 조용히 죽는다 —
146
+ // 실제로 그렇게 멈춰 있었다.
147
+ function needsRefresh(res) {
148
+ if (res.status !== 401)
149
+ return false;
150
+ const code = errorOf(res).code;
151
+ return code === 'POLICY_STALE' || code === 'UNAUTHENTICATED';
152
+ }
153
+ function toApiError(res) {
154
+ const { code, message, details } = errorOf(res);
155
+ return new NomosApiError(res.status, code ?? 'UNKNOWN', message ?? 'request failed', details);
156
+ }
157
+ function unwrap(res) {
158
+ if (res.status >= 400)
159
+ throw toApiError(res);
160
+ return res.body.data ?? {};
161
+ }
@@ -0,0 +1,93 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const exec = promisify(execFile);
4
+ // submit_artifact가 서버에 제출하기 **전에** 태스크 브랜치를 push한다.
5
+ //
6
+ // 이유: 배포 서버의 V3는 GitHub 커밋 API로 diff를 읽는다. 커밋이 팀원 노트북에만 있으면 GitHub는 404를
7
+ // 주고, 그건 "없는 커밋 = FAIL"로 적혀 retry_count가 오른다 — 에이전트 잘못이 아닌데 재시도를 잃는다.
8
+ //
9
+ // push는 모델이 아니라 이 도구가 한다. 모델에게 push 도구를 주면 아무 브랜치에나 쓸 수 있고,
10
+ // 그게 GitHub MCP를 막아둔 이유(--strict-mcp-config)를 그대로 무너뜨린다. 그래서 규칙을 여기 고정한다:
11
+ // - 서버에 기록된 태스크 브랜치(tasks.branch_name)에만 push한다
12
+ // - main·dev·레포의 default_branch·dev_branch로는 절대 push하지 않는다
13
+ // - force push 하지 않는다 (앞선 커밋과 어긋나면 거부당하고 그대로 실패로 돌려준다)
14
+ // - 원격(origin)이 없는 로컬 데모 레포는 건너뛴다 — 서버가 mirror 모드로 로컬 경로에서 직접 읽는다
15
+ // 실패하면 서버에 제출하지 않는다. 호출부가 모델에게 오류를 돌려준다.
16
+ export class PushRefused extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = 'PushRefused';
20
+ }
21
+ }
22
+ // 이름으로 막는 브랜치. 레포별 default_branch·dev_branch는 호출부가 더한다.
23
+ const ALWAYS_PROTECTED = ['main', 'dev'];
24
+ const REMOTE = 'origin';
25
+ const PUSH_TIMEOUT_MS = 60_000;
26
+ // 헤드리스에서 자격 증명을 묻는 프롬프트가 뜨면 도구 호출이 끝없이 멈춘다. 묻지 말고 실패하게 한다.
27
+ const defaultGit = async (cwd, args) => {
28
+ const { stdout } = await exec('git', ['-C', cwd, ...args], {
29
+ timeout: PUSH_TIMEOUT_MS,
30
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
31
+ });
32
+ return stdout.trim();
33
+ };
34
+ // 원격 URL에 토큰이 박혀 있으면(https://user:token@host) git 에러 메시지에 그대로 실릴 수 있다.
35
+ // 모델에게 돌려주기 전에 지운다.
36
+ function redact(text) {
37
+ return text.replace(/(\w+:\/\/)[^/@\s]+@/g, '$1***@');
38
+ }
39
+ function failure(err) {
40
+ const stderr = err.stderr;
41
+ const raw = typeof stderr === 'string' && stderr.trim() !== '' ? stderr : err instanceof Error ? err.message : String(err);
42
+ return redact(raw.trim().split('\n').slice(-3).join(' / '));
43
+ }
44
+ export async function pushTaskBranch(input) {
45
+ const git = input.git ?? defaultGit;
46
+ const ws = input.workspaceDir;
47
+ try {
48
+ await git(ws, ['remote', 'get-url', REMOTE]);
49
+ }
50
+ catch {
51
+ return { pushed: false, reason: 'no-remote' };
52
+ }
53
+ const branch = input.taskBranch;
54
+ if (!branch) {
55
+ throw new PushRefused('태스크 브랜치가 서버에 기록되지 않았다 — Executor가 작업공간을 만들 때 보고한다');
56
+ }
57
+ try {
58
+ await git(ws, ['check-ref-format', '--branch', branch]);
59
+ }
60
+ catch {
61
+ throw new PushRefused(`브랜치 이름이 올바르지 않다: ${branch}`);
62
+ }
63
+ // 대소문자만 다른 이름으로 우회하지 못하게 한다(Windows·macOS에서는 같은 브랜치로 취급될 수 있다).
64
+ const protectedSet = new Set([...ALWAYS_PROTECTED, ...input.protectedBranches].map((b) => b.toLowerCase()));
65
+ if (protectedSet.has(branch.toLowerCase())) {
66
+ throw new PushRefused(`${branch}에는 push하지 않는다 — 태스크 브랜치에서 작업해야 한다`);
67
+ }
68
+ let head;
69
+ try {
70
+ head = await git(ws, ['symbolic-ref', '--short', 'HEAD']);
71
+ }
72
+ catch {
73
+ throw new PushRefused('작업공간이 브랜치 위에 있지 않다(detached HEAD)');
74
+ }
75
+ if (head !== branch) {
76
+ throw new PushRefused(`작업공간이 태스크 브랜치(${branch})가 아니라 ${head}에 있다`);
77
+ }
78
+ // 제출하려는 커밋이 태스크 브랜치에 들어 있어야 push 뒤에 서버가 찾을 수 있다.
79
+ try {
80
+ await git(ws, ['merge-base', '--is-ancestor', input.commitSha, `refs/heads/${branch}`]);
81
+ }
82
+ catch {
83
+ throw new PushRefused(`커밋 ${input.commitSha}이 태스크 브랜치(${branch})에 없다`);
84
+ }
85
+ // 명시적 refspec. '+' 접두사가 없으므로 force가 아니다 — 원격과 어긋나면 git이 거부한다.
86
+ try {
87
+ await git(ws, ['push', '--porcelain', REMOTE, `refs/heads/${branch}:refs/heads/${branch}`]);
88
+ }
89
+ catch (err) {
90
+ throw new PushRefused(`push 실패: ${failure(err)}`);
91
+ }
92
+ return { pushed: true, branch };
93
+ }