@angar2/taskery 0.8.1 → 1.0.1

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 +437 -188
  2. package/bin/add.js +27 -99
  3. package/bin/backlog.js +240 -113
  4. package/bin/check.js +182 -0
  5. package/bin/commands.js +151 -0
  6. package/bin/finish.js +365 -0
  7. package/bin/init.js +99 -190
  8. package/bin/install.js +143 -0
  9. package/bin/lib.js +591 -1336
  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 +329 -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 +104 -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/finish.js ADDED
@@ -0,0 +1,365 @@
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 before = st.commit;
110
+ const prev = (before && before.commits) || [];
111
+ const commits = [...prev, ...made];
112
+ const noCodeChange = !L.sw(st, 'dev') || (st.noBranch ? commits.length === 0 : !hasCodeChange(main, st));
113
+ st.commit = { at: L.nowIso(), noCodeChange, commits };
114
+ L.writeState(main, st);
115
+ for (const m of made) L.appendResult(main, st, `- 커밋 · ${m.subject}`);
116
+ if (L.sw(st, 'dev') && noCodeChange && !before) L.appendResult(main, st, '- 커밋 · 코드 변경 없음');
117
+
118
+ // 병합 확인 화면 — 커밋 목록·바뀐 파일·단계별 시간·계획 확인 뒤 추가된 파일
119
+ let changed;
120
+ let list;
121
+ if (st.noBranch) {
122
+ list = commits.map((c) => `${c.sha.slice(0, 7)} ${c.subject}`);
123
+ changed = [...new Set(commits.flatMap((c) => c.files))];
124
+ } else {
125
+ const log = L.git(dir, ['log', '--format=%h %s', `${st.startCommit}..HEAD`]);
126
+ list = log ? log.split('\n') : [];
127
+ const diff = L.git(dir, ['diff', '--name-only', st.startCommit, 'HEAD']);
128
+ changed = diff ? diff.split('\n') : [];
129
+ }
130
+ const planned = (st.approve && st.approve.files) || [];
131
+ const added = changed.filter((f) => !planned.some((p) => L.fileMatches(f, p)));
132
+ const d = L.stageDurations(st);
133
+ const out = [`${L.taskLabel(st.num)} 「${st.title}」 커밋 요약`];
134
+ if (noCodeChange) out.push('- 코드 변경 없음 — 커밋하지 않았다. merge-task는 병합을 건너뛴다.');
135
+ else {
136
+ out.push(`- 커밋 ${list.length}개:`);
137
+ for (const l of list) out.push(` - ${l}`);
138
+ out.push(`- 바뀐 파일 ${changed.length}개: ${changed.join(', ')}`);
139
+ }
140
+ out.push(
141
+ `- 단계별 시간: 시작·기획 ${L.minutes(d.plan) || '–'} · 개발 ${L.sw(st, 'dev') ? L.minutes(d.dev) || '–' : '꺼짐'} · 테스트 ${L.sw(st, 'test') ? L.minutes(d.test) || '–' : '꺼짐'}`,
142
+ );
143
+ out.push(`- 계획 확인 뒤 추가된 파일: ${added.length ? added.join(', ') : '없음'}`);
144
+ out.push('다음: merge-task (진행 범위가 "끝까지"가 아니면 사용자 확인을 받은 뒤).');
145
+ return out.join('\n');
146
+ }
147
+
148
+ function gitPath(dir, name) {
149
+ return L.git(dir, ['rev-parse', '--path-format=absolute', '--git-path', name]);
150
+ }
151
+
152
+ function rebaseInProgress(dir) {
153
+ return fs.existsSync(gitPath(dir, 'rebase-merge')) || fs.existsSync(gitPath(dir, 'rebase-apply'));
154
+ }
155
+
156
+ function unmergedFiles(dir) {
157
+ const out = L.git(dir, ['diff', '--name-only', '--diff-filter=U']);
158
+ return out ? out.split('\n') : [];
159
+ }
160
+
161
+ const MARKER_RE = /^(<{7}|>{7})( |$)/m;
162
+
163
+ function conflictStop(st, files) {
164
+ return L.fail(
165
+ `merge-task: ${L.taskLabel(st.num)} rebase 중 충돌이 났다. 잠금은 풀었고 rebase 진행 상태는 그대로 두었다.\n` +
166
+ `충돌 파일:\n${files.map((f) => `- ${f}`).join('\n')}\n` +
167
+ '충돌 파일을 직접 읽고 고친 뒤 merge-task를 다시 부른다(스테이징·이어 가기는 명령이 한다). 풀지 못하면 멈추고 보고한다.',
168
+ );
169
+ }
170
+
171
+ // 부모 최신 위로 rebase — 진행 중인 rebase가 있으면 이어 가고, 부모가 움직였으면 다시 한다
172
+ function rebaseOntoParent(dir, st) {
173
+ const env = { GIT_EDITOR: 'true' };
174
+ for (let guard = 0; guard < 100; guard++) {
175
+ if (rebaseInProgress(dir)) {
176
+ const unmerged = unmergedFiles(dir);
177
+ const marked = unmerged.filter((f) => {
178
+ const p = path.join(dir, f);
179
+ return fs.existsSync(p) && MARKER_RE.test(fs.readFileSync(p, 'utf8'));
180
+ });
181
+ if (marked.length) conflictStop(st, marked);
182
+ L.git(dir, ['add', '-A']);
183
+ if (L.git(dir, ['-c', 'core.editor=true', 'rebase', '--continue'], { env, allowFail: true }) === null) {
184
+ if (rebaseInProgress(dir) && unmergedFiles(dir).length) conflictStop(st, unmergedFiles(dir));
185
+ if (rebaseInProgress(dir)) L.fail('merge-task: rebase를 이어 가지 못했다. `git status`로 상태를 확인한다.');
186
+ }
187
+ continue;
188
+ }
189
+ if (L.isAncestor(dir, st.parent, 'HEAD')) return;
190
+ if (L.git(dir, ['rebase', st.parent], { env, allowFail: true }) === null) {
191
+ if (rebaseInProgress(dir)) conflictStop(st, unmergedFiles(dir));
192
+ L.fail('merge-task: rebase에 실패했다. `git status`로 상태를 확인한다.');
193
+ }
194
+ }
195
+ return L.fail('merge-task: rebase가 끝나지 않는다. `git status`로 상태를 확인한다.');
196
+ }
197
+
198
+ async function mergeTask(ctx, a) {
199
+ const main = ctx.main;
200
+ const st = openState(main, a.task);
201
+ if (!st.commit) L.fail(`merge-task: ${L.taskLabel(st.num)}의 커밋 기록이 없다. commit-task를 먼저 부른다.`);
202
+ if (st.merge && st.merge.commit) return `${L.taskLabel(st.num)}은 이미 병합했다 (${st.merge.commit.slice(0, 7)}).`;
203
+
204
+ if (L.needsNoMerge(st)) {
205
+ if (st.merge && st.merge.skipped) return `${L.taskLabel(st.num)} 병합은 이미 건너뛰었다(${st.merge.skipped}).\n다음: 태스크를 연 주인이 본진에서 close-task를 부른다.`;
206
+ let note = '';
207
+ if (st.noWorktree && L.currentBranch(main) !== st.parent) {
208
+ L.git(main, ['checkout', st.parent]);
209
+ note = ` 본진을 부모 브랜치 ${st.parent}로 되돌렸다.`;
210
+ }
211
+ const why = !L.sw(st, 'dev') ? '개발 꺼짐' : st.noBranch ? '브랜치 생략' : '코드 변경 없음';
212
+ st.merge = { at: L.nowIso(), skipped: why };
213
+ L.writeState(main, st);
214
+ L.appendResult(main, st, `- 병합 · 건너뜀(${why}) · ${L.clock(st.merge.at)}`);
215
+ return `${L.taskLabel(st.num)} 병합을 건너뛴다(${why}).${note}\n다음: 태스크를 연 주인이 본진에서 close-task를 부른다.`;
216
+ }
217
+
218
+ if (!st.mergeStart) {
219
+ st.mergeStart = L.nowIso();
220
+ // rebase 전 브랜치 끝 — 부모에서 받아 온 변경에 의존성 파일이 있는지 볼 때 쓴다(충돌 뒤 다시 불러도 같은 값)
221
+ st.mergeFrom = L.git(st.noWorktree ? main : st.worktree, ['rev-parse', 'HEAD']);
222
+ L.writeState(main, st);
223
+ }
224
+ const rule = L.readGitRule(main);
225
+ return L.withLock(main, 'merge', async () => {
226
+ checkMain(main, { self: st, parent: st.parent });
227
+ const dir = st.noWorktree ? main : st.worktree;
228
+ rebaseOntoParent(dir, st);
229
+ const notes = [];
230
+ // 새 커밋이 들어와 코드가 test-code 때와 달라졌을 때만 코드 테스트를 다시 돌린다
231
+ const fp = L.fingerprint(dir);
232
+ if (st.testCode && fp !== st.testCode.fingerprint) {
233
+ // 의존성 파일(package-lock.json)이 바뀌었으면 다시 설치한 뒤 테스트한다
234
+ if (st.mergeFrom && L.git(dir, ['diff', '--name-only', st.mergeFrom, 'HEAD', '--', 'package-lock.json'])) {
235
+ for (const n of await installPackages(dir)) notes.push(`의존성 파일이 바뀌어 다시 설치했다: ${n}`);
236
+ }
237
+ const r = await runCodeTests(main, dir);
238
+ st.testCode.fingerprint = fp;
239
+ L.writeState(main, st);
240
+ notes.push(r.none ? '부모의 새 커밋을 받았다 — 코드 테스트 없음으로 등록된 리포.' : `부모의 새 커밋을 받아 코드 테스트를 다시 돌렸다 — 통과(${L.minutes(r.ms)}).`);
241
+ const at = L.clock(L.nowIso());
242
+ L.appendResult(
243
+ main,
244
+ st,
245
+ r.none ? `- 코드 테스트 · 부모 새 커밋 받음 · 등록된 명령 없음 · ${at}` : `- 코드 테스트 · 부모 새 커밋 받아 다시 통과 · ${L.minutes(r.ms)} · ${at}`,
246
+ );
247
+ }
248
+ if (st.noWorktree) L.git(main, ['checkout', st.parent]);
249
+ const args = rule.merge === 'ff-only' ? ['merge', '--ff-only', st.branch] : ['merge', '--no-ff', '--no-edit', st.branch];
250
+ L.git(main, args);
251
+ const head = L.git(main, ['rev-parse', 'HEAD']);
252
+ st.merge = { at: L.nowIso(), commit: head };
253
+ L.writeState(main, st);
254
+ L.appendResult(main, st, `- 병합 · ${st.parent} ← ${head.slice(0, 7)} · ${L.clock(st.merge.at)}`);
255
+ return [
256
+ `${L.taskLabel(st.num)}을 ${st.parent}에 병합했다(${rule.merge}, ${head.slice(0, 7)}).`,
257
+ ...notes,
258
+ '다음: 태스크를 연 주인이 본진에서 close-task를 부른다(직접 수행이면 워크트리에서 나와서).',
259
+ ].join('\n');
260
+ });
261
+ }
262
+
263
+ // CHANGELOG_RULE.md의 "항목 틀" 코드 블록을 읽어 변경 기록 한 항목을 쓴다
264
+ function changelogTemplate(main) {
265
+ const file = path.join(main, '.taskery', 'rules', 'CHANGELOG_RULE.md');
266
+ const fallback = '## [TASK-{num}] {title}\n\n- 날짜: {date}\n- 유형: {type}\n- 요약: {goal}';
267
+ if (!fs.existsSync(file)) return fallback;
268
+ const m = fs.readFileSync(file, 'utf8').match(/## 항목 틀[\s\S]*?```(?:markdown)?\n([\s\S]*?)```/);
269
+ return m ? m[1].replace(/\s+$/, '') : fallback;
270
+ }
271
+
272
+ function writeChangelog(main, st, goal) {
273
+ const date = L.todayLocal();
274
+ const entry = L.fillTemplate(changelogTemplate(main), { num: L.pad(st.num), title: st.title, date, type: st.type, goal });
275
+ const file = path.join(main, '.taskery', 'changelog', `${date.slice(0, 7)}.md`);
276
+ fs.mkdirSync(path.dirname(file), { recursive: true });
277
+ if (!fs.existsSync(file)) {
278
+ fs.writeFileSync(file, `# 변경 기록 ${date.slice(0, 7)}\n\n${entry}\n`);
279
+ } else {
280
+ const lines = fs.readFileSync(file, 'utf8').split('\n');
281
+ const first = lines.findIndex((l) => /^## /.test(l));
282
+ if (first === -1) lines.push('', entry, '');
283
+ else lines.splice(first, 0, entry, '');
284
+ fs.writeFileSync(file, lines.join('\n'));
285
+ }
286
+ return file;
287
+ }
288
+
289
+ // 워크트리·브랜치 정리 — 커밋 안 된 변경이 있으면 남기고, 브랜치는 병합된 것만 지운다.
290
+ // force는 prune에서 사용자가 확인했을 때만 — 병합 안 된 브랜치도 지운다(복구 명령 출력)
291
+ function cleanup(main, st, { force = false } = {}) {
292
+ const notes = [];
293
+ if (st.noBranch) return notes;
294
+ const exists = L.branchExists(main, st.branch);
295
+ const tip = exists ? L.git(main, ['rev-parse', st.branch]) : null;
296
+ const merged = exists && L.isAncestor(main, st.branch, st.parent);
297
+ let branchFree = true;
298
+
299
+ if (st.worktree && fs.existsSync(st.worktree)) {
300
+ const dirty = L.changedFiles(st.worktree);
301
+ if (dirty.length) {
302
+ notes.push(`워크트리에 커밋 안 된 변경·추적 안 되는 파일이 있어 워크트리와 브랜치를 남겼다: ${st.worktree} (${dirty.slice(0, 5).join(', ')}${dirty.length > 5 ? ' …' : ''})`);
303
+ return notes;
304
+ }
305
+ if (st.by === 'orca') {
306
+ // Orca는 병합을 증명할 수 없는 브랜치를 남긴다(`orca worktree rm --help`) — 병합 여부와 무관하게 부른다
307
+ try {
308
+ execFileSync('orca', ['worktree', 'rm', '--worktree', `path:${st.worktree}`, '--json'], { stdio: ['ignore', 'pipe', 'pipe'] });
309
+ notes.push(`Orca 워크트리를 지웠다: ${st.worktree}`);
310
+ } catch (e) {
311
+ notes.push(`Orca 워크트리를 지우지 못해 남겼다: ${((e.stderr || '') + '').trim() || e.message}`);
312
+ branchFree = false;
313
+ }
314
+ } else if (L.git(main, ['worktree', 'remove', st.worktree], { allowFail: true }) === null) {
315
+ notes.push(`워크트리를 지우지 못해 남겼다: ${st.worktree}`);
316
+ branchFree = false;
317
+ } else {
318
+ notes.push(`워크트리를 지웠다: ${st.worktree}`);
319
+ }
320
+ }
321
+
322
+ if (!L.branchExists(main, st.branch)) {
323
+ if (exists) notes.push(`브랜치 ${st.branch}가 지워졌다. 복구: git branch ${st.branch} ${tip}`);
324
+ return notes;
325
+ }
326
+ if (!merged && !force) notes.push(`브랜치 ${st.branch}는 부모에 병합되지 않아 남겼다.`);
327
+ else if (!branchFree || L.currentBranch(main) === st.branch) notes.push(`브랜치 ${st.branch}를 지우지 못해 남겼다(사용 중).`);
328
+ else if (L.git(main, ['branch', merged ? '-d' : '-D', st.branch], { allowFail: true }) === null) notes.push(`브랜치 ${st.branch}를 지우지 못해 남겼다.`);
329
+ else notes.push(`브랜치 ${st.branch}를 지웠다. 복구: git branch ${st.branch} ${tip}`);
330
+ return notes;
331
+ }
332
+
333
+ async function closeTask(ctx, a) {
334
+ const main = ctx.main;
335
+ const st = openState(main, a.task);
336
+ const finished = L.hasFinishRecord(st);
337
+ const notes = [];
338
+ // 끝난 태스크면 워크트리를 지우기 직전에 빌드 결과 폴더를 본진의 씨앗으로(§7). 남길 워크트리(커밋 안 된 변경)는 그대로 둔다
339
+ if (finished && st.worktree && fs.existsSync(st.worktree) && !L.changedFiles(st.worktree).length) {
340
+ notes.push(...reseedBuildOutputs(main, st.worktree, L.readManifest(main).buildOutput));
341
+ }
342
+ notes.push(...cleanup(main, st));
343
+ const goal = L.goalLine(fs.existsSync(L.docAbs(main, st)) ? L.readDoc(main, st) : '') || st.title;
344
+ st.closed = { at: L.nowIso(), finished };
345
+ L.writeState(main, st);
346
+ L.syncDoc(main, st);
347
+ // 백로그 연결 표시 — (완료)·(포기), 연결 태스크가 모두 닫히면 항목 이동(§5-3)
348
+ const blNotes = await L.withLock(main, 'number', async () => B.markClosed(main, L.taskLabel(st.num), finished));
349
+ for (const n of blNotes) notes.push(`백로그: ${n}`);
350
+ if (finished) notes.push(`변경 기록: ${writeChangelog(main, st, goal)}`);
351
+ const d = L.stageDurations(st);
352
+ const row = (label, on, v) => ` - ${label}: ${on ? L.minutes(v) || '–' : '꺼짐'}`;
353
+ return [
354
+ `${L.taskLabel(st.num)} 「${st.title}」을 닫았다 — ${finished ? '끝난 태스크' : '포기(마무리 기록 없이 닫음)'}.`,
355
+ ...notes.map((n) => `- ${n}`),
356
+ '- 단계별 시간:',
357
+ row('시작·기획', true, d.plan),
358
+ row('개발', L.sw(st, 'dev'), d.dev),
359
+ row('테스트', L.sw(st, 'test'), d.test),
360
+ row('닫기', true, d.close),
361
+ ` - 합계: ${L.minutes(d.total)} (계획 확인·눈 확인·질문 답을 기다린 시간 포함, 병합 확인 대기는 뺌)`,
362
+ ].join('\n');
363
+ }
364
+
365
+ module.exports = { verifyClose, commitTask, mergeTask, closeTask, cleanup };