@capstone-nomos/cli 0.1.14 → 0.1.16

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.
@@ -3,29 +3,48 @@ import path from 'node:path';
3
3
  // 브리핑으로 프롬프트에 넣은 인계 노트의 id. 제출할 때 "이미 받은 노트"로 서버에 함께 보낸다.
4
4
  // 서버는 관련 노트를 전부 확인해야 제출을 받는다(NOTES_UNACKNOWLEDGED). 프롬프트로 전달된 노트는 확인한 것이므로
5
5
  // 모델이 id를 옮겨 적게 하지 않고 브릿지가 넣는다 — 모델이 직접 넣는 건 반려 뒤 새로 받은 노트뿐이다.
6
+ // 질문으로 정해진 결정(decisions)도 같다 — 브리핑으로 받은 결정 id를 함께 남기고 제출 때 보낸다(DECISIONS_UNACKNOWLEDGED).
6
7
  // Executor가 작업공간에 쓰고(브리핑 직후), MCP 서버의 submit_artifact가 읽는다. 둘은 다른 프로세스다.
7
8
  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`);
9
+ export function writeBriefingNotes(workspaceDir, taskId, noteIds, decisionIds = []) {
10
+ writeFileSync(path.join(workspaceDir, BRIEFING_NOTES_FILE), `${JSON.stringify({ taskId, noteIds, decisionIds }, null, 2)}\n`);
10
11
  }
11
- // 다른 태스크의 파일이거나 없으면 빈 목록 — 엉뚱한 태스크의 노트를 확인한 것으로 보내지 않는다.
12
- export function readBriefingNoteIds(workspaceDir, taskId) {
12
+ // 다른 태스크의 파일이거나 없으면 빈 목록 — 엉뚱한 태스크의 노트·결정을 확인한 것으로 보내지 않는다.
13
+ function readBriefingIds(workspaceDir, taskId, key) {
13
14
  const file = path.join(workspaceDir, BRIEFING_NOTES_FILE);
14
15
  if (!existsSync(file))
15
16
  return [];
16
17
  try {
17
18
  const parsed = JSON.parse(readFileSync(file, 'utf-8'));
18
- if (parsed.taskId !== taskId || !Array.isArray(parsed.noteIds))
19
+ const ids = parsed[key];
20
+ if (parsed.taskId !== taskId || !Array.isArray(ids))
19
21
  return [];
20
- return parsed.noteIds.filter((id) => typeof id === 'string');
22
+ return ids.filter((id) => typeof id === 'string');
21
23
  }
22
24
  catch {
23
25
  return [];
24
26
  }
25
27
  }
28
+ export function readBriefingNoteIds(workspaceDir, taskId) {
29
+ return readBriefingIds(workspaceDir, taskId, 'noteIds');
30
+ }
31
+ export function readBriefingDecisionIds(workspaceDir, taskId) {
32
+ return readBriefingIds(workspaceDir, taskId, 'decisionIds');
33
+ }
26
34
  export function mergeAcknowledged(briefed, fromModel) {
27
35
  return [...new Set([...briefed, ...(fromModel ?? [])])].sort();
28
36
  }
37
+ // submit_artifact가 서버에 보내는 본문. 브리핑으로 받은 노트·결정과 모델이 반려 뒤 확인한 것을 합친다.
38
+ // acknowledgedDecisionIds는 비어 있어도 **항상** 보낸다 — 서버는 이 필드가 없으면 옛 CLI(0.1.14 이하)로 보고 결정 확인을 건너뛴다.
39
+ export function buildSubmitBody(input) {
40
+ const acknowledgedNoteIds = mergeAcknowledged(readBriefingNoteIds(input.workspaceDir, input.taskId), input.acknowledgedNoteIds);
41
+ return {
42
+ commitSha: input.commitSha,
43
+ changedPaths: input.changedPaths,
44
+ ...(acknowledgedNoteIds.length === 0 ? {} : { acknowledgedNoteIds }),
45
+ acknowledgedDecisionIds: mergeAcknowledged(readBriefingDecisionIds(input.workspaceDir, input.taskId), input.acknowledgedDecisionIds),
46
+ };
47
+ }
29
48
  // 지금 작업공간의 태스크 id — 권한 도구가 AskUserQuestion을 어느 태스크의 질문으로 올릴지 정한다. 없으면 null.
30
49
  export function readBriefingTaskId(workspaceDir) {
31
50
  const file = path.join(workspaceDir, BRIEFING_NOTES_FILE);
@@ -7,7 +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, readBriefingTaskId } from './briefing-notes.js';
10
+ import { buildSubmitBody, readBriefingTaskId } from './briefing-notes.js';
11
11
  import { PERMISSION_TOOL } from './claude-args.js';
12
12
  import { DENY_OTHER, denyUnanswered, handleAskUserQuestion, isHandled } from './permission.js';
13
13
  import { pushTaskBranch } from './push.js';
@@ -72,7 +72,7 @@ server.registerTool('claim_task', {
72
72
  const workspaceDir = process.env.NOMOS_WORKSPACE_DIR ?? process.cwd();
73
73
  server.registerTool('submit_artifact', {
74
74
  title: 'Submit an artifact',
75
- description: 'Submit finished work for a task you have claimed. Commit your work on the task branch first, then provide the latest commit sha and every path changed on the task branch (including commits from earlier attempts on this branch). 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.',
75
+ description: 'Submit finished work for a task you have claimed. Commit your work on the task branch first, then provide the latest commit sha and every path changed on the task branch (including commits from earlier attempts on this branch). 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. Likewise, if decisions were made by answering other tasks\' questions after you started, the submission is rejected with DECISIONS_UNACKNOWLEDGED and the decisions are returned (with any new related notes): follow them, change and commit your work if it differs, then submit again with their ids in acknowledged_decision_ids (and the notes\' ids in acknowledged_note_ids). Decisions already in your instructions are acknowledged automatically.',
76
76
  inputSchema: {
77
77
  task_id: z.string().describe('The claimed task id'),
78
78
  commit_sha: z.string().describe('Commit sha containing the work'),
@@ -81,8 +81,12 @@ server.registerTool('submit_artifact', {
81
81
  .array(z.string())
82
82
  .optional()
83
83
  .describe('Ids of handover notes you read after a NOTES_UNACKNOWLEDGED rejection'),
84
+ acknowledged_decision_ids: z
85
+ .array(z.string())
86
+ .optional()
87
+ .describe('Ids of decisions you read after a DECISIONS_UNACKNOWLEDGED rejection'),
84
88
  },
85
- }, async ({ task_id, commit_sha, changed_paths, acknowledged_note_ids }) => run(async () => {
89
+ }, async ({ task_id, commit_sha, changed_paths, acknowledged_note_ids, acknowledged_decision_ids }) => run(async () => {
86
90
  // push할 브랜치와 막을 브랜치는 서버가 정한다. 모델이 넘긴 값으로 고르지 않는다.
87
91
  const briefing = (await client.getBriefing(task_id));
88
92
  const push = await pushTaskBranch({
@@ -95,13 +99,15 @@ server.registerTool('submit_artifact', {
95
99
  process.stderr.write('[nomos-mcp] origin 없음 — push 건너뜀(서버가 mirror 모드로 로컬에서 읽는다)\n');
96
100
  }
97
101
  // push가 실패하면(PushRefused) 여기까지 오지 않는다 — 제출하지 않고 run()이 모델에게 사유를 돌려준다.
98
- // 브리핑으로 프롬프트에 들어간 노트는 이미 받은 것이다 — Executor가 작업공간에 남긴 id를 함께 보낸다.
99
- const acknowledgedNoteIds = mergeAcknowledged(readBriefingNoteIds(workspaceDir, task_id), acknowledged_note_ids);
100
- const artifact = await client.submitArtifact(task_id, {
102
+ // 브리핑으로 프롬프트에 들어간 노트·결정은 이미 받은 것이다 — Executor가 작업공간에 남긴 id를 함께 보낸다.
103
+ const artifact = await client.submitArtifact(task_id, buildSubmitBody({
104
+ workspaceDir,
105
+ taskId: task_id,
101
106
  commitSha: commit_sha,
102
107
  changedPaths: changed_paths,
103
- ...(acknowledgedNoteIds.length === 0 ? {} : { acknowledgedNoteIds }),
104
- });
108
+ ...(acknowledged_note_ids === undefined ? {} : { acknowledgedNoteIds: acknowledged_note_ids }),
109
+ ...(acknowledged_decision_ids === undefined ? {} : { acknowledgedDecisionIds: acknowledged_decision_ids }),
110
+ }));
105
111
  return { ...artifact, push };
106
112
  }, 'submit_artifact'));
107
113
  server.registerTool('publish_note', {
@@ -29,6 +29,7 @@ export class NomosClient {
29
29
  baseUrl;
30
30
  fetchImpl;
31
31
  onTokensChanged;
32
+ onApiError;
32
33
  current;
33
34
  // 진단용. 통합 테스트가 "정확히 한 번만 재발급했는가"를 확인한다.
34
35
  refreshCount = 0;
@@ -36,6 +37,7 @@ export class NomosClient {
36
37
  this.baseUrl = options.baseUrl.replace(/\/$/, '');
37
38
  this.fetchImpl = options.fetchImpl ?? fetch;
38
39
  this.onTokensChanged = options.onTokensChanged;
40
+ this.onApiError = options.onApiError;
39
41
  this.current = options.tokens;
40
42
  }
41
43
  get tokens() {
@@ -44,7 +46,9 @@ export class NomosClient {
44
46
  async claimTask(taskId) {
45
47
  return (await this.request('POST', `/api/tasks/${taskId}/claim`));
46
48
  }
47
- async submitArtifact(taskId, input) {
49
+ async submitArtifact(taskId,
50
+ // acknowledgedDecisionIds: MCP submit_artifact는 비어 있어도 항상 보낸다(없으면 서버가 옛 CLI로 보고 결정 확인을 건너뛴다).
51
+ input) {
48
52
  return (await this.request('POST', `/api/tasks/${taskId}/artifacts`, input));
49
53
  }
50
54
  // 브릿지 단계(V2·V4) 보고. 서버가 판정하는 V1A·V1B·V3는 여기로 보낼 수 없다 — 403이 돌아온다.
@@ -152,6 +156,22 @@ export class NomosClient {
152
156
  await this.request('POST', `/api/pm/jobs/${jobId}/failure`, { message });
153
157
  }
154
158
  async request(method, path, body) {
159
+ try {
160
+ return await this.requestOnce(method, path, body);
161
+ }
162
+ catch (err) {
163
+ if (err instanceof NomosApiError) {
164
+ try {
165
+ this.onApiError?.(err);
166
+ }
167
+ catch {
168
+ // 알림이 실패해도 원래 에러를 그대로 던진다.
169
+ }
170
+ }
171
+ throw err;
172
+ }
173
+ }
174
+ async requestOnce(method, path, body) {
155
175
  const first = await this.send(method, path, body);
156
176
  if (!needsRefresh(first))
157
177
  return unwrap(first);
@@ -26,6 +26,11 @@ export function isAlive(pid) {
26
26
  return err.code === 'EPERM';
27
27
  }
28
28
  }
29
+ // 이 프로세스가 백그라운드 connect 자신이면 daemon.json을 지운다 — 연결 해제로 스스로 끝날 때(cli.ts) status가 바로 "돌고 있지 않음"을 보이게.
30
+ export function clearDaemonIfSelf() {
31
+ if (readDaemon()?.pid === process.pid)
32
+ rmSync(daemonFile(), { force: true });
33
+ }
29
34
  // 살아 있는 백그라운드 connect. 죽었는데 파일만 남았으면 지우고 null.
30
35
  export function runningDaemon() {
31
36
  const info = readDaemon();
@@ -27,7 +27,8 @@ import { credentialsPath, readCredentials, updateAccessToken, writeCredentials,
27
27
  import { NomosApiError, NomosClient } from '../bridge/nomos-client.js';
28
28
  import { ActivityReporter, ActivityTracker, RunObserver } from './activity.js';
29
29
  import { accountLine, connect, DEFAULT_SERVER, serverCalls } from './connect.js';
30
- import { runningDaemon, startDaemon, stopDaemon, tailLog } from './background.js';
30
+ import { clearDaemonIfSelf, runningDaemon, startDaemon, stopDaemon, tailLog } from './background.js';
31
+ import { agentStop, DISCONNECTED_EXIT_CODE, DISCONNECTED_MESSAGE } from './stop.js';
31
32
  import { deviceLogin } from './device-login.js';
32
33
  import { cliVersion, mcpServerPath } from './paths.js';
33
34
  import { checkTools } from './preflight.js';
@@ -46,6 +47,24 @@ const POLL_INTERVAL_MS = 10_000;
46
47
  function log(line) {
47
48
  process.stdout.write(`[nomos] ${line}\n`);
48
49
  }
50
+ // 돌고 있는 일(태스크 실행·상담·계약 초안) — 연결 해제로 멈출 때 다 끝나기를 기다린 뒤 프로세스를 끝낸다.
51
+ const running = new Set();
52
+ function track(work) {
53
+ const p = work().finally(() => running.delete(p));
54
+ running.add(p);
55
+ return p;
56
+ }
57
+ // 연결 해제로 멈출 때, 이미 끝내라고 한(트리째 종료) 실행들이 정리되기를 잠시 기다린다. 오래 걸려도 프로세스는 끝낸다.
58
+ async function settleRunning(timeoutMs = 15_000) {
59
+ await Promise.race([Promise.allSettled([...running]), new Promise((r) => setTimeout(r, timeoutMs).unref())]);
60
+ }
61
+ // 연결 해제로 끝난다 — 0이 아닌 코드. 백그라운드(--background)로 돌던 것이면 daemon.json도 지워 status가 "돌고 있지 않음"을 보이게 한다.
62
+ // 남은 타이머(폴링 대기 등)가 프로세스를 붙잡지 않게 바로 끝낸다.
63
+ function exitDisconnected() {
64
+ log(DISCONNECTED_MESSAGE);
65
+ clearDaemonIfSelf();
66
+ process.exit(DISCONNECTED_EXIT_CODE);
67
+ }
49
68
  function createClient() {
50
69
  const credentials = readCredentials();
51
70
  if (!credentials) {
@@ -56,6 +75,11 @@ function createClient() {
56
75
  tokens: { accessToken: credentials.accessToken, refreshToken: credentials.refreshToken },
57
76
  // 재발급 결과를 파일에 되돌려 쓴다. 없으면 다음 실행이 또 401로 시작한다.
58
77
  onTokensChanged: (tokens) => updateAccessToken(tokens.accessToken),
78
+ // 어느 요청에서든 AGENT_DISCONNECTED를 받으면 실행 중인 Claude를 모두 멈춘다(stop.ts).
79
+ onApiError: (err) => {
80
+ if (!agentStop.stopped && agentStop.noteError(err))
81
+ log(DISCONNECTED_MESSAGE);
82
+ },
59
83
  });
60
84
  }
61
85
  // 지금 돌고 있는 태스크의 작업공간 — 상담 실행이 진행 중인 작업(커밋 안 된 파일 포함)을 읽으려고 둔다.
@@ -72,7 +96,7 @@ async function consultPending(client, role) {
72
96
  if (!job)
73
97
  return;
74
98
  consulting.add(job.id);
75
- void (async () => {
99
+ track(async () => {
76
100
  try {
77
101
  const active = [...activeWorkspaces.values()].find((w) => repos.some((r) => r.fullName === w.repo));
78
102
  const repo = active ? { fullName: active.repo } : repos[0];
@@ -80,18 +104,20 @@ async function consultPending(client, role) {
80
104
  throw new Error('이 역할이 소유한 레포가 프로젝트에 없다');
81
105
  const dir = active ? active.dir : ensureRepo({ fullName: repo.fullName, cloneUrl: repos[0].cloneUrl }, { log }).path;
82
106
  log(`상담 실행: ${job.askerRole}의 질문 ${job.questions.length}개 — ${repo.fullName}${active ? ' (진행 중인 작업공간)' : ''}`);
83
- const draft = await runConsult({ dir, role, repoName: repo.fullName, question: job });
107
+ const draft = await runConsult({ dir, role, repoName: repo.fullName, question: job, signal: agentStop.signal });
84
108
  const result = await client.submitDraft(job.id, draft);
85
109
  log(result.status === 'agent_answered' ? ' 초안 올림 — 전부 코드에 정해져 있어 바로 답이 됐다(사람 확인 대기)' : ' 초안 올림 — 정해지지 않은 결정이 있어 담당자에게 넘어갔다');
86
110
  }
87
111
  catch (err) {
88
112
  consultGaveUp.add(job.id);
89
- log(` 상담 실패 — 담당자가 직접 답한다: ${err instanceof Error ? err.message : String(err)}`);
113
+ // 연결 해제로 멈췄으면 사유는 이미 남겼다(그 뒤의 보고는 401이라 보내지 않는다).
114
+ if (!agentStop.stopped)
115
+ log(` 상담 실패 — 담당자가 직접 답한다: ${err instanceof Error ? err.message : String(err)}`);
90
116
  }
91
117
  finally {
92
118
  consulting.delete(job.id);
93
119
  }
94
- })();
120
+ });
95
121
  }
96
122
  // 계약 작성(023) — BACKEND 노트북만. 기능 명세에 API가 필요하면 BE 레포를 읽기만 해서 계약 초안을 올린다(본인 구독).
97
123
  // 한 번에 한 묶음씩. 첫 회차가 둘 이상이면 한 번의 실행으로 함께 쓰고(레포를 한 번만 읽는다 — contract.ts), 레포 최신화도 묶음당 한 번이다.
@@ -107,7 +133,7 @@ async function contractPending(client) {
107
133
  if (jobs.length === 0)
108
134
  return;
109
135
  drafting = true;
110
- void (async () => {
136
+ track(async () => {
111
137
  try {
112
138
  const active = [...activeWorkspaces.values()].find((w) => repos.some((r) => r.fullName === w.repo));
113
139
  const repo = active ? repos.find((r) => r.fullName === active.repo) : repos[0];
@@ -124,7 +150,7 @@ async function contractPending(client) {
124
150
  if (firsts.length >= 2) {
125
151
  log(`계약 묶음 작성: ${firsts.map((j) => j.featureKey).join(', ')} (${firsts.length}개, 1회차) — ${repo.fullName}`);
126
152
  try {
127
- batched = await runContractBatch({ dir, repoName: repo.fullName, jobs: firsts, approved });
153
+ batched = await runContractBatch({ dir, repoName: repo.fullName, jobs: firsts, approved, signal: agentStop.signal });
128
154
  const missing = firsts.filter((j) => !batched.has(j.featureKey)).map((j) => j.featureKey);
129
155
  if (missing.length > 0)
130
156
  log(` 묶음에서 빠진 기능 ${missing.join(', ')} — 하나씩 다시 쓴다`);
@@ -134,11 +160,13 @@ async function contractPending(client) {
134
160
  }
135
161
  }
136
162
  for (const job of jobs) {
163
+ if (agentStop.stopped)
164
+ break;
137
165
  try {
138
166
  const ready = batched.get(job.featureKey);
139
167
  if (!ready)
140
168
  log(`계약 작성: ${job.featureKey} ${job.specTitle} (${job.attempt}회차) — ${repo.fullName}`);
141
- let draft = ready ?? (await runContractDraft({ dir, repoName: repo.fullName, job, approved }));
169
+ let draft = ready ?? (await runContractDraft({ dir, repoName: repo.fullName, job, approved, signal: agentStop.signal }));
142
170
  try {
143
171
  await client.submitContractDraft(job.id, { attempt: job.attempt, ...draft });
144
172
  }
@@ -147,7 +175,7 @@ async function contractPending(client) {
147
175
  throw err;
148
176
  const problems = Array.isArray(err.details) ? err.details.map(String) : [err.message];
149
177
  log(` ${job.featureKey} 형식 위반 ${problems.length}개 — 한 번 고쳐 쓴다`);
150
- draft = await runContractDraft({ dir, repoName: repo.fullName, job, approved, problems });
178
+ draft = await runContractDraft({ dir, repoName: repo.fullName, job, approved, problems, signal: agentStop.signal });
151
179
  await client.submitContractDraft(job.id, { attempt: job.attempt, ...draft });
152
180
  }
153
181
  log(` ${job.featureKey} 초안 올림 — BE 담당이 웹에서 확정하거나 수정을 요청한다`);
@@ -160,10 +188,13 @@ async function contractPending(client) {
160
188
  finally {
161
189
  drafting = false;
162
190
  }
163
- })();
191
+ });
164
192
  }
165
193
  async function reportContractFailure(client, job, err) {
166
194
  contractGaveUp.add(`${job.id}:${job.attempt}`);
195
+ // 연결 해제로 멈췄다 — 실패 보고는 401이라 보내지 않는다. 서버에서는 BE 담당이 다시 요청할 수 있다.
196
+ if (agentStop.stopped)
197
+ return;
167
198
  // 그 사이 수정 요청이 와서 회차가 바뀌었다 — 새 회차는 다음 확인에서 다시 쓴다.
168
199
  if (err instanceof NomosApiError && err.code === 'CONTRACT_STALE') {
169
200
  log(` ${job.featureKey} — 그 사이 계약이 바뀌었다, 이 회차는 버린다`);
@@ -190,8 +221,8 @@ async function handleTask(client, projectId, task) {
190
221
  });
191
222
  log(`작업공간 ${workspace.dir} (브랜치 ${workspace.branch})`);
192
223
  activeWorkspaces.set(task.id, { dir: workspace.dir, repo: briefing.repo.fullName });
193
- // 프롬프트에 넣는 노트 = 제출 때 "받은 노트"로 함께 보낼 노트. MCP 서버(submit_artifact)가 읽는다.
194
- writeBriefingNotes(workspace.dir, task.id, (briefing.notes ?? []).map((n) => n.id));
224
+ // 프롬프트에 넣는 노트·결정 = 제출 때 "받은 것"으로 함께 보낼 노트·결정. MCP 서버(submit_artifact)가 읽는다.
225
+ writeBriefingNotes(workspace.dir, task.id, (briefing.notes ?? []).map((n) => n.id), (briefing.decisions ?? []).map((d) => d.id));
195
226
  if (workspace.createdBranch) {
196
227
  await client.reportBranch(task.id, workspace.branch);
197
228
  }
@@ -213,6 +244,9 @@ async function handleTask(client, projectId, task) {
213
244
  const tracker = new ActivityTracker(workspace.dir);
214
245
  // 의존성은 모델이 아니라 여기서 설치한다(설치 스크립트 끔). 실패해도 진행한다 — 프롬프트에 결과를 적는다.
215
246
  const setup = await prepareDependencies(workspace.dir, log);
247
+ // 준비하는 사이 연결이 해제됐으면 Claude를 띄우지 않는다.
248
+ if (agentStop.stopped)
249
+ return;
216
250
  const before = headSha(workspace.dir);
217
251
  const result = await runClaude({
218
252
  workspaceDir: workspace.dir,
@@ -225,7 +259,15 @@ async function handleTask(client, projectId, task) {
225
259
  observer.observe(line);
226
260
  reporter?.push(tracker.observe(line));
227
261
  },
262
+ signal: agentStop.signal,
228
263
  });
264
+ // 연결이 해제돼 멈췄다 — 서버가 열린 실행을 이미 닫았고(outcome disconnected) 종료 보고는 401이라 보내지 않는다.
265
+ // 작업공간은 지우지 않는다(남기는 규칙 그대로).
266
+ if (result.outcome === 'aborted') {
267
+ await reporter?.close().catch(() => { });
268
+ log(`태스크 ${task.id} 실행을 멈췄다(연결 해제). 작업공간은 남긴다: ${workspace.dir}`);
269
+ return;
270
+ }
229
271
  const committed = headSha(workspace.dir) !== before;
230
272
  if (reporter) {
231
273
  reporter.push(tracker.finish());
@@ -320,19 +362,29 @@ async function start() {
320
362
  },
321
363
  fallback: () => pollingTaskSource(client).nextTasks(Number.MAX_SAFE_INTEGER),
322
364
  log,
365
+ // 서버가 연결 해제로 스트림을 닫았다(4401 AGENT_DISCONNECTED) — 실행 중인 Claude를 멈춘다.
366
+ onAgentDisconnected: () => agentStop.stop(),
323
367
  });
324
368
  log(`시작 — 프로젝트 ${self.projectId}, 역할 ${self.teamRole}, 동시 실행 ${self.maxConcurrent} (${source.kind})`);
325
369
  const inFlight = new Set();
326
370
  // 태스크 하나가 끝나면 자리가 난다 — 다음 푸시나 간격을 기다리지 않고 바로 다시 본다.
327
371
  let finished = () => { };
372
+ // 연결 해제 — 실행 중인 것은 이미 트리째 끝내라고 했다(stop.ts). 정리를 기다린 뒤 멈춘다.
373
+ const stopDisconnected = async () => {
374
+ source.close();
375
+ await settleRunning();
376
+ return 'disconnected';
377
+ };
328
378
  for (;;) {
379
+ if (agentStop.stopped)
380
+ return stopDisconnected();
329
381
  try {
330
382
  const capacity = self.maxConcurrent - inFlight.size;
331
383
  if (capacity > 0) {
332
384
  // 처리 중인 것도 목록에 남아 있을 수 있으니(수령 전) 그만큼 더 받아 거른다.
333
385
  for (const task of (await source.nextTasks(capacity + inFlight.size)).filter((t) => !inFlight.has(t.id)).slice(0, capacity)) {
334
386
  inFlight.add(task.id);
335
- void handleTask(client, self.projectId, task)
387
+ void track(() => handleTask(client, self.projectId, task))
336
388
  .catch((err) => log(`태스크 ${task.id} 실패: ${err instanceof Error ? err.message : String(err)}`))
337
389
  .finally(() => {
338
390
  inFlight.delete(task.id);
@@ -345,9 +397,8 @@ async function start() {
345
397
  catch (err) {
346
398
  // 대표나 본인이 웹에서 이 에이전트의 연결을 해제했다(서버 021) — 계속 물어도 같은 답이니 이유를 알리고 멈춘다.
347
399
  if (err instanceof NomosApiError && err.code === 'AGENT_DISCONNECTED') {
348
- log('이 에이전트는 연결이 해제됐다 — 다시 쓰려면 connect를 실행하라');
349
- source.close();
350
- return 'disconnected';
400
+ agentStop.stop();
401
+ return stopDisconnected();
351
402
  }
352
403
  // 배정이 풀렸다(대표의 해제·프로젝트 중단, 서버 021). 같은 목록을 계속 물어도 거부된다.
353
404
  if (err instanceof NomosApiError && err.code === 'NOT_PROJECT_MEMBER' && inFlight.size === 0) {
@@ -376,7 +427,7 @@ async function start() {
376
427
  if (!(err instanceof NomosApiError && err.status === 404))
377
428
  log(`계약 작업 목록 실패: ${err instanceof Error ? err.message : String(err)}`);
378
429
  }
379
- await Promise.race([source.waitForChange(POLL_INTERVAL_MS), new Promise((resolve) => (finished = resolve))]);
430
+ await Promise.race([source.waitForChange(POLL_INTERVAL_MS), new Promise((resolve) => (finished = resolve)), agentStop.whenStopped()]);
380
431
  }
381
432
  }
382
433
  // 토큰이 헤더로 다니므로 원격은 https만 받는다. 로컬 개발 서버만 예외다.
@@ -522,8 +573,9 @@ async function connectCommand(argv) {
522
573
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
523
574
  log,
524
575
  });
525
- if ((await start()) !== 'unassigned')
526
- return;
576
+ const reason = await start();
577
+ if (reason === 'disconnected')
578
+ exitDisconnected();
527
579
  log('다시 배정을 기다린다');
528
580
  }
529
581
  }
@@ -626,7 +678,8 @@ async function main() {
626
678
  if (command === 'once')
627
679
  return once();
628
680
  if (command === 'start') {
629
- await start();
681
+ if ((await start()) === 'disconnected')
682
+ exitDisconnected();
630
683
  return;
631
684
  }
632
685
  if (command === 'pm-worker')
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import path from 'node:path';
5
+ import { killOnAbort } from './stop.js';
5
6
  import { claudeEnv, resolveClaudeCommand } from './runner.js';
6
7
  // 상담 응답의 형식(구조화 출력). 모든 객체는 additionalProperties: false + 전 필드 required.
7
8
  export const CONSULT_JSON_SCHEMA = {
@@ -63,6 +64,7 @@ export function consultArgs(prompt, emptyMcpPath) {
63
64
  return readOnlyClaudeArgs(prompt, emptyMcpPath, CONSULT_JSON_SCHEMA);
64
65
  }
65
66
  // 읽기 전용 Claude를 한 번 돌려 구조화 출력을 돌려준다(없으면 result 문자열).
67
+ // signal이 멈추면(연결 해제 — stop.ts) claude를 트리째 끝내고 그 사유로 실패한다.
66
68
  export async function runReadOnlyClaude(input) {
67
69
  const scratch = path.join(tmpdir(), 'nomos-consult');
68
70
  mkdirSync(scratch, { recursive: true });
@@ -76,14 +78,20 @@ export async function runReadOnlyClaude(input) {
76
78
  child.kill();
77
79
  reject(new Error(`${input.label} 시간 초과`));
78
80
  }, input.timeoutMs);
81
+ const unwatch = killOnAbort(child, input.signal);
79
82
  child.stdout.on('data', (d) => (stdout += d));
80
83
  child.once('error', (err) => {
81
84
  clearTimeout(timer);
85
+ unwatch();
82
86
  reject(err);
83
87
  });
84
88
  child.on('close', () => {
85
89
  clearTimeout(timer);
86
- resolve(stdout);
90
+ unwatch();
91
+ if (input.signal?.aborted)
92
+ reject(input.signal.reason instanceof Error ? input.signal.reason : new Error(`${input.label} 중단`));
93
+ else
94
+ resolve(stdout);
87
95
  });
88
96
  child.stdin.end(input.prompt);
89
97
  });
@@ -124,6 +132,7 @@ export async function runConsult(input) {
124
132
  schema: CONSULT_JSON_SCHEMA,
125
133
  timeoutMs: input.timeoutMs ?? 5 * 60_000,
126
134
  label: '상담 실행',
135
+ ...(input.signal ? { signal: input.signal } : {}),
127
136
  });
128
137
  return parseConsultDraft(output, input.question.questions.map((q) => q.question));
129
138
  }
@@ -80,6 +80,7 @@ export async function runContractDraft(input) {
80
80
  // 계약은 레포를 넓게 읽는다(라우트·에러 형식·인증) — 상담(5분)보다 길게 둔다.
81
81
  timeoutMs: input.timeoutMs ?? 10 * 60_000,
82
82
  label: '계약 작성',
83
+ ...(input.signal ? { signal: input.signal } : {}),
83
84
  });
84
85
  return parseContractDraft(output);
85
86
  }
@@ -170,6 +171,7 @@ export async function runContractBatch(input) {
170
171
  // 레포는 한 번 읽지만 쓸 양은 기능 수만큼 는다. 기본 10분 + 기능당 2분, 최대 30분.
171
172
  timeoutMs: input.timeoutMs ?? Math.min(30, 10 + 2 * input.jobs.length) * 60_000,
172
173
  label: '계약 묶음 작성',
174
+ ...(input.signal ? { signal: input.signal } : {}),
173
175
  });
174
176
  return parseContractBatch(output, input.jobs);
175
177
  }
@@ -42,6 +42,16 @@ export function buildTaskPrompt(briefing, branch, environment) {
42
42
  '이 브랜치에 이전 작업이 남아 있으면 이어서 진행한다. 같은 질문을 다시 묻지 않는다.',
43
43
  ].join('\n'));
44
44
  }
45
+ // 다른 역할·PM이 질문에 답해 확정한 결정 — 이 태스크가 물은 것이 아니어도 프로젝트 전체가 따른다.
46
+ // 실행 중에는 끼워 넣지 않는다. 작업 중에 새로 정해진 결정은 제출 때 DECISIONS_UNACKNOWLEDGED로 온다.
47
+ if (briefing.decisions && briefing.decisions.length > 0) {
48
+ sections.push([
49
+ '# 질문으로 정해진 결정',
50
+ '다른 역할·PM이 질문에 답해 확정한 결정이다. 구현이 이 결정과 다르면 결정을 따른다.',
51
+ '결정 자체가 틀렸거나 바꿔야 한다고 보면 임의로 바꾸지 말고 AskUserQuestion으로 그 역할에 다시 묻는다.',
52
+ ...briefing.decisions.map((d) => `- [물은 쪽 ${d.askerRole} · 정한 쪽 ${d.targetRole}${d.decidedByName ? ` ${d.decidedByName}` : ''}] Q: ${d.question}\n A: ${d.answer ?? ''}`),
53
+ ].join('\n'));
54
+ }
45
55
  // 사람(그 역할 담당·대표)이 룸에서 보낸 메시지 — 이번 실행이 시작되기 전에 온 것만 들어온다(실행 중에는 끼워 넣지 않는다).
46
56
  if (briefing.humanMessages && briefing.humanMessages.length > 0) {
47
57
  sections.push([
@@ -67,6 +77,7 @@ export function buildTaskPrompt(briefing, branch, environment) {
67
77
  ' 바꾸지 않은 경로를 적거나 sha를 지어내지 않는다 — 서버가 제출 시점에 다시 검증한다.',
68
78
  '- 제출이 NOTES_UNACKNOWLEDGED로 반려되면, 작업 중에 새로 올라온 인계 노트가 함께 온다. 읽고, 작업에 영향이 있으면 고쳐서 커밋한 뒤',
69
79
  ' submit_artifact를 다시 부르며 그 노트 id를 acknowledged_note_ids에 넣는다. 위 [인계 노트]의 노트는 따로 넣지 않아도 된다.',
80
+ '- 제출이 DECISIONS_UNACKNOWLEDGED로 반려되면, 작업 중에 새로 정해진 결정이 함께 온다. 반영하고 그 id를 acknowledged_decision_ids에 넣어 다시 제출하라(함께 온 노트가 있으면 그 id는 acknowledged_note_ids에).',
70
81
  '- 다음 사람이 알아야 할 결정이나 함정이 있으면 publish_note로 남긴다. 다른 사람도 따라야 할 결정(계약·형식)은 DECIDED로 남긴다 — 프로젝트 전체에 전달된다.',
71
82
  // 실험(E6): 이 줄이 없으면 FE 에이전트가 명세에 없는 BE 계약을 스스로 정해 DECIDED로 발행했다 — 그 노트가 BE 브리핑에 "결정"으로 들어간다.
72
83
  `- 단, 다른 역할이 정해야 하는 계약(그 역할이 만드는 API의 응답 형식·상태 코드·필드 이름 등)은 DECIDED로 남기지 않는다 — 네 역할(${task.teamRole ?? '제한 없음'})이 정할 수 있는 것이 아니다.`,
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { existsSync, writeFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { buildClaudeArgs } from '../bridge/claude-args.js';
5
+ import { killOnAbort } from './stop.js';
5
6
  export const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
6
7
  // 모델 출력(stream-json)이 이만큼 없으면 응답이 끊긴 것으로 보고 끊는다. 30분을 다 기다리지 않고, 사유도 "오래 일함"과 구분된다.
7
8
  // 실측: 도구 결과를 받은 뒤 다음 모델 응답이 오지 않은 채 20분 넘게 멈춘 실행이 있었다(같은 조건 재실행은 정상).
@@ -111,14 +112,20 @@ export async function runClaude(input) {
111
112
  child.kill();
112
113
  }
113
114
  }, Math.min(30_000, Math.max(50, Math.floor(idleMs / 4))));
115
+ const unwatch = killOnAbort(child, input.signal);
114
116
  child.on('close', (code) => {
115
117
  clearTimeout(timer);
116
118
  clearInterval(idleCheck);
117
- writeFileSync(logPath, stalled ? `${chunks.join('')}
118
- [executor] ${Math.round(idleMs / 60_000)}분 동안 모델 출력이 없어 실행을 끊었다
119
- ` : chunks.join(''));
119
+ unwatch();
120
+ const aborted = input.signal?.aborted === true;
121
+ const note = aborted
122
+ ? '\n[executor] 에이전트 연결이 해제돼 실행을 멈췄다\n'
123
+ : stalled
124
+ ? `\n[executor] ${Math.round(idleMs / 60_000)}분 동안 모델 출력이 없어 실행을 끊었다\n`
125
+ : '';
126
+ writeFileSync(logPath, `${chunks.join('')}${note}`);
120
127
  resolve({
121
- outcome: stalled ? 'stalled' : timedOut ? 'timeout' : code === 0 ? 'completed' : 'failed',
128
+ outcome: aborted ? 'aborted' : stalled ? 'stalled' : timedOut ? 'timeout' : code === 0 ? 'completed' : 'failed',
122
129
  exitCode: code,
123
130
  durationMs: Date.now() - startedAt,
124
131
  logPath,
@@ -127,6 +134,7 @@ export async function runClaude(input) {
127
134
  child.on('error', (err) => {
128
135
  clearTimeout(timer);
129
136
  clearInterval(idleCheck);
137
+ unwatch();
130
138
  writeFileSync(logPath, `${chunks.join('')}\n[executor] spawn 실패: ${err.message}\n`);
131
139
  resolve({ outcome: 'failed', exitCode: null, durationMs: Date.now() - startedAt, logPath });
132
140
  });
@@ -0,0 +1,66 @@
1
+ import { spawn } from 'node:child_process';
2
+ // 이 에이전트의 연결이 해제됐다(서버 021 — 웹에서 본인·대표가 해제, 조직 탈퇴·해산). 알게 되는 곳이 여러 군데라 한 곳에 모은다:
3
+ // 폴링·브리핑·활동 보고 같은 HTTP 응답(NomosClient의 onApiError), 태스크 스트림의 4401 AGENT_DISCONNECTED 종료.
4
+ // 알게 되면 실행 중인 Claude(태스크 실행·상담·계약 초안)를 모두 멈춘다 — 해제 뒤에는 무엇을 해도 서버가 받지 않고(401),
5
+ // 실행이 끝까지 가면 룸에 "생각 중"만 남았다. 서버는 해제하면서 열린 실행을 이미 닫는다(outcome disconnected) — 그래서 종료 보고도 보내지 않는다.
6
+ export const AGENT_DISCONNECTED = 'AGENT_DISCONNECTED';
7
+ export const DISCONNECTED_MESSAGE = '이 에이전트의 연결이 해제돼 실행 중인 작업을 멈췄다 — 다시 쓰려면 connect를 실행하라';
8
+ // connect·start가 연결 해제로 끝날 때의 종료 코드(0이 아니다 — 스크립트·백그라운드 감시가 정상 종료와 가를 수 있게).
9
+ export const DISCONNECTED_EXIT_CODE = 3;
10
+ export function isAgentDisconnected(err) {
11
+ return typeof err === 'object' && err !== null && err.code === AGENT_DISCONNECTED;
12
+ }
13
+ // 프로세스 전체에서 하나 — 연결 해제는 에이전트 단위라 실행마다 따로 둘 이유가 없다.
14
+ export class AgentStop {
15
+ controller = new AbortController();
16
+ get signal() {
17
+ return this.controller.signal;
18
+ }
19
+ get stopped() {
20
+ return this.controller.signal.aborted;
21
+ }
22
+ // 에러가 연결 해제면 멈춘다. 멈췄으면 true(이미 멈춘 경우도).
23
+ noteError(err) {
24
+ if (isAgentDisconnected(err))
25
+ this.stop();
26
+ return this.stopped;
27
+ }
28
+ stop() {
29
+ if (!this.controller.signal.aborted)
30
+ this.controller.abort(new Error(DISCONNECTED_MESSAGE));
31
+ }
32
+ // 멈출 때 풀리는 약속 — 대기(waitForChange)와 경주시킨다.
33
+ whenStopped() {
34
+ if (this.stopped)
35
+ return Promise.resolve();
36
+ return new Promise((resolve) => this.controller.signal.addEventListener('abort', () => resolve(), { once: true }));
37
+ }
38
+ }
39
+ export const agentStop = new AgentStop();
40
+ // 자식 프로세스를 트리째 끝낸다. claude는 MCP 서버·Bash 도구(시험 실행 등)를 자식으로 띄운다.
41
+ // - Windows: claude를 node로 cli-wrapper를 거쳐 띄우므로(runner.ts의 resolveClaudeCommand) 그 node만 죽이면 실제 claude가 남는다 —
42
+ // taskkill /T(트리) /F로 끝낸다. taskkill을 못 띄우면 직접 kill로 내려간다.
43
+ // - 그 밖: SIGTERM을 claude에 보낸다. MCP 서버는 stdin(파이프)이 닫히면 함께 끝난다. 프로세스 그룹째(detached + kill(-pid)) 죽이지 않는 이유:
44
+ // detached로 띄우면 터미널의 Ctrl+C가 claude에 닿지 않아, connect를 끄면 claude가 고아로 남는다.
45
+ export function killProcessTree(child) {
46
+ if (child.exitCode !== null || child.signalCode !== null)
47
+ return;
48
+ if (process.platform === 'win32' && child.pid !== undefined) {
49
+ const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
50
+ killer.once('error', () => child.kill());
51
+ return;
52
+ }
53
+ child.kill('SIGTERM');
54
+ }
55
+ // signal이 멈추면 자식을 트리째 끝낸다. 돌려주는 함수로 구독을 푼다(자식이 먼저 끝나면 부른다).
56
+ export function killOnAbort(child, signal) {
57
+ if (!signal)
58
+ return () => { };
59
+ const onAbort = () => killProcessTree(child);
60
+ if (signal.aborted) {
61
+ onAbort();
62
+ return () => { };
63
+ }
64
+ signal.addEventListener('abort', onAbort, { once: true });
65
+ return () => signal.removeEventListener('abort', onAbort);
66
+ }
@@ -89,6 +89,12 @@ export function streamTaskSource(deps) {
89
89
  wake();
90
90
  return;
91
91
  }
92
+ if (ev.code === 4401 && ev.reason === 'AGENT_DISCONNECTED') {
93
+ deps.log('서버가 이 에이전트의 연결 해제로 푸시를 닫았다');
94
+ deps.onAgentDisconnected?.();
95
+ wake();
96
+ return;
97
+ }
92
98
  if (ev.code === 4401 && !refreshedInARow) {
93
99
  refreshedInARow = true;
94
100
  deps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capstone-nomos/cli",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "NOMOS 에이전트 CLI — 노트북의 Claude Code를 NOMOS 서버에 연결하고 배정된 태스크를 실행한다",
5
5
  "type": "module",
6
6
  "bin": {