@capstone-nomos/cli 0.1.1 → 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.
@@ -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', {
@@ -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
  }
@@ -22,7 +22,9 @@ export function buildTaskPrompt(briefing, branch) {
22
22
  '- 위 목록 밖의 경로는 수정하지 않는다. 비밀 파일(.env, *.pem, *.key, secrets/)은 절대 건드리지 않는다.',
23
23
  '- 작업이 끝나면 커밋하고, submit_artifact에 **실제 커밋 sha와 실제로 바꾼 모든 경로**를 넣는다.',
24
24
  ' 바꾸지 않은 경로를 적거나 sha를 지어내지 않는다 — 서버가 제출 시점에 다시 검증한다.',
25
- '- 다음 사람이 알아야 할 결정이나 함정이 있으면 publish_note로 남긴다.',
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로 받고 위 규칙에 따라 진행하라.`);
@@ -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 = [];
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.2",
4
4
  "description": "NOMOS 에이전트 CLI — 노트북의 Claude Code를 NOMOS 서버에 연결하고 배정된 태스크를 실행한다",
5
5
  "type": "module",
6
6
  "bin": {