@capstone-nomos/cli 0.1.3 → 0.1.4

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.
@@ -40,6 +40,14 @@ export const ALLOWED_BASH_PREFIXES = [
40
40
  'npm run test',
41
41
  'npx vitest',
42
42
  'node --test',
43
+ // 시험 실행기(운영 테스트 4-4: python이 막혀 BE가 시험을 한 번도 못 돌리고 거부만 13번). 설치는 Executor가 미리 한다 — 설치 명령은 열지 않는다.
44
+ 'pnpm test',
45
+ 'pnpm run test',
46
+ 'yarn test',
47
+ 'python -m pytest',
48
+ 'python -m unittest',
49
+ 'python3 -m pytest',
50
+ 'python3 -m unittest',
43
51
  ];
44
52
  export function allowedBashRules() {
45
53
  return ALLOWED_BASH_PREFIXES.map((prefix) => `Bash(${prefix}:*)`);
@@ -95,6 +95,16 @@ export class NomosClient {
95
95
  async describeSelf() {
96
96
  return (await this.request('GET', '/api/agents/me'));
97
97
  }
98
+ // 룸: Claude 실행 시작·끝, 실행 중 도구 사용(종류와 대상만).
99
+ async startRun(taskId) {
100
+ await this.request('POST', `/api/tasks/${taskId}/runs/start`);
101
+ }
102
+ async endRun(taskId, body) {
103
+ return (await this.request('POST', `/api/tasks/${taskId}/runs/end`, body));
104
+ }
105
+ async postActivity(taskId, items) {
106
+ await this.request('POST', `/api/tasks/${taskId}/activity`, { items });
107
+ }
98
108
  async reportBranch(taskId, branchName) {
99
109
  await this.request('PATCH', `/api/tasks/${taskId}/branch`, { branchName });
100
110
  }
@@ -0,0 +1,192 @@
1
+ import path from 'node:path';
2
+ const MAX_TARGET = 300;
3
+ function clip(text) {
4
+ const oneLine = text.split(/\r?\n/)[0].trim();
5
+ return oneLine.length > MAX_TARGET ? `${oneLine.slice(0, MAX_TARGET - 1)}…` : oneLine;
6
+ }
7
+ // 작업공간 안의 경로는 상대 경로로(슬래시 통일), 밖이면 그대로. 노트북의 사용자 폴더 이름이 룸에 찍히지 않게 한다.
8
+ function relative(cwd, p) {
9
+ if (typeof p !== 'string' || p === '')
10
+ return null;
11
+ const abs = path.resolve(cwd, p);
12
+ const rel = path.relative(cwd, abs);
13
+ if (rel === '')
14
+ return '.';
15
+ if (rel.startsWith('..') || path.isAbsolute(rel))
16
+ return path.basename(abs);
17
+ return rel.split(path.sep).join('/');
18
+ }
19
+ // 명령 안의 작업공간 절대 경로를 '.'으로 바꾼다 — 모델이 `git -C "C:/Users/<이름>/.nomos/..."`처럼 쓰면 사용자 폴더 이름이 룸에 찍힌다(실측).
20
+ function hideWorkspace(command, cwd) {
21
+ const forms = new Set([cwd, cwd.split(path.sep).join('/'), cwd.split('/').join('\\')]);
22
+ let out = command;
23
+ for (const form of [...forms].sort((a, b) => b.length - a.length)) {
24
+ if (form)
25
+ out = out.split(form).join('.');
26
+ }
27
+ return out;
28
+ }
29
+ // 도구 하나 → 활동 한 줄. 룸에 보일 필요가 없는 것(NOMOS 도구 — 제출·노트는 서버 이벤트로 이미 보인다, 할 일 목록)은 null.
30
+ export function toActivity(tool, input, cwd) {
31
+ // ToolSearch는 Claude Code가 도구 정의를 불러오는 내부 동작이다(실측: 실행마다 첫 줄로 찍혔다).
32
+ if (tool.startsWith('mcp__nomos__') || ['TodoWrite', 'AskUserQuestion', 'ToolSearch'].includes(tool))
33
+ return null;
34
+ const file = (key) => relative(cwd, input[key]);
35
+ let item = null;
36
+ switch (tool) {
37
+ case 'Read': {
38
+ const f = file('file_path');
39
+ item = f ? { kind: 'read', target: f } : null;
40
+ break;
41
+ }
42
+ case 'Edit':
43
+ case 'MultiEdit': {
44
+ const f = file('file_path');
45
+ item = f ? { kind: 'edit', target: f } : null;
46
+ break;
47
+ }
48
+ case 'NotebookEdit': {
49
+ const f = file('notebook_path');
50
+ item = f ? { kind: 'edit', target: f } : null;
51
+ break;
52
+ }
53
+ case 'Write': {
54
+ const f = file('file_path');
55
+ item = f ? { kind: 'write', target: f } : null;
56
+ break;
57
+ }
58
+ case 'Bash':
59
+ item = typeof input.command === 'string' && input.command.trim() ? { kind: 'run', target: hideWorkspace(input.command, cwd) } : null;
60
+ break;
61
+ case 'Grep':
62
+ case 'Glob':
63
+ item = typeof input.pattern === 'string' && input.pattern ? { kind: 'search', target: input.pattern } : null;
64
+ break;
65
+ default:
66
+ item = { kind: 'other', target: tool };
67
+ }
68
+ return item ? { kind: item.kind, target: clip(item.target) } : null;
69
+ }
70
+ // stream-json 한 줄 → 그 줄에 든 도구 호출들. 형식이 다르거나 깨진 줄은 조용히 건너뛴다(룸 표시가 실행을 막으면 안 된다).
71
+ export function activityFromStreamLine(line, cwd) {
72
+ const trimmed = line.trim();
73
+ if (!trimmed.startsWith('{'))
74
+ return [];
75
+ let msg;
76
+ try {
77
+ msg = JSON.parse(trimmed);
78
+ }
79
+ catch {
80
+ return [];
81
+ }
82
+ if (msg.type !== 'assistant' || !Array.isArray(msg.message?.content))
83
+ return [];
84
+ const items = [];
85
+ for (const block of msg.message.content) {
86
+ if (block.type !== 'tool_use' || typeof block.name !== 'string')
87
+ continue;
88
+ const item = toActivity(block.name, (block.input ?? {}), cwd);
89
+ if (item)
90
+ items.push(item);
91
+ }
92
+ return items;
93
+ }
94
+ // 활동을 모아 2초마다 한 번에 보낸다. 보내기 실패는 기록만 하고 넘어간다 — 룸 표시가 태스크 실행을 멈추면 안 된다.
95
+ export class ActivityReporter {
96
+ client;
97
+ taskId;
98
+ log;
99
+ queue = [];
100
+ timer = null;
101
+ sending = Promise.resolve();
102
+ warned = false;
103
+ constructor(client, taskId, log, intervalMs = 2_000) {
104
+ this.client = client;
105
+ this.taskId = taskId;
106
+ this.log = log;
107
+ this.timer = setInterval(() => void this.flush(), intervalMs);
108
+ this.timer.unref();
109
+ }
110
+ push(items) {
111
+ this.queue.push(...items);
112
+ }
113
+ flush() {
114
+ this.sending = this.sending.then(async () => {
115
+ while (this.queue.length > 0) {
116
+ const batch = this.queue.splice(0, 50);
117
+ try {
118
+ await this.client.postActivity(this.taskId, batch);
119
+ }
120
+ catch (err) {
121
+ if (!this.warned) {
122
+ this.warned = true;
123
+ this.log(`룸 활동 보고 실패(실행은 계속한다): ${err instanceof Error ? err.message : String(err)}`);
124
+ }
125
+ }
126
+ }
127
+ });
128
+ return this.sending;
129
+ }
130
+ async close() {
131
+ if (this.timer)
132
+ clearInterval(this.timer);
133
+ this.timer = null;
134
+ await this.flush();
135
+ }
136
+ }
137
+ // 실행이 제출 없이 끝났을 때 대표에게 보일 사유를 모은다 — 거부된 쉘 명령과 모델의 마지막 말.
138
+ // 거부는 Claude Code가 돌려준 도구 결과(is_error)의 문구로 가른다("requires approval" 등). 문구가 바뀌면 빠질 수 있으나 판정이 아니라 안내라 괜찮다.
139
+ const DENIAL = /requires (manual )?approval|permission|not allowed|denied/i;
140
+ const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
141
+ export class RunObserver {
142
+ cwd;
143
+ commands = new Map();
144
+ denied = [];
145
+ lastText = null;
146
+ resultText = null;
147
+ constructor(cwd) {
148
+ this.cwd = cwd;
149
+ }
150
+ observe(line) {
151
+ const trimmed = line.trim();
152
+ if (!trimmed.startsWith('{'))
153
+ return;
154
+ let msg;
155
+ try {
156
+ msg = JSON.parse(trimmed);
157
+ }
158
+ catch {
159
+ return;
160
+ }
161
+ if (msg.type === 'result' && typeof msg.result === 'string')
162
+ this.resultText = msg.result;
163
+ if (!Array.isArray(msg.message?.content))
164
+ return;
165
+ for (const block of msg.message.content) {
166
+ if (msg.type === 'assistant' && block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
167
+ this.lastText = block.text;
168
+ }
169
+ if (msg.type === 'assistant' && block.type === 'tool_use' && typeof block.name === 'string' && SHELL_TOOLS.has(block.name)) {
170
+ const command = block.input?.command;
171
+ if (typeof block.id === 'string' && typeof command === 'string')
172
+ this.commands.set(block.id, command);
173
+ }
174
+ if (msg.type === 'user' && block.type === 'tool_result' && block.is_error === true && typeof block.tool_use_id === 'string') {
175
+ const command = this.commands.get(block.tool_use_id);
176
+ const content = typeof block.content === 'string' ? block.content : JSON.stringify(block.content ?? '');
177
+ if (command !== undefined && DENIAL.test(content)) {
178
+ const shown = clip(hideWorkspace(command, this.cwd));
179
+ if (!this.denied.includes(shown) && this.denied.length < 20)
180
+ this.denied.push(shown);
181
+ }
182
+ }
183
+ }
184
+ }
185
+ summary() {
186
+ const text = this.resultText ?? this.lastText;
187
+ return {
188
+ lastMessage: text ? hideWorkspace(text, this.cwd).trim().slice(0, 2000) : null,
189
+ deniedCommands: [...this.denied],
190
+ };
191
+ }
192
+ }
@@ -21,9 +21,11 @@ import os from 'node:os';
21
21
  import path from 'node:path';
22
22
  import { createInterface } from 'node:readline/promises';
23
23
  import { writeBriefingNotes } from '../bridge/briefing-notes.js';
24
- import { buildMcpConfig } from '../bridge/claude-args.js';
24
+ import { ALLOWED_BASH_PREFIXES, buildMcpConfig } from '../bridge/claude-args.js';
25
+ import { prepareDependencies } from './setup.js';
25
26
  import { credentialsPath, readCredentials, updateAccessToken, writeCredentials, } from '../bridge/credentials.js';
26
27
  import { NomosApiError, NomosClient } from '../bridge/nomos-client.js';
28
+ import { ActivityReporter, activityFromStreamLine, RunObserver } from './activity.js';
27
29
  import { connect, DEFAULT_SERVER, serverCalls } from './connect.js';
28
30
  import { deviceLogin } from './device-login.js';
29
31
  import { cliVersion, mcpServerPath } from './paths.js';
@@ -79,13 +81,41 @@ async function handleTask(client, projectId, task) {
79
81
  writeFileSync(mcpConfigPath,
80
82
  // 작업공간 경로를 넘겨야 submit_artifact가 이 worktree의 태스크 브랜치를 push한다.
81
83
  buildMcpConfig({ serverPath: mcpServerPath(), workspaceDir: workspace.dir }));
84
+ // 룸: 실행 시작을 알리고, 도는 동안 도구 사용을 보낸다. 룸 보고가 실패해도 실행은 그대로 간다(옛 서버에는 이 API가 없다).
85
+ const runOpen = await client
86
+ .startRun(task.id)
87
+ .then(() => true)
88
+ .catch((err) => {
89
+ log(`룸에 실행 시작을 알리지 못했다(실행은 계속한다): ${err instanceof Error ? err.message : String(err)}`);
90
+ return false;
91
+ });
92
+ const reporter = runOpen ? new ActivityReporter(client, task.id, log) : null;
93
+ const observer = new RunObserver(workspace.dir);
94
+ // 의존성은 모델이 아니라 여기서 설치한다(설치 스크립트 끔). 실패해도 진행한다 — 프롬프트에 결과를 적는다.
95
+ const setup = await prepareDependencies(workspace.dir, log);
82
96
  const before = headSha(workspace.dir);
83
97
  const result = await runClaude({
84
98
  workspaceDir: workspace.dir,
85
- prompt: buildTaskPrompt(briefing, workspace.branch),
99
+ prompt: buildTaskPrompt(briefing, workspace.branch, { allowedCommands: ALLOWED_BASH_PREFIXES, setup: setup.steps }),
86
100
  mcpConfigPath,
101
+ pathPrepend: setup.venvBin,
102
+ onStdoutLine: (line) => {
103
+ observer.observe(line);
104
+ reporter?.push(activityFromStreamLine(line, workspace.dir));
105
+ },
87
106
  });
88
107
  const committed = headSha(workspace.dir) !== before;
108
+ if (reporter) {
109
+ await reporter.close();
110
+ // 제출했는지는 서버가 태스크 상태로 판단해 룸에 남긴다("끝났지만 제출하지 않았다"가 대표에게 보인다).
111
+ await client
112
+ .endRun(task.id, { outcome: result.outcome, committed, durationMs: result.durationMs, exitCode: result.exitCode, ...observer.summary() })
113
+ .then((r) => {
114
+ if (r.blocked)
115
+ log('제출 없이 끝나 태스크가 멈춤(BLOCKED)으로 바뀌었다 — 대표가 원인을 해결하고 재개하면 다시 가져간다');
116
+ })
117
+ .catch((err) => log(`룸에 실행 종료를 알리지 못했다: ${err instanceof Error ? err.message : String(err)}`));
118
+ }
89
119
  // 자동 재시도는 넣지 않는다. tasks.retry_count는 서버가 관리하는 값이고,
90
120
  // Executor가 멋대로 돌리면 M4(재작업률)가 오염된다.
91
121
  if (result.outcome === 'completed' && committed) {
@@ -1,4 +1,4 @@
1
- export function buildTaskPrompt(briefing, branch) {
1
+ export function buildTaskPrompt(briefing, branch, environment) {
2
2
  const { task, repo, spec } = briefing;
3
3
  const sections = [];
4
4
  sections.push([
@@ -35,6 +35,21 @@ export function buildTaskPrompt(briefing, branch) {
35
35
  '- 다음 사람이 알아야 할 결정이나 함정이 있으면 publish_note로 남긴다. 다른 사람도 따라야 할 결정(계약·형식)은 DECIDED로 남긴다 — 프로젝트 전체에 전달된다.',
36
36
  '- 다른 에이전트의 잘못을 노트에 적지 않는다. 그건 raise_dispute의 몫이다.',
37
37
  ].join('\n'));
38
+ if (environment) {
39
+ const installed = environment.setup.length === 0
40
+ ? '- 설치할 의존성 파일(package.json·requirements.txt 등)이 없었다.'
41
+ : environment.setup.map((s) => `- ${s.ok ? '설치됨' : '설치 실패'}: ${s.command}`).join('\n');
42
+ sections.push([
43
+ '# 작업 환경',
44
+ '- 쉘 명령은 Bash 도구로, 아래 접두사로 시작하는 것만 쓸 수 있다. 그 밖의 명령과 PowerShell은 승인할 사람이 없어 **자동으로 거부된다** — 바꿔 가며 다시 시도하지 않는다.',
45
+ ...environment.allowedCommands.map((c) => ` - ${c}`),
46
+ '- 명령은 한 번에 하나씩 쓴다. `&&`·`;`·`|`로 잇거나 리다이렉트하면 거부된다.',
47
+ '- 파일을 보거나 찾을 때는 쉘(ls·cat·find) 대신 Read·Glob·Grep 도구를 쓴다. 작업 폴더 밖은 볼 수 없다.',
48
+ '- 의존성은 NOMOS가 미리 설치해 두었다. 직접 설치(npm install·pip install)는 할 수 없다. 새 의존성이 필요하면 매니페스트(package.json·requirements.txt)만 고치고 GOTCHA 노트에 남긴다.',
49
+ installed,
50
+ '- 환경이 막혀 태스크를 끝낼 수 없으면 지어내거나 시험 없이 통과했다고 하지 않는다. 할 수 있는 만큼 하고, 막힌 이유를 마지막 메시지에 분명히 적고 끝낸다 — 그 문장이 대표에게 그대로 전달되고 대표가 해결한 뒤 다시 시작된다.',
51
+ ].join('\n'));
52
+ }
38
53
  sections.push(`# 지금 할 일\n태스크 ${task.id}를 claim_task로 받고 위 규칙에 따라 진행하라.`);
39
54
  return sections.join('\n\n');
40
55
  }
@@ -15,6 +15,11 @@ export function resolveClaudeCommand(args) {
15
15
  }
16
16
  return { command: 'claude', commandArgs: args };
17
17
  }
18
+ // Windows는 환경변수 이름이 대소문자를 가리지 않아 Path로 들어 있는 경우가 많다 — 있는 이름을 그대로 쓴다.
19
+ export function withPathPrepended(env, dir) {
20
+ const key = Object.keys(env).find((k) => k.toUpperCase() === 'PATH') ?? 'PATH';
21
+ return { ...env, [key]: env[key] ? `${dir}${path.delimiter}${env[key]}` : dir };
22
+ }
18
23
  export async function runClaude(input) {
19
24
  const args = buildClaudeArgs({
20
25
  prompt: input.prompt,
@@ -30,10 +35,30 @@ export async function runClaude(input) {
30
35
  const child = spawn(command, commandArgs, {
31
36
  cwd: input.workspaceDir,
32
37
  stdio: ['ignore', 'pipe', 'pipe'],
38
+ ...(input.pathPrepend ? { env: withPathPrepended(process.env, input.pathPrepend) } : {}),
33
39
  });
34
40
  const chunks = [];
35
41
  const collect = (c) => chunks.push(c.toString());
36
- child.stdout.on('data', collect);
42
+ let partial = '';
43
+ const emitLines = (c) => {
44
+ if (!input.onStdoutLine)
45
+ return;
46
+ partial += c.toString();
47
+ const lines = partial.split('\n');
48
+ partial = lines.pop() ?? '';
49
+ for (const line of lines) {
50
+ try {
51
+ input.onStdoutLine(line);
52
+ }
53
+ catch {
54
+ // 룸 표시 실패가 실행을 멈추면 안 된다.
55
+ }
56
+ }
57
+ };
58
+ child.stdout.on('data', (c) => {
59
+ collect(c);
60
+ emitLines(c);
61
+ });
37
62
  child.stderr.on('data', collect);
38
63
  let timedOut = false;
39
64
  const timer = setTimeout(() => {
@@ -0,0 +1,71 @@
1
+ import { exec as execShell, execFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ // 의존성 설치는 모델이 아니라 Executor가 Claude를 띄우기 전에 한다(운영 테스트 4-4).
6
+ // 모델에게 `npm install`·`pip install`을 열면 설치 스크립트(postinstall·setup.py)가 임의 코드를 돌려 Edit 금지 규칙(.env·contracts)을 우회할 수 있다.
7
+ // 그래서 여기서도 스크립트를 끈다: npm 계열은 --ignore-scripts, pip는 바이너리 휠만(--only-binary :all: — 소스 빌드는 코드 실행이다).
8
+ // 설치가 실패해도 태스크는 진행한다 — 모델은 설치 없이 할 수 있는 만큼 하고, 못 한 것은 멈춤 사유(거부 명령)로 드러난다.
9
+ const run = promisify(execFile);
10
+ const runShell = promisify(execShell);
11
+ const INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
12
+ // 작업공간에 만든 파이썬 가상환경 이름. info/exclude에 들어 있어 커밋에 섞이지 않는다(workspace.ts).
13
+ export const VENV_DIR = '.nomos-venv';
14
+ const NODE_MANAGERS = new Set(['npm', 'pnpm', 'yarn']);
15
+ const defaultExec = async (cmd, args, cwd) => {
16
+ // Windows에서 npm·pnpm·yarn은 .cmd라 셸 없이 못 띄운다. 그 셋은 명령 이름과 인자가 전부 이 파일의 고정 문자열이라 셸에 넘겨도 주입될 값이 없다.
17
+ // 파이썬은 실행 파일 경로(공백이 들어갈 수 있다)를 직접 띄우므로 셸을 쓰지 않는다.
18
+ const options = { cwd, timeout: INSTALL_TIMEOUT_MS, windowsHide: true, maxBuffer: 16 * 1024 * 1024 };
19
+ if (process.platform === 'win32' && NODE_MANAGERS.has(cmd)) {
20
+ await runShell([cmd, ...args].join(' '), options);
21
+ return;
22
+ }
23
+ await run(cmd, args, options);
24
+ };
25
+ function pythonBin(dir) {
26
+ return process.platform === 'win32' ? path.join(dir, VENV_DIR, 'Scripts') : path.join(dir, VENV_DIR, 'bin');
27
+ }
28
+ // 어떤 설치를 할지(순수 함수). 잠금 파일이 있으면 그대로 재현(ci/frozen), 없으면 package.json만으로 설치하되 잠금 파일을 만들지 않는다
29
+ // (만들면 작업공간에 새 파일이 생겨 모델이 커밋에 섞는다).
30
+ export function planSetup(has) {
31
+ const steps = [];
32
+ if (has('pnpm-lock.yaml'))
33
+ steps.push({ manager: 'pnpm', cmd: 'pnpm', args: ['install', '--frozen-lockfile', '--ignore-scripts'] });
34
+ else if (has('yarn.lock'))
35
+ steps.push({ manager: 'yarn', cmd: 'yarn', args: ['install', '--frozen-lockfile', '--ignore-scripts'] });
36
+ else if (has('package-lock.json'))
37
+ steps.push({ manager: 'npm', cmd: 'npm', args: ['ci', '--ignore-scripts', '--no-audit', '--no-fund'] });
38
+ else if (has('package.json')) {
39
+ steps.push({ manager: 'npm', cmd: 'npm', args: ['install', '--ignore-scripts', '--no-package-lock', '--no-audit', '--no-fund'] });
40
+ }
41
+ if (has('requirements.txt')) {
42
+ steps.push({ manager: 'python', cmd: process.platform === 'win32' ? 'python' : 'python3', args: ['-m', 'venv', VENV_DIR] });
43
+ steps.push({ manager: 'pip', cmd: 'venv-python', args: ['-m', 'pip', 'install', '--only-binary', ':all:', '--disable-pip-version-check', '-r', 'requirements.txt'] });
44
+ }
45
+ return steps;
46
+ }
47
+ export async function prepareDependencies(dir, log, exec = defaultExec) {
48
+ const plan = planSetup((file) => existsSync(path.join(dir, file)));
49
+ const steps = [];
50
+ let venvBin = null;
51
+ for (const step of plan) {
52
+ const cmd = step.cmd === 'venv-python' ? path.join(pythonBin(dir), 'python') : step.cmd;
53
+ // venv를 못 만들었으면 pip도 건너뛴다.
54
+ if (step.cmd === 'venv-python' && venvBin === null)
55
+ continue;
56
+ const command = `${step.cmd === 'venv-python' ? 'python' : step.cmd} ${step.args.join(' ')}`;
57
+ try {
58
+ await exec(cmd, step.args, dir);
59
+ steps.push({ manager: step.manager, command, ok: true });
60
+ if (step.manager === 'python')
61
+ venvBin = pythonBin(dir);
62
+ log(`의존성 설치: ${command} — OK`);
63
+ }
64
+ catch (err) {
65
+ const detail = err instanceof Error ? err.message.split('\n')[0].slice(0, 200) : String(err);
66
+ steps.push({ manager: step.manager, command, ok: false, detail });
67
+ log(`의존성 설치 실패(태스크는 진행한다): ${command} — ${detail}`);
68
+ }
69
+ }
70
+ return { steps, venvBin };
71
+ }
@@ -68,7 +68,16 @@ export function prepareWorkspace(input) {
68
68
  }
69
69
  // Executor가 작업공간에 쓰는 파일(MCP 설정·실행 로그·브리핑 노트·정책 표시·settings.json)이 모델의 `git add`에 섞이지 않게 한다.
70
70
  // 커밋에 섞이면 신고 경로와 실제 diff가 어긋나 V3가 FAIL을 낸다. info/exclude는 레포 공통이라 모든 worktree에 걸린다.
71
- export const NOMOS_LOCAL_FILES = ['/.nomos-mcp.json', '/.nomos-run.log', '/.nomos-briefing.json', '/.claude/.nomos-policy.json', '/.claude/settings.json'];
71
+ // node_modules·.nomos-venv는 Executor가 미리 설치한 의존성(setup.ts) — 레포에 .gitignore가 없어도 커밋에 섞이지 않게.
72
+ export const NOMOS_LOCAL_FILES = [
73
+ '/.nomos-mcp.json',
74
+ '/.nomos-run.log',
75
+ '/.nomos-briefing.json',
76
+ '/.claude/.nomos-policy.json',
77
+ '/.claude/settings.json',
78
+ '/node_modules/',
79
+ '/.nomos-venv/',
80
+ ];
72
81
  export function excludeNomosFiles(repoPath) {
73
82
  try {
74
83
  const commonDir = path.resolve(repoPath, git(repoPath, ['rev-parse', '--git-common-dir']));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capstone-nomos/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "NOMOS 에이전트 CLI — 노트북의 Claude Code를 NOMOS 서버에 연결하고 배정된 태스크를 실행한다",
5
5
  "type": "module",
6
6
  "bin": {