@capstone-nomos/cli 0.1.1 → 0.1.3

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,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
- const artifact = await client.submitArtifact(task_id, { commitSha: commit_sha, changedPaths: changed_paths });
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', {
@@ -0,0 +1,18 @@
1
+ import { rmSync } from 'node:fs';
2
+ // 뒷정리용 삭제. Windows에서는 자식 프로세스(claude·vitest)가 끝난 직후에도 폴더가 잠깐 잠겨 있어 EBUSY가 난다.
3
+ // Node의 재시도 옵션으로 잠시 기다렸다 다시 지우고, 그래도 못 지우면 경고만 남기고 넘어간다 —
4
+ // finally에서 던지면 이미 받아 둔 결과(PM 초안·검증 보고)를 오류가 덮는다(pm-worker에서 실제로 그랬다).
5
+ export function removeQuietly(dir, warn = defaultWarn) {
6
+ try {
7
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
8
+ return true;
9
+ }
10
+ catch (err) {
11
+ warn(`임시 폴더를 지우지 못했습니다(남겨 둠): ${dir} — ${err.message}`);
12
+ return false;
13
+ }
14
+ }
15
+ // stdout은 MCP 서버에서 JSON-RPC 전용이라 경고는 stderr로 쓴다.
16
+ function defaultWarn(line) {
17
+ process.stderr.write(`[nomos] 경고: ${line}\n`);
18
+ }
@@ -20,6 +20,7 @@ import { writeFileSync } from 'node:fs';
20
20
  import os from 'node:os';
21
21
  import path from 'node:path';
22
22
  import { createInterface } from 'node:readline/promises';
23
+ import { writeBriefingNotes } from '../bridge/briefing-notes.js';
23
24
  import { buildMcpConfig } from '../bridge/claude-args.js';
24
25
  import { credentialsPath, readCredentials, updateAccessToken, writeCredentials, } from '../bridge/credentials.js';
25
26
  import { NomosApiError, NomosClient } from '../bridge/nomos-client.js';
@@ -68,6 +69,8 @@ async function handleTask(client, projectId, task) {
68
69
  policyHash: briefing.policyHash,
69
70
  });
70
71
  log(`작업공간 ${workspace.dir} (브랜치 ${workspace.branch})`);
72
+ // 프롬프트에 넣는 노트 = 제출 때 "받은 노트"로 함께 보낼 노트. MCP 서버(submit_artifact)가 읽는다.
73
+ writeBriefingNotes(workspace.dir, task.id, (briefing.notes ?? []).map((n) => n.id));
71
74
  if (workspace.createdBranch) {
72
75
  await client.reportBranch(task.id, workspace.branch);
73
76
  }
@@ -1,8 +1,9 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { mkdtempSync, writeFileSync } from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { resolveClaudeCommand } from './runner.js';
6
+ import { removeQuietly } from './cleanup.js';
6
7
  // 지침·프롬프트는 길어서(이전 초안이 붙는 수정 요청) 명령줄로 넘기지 않는다 — Windows 명령줄은 32k자에서 잘린다.
7
8
  // 지침은 파일, 프롬프트는 stdin. 도구는 전부 끄고(--tools ""), 사용자 MCP·세션 기록도 쓰지 않는다.
8
9
  export function buildPmClaudeArgs(job, systemPromptFile) {
@@ -80,7 +81,7 @@ export async function runPmJob(job, run = spawnClaude) {
80
81
  return toJobResult(out, job.request.model);
81
82
  }
82
83
  finally {
83
- rmSync(dir, { recursive: true, force: true });
84
+ removeQuietly(dir);
84
85
  }
85
86
  }
86
87
  // 작업 하나를 가져와 처리한다. 처리했으면 true.
