@angar2/taskery 1.0.3 → 1.1.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 +7 -7
- package/bin/check.js +3 -4
- package/bin/commands.js +18 -6
- package/bin/finish.js +27 -30
- package/bin/hosts/herdr.js +212 -0
- package/bin/hosts/index.js +82 -0
- package/bin/hosts/orca.js +199 -0
- package/bin/lib.js +4 -4
- package/bin/orche.js +156 -134
- package/bin/prepare.js +8 -34
- package/package.json +1 -1
- package/template/.taskery/rules/TASKERY_RULE.md +7 -3
- package/template/shared/skills/task-orche/SKILL.md +10 -9
package/README.md
CHANGED
|
@@ -249,7 +249,7 @@ my-app/ ← 본진 (리포 원래 폴더, dev 브랜
|
|
|
249
249
|
- **본진** = 리포의 원래 폴더입니다. 끝난 태스크가 병합되어 들어오는 자리라서 그대로 둡니다.
|
|
250
250
|
- **부모 브랜치** = 본진이 서 있는 브랜치입니다. 태스크는 여기서 갈라져 나와 여기로 병합됩니다.
|
|
251
251
|
- 한 줄 수정도 작업 폴더를 만듭니다. 생략은 사용자가 직접 말할 때만 합니다.
|
|
252
|
-
-
|
|
252
|
+
- 작업 폴더는 어디서 쓰든 taskery가 `~/.taskery/worktrees/` 아래에 만듭니다. Orca·Herdr(탭을 여는 터미널 앱) 안에서 쓰면 그 앱의 목록에 부모 브랜치의 하위 항목으로 보입니다. Orca에서는 처음 쓸 때 저장소 설정 "밖에서 만든 워크트리 보이기"를 켭니다.
|
|
253
253
|
|
|
254
254
|
### 동시에 돌려도 섞이지 않는 이유
|
|
255
255
|
|
|
@@ -275,12 +275,12 @@ Node 프로젝트는 `package-lock.json`이 있으면 `npm ci`로 설치합니
|
|
|
275
275
|
|
|
276
276
|
## 오케스트레이션 — 한 줄 지시로 여러 태스크를 나눠 맡기기
|
|
277
277
|
|
|
278
|
-
**오케스트레이션** = 한 세션(관리 세션)이 백로그를 태스크로 나누고, 태스크마다 새 AI 탭을 띄워 맡기고, 보고를 받아 정리하는 방식입니다. Orca 안에서 씁니다.
|
|
278
|
+
**오케스트레이션** = 한 세션(관리 세션)이 백로그를 태스크로 나누고, 태스크마다 새 AI 탭을 띄워 맡기고, 보고를 받아 정리하는 방식입니다. 탭을 여는 터미널 앱(Orca·Herdr) 안에서 씁니다. 그런 앱을 쓸 수 없을 때는 사용자가 직접 세션을 열어 첫 지시를 붙여 넣는 방식으로도 진행할 수 있습니다.
|
|
279
279
|
|
|
280
280
|
```
|
|
281
281
|
👤 사용자
|
|
282
282
|
① 한 줄 지시 ↙ ↘ ⑥ 확인은 그 탭에서 직접 답
|
|
283
|
-
┌─ 🏠 본진 ───────────────┐ ┌─ 🪟 Orca
|
|
283
|
+
┌─ 🏠 본진 ───────────────┐ ┌─ 🪟 Orca · Herdr ──────────┐
|
|
284
284
|
│ 🎛 관리 세션 │ ③ ─────▶ │ TASK-107 탭 📁 + 🧠 Claude │
|
|
285
285
|
│ ↑ ② 읽고 나눔 │ ─────▶ │ TASK-108 탭 📁 + 🧠 Codex │
|
|
286
286
|
│ 📥 BACKLOG 🗂 PLAN │ ─────▶ │ TASK-109 탭 📁 + 🧠 Claude │
|
|
@@ -294,7 +294,7 @@ Node 프로젝트는 `package-lock.json`이 있으면 `npm ci`로 설치합니
|
|
|
294
294
|
|---|---|
|
|
295
295
|
| ① | 사용자가 관리 세션에 한 줄로 지시합니다. 예: "백로그 BL-1~3을 끝까지 진행해" |
|
|
296
296
|
| ② | 관리 세션이 백로그와 `PLAN.md`를 읽고 태스크로 나눕니다. 어떤 태스크를 어떤 순서와 AI로 진행할지 표로 보여 주고 확인받습니다. 지시에 이미 정해져 있으면 다시 묻지 않습니다 |
|
|
297
|
-
| ③ | 태스크마다 작업 폴더를 만들고(`prepare-task`),
|
|
297
|
+
| ③ | 태스크마다 작업 폴더를 만들고(`prepare-task`), 터미널 앱의 새 탭에 Claude나 Codex를 띄워 첫 지시를 보냅니다(`dispatch-task`). 첫 지시는 늘 같은 틀(기본 정보·작업 방식·이번 태스크 지시)로 나갑니다. AI가 폴더 신뢰 같은 확인 창에서 멈추면 관리 세션이 그 탭에서 확인해 달라고 알리고, 확인이 끝나면 첫 지시를 이어 보냅니다. 탭을 띄울 수 없거나 사용자가 원하면, 관리 세션이 첫 지시를 보여 주고 사용자가 직접 연 세션에 붙여 넣습니다 |
|
|
298
298
|
| ④ | 각 탭이 자기 작업 폴더에서 기획부터 마무리까지 진행합니다. 끝났을 때, 확인을 기다릴 때, 질문이 있을 때, 막혔을 때 보고함에 한 줄 남깁니다(`report-task`) |
|
|
299
299
|
| ⑤ | 관리 세션은 기다리는 동안 잠들어 있어 토큰을 쓰지 않습니다(`wait-reports`). 보고가 오거나 60분이 지나면 깨어나 처리합니다 |
|
|
300
300
|
| ⑥ | 사용자 확인이 필요하면 관리 세션이 "TASK-108 탭에서 화면 확인해 주세요"라고 알립니다. 사용자는 그 탭에서 직접 답합니다 |
|
|
@@ -345,7 +345,7 @@ Node 프로젝트는 `package-lock.json`이 있으면 `npm ci`로 설치합니
|
|
|
345
345
|
|
|
346
346
|
## 빠른 시작
|
|
347
347
|
|
|
348
|
-
> **준비물**: Node.js 18 이상, git 2.31 이상, 위 두 에이전트 중 하나 이상. 오케스트레이션은 Orca
|
|
348
|
+
> **준비물**: Node.js 18 이상, git 2.31 이상, 위 두 에이전트 중 하나 이상. 오케스트레이션은 Orca나 Herdr 안에서 씁니다(둘 다 없으면 태스크 세션을 직접 엽니다).
|
|
349
349
|
|
|
350
350
|
### 1. 설치
|
|
351
351
|
|
|
@@ -463,7 +463,7 @@ taskery 파일은 그 리포에만 적용되는 git 무시 목록(`.git/info/exc
|
|
|
463
463
|
| `close-task <TASK>` | 작업 폴더와 브랜치를 정리하고, 태스크를 닫고, 백로그와 변경 기록을 갱신하고, 걸린 시간을 보고합니다 |
|
|
464
464
|
| `prune` | 닫힌 태스크에 남은 작업 폴더와 브랜치를 정리합니다(항목마다 묻습니다) |
|
|
465
465
|
| `backlog-add` · `backlog-get` · `backlog-mark` | 백로그 항목을 만들고, 읽고, 태스크에 연결합니다 |
|
|
466
|
-
| `
|
|
466
|
+
| `dispatch-task` · `send-task` · `read-task` · `report-task` · `wait-reports` | 오케스트레이션용 — 탭 띄우기, 탭에 말 보내기, 탭 화면 읽기, 보고 남기기, 보고 기다리기 |
|
|
467
467
|
| `init` · `update` · `add <claude\|codex>` | 설치, 갱신, 에이전트 추가 |
|
|
468
468
|
|
|
469
469
|
전체 옵션은 `npx @angar2/taskery help`로 볼 수 있습니다. 명령이 멈출 때는 왜 멈췄는지와 다음에 무엇을 하면 되는지를 함께 알려 줍니다.
|
|
@@ -490,7 +490,7 @@ taskery 파일은 그 리포에만 적용되는 git 무시 목록(`.git/info/exc
|
|
|
490
490
|
- PR로 병합하는 방식은 다루지 않습니다. 병합은 로컬에서만 합니다.
|
|
491
491
|
- 태스크 문서와 백로그는 git 이력에 남지 않습니다.
|
|
492
492
|
- 빌드 결과 복제는 macOS에서만 됩니다. 다른 환경에서는 평소처럼 빌드합니다.
|
|
493
|
-
- 오케스트레이션의 탭 자동 띄우기는 Orca 안에서만 됩니다. 그 밖에서는 사용자가 태스크 세션을 직접 열고 첫 지시를 붙여 넣습니다.
|
|
493
|
+
- 오케스트레이션의 탭 자동 띄우기는 Orca와 Herdr 안에서만 됩니다. 그 밖에서는 사용자가 태스크 세션을 직접 열고 첫 지시를 붙여 넣습니다.
|
|
494
494
|
- Codex는 작업 폴더로 옮겨 갈 수 없습니다. Codex로 태스크를 하려면 오케스트레이션이 Codex 탭을 띄우거나, 사용자가 그 작업 폴더에서 Codex를 엽니다.
|
|
495
495
|
|
|
496
496
|
---
|
package/bin/check.js
CHANGED
|
@@ -39,7 +39,7 @@ async function approvePlan(ctx, a) {
|
|
|
39
39
|
async function runCodeTests(main, dir) {
|
|
40
40
|
const manifest = L.requireInstalled(main);
|
|
41
41
|
const cmds = manifest.codeTest;
|
|
42
|
-
if (cmds === 'none') return { none: true, ms: 0, outputs: [] };
|
|
42
|
+
if (cmds === 'none') return { none: true, ms: 0, outputs: [], cmds: [] };
|
|
43
43
|
if (!Array.isArray(cmds) || cmds.length === 0) {
|
|
44
44
|
L.fail(
|
|
45
45
|
'test-code: 등록된 코드 테스트 명령이 없다. 리포를 보고 화면을 켜지 않는 코드 테스트 명령(린트·타입·빌드·단위 테스트)을 정해 ' +
|
|
@@ -56,7 +56,7 @@ async function runCodeTests(main, dir) {
|
|
|
56
56
|
}
|
|
57
57
|
outputs.push({ cmd, out: r.out });
|
|
58
58
|
}
|
|
59
|
-
return { none: false, ms: total, outputs };
|
|
59
|
+
return { none: false, ms: total, outputs, cmds };
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function overNotice(totalMs, cmds) {
|
|
@@ -90,7 +90,6 @@ async function testCode(ctx, a) {
|
|
|
90
90
|
dir = L.git(ctx.cwd, ['rev-parse', '--show-toplevel']);
|
|
91
91
|
}
|
|
92
92
|
const result = await runCodeTests(main, dir);
|
|
93
|
-
const cmds = L.requireInstalled(main).codeTest;
|
|
94
93
|
const lines = [];
|
|
95
94
|
if (result.none) lines.push('코드 테스트 없음으로 등록된 리포 — 실행 없이 통과.');
|
|
96
95
|
else {
|
|
@@ -110,7 +109,7 @@ async function testCode(ctx, a) {
|
|
|
110
109
|
);
|
|
111
110
|
lines.push(`${L.taskLabel(st.num)} 개발 칸을 기록했다 — 개발 ${L.minutes(L.stageDurations(st).dev)}.`);
|
|
112
111
|
}
|
|
113
|
-
if (!result.none) lines.push(overNotice(result.ms, cmds).trim());
|
|
112
|
+
if (!result.none) lines.push(overNotice(result.ms, result.cmds).trim());
|
|
114
113
|
return lines.filter(Boolean).join('\n');
|
|
115
114
|
}
|
|
116
115
|
|
package/bin/commands.js
CHANGED
|
@@ -7,7 +7,7 @@ const { status } = require('./status');
|
|
|
7
7
|
const { planInit } = require('./plan');
|
|
8
8
|
const { backlogAdd, backlogGet, backlogMark } = require('./backlog');
|
|
9
9
|
const { prune } = require('./prune');
|
|
10
|
-
const {
|
|
10
|
+
const { dispatchTask, sendTask, readTask, reportTask, waitReports } = require('./orche');
|
|
11
11
|
|
|
12
12
|
// 인자 종류: positional(순서대로) · string(--이름 값) · bool(--이름) · list(--이름 값 값 …)
|
|
13
13
|
const TASK = { name: 'task', positional: true, desc: '태스크 번호 (TASK-012 또는 12)' };
|
|
@@ -112,19 +112,31 @@ const COMMANDS = [
|
|
|
112
112
|
run: backlogMark,
|
|
113
113
|
},
|
|
114
114
|
{
|
|
115
|
-
name: '
|
|
116
|
-
summary: '
|
|
115
|
+
name: 'dispatch-task',
|
|
116
|
+
summary: '터미널 앱(이 세션이 도는 곳)의 새 탭에 태스크 세션을 띄우고 첫 지시문을 보낸다(탭 기록은 .state). 확인 창에서 멈춰 다시 부르면 열린 탭에 첫 지시만 보낸다. --manual이면 띄우지 않고 사용자가 붙여 넣을 첫 지시문을 출력한다',
|
|
117
117
|
args: [
|
|
118
118
|
TASK,
|
|
119
119
|
{ name: 'agent', desc: 'claude · codex (--manual이 아니면 필수)' },
|
|
120
120
|
{ name: 'model', desc: '에이전트 모델 — 사용자가 정한 것 (--manual이 아니면 필수)' },
|
|
121
|
-
{ name: 'manual', type: 'bool', desc: '손 전달 — 탭을 띄우지 않고 작업 폴더·실행 안내·첫 지시문을 출력한다.
|
|
121
|
+
{ name: 'manual', type: 'bool', desc: '손 전달 — 탭을 띄우지 않고 작업 폴더·실행 안내·첫 지시문을 출력한다. 터미널 앱이 탭을 띄울 수 없거나 사용자가 태스크 세션을 직접 열겠다고 했을 때' },
|
|
122
122
|
{ name: 'scope', desc: "첫 지시문 '맡는 일' 칸 — 이번 태스크가 맡는 일" },
|
|
123
123
|
{ name: 'avoid', desc: "첫 지시문 '손대지 않는 것' 칸" },
|
|
124
124
|
{ name: 'read', desc: "첫 지시문 '먼저 읽을 것' 칸 — 태스크 문서·플랜 문서 말고 더 읽을 문서" },
|
|
125
125
|
{ name: 'extra', desc: "첫 지시문 '그 밖' 칸 — 위 칸에 들지 않는 이번 태스크만의 지시" },
|
|
126
126
|
],
|
|
127
|
-
run:
|
|
127
|
+
run: dispatchTask,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'send-task',
|
|
131
|
+
summary: '띄운 태스크 탭에 오케스트레이션의 말을 보낸다([오케스트레이션] 머리표를 붙인다)',
|
|
132
|
+
args: [TASK, { name: 'text', positional: true, desc: '보낼 말 (필수)' }],
|
|
133
|
+
run: sendTask,
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'read-task',
|
|
137
|
+
summary: '띄운 태스크 탭의 지금 화면을 읽는다',
|
|
138
|
+
args: [TASK],
|
|
139
|
+
run: readTask,
|
|
128
140
|
},
|
|
129
141
|
{
|
|
130
142
|
name: 'report-task',
|
|
@@ -134,7 +146,7 @@ const COMMANDS = [
|
|
|
134
146
|
},
|
|
135
147
|
{
|
|
136
148
|
name: 'wait-reports',
|
|
137
|
-
summary: '안 읽은 보고가 생기면 출력하고 끝난다. 보고 없이 60분이면
|
|
149
|
+
summary: '안 읽은 보고가 생기면 출력하고 끝난다. 보고 없이 60분이면 살펴볼 태스크 탭 목록과 함께 끝난다(백그라운드 셸로 건다)',
|
|
138
150
|
args: [],
|
|
139
151
|
cliOnly: true,
|
|
140
152
|
run: waitReports,
|
package/bin/finish.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// verify-close · commit-task · merge-task · close-task — 마무리 4명령(검사·커밋·병합·정리, §5-3)
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
|
-
const { execFileSync } = require('child_process');
|
|
5
4
|
const L = require('./lib');
|
|
6
5
|
const { checkMain, installPackages, reseedBuildOutputs } = require('./prepare');
|
|
7
6
|
const B = require('./backlog');
|
|
8
7
|
const { runCodeTests, lastEntry } = require('./check');
|
|
8
|
+
const H = require('./hosts');
|
|
9
9
|
|
|
10
10
|
function openState(main, task) {
|
|
11
11
|
const num = L.parseTaskNum(task);
|
|
@@ -286,20 +286,30 @@ function writeChangelog(main, st, goal) {
|
|
|
286
286
|
return file;
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
289
|
+
// 띄운 태스크 탭을 닫는다 — 닫는 방법이 있는 호스트(close)만
|
|
290
|
+
function closeTab(st) {
|
|
291
|
+
const tab = H.tabOf(st);
|
|
292
|
+
const host = tab && H.byName(tab.host);
|
|
293
|
+
if (!host || !host.close) return [];
|
|
294
|
+
try {
|
|
295
|
+
return host.close(tab.id) === false ? [] : [`탭 ${tab.id}을 닫았다`];
|
|
296
|
+
} catch (e) {
|
|
297
|
+
return [`탭 ${tab.id}을 닫지 못했다: ${e.message}`];
|
|
298
|
+
}
|
|
296
299
|
}
|
|
297
300
|
|
|
298
301
|
// 워크트리·브랜치 정리 — 커밋 안 된 변경이 있으면 남기고, 브랜치는 병합된 것만 지운다.
|
|
299
|
-
// force는 prune에서 사용자가 확인했을 때만 — 병합 안 된 브랜치도 지운다(복구 명령 출력)
|
|
300
|
-
function cleanup(main, st,
|
|
302
|
+
// force는 prune에서 사용자가 확인했을 때만 — 병합 안 된 브랜치도 지운다(복구 명령 출력). 워크트리를 지웠으면 띄운 탭도 닫는다
|
|
303
|
+
function cleanup(main, st, opts = {}) {
|
|
304
|
+
const r = cleanTree(main, st, opts);
|
|
305
|
+
// 워크트리를 남겼으면 탭도 남긴다 — 사용자가 그 탭에서 남은 변경을 볼 수 있게
|
|
306
|
+
if (!r.left) r.notes.push(...closeTab(st));
|
|
307
|
+
return r.notes;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function cleanTree(main, st, { force = false } = {}) {
|
|
301
311
|
const notes = [];
|
|
302
|
-
if (st.noBranch) return notes;
|
|
312
|
+
if (st.noBranch) return { notes, left: false };
|
|
303
313
|
const exists = L.branchExists(main, st.branch);
|
|
304
314
|
const tip = exists ? L.git(main, ['rev-parse', st.branch]) : null;
|
|
305
315
|
const merged = exists && L.isAncestor(main, st.branch, st.parent);
|
|
@@ -309,20 +319,9 @@ function cleanup(main, st, { force = false } = {}) {
|
|
|
309
319
|
const dirty = L.changedFiles(st.worktree);
|
|
310
320
|
if (dirty.length) {
|
|
311
321
|
notes.push(`워크트리에 커밋 안 된 변경·추적 안 되는 파일이 있어 워크트리와 브랜치를 남겼다: ${st.worktree} (${dirty.slice(0, 5).join(', ')}${dirty.length > 5 ? ' …' : ''})`);
|
|
312
|
-
return notes;
|
|
322
|
+
return { notes, left: true };
|
|
313
323
|
}
|
|
314
|
-
if (
|
|
315
|
-
// Orca는 병합을 증명할 수 없는 브랜치를 남긴다(`orca worktree rm --help`) — 병합 여부와 무관하게 부른다
|
|
316
|
-
try {
|
|
317
|
-
execFileSync('orca', ['worktree', 'rm', '--worktree', `path:${st.worktree}`, '--json'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
318
|
-
notes.push(`Orca 워크트리를 지웠다: ${st.worktree}`);
|
|
319
|
-
// Orca는 답한 뒤에도 뒤에서 폴더를 지우고 병합된 브랜치도 함께 지운다 — 폴더가 사라질 때까지 기다린 뒤 브랜치를 본다
|
|
320
|
-
waitGone(st.worktree, ORCA_RM_WAIT_MS);
|
|
321
|
-
} catch (e) {
|
|
322
|
-
notes.push(`Orca 워크트리를 지우지 못해 남겼다: ${((e.stderr || '') + '').trim() || e.message}`);
|
|
323
|
-
branchFree = false;
|
|
324
|
-
}
|
|
325
|
-
} else if (L.git(main, ['worktree', 'remove', st.worktree], { allowFail: true }) === null) {
|
|
324
|
+
if (L.git(main, ['worktree', 'remove', st.worktree], { allowFail: true }) === null) {
|
|
326
325
|
notes.push(`워크트리를 지우지 못해 남겼다: ${st.worktree}`);
|
|
327
326
|
branchFree = false;
|
|
328
327
|
} else {
|
|
@@ -330,18 +329,16 @@ function cleanup(main, st, { force = false } = {}) {
|
|
|
330
329
|
}
|
|
331
330
|
}
|
|
332
331
|
|
|
332
|
+
const left = !branchFree;
|
|
333
333
|
if (!L.branchExists(main, st.branch)) {
|
|
334
334
|
if (exists) notes.push(`브랜치 ${st.branch}가 지워졌다. 복구: git branch ${st.branch} ${tip}`);
|
|
335
|
-
return notes;
|
|
335
|
+
return { notes, left };
|
|
336
336
|
}
|
|
337
337
|
if (!merged && !force) notes.push(`브랜치 ${st.branch}는 부모에 병합되지 않아 남겼다.`);
|
|
338
338
|
else if (!branchFree || L.currentBranch(main) === st.branch) notes.push(`브랜치 ${st.branch}를 지우지 못해 남겼다(사용 중).`);
|
|
339
|
-
else if (L.git(main, ['branch', merged ? '-d' : '-D', st.branch], { allowFail: true }) === null) {
|
|
340
|
-
// 그 사이 Orca가 지웠으면 실패가 아니다
|
|
341
|
-
notes.push(L.branchExists(main, st.branch) ? `브랜치 ${st.branch}를 지우지 못해 남겼다.` : `브랜치 ${st.branch}가 지워졌다. 복구: git branch ${st.branch} ${tip}`);
|
|
342
|
-
}
|
|
339
|
+
else if (L.git(main, ['branch', merged ? '-d' : '-D', st.branch], { allowFail: true }) === null) notes.push(`브랜치 ${st.branch}를 지우지 못해 남겼다.`);
|
|
343
340
|
else notes.push(`브랜치 ${st.branch}를 지웠다. 복구: git branch ${st.branch} ${tip}`);
|
|
344
|
-
return notes;
|
|
341
|
+
return { notes, left };
|
|
345
342
|
}
|
|
346
343
|
|
|
347
344
|
async function closeTask(ctx, a) {
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Herdr 호스트 — Herdr(코딩 에이전트용 터미널 분할 앱)의 방식. 계약은 hosts/index.js
|
|
2
|
+
// 작업 폴더(워크트리)는 taskery가 만든다. 띄울 때 그 폴더를 본진 워크스페이스의 하위 워크스페이스로 열고(herdr worktree open) 거기서 에이전트를 띄운다
|
|
3
|
+
// 탭 id는 그 탭 첫 칸의 pane id(wA:p3)다 — 닫혀도 다시 쓰이지 않는다
|
|
4
|
+
const { execFileSync } = require('child_process');
|
|
5
|
+
const L = require('../lib');
|
|
6
|
+
|
|
7
|
+
// 에이전트 시작을 기다리는 최대 시간
|
|
8
|
+
const START_TIMEOUT_MS = 60 * 1000;
|
|
9
|
+
// 첫 지시문을 보낸 뒤 에이전트가 일을 시작하기를 기다리는 최대 시간
|
|
10
|
+
const PROMPT_TIMEOUT_MS = 30 * 1000;
|
|
11
|
+
// 지시문을 보낸 뒤 화면을 읽기 전 기다리는 시간
|
|
12
|
+
const SCREEN_SETTLE_MS = 2000;
|
|
13
|
+
// 새 탭의 셸이 뜨기를 기다리는 최대 시간 — 셸 시작 스크립트가 도는 동안 agent start는 agent_pane_busy로 거절된다.
|
|
14
|
+
// 여러 탭을 한꺼번에 열면 시작 스크립트끼리 잠금을 기다려 1분 가까이 걸린 적이 있다(pyenv rehash, 실측)
|
|
15
|
+
const SHELL_WAIT_MS = 90 * 1000;
|
|
16
|
+
const BLOCKED = '에이전트가 확인 창에서 멈췄다(blocked)';
|
|
17
|
+
|
|
18
|
+
function parse(textOut) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(textOut);
|
|
21
|
+
} catch (e) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// herdr 명령을 부른다. JSON 답이면 result를, 오류면 code를 가진 TaskeryError를 던진다. text면 출력 글을 그대로 돌려준다.
|
|
27
|
+
// 오류 JSON은 표준 오류로 나온다(실측)
|
|
28
|
+
function herdr(args, { text = false } = {}) {
|
|
29
|
+
let out = '';
|
|
30
|
+
let errOut = '';
|
|
31
|
+
let failed = false;
|
|
32
|
+
try {
|
|
33
|
+
out = execFileSync('herdr', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
34
|
+
} catch (e) {
|
|
35
|
+
failed = true;
|
|
36
|
+
out = (e.stdout || '').toString();
|
|
37
|
+
errOut = (e.stderr || '').toString().trim() || e.message;
|
|
38
|
+
}
|
|
39
|
+
if (text && !failed) return out;
|
|
40
|
+
const j = parse(out) || parse(errOut);
|
|
41
|
+
const err = errOut.split('\n')[0];
|
|
42
|
+
if (j && j.error) {
|
|
43
|
+
const e = new L.TaskeryError(j.error.message || j.error.code || '알 수 없는 오류');
|
|
44
|
+
e.code = j.error.code;
|
|
45
|
+
throw e;
|
|
46
|
+
}
|
|
47
|
+
if (!failed && j && 'result' in j) return j.result;
|
|
48
|
+
throw new L.TaskeryError(`herdr ${args.slice(0, 2).join(' ')} 실패: ${err || out.trim() || '알 수 없는 오류'}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function paneInfo(id) {
|
|
52
|
+
const r = herdr(['pane', 'get', id]);
|
|
53
|
+
return (r && r.pane) || {};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// pane id로 만든 에이전트 이름 — [a-z][a-z0-9_-]{0,31}, 살아 있는 에이전트끼리 겹치지 않는다
|
|
57
|
+
function agentName(label, pane) {
|
|
58
|
+
return `${label}-${pane}`.toLowerCase().replace(/[^a-z0-9_-]/g, '-').slice(0, 32);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 워크트리를 하위 워크스페이스로 연다 — 이미 열려 있으면 그 워크스페이스에 새 탭을 연다. 에이전트를 띄우지 못하면 이번에 연 것을 닫는다
|
|
62
|
+
function openPane(main, dir, label) {
|
|
63
|
+
const r = herdr(['worktree', 'open', '--cwd', main, '--path', dir, '--label', label, '--no-focus']);
|
|
64
|
+
const ws = r && r.workspace && r.workspace.workspace_id;
|
|
65
|
+
if (!r.already_open) {
|
|
66
|
+
const id = r.root_pane && r.root_pane.pane_id;
|
|
67
|
+
if (!id) L.fail('Herdr가 하위 워크스페이스의 pane id를 돌려주지 않았다');
|
|
68
|
+
return { id, undo: ['workspace', 'close', ws] };
|
|
69
|
+
}
|
|
70
|
+
const t = herdr(['tab', 'create', '--workspace', ws, '--cwd', dir, '--label', label, '--no-focus']);
|
|
71
|
+
const id = t && t.root_pane && t.root_pane.pane_id;
|
|
72
|
+
if (!id) L.fail('Herdr가 새 탭의 pane id를 돌려주지 않았다');
|
|
73
|
+
return { id, undo: ['tab', 'close', (t.tab && t.tab.tab_id) || t.root_pane.tab_id] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function open({ main, dir, label, agent, args }) {
|
|
77
|
+
const { id, undo } = openPane(main, dir, label);
|
|
78
|
+
const start = () => herdr(['agent', 'start', agentName(label, id), '--kind', agent, '--pane', id, '--timeout', String(START_TIMEOUT_MS), '--', ...args]);
|
|
79
|
+
try {
|
|
80
|
+
// 셸이 아직 뜨지 않은 탭(agent_pane_busy)은 에이전트를 띄우지 않은 것이라 다시 불러도 겹쳐 뜨지 않는다
|
|
81
|
+
const until = Date.now() + SHELL_WAIT_MS;
|
|
82
|
+
for (;;) {
|
|
83
|
+
try {
|
|
84
|
+
start();
|
|
85
|
+
break;
|
|
86
|
+
} catch (e) {
|
|
87
|
+
if (e.code !== 'agent_pane_busy' || Date.now() >= until) throw e;
|
|
88
|
+
L.sleepSync(500);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch (e) {
|
|
92
|
+
// 시작 중 확인 창(폴더 신뢰 등)·시간 초과 — 에이전트가 떠 있을 수 있어 탭을 남기고 기록한다
|
|
93
|
+
if (e.code === 'agent_not_ready') return { id, blocked: '에이전트가 시작 중 확인 창(폴더 신뢰 등)에서 멈췄다' };
|
|
94
|
+
if (/timeout/.test(e.code || '')) return { id, blocked: `에이전트가 ${START_TIMEOUT_MS / 1000}초 안에 준비되지 않았다` };
|
|
95
|
+
// 그 밖은 에이전트를 띄우지 못한 것 — 이번에 연 것을 닫는다. 셸이 끝내 뜨지 않은 탭도 에이전트가 없어 남기면 다시 불러도 이어 갈 수 없다
|
|
96
|
+
try {
|
|
97
|
+
herdr(undo);
|
|
98
|
+
} catch (e2) {
|
|
99
|
+
// 닫지 못해도 원래 오류를 알린다
|
|
100
|
+
}
|
|
101
|
+
if (e.code === 'agent_pane_busy') L.fail(`새 탭의 셸이 ${SHELL_WAIT_MS / 1000}초 안에 뜨지 않아 에이전트를 띄우지 못했다 — 탭을 닫았다(${e.message})`);
|
|
102
|
+
L.fail(`에이전트를 띄우지 못했다(${e.message})`);
|
|
103
|
+
}
|
|
104
|
+
return { id, blocked: null };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function alive(id) {
|
|
108
|
+
try {
|
|
109
|
+
paneInfo(id);
|
|
110
|
+
return true;
|
|
111
|
+
} catch (e) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function ready(id) {
|
|
117
|
+
const p = paneInfo(id);
|
|
118
|
+
if (!p.agent) return { ok: false, why: '에이전트가 없다' };
|
|
119
|
+
if (p.agent_status === 'idle' || p.agent_status === 'done') return { ok: true };
|
|
120
|
+
if (p.agent_status === 'blocked') return { ok: false, why: '확인 창에서 멈춰 있다' };
|
|
121
|
+
return { ok: false, why: `에이전트가 입력을 받을 상태가 아니다(${p.agent_status})` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function read(id) {
|
|
125
|
+
return herdr(['pane', 'read', id, '--source', 'visible', '--format', 'text'], { text: true });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 지시문을 보내고 에이전트가 일을 시작하거나 멈출 때까지 기다린다. 확인 창이면 던진다. 멈춤·시간 초과는 화면으로 판단한다
|
|
129
|
+
function promptOnce(id, text) {
|
|
130
|
+
let r;
|
|
131
|
+
try {
|
|
132
|
+
r = herdr(['agent', 'prompt', id, text, '--wait', '--until', 'working', '--until', 'blocked', '--timeout', String(PROMPT_TIMEOUT_MS)]);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
if (e.code === 'agent_blocked') L.fail(BLOCKED);
|
|
135
|
+
if (/stall|timeout/.test(e.code || '')) return;
|
|
136
|
+
L.fail(`첫 지시문을 보내지 못했다(${e.message})`);
|
|
137
|
+
}
|
|
138
|
+
const status = r && r.agent && r.agent.agent_status;
|
|
139
|
+
if (status === 'blocked') L.fail(BLOCKED);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 화면에 첫 지시문 첫머리(첫 줄 앞 네 낱말)가 보이나 — 상태만으로는 제출을 믿지 않는다(다른 화면이 지시문을 삼켜도 done이 된 적이 있다)
|
|
143
|
+
function onScreen(id, text) {
|
|
144
|
+
const marker = text.split('\n')[0].split(' ').slice(0, 4).join(' ').replace(/\s+/g, '');
|
|
145
|
+
L.sleepSync(SCREEN_SETTLE_MS);
|
|
146
|
+
try {
|
|
147
|
+
return herdr(['pane', 'read', id, '--source', 'recent', '--lines', '200', '--format', 'text'], { text: true }).replace(/\s+/g, '').includes(marker);
|
|
148
|
+
} catch (e) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function sendFirst(id, text) {
|
|
154
|
+
promptOnce(id, text);
|
|
155
|
+
if (onScreen(id, text)) return { note: null };
|
|
156
|
+
promptOnce(id, text);
|
|
157
|
+
if (onScreen(id, text)) return { note: '첫 전송이 화면에 보이지 않아 한 번 더 보냈다.' };
|
|
158
|
+
L.fail('두 번 보냈지만 탭 화면에 첫 지시문이 보이지 않는다');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function send(id, text) {
|
|
162
|
+
try {
|
|
163
|
+
herdr(['agent', 'prompt', id, text]);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
if (e.code === 'agent_blocked') L.fail('에이전트가 확인 창에서 멈춰 있어 보내지 못했다(blocked)');
|
|
166
|
+
L.fail(`보내지 못했다(${e.message})`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Herdr는 마지막 출력 시각을 주지 않는다 — 에이전트 상태로 고른다(quietMs는 쓰지 않는다)
|
|
171
|
+
function check(id) {
|
|
172
|
+
let p;
|
|
173
|
+
try {
|
|
174
|
+
p = paneInfo(id);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
if (e.code === 'pane_not_found') return '탭이 없다';
|
|
177
|
+
throw e;
|
|
178
|
+
}
|
|
179
|
+
if (p.agent_status === 'blocked') return '확인 창·질문에서 멈춰 있다(blocked)';
|
|
180
|
+
if (p.agent_status === 'idle' || p.agent_status === 'done') return `입력을 기다린다(${p.agent_status})`;
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 탭을 닫는다 — 하위 워크스페이스(워크트리)에 있으면 워크스페이스째, 아니면 탭만(오케스트레이터 워크스페이스를 닫지 않게). 이미 없으면 false
|
|
185
|
+
function close(id) {
|
|
186
|
+
let p;
|
|
187
|
+
try {
|
|
188
|
+
p = paneInfo(id);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
if (e.code === 'pane_not_found') return false;
|
|
191
|
+
throw e;
|
|
192
|
+
}
|
|
193
|
+
const w = herdr(['workspace', 'get', p.workspace_id]);
|
|
194
|
+
const linked = !!(w && w.workspace && w.workspace.worktree && w.workspace.worktree.is_linked_worktree);
|
|
195
|
+
herdr(linked ? ['workspace', 'close', p.workspace_id] : ['tab', 'close', p.tab_id]);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = {
|
|
200
|
+
name: 'herdr',
|
|
201
|
+
label: 'Herdr',
|
|
202
|
+
envKeys: ['HERDR_ENV'],
|
|
203
|
+
detect: (env) => env.HERDR_ENV === '1',
|
|
204
|
+
open,
|
|
205
|
+
alive,
|
|
206
|
+
ready,
|
|
207
|
+
sendFirst,
|
|
208
|
+
send,
|
|
209
|
+
read,
|
|
210
|
+
check,
|
|
211
|
+
close,
|
|
212
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// 호스트 등록·판정 — 호스트 = 태스크 세션을 띄울 탭을 주는 터미널 앱(Orca·Herdr …).
|
|
2
|
+
// 이 폴더의 index.js 밖 *.js 하나가 호스트 하나다. 새 터미널 앱은 계약(아래 REQUIRED·OPTIONAL)을 갖춘 파일 하나를 더해 붙인다.
|
|
3
|
+
// 공통 코드는 호스트 이름을 모른다 — 판정은 current(), 기록된 이름은 byName()으로만 찾는다.
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const L = require('../lib');
|
|
7
|
+
|
|
8
|
+
// 필수 — name·label·envKeys는 값, 나머지는 함수(모양은 기획서 §4)
|
|
9
|
+
const REQUIRED = ['name', 'label', 'envKeys', 'detect', 'open', 'alive', 'ready', 'sendFirst', 'send', 'read', 'check'];
|
|
10
|
+
// 선택 — legacyTab이 참인 호스트는 1.0.3까지 문자열로 남은 탭 기록의 주인이다
|
|
11
|
+
const OPTIONAL = ['attach', 'close', 'legacyTab'];
|
|
12
|
+
|
|
13
|
+
// 값 항목과 그 모양 — 나머지 계약 항목은 함수다(legacyTab은 참·거짓)
|
|
14
|
+
const VALUES = {
|
|
15
|
+
name: [(v) => typeof v === 'string', '글이어야 한다'],
|
|
16
|
+
label: [(v) => typeof v === 'string', '글이어야 한다'],
|
|
17
|
+
envKeys: [(v) => Array.isArray(v) && v.length > 0 && v.every((k) => typeof k === 'string'), '글 하나 이상의 배열이어야 한다'],
|
|
18
|
+
legacyTab: [(v) => typeof v === 'boolean', '참·거짓이어야 한다'],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// 호스트 파일 하나가 계약을 어긴 곳 목록
|
|
22
|
+
function problemsOf(host, name) {
|
|
23
|
+
if (!host || typeof host !== 'object') return ['module.exports가 객체가 아니다'];
|
|
24
|
+
const problems = REQUIRED.filter((k) => host[k] == null).map((k) => `빠진 항목 ${k}`);
|
|
25
|
+
for (const k of [...REQUIRED, ...OPTIONAL]) {
|
|
26
|
+
if (host[k] == null) continue;
|
|
27
|
+
const [okShape, want] = VALUES[k] || [(v) => typeof v === 'function', '함수여야 한다'];
|
|
28
|
+
if (!okShape(host[k])) problems.push(`${k}는 ${want}`);
|
|
29
|
+
}
|
|
30
|
+
if (host.name != null && host.name !== name) problems.push(`name '${host.name}'이 파일 이름 '${name}'과 다르다`);
|
|
31
|
+
return problems;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 폴더의 호스트 파일을 이름순으로 읽고 계약을 확인한다 — 어긴 파일이 있으면 그 파일 이름과 어긴 곳을 넣어 멈춘다
|
|
35
|
+
function load(dir = __dirname) {
|
|
36
|
+
const loaded = fs
|
|
37
|
+
.readdirSync(dir)
|
|
38
|
+
.filter((f) => f.endsWith('.js') && f !== 'index.js')
|
|
39
|
+
.sort()
|
|
40
|
+
.map((file) => {
|
|
41
|
+
const host = require(path.join(dir, file));
|
|
42
|
+
const problems = problemsOf(host, file.slice(0, -'.js'.length));
|
|
43
|
+
if (problems.length) throw new L.TaskeryError(`호스트 파일 ${path.join(dir, file)}이 계약에 맞지 않는다: ${problems.join(', ')}`);
|
|
44
|
+
return host;
|
|
45
|
+
});
|
|
46
|
+
// 옛 문자열 탭 기록의 주인은 하나뿐이다 — 둘이면 파일 이름 순서로 주인이 바뀐다
|
|
47
|
+
const legacy = loaded.filter((h) => h.legacyTab).map((h) => h.name);
|
|
48
|
+
if (legacy.length > 1) throw new L.TaskeryError(`호스트 파일 ${dir}에 legacyTab 호스트가 둘 이상이다: ${legacy.join(', ')}`);
|
|
49
|
+
return loaded;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let hosts = null;
|
|
53
|
+
function list() {
|
|
54
|
+
if (!hosts) hosts = load();
|
|
55
|
+
return hosts;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 지금 이 세션이 도는 호스트 — 등록 순서대로 처음 맞는 것, 없으면 null
|
|
59
|
+
function current(env = process.env) {
|
|
60
|
+
return list().find((h) => h.detect(env)) || null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function byName(name) {
|
|
64
|
+
return list().find((h) => h.name === name) || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 모든 호스트의 판정 환경 변수 — 판정 실패 문구와 시험이 쓴다
|
|
68
|
+
function envKeys() {
|
|
69
|
+
return [...new Set(list().flatMap((h) => h.envKeys))];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// .state의 탭 기록 { host, id, sent } — 1.0.3까지의 문자열 기록은 그것을 남긴 호스트(legacyTab)의 첫 지시를 보낸 탭으로 읽는다
|
|
73
|
+
function tabOf(st) {
|
|
74
|
+
if (!st || !st.tab) return null;
|
|
75
|
+
if (typeof st.tab === 'string') {
|
|
76
|
+
const owner = list().find((h) => h.legacyTab);
|
|
77
|
+
return { host: owner ? owner.name : null, id: st.tab, sent: true };
|
|
78
|
+
}
|
|
79
|
+
return st.tab;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { REQUIRED, OPTIONAL, load, list, current, byName, envKeys, tabOf };
|