@capstone-nomos/cli 0.1.12 → 0.1.14
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.
|
@@ -125,6 +125,19 @@ server.registerTool('publish_note', {
|
|
|
125
125
|
affects: affects ?? [],
|
|
126
126
|
...(supersedes === undefined ? {} : { supersedes }),
|
|
127
127
|
}), 'publish_note'));
|
|
128
|
+
server.registerTool('read_contracts', {
|
|
129
|
+
title: 'Read confirmed API contracts',
|
|
130
|
+
description: 'Read the confirmed API contracts (OpenAPI fragments) of the project — the backend owner confirmed these, and frontend and backend must follow them exactly. ' +
|
|
131
|
+
"The contract for your own task's feature is already in your prompt; use this when you need another feature's API (paths, request/response fields, error status codes).",
|
|
132
|
+
inputSchema: {
|
|
133
|
+
project_id: z.string().describe('Project id'),
|
|
134
|
+
feature_key: z.string().optional().describe('Only this feature (e.g. F-02)'),
|
|
135
|
+
},
|
|
136
|
+
}, async ({ project_id, feature_key }) => run(async () => {
|
|
137
|
+
const { contracts } = await client.readContracts(project_id);
|
|
138
|
+
const picked = feature_key === undefined ? contracts : contracts.filter((c) => c.featureKey === feature_key);
|
|
139
|
+
return { contracts: picked.map((c) => ({ featureKey: c.featureKey, title: c.specTitle, summary: c.summary, openapiYaml: c.schemaYaml })) };
|
|
140
|
+
}, 'read_contracts'));
|
|
128
141
|
server.registerTool('read_notes', {
|
|
129
142
|
title: 'Read handover notes',
|
|
130
143
|
description: 'Read handover notes for the project. Notes relevant to your task are already in your prompt, so use this only when you need more — older notes, or notes for a different feature.',
|
|
@@ -90,6 +90,10 @@ export class NomosClient {
|
|
|
90
90
|
async submitDraft(questionId, draft) {
|
|
91
91
|
return (await this.request('POST', `/api/questions/${questionId}/draft`, draft));
|
|
92
92
|
}
|
|
93
|
+
// 확정한 API 계약(에이전트 — MCP read_contracts). FE가 연동할 API를 기능을 가리지 않고 찾는다.
|
|
94
|
+
async readContracts(projectId) {
|
|
95
|
+
return (await this.request('GET', `/api/projects/${projectId}/contracts`));
|
|
96
|
+
}
|
|
93
97
|
// 계약 작성(Executor, 023): BACKEND 에이전트가 쓸 계약 작업·확정한 계약·읽을 레포, 그리고 초안 제출·실패 보고.
|
|
94
98
|
async listContractJobs() {
|
|
95
99
|
return (await this.request('GET', '/api/agents/me/contract-jobs'));
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { nomosHome } from '../bridge/credentials.js';
|
|
5
|
+
export function daemonFile() {
|
|
6
|
+
return path.join(nomosHome(), 'daemon.json');
|
|
7
|
+
}
|
|
8
|
+
export function logDir() {
|
|
9
|
+
return path.join(nomosHome(), 'logs');
|
|
10
|
+
}
|
|
11
|
+
export function readDaemon() {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(daemonFile(), 'utf-8'));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// 그 pid의 프로세스가 살아 있는가(신호 0은 확인만 한다).
|
|
20
|
+
export function isAlive(pid) {
|
|
21
|
+
try {
|
|
22
|
+
process.kill(pid, 0);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
return err.code === 'EPERM';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// 살아 있는 백그라운드 connect. 죽었는데 파일만 남았으면 지우고 null.
|
|
30
|
+
export function runningDaemon() {
|
|
31
|
+
const info = readDaemon();
|
|
32
|
+
if (!info)
|
|
33
|
+
return null;
|
|
34
|
+
if (isAlive(info.pid))
|
|
35
|
+
return info;
|
|
36
|
+
rmSync(daemonFile(), { force: true });
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
// --background를 뺀 같은 인자로 connect를 분리된 자식으로 띄운다. 이 프로세스는 바로 끝나도 자식은 계속 돈다.
|
|
40
|
+
export function startDaemon(input) {
|
|
41
|
+
const existing = runningDaemon();
|
|
42
|
+
if (existing)
|
|
43
|
+
throw new Error(`이미 백그라운드에서 돌고 있다(pid ${existing.pid}, 서버 ${existing.server}). 끄려면 nomos stop`);
|
|
44
|
+
mkdirSync(logDir(), { recursive: true });
|
|
45
|
+
const logFile = path.join(logDir(), 'connect.log');
|
|
46
|
+
const fd = openSync(logFile, 'a');
|
|
47
|
+
const args = input.args.filter((a) => a !== '--background');
|
|
48
|
+
const child = spawn(process.execPath, [input.cliPath, 'connect', ...args], {
|
|
49
|
+
detached: true,
|
|
50
|
+
stdio: ['ignore', fd, fd],
|
|
51
|
+
windowsHide: true,
|
|
52
|
+
env: { ...process.env, NOMOS_BACKGROUND: '1' },
|
|
53
|
+
});
|
|
54
|
+
closeSync(fd);
|
|
55
|
+
if (child.pid === undefined)
|
|
56
|
+
throw new Error('백그라운드 프로세스를 띄우지 못했다');
|
|
57
|
+
child.unref();
|
|
58
|
+
const info = { pid: child.pid, startedAt: new Date().toISOString(), server: input.server, logFile, args };
|
|
59
|
+
mkdirSync(nomosHome(), { recursive: true });
|
|
60
|
+
writeFileSync(daemonFile(), JSON.stringify(info, null, 2), { mode: 0o600 });
|
|
61
|
+
return info;
|
|
62
|
+
}
|
|
63
|
+
export function stopDaemon() {
|
|
64
|
+
const info = runningDaemon();
|
|
65
|
+
if (!info)
|
|
66
|
+
return null;
|
|
67
|
+
try {
|
|
68
|
+
process.kill(info.pid);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// 이미 끝났다.
|
|
72
|
+
}
|
|
73
|
+
rmSync(daemonFile(), { force: true });
|
|
74
|
+
return info;
|
|
75
|
+
}
|
|
76
|
+
// 로그 끝부분(최대 n줄). 파일이 크면 끝 64KB만 읽는다.
|
|
77
|
+
export function tailLog(file, lines = 15) {
|
|
78
|
+
if (!existsSync(file))
|
|
79
|
+
return [];
|
|
80
|
+
const size = statSync(file).size;
|
|
81
|
+
const start = Math.max(0, size - 64 * 1024);
|
|
82
|
+
const fd = openSync(file, 'r');
|
|
83
|
+
try {
|
|
84
|
+
const buf = Buffer.alloc(size - start);
|
|
85
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
86
|
+
return buf.toString('utf-8').split(/\r?\n/).filter(Boolean).slice(-lines);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
closeSync(fd);
|
|
90
|
+
}
|
|
91
|
+
}
|
package/dist/executor/cli.js
CHANGED
|
@@ -27,6 +27,7 @@ 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
31
|
import { deviceLogin } from './device-login.js';
|
|
31
32
|
import { cliVersion, mcpServerPath } from './paths.js';
|
|
32
33
|
import { checkTools } from './preflight.js';
|
|
@@ -400,7 +401,7 @@ async function postJson(url, body) {
|
|
|
400
401
|
return json.data;
|
|
401
402
|
}
|
|
402
403
|
function parseArgs(argv) {
|
|
403
|
-
const out = { positional: undefined, server: undefined, name: undefined, code: undefined, connectKey: false, json: false };
|
|
404
|
+
const out = { positional: undefined, server: undefined, name: undefined, code: undefined, connectKey: false, json: false, background: false };
|
|
404
405
|
for (let i = 0; i < argv.length; i += 1) {
|
|
405
406
|
const arg = argv[i];
|
|
406
407
|
if (arg === '--name')
|
|
@@ -411,6 +412,8 @@ function parseArgs(argv) {
|
|
|
411
412
|
out.connectKey = true;
|
|
412
413
|
else if (arg === '--code')
|
|
413
414
|
out.code = argv[(i += 1)];
|
|
415
|
+
else if (arg === '--background')
|
|
416
|
+
out.background = true;
|
|
414
417
|
else if (arg === '--json')
|
|
415
418
|
out.json = true;
|
|
416
419
|
else
|
|
@@ -490,6 +493,19 @@ async function connectCommand(argv) {
|
|
|
490
493
|
log(`${t.name}을 실행할 수 없다: ${t.detail}\n → ${t.hint}`);
|
|
491
494
|
throw new Error('필요한 도구가 없어 시작하지 않는다');
|
|
492
495
|
}
|
|
496
|
+
// 백그라운드(창 없이) — 같은 인자로 connect를 분리된 자식으로 띄우고 여기서 끝난다. 브라우저 승인 로그인은 화면이 필요하므로
|
|
497
|
+
// 이 서버의 자격 증명이 없으면 --code(1회용 연결 코드)와 함께만 받는다.
|
|
498
|
+
if (args.background) {
|
|
499
|
+
const saved = readCredentials();
|
|
500
|
+
if (!args.code && (!saved || saved.baseUrl !== server)) {
|
|
501
|
+
throw new Error('백그라운드로 띄우려면 먼저 한 번 connect로 로그인하거나, 웹의 연결 명령(--code)에 --background를 붙여라');
|
|
502
|
+
}
|
|
503
|
+
const info = startDaemon({ cliPath: process.argv[1], args: argv, server });
|
|
504
|
+
log(`백그라운드에서 실행한다 — pid ${info.pid}. 창을 닫아도 계속 돈다`);
|
|
505
|
+
log(`로그: ${info.logFile}`);
|
|
506
|
+
log('상태: nomos status · 끄기: nomos stop · 진행 상황은 웹의 룸에서 본다');
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
493
509
|
// 배정이 풀리면(해제·프로젝트 중단) 창을 닫지 않고 다시 배정을 기다린다. 연결 해제면 멈춘다.
|
|
494
510
|
// 1회용 코드는 처음 한 번만 쓴다 — 그 뒤로는 그 코드로 받은 자격 증명이 저장돼 있다.
|
|
495
511
|
let code = args.code?.trim() || undefined;
|
|
@@ -575,6 +591,17 @@ async function pmWorker() {
|
|
|
575
591
|
await new Promise((resolve) => setTimeout(resolve, PM_POLL_INTERVAL_MS));
|
|
576
592
|
}
|
|
577
593
|
}
|
|
594
|
+
function daemonStatus() {
|
|
595
|
+
const info = runningDaemon();
|
|
596
|
+
if (!info) {
|
|
597
|
+
log('백그라운드에서 돌고 있는 connect가 없다 (켜려면 connect --background)');
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
log(`실행 중 — pid ${info.pid}, ${info.startedAt}부터, 서버 ${info.server}`);
|
|
601
|
+
log(`로그: ${info.logFile}`);
|
|
602
|
+
for (const line of tailLog(info.logFile))
|
|
603
|
+
process.stdout.write(` ${line}\n`);
|
|
604
|
+
}
|
|
578
605
|
async function main() {
|
|
579
606
|
const command = process.argv[2];
|
|
580
607
|
if (command === 'connect')
|
|
@@ -587,6 +614,13 @@ async function main() {
|
|
|
587
614
|
}
|
|
588
615
|
if (command === 'login')
|
|
589
616
|
return login(process.argv.slice(3));
|
|
617
|
+
if (command === 'status')
|
|
618
|
+
return daemonStatus();
|
|
619
|
+
if (command === 'stop') {
|
|
620
|
+
const stopped = stopDaemon();
|
|
621
|
+
log(stopped ? `백그라운드 connect를 껐다(pid ${stopped.pid})` : '백그라운드에서 돌고 있는 connect가 없다');
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
590
624
|
if (command === 'refresh')
|
|
591
625
|
return refresh();
|
|
592
626
|
if (command === 'once')
|
|
@@ -604,7 +638,8 @@ async function main() {
|
|
|
604
638
|
}
|
|
605
639
|
process.stderr.write([
|
|
606
640
|
'사용법: nomos <명령>',
|
|
607
|
-
' connect [--code 코드] [--server 주소] [--name 이름] 로그인 → 배정 대기 → 작업 시작 (--code: 웹이 준 1회용 코드,
|
|
641
|
+
' connect [--code 코드] [--server 주소] [--name 이름] [--background] 로그인 → 배정 대기 → 작업 시작 (--code: 웹이 준 1회용 코드, --background: 창 없이)',
|
|
642
|
+
' status | stop 백그라운드 connect 상태 보기 · 끄기',
|
|
608
643
|
' doctor 설치 상태 점검',
|
|
609
644
|
' login [주소] [--name 이름] [--connect-key] 로그인만',
|
|
610
645
|
' refresh | once | start | clean | --version',
|
package/dist/executor/prompt.js
CHANGED
|
@@ -15,12 +15,15 @@ export function buildTaskPrompt(briefing, branch, environment) {
|
|
|
15
15
|
// 확정한 API 계약 — BE 담당이 확정했고 FE·BE가 같은 것을 받는다. 다르게 만들면 서로 붙지 않는다.
|
|
16
16
|
if (briefing.contract) {
|
|
17
17
|
sections.push([
|
|
18
|
-
|
|
18
|
+
task.teamRole === 'FRONTEND'
|
|
19
|
+
? `# 확정한 API 계약 (${briefing.contract.featureKey}) — 백엔드 담당이 확정했다. 화면에서 이 경로·메서드로 호출하고, 응답 필드·에러 상태 코드를 이대로 처리한다(API 연동)`
|
|
20
|
+
: `# 확정한 API 계약 (${briefing.contract.featureKey}) — 백엔드 담당이 확정했다. 경로·메서드·필드·상태 코드를 이대로 구현한다`,
|
|
19
21
|
briefing.contract.summary,
|
|
20
22
|
'```yaml',
|
|
21
23
|
briefing.contract.schemaYaml.trimEnd(),
|
|
22
24
|
'```',
|
|
23
25
|
'계약을 바꿔야 할 이유가 생기면 계약과 다르게 구현하지 말고, 그 사실을 GOTCHA 노트로 남긴다.',
|
|
26
|
+
'다른 기능의 API가 필요하면 read_contracts 도구로 확정한 계약을 찾는다.',
|
|
24
27
|
].join('\n'));
|
|
25
28
|
}
|
|
26
29
|
// 직전 제출이 반려됐다 — 같은 태스크 브랜치에 이전 작업이 남아 있으니 이어서 고친다.
|
|
@@ -39,6 +42,14 @@ export function buildTaskPrompt(briefing, branch, environment) {
|
|
|
39
42
|
'이 브랜치에 이전 작업이 남아 있으면 이어서 진행한다. 같은 질문을 다시 묻지 않는다.',
|
|
40
43
|
].join('\n'));
|
|
41
44
|
}
|
|
45
|
+
// 사람(그 역할 담당·대표)이 룸에서 보낸 메시지 — 이번 실행이 시작되기 전에 온 것만 들어온다(실행 중에는 끼워 넣지 않는다).
|
|
46
|
+
if (briefing.humanMessages && briefing.humanMessages.length > 0) {
|
|
47
|
+
sections.push([
|
|
48
|
+
'# 담당자가 보낸 메시지 (팀의 사람이 이 역할 에이전트에게 남긴 지시 — 오래된 것부터)',
|
|
49
|
+
...briefing.humanMessages.map((m) => `- ${m.fromNickname ?? '팀원'}${m.fromRepresentative ? '(대표)' : ''}: ${m.text}`),
|
|
50
|
+
'태스크 범위 안에서 반영한다. 수정할 수 있는 경로·금지 규칙은 메시지와 상관없이 그대로다 — 따를 수 없으면 이유를 마지막 메시지에 적는다.',
|
|
51
|
+
].join('\n'));
|
|
52
|
+
}
|
|
42
53
|
if (briefing.notesBlock) {
|
|
43
54
|
sections.push(briefing.notesBlock);
|
|
44
55
|
}
|