@@ -12,6 +12,14 @@ export function buildTaskPrompt(briefing, branch) {
12
12
  if (spec) {
13
13
  sections.push(`# 명세 ${spec.featureKey} — ${spec.title}\n${spec.content}`);
14
14
  }
15
+ // 직전 제출이 반려됐다 — 같은 태스크 브랜치에 이전 작업이 남아 있으니 이어서 고친다.
16
+ if (briefing.lastRejection) {
17
+ sections.push([
18
+ '# 직전 제출이 대표에게 반려됐다',
19
+ `사유: ${briefing.lastRejection.reason}`,
20
+ `반려된 커밋: ${briefing.lastRejection.commitSha ?? '(알 수 없음)'} — 이 브랜치에 이전 작업이 남아 있다. 처음부터 다시 만들지 말고 사유에 맞게 고친 뒤 다시 제출한다.`,
21
+ ].join('\n'));
22
+ }
15
23
  if (briefing.notesBlock) {
16
24
  sections.push(briefing.notesBlock);
17
25
  }
@@ -22,7 +30,9 @@ export function buildTaskPrompt(briefing, branch) {
22
30
  '- 위 목록 밖의 경로는 수정하지 않는다. 비밀 파일(.env, *.pem, *.key, secrets/)은 절대 건드리지 않는다.',
23
31
  '- 작업이 끝나면 커밋하고, submit_artifact에 **실제 커밋 sha와 실제로 바꾼 모든 경로**를 넣는다.',
24
32
  ' 바꾸지 않은 경로를 적거나 sha를 지어내지 않는다 — 서버가 제출 시점에 다시 검증한다.',
25
- '- 다음 사람이 알아야 할 결정이나 함정이 있으면 publish_note로 남긴다.',
33
+ '- 제출이 NOTES_UNACKNOWLEDGED로 반려되면, 작업 중에 새로 올라온 인계 노트가 함께 온다. 읽고, 작업에 영향이 있으면 고쳐서 커밋한 뒤',
34
+ ' submit_artifact를 다시 부르며 그 노트 id를 acknowledged_note_ids에 넣는다. 위 [인계 노트]의 노트는 따로 넣지 않아도 된다.',
35
+ '- 다음 사람이 알아야 할 결정이나 함정이 있으면 publish_note로 남긴다. 다른 사람도 따라야 할 결정(계약·형식)은 DECIDED로 남긴다 — 프로젝트 전체에 전달된다.',
26
36
  '- 다른 에이전트의 잘못을 노트에 적지 않는다. 그건 raise_dispute의 몫이다.',
27
37
  ].join('\n'));
28
38
  sections.push(`# 지금 할 일\n태스크 ${task.id}를 claim_task로 받고 위 규칙에 따라 진행하라.`);
@@ -5,9 +5,10 @@
5
5
  // 여기서 판정하는 것은 "명령이 0으로 끝났는가"뿐이다. 출력을 읽고 해석하지 않는다 —
6
6
  // 해석이 들어가는 순간 브릿지마다 결과가 달라지고, 그건 결정적 검증이 아니다.
7
7
  import { execFile } from 'node:child_process';
8
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { promisify } from 'node:util';
11
+ import { removeQuietly } from './cleanup.js';
11
12
  const run = promisify(execFile);
12
13
  // 린트와 테스트는 오래 걸릴 수 있지만 무한정은 아니다. 초과하면 SKIPPED로 남긴다 —
13
14
  // FAIL로 적으면 코드가 아니라 기계가 느려서 재시도 횟수를 잃는다.
@@ -80,7 +81,7 @@ export async function runSpecTests(workspaceDir, specTests) {
80
81
  }
81
82
  const dir = path.join(workspaceDir, SPEC_TEST_DIR);
82
83
  // 시험지는 작업 결과가 아니다. 커밋에 섞이면 V3에서 신고 누락으로 잡힌다.
83
- rmSync(dir, { recursive: true, force: true });
84
+ removeQuietly(dir);
84
85
  mkdirSync(dir, { recursive: true });
85
86
  try {
86
87
  const files = specTests.map((t) => {
@@ -101,6 +102,6 @@ export async function runSpecTests(workspaceDir, specTests) {
101
102
  }
102
103
  finally {
103
104
  // 남겨두면 다음 실행에서 모델이 시험지를 읽고 거기에 맞춰 고칠 수 있다.
104
- rmSync(dir, { recursive: true, force: true });
105
+ removeQuietly(dir);
105
106
  }
106
107
  }
@@ -25,9 +25,12 @@ function git(repoPath, args) {
25
25
  return execFileSync('git', args, { cwd: repoPath, encoding: 'utf-8' }).trim();
26
26
  }
27
27
  function branchExists(repoPath, branch) {
28
+ return refExists(repoPath, `refs/heads/${branch}`);
29
+ }
30
+ function refExists(repoPath, ref) {
28
31
  try {
29
32
  // stdio를 삼킨다 — 없는 브랜치면 git이 stderr에 fatal을 찍어 로그를 더럽힌다.
30
- execFileSync('git', ['rev-parse', '--verify', `refs/heads/${branch}`], {
33
+ execFileSync('git', ['rev-parse', '--verify', '--quiet', ref], {
31
34
  cwd: repoPath,
32
35
  stdio: 'ignore',
33
36
  });
@@ -43,11 +46,17 @@ export function prepareWorkspace(input) {
43
46
  const dir = workspaceDir(input.projectId, input.taskId);
44
47
  const branch = input.branchName ?? `task/${input.taskId}`;
45
48
  const existed = branchExists(input.repoPath, branch);
49
+ excludeNomosFiles(input.repoPath);
46
50
  if (!existsSync(dir)) {
47
51
  mkdirSync(path.dirname(dir), { recursive: true });
52
+ // 재시도(검증 실패·반려)는 같은 태스크 브랜치에서 **이어서** 고친다. 이 노트북에 브랜치가 없어도(다른 노트북에서 했거나 새로 받은 클론)
53
+ // origin에 있으면 거기서 이어 간다 — 기본 브랜치에서 새로 따면 이전 작업이 안 보이고, 제출 때 push가 갈라진 이력으로 거부된다.
54
+ const remote = `refs/remotes/origin/${branch}`;
48
55
  const args = existed
49
56
  ? ['worktree', 'add', dir, branch]
50
- : ['worktree', 'add', '-b', branch, dir, input.baseBranch];
57
+ : refExists(input.repoPath, remote)
58
+ ? ['worktree', 'add', '-b', branch, dir, `origin/${branch}`]
59
+ : ['worktree', 'add', '-b', branch, dir, input.baseBranch];
51
60
  git(input.repoPath, args);
52
61
  }
53
62
  const claudeDir = path.join(dir, '.claude');
@@ -57,6 +66,26 @@ export function prepareWorkspace(input) {
57
66
  writeFileSync(path.join(claudeDir, '.nomos-policy.json'), `${JSON.stringify({ policyHash: input.policyHash, taskId: input.taskId, generatedAt: new Date().toISOString() }, null, 2)}\n`);
58
67
  return { dir, branch, createdBranch: !existed, settingsPath };
59
68
  }
69
+ // Executor가 작업공간에 쓰는 파일(MCP 설정·실행 로그·브리핑 노트·정책 표시·settings.json)이 모델의 `git add`에 섞이지 않게 한다.
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'];
72
+ export function excludeNomosFiles(repoPath) {
73
+ try {
74
+ const commonDir = path.resolve(repoPath, git(repoPath, ['rev-parse', '--git-common-dir']));
75
+ const file = path.join(commonDir, 'info', 'exclude');
76
+ mkdirSync(path.dirname(file), { recursive: true });
77
+ const current = existsSync(file) ? readFileSync(file, 'utf-8') : '';
78
+ const lines = new Set(current.split(/\r?\n/));
79
+ const missing = NOMOS_LOCAL_FILES.filter((p) => !lines.has(p));
80
+ if (missing.length === 0)
81
+ return;
82
+ const prefix = current.length === 0 || current.endsWith('\n') ? '' : '\n';
83
+ writeFileSync(file, `${current}${prefix}# NOMOS Executor가 작업공간에 쓰는 파일\n${missing.join('\n')}\n`);
84
+ }
85
+ catch {
86
+ // 못 써도 작업은 진행한다 — 섞이면 V3가 잡는다.
87
+ }
88
+ }
60
89
  // executor clean — worktree를 정리한다. 남겨두는 게 기본이라 정리는 명시적 명령으로만 한다.
61
90
  export function cleanWorkspaces() {
62
91
  const pruned = [];
@@ -84,7 +113,8 @@ export function cleanWorkspaces() {
84
113
  const root = workspacesRoot();
85
114
  if (!existsSync(root))
86
115
  return { pruned, removed: null };
87
- rmSync(root, { recursive: true, force: true });
116
+ // 사람이 직접 부르는 명령이라 못 지우면 오류를 그대로 보인다(삼키면 지웠다고 거짓 보고한다). 잠금만 재시도로 넘긴다.
117
+ rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
88
118
  return { pruned, removed: root };
89
119
  }
90
120
  export function headSha(dir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capstone-nomos/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "NOMOS 에이전트 CLI — 노트북의 Claude Code를 NOMOS 서버에 연결하고 배정된 태스크를 실행한다",
5
5
  "type": "module",
6
6
  "bin": {