@angar2/taskery 0.8.0 → 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 +515 -1320
- 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 -266
- 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/migrate.js +0 -344
- 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/plan.js
CHANGED
|
@@ -1,125 +1,63 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* bin/plan.js
|
|
5
|
-
* `npx @angar2/taskery plan-init <slug> [--force]` — plan 생성.
|
|
6
|
-
* `npx @angar2/taskery plan-switch <NNN_slug>` — 활성 plan 전환.
|
|
7
|
-
*
|
|
8
|
-
* dispatcher가 op('init' | 'switch')를 선행 인자로 주입한다 (backlog.js와 동일 패턴).
|
|
9
|
-
*
|
|
10
|
-
* init: 채번(NNN) + legacy 게이트 + 폴더 mkdir(plans/<NNN_slug> + tasks/<NNN_slug>/{spec-diffs,screenshots,mockup})
|
|
11
|
-
* + ROADMAP/PLAN/BACKLOG 골격 Write + manifest.activePlan 갱신을 코드가 수행.
|
|
12
|
-
* 성공 시 JSON 한 줄 — { plan, nnn, planDir, tasksDir }. 이후 ROADMAP Stage 내용·PLAN 링크·
|
|
13
|
-
* FEATURES/UX-UI delta는 LLM(plan-init 스킬)이 골격 placeholder를 채운다.
|
|
14
|
-
* legacy 폴더(NNN_ 아닌 plan 폴더) 잔존 + --force 없음 → exit 2 + { gated:true, legacyDirs }
|
|
15
|
-
* (스킬이 사용자 confirm 후 --force로 재호출).
|
|
16
|
-
*
|
|
17
|
-
* switch: 대상 plan 폴더 실재 검증 후 manifest.activePlan 갱신. 성공 시 JSON — { plan, planDir }.
|
|
18
|
-
*
|
|
19
|
-
* 그 외 실패 시 stderr + exit 1.
|
|
20
|
-
*/
|
|
21
|
-
|
|
1
|
+
// plan-init — 잠금 안에서 다음 플랜 번호를 발급하고 plans/<NNN>_<slug>/PLAN.md 틀을 만든다(§5-3)
|
|
2
|
+
const fs = require('fs');
|
|
22
3
|
const path = require('path');
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function runInit(argv) {
|
|
48
|
-
const positional = [];
|
|
49
|
-
let force = false;
|
|
50
|
-
for (const a of argv) {
|
|
51
|
-
if (a === '--force') force = true;
|
|
52
|
-
else if (!a.startsWith('--')) positional.push(a);
|
|
53
|
-
}
|
|
54
|
-
const slug = positional[0];
|
|
55
|
-
|
|
56
|
-
if (!slug) {
|
|
57
|
-
console.error('사용법: npx @angar2/taskery plan-init <slug> [--force]');
|
|
58
|
-
console.error('예: npx @angar2/taskery plan-init compare-products');
|
|
59
|
-
process.exit(1);
|
|
60
|
-
}
|
|
61
|
-
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
|
|
62
|
-
console.error(`slug은 영어 kebab-case (받음: ${slug})`);
|
|
63
|
-
process.exit(1);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const mainWt = resolveMainWt();
|
|
67
|
-
|
|
68
|
-
let result;
|
|
69
|
-
try {
|
|
70
|
-
result = initPlan(mainWt, slug, { force });
|
|
71
|
-
} catch (e) {
|
|
72
|
-
console.error(`taskery plan-init 실패: ${e.message}`);
|
|
73
|
-
process.exit(1);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (result.gated) {
|
|
77
|
-
// legacy 폴더 잔존 — 사용자 confirm 전 생성 금지. 스킬이 경고 + confirm 후 --force 재호출.
|
|
78
|
-
console.error(
|
|
79
|
-
`plans/에 NNN 채번이 아닌 폴더(${result.legacyDirs.join(', ')})가 있어 활성 plan이 갈릴 위험. ` +
|
|
80
|
-
`수동 이전 후 진행하거나, 그대로 진행하려면 --force 추가.`,
|
|
81
|
-
);
|
|
82
|
-
process.stdout.write(JSON.stringify({ gated: true, legacyDirs: result.legacyDirs }) + '\n');
|
|
83
|
-
process.exit(2);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
process.stdout.write(
|
|
87
|
-
JSON.stringify({
|
|
88
|
-
plan: result.plan,
|
|
89
|
-
nnn: result.nnn,
|
|
90
|
-
planDir: result.planDir,
|
|
91
|
-
tasksDir: result.tasksDir,
|
|
92
|
-
}) + '\n',
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function runSwitch(argv) {
|
|
97
|
-
const plan = argv.filter((a) => !a.startsWith('--'))[0];
|
|
98
|
-
if (!plan) {
|
|
99
|
-
console.error('사용법: npx @angar2/taskery plan-switch <NNN_slug>');
|
|
100
|
-
console.error('예: npx @angar2/taskery plan-switch 002_compare-products');
|
|
101
|
-
process.exit(1);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const mainWt = resolveMainWt();
|
|
105
|
-
|
|
106
|
-
let result;
|
|
107
|
-
try {
|
|
108
|
-
result = setActivePlan(mainWt, plan);
|
|
109
|
-
} catch (e) {
|
|
110
|
-
console.error(`taskery plan-switch 실패: ${e.message}`);
|
|
111
|
-
process.exit(1);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
process.stdout.write(JSON.stringify({ plan: result.plan, planDir: result.planDir }) + '\n');
|
|
4
|
+
const L = require('./lib');
|
|
5
|
+
|
|
6
|
+
async function planInit(ctx, a) {
|
|
7
|
+
const main = ctx.main;
|
|
8
|
+
L.requireInstalled(main);
|
|
9
|
+
const slug = a.slug;
|
|
10
|
+
if (!slug) L.fail('plan-init: <slug>를 넣는다 — 영문 kebab-case (예: mvp, compare-products).');
|
|
11
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) L.fail(`plan-init: '${slug}'는 영문 kebab-case가 아니다 (소문자·숫자·하이픈).`);
|
|
12
|
+
const dir = path.join(main, '.taskery', 'plans');
|
|
13
|
+
return L.withLock(main, 'number', async () => {
|
|
14
|
+
const names = fs.existsSync(dir) ? fs.readdirSync(dir) : [];
|
|
15
|
+
const same = names.find((n) => n.replace(/^\d{3}_/, '') === slug);
|
|
16
|
+
if (same) L.fail(`plan-init: 같은 slug의 플랜이 이미 있다: ${same}. 다른 slug를 쓴다.`);
|
|
17
|
+
let max = 0;
|
|
18
|
+
for (const n of names) {
|
|
19
|
+
const m = n.match(/^(\d{3})_/);
|
|
20
|
+
if (m) max = Math.max(max, parseInt(m[1], 10));
|
|
21
|
+
}
|
|
22
|
+
const plan = `${L.pad(max + 1)}_${slug}`;
|
|
23
|
+
const file = path.join(dir, plan, 'PLAN.md');
|
|
24
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
25
|
+
fs.writeFileSync(file, `# ${a.title || slug}\n\n## 목표\n\n## 태스크 목록\n`);
|
|
26
|
+
return `플랜 ${plan}을 만들었다: ${file}\n다음: PLAN.md에 목표와 태스크 목록(한 줄 형식 '<항목 번호>. <한 줄 설명> — 선행: <항목 번호들 또는 없음>')을 적는다.`;
|
|
27
|
+
});
|
|
115
28
|
}
|
|
116
29
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
30
|
+
// PLAN.md `## 태스크 목록`을 읽는다. 한 줄 형식 `<항목 번호>. <한 줄 설명> — 선행: <항목 번호들 또는 없음>`,
|
|
31
|
+
// 줄 끝의 `(TASK-012)`는 prepare-task --item이 붙인 연결 표시다(마지막 것이 가장 최근 연결). 읽지 못한 줄은 따로 돌려준다
|
|
32
|
+
function parsePlanItems(text) {
|
|
33
|
+
const items = [];
|
|
34
|
+
const unreadable = [];
|
|
35
|
+
const body = L.section(text, '태스크 목록');
|
|
36
|
+
if (body == null) return { items, unreadable, noList: true };
|
|
37
|
+
for (const raw of body.replace(/<!--[\s\S]*?-->/g, '').split('\n')) {
|
|
38
|
+
const line = raw.trim();
|
|
39
|
+
if (!line) continue;
|
|
40
|
+
const tasks = [];
|
|
41
|
+
let rest = line;
|
|
42
|
+
for (let m; (m = rest.match(/\s*\(TASK-(\d+)\)\s*$/)); ) {
|
|
43
|
+
tasks.unshift(parseInt(m[1], 10));
|
|
44
|
+
rest = rest.slice(0, m.index);
|
|
45
|
+
}
|
|
46
|
+
const m = rest.match(/^(\d+)\.\s+(.+?)\s+[—–-]\s+선행\s*:\s*(.+)$/);
|
|
47
|
+
const pre = m && m[3].trim();
|
|
48
|
+
if (!m || !(pre === '없음' || /^\d+(\s*[,·]\s*\d+)*$/.test(pre))) {
|
|
49
|
+
unreadable.push(line);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
items.push({
|
|
53
|
+
num: parseInt(m[1], 10),
|
|
54
|
+
desc: m[2].trim(),
|
|
55
|
+
pre: pre === '없음' ? [] : pre.split(/\s*[,·]\s*/).map((n) => parseInt(n, 10)),
|
|
56
|
+
tasks,
|
|
57
|
+
line,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return { items, unreadable, noList: false };
|
|
123
61
|
}
|
|
124
62
|
|
|
125
|
-
|
|
63
|
+
module.exports = { planInit, parsePlanItems };
|
package/bin/prepare.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// prepare-task — 번호 발급·플랜 결정·브랜치/워크트리 분기·taskery 파일 심기·태스크 문서·.state 기록(§5-3)
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
const L = require('./lib');
|
|
7
|
+
const B = require('./backlog');
|
|
8
|
+
|
|
9
|
+
const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
10
|
+
|
|
11
|
+
function validate(a, rule) {
|
|
12
|
+
const missing = [];
|
|
13
|
+
if (!a.name || !String(a.name).trim()) missing.push('<이름> — 태스크 제목 한 줄 (첫 번째 인자)');
|
|
14
|
+
if (!a.slug) missing.push('--slug — 영문 kebab-case (예: settings-save-color)');
|
|
15
|
+
if (!a.type) missing.push(`--type — ${L.TYPES.join(' · ')}`);
|
|
16
|
+
if (!a.size) missing.push(`--size — ${L.SIZES.join(' · ')}`);
|
|
17
|
+
const needDev = !a['no-branch'] && rule.branch.includes('{dev}');
|
|
18
|
+
if (needDev && !a.dev) missing.push('--dev — 작업자 식별자 (GIT_RULE.md 브랜치 이름에 작업자 칸이 있다. 예: claude)');
|
|
19
|
+
if (missing.length) L.fail(`prepare-task: 빠진 입력이 있다.\n${missing.map((m) => `- ${m}`).join('\n')}`);
|
|
20
|
+
if (!SLUG_RE.test(a.slug)) L.fail(`prepare-task: --slug '${a.slug}'는 영문 kebab-case가 아니다 (소문자·숫자·하이픈).`);
|
|
21
|
+
if (!L.TYPES.includes(a.type)) L.fail(`prepare-task: --type '${a.type}'을 모른다. ${L.TYPES.join(' · ')} 중 하나를 넣는다.`);
|
|
22
|
+
if (!L.SIZES.includes(a.size)) L.fail(`prepare-task: --size '${a.size}'를 모른다. ${L.SIZES.join(' · ')} 중 하나를 넣는다.`);
|
|
23
|
+
const switches = a.switch
|
|
24
|
+
? String(a.switch)
|
|
25
|
+
.split(',')
|
|
26
|
+
.map((s) => s.trim())
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
: [...L.SWITCHES];
|
|
29
|
+
const bad = switches.filter((s) => !L.SWITCHES.includes(s));
|
|
30
|
+
if (bad.length) L.fail(`prepare-task: --switch에 모르는 값 ${bad.join(', ')}. ${L.SWITCHES.join(',')} 중에서 켜진 것만 쉼표로 넣는다.`);
|
|
31
|
+
if (!switches.includes('dev') && !switches.includes('test')) {
|
|
32
|
+
L.fail('prepare-task: 개발과 테스트가 둘 다 꺼진 태스크는 할 일이 없다. --switch에 dev나 test 중 하나는 넣는다.');
|
|
33
|
+
}
|
|
34
|
+
if (a.item != null && !/^\d+$/.test(String(a.item))) L.fail(`prepare-task: --item '${a.item}'은 PLAN.md 목록의 항목 번호(숫자)여야 한다.`);
|
|
35
|
+
return L.SWITCHES.filter((s) => switches.includes(s));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolvePlan(main, plan) {
|
|
39
|
+
const dir = path.join(main, '.taskery', 'plans');
|
|
40
|
+
const plans = fs.existsSync(dir)
|
|
41
|
+
? fs.readdirSync(dir).filter((n) => fs.existsSync(path.join(dir, n, 'PLAN.md')))
|
|
42
|
+
: [];
|
|
43
|
+
if (plan) {
|
|
44
|
+
if (!plans.includes(plan)) {
|
|
45
|
+
L.fail(`prepare-task: 플랜 '${plan}'이 없다. 있는 플랜: ${plans.join(', ') || '없음'}.`);
|
|
46
|
+
}
|
|
47
|
+
return plan;
|
|
48
|
+
}
|
|
49
|
+
if (plans.length === 1) return plans[0];
|
|
50
|
+
if (plans.length === 0) L.fail("prepare-task: 플랜이 없다. 'plan-init <slug>'로 플랜을 먼저 만든다.");
|
|
51
|
+
return L.fail(`prepare-task: 플랜이 여럿이라 정할 수 없다. --plan으로 하나를 넣는다: ${plans.join(', ')}.`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 본진 검사(§3-4) — 본진이 부모 브랜치에 서 있고 커밋 안 된 코드 변경이 없어야 한다
|
|
55
|
+
function checkMain(main, { self = null, parent = null } = {}) {
|
|
56
|
+
const others = L.listStates(main).filter((s) => !s.closed && (!self || s.num !== self.num) && (s.noWorktree || s.noBranch));
|
|
57
|
+
const cur = L.currentBranch(main);
|
|
58
|
+
const problems = [];
|
|
59
|
+
if (!cur) problems.push('본진이 브랜치가 아닌 커밋(detached HEAD)에 서 있다');
|
|
60
|
+
else if (parent && cur !== parent && !(self && self.noWorktree && cur === self.branch)) {
|
|
61
|
+
problems.push(`본진이 부모 브랜치 ${parent}가 아니라 ${cur}에 서 있다`);
|
|
62
|
+
} else if (!parent) {
|
|
63
|
+
const owner = others.find((s) => s.noWorktree && s.branch === cur);
|
|
64
|
+
if (owner) problems.push(`본진이 ${L.taskLabel(owner.num)}의 브랜치 ${cur}에 서 있다`);
|
|
65
|
+
}
|
|
66
|
+
const selfSkips = self && (self.noBranch || self.noWorktree);
|
|
67
|
+
const dirty = L.trackedChanges(main);
|
|
68
|
+
if (dirty.length && !selfSkips) problems.push(`본진에 커밋 안 된 코드 변경이 있다 (${dirty.slice(0, 5).join(', ')}${dirty.length > 5 ? ' …' : ''})`);
|
|
69
|
+
if (!problems.length) return cur;
|
|
70
|
+
const cause = others.length
|
|
71
|
+
? `원인 태스크: ${others.map((s) => `${L.taskLabel(s.num)}(${s.noBranch ? '브랜치 생략' : '워크트리 생략'})`).join(', ')} — 그 태스크를 마무리한 뒤 다시 부른다.`
|
|
72
|
+
: '열린 분기 생략 태스크는 없다 — 본진을 부모 브랜치로 되돌리고 변경을 정리한 뒤 다시 부른다.';
|
|
73
|
+
return L.fail(`본진(${main}) 검사에 걸렸다.\n${problems.map((p) => `- ${p}`).join('\n')}\n${cause}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 다음 태스크 번호 — .state 기록과 git 이력의 TASK-NNN 중 가장 큰 값 + 1
|
|
77
|
+
function nextNumber(main) {
|
|
78
|
+
let max = 0;
|
|
79
|
+
for (const s of L.listStates(main)) max = Math.max(max, s.num);
|
|
80
|
+
const log = L.git(main, ['log', '--all', '--format=%s', '-E', '--grep', 'TASK-[0-9]+'], { allowFail: true }) || '';
|
|
81
|
+
for (const m of log.matchAll(/TASK-(\d+)/g)) max = Math.max(max, parseInt(m[1], 10));
|
|
82
|
+
return max + 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function worktreePaths(main) {
|
|
86
|
+
const out = L.git(main, ['worktree', 'list', '--porcelain']);
|
|
87
|
+
return out
|
|
88
|
+
.split('\n')
|
|
89
|
+
.filter((l) => l.startsWith('worktree '))
|
|
90
|
+
.map((l) => l.slice('worktree '.length));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Orca 워크트리 — 만든 뒤 브랜치 이름을 리포 규칙대로 바꾼다(§3-4)
|
|
94
|
+
function createOrcaWorktree(main, { name, parent, branch }) {
|
|
95
|
+
const before = new Set(worktreePaths(main));
|
|
96
|
+
try {
|
|
97
|
+
execFileSync('orca', ['worktree', 'create', '--repo', `path:${main}`, '--name', name, '--base-branch', parent, '--json'], {
|
|
98
|
+
encoding: 'utf8',
|
|
99
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
100
|
+
});
|
|
101
|
+
} catch (e) {
|
|
102
|
+
L.fail(`Orca 워크트리를 만들지 못했다: ${((e.stderr || '') + (e.stdout || '')).toString().trim() || e.message}`);
|
|
103
|
+
}
|
|
104
|
+
const created = worktreePaths(main).filter((p) => !before.has(p));
|
|
105
|
+
if (created.length !== 1) L.fail(`Orca가 만든 워크트리를 찾지 못했다(새 워크트리 ${created.length}개). 'git worktree list'로 확인한다.`);
|
|
106
|
+
const wt = created[0];
|
|
107
|
+
L.git(wt, ['branch', '-m', branch]);
|
|
108
|
+
return wt;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function createTaskeryWorktree(main, { projectId, nnn, slug, parent, branch }) {
|
|
112
|
+
const root = path.join(os.homedir(), '.taskery', 'worktrees', projectId);
|
|
113
|
+
fs.mkdirSync(root, { recursive: true });
|
|
114
|
+
const wt = path.join(root, `TASK-${nnn}_${slug}`);
|
|
115
|
+
L.git(main, ['worktree', 'add', wt, '-b', branch, parent]);
|
|
116
|
+
return wt;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 워크트리에 taskery 파일 심기 — 지침 파일은 복사, 나머지는 본진을 가리키는 링크(§3-4)
|
|
120
|
+
function plant(main, wt) {
|
|
121
|
+
for (const name of L.PLANT_COPY) {
|
|
122
|
+
const src = path.join(main, name);
|
|
123
|
+
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(wt, name), fs.constants.COPYFILE_FICLONE);
|
|
124
|
+
}
|
|
125
|
+
for (const name of L.PLANT_LINK) {
|
|
126
|
+
const src = path.join(main, name);
|
|
127
|
+
const dst = path.join(wt, name);
|
|
128
|
+
if (fs.existsSync(src) && !fs.existsSync(dst)) fs.symlinkSync(src, dst);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function seconds(t0) {
|
|
133
|
+
return `${((Date.now() - t0) / 1000).toFixed(1)}초`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 경로가 놓인 파일 시스템 이름(apfs·hfs 등) — df로 마운트 위치를 찾고 mount 목록에서 읽는다
|
|
137
|
+
function fsType(p) {
|
|
138
|
+
try {
|
|
139
|
+
const row = execFileSync('df', ['-P', p], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().split('\n')[1];
|
|
140
|
+
const mp = row.split(/\s+/).slice(5).join(' ');
|
|
141
|
+
const line = execFileSync('mount', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
142
|
+
.split('\n')
|
|
143
|
+
.find((l) => l.includes(` on ${mp} (`));
|
|
144
|
+
return line ? line.slice(line.indexOf(` on ${mp} (`) + mp.length + 6).split(/[,)]/)[0].trim() : null;
|
|
145
|
+
} catch (e) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// APFS 복제를 할 수 없는 이유(할 수 있으면 null). cp -c는 복제가 안 되는 곳(APFS 아님·다른 볼륨)에서
|
|
151
|
+
// 조용히 통째 복사로 넘어가므로, 같은 APFS 볼륨일 때만 부른다
|
|
152
|
+
function cloneBlocker(main, wt) {
|
|
153
|
+
if (process.platform !== 'darwin') return 'APFS 복제는 macOS 전용이다';
|
|
154
|
+
const type = fsType(main);
|
|
155
|
+
if (type !== 'apfs') return `본진이 APFS가 아니다(${type || '알 수 없음'})`;
|
|
156
|
+
if (fs.statSync(main).dev !== fs.statSync(wt).dev) return '워크트리가 본진과 다른 볼륨에 있어 APFS 복제가 되지 않는다';
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 등록된 빌드 결과 폴더를 본진에서 APFS 복제(cp -Rc)로 새 워크트리에 심는다. 워크트리마다 자기 폴더로 빌드한다(§7)
|
|
161
|
+
function cloneBuildOutputs(main, wt, list) {
|
|
162
|
+
const notes = [];
|
|
163
|
+
if (!Array.isArray(list) || !list.length) return notes;
|
|
164
|
+
const blocker = cloneBlocker(main, wt);
|
|
165
|
+
if (blocker) return [`빌드 결과 폴더 복제를 건너뛰었다 — ${blocker}`];
|
|
166
|
+
for (const rel of list) {
|
|
167
|
+
const src = path.join(main, rel);
|
|
168
|
+
const dst = path.join(wt, rel);
|
|
169
|
+
if (!fs.existsSync(src)) {
|
|
170
|
+
notes.push(`빌드 결과 폴더 ${rel}가 본진에 없어 복제를 건너뛰었다(처음부터 빌드한다)`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (fs.existsSync(dst)) continue;
|
|
174
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
175
|
+
const t0 = Date.now();
|
|
176
|
+
try {
|
|
177
|
+
execFileSync('cp', ['-Rc', src, dst], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
178
|
+
notes.push(`빌드 결과 폴더 ${rel}를 APFS 복제했다(${seconds(t0)})`);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
fs.rmSync(dst, { recursive: true, force: true });
|
|
181
|
+
const why = ((e.stderr || '') + '').trim().split('\n')[0] || e.message;
|
|
182
|
+
notes.push(`빌드 결과 폴더 ${rel} 복제를 건너뛰었다(${why})`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return notes;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 끝난 태스크의 워크트리 빌드 결과 폴더를 본진으로 APFS 복제해 씨앗을 갱신한다 — 다음 태스크가 증분 빌드하게.
|
|
189
|
+
// close-task가 워크트리를 지우기 직전에 부른다. 새로 복제한 뒤 옛 폴더와 바꾼다. 안 되면 조용히 건너뛴다
|
|
190
|
+
function reseedBuildOutputs(main, wt, list) {
|
|
191
|
+
const notes = [];
|
|
192
|
+
if (!Array.isArray(list) || !list.length || !fs.existsSync(wt) || cloneBlocker(main, wt)) return notes;
|
|
193
|
+
for (const rel of list) {
|
|
194
|
+
const src = path.join(wt, rel);
|
|
195
|
+
const dst = path.join(main, rel);
|
|
196
|
+
if (!fs.existsSync(src) || fs.lstatSync(src).isSymbolicLink()) continue;
|
|
197
|
+
const tmp = `${dst}.taskery-${process.pid}`;
|
|
198
|
+
try {
|
|
199
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
200
|
+
execFileSync('cp', ['-Rc', src, tmp], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
201
|
+
fs.rmSync(dst, { recursive: true, force: true });
|
|
202
|
+
fs.renameSync(tmp, dst);
|
|
203
|
+
notes.push(`빌드 결과 폴더 ${rel}를 본진으로 APFS 복제해 다음 태스크의 씨앗으로 바꿨다`);
|
|
204
|
+
} catch (e) {
|
|
205
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return notes;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Node 패키지 설치 — package-lock.json이 있으면 npm ci 그대로(§7). 실패해도 멈추지 않고 알린다.
|
|
212
|
+
// 비동기라 병합 잠금 안에서 불러도 잠금 갱신이 멈추지 않는다
|
|
213
|
+
async function installPackages(dir) {
|
|
214
|
+
if (!fs.existsSync(path.join(dir, 'package-lock.json'))) return [];
|
|
215
|
+
const r = await L.runShell('npm ci', dir);
|
|
216
|
+
const took = `${(r.ms / 1000).toFixed(1)}초`;
|
|
217
|
+
if (r.code === 0) return [`npm ci(${took})`];
|
|
218
|
+
return [`npm ci 실패(${took}) — 워크트리에서 직접 설치한다(${dir}):\n${L.tail(r.out, 15)}`];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// PLAN.md 목록 항목 줄 끝에 태스크 번호를 붙인다(`--item`)
|
|
222
|
+
function linkItem(main, plan, item, label) {
|
|
223
|
+
const file = path.join(main, '.taskery', 'plans', plan, 'PLAN.md');
|
|
224
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
|
225
|
+
const idx = lines.findIndex((l) => new RegExp(`^\\s*${item}\\.\\s`).test(l));
|
|
226
|
+
if (idx === -1) L.fail(`prepare-task: ${plan}/PLAN.md에 항목 ${item}이 없다. 목록 형식은 '${item}. <한 줄 설명> — 선행: <항목 번호들 또는 없음>'이다.`);
|
|
227
|
+
const linked = lines[idx].match(/TASK-\d+/g) || [];
|
|
228
|
+
lines[idx] = `${lines[idx].replace(/\s+$/, '')} (${label})`;
|
|
229
|
+
fs.writeFileSync(file, lines.join('\n'));
|
|
230
|
+
return linked.length ? `항목 ${item}에는 이미 ${linked.join(', ')}가 연결돼 있다 — 그대로 ${label}을 더했다.` : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function prepareTask(ctx, a) {
|
|
234
|
+
const main = ctx.main;
|
|
235
|
+
const manifest = L.requireInstalled(main);
|
|
236
|
+
const rule = L.readGitRule(main);
|
|
237
|
+
const switches = validate(a, rule);
|
|
238
|
+
const plan = resolvePlan(main, a.plan);
|
|
239
|
+
const noBranch = !!a['no-branch'];
|
|
240
|
+
const noWorktree = noBranch || !!a['no-worktree'];
|
|
241
|
+
const parent = checkMain(main);
|
|
242
|
+
|
|
243
|
+
const opened = await L.withLock(main, 'number', async () => {
|
|
244
|
+
const fromNums = a.from ? B.parseFromList(main, a.from) : [];
|
|
245
|
+
const num = nextNumber(main);
|
|
246
|
+
const nnn = L.pad(num);
|
|
247
|
+
const branch = noBranch
|
|
248
|
+
? null
|
|
249
|
+
: L.fillTemplate(rule.branch, { type: a.type, dev: a.dev || '', num: nnn, slug: a.slug });
|
|
250
|
+
if (branch && L.branchExists(main, branch)) L.fail(`prepare-task: 브랜치 ${branch}가 이미 있다. --slug를 바꿔 다시 부른다.`);
|
|
251
|
+
const startCommit = L.git(main, ['rev-parse', 'HEAD']);
|
|
252
|
+
|
|
253
|
+
let worktree = null;
|
|
254
|
+
let by = '생략';
|
|
255
|
+
if (noBranch) {
|
|
256
|
+
// 본진의 현재 브랜치(부모)에서 그대로 일한다
|
|
257
|
+
} else if (noWorktree) {
|
|
258
|
+
L.git(main, ['checkout', '-b', branch]);
|
|
259
|
+
} else if (L.inOrca()) {
|
|
260
|
+
worktree = createOrcaWorktree(main, { name: `TASK-${nnn}-${a.slug}`, parent, branch });
|
|
261
|
+
by = 'orca';
|
|
262
|
+
} else {
|
|
263
|
+
worktree = createTaskeryWorktree(main, { projectId: manifest.projectId, nnn, slug: a.slug, parent, branch });
|
|
264
|
+
by = 'taskery';
|
|
265
|
+
}
|
|
266
|
+
L.ensureExclude(main, manifest.buildOutput);
|
|
267
|
+
if (worktree) plant(main, worktree);
|
|
268
|
+
|
|
269
|
+
const doc = path.join('.taskery', 'plans', plan, 'tasks', `${nnn}_${a.slug}.md`);
|
|
270
|
+
const st = {
|
|
271
|
+
num,
|
|
272
|
+
title: String(a.name).trim(),
|
|
273
|
+
slug: a.slug,
|
|
274
|
+
type: a.type,
|
|
275
|
+
size: a.size,
|
|
276
|
+
switch: switches,
|
|
277
|
+
range: a.range || L.DEFAULT_RANGE,
|
|
278
|
+
plan,
|
|
279
|
+
doc,
|
|
280
|
+
branch,
|
|
281
|
+
parent,
|
|
282
|
+
by,
|
|
283
|
+
worktree,
|
|
284
|
+
noWorktree,
|
|
285
|
+
noBranch,
|
|
286
|
+
startCommit,
|
|
287
|
+
times: { prepare: L.nowIso() },
|
|
288
|
+
};
|
|
289
|
+
fs.mkdirSync(path.dirname(path.join(main, doc)), { recursive: true });
|
|
290
|
+
fs.writeFileSync(path.join(main, doc), L.renderDoc(st));
|
|
291
|
+
L.writeState(main, st);
|
|
292
|
+
const itemNote = a.item != null ? linkItem(main, plan, a.item, L.taskLabel(num)) : null;
|
|
293
|
+
if (a.item != null) st.item = parseInt(a.item, 10);
|
|
294
|
+
const fromNotes = fromNums.length ? B.linkTasks(main, fromNums, L.taskLabel(num)) : [];
|
|
295
|
+
if (fromNums.length) st.from = fromNums.map((n) => `BL-${n}`);
|
|
296
|
+
if (a.item != null || fromNums.length) L.writeState(main, st);
|
|
297
|
+
|
|
298
|
+
const out = [
|
|
299
|
+
`${L.taskLabel(num)} 「${st.title}」을 열었다.`,
|
|
300
|
+
`- 태스크 문서: ${L.docShown(main, st)}`,
|
|
301
|
+
`- 플랜: ${plan}${st.item ? ` (항목 ${st.item})` : ''} · 크기: ${st.size} · 스위치: ${switches.join(',')} · 범위: ${st.range}`,
|
|
302
|
+
`- 부모 브랜치: ${parent}`,
|
|
303
|
+
];
|
|
304
|
+
if (noBranch) out.push('- 분기 생략: 본진의 현재 브랜치에서 일한다(--no-branch).');
|
|
305
|
+
else if (noWorktree) out.push(`- 브랜치: ${branch} — 본진을 이 브랜치로 옮겼다(--no-worktree). 본진에서 일한다.`);
|
|
306
|
+
else {
|
|
307
|
+
out.push(`- 브랜치: ${branch}`);
|
|
308
|
+
out.push(`- 워크트리: ${worktree} (${by === 'orca' ? 'Orca가 만듦' : 'taskery가 만듦'})`);
|
|
309
|
+
}
|
|
310
|
+
if (st.from) out.push(`- 백로그: ${st.from.join(', ')} — 진행`);
|
|
311
|
+
if (itemNote) out.push(`- 알림: ${itemNote}`);
|
|
312
|
+
for (const n of fromNotes) out.push(`- 알림: ${n}`);
|
|
313
|
+
return { out, worktree };
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ⑤ 워크트리 준비 — 번호 잠금 밖에서 한다(동시에 여는 태스크끼리 기다리지 않게)
|
|
317
|
+
const out = opened.out;
|
|
318
|
+
if (opened.worktree) {
|
|
319
|
+
const prep = [...cloneBuildOutputs(main, opened.worktree, manifest.buildOutput), ...(await installPackages(opened.worktree))];
|
|
320
|
+
for (const n of prep) out.push(`- 준비: ${n}`);
|
|
321
|
+
}
|
|
322
|
+
out.push(opened.worktree ? `다음: 워크트리로 들어가(Claude Code는 EnterWorktree) task-plan을 한다.` : '다음: task-plan을 한다.');
|
|
323
|
+
return out.join('\n');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
module.exports = { prepareTask, checkMain, installPackages, reseedBuildOutputs };
|