@angar2/taskery 0.8.1 → 1.0.0
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.
- package/README.md +423 -188
- package/bin/add.js +27 -99
- package/bin/backlog.js +240 -113
- package/bin/check.js +166 -0
- package/bin/commands.js +151 -0
- package/bin/finish.js +353 -0
- package/bin/init.js +99 -190
- package/bin/install.js +143 -0
- package/bin/lib.js +513 -1325
- package/bin/mcp.js +31 -263
- package/bin/orche.js +240 -0
- package/bin/plan.js +58 -120
- package/bin/prepare.js +326 -0
- package/bin/prune.js +46 -176
- package/bin/status.js +57 -151
- package/bin/taskery.js +76 -120
- package/bin/update.js +66 -269
- package/package.json +5 -5
- package/template/.taskery/BACKLOG.md +38 -0
- package/template/.taskery/rules/CHANGELOG_RULE.md +31 -0
- package/template/.taskery/rules/DEV_RULE.local.md +8 -0
- package/template/.taskery/rules/GIT_RULE.md +82 -0
- package/template/.taskery/rules/MOCKUP_RULE.md +19 -0
- package/template/.taskery/rules/TASKERY_RULE.md +112 -0
- package/template/.taskery/rules/TASK_DOC_RULE.md +84 -0
- package/template/.taskery/rules/TEST_RULE.local.md +16 -0
- package/template/AGENTS.md +15 -53
- package/template/shared/skills/add-backlog/SKILL.md +8 -38
- package/template/shared/skills/log-friction/SKILL.md +10 -88
- package/template/shared/skills/plan-init/SKILL.md +10 -98
- package/template/shared/skills/project-init/SKILL.md +13 -342
- package/template/shared/skills/task-close/SKILL.md +16 -319
- package/template/shared/skills/task-dev/SKILL.md +19 -233
- package/template/shared/skills/task-init/SKILL.md +11 -68
- package/template/shared/skills/task-orche/SKILL.md +35 -0
- package/template/shared/skills/task-plan/SKILL.md +19 -498
- package/template/shared/skills/task-test/SKILL.md +23 -342
- package/bin/close.js +0 -86
- package/bin/fork.js +0 -112
- package/bin/legacy-quotes.js +0 -60
- package/bin/migrate.js +0 -481
- package/bin/set-status.js +0 -98
- package/template/.claude/hooks/closed-immutable.sh +0 -77
- package/template/.claude/settings.json +0 -18
- package/template/.claude/skills/run-team/SKILL.md +0 -145
- package/template/.codex/agents/task-tester.toml +0 -41
- package/template/.codex/agents/test-plan-author.toml +0 -34
- package/template/.codex/config.toml +0 -17
- package/template/.codex/hooks/closed-immutable.sh +0 -66
- package/template/.project/changelog/.gitkeep +0 -0
- package/template/.project/flows/.gitkeep +0 -0
- package/template/.project/plans/.gitkeep +0 -0
- package/template/.project/rules/CHANGELOG_RULE.md +0 -97
- package/template/.project/rules/GIT_RULE.md +0 -325
- package/template/.project/rules/MOCKUP_RULE.md +0 -86
- package/template/.project/rules/TASKERY_RULE.md +0 -237
- package/template/.project/rules/TASK_DOC_RULE.md +0 -668
- package/template/.project/shared/received/completed/.gitkeep +0 -0
- package/template/.project/shared/sent/completed/.gitkeep +0 -0
- package/template/.project/tasks/.gitkeep +0 -0
- package/template/shared/hooks/git-guard.sh +0 -108
package/bin/mcp.js
CHANGED
|
@@ -1,280 +1,48 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// taskery MCP 서버 —
|
|
3
|
-
/**
|
|
4
|
-
* bin/mcp.js
|
|
5
|
-
* `npx @angar2/taskery mcp` — stdio MCP 서버. Claude(.mcp.json) / Codex(codex mcp add) 등록.
|
|
6
|
-
*
|
|
7
|
-
* 도구 8종 = CLI 명령과 동일한 lib.js 함수의 다른 입구(네이티브 구조화 인자/반환).
|
|
8
|
-
* backlog_add / backlog_get / backlog_mark — 활성 plan BACKLOG.md 조작
|
|
9
|
-
* set_status — task 헤더 상태 전이 (7×7 유효전이 검증, closed 거부)
|
|
10
|
-
* plan_init — plan 생성 (채번+골격+manifest.activePlan 갱신)
|
|
11
|
-
* task_init — task 분기 (fork 원자 채번 + §1.3 골격 자동 생성)
|
|
12
|
-
* task_close — close 결정적 준비만 (Phase커밋·status=closed·마커). 비가역 머지/정리는 스킬.
|
|
13
|
-
* status — 멀티세션 현황 + 활성 plan
|
|
14
|
-
*
|
|
15
|
-
* SDK(@modelcontextprotocol/sdk)는 dual CJS/ESM — require 조건으로 CJS 빌드 해결(검증 완료).
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
const path = require('path');
|
|
2
|
+
// taskery MCP 서버 — 명령 정의표(commands.js)의 명령을 같은 이름·같은 인자의 MCP 도구로 띄운다
|
|
19
3
|
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
20
4
|
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
21
5
|
const { z } = require('zod');
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
return mainWt;
|
|
6
|
+
const { COMMANDS, execute } = require('./commands');
|
|
7
|
+
const L = require('./lib');
|
|
8
|
+
|
|
9
|
+
// 서버를 띄운 폴더의 본진을 처음에 한 번 정한다 — 세션이 워크트리를 오가도, 워크트리가 지워져도 같은 본진을 쓴다
|
|
10
|
+
const startCwd = process.cwd();
|
|
11
|
+
let main = null;
|
|
12
|
+
try {
|
|
13
|
+
main = L.findMain(startCwd);
|
|
14
|
+
} catch (e) {
|
|
15
|
+
main = null;
|
|
34
16
|
}
|
|
35
17
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
18
|
+
function schemaOf(cmd) {
|
|
19
|
+
const shape = {};
|
|
20
|
+
for (const a of cmd.args) {
|
|
21
|
+
let t;
|
|
22
|
+
if (a.type === 'bool') t = z.boolean();
|
|
23
|
+
else if (a.type === 'list') t = z.array(z.string());
|
|
24
|
+
else t = z.union([z.string(), z.number()]).transform(String);
|
|
25
|
+
shape[a.name] = t.optional().describe(a.desc);
|
|
26
|
+
}
|
|
27
|
+
return shape;
|
|
40
28
|
}
|
|
41
29
|
|
|
42
|
-
|
|
43
|
-
function resolveTaskDoc(mainWt, taskNum) {
|
|
44
|
-
const active = lib.getActiveTasks(mainWt).find((t) => t.taskNum === taskNum);
|
|
45
|
-
const wtPath = active
|
|
46
|
-
? lib.getWorktreePath(lib.getProjectId(mainWt), {
|
|
47
|
-
taskNum,
|
|
48
|
-
src: active.src,
|
|
49
|
-
slug: active.slug,
|
|
50
|
-
})
|
|
51
|
-
: mainWt;
|
|
52
|
-
return lib.resolveTaskDocPath(mainWt, wtPath, { taskNum });
|
|
53
|
-
}
|
|
30
|
+
const server = new McpServer({ name: 'taskery', version: L.getPackageVersion() });
|
|
54
31
|
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return { content: [{ type: 'text', text }] };
|
|
59
|
-
}
|
|
60
|
-
function fail(message) {
|
|
61
|
-
return { content: [{ type: 'text', text: `오류: ${message}` }], isError: true };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// 핸들러 공통 래퍼 — 예외를 isError 응답으로 변환(서버 크래시 방지).
|
|
65
|
-
function handler(fn) {
|
|
66
|
-
return async (args) => {
|
|
32
|
+
// wait-reports는 CLI만 — MCP 도구 호출은 결과가 올 때까지 세션을 붙잡는다(§5-3)
|
|
33
|
+
for (const cmd of COMMANDS.filter((c) => !c.cliOnly)) {
|
|
34
|
+
server.registerTool(cmd.name, { description: cmd.summary, inputSchema: schemaOf(cmd) }, async (args) => {
|
|
67
35
|
try {
|
|
68
|
-
|
|
36
|
+
const out = await execute(cmd, args || {}, { cwd: startCwd, main: main || L.findMain(startCwd) });
|
|
37
|
+
return { content: [{ type: 'text', text: out || '완료' }] };
|
|
69
38
|
} catch (e) {
|
|
70
|
-
|
|
39
|
+
const text = e instanceof L.TaskeryError ? e.message : `taskery ${cmd.name} 실패: ${e.message}`;
|
|
40
|
+
return { content: [{ type: 'text', text }], isError: true };
|
|
71
41
|
}
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const server = new McpServer({ name: 'taskery', version: lib.getPackageVersion() });
|
|
76
|
-
|
|
77
|
-
server.registerTool(
|
|
78
|
-
'backlog_add',
|
|
79
|
-
{
|
|
80
|
-
description:
|
|
81
|
-
'활성 plan BACKLOG.md에 task 후보 1건 추가 (BL-NNN 채번 + withMetaLock 직렬화). 얕은 분석 메타를 인자로 받는다.',
|
|
82
|
-
inputSchema: {
|
|
83
|
-
type: z.enum(VALID_TYPES),
|
|
84
|
-
title: z.string().describe('한 줄 제목(한국어)'),
|
|
85
|
-
slug: z.string().describe('영어 kebab-case 슬러그'),
|
|
86
|
-
summary: z.string().describe('한 줄 개요'),
|
|
87
|
-
target: z.string().describe('한 줄 대상 영역'),
|
|
88
|
-
},
|
|
89
|
-
},
|
|
90
|
-
handler(async (a) => {
|
|
91
|
-
const mainWt = resolveMainWt();
|
|
92
|
-
const blNum = await lib.appendBacklogItem(mainWt, a);
|
|
93
|
-
const blId = `BL-${String(blNum).padStart(3, '0')}`;
|
|
94
|
-
return ok({ blNum, blId }, `${blId} 추가됨. 진행 의향이면 task_init이 이 메타로 워크트리를 분기한다.`);
|
|
95
|
-
}),
|
|
96
|
-
);
|
|
97
|
-
|
|
98
|
-
server.registerTool(
|
|
99
|
-
'backlog_get',
|
|
100
|
-
{
|
|
101
|
-
description:
|
|
102
|
-
'활성 plan BACKLOG.md에서 BL-NNN 항목 메타 조회 → { blId, status, type, title, slug, summary, target, taskNums }.',
|
|
103
|
-
inputSchema: { blId: z.string().regex(/^BL-\d+$/, 'BL-NNN 형식') },
|
|
104
|
-
},
|
|
105
|
-
handler(async (a) => {
|
|
106
|
-
const mainWt = resolveMainWt();
|
|
107
|
-
const item = lib.parseBacklogItem(mainWt, a.blId);
|
|
108
|
-
if (!item) throw new Error(`${a.blId} 항목 없음.`);
|
|
109
|
-
return ok(item);
|
|
110
|
-
}),
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
server.registerTool(
|
|
114
|
-
'backlog_mark',
|
|
115
|
-
{
|
|
116
|
-
description: 'BL-NNN을 [x] 확인 마킹 + TASK-NNN 연결 (withMetaLock). task_init 직후 호출.',
|
|
117
|
-
inputSchema: {
|
|
118
|
-
blId: z.string().regex(/^BL-\d+$/, 'BL-NNN 형식'),
|
|
119
|
-
task: z.union([z.string(), z.number()]).describe('TASK-NNN 또는 번호'),
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
|
-
handler(async (a) => {
|
|
123
|
-
const taskNum = parseTaskNum(a.task);
|
|
124
|
-
if (!Number.isInteger(taskNum)) throw new Error(`TASK 번호 파싱 실패 (받음: ${a.task}).`);
|
|
125
|
-
const mainWt = resolveMainWt();
|
|
126
|
-
await lib.markBacklogChecked(mainWt, a.blId, taskNum);
|
|
127
|
-
return ok({ blId: a.blId, taskNum });
|
|
128
|
-
}),
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
server.registerTool(
|
|
132
|
-
'set_status',
|
|
133
|
-
{
|
|
134
|
-
description:
|
|
135
|
-
'task 헤더 상태 전이 (TASK_DOC_RULE §1.2 7×7 유효전이 검증). 잘못된 전이 거부. closed 전이는 task_close가 담당하므로 여기선 거부.',
|
|
136
|
-
inputSchema: {
|
|
137
|
-
task: z.union([z.string(), z.number()]).describe('TASK-NNN 또는 번호'),
|
|
138
|
-
state: z.string().describe('전이 목표 상태'),
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
handler(async (a) => {
|
|
142
|
-
const taskNum = parseTaskNum(a.task);
|
|
143
|
-
if (!Number.isInteger(taskNum)) throw new Error(`TASK 번호 파싱 실패 (받음: ${a.task}).`);
|
|
144
|
-
if (!Object.prototype.hasOwnProperty.call(lib.TRANSITIONS, a.state)) {
|
|
145
|
-
throw new Error(`알 수 없는 상태: ${a.state} (유효: ${Object.keys(lib.TRANSITIONS).join(', ')})`);
|
|
146
|
-
}
|
|
147
|
-
if (a.state === 'closed') {
|
|
148
|
-
throw new Error(`closed 전이는 set_status가 아니라 task_close(close 파이프라인)가 담당.`);
|
|
149
|
-
}
|
|
150
|
-
const mainWt = resolveMainWt();
|
|
151
|
-
const resolved = resolveTaskDoc(mainWt, taskNum);
|
|
152
|
-
if (!resolved) throw new Error(`TASK-${String(taskNum).padStart(3, '0')} 문서를 찾지 못함.`);
|
|
153
|
-
const result = await lib.setStatus(resolved.file, a.state);
|
|
154
|
-
return ok({ taskNum, ...result });
|
|
155
|
-
}),
|
|
156
|
-
);
|
|
157
|
-
|
|
158
|
-
server.registerTool(
|
|
159
|
-
'plan_init',
|
|
160
|
-
{
|
|
161
|
-
description:
|
|
162
|
-
'plan(기능 그룹) 생성 — 채번 + 폴더 + ROADMAP/PLAN/BACKLOG 골격 + 활성 plan 갱신. 이후 골격 내용은 LLM이 채움. legacy 폴더 잔존 시 gated 반환(force로 재호출).',
|
|
163
|
-
inputSchema: {
|
|
164
|
-
slug: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, '영어 kebab-case'),
|
|
165
|
-
force: z.boolean().optional().describe('legacy 게이트 무시'),
|
|
166
|
-
},
|
|
167
|
-
},
|
|
168
|
-
handler(async (a) => {
|
|
169
|
-
const mainWt = resolveMainWt();
|
|
170
|
-
const result = lib.initPlan(mainWt, a.slug, { force: !!a.force });
|
|
171
|
-
if (result.gated) {
|
|
172
|
-
return ok(
|
|
173
|
-
{ gated: true, legacyDirs: result.legacyDirs },
|
|
174
|
-
`plans/에 NNN 채번 아닌 폴더(${result.legacyDirs.join(', ')}) 잔존 — 사용자 confirm 후 force:true로 재호출.`,
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
return ok(
|
|
178
|
-
{ plan: result.plan, nnn: result.nnn, planDir: result.planDir, tasksDir: result.tasksDir },
|
|
179
|
-
`골격 생성됨. ROADMAP Stage 내용·PLAN 링크·FEATURES/UX-UI delta는 placeholder를 채운다.`,
|
|
180
|
-
);
|
|
181
|
-
}),
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
server.registerTool(
|
|
185
|
-
'task_init',
|
|
186
|
-
{
|
|
187
|
-
description:
|
|
188
|
-
'task 분기 — 채번+워크트리+브랜치 생성을 init 락으로 원자 실행. size·title 주면 §1.3 골격 task.md도 자동 생성(생성일·status=draft 자동). 통상 /task-init 경유.',
|
|
189
|
-
inputSchema: {
|
|
190
|
-
type: z.enum(VALID_TYPES),
|
|
191
|
-
dev: z.string().describe('개발자 식별자 (claude / angar2 등)'),
|
|
192
|
-
src: z.string().regex(/^(BL-\d+|RM-\d+|ST-\d+|DR)$/, 'BL-NNN | ST-N(로드맵 Stage) | DR (구형 RM-NNN 호환)'),
|
|
193
|
-
slug: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, '영어 kebab-case'),
|
|
194
|
-
size: z.enum(VALID_SIZES).optional(),
|
|
195
|
-
title: z.string().optional().describe('한국어 제목 (size와 함께 주면 골격 생성)'),
|
|
196
|
-
promote: z.boolean().optional(),
|
|
197
|
-
},
|
|
198
|
-
},
|
|
199
|
-
handler(async (a) => {
|
|
200
|
-
const mainWt = resolveMainWt();
|
|
201
|
-
const result = await lib.forkTask(mainWt, {
|
|
202
|
-
type: a.type,
|
|
203
|
-
dev: a.dev,
|
|
204
|
-
src: a.src,
|
|
205
|
-
slug: a.slug,
|
|
206
|
-
});
|
|
207
|
-
let out = result;
|
|
208
|
-
if (a.size && a.title) {
|
|
209
|
-
try {
|
|
210
|
-
const doc = lib.scaffoldTaskDoc(mainWt, result.wtPath, {
|
|
211
|
-
taskNum: result.taskNum,
|
|
212
|
-
slug: a.slug,
|
|
213
|
-
title: a.title,
|
|
214
|
-
type: a.type,
|
|
215
|
-
size: a.size,
|
|
216
|
-
parent: result.parent,
|
|
217
|
-
promoted: !!a.promote,
|
|
218
|
-
});
|
|
219
|
-
out = { ...result, docPath: doc.file, registered: doc.registered, promoted: doc.promoted };
|
|
220
|
-
} catch (e) {
|
|
221
|
-
out = { ...result, scaffoldError: e.message };
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
return ok(out, `워크트리 생성됨. 제목 확인 후 /task-plan으로 진행.`);
|
|
225
|
-
}),
|
|
226
|
-
);
|
|
227
|
-
|
|
228
|
-
server.registerTool(
|
|
229
|
-
'task_close',
|
|
230
|
-
{
|
|
231
|
-
description:
|
|
232
|
-
'close 결정적 준비만 — Phase 커밋·status=closed·문서 커밋·추적마커. 게이트(status=tested)·매핑 모호 시 blocked 반환. 반환 parent = 스킬 머지 대상 부모 브랜치. 비가역(부모 --no-ff 머지·worktree/branch 정리·충돌 해결)은 스킬(task-close)이 후속 수행.',
|
|
233
|
-
inputSchema: { task: z.union([z.string(), z.number()]).describe('TASK-NNN 또는 번호') },
|
|
234
|
-
},
|
|
235
|
-
handler(async (a) => {
|
|
236
|
-
const taskNum = parseTaskNum(a.task);
|
|
237
|
-
if (!Number.isInteger(taskNum)) throw new Error(`TASK 번호 파싱 실패 (받음: ${a.task}).`);
|
|
238
|
-
const mainWt = resolveMainWt();
|
|
239
|
-
const result = await lib.closeTask(mainWt, { taskNum });
|
|
240
|
-
if (result.blocked === 'status') {
|
|
241
|
-
return ok(result, `게이트 차단 — status=${result.current} (tested 아님). /task-test 통과 후 재호출.`);
|
|
242
|
-
}
|
|
243
|
-
if (result.blocked === 'mapping') {
|
|
244
|
-
return ok(
|
|
245
|
-
result,
|
|
246
|
-
`Phase↔파일 매핑 모호 (${result.reason}) — Dev Plan '파일' 필드 확인 후 수동 Phase 커밋 → task_close 재호출.`,
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
return ok(result, `prep 완료 (부모=${result.parent}). 비가역(사전 rebase·충돌해결·머지 락·부모 --no-ff 머지·정리)은 task-close 스킬이 후속 수행.`);
|
|
250
|
-
}),
|
|
251
|
-
);
|
|
252
|
-
|
|
253
|
-
server.registerTool(
|
|
254
|
-
'status',
|
|
255
|
-
{
|
|
256
|
-
description: '멀티세션 현황 — 진행중 task + 워크트리 + 활성 plan 버전.',
|
|
257
|
-
inputSchema: {},
|
|
258
|
-
},
|
|
259
|
-
handler(async () => {
|
|
260
|
-
const mainWt = resolveMainWt();
|
|
261
|
-
const activeTasks = lib.getActiveTasks(mainWt);
|
|
262
|
-
let activePlan = null;
|
|
263
|
-
try {
|
|
264
|
-
activePlan = lib.getActiveVersion(mainWt);
|
|
265
|
-
} catch (e) {
|
|
266
|
-
activePlan = null;
|
|
267
|
-
}
|
|
268
|
-
return ok({ activePlan, activeTasks });
|
|
269
|
-
}),
|
|
270
|
-
);
|
|
271
|
-
|
|
272
|
-
async function main() {
|
|
273
|
-
const transport = new StdioServerTransport();
|
|
274
|
-
await server.connect(transport);
|
|
42
|
+
});
|
|
275
43
|
}
|
|
276
44
|
|
|
277
|
-
|
|
45
|
+
server.connect(new StdioServerTransport()).catch((e) => {
|
|
278
46
|
console.error(`taskery mcp 실패: ${e.message}`);
|
|
279
47
|
process.exit(1);
|
|
280
48
|
});
|
package/bin/orche.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// orca-dispatch-task · report-task · wait-reports — 오케스트레이션의 띄우기·보고·보고 받기(§5-3, §5-4)
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { execFileSync } = require('child_process');
|
|
5
|
+
const L = require('./lib');
|
|
6
|
+
|
|
7
|
+
const AGENTS = ['claude', 'codex'];
|
|
8
|
+
// 보고 없이 기다리는 시간이자 '오래 조용한 탭'의 기준. 환경 변수는 시험 전용 통로다
|
|
9
|
+
const WAIT_MS = Number(process.env.TASKERY_WAIT_REPORTS_MS) || 10 * 60 * 1000;
|
|
10
|
+
const POLL_MS = Math.min(1000, WAIT_MS);
|
|
11
|
+
// 첫 지시문을 보낸 뒤 턴 시작을 지켜보는 시간(orca terminal send --wait-submit)
|
|
12
|
+
const SUBMIT_WAIT_SEC = 30;
|
|
13
|
+
|
|
14
|
+
function reportsFile(main) {
|
|
15
|
+
return path.join(main, '.taskery', 'reports.log');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readPosFile(main) {
|
|
19
|
+
return path.join(L.stateDir(main), 'reports-read.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// orca 명령을 --json으로 부르고 result를 돌려준다. 종료 코드가 0이 아니어도 JSON이 ok면 돌려준다(terminal wait 미충족)
|
|
23
|
+
function orca(args) {
|
|
24
|
+
let out = '';
|
|
25
|
+
let err = '';
|
|
26
|
+
try {
|
|
27
|
+
out = execFileSync('orca', [...args, '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
28
|
+
} catch (e) {
|
|
29
|
+
out = (e.stdout || '').toString();
|
|
30
|
+
err = ((e.stderr || '').toString().trim() || e.message).split('\n')[0];
|
|
31
|
+
}
|
|
32
|
+
let j = null;
|
|
33
|
+
try {
|
|
34
|
+
j = JSON.parse(out);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
j = null;
|
|
37
|
+
}
|
|
38
|
+
if (j && j.ok) return j.result;
|
|
39
|
+
const why = (j && j.error && (j.error.message || j.error.code)) || err || out.trim() || '알 수 없는 오류';
|
|
40
|
+
throw new L.TaskeryError(`orca ${args.slice(0, 2).join(' ')} 실패: ${why}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 셸에 그대로 넘길 수 있게 감싼다(--command는 탭의 셸에 입력된다)
|
|
44
|
+
function shq(v) {
|
|
45
|
+
return /^[A-Za-z0-9_.\/:=@%+-]+$/.test(v) ? v : `'${String(v).replace(/'/g, `'\\''`)}'`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 에이전트 실행 명령 — 권한·승인 옵션은 붙이지 않는다(사용자 설정 그대로, I5)
|
|
49
|
+
function agentCommand(main, agent, model) {
|
|
50
|
+
if (agent === 'claude') return `claude --model ${shq(model)}`;
|
|
51
|
+
return `codex -m ${shq(model)} --add-dir ${shq(path.join(main, '.taskery'))}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 단계 표에서 비어 있는 첫 단계의 스킬 — 새 태스크면 task-plan
|
|
55
|
+
function startSkill(st) {
|
|
56
|
+
if (!st.approve) return 'task-plan';
|
|
57
|
+
if (L.sw(st, 'dev') && !st.testCode) return 'task-dev';
|
|
58
|
+
if (L.sw(st, 'test') && !st.testDone) return 'task-test';
|
|
59
|
+
return 'task-close';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function oneLine(text) {
|
|
63
|
+
return String(text).replace(/\s*\n\s*/g, ' ').trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 첫 지시문 틀(§5-4) — 마지막 줄은 --note를 줬을 때만
|
|
67
|
+
function firstInstruction(st, note) {
|
|
68
|
+
const label = L.taskLabel(st.num);
|
|
69
|
+
const lines = [
|
|
70
|
+
`[오케스트레이션] taskery 태스크 ${label} 「${st.title}」를 맡는다. 이 세션은 태스크 세션이다.`,
|
|
71
|
+
`- 태스크 문서: ${st.doc} — 먼저 읽는다.`,
|
|
72
|
+
`- 시작: ${startSkill(st)}부터 한다. 태스크 만들기(task-init)와 워크트리 준비는 끝났다.`,
|
|
73
|
+
`- 진행 범위: ${st.range}. 범위와 무관하게 사용자 확인·FAIL·풀지 못한 충돌에서는 멈춘다.`,
|
|
74
|
+
`- 보고: 마무리 완료(병합, 병합이 없는 태스크는 merge-task가 건너뛴 뒤)·사용자 확인 대기·질문·막힘 때 report-task ${label} "<한 줄>"을 부른다.`,
|
|
75
|
+
'- 마무리(merge-task)까지 끝나면 보고하고 멈춘다. 워크트리 정리와 태스크 닫기는 오케스트레이션이 한다.',
|
|
76
|
+
];
|
|
77
|
+
if (note && oneLine(note)) lines.push(`- 오케스트레이션이 전하는 말: ${oneLine(note)}`);
|
|
78
|
+
return lines.join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 지시문을 보내고 턴 시작이 관측됐는지 돌려준다. 받지 않았으면 refused. 관측할 수 없는 곳(영수증 없음·provider가 보고 못 함)은 보낸 것으로 본다
|
|
82
|
+
function sendPrompt(handle, text) {
|
|
83
|
+
const r = orca(['terminal', 'send', '--terminal', handle, '--text', text, '--enter', '--wait-submit', String(SUBMIT_WAIT_SEC)]);
|
|
84
|
+
const send = (r && r.send) || {};
|
|
85
|
+
if (send.accepted === false) return { refused: send.refusedReason || '거부' };
|
|
86
|
+
const p = send.prompt;
|
|
87
|
+
return { observed: !p || (p.stages || []).includes('turn_started') || ['unsupported', 'old-host'].includes(p.provider) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 탭 화면에 첫 지시문 첫머리가 보이나 — 보이면 들어간 것이라 다시 보내지 않는다
|
|
91
|
+
function onScreen(handle, marker) {
|
|
92
|
+
try {
|
|
93
|
+
const r = orca(['terminal', 'read', '--terminal', handle, '--screen']);
|
|
94
|
+
const screen = ((r && r.terminal && r.terminal.tail) || []).join('').replace(/\s+/g, '');
|
|
95
|
+
return screen.includes(marker.replace(/\s+/g, ''));
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function orcaDispatchTask(ctx, a) {
|
|
102
|
+
const main = ctx.main;
|
|
103
|
+
if (!L.inOrca()) {
|
|
104
|
+
L.fail(
|
|
105
|
+
'orca-dispatch-task: Orca 탭 안이 아니라 태스크 세션을 띄울 수 없다(Orca 전용 — Orca 환경 변수 ORCA_TERMINAL_HANDLE이 없다). ' +
|
|
106
|
+
'Orca 탭에서 연 오케스트레이션 세션에서 부르거나, 사용자가 그 태스크의 워크트리에서 직접 세션을 연다.',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
L.requireInstalled(main);
|
|
110
|
+
const missing = [];
|
|
111
|
+
if (!a.task) missing.push('<task> — 태스크 번호 (첫 번째 인자)');
|
|
112
|
+
if (!a.agent) missing.push(`--agent — ${AGENTS.join(' · ')}`);
|
|
113
|
+
if (!a.model) missing.push('--model — 에이전트 모델 (사용자가 정한 것)');
|
|
114
|
+
if (missing.length) L.fail(`orca-dispatch-task: 빠진 입력이 있다.\n${missing.map((m) => `- ${m}`).join('\n')}`);
|
|
115
|
+
if (!AGENTS.includes(a.agent)) L.fail(`orca-dispatch-task: --agent '${a.agent}'을 모른다. ${AGENTS.join(' · ')} 중 하나를 넣는다.`);
|
|
116
|
+
const num = L.parseTaskNum(a.task);
|
|
117
|
+
const st = L.readState(main, num);
|
|
118
|
+
const label = L.taskLabel(num);
|
|
119
|
+
if (st.closed) L.fail(`${label}은 이미 닫혔다.`);
|
|
120
|
+
const dir = L.workDir(main, st);
|
|
121
|
+
if (!fs.existsSync(dir)) L.fail(`orca-dispatch-task: ${label}의 워크트리가 없다(${dir}). 'status'로 확인한다.`);
|
|
122
|
+
|
|
123
|
+
const command = agentCommand(main, a.agent, String(a.model));
|
|
124
|
+
const created = orca(['terminal', 'create', '--worktree', `path:${dir}`, '--title', label, '--command', command]);
|
|
125
|
+
const handle = created && created.terminal && created.terminal.handle;
|
|
126
|
+
if (!handle) L.fail(`orca-dispatch-task: ${label} 탭을 열었지만 Orca가 탭 handle을 돌려주지 않았다. 'orca terminal list'로 확인한다.`);
|
|
127
|
+
|
|
128
|
+
// 탭 handle을 메타에 기록한다 — 첫 지시문을 보내기 전이라 태스크 세션의 기록과 겹치지 않는다
|
|
129
|
+
const fresh = L.readState(main, num);
|
|
130
|
+
fresh.tab = handle;
|
|
131
|
+
L.writeState(main, fresh);
|
|
132
|
+
L.syncDoc(main, fresh);
|
|
133
|
+
|
|
134
|
+
const text = firstInstruction(fresh, a.note);
|
|
135
|
+
const manual = `탭 화면을 'orca terminal read --terminal ${handle}'로 확인하고, 준비되면 아래 첫 지시문을 'orca terminal send --terminal ${handle} --text "<첫 지시문>" --enter'로 보낸다.\n--- 첫 지시문 ---\n${text}`;
|
|
136
|
+
let waited;
|
|
137
|
+
try {
|
|
138
|
+
waited = orca(['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle']);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
L.fail(`orca-dispatch-task: ${label} 탭(${handle})을 열었지만 준비를 기다리지 못해 첫 지시문을 보내지 않았다 — ${e.message}\n${manual}`);
|
|
141
|
+
}
|
|
142
|
+
if (waited && waited.wait && waited.wait.satisfied === false) {
|
|
143
|
+
const why = waited.wait.reason || waited.wait.status || '준비 대기 미충족';
|
|
144
|
+
L.fail(`orca-dispatch-task: ${label} 탭(${handle})을 열었지만 준비(tui-idle)가 되지 않아 첫 지시문을 보내지 않았다 — ${why}\n${manual}`);
|
|
145
|
+
}
|
|
146
|
+
const send = () => {
|
|
147
|
+
let r;
|
|
148
|
+
try {
|
|
149
|
+
r = sendPrompt(handle, text);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
L.fail(`orca-dispatch-task: ${label} 탭(${handle})에 첫 지시문을 보내지 못했다 — ${e.message}\n${manual}`);
|
|
152
|
+
}
|
|
153
|
+
if (r.refused) L.fail(`orca-dispatch-task: ${label} 탭(${handle})이 첫 지시문을 받지 않았다 — ${r.refused}\n${manual}`);
|
|
154
|
+
return r.observed;
|
|
155
|
+
};
|
|
156
|
+
// 턴 시작이 관측되지 않으면 화면을 보고, 지시문이 보이지 않을 때만 한 번 더 보낸다(준비 전 입력이 사라진 경우)
|
|
157
|
+
let sendNote = null;
|
|
158
|
+
if (!send()) {
|
|
159
|
+
if (onScreen(handle, text.split('\n')[0].split(' ').slice(0, 4).join(' '))) {
|
|
160
|
+
sendNote = '턴 시작은 관측되지 않았지만 탭 화면에 첫 지시문이 보인다 — 탭에서 진행을 확인한다.';
|
|
161
|
+
} else if (send()) {
|
|
162
|
+
sendNote = '첫 전송은 턴 시작이 관측되지 않고 화면에도 없어 한 번 더 보냈다.';
|
|
163
|
+
} else {
|
|
164
|
+
L.fail(`orca-dispatch-task: ${label} 탭(${handle})에 첫 지시문을 두 번 보냈지만 턴 시작이 관측되지 않았다.\n${manual}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return [
|
|
168
|
+
`${label} 태스크 세션을 띄웠다 — 탭 ${handle}`,
|
|
169
|
+
`- 실행: ${command}`,
|
|
170
|
+
`- 폴더: ${dir}`,
|
|
171
|
+
`- 첫 지시문을 보냈다(시작: ${startSkill(fresh)}, 범위: ${fresh.range}).`,
|
|
172
|
+
...(sendNote ? [`- 알림: ${sendNote}`] : []),
|
|
173
|
+
'다음: wait-reports를 백그라운드 셸로 걸어 둔다.',
|
|
174
|
+
].join('\n');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function reportTask(ctx, a) {
|
|
178
|
+
const main = ctx.main;
|
|
179
|
+
L.requireInstalled(main);
|
|
180
|
+
if (!a.task || !a.text || !oneLine(a.text)) L.fail('report-task: 태스크 번호와 보고 한 줄을 넣는다. 예: report-task TASK-012 "병합 완료"');
|
|
181
|
+
const line = `${L.clock(L.nowIso())} ${L.taskLabel(L.parseTaskNum(a.task))} ${oneLine(a.text)}`;
|
|
182
|
+
// 한 줄을 한 번의 append로 쓴다 — 여러 태스크 세션이 동시에 보고해도 줄이 섞이지 않는다
|
|
183
|
+
fs.appendFileSync(reportsFile(main), `${line}\n`);
|
|
184
|
+
return `보고를 남겼다: ${line}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function readPos(main) {
|
|
188
|
+
try {
|
|
189
|
+
return JSON.parse(fs.readFileSync(readPosFile(main), 'utf8')).offset || 0;
|
|
190
|
+
} catch (e) {
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 읽은 위치 뒤의 완성된 줄들을 읽고 위치를 옮긴다. 파일이 줄었으면(지웠으면) 처음부터 읽는다
|
|
196
|
+
function takeUnread(main) {
|
|
197
|
+
const file = reportsFile(main);
|
|
198
|
+
if (!fs.existsSync(file)) return [];
|
|
199
|
+
const buf = fs.readFileSync(file);
|
|
200
|
+
let pos = readPos(main);
|
|
201
|
+
if (pos > buf.length) pos = 0;
|
|
202
|
+
const end = buf.lastIndexOf(0x0a) + 1;
|
|
203
|
+
if (end <= pos) return [];
|
|
204
|
+
const lines = buf.slice(pos, end).toString('utf8').split('\n').filter((l) => l.trim());
|
|
205
|
+
L.writeJsonAtomic(readPosFile(main), { offset: end });
|
|
206
|
+
return lines;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 오래 조용한 태스크 탭 — 열린 태스크 중 탭 기록이 있고 마지막 출력이 기다린 시간보다 오래된 것
|
|
210
|
+
function quietTabs(main) {
|
|
211
|
+
const out = [];
|
|
212
|
+
for (const st of L.listStates(main)) {
|
|
213
|
+
if (st.closed || !st.tab) continue;
|
|
214
|
+
const label = L.taskLabel(st.num);
|
|
215
|
+
try {
|
|
216
|
+
const r = orca(['terminal', 'show', '--terminal', st.tab]);
|
|
217
|
+
const last = r && r.terminal && r.terminal.lastOutputAt;
|
|
218
|
+
if (last && Date.now() - last >= WAIT_MS) out.push(`- ${label} 탭 ${st.tab} — 마지막 출력 ${L.minutes(Date.now() - last)} 전`);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
out.push(`- ${label} 탭 ${st.tab} — 탭을 읽지 못했다(${e.message.replace(/^orca terminal show 실패: /, '')})`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function waitReports(ctx) {
|
|
227
|
+
const main = ctx.main;
|
|
228
|
+
L.requireInstalled(main);
|
|
229
|
+
const until = Date.now() + WAIT_MS;
|
|
230
|
+
for (;;) {
|
|
231
|
+
const lines = takeUnread(main);
|
|
232
|
+
if (lines.length) return lines.join('\n');
|
|
233
|
+
if (Date.now() >= until) break;
|
|
234
|
+
await new Promise((r) => setTimeout(r, Math.min(POLL_MS, Math.max(0, until - Date.now()))));
|
|
235
|
+
}
|
|
236
|
+
const quiet = quietTabs(main);
|
|
237
|
+
return [`보고 없이 ${L.minutes(WAIT_MS)}이 지났다.`, `오래 조용한 탭: ${quiet.length ? '' : '없음'}`.trimEnd(), ...quiet].join('\n');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = { orcaDispatchTask, reportTask, waitReports, firstInstruction };
|