@capstone-nomos/cli 0.1.19 → 0.1.20

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.
@@ -1,4 +1,4 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  // NOMOS의 로컬 상태(자격 증명·작업공간·레포)를 두는 곳. 기본 ~/.nomos.
@@ -26,7 +26,18 @@ export function readCredentials() {
26
26
  export function writeCredentials(credentials) {
27
27
  mkdirSync(credentialsDir(), { recursive: true, mode: 0o700 });
28
28
  const file = credentialsPath();
29
- writeFileSync(file, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
29
+ // 임시 파일에 다 쓴 뒤 이름을 바꾼다 — 읽는 쪽이 반쯤 쓰인 파일을 보지 않게(토큰 재발급마다 쓴다).
30
+ // 같은 NOMOS_HOME을 두 프로세스가 쓰다 "Unexpected end of JSON input"으로 죽은 일이 있었다(운영 실험) — 공유 자체는 executor/home-lock.ts가 막는다.
31
+ const tmp = `${file}.${process.pid}.tmp`;
32
+ writeFileSync(tmp, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
33
+ try {
34
+ renameSync(tmp, file);
35
+ }
36
+ catch {
37
+ // Windows에서 다른 프로세스가 그 순간 파일을 열고 있으면 바꾸기가 거부될 수 있다 — 직접 쓴다.
38
+ writeFileSync(file, readFileSync(tmp), { mode: 0o600 });
39
+ rmSync(tmp, { force: true });
40
+ }
30
41
  try {
31
42
  chmodSync(file, 0o600);
32
43
  }
@@ -33,6 +33,7 @@ import { deviceLogin } from './device-login.js';
33
33
  import { cliVersion, mcpServerPath } from './paths.js';
34
34
  import { checkTools } from './preflight.js';
35
35
  import { handleNextPmJob } from './pm-worker.js';
36
+ import { acquireHomeLock } from './home-lock.js';
36
37
  import { buildTaskPrompt } from './prompt.js';
37
38
  import { DEFAULT_IDLE_MS, runClaude } from './runner.js';
38
39
  import { streamTaskSource } from './stream-source.js';
@@ -41,7 +42,7 @@ import { runLint, runSpecTests } from './verify.js';
41
42
  import { ensureRepo } from './repo-checkout.js';
42
43
  import { runConsult } from './consult.js';
43
44
  import { runContractBatch, runContractDraft } from './contract.js';
44
- import { cleanWorkspaces, headSha, prepareWorkspace } from './workspace.js';
45
+ import { cleanWorkspaces, fetchCommit, headSha, mergePredecessors, prepareWorkspace } from './workspace.js';
45
46
  // 푸시가 오지 않아도 이 간격으로 한 번씩 다시 본다(끊겼을 때는 이 간격으로 폴링한다).
46
47
  const POLL_INTERVAL_MS = 10_000;
47
48
  function log(line) {
@@ -209,17 +210,35 @@ async function handleTask(client, projectId, task) {
209
210
  const briefing = (await client.getBriefing(task.id));
210
211
  // cloneUrl이 없는 옛 서버(브리핑에 필드 자체가 없음)도 github.com/{fullName}으로 받는다.
211
212
  const checkout = ensureRepo({ fullName: briefing.repo.fullName, cloneUrl: briefing.repo.cloneUrl ?? null }, { log });
213
+ // 서버가 만든 시작 기준점이 있으면 거기서 분기한다(선행 코드가 이미 합쳐져 있다). 받지 못하면 기본 브랜치에서 따고 선행을 직접 합친다.
214
+ const serverBase = briefing.base && fetchCommit(checkout.path, briefing.base.branchName, briefing.base.commitSha) ? briefing.base : null;
215
+ if (briefing.base && !serverBase)
216
+ log(`서버 기준점 ${briefing.base.branchName}을 받지 못했다 — 선행을 직접 합친다`);
212
217
  const workspace = prepareWorkspace({
213
218
  repoPath: checkout.path,
214
219
  projectId,
215
220
  taskId: task.id,
216
221
  branchName: briefing.task.branchName,
217
- // CLI가 관리하는 클론은 방금 fetch한 origin/<기본 브랜치>에서 분기한다.
218
- baseBranch: checkout.baseRef(briefing.repo.defaultBranch),
222
+ // CLI가 관리하는 클론은 방금 fetch한 origin/<기본 브랜치>에서 분기한다. 서버 기준점이 있으면 그 커밋에서.
223
+ baseBranch: serverBase?.commitSha ?? checkout.baseRef(briefing.repo.defaultBranch),
219
224
  settings: briefing.claudeSettings,
220
225
  policyHash: briefing.policyHash,
221
226
  });
222
227
  log(`작업공간 ${workspace.dir} (브랜치 ${workspace.branch})`);
228
+ // 선행 작업 위에서 시작한다 — 새로 딴 브랜치일 때만(이어 가는 브랜치에는 이미 들어 있다).
229
+ let predecessorMerge;
230
+ if (workspace.startedFresh && serverBase && (briefing.predecessors?.length ?? 0) > 0) {
231
+ // 서버가 이미 합쳤다 — 프롬프트에 "선행 코드가 들어 있다"만 알린다.
232
+ predecessorMerge = { merged: briefing.predecessors, failed: [] };
233
+ log(` 서버 기준점에서 시작: ${serverBase.branchName} (${serverBase.commitSha.slice(0, 12)}) — 선행 ${briefing.predecessors.length}개 포함`);
234
+ }
235
+ else if (workspace.startedFresh && (briefing.predecessors?.length ?? 0) > 0) {
236
+ predecessorMerge = mergePredecessors(workspace.dir, briefing.predecessors);
237
+ for (const p of predecessorMerge.merged)
238
+ log(` 선행 합침: ${p.title} (${p.commitSha.slice(0, 12)})`);
239
+ for (const f of predecessorMerge.failed)
240
+ log(` 선행 합치지 못함: ${f.predecessor.title} — ${f.reason}`);
241
+ }
223
242
  activeWorkspaces.set(task.id, { dir: workspace.dir, repo: briefing.repo.fullName });
224
243
  // 프롬프트에 넣는 노트·결정 = 제출 때 "받은 것"으로 함께 보낼 노트·결정. MCP 서버(submit_artifact)가 읽는다.
225
244
  writeBriefingNotes(workspace.dir, task.id, (briefing.notes ?? []).map((n) => n.id), (briefing.decisions ?? []).map((d) => d.id));
@@ -250,7 +269,7 @@ async function handleTask(client, projectId, task) {
250
269
  const before = headSha(workspace.dir);
251
270
  const result = await runClaude({
252
271
  workspaceDir: workspace.dir,
253
- prompt: buildTaskPrompt(briefing, workspace.branch, { allowedCommands: ALLOWED_BASH_PREFIXES, setup: setup.steps }),
272
+ prompt: buildTaskPrompt({ ...briefing, predecessorMerge }, workspace.branch, { allowedCommands: ALLOWED_BASH_PREFIXES, setup: setup.steps }),
254
273
  mcpConfigPath,
255
274
  pathPrepend: setup.venvBin,
256
275
  // 질문 중계가 꺼진 프로젝트(또는 옛 서버)면 권한 도구를 붙이지 않는다 — AskUserQuestion이 없어진다.
@@ -337,6 +356,7 @@ async function reportBridgeStages(client, taskId, workspaceDir, specTests) {
337
356
  }
338
357
  }
339
358
  async function once() {
359
+ acquireHomeLock('once');
340
360
  const client = createClient();
341
361
  const self = await client.describeSelf();
342
362
  const source = pollingTaskSource(client);
@@ -349,6 +369,7 @@ async function once() {
349
369
  }
350
370
  // 멈춘 이유를 돌려준다 — connect는 배정이 풀린 경우(해제·프로젝트 중단) 다시 배정을 기다린다.
351
371
  async function start() {
372
+ acquireHomeLock('start');
352
373
  const client = createClient();
353
374
  const self = await client.describeSelf();
354
375
  // 서버 푸시(웹소켓)로 받는다. 끊겨 있는 동안은 같은 목록을 HTTP로 읽는다.
@@ -524,6 +545,8 @@ async function loginWithCode(baseUrl, agentName, code) {
524
545
  log(`${credentialsPath()}에 저장했다. 내 계정이 아니면 바로 Ctrl+C로 멈추고 웹에서 이 에이전트를 연결 해제하라.`);
525
546
  }
526
547
  async function login(argv) {
548
+ // 돌고 있는 에이전트의 자격 증명을 덮어쓰지 않게 — 같은 폴더를 쓰는 프로세스가 있으면 멈춘다.
549
+ acquireHomeLock('login');
527
550
  const args = parseArgs(argv);
528
551
  const baseUrl = normalizeBaseUrl(args.positional ?? args.server);
529
552
  const previous = readCredentials();
@@ -557,6 +580,8 @@ async function connectCommand(argv) {
557
580
  log('상태: nomos status · 끄기: nomos stop · 진행 상황은 웹의 룸에서 본다');
558
581
  return;
559
582
  }
583
+ // 한 NOMOS_HOME = 한 에이전트(home-lock.ts). 백그라운드를 띄우는 부모는 바로 끝나므로 잡지 않고, 실제로 도는 자식이 여기서 잡는다.
584
+ acquireHomeLock('connect');
560
585
  // 배정이 풀리면(해제·프로젝트 중단) 창을 닫지 않고 다시 배정을 기다린다. 연결 해제면 멈춘다.
561
586
  // 1회용 코드는 처음 한 번만 쓴다 — 그 뒤로는 그 코드로 받은 자격 증명이 저장돼 있다.
562
587
  let code = args.code?.trim() || undefined;
@@ -627,6 +652,7 @@ async function refresh() {
627
652
  }
628
653
  const PM_POLL_INTERVAL_MS = 3_000;
629
654
  async function pmWorker() {
655
+ acquireHomeLock('pm-worker');
630
656
  const client = createClient();
631
657
  log('pm-worker 시작 — 대표가 PM에 계획을 요청하면 여기서 claude가 돈다 (Ctrl+C로 종료)');
632
658
  for (;;) {
@@ -0,0 +1,39 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { nomosHome } from '../bridge/credentials.js';
4
+ export function homeLockPath() {
5
+ return path.join(nomosHome(), 'agent.lock');
6
+ }
7
+ function alive(pid) {
8
+ try {
9
+ process.kill(pid, 0);
10
+ return true;
11
+ }
12
+ catch (err) {
13
+ // 권한이 없을 뿐 살아 있다.
14
+ return err.code === 'EPERM';
15
+ }
16
+ }
17
+ export function readHomeLock() {
18
+ try {
19
+ return JSON.parse(readFileSync(homeLockPath(), 'utf-8'));
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ // 잡지 못하면 이유를 담아 던진다. 끝날 때 자동으로 놓는다.
26
+ export function acquireHomeLock(command) {
27
+ const file = homeLockPath();
28
+ const held = existsSync(file) ? readHomeLock() : null;
29
+ if (held && held.pid !== process.pid && alive(held.pid)) {
30
+ throw new Error(`이 폴더(${nomosHome()})는 다른 nomos 프로세스(pid ${held.pid}, ${held.command}, ${held.startedAt}부터)가 쓰고 있다. ` +
31
+ '한 노트북에서 에이전트를 여럿 돌리려면 창마다 NOMOS_HOME을 다르게 지정한다 — cmd: set NOMOS_HOME=%USERPROFILE%\.nomos-fe / PowerShell: $env:NOMOS_HOME="$HOME\.nomos-fe"');
32
+ }
33
+ mkdirSync(path.dirname(file), { recursive: true });
34
+ writeFileSync(file, `${JSON.stringify({ pid: process.pid, command, startedAt: new Date().toISOString() })}\n`);
35
+ process.once('exit', () => {
36
+ if (readHomeLock()?.pid === process.pid)
37
+ rmSync(file, { force: true });
38
+ });
39
+ }
@@ -53,6 +53,24 @@ export function buildTaskPrompt(briefing, branch, environment) {
53
53
  ].join('\n'));
54
54
  }
55
55
  // 사람(그 역할 담당·대표)이 룸에서 보낸 메시지 — 이번 실행이 시작되기 전에 온 것만 들어온다(실행 중에는 끼워 넣지 않는다).
56
+ // 선행 작업 위에서 시작했다 — 선행이 만든 코드가 이미 이 브랜치에 있다. 못 합친 선행은 다시 만들지 말고 멈추게 한다
57
+ // (남의 작업을 지어내면 V3·리뷰에서 엉뚱한 diff가 된다 — 운영 실험의 BE도 그래서 멈췄다).
58
+ const pm = briefing.predecessorMerge;
59
+ if (pm && (pm.merged.length > 0 || pm.failed.length > 0)) {
60
+ sections.push([
61
+ '# 선행 작업 위에서 시작한다',
62
+ ...(pm.merged.length > 0
63
+ ? ['이 브랜치에 선행 태스크의 코드가 이미 합쳐져 있다 — 그 위에 이어서 구현한다(다시 만들지 않는다):', ...pm.merged.map((m) => `- ${m.title} (${m.commitSha.slice(0, 12)})`)]
64
+ : []),
65
+ ...(pm.failed.length > 0
66
+ ? [
67
+ '합치지 못한 선행 태스크가 있다 — 그 코드는 이 브랜치에 없다:',
68
+ ...pm.failed.map((f) => `- ${f.predecessor.title}: ${f.reason}`),
69
+ '그 코드가 꼭 필요하면 직접 다시 만들지 말고, 커밋하지 말고 멈춘 뒤 마지막 메시지에 무엇이 없어서 막혔는지 적는다.',
70
+ ]
71
+ : []),
72
+ ].join('\n'));
73
+ }
56
74
  if (briefing.humanMessages && briefing.humanMessages.length > 0) {
57
75
  sections.push([
58
76
  '# 담당자가 보낸 메시지 (팀의 사람이 이 역할 에이전트에게 남긴 지시 — 오래된 것부터)',
@@ -46,6 +46,7 @@ export function prepareWorkspace(input) {
46
46
  const dir = workspaceDir(input.projectId, input.taskId);
47
47
  const branch = input.branchName ?? `task/${input.taskId}`;
48
48
  const existed = branchExists(input.repoPath, branch);
49
+ let startedFresh = false;
49
50
  excludeNomosFiles(input.repoPath);
50
51
  if (!existsSync(dir)) {
51
52
  mkdirSync(path.dirname(dir), { recursive: true });
@@ -57,6 +58,7 @@ export function prepareWorkspace(input) {
57
58
  : refExists(input.repoPath, remote)
58
59
  ? ['worktree', 'add', '-b', branch, dir, `origin/${branch}`]
59
60
  : ['worktree', 'add', '-b', branch, dir, input.baseBranch];
61
+ startedFresh = args.at(-1) === input.baseBranch;
60
62
  git(input.repoPath, args);
61
63
  }
62
64
  const claudeDir = path.join(dir, '.claude');
@@ -64,7 +66,58 @@ export function prepareWorkspace(input) {
64
66
  const settingsPath = path.join(claudeDir, 'settings.json');
65
67
  writeFileSync(settingsPath, `${JSON.stringify(input.settings, null, 2)}\n`);
66
68
  writeFileSync(path.join(claudeDir, '.nomos-policy.json'), `${JSON.stringify({ policyHash: input.policyHash, taskId: input.taskId, generatedAt: new Date().toISOString() }, null, 2)}\n`);
67
- return { dir, branch, createdBranch: !existed, settingsPath };
69
+ return { dir, branch, createdBranch: !existed, startedFresh, settingsPath };
70
+ }
71
+ const SHA = /^[0-9a-f]{7,64}$/i;
72
+ const BRANCH = /^[A-Za-z0-9._/-]+$/;
73
+ // 원격 브랜치의 커밋을 이 레포로 받아 둔다(서버가 만든 시작 기준점, 선행 브랜치). 이미 있거나 받았으면 true.
74
+ export function fetchCommit(repoPath, branchName, commitSha) {
75
+ if (!SHA.test(commitSha))
76
+ return false;
77
+ if (refExists(repoPath, `${commitSha}^{commit}`))
78
+ return true;
79
+ if (branchName && BRANCH.test(branchName) && !branchName.startsWith('-')) {
80
+ try {
81
+ execFileSync('git', ['fetch', '--quiet', 'origin', `refs/heads/${branchName}`], { cwd: repoPath, stdio: 'ignore' });
82
+ }
83
+ catch {
84
+ // origin이 없거나(로컬 레포) 브랜치가 없다.
85
+ }
86
+ }
87
+ return refExists(repoPath, `${commitSha}^{commit}`);
88
+ }
89
+ // 새로 딴 태스크 브랜치에 선행 커밋을 오래된 것부터 합친다(앞서면 빨리 감기, 아니면 병합 커밋). 모델이 아니라 Executor가 한다 —
90
+ // 모델의 git은 merge·cherry-pick이 막혀 있다. 커밋이 없으면 선행 브랜치를 받아 온다. 충돌이나 실패는 되돌리고 건너뛴다 —
91
+ // 실행을 막으면 태스크가 READY로 남아 같은 실패를 되풀이하므로, 프롬프트에 "합치지 못했다"를 적고 모델이 멈출지 판단하게 한다.
92
+ // 서버의 V3는 같은 커밋들을 비교 기준으로 써서 선행의 파일을 이 태스크의 변경으로 세지 않는다.
93
+ export function mergePredecessors(dir, predecessors) {
94
+ const result = { merged: [], failed: [] };
95
+ for (const p of predecessors) {
96
+ if (!SHA.test(p.commitSha)) {
97
+ result.failed.push({ predecessor: p, reason: '커밋 형식이 아니다' });
98
+ continue;
99
+ }
100
+ if (!fetchCommit(dir, p.branchName, p.commitSha)) {
101
+ result.failed.push({ predecessor: p, reason: `커밋 ${p.commitSha.slice(0, 12)}을 찾지 못했다(선행 브랜치 ${p.branchName ?? '?'}가 push되지 않았거나 지워졌다)` });
102
+ continue;
103
+ }
104
+ try {
105
+ execFileSync('git', ['merge', '--no-edit', '-m', `nomos: 선행 태스크 합침 — ${p.title.slice(0, 120)}`, p.commitSha], { cwd: dir, stdio: 'pipe' });
106
+ result.merged.push(p);
107
+ }
108
+ catch (err) {
109
+ try {
110
+ execFileSync('git', ['merge', '--abort'], { cwd: dir, stdio: 'ignore' });
111
+ }
112
+ catch {
113
+ // 병합이 시작되지도 않았다(신원 미설정 등) — 되돌릴 것이 없다.
114
+ }
115
+ // 충돌 안내(CONFLICT ...)는 stdout으로, 그 밖의 실패는 stderr로 나온다.
116
+ const stderr = `${String(err.stdout ?? '')}\n${String(err.stderr ?? '')}`.trim();
117
+ result.failed.push({ predecessor: p, reason: /conflict/i.test(stderr) ? '병합 충돌' : `병합 실패: ${stderr.split('\n')[0]?.slice(0, 200) ?? ''}` });
118
+ }
119
+ }
120
+ return result;
68
121
  }
69
122
  // Executor가 작업공간에 쓰는 파일(MCP 설정·실행 로그·브리핑 노트·정책 표시·settings.json)이 모델의 `git add`에 섞이지 않게 한다.
70
123
  // 커밋에 섞이면 신고 경로와 실제 diff가 어긋나 V3가 FAIL을 낸다. info/exclude는 레포 공통이라 모든 worktree에 걸린다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capstone-nomos/cli",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "NOMOS 에이전트 CLI — 노트북의 Claude Code를 NOMOS 서버에 연결하고 배정된 태스크를 실행한다",
5
5
  "type": "module",
6
6
  "bin": {