@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.
Files changed (61) hide show
  1. package/README.md +423 -188
  2. package/bin/add.js +27 -99
  3. package/bin/backlog.js +240 -113
  4. package/bin/check.js +166 -0
  5. package/bin/commands.js +151 -0
  6. package/bin/finish.js +353 -0
  7. package/bin/init.js +99 -190
  8. package/bin/install.js +143 -0
  9. package/bin/lib.js +513 -1325
  10. package/bin/mcp.js +31 -263
  11. package/bin/orche.js +240 -0
  12. package/bin/plan.js +58 -120
  13. package/bin/prepare.js +326 -0
  14. package/bin/prune.js +46 -176
  15. package/bin/status.js +57 -151
  16. package/bin/taskery.js +76 -120
  17. package/bin/update.js +66 -269
  18. package/package.json +5 -5
  19. package/template/.taskery/BACKLOG.md +38 -0
  20. package/template/.taskery/rules/CHANGELOG_RULE.md +31 -0
  21. package/template/.taskery/rules/DEV_RULE.local.md +8 -0
  22. package/template/.taskery/rules/GIT_RULE.md +82 -0
  23. package/template/.taskery/rules/MOCKUP_RULE.md +19 -0
  24. package/template/.taskery/rules/TASKERY_RULE.md +112 -0
  25. package/template/.taskery/rules/TASK_DOC_RULE.md +84 -0
  26. package/template/.taskery/rules/TEST_RULE.local.md +16 -0
  27. package/template/AGENTS.md +15 -53
  28. package/template/shared/skills/add-backlog/SKILL.md +8 -38
  29. package/template/shared/skills/log-friction/SKILL.md +10 -88
  30. package/template/shared/skills/plan-init/SKILL.md +10 -98
  31. package/template/shared/skills/project-init/SKILL.md +13 -342
  32. package/template/shared/skills/task-close/SKILL.md +16 -319
  33. package/template/shared/skills/task-dev/SKILL.md +19 -233
  34. package/template/shared/skills/task-init/SKILL.md +11 -68
  35. package/template/shared/skills/task-orche/SKILL.md +35 -0
  36. package/template/shared/skills/task-plan/SKILL.md +19 -498
  37. package/template/shared/skills/task-test/SKILL.md +23 -342
  38. package/bin/close.js +0 -86
  39. package/bin/fork.js +0 -112
  40. package/bin/legacy-quotes.js +0 -60
  41. package/bin/migrate.js +0 -481
  42. package/bin/set-status.js +0 -98
  43. package/template/.claude/hooks/closed-immutable.sh +0 -77
  44. package/template/.claude/settings.json +0 -18
  45. package/template/.claude/skills/run-team/SKILL.md +0 -145
  46. package/template/.codex/agents/task-tester.toml +0 -41
  47. package/template/.codex/agents/test-plan-author.toml +0 -34
  48. package/template/.codex/config.toml +0 -17
  49. package/template/.codex/hooks/closed-immutable.sh +0 -66
  50. package/template/.project/changelog/.gitkeep +0 -0
  51. package/template/.project/flows/.gitkeep +0 -0
  52. package/template/.project/plans/.gitkeep +0 -0
  53. package/template/.project/rules/CHANGELOG_RULE.md +0 -97
  54. package/template/.project/rules/GIT_RULE.md +0 -325
  55. package/template/.project/rules/MOCKUP_RULE.md +0 -86
  56. package/template/.project/rules/TASKERY_RULE.md +0 -237
  57. package/template/.project/rules/TASK_DOC_RULE.md +0 -668
  58. package/template/.project/shared/received/completed/.gitkeep +0 -0
  59. package/template/.project/shared/sent/completed/.gitkeep +0 -0
  60. package/template/.project/tasks/.gitkeep +0 -0
  61. package/template/shared/hooks/git-guard.sh +0 -108
package/bin/check.js ADDED
@@ -0,0 +1,166 @@
1
+ // approve-plan · test-code · test-scenario — 계획 끝 기록, 코드 테스트, 실사용 시나리오 결과(§5-3)
2
+ const L = require('./lib');
3
+
4
+ async function approvePlan(ctx, a) {
5
+ const main = ctx.main;
6
+ const num = L.parseTaskNum(a.task);
7
+ const st = L.readState(main, num);
8
+ if (st.closed) L.fail(`${L.taskLabel(num)}은 이미 닫혔다.`);
9
+ const text = L.readDoc(main, st);
10
+ const problems = [];
11
+ if (!L.goalLine(text)) problems.push('`## 목표`가 비어 있다');
12
+ const files = L.plannedFiles(text);
13
+ if (!files.length) problems.push('`## 만질 파일`(medium·large는 `## Phase`의 `- 파일:` 포함)에 파일이 없다 — 한 줄에 `- 경로` 하나씩');
14
+ if (L.sw(st, 'test')) {
15
+ const n = L.parseCriteria(text).length;
16
+ if (n < 1 || n > 5) {
17
+ problems.push(`\`## 완료 기준\` 시나리오가 ${n}개다 — 테스트 켜짐이면 1~5개를 \`1. [AUTO] 시작 → 행동 → 기대하는 끝 상태\` 형식으로 적는다`);
18
+ }
19
+ }
20
+ if (problems.length) L.fail(`approve-plan: ${L.taskLabel(num)} 태스크 문서를 고친 뒤 다시 부른다.\n${problems.map((p) => `- ${p}`).join('\n')}\n문서: ${L.docShown(main, st)}`);
21
+ st.approve = { at: L.nowIso(), files };
22
+ L.writeState(main, st);
23
+ L.syncDoc(main, st);
24
+ const d = L.stageDurations(st);
25
+ const next = L.sw(st, 'dev') ? '개발(task-dev)' : '테스트(task-test)';
26
+ return `${L.taskLabel(num)} 계획을 기록했다 — 시작·기획 ${L.minutes(d.plan)}, 만질 파일 ${files.length}개.\n다음: ${next}.`;
27
+ }
28
+
29
+ // 등록된 코드 테스트를 dir에서 차례로 실행한다. 실패하면 멈춤 오류를 던진다
30
+ async function runCodeTests(main, dir) {
31
+ const manifest = L.requireInstalled(main);
32
+ const cmds = manifest.codeTest;
33
+ if (cmds === 'none') return { none: true, ms: 0, outputs: [] };
34
+ if (!Array.isArray(cmds) || cmds.length === 0) {
35
+ L.fail(
36
+ 'test-code: 등록된 코드 테스트 명령이 없다. 리포를 보고 화면을 켜지 않는 코드 테스트 명령(린트·타입·빌드·단위 테스트)을 정해 ' +
37
+ "`test-code --register \"<명령>\" [\"<명령>\" …]`으로 등록한 뒤 다시 부른다. 코드 테스트가 없는 리포면 `test-code --register none`.",
38
+ );
39
+ }
40
+ let total = 0;
41
+ const outputs = [];
42
+ for (const cmd of cmds) {
43
+ const r = await L.runShell(cmd, dir);
44
+ total += r.ms;
45
+ if (r.code !== 0) {
46
+ L.fail(`test-code: 실패 — \`${cmd}\` (종료 코드 ${r.code})\n--- 출력 끝부분 ---\n${L.tail(r.out, 60)}\n---\n원인을 고친 뒤 다시 부른다.${overNotice(total, cmds)}`);
47
+ }
48
+ outputs.push({ cmd, out: r.out });
49
+ }
50
+ return { none: false, ms: total, outputs };
51
+ }
52
+
53
+ function overNotice(totalMs, cmds) {
54
+ if (totalMs <= L.CODE_TEST_NOTICE_MS) return '';
55
+ return (
56
+ `\n알림: 코드 테스트가 3분을 넘었다(${L.minutes(totalMs)}) — 화면을 켜는 테스트가 섞였는지 확인한다.\n` +
57
+ `지금 등록된 명령: ${cmds.map((c) => `\`${c}\``).join(', ')}\n` +
58
+ '바꾸려면 `test-code --register "<명령>" …`으로 목록 전체를 다시 등록한다.'
59
+ );
60
+ }
61
+
62
+ async function testCode(ctx, a) {
63
+ const main = ctx.main;
64
+ if (a.register) {
65
+ const list = a.register.map((s) => String(s).trim()).filter(Boolean);
66
+ if (!list.length) L.fail('test-code --register: 등록할 명령을 하나 이상 넣는다. 코드 테스트가 없는 리포면 `--register none`.');
67
+ const manifest = L.requireInstalled(main);
68
+ manifest.codeTest = list.length === 1 && list[0] === 'none' ? 'none' : list;
69
+ L.writeManifest(main, manifest);
70
+ return manifest.codeTest === 'none'
71
+ ? '코드 테스트 없음으로 등록했다 — test-code는 코드 지문만 남기고 통과한다.'
72
+ : `코드 테스트 명령 ${list.length}개를 등록했다(목록 전체를 바꿨다):\n${list.map((c) => `- ${c}`).join('\n')}`;
73
+ }
74
+ let st = null;
75
+ let dir;
76
+ if (a.task) {
77
+ st = L.readState(main, L.parseTaskNum(a.task));
78
+ if (st.closed) L.fail(`${L.taskLabel(st.num)}은 이미 닫혔다.`);
79
+ dir = L.workDir(main, st);
80
+ } else {
81
+ dir = L.git(ctx.cwd, ['rev-parse', '--show-toplevel']);
82
+ }
83
+ const result = await runCodeTests(main, dir);
84
+ const cmds = L.requireInstalled(main).codeTest;
85
+ const lines = [];
86
+ if (result.none) lines.push('코드 테스트 없음으로 등록된 리포 — 실행 없이 통과.');
87
+ else {
88
+ lines.push(`통과 — ${result.outputs.length}개 명령, ${L.minutes(result.ms)}.`);
89
+ for (const o of result.outputs) lines.push(`--- \`${o.cmd}\` 출력 끝부분 ---\n${L.tail(o.out, 15) || '(출력 없음)'}`);
90
+ }
91
+ if (st) {
92
+ st.testCode = { at: L.nowIso(), fingerprint: L.fingerprint(dir) };
93
+ L.writeState(main, st);
94
+ L.syncDoc(main, st);
95
+ lines.push(`${L.taskLabel(st.num)} 개발 칸을 기록했다 — 개발 ${L.minutes(L.stageDurations(st).dev)}.`);
96
+ }
97
+ if (!result.none) lines.push(overNotice(result.ms, cmds).trim());
98
+ return lines.filter(Boolean).join('\n');
99
+ }
100
+
101
+ const RESULTS = ['pass', 'fail', 'accept'];
102
+
103
+ function lastEntry(st, n) {
104
+ const list = (st.scenarios && st.scenarios[n]) || [];
105
+ return list[list.length - 1] || null;
106
+ }
107
+
108
+ async function testScenario(ctx, a) {
109
+ const main = ctx.main;
110
+ const num = L.parseTaskNum(a.task);
111
+ const st = L.readState(main, num);
112
+ if (st.closed) L.fail(`${L.taskLabel(num)}은 이미 닫혔다.`);
113
+ const criteria = L.parseCriteria(L.readDoc(main, st));
114
+ const n = parseInt(a.number, 10);
115
+ if (!criteria.length) L.fail('test-scenario: 태스크 문서 `## 완료 기준`에 시나리오가 없다.');
116
+ if (!(n >= 1 && n <= criteria.length)) L.fail(`test-scenario: 시나리오 번호는 1~${criteria.length} 중 하나다 (받음: '${a.number}').`);
117
+ const result = String(a.result || '').toLowerCase();
118
+ if (!RESULTS.includes(result)) L.fail("test-scenario: 결과는 pass · fail · accept 중 하나다.");
119
+ const text = String(a.text || '').trim();
120
+ if (!text) {
121
+ L.fail(
122
+ result === 'accept'
123
+ ? 'test-scenario accept: FAIL을 알고 넘어가자고 한 사용자의 말을 그대로 넣는다.'
124
+ : 'test-scenario: 증거(실행 출력, 화면 캡처 경로, 또는 사용자 확인 시각)를 넣는다. 증거 없는 PASS는 적지 않는다.',
125
+ );
126
+ }
127
+ if (result === 'accept') {
128
+ const last = lastEntry(st, n);
129
+ if (!last || last.result !== 'fail') L.fail(`test-scenario accept: 시나리오 ${n}의 마지막 결과가 FAIL일 때만 받는다.`);
130
+ }
131
+ const at = L.nowIso();
132
+ st.scenarios = st.scenarios || {};
133
+ st.scenarios[n] = st.scenarios[n] || [];
134
+ st.scenarios[n].push(result === 'accept' ? { result, quote: text, at } : { result, evidence: text, at });
135
+ const label = result === 'pass' ? 'PASS' : result === 'fail' ? 'FAIL' : 'FAIL 수락';
136
+ const body = result === 'accept' ? `사용자: "${text}"` : text;
137
+
138
+ // 모든 시나리오가 PASS거나 FAIL 뒤 accept면 테스트 칸을 채운다
139
+ let complete = true;
140
+ let accepted = false;
141
+ for (let i = 1; i <= criteria.length; i++) {
142
+ const last = lastEntry(st, i);
143
+ if (!last || last.result === 'fail') complete = false;
144
+ else if (last.result === 'accept') accepted = true;
145
+ }
146
+ if (complete) st.testDone = { at, accepted };
147
+ else delete st.testDone;
148
+ L.writeState(main, st);
149
+ L.appendResult(main, st, `- 시나리오 ${n} · ${label} · ${body} · ${L.clock(at)}`);
150
+ L.syncDoc(main, st);
151
+
152
+ const out = [`${L.taskLabel(num)} 시나리오 ${n}: ${label} 기록.`];
153
+ if (complete) out.push(`모든 시나리오가 끝났다 — 테스트 칸을 채웠다(${L.minutes(L.stageDurations(st).test)}${accepted ? ', FAIL 수락' : ''}). 다음: 마무리(task-close).`);
154
+ else {
155
+ const left = [];
156
+ for (let i = 1; i <= criteria.length; i++) {
157
+ const last = lastEntry(st, i);
158
+ if (!last) left.push(`${i}(결과 없음)`);
159
+ else if (last.result === 'fail') left.push(`${i}(FAIL)`);
160
+ }
161
+ out.push(`남은 시나리오: ${left.join(', ')}`);
162
+ }
163
+ return out.join('\n');
164
+ }
165
+
166
+ module.exports = { approvePlan, testCode, testScenario, runCodeTests, lastEntry };
@@ -0,0 +1,151 @@
1
+ // 명령 정의표 — 이름·받는 값·할 일을 한 번만 적고, CLI 입구(taskery.js)와 MCP 도구(mcp.js)를 여기서 만든다(§5-3)
2
+ const L = require('./lib');
3
+ const { prepareTask } = require('./prepare');
4
+ const { approvePlan, testCode, testScenario } = require('./check');
5
+ const { verifyClose, commitTask, mergeTask, closeTask } = require('./finish');
6
+ const { status } = require('./status');
7
+ const { planInit } = require('./plan');
8
+ const { backlogAdd, backlogGet, backlogMark } = require('./backlog');
9
+ const { prune } = require('./prune');
10
+ const { orcaDispatchTask, reportTask, waitReports } = require('./orche');
11
+
12
+ // 인자 종류: positional(순서대로) · string(--이름 값) · bool(--이름) · list(--이름 값 값 …)
13
+ const TASK = { name: 'task', positional: true, desc: '태스크 번호 (TASK-012 또는 12)' };
14
+ const RANGE = { name: 'range', desc: '사용자가 새로 말한 진행 범위 — 태스크 문서의 범위 메모만 바꾼다' };
15
+
16
+ const COMMANDS = [
17
+ {
18
+ name: 'status',
19
+ summary: '열린 태스크·단계 표·단계별 시간·문서 경로·워크트리 경로, 플랜별 시작할 수 있는 태스크를 보여 준다',
20
+ args: [],
21
+ run: status,
22
+ },
23
+ {
24
+ name: 'plan-init',
25
+ summary: '다음 플랜 번호로 .taskery/plans/<NNN>_<slug>/PLAN.md 틀을 만든다',
26
+ args: [
27
+ { name: 'slug', positional: true, desc: '플랜 이름 — 영문 kebab-case (필수)' },
28
+ { name: 'title', desc: '플랜 제목 (없으면 slug)' },
29
+ ],
30
+ run: planInit,
31
+ },
32
+ {
33
+ name: 'prepare-task',
34
+ summary: '태스크를 연다 — 번호·브랜치·워크트리·태스크 문서·기록',
35
+ args: [
36
+ { name: 'name', positional: true, desc: '태스크 제목 한 줄 (필수)' },
37
+ { name: 'slug', desc: '영문 kebab-case — 브랜치·문서 파일 이름 (필수)' },
38
+ { name: 'type', desc: 'feature · bug · improve · refactor · docs · chore (필수)' },
39
+ { name: 'size', desc: 'small · medium · large (필수)' },
40
+ { name: 'switch', desc: '켜진 스위치 목록, 예 plan,dev,test (기본 모두 켜짐)' },
41
+ { name: 'range', desc: '사용자가 말한 진행 범위 그대로 (기본 한 단계)' },
42
+ { name: 'plan', desc: '플랜 폴더 이름 (플랜이 하나면 생략)' },
43
+ { name: 'item', desc: 'PLAN.md 태스크 목록의 항목 번호' },
44
+ { name: 'from', desc: '옮겨 오는 백로그 번호 목록, 예 BL-3,BL-7' },
45
+ { name: 'dev', desc: '작업자 식별자 (GIT_RULE.md 브랜치 이름에 작업자 칸이 있을 때 필수)' },
46
+ { name: 'no-worktree', type: 'bool', desc: '워크트리 생략 — 사용자가 명시했을 때만' },
47
+ { name: 'no-branch', type: 'bool', desc: '브랜치·워크트리 생략 — 사용자가 명시했을 때만' },
48
+ ],
49
+ run: prepareTask,
50
+ },
51
+ {
52
+ name: 'approve-plan',
53
+ summary: '태스크 문서(목표·만질 파일·완료 기준)를 검사하고 계획 끝을 기록한다',
54
+ args: [TASK, RANGE],
55
+ run: approvePlan,
56
+ },
57
+ {
58
+ name: 'test-code',
59
+ summary: '등록된 코드 테스트를 실행한다(태스크를 주면 개발 칸 기록). --register로 명령 목록을 등록·교체한다',
60
+ args: [
61
+ { ...TASK, desc: '태스크 번호 (없으면 기록 없이 결과만)' },
62
+ { name: 'register', type: 'list', desc: '코드 테스트 명령 목록 — 부를 때마다 목록 전체를 바꾼다. 없으면 none' },
63
+ RANGE,
64
+ ],
65
+ run: testCode,
66
+ },
67
+ {
68
+ name: 'test-scenario',
69
+ summary: '완료 기준 시나리오 결과를 기록한다 — pass|fail "증거" 또는 accept "<사용자가 한 말>"',
70
+ args: [
71
+ TASK,
72
+ { name: 'number', positional: true, desc: '시나리오 번호' },
73
+ { name: 'result', positional: true, desc: 'pass · fail · accept' },
74
+ { name: 'text', positional: true, desc: 'pass·fail은 증거, accept는 사용자가 한 말' },
75
+ RANGE,
76
+ ],
77
+ run: testScenario,
78
+ },
79
+ { name: 'verify-close', summary: '마무리 검사만 한다', args: [TASK, RANGE], run: verifyClose },
80
+ { name: 'commit-task', summary: 'Phase별 코드 커밋만 하고 병합 확인 요약을 낸다', args: [TASK, RANGE], run: commitTask },
81
+ { name: 'merge-task', summary: '병합 잠금 안에서 부모 최신으로 rebase한 뒤 병합한다', args: [TASK, RANGE], run: mergeTask },
82
+ { name: 'close-task', summary: '워크트리·브랜치를 정리하고 태스크를 닫는다(주인이 본진에서)', args: [TASK], run: closeTask },
83
+ {
84
+ name: 'prune',
85
+ summary: '닫힌 태스크에 남은 워크트리·브랜치를 정리한다 — 기본은 항목마다 묻는다',
86
+ args: [{ name: 'yes', type: 'bool', desc: '묻지 않고 정리 — 사용자가 명시했을 때만. 커밋 안 된 변경이 있는 곳은 건너뛴다' }],
87
+ interactive: true,
88
+ run: prune,
89
+ },
90
+ {
91
+ name: 'backlog-add',
92
+ summary: '백로그 번호를 발급해 BACKLOG.md 열린 항목 맨 위에 빈 양식을 넣는다',
93
+ args: [
94
+ { name: 'title', positional: true, desc: '항목 제목 한 줄 (필수)' },
95
+ { name: 'type', desc: 'bug · improve · feature · refactor · docs · chore' },
96
+ ],
97
+ run: backlogAdd,
98
+ },
99
+ {
100
+ name: 'backlog-get',
101
+ summary: '번호를 주면 그 항목 전문, 없으면 열린 항목 목록(번호·종류·제목·상태)',
102
+ args: [{ name: 'id', positional: true, desc: '백로그 번호 (BL-3 또는 3)' }],
103
+ run: backlogGet,
104
+ },
105
+ {
106
+ name: 'backlog-mark',
107
+ summary: '--from 없이 연 태스크를 백로그 항목에 연결한다(연결 태스크 칸·상태 진행)',
108
+ args: [
109
+ { name: 'id', positional: true, desc: '백로그 번호 (BL-3 또는 3)' },
110
+ { name: 'task', positional: true, desc: '태스크 번호 (TASK-012 또는 12)' },
111
+ ],
112
+ run: backlogMark,
113
+ },
114
+ {
115
+ name: 'orca-dispatch-task',
116
+ summary: 'Orca 새 탭에 태스크 세션을 띄우고 첫 지시문을 보낸다(Orca 전용). 탭 handle은 태스크 문서 메타에 기록한다',
117
+ args: [
118
+ TASK,
119
+ { name: 'agent', desc: 'claude · codex (필수)' },
120
+ { name: 'model', desc: '에이전트 모델 — 사용자가 정한 것 (필수)' },
121
+ { name: 'note', desc: '첫 지시문 끝에 붙일 오케스트레이션의 말 한 줄' },
122
+ ],
123
+ run: orcaDispatchTask,
124
+ },
125
+ {
126
+ name: 'report-task',
127
+ summary: '태스크 세션이 오케스트레이션에 보고 한 줄을 남긴다(.taskery/reports.log)',
128
+ args: [TASK, { name: 'text', positional: true, desc: '보고 한 줄 (필수)' }],
129
+ run: reportTask,
130
+ },
131
+ {
132
+ name: 'wait-reports',
133
+ summary: '안 읽은 보고가 생기면 그 줄들을 출력하고 끝난다. 보고 없이 10분이면 오래 조용한 태스크 탭 목록과 함께 끝난다(백그라운드 셸로 건다)',
134
+ args: [],
135
+ cliOnly: true,
136
+ run: waitReports,
137
+ },
138
+ ];
139
+
140
+ // 명령 실행 공통 — 본진을 찾고, --range가 있으면 범위 메모를 먼저 갱신한다
141
+ // ask는 묻는 명령(prune)에 CLI가 넘기는 질문 함수다. MCP에는 없다
142
+ async function execute(cmd, args, { cwd = process.cwd(), main = null, ask = null } = {}) {
143
+ const ctx = { cwd, main: main || L.findMain(cwd), ask };
144
+ if (args.range && args.task && cmd.args.includes(RANGE)) {
145
+ const st = L.readState(ctx.main, L.parseTaskNum(args.task));
146
+ L.updateRange(ctx.main, st, String(args.range));
147
+ }
148
+ return cmd.run(ctx, args);
149
+ }
150
+
151
+ module.exports = { COMMANDS, execute };
package/bin/finish.js ADDED
@@ -0,0 +1,353 @@
1
+ // verify-close · commit-task · merge-task · close-task — 마무리 4명령(검사·커밋·병합·정리, §5-3)
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { execFileSync } = require('child_process');
5
+ const L = require('./lib');
6
+ const { checkMain, installPackages, reseedBuildOutputs } = require('./prepare');
7
+ const B = require('./backlog');
8
+ const { runCodeTests, lastEntry } = require('./check');
9
+
10
+ function openState(main, task) {
11
+ const num = L.parseTaskNum(task);
12
+ const st = L.readState(main, num);
13
+ if (st.closed) L.fail(`${L.taskLabel(num)}은 이미 닫혔다.`);
14
+ return st;
15
+ }
16
+
17
+ // 시작 커밋 이후 이 태스크의 코드 변경이 있나 — --no-branch는 작업 트리만 본다
18
+ function hasCodeChange(main, st) {
19
+ const dir = L.workDir(main, st);
20
+ if (L.changedFiles(dir).length) return true;
21
+ if (st.noBranch) return false;
22
+ return parseInt(L.git(dir, ['rev-list', '--count', `${st.startCommit}..HEAD`]), 10) > 0;
23
+ }
24
+
25
+ async function verifyClose(ctx, a) {
26
+ const main = ctx.main;
27
+ const st = openState(main, a.task);
28
+ const dir = L.workDir(main, st);
29
+ const problems = [];
30
+ if (!st.approve) problems.push('계획 끝 표시가 없다 — 태스크 문서를 채우고 approve-plan을 부른다');
31
+ if (L.sw(st, 'dev')) {
32
+ if (!st.testCode) problems.push('test-code 통과 기록이 없다 — test-code를 부른다');
33
+ else if (L.fingerprint(dir) !== st.testCode.fingerprint) problems.push('test-code 통과 뒤 코드가 바뀌었다 — test-code를 다시 부른다');
34
+ } else if (hasCodeChange(main, st)) {
35
+ problems.push(
36
+ st.noBranch
37
+ ? '개발 꺼짐 태스크인데 작업 트리에 커밋 안 된 코드 변경이 있다 — 되돌리거나 스위치를 다시 정한다'
38
+ : '개발 꺼짐 태스크인데 시작 이후 코드 변경(커밋 또는 작업 트리)이 있다 — 되돌리거나 스위치를 다시 정한다',
39
+ );
40
+ }
41
+ if (L.sw(st, 'test')) {
42
+ const criteria = L.parseCriteria(L.readDoc(main, st));
43
+ if (!criteria.length) problems.push('`## 완료 기준`에 시나리오가 없다');
44
+ for (let i = 1; i <= criteria.length; i++) {
45
+ const last = lastEntry(st, i);
46
+ if (!last) problems.push(`시나리오 ${i}의 결과·증거가 없다 — test-scenario로 기록한다`);
47
+ else if (last.result === 'fail') problems.push(`시나리오 ${i}가 FAIL이다 — 고쳐서 다시 확인하거나, 사용자가 알고 넘어가기로 했으면 test-scenario … accept로 그 말을 기록한다`);
48
+ }
49
+ }
50
+ if (problems.length) L.fail(`verify-close: ${L.taskLabel(st.num)} 마무리 검사에 걸렸다.\n${problems.map((p) => `- ${p}`).join('\n')}`);
51
+ st.verify = { at: L.nowIso() };
52
+ L.writeState(main, st);
53
+ return `${L.taskLabel(st.num)} 마무리 검사 통과. 다음: commit-task.`;
54
+ }
55
+
56
+ // 바뀐 파일을 Phase별로 나눈다 — 여러 Phase면 앞 Phase, 목록에 없으면 마지막 Phase(§4-5)
57
+ function groupByPhase(files, phases) {
58
+ const groups = phases.map(() => []);
59
+ const notes = {};
60
+ for (const f of files) {
61
+ const hits = phases.map((p, i) => (p.files.some((pf) => L.fileMatches(f, pf)) ? i : -1)).filter((i) => i >= 0);
62
+ if (hits.length === 0) groups[phases.length - 1].push(f);
63
+ else {
64
+ groups[hits[0]].push(f);
65
+ if (hits.length > 1) notes[f] = ` (Phase ${hits.map((i) => phases[i].num).join('·')} 변경 포함)`;
66
+ }
67
+ }
68
+ return { groups, notes };
69
+ }
70
+
71
+ function commitFiles(dir, files, message) {
72
+ L.git(dir, ['add', '-A', '--', ...files]);
73
+ L.git(dir, ['commit', '-q', '-m', message]);
74
+ return L.git(dir, ['rev-parse', 'HEAD']);
75
+ }
76
+
77
+ function commitBody(files, notes, reason) {
78
+ return `\n\n${files.map((f) => `- ${f}${notes[f] || ''}`).join('\n')}\n- 사유: ${reason}`;
79
+ }
80
+
81
+ async function commitTask(ctx, a) {
82
+ const main = ctx.main;
83
+ const st = openState(main, a.task);
84
+ if (!st.verify) L.fail(`commit-task: ${L.taskLabel(st.num)}의 마무리 검사 기록이 없다. verify-close를 먼저 부른다.`);
85
+ const dir = L.workDir(main, st);
86
+ const text = L.readDoc(main, st);
87
+ const rule = L.readGitRule(main);
88
+ const made = [];
89
+ if (L.sw(st, 'dev')) {
90
+ const files = L.changedFiles(dir);
91
+ if (files.length) {
92
+ const tag = L.TAG_OF[st.type] || 'docs';
93
+ const num = L.pad(st.num);
94
+ const phases = st.size === 'small' ? [] : L.parsePhases(text);
95
+ if (phases.length) {
96
+ const { groups, notes } = groupByPhase(files, phases);
97
+ phases.forEach((p, i) => {
98
+ if (!groups[i].length) return;
99
+ const summary = `Phase ${p.num} - ${p.name}`;
100
+ const msg = L.fillTemplate(rule.commit, { tag, num, summary }) + commitBody(groups[i], notes, p.reason || p.name);
101
+ made.push({ sha: commitFiles(dir, groups[i], msg), subject: msg.split('\n')[0], files: groups[i] });
102
+ });
103
+ } else {
104
+ const msg = L.fillTemplate(rule.commit, { tag, num, summary: st.title }) + commitBody(files, {}, L.goalLine(text) || st.title);
105
+ made.push({ sha: commitFiles(dir, files, msg), subject: msg.split('\n')[0], files });
106
+ }
107
+ }
108
+ }
109
+ const prev = (st.commit && st.commit.commits) || [];
110
+ const commits = [...prev, ...made];
111
+ const noCodeChange = !L.sw(st, 'dev') || (st.noBranch ? commits.length === 0 : !hasCodeChange(main, st));
112
+ st.commit = { at: L.nowIso(), noCodeChange, commits };
113
+ L.writeState(main, st);
114
+
115
+ // 병합 확인 화면 — 커밋 목록·바뀐 파일·단계별 시간·계획 확인 뒤 추가된 파일
116
+ let changed;
117
+ let list;
118
+ if (st.noBranch) {
119
+ list = commits.map((c) => `${c.sha.slice(0, 7)} ${c.subject}`);
120
+ changed = [...new Set(commits.flatMap((c) => c.files))];
121
+ } else {
122
+ const log = L.git(dir, ['log', '--format=%h %s', `${st.startCommit}..HEAD`]);
123
+ list = log ? log.split('\n') : [];
124
+ const diff = L.git(dir, ['diff', '--name-only', st.startCommit, 'HEAD']);
125
+ changed = diff ? diff.split('\n') : [];
126
+ }
127
+ const planned = (st.approve && st.approve.files) || [];
128
+ const added = changed.filter((f) => !planned.some((p) => L.fileMatches(f, p)));
129
+ const d = L.stageDurations(st);
130
+ const out = [`${L.taskLabel(st.num)} 「${st.title}」 커밋 요약`];
131
+ if (noCodeChange) out.push('- 코드 변경 없음 — 커밋하지 않았다. merge-task는 병합을 건너뛴다.');
132
+ else {
133
+ out.push(`- 커밋 ${list.length}개:`);
134
+ for (const l of list) out.push(` - ${l}`);
135
+ out.push(`- 바뀐 파일 ${changed.length}개: ${changed.join(', ')}`);
136
+ }
137
+ out.push(
138
+ `- 단계별 시간: 시작·기획 ${L.minutes(d.plan) || '–'} · 개발 ${L.sw(st, 'dev') ? L.minutes(d.dev) || '–' : '꺼짐'} · 테스트 ${L.sw(st, 'test') ? L.minutes(d.test) || '–' : '꺼짐'}`,
139
+ );
140
+ out.push(`- 계획 확인 뒤 추가된 파일: ${added.length ? added.join(', ') : '없음'}`);
141
+ out.push('다음: merge-task (진행 범위가 "끝까지"가 아니면 사용자 확인을 받은 뒤).');
142
+ return out.join('\n');
143
+ }
144
+
145
+ function gitPath(dir, name) {
146
+ return L.git(dir, ['rev-parse', '--path-format=absolute', '--git-path', name]);
147
+ }
148
+
149
+ function rebaseInProgress(dir) {
150
+ return fs.existsSync(gitPath(dir, 'rebase-merge')) || fs.existsSync(gitPath(dir, 'rebase-apply'));
151
+ }
152
+
153
+ function unmergedFiles(dir) {
154
+ const out = L.git(dir, ['diff', '--name-only', '--diff-filter=U']);
155
+ return out ? out.split('\n') : [];
156
+ }
157
+
158
+ const MARKER_RE = /^(<{7}|>{7})( |$)/m;
159
+
160
+ function conflictStop(st, files) {
161
+ return L.fail(
162
+ `merge-task: ${L.taskLabel(st.num)} rebase 중 충돌이 났다. 잠금은 풀었고 rebase 진행 상태는 그대로 두었다.\n` +
163
+ `충돌 파일:\n${files.map((f) => `- ${f}`).join('\n')}\n` +
164
+ '충돌 파일을 직접 읽고 고친 뒤 merge-task를 다시 부른다(스테이징·이어 가기는 명령이 한다). 풀지 못하면 멈추고 보고한다.',
165
+ );
166
+ }
167
+
168
+ // 부모 최신 위로 rebase — 진행 중인 rebase가 있으면 이어 가고, 부모가 움직였으면 다시 한다
169
+ function rebaseOntoParent(dir, st) {
170
+ const env = { GIT_EDITOR: 'true' };
171
+ for (let guard = 0; guard < 100; guard++) {
172
+ if (rebaseInProgress(dir)) {
173
+ const unmerged = unmergedFiles(dir);
174
+ const marked = unmerged.filter((f) => {
175
+ const p = path.join(dir, f);
176
+ return fs.existsSync(p) && MARKER_RE.test(fs.readFileSync(p, 'utf8'));
177
+ });
178
+ if (marked.length) conflictStop(st, marked);
179
+ L.git(dir, ['add', '-A']);
180
+ if (L.git(dir, ['-c', 'core.editor=true', 'rebase', '--continue'], { env, allowFail: true }) === null) {
181
+ if (rebaseInProgress(dir) && unmergedFiles(dir).length) conflictStop(st, unmergedFiles(dir));
182
+ if (rebaseInProgress(dir)) L.fail('merge-task: rebase를 이어 가지 못했다. `git status`로 상태를 확인한다.');
183
+ }
184
+ continue;
185
+ }
186
+ if (L.isAncestor(dir, st.parent, 'HEAD')) return;
187
+ if (L.git(dir, ['rebase', st.parent], { env, allowFail: true }) === null) {
188
+ if (rebaseInProgress(dir)) conflictStop(st, unmergedFiles(dir));
189
+ L.fail('merge-task: rebase에 실패했다. `git status`로 상태를 확인한다.');
190
+ }
191
+ }
192
+ return L.fail('merge-task: rebase가 끝나지 않는다. `git status`로 상태를 확인한다.');
193
+ }
194
+
195
+ async function mergeTask(ctx, a) {
196
+ const main = ctx.main;
197
+ const st = openState(main, a.task);
198
+ if (!st.commit) L.fail(`merge-task: ${L.taskLabel(st.num)}의 커밋 기록이 없다. commit-task를 먼저 부른다.`);
199
+ if (st.merge && st.merge.commit) return `${L.taskLabel(st.num)}은 이미 병합했다 (${st.merge.commit.slice(0, 7)}).`;
200
+
201
+ if (L.needsNoMerge(st)) {
202
+ let note = '';
203
+ if (st.noWorktree && L.currentBranch(main) !== st.parent) {
204
+ L.git(main, ['checkout', st.parent]);
205
+ note = ` 본진을 부모 브랜치 ${st.parent}로 되돌렸다.`;
206
+ }
207
+ const why = !L.sw(st, 'dev') ? '개발 꺼짐' : st.noBranch ? '브랜치 생략' : '코드 변경 없음';
208
+ st.merge = { at: L.nowIso(), skipped: why };
209
+ L.writeState(main, st);
210
+ return `${L.taskLabel(st.num)} 병합을 건너뛴다(${why}).${note}\n다음: 태스크를 연 주인이 본진에서 close-task를 부른다.`;
211
+ }
212
+
213
+ if (!st.mergeStart) {
214
+ st.mergeStart = L.nowIso();
215
+ // rebase 전 브랜치 끝 — 부모에서 받아 온 변경에 의존성 파일이 있는지 볼 때 쓴다(충돌 뒤 다시 불러도 같은 값)
216
+ st.mergeFrom = L.git(st.noWorktree ? main : st.worktree, ['rev-parse', 'HEAD']);
217
+ L.writeState(main, st);
218
+ }
219
+ const rule = L.readGitRule(main);
220
+ return L.withLock(main, 'merge', async () => {
221
+ checkMain(main, { self: st, parent: st.parent });
222
+ const dir = st.noWorktree ? main : st.worktree;
223
+ rebaseOntoParent(dir, st);
224
+ const notes = [];
225
+ // 새 커밋이 들어와 코드가 test-code 때와 달라졌을 때만 코드 테스트를 다시 돌린다
226
+ const fp = L.fingerprint(dir);
227
+ if (st.testCode && fp !== st.testCode.fingerprint) {
228
+ // 의존성 파일(package-lock.json)이 바뀌었으면 다시 설치한 뒤 테스트한다
229
+ if (st.mergeFrom && L.git(dir, ['diff', '--name-only', st.mergeFrom, 'HEAD', '--', 'package-lock.json'])) {
230
+ for (const n of await installPackages(dir)) notes.push(`의존성 파일이 바뀌어 다시 설치했다: ${n}`);
231
+ }
232
+ const r = await runCodeTests(main, dir);
233
+ st.testCode.fingerprint = fp;
234
+ L.writeState(main, st);
235
+ notes.push(r.none ? '부모의 새 커밋을 받았다 — 코드 테스트 없음으로 등록된 리포.' : `부모의 새 커밋을 받아 코드 테스트를 다시 돌렸다 — 통과(${L.minutes(r.ms)}).`);
236
+ }
237
+ if (st.noWorktree) L.git(main, ['checkout', st.parent]);
238
+ const args = rule.merge === 'ff-only' ? ['merge', '--ff-only', st.branch] : ['merge', '--no-ff', '--no-edit', st.branch];
239
+ L.git(main, args);
240
+ const head = L.git(main, ['rev-parse', 'HEAD']);
241
+ st.merge = { at: L.nowIso(), commit: head };
242
+ L.writeState(main, st);
243
+ return [
244
+ `${L.taskLabel(st.num)}을 ${st.parent}에 병합했다(${rule.merge}, ${head.slice(0, 7)}).`,
245
+ ...notes,
246
+ '다음: 태스크를 연 주인이 본진에서 close-task를 부른다(직접 수행이면 워크트리에서 나와서).',
247
+ ].join('\n');
248
+ });
249
+ }
250
+
251
+ // CHANGELOG_RULE.md의 "항목 틀" 코드 블록을 읽어 변경 기록 한 항목을 쓴다
252
+ function changelogTemplate(main) {
253
+ const file = path.join(main, '.taskery', 'rules', 'CHANGELOG_RULE.md');
254
+ const fallback = '## [TASK-{num}] {title}\n\n- 날짜: {date}\n- 유형: {type}\n- 요약: {goal}';
255
+ if (!fs.existsSync(file)) return fallback;
256
+ const m = fs.readFileSync(file, 'utf8').match(/## 항목 틀[\s\S]*?```(?:markdown)?\n([\s\S]*?)```/);
257
+ return m ? m[1].replace(/\s+$/, '') : fallback;
258
+ }
259
+
260
+ function writeChangelog(main, st, goal) {
261
+ const date = L.todayLocal();
262
+ const entry = L.fillTemplate(changelogTemplate(main), { num: L.pad(st.num), title: st.title, date, type: st.type, goal });
263
+ const file = path.join(main, '.taskery', 'changelog', `${date.slice(0, 7)}.md`);
264
+ fs.mkdirSync(path.dirname(file), { recursive: true });
265
+ if (!fs.existsSync(file)) {
266
+ fs.writeFileSync(file, `# 변경 기록 ${date.slice(0, 7)}\n\n${entry}\n`);
267
+ } else {
268
+ const lines = fs.readFileSync(file, 'utf8').split('\n');
269
+ const first = lines.findIndex((l) => /^## /.test(l));
270
+ if (first === -1) lines.push('', entry, '');
271
+ else lines.splice(first, 0, entry, '');
272
+ fs.writeFileSync(file, lines.join('\n'));
273
+ }
274
+ return file;
275
+ }
276
+
277
+ // 워크트리·브랜치 정리 — 커밋 안 된 변경이 있으면 남기고, 브랜치는 병합된 것만 지운다.
278
+ // force는 prune에서 사용자가 확인했을 때만 — 병합 안 된 브랜치도 지운다(복구 명령 출력)
279
+ function cleanup(main, st, { force = false } = {}) {
280
+ const notes = [];
281
+ if (st.noBranch) return notes;
282
+ const exists = L.branchExists(main, st.branch);
283
+ const tip = exists ? L.git(main, ['rev-parse', st.branch]) : null;
284
+ const merged = exists && L.isAncestor(main, st.branch, st.parent);
285
+ let branchFree = true;
286
+
287
+ if (st.worktree && fs.existsSync(st.worktree)) {
288
+ const dirty = L.changedFiles(st.worktree);
289
+ if (dirty.length) {
290
+ notes.push(`워크트리에 커밋 안 된 변경·추적 안 되는 파일이 있어 워크트리와 브랜치를 남겼다: ${st.worktree} (${dirty.slice(0, 5).join(', ')}${dirty.length > 5 ? ' …' : ''})`);
291
+ return notes;
292
+ }
293
+ if (st.by === 'orca') {
294
+ // Orca는 병합을 증명할 수 없는 브랜치를 남긴다(`orca worktree rm --help`) — 병합 여부와 무관하게 부른다
295
+ try {
296
+ execFileSync('orca', ['worktree', 'rm', '--worktree', `path:${st.worktree}`, '--json'], { stdio: ['ignore', 'pipe', 'pipe'] });
297
+ notes.push(`Orca 워크트리를 지웠다: ${st.worktree}`);
298
+ } catch (e) {
299
+ notes.push(`Orca 워크트리를 지우지 못해 남겼다: ${((e.stderr || '') + '').trim() || e.message}`);
300
+ branchFree = false;
301
+ }
302
+ } else if (L.git(main, ['worktree', 'remove', st.worktree], { allowFail: true }) === null) {
303
+ notes.push(`워크트리를 지우지 못해 남겼다: ${st.worktree}`);
304
+ branchFree = false;
305
+ } else {
306
+ notes.push(`워크트리를 지웠다: ${st.worktree}`);
307
+ }
308
+ }
309
+
310
+ if (!L.branchExists(main, st.branch)) {
311
+ if (exists) notes.push(`브랜치 ${st.branch}가 지워졌다. 복구: git branch ${st.branch} ${tip}`);
312
+ return notes;
313
+ }
314
+ if (!merged && !force) notes.push(`브랜치 ${st.branch}는 부모에 병합되지 않아 남겼다.`);
315
+ else if (!branchFree || L.currentBranch(main) === st.branch) notes.push(`브랜치 ${st.branch}를 지우지 못해 남겼다(사용 중).`);
316
+ else if (L.git(main, ['branch', merged ? '-d' : '-D', st.branch], { allowFail: true }) === null) notes.push(`브랜치 ${st.branch}를 지우지 못해 남겼다.`);
317
+ else notes.push(`브랜치 ${st.branch}를 지웠다. 복구: git branch ${st.branch} ${tip}`);
318
+ return notes;
319
+ }
320
+
321
+ async function closeTask(ctx, a) {
322
+ const main = ctx.main;
323
+ const st = openState(main, a.task);
324
+ const finished = L.hasFinishRecord(st);
325
+ const notes = [];
326
+ // 끝난 태스크면 워크트리를 지우기 직전에 빌드 결과 폴더를 본진의 씨앗으로(§7). 남길 워크트리(커밋 안 된 변경)는 그대로 둔다
327
+ if (finished && st.worktree && fs.existsSync(st.worktree) && !L.changedFiles(st.worktree).length) {
328
+ notes.push(...reseedBuildOutputs(main, st.worktree, L.readManifest(main).buildOutput));
329
+ }
330
+ notes.push(...cleanup(main, st));
331
+ const goal = L.goalLine(fs.existsSync(L.docAbs(main, st)) ? L.readDoc(main, st) : '') || st.title;
332
+ st.closed = { at: L.nowIso(), finished };
333
+ L.writeState(main, st);
334
+ L.syncDoc(main, st);
335
+ // 백로그 연결 표시 — (완료)·(포기), 연결 태스크가 모두 닫히면 항목 이동(§5-3)
336
+ const blNotes = await L.withLock(main, 'number', async () => B.markClosed(main, L.taskLabel(st.num), finished));
337
+ for (const n of blNotes) notes.push(`백로그: ${n}`);
338
+ if (finished) notes.push(`변경 기록: ${writeChangelog(main, st, goal)}`);
339
+ const d = L.stageDurations(st);
340
+ const row = (label, on, v) => ` - ${label}: ${on ? L.minutes(v) || '–' : '꺼짐'}`;
341
+ return [
342
+ `${L.taskLabel(st.num)} 「${st.title}」을 닫았다 — ${finished ? '끝난 태스크' : '포기(마무리 기록 없이 닫음)'}.`,
343
+ ...notes.map((n) => `- ${n}`),
344
+ '- 단계별 시간:',
345
+ row('시작·기획', true, d.plan),
346
+ row('개발', L.sw(st, 'dev'), d.dev),
347
+ row('테스트', L.sw(st, 'test'), d.test),
348
+ row('닫기', true, d.close),
349
+ ` - 합계: ${L.minutes(d.total)} (계획 확인·눈 확인·질문 답을 기다린 시간 포함, 병합 확인 대기는 뺌)`,
350
+ ].join('\n');
351
+ }
352
+
353
+ module.exports = { verifyClose, commitTask, mergeTask, closeTask, cleanup };