@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.
Files changed (60) 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 +515 -1320
  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 -266
  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/migrate.js +0 -344
  41. package/bin/set-status.js +0 -98
  42. package/template/.claude/hooks/closed-immutable.sh +0 -77
  43. package/template/.claude/settings.json +0 -18
  44. package/template/.claude/skills/run-team/SKILL.md +0 -145
  45. package/template/.codex/agents/task-tester.toml +0 -41
  46. package/template/.codex/agents/test-plan-author.toml +0 -34
  47. package/template/.codex/config.toml +0 -17
  48. package/template/.codex/hooks/closed-immutable.sh +0 -66
  49. package/template/.project/changelog/.gitkeep +0 -0
  50. package/template/.project/flows/.gitkeep +0 -0
  51. package/template/.project/plans/.gitkeep +0 -0
  52. package/template/.project/rules/CHANGELOG_RULE.md +0 -97
  53. package/template/.project/rules/GIT_RULE.md +0 -325
  54. package/template/.project/rules/MOCKUP_RULE.md +0 -86
  55. package/template/.project/rules/TASKERY_RULE.md +0 -237
  56. package/template/.project/rules/TASK_DOC_RULE.md +0 -668
  57. package/template/.project/shared/received/completed/.gitkeep +0 -0
  58. package/template/.project/shared/sent/completed/.gitkeep +0 -0
  59. package/template/.project/tasks/.gitkeep +0 -0
  60. package/template/shared/hooks/git-guard.sh +0 -108
package/bin/lib.js CHANGED
@@ -1,1478 +1,673 @@
1
- /**
2
- * bin/lib.js
3
- * 공통 유틸 — taskery init/create/update/prune/status 스크립트가 공유.
4
- *
5
- * 핵심:
6
- * - findTemplateDir(): 패키지 설치 위치에서 template/ 디렉토리 찾기
7
- * - walkTemplate(templateDir): template/ 안 *모든 파일* 상대경로 + 해시 맵 리턴
8
- * - sha256(path): 파일 해시
9
- * - mkdirp(path): mkdir -p
10
- * - copyFile(src, dst): 파일 카피 + 디렉토리 자동 생성
11
- * - writeManifest(manifest, dst): manifest JSON 기록
12
- * - readManifest(path): 기존 manifest Read (없으면 null)
13
- * - LOCAL_SUFFIX: '.local.md' suffix 검사 (npx 미터치 룰)
14
- *
15
- * 멀티세션 (0.1.2+):
16
- * - generateProjectId(): 새 projectId 생성 (8자 hex)
17
- * - getMainWorktreePath(cwd): cwd가 속한 git 트리의 *메인 워크트리* 절대 경로
18
- * - getProjectId(mainWtPath): manifest에서 projectId 추출
19
- * - getWorktreesRoot(projectId): ~/.taskery/worktrees/<projectId>
20
- * - getWorktreePath(projectId, { taskNum, src, slug }): 특정 task 워크트리 경로
21
- * - getMergeLockPath(projectId): ~/.taskery/<projectId>.merge.lock
22
- * - getInitLockPath(projectId): ~/.taskery/<projectId>.init.lock (task 분기 직렬화용)
23
- * - withMergeLock(projectId, fn, opts): proper-lockfile로 머지 락 + fn 실행 + 자동 release
24
- * - withMetaLock(filePath, fn, opts): 메타 파일 쓰기 락 + fn 실행
25
- * - withMetaLockSync(filePath, fn, opts): 위의 동기 변형 (sync 호출 체인용 — manifest 갱신)
26
- * - getActiveTasks(mainWtPath): SSoT 조회 (git branch --list 'feature/*_TASK-*' ...)
27
- * - getNextTaskNumber(mainWtPath): (진행중 ∪ 전 ref 머지 히스토리) 최대 + 1 (부모 무관 전역 채번)
28
- * - forkTask(mainWtPath, {type,dev,src,slug}): init 락 안에서 부모(현재 브랜치) 캡처+채번+워크트리·브랜치 생성 원자 실행
29
- * - currentBranch(mainWtPath): 메인 워크트리 현재 브랜치 = 부모 브랜치(detached면 'HEAD')
30
- * - assertMainWorktreeOn(mainWtPath, branch): 메인 워크트리가 지정 브랜치 체크아웃 상태인지 검증
31
- *
32
- * 백로그 (0.1.2+):
33
- * - getActiveVersion(mainWtPath): 활성 plan 식별자 — manifest.activePlan 우선, 구형 AGENT-GUIDE.md 폴백 (0.7.0+)
34
- * - setActivePlan(mainWtPath, plan): manifest.activePlan 갱신 (plan 폴더 실재 검증 포함 — plan-switch)
35
- * - getBacklogPath(mainWtPath, activeVersion?): .project/tasks/<활성 plan>/BACKLOG.md 절대 경로
36
- * - parseBacklogItem(mainWtPath, blId): BL-NNN 항목 메타 파싱 ({ status, type, title, slug, summary, target, taskNums })
37
- * - appendBacklogItem(mainWtPath, meta): withMetaLock + BL-NNN 채번 + 항목 append (placeholder 치환 우선)
38
- * - markBacklogChecked(mainWtPath, blId, taskNum): withMetaLock + [ ] → [x] + TASK 마크 (다회 진행 시 콤마 추가)
39
- */
40
-
1
+ // taskery 1.0 명령이 함께 쓰는 도구 — git·경로·매니페스트·.state·잠금·태스크 문서·GIT_RULE 표·코드 지문
41
2
  const fs = require('fs');
42
3
  const path = require('path');
43
- const os = require('os');
44
4
  const crypto = require('crypto');
45
- const { execFileSync } = require('child_process');
46
-
47
- const LOCAL_SUFFIX = '.local.md';
48
- const MANIFEST_NAME = '.taskery-manifest.json';
49
-
50
- // === 멀티 플랫폼 (0.2.0+) ===
51
- // 공통 소스 + 플랫폼별 조립 설치: 공통 자산(shared/)은 단일 소스로 보관하고
52
- // 설치 시 고른 플랫폼의 실제 경로로 매핑 복사한다. 플랫폼 고유 자산은 그대로 복사.
53
- const PLATFORMS = ['claude', 'codex'];
54
- const DEFAULT_PLATFORMS = ['claude']; // 기존(0.1.x) 설치 마이그레이션 기본값
55
-
56
- // shared/ 공통 자산의 플랫폼별 설치 경로 매핑.
57
- // shared 하위 첫 세그먼트(skills|hooks) → 각 플랫폼의 실제 설치 루트.
58
- // 스킬·git-guard 본문은 양 플랫폼 동일(검증 완료) — 단일 소스로 보관, 설치 때 갈라 깐다.
59
- const SHARED_DEST = {
60
- claude: { skills: '.claude/skills', hooks: '.claude/hooks' },
61
- codex: { skills: '.agents/skills', hooks: '.codex/hooks' },
62
- };
63
-
64
- // template 상대경로 → 소속 ('agnostic' | 'shared' | 'claude' | 'codex')
65
- function platformOf(relPath) {
66
- if (relPath.startsWith('shared/')) return 'shared'; // 공통 — 설치 시 플랫폼별 매핑
67
- if (relPath === 'CLAUDE.md' || relPath.startsWith('.claude/')) return 'claude';
68
- if (relPath.startsWith('.codex/')) return 'codex';
69
- // AGENTS.md는 0.7.0부터 agnostic — 진입 문서 본문의 단일 소스이고
70
- // CLAUDE.md는 이를 `@AGENTS.md`로 임포트만 한다(Claude Code는 AGENTS.md를 자동 로드하지 않음).
71
- return 'agnostic'; // AGENTS.md, .project/, .gitignore 등 — 항상 설치
72
- }
5
+ const { execFileSync, spawn } = require('child_process');
73
6
 
74
- // shared/ 상대경로를 특정 플랫폼의 설치 경로로 변환.
75
- // 예: 'shared/skills/task-init/SKILL.md' + 'codex' → '.agents/skills/task-init/SKILL.md'
76
- function mapSharedDest(relPath, platform) {
77
- const rest = relPath.slice('shared/'.length); // 'skills/task-init/SKILL.md'
78
- const sep = rest.indexOf('/');
79
- const top = sep === -1 ? rest : rest.slice(0, sep); // 'skills'
80
- const tail = sep === -1 ? '' : rest.slice(sep); // '/task-init/SKILL.md'
81
- const root = SHARED_DEST[platform] && SHARED_DEST[platform][top];
82
- if (!root) {
83
- throw new Error(`shared 자산 매핑 미정의: ${relPath} (platform=${platform})`);
84
- }
85
- return root + tail;
86
- }
87
-
88
- // template 파일맵을 설치 계획으로 전개 — [{ templateRel, installRel, hash }].
89
- // - agnostic: 그대로 (includeAgnostic=false면 제외 — add 용)
90
- // - shared: 선택 플랫폼마다 매핑 경로로 1건씩 전개 (1:N)
91
- // - 플랫폼 고유: 선택 플랫폼이면 그대로
92
- function resolveInstallPlan(templateFiles, platforms, { includeAgnostic = true } = {}) {
93
- const plan = [];
94
- for (const [rel, hash] of Object.entries(templateFiles)) {
95
- const p = platformOf(rel);
96
- if (p === 'agnostic') {
97
- if (includeAgnostic) plan.push({ templateRel: rel, installRel: rel, hash });
98
- } else if (p === 'shared') {
99
- for (const platform of platforms) {
100
- plan.push({ templateRel: rel, installRel: mapSharedDest(rel, platform), hash });
101
- }
102
- } else if (platforms.includes(p)) {
103
- plan.push({ templateRel: rel, installRel: rel, hash });
104
- }
105
- }
106
- return plan;
107
- }
7
+ const MANIFEST_NAME = path.join('.taskery', 'manifest.json');
8
+ // taskery가 설치하고 쓰는 파일 — 모두 git 밖(.git/info/exclude 이름 규칙, §3-4)
9
+ const EXCLUDE_NAMES = ['.taskery', '.claude', '.codex', '.mcp.json', 'AGENTS.md', 'CLAUDE.md'];
10
+ // 워크트리에 심는 방식 — 지침 파일은 복사, 나머지는 본진을 가리키는 링크(§3-4)
11
+ const PLANT_COPY = ['AGENTS.md', 'CLAUDE.md'];
12
+ const PLANT_LINK = ['.taskery', '.claude', '.codex', '.mcp.json'];
108
13
 
109
- // .gitignore 등록 패턴 (플랫폼별)
110
- const GITIGNORE_PATTERNS = {
111
- agnostic: ['.project/', '.taskery-manifest.json', 'AGENTS.md'],
112
- claude: ['.claude/', 'CLAUDE.md'],
113
- codex: ['.codex/', '.agents/'],
114
- };
14
+ const TYPES = ['feature', 'bug', 'improve', 'refactor', 'docs', 'chore'];
15
+ const SIZES = ['small', 'medium', 'large'];
16
+ const SWITCHES = ['plan', 'dev', 'test'];
17
+ const DEFAULT_RANGE = '한 단계';
18
+ // 브랜치 유형 → 커밋 태그 (GIT_RULE.md "커밋 태그" 표와 같다)
19
+ const TAG_OF = { feature: 'feat', bug: 'fix', improve: 'improve', refactor: 'refactor', docs: 'docs', chore: 'docs' };
20
+ const CODE_TEST_NOTICE_MS = 3 * 60 * 1000;
115
21
 
116
- // 선택 플랫폼 + agnostic의 gitignore 패턴 합성 (중복 제거)
117
- function gitignorePatternsFor(platforms) {
118
- const out = [...GITIGNORE_PATTERNS.agnostic];
119
- for (const p of platforms) {
120
- for (const pat of GITIGNORE_PATTERNS[p] || []) {
121
- if (!out.includes(pat)) out.push(pat);
122
- }
123
- }
124
- return out;
22
+ // 명령이 멈출 때 쓰는 오류 — 메시지가 곧 "왜 멈췄고 다음에 무엇을 하라"이다
23
+ class TaskeryError extends Error {}
24
+ function fail(message) {
25
+ throw new TaskeryError(message);
125
26
  }
126
27
 
127
- function findTemplateDir() {
128
- // 패키지 루트는 이 lib.js 파일의 부모(bin/)의 부모
129
- const pkgRoot = path.resolve(__dirname, '..');
130
- const tmpl = path.join(pkgRoot, 'template');
131
- if (!fs.existsSync(tmpl)) {
132
- throw new Error(`template/ not found at ${tmpl}. taskery 패키지 설치 손상 가능성.`);
133
- }
134
- return tmpl;
135
- }
28
+ // ─── git ─────────────────────────────────────────────
136
29
 
137
- function sha256(filePath) {
138
- const buf = fs.readFileSync(filePath);
139
- return 'sha256:' + crypto.createHash('sha256').update(buf).digest('hex');
140
- }
141
-
142
- function walkTemplate(templateDir) {
143
- // template/ 안 모든 파일 상대경로 + 해시 맵 리턴
144
- // 결과: { 'CLAUDE.md': 'sha256:...', '.claude/skills/task-init/SKILL.md': 'sha256:...', ... }
145
- const result = {};
146
- function walk(dir, relBase) {
147
- const entries = fs.readdirSync(dir, { withFileTypes: true });
148
- for (const entry of entries) {
149
- const full = path.join(dir, entry.name);
150
- const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
151
- if (entry.isDirectory()) {
152
- walk(full, rel);
153
- } else if (entry.isFile()) {
154
- result[rel] = sha256(full);
155
- }
156
- }
30
+ function git(cwd, args, opts = {}) {
31
+ try {
32
+ return execFileSync('git', ['-C', cwd, ...args], {
33
+ encoding: 'utf8',
34
+ stdio: ['ignore', 'pipe', 'pipe'],
35
+ env: opts.env ? { ...process.env, ...opts.env } : process.env,
36
+ maxBuffer: 64 * 1024 * 1024,
37
+ }).replace(/\s+$/, '');
38
+ } catch (e) {
39
+ if (opts.allowFail) return null;
40
+ const msg = ((e.stderr || '') + (e.stdout || '')).toString().trim();
41
+ throw new TaskeryError(`git ${args.join(' ')} 실패: ${msg || e.message}`);
157
42
  }
158
- walk(templateDir, '');
159
- return result;
160
43
  }
161
44
 
162
- function mkdirp(dirPath) {
163
- fs.mkdirSync(dirPath, { recursive: true });
45
+ function gitOk(cwd, args) {
46
+ return git(cwd, args, { allowFail: true }) !== null;
164
47
  }
165
48
 
166
- function copyFile(src, dst) {
167
- mkdirp(path.dirname(dst));
168
- fs.copyFileSync(src, dst);
169
- // 실행 권한 보존 (hooks .sh 파일)
170
- if (src.endsWith('.sh')) {
171
- fs.chmodSync(dst, 0o755);
172
- }
49
+ // 본진 = 공통 .git의 부모 폴더. 워크트리 안에서 불러도 본진을 돌려준다
50
+ function findMain(cwd = process.cwd()) {
51
+ const common = git(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir'], { allowFail: true });
52
+ if (!common) fail(`git 리포 안이 아니다 (${cwd}). taskery를 설치한 리포에서 부른다.`);
53
+ return path.dirname(common);
173
54
  }
174
55
 
175
- function writeManifest(manifest, dst) {
176
- fs.writeFileSync(dst, JSON.stringify(manifest, null, 2) + '\n');
56
+ function currentBranch(dir) {
57
+ const b = git(dir, ['symbolic-ref', '--short', '-q', 'HEAD'], { allowFail: true });
58
+ return b || null; // detached HEAD면 null
177
59
  }
178
60
 
179
- function readManifest(filePath) {
180
- if (!fs.existsSync(filePath)) return null;
181
- try {
182
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
183
- } catch (e) {
184
- throw new Error(`${filePath} 파싱 실패: ${e.message}`);
61
+ // 커밋 안 된 변경(추적 안 되는 파일 포함, exclude된 taskery 파일 제외) 경로 목록
62
+ function changedFiles(dir) {
63
+ const out = git(dir, ['status', '--porcelain', '-z', '-uall']);
64
+ if (!out) return [];
65
+ const parts = out.split('\0').filter(Boolean);
66
+ const files = [];
67
+ for (let i = 0; i < parts.length; i++) {
68
+ const code = parts[i].slice(0, 2);
69
+ files.push(parts[i].slice(3));
70
+ if (code[0] === 'R' || code[0] === 'C') files.push(parts[++i]); // 원래 경로
185
71
  }
72
+ return files;
186
73
  }
187
74
 
188
- function isLocalOverride(relPath) {
189
- // *.local.md suffix 사용자 오버라이드 — npx 미터치 명시
190
- return relPath.endsWith(LOCAL_SUFFIX);
75
+ // 추적 중인 파일의 커밋 안 된 변경 경로 목록(추적 안 되는 파일 제외)
76
+ function trackedChanges(dir) {
77
+ const out = git(dir, ['status', '--porcelain', '-uno']);
78
+ return out ? out.split('\n').map((l) => l.slice(3)) : [];
191
79
  }
192
80
 
193
- function getPackageVersion() {
194
- const pkgPath = path.resolve(__dirname, '..', 'package.json');
195
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
196
- return pkg.version;
81
+ function isAncestor(dir, a, b) {
82
+ return gitOk(dir, ['merge-base', '--is-ancestor', a, b]);
197
83
  }
198
84
 
199
- // === 멀티세션 (0.1.2+) ===
200
-
201
- const TASKERY_HOME = path.join(os.homedir(), '.taskery');
202
- const DEFAULT_STALE_DAYS = 30;
203
- const DEFAULT_LOCK_TIMEOUT_MS = 30000;
204
-
205
- const BRANCH_PATTERNS = [
206
- 'feature/*_TASK-*',
207
- 'bug/*_TASK-*',
208
- 'improve/*_TASK-*',
209
- 'refactor/*_TASK-*',
210
- 'docs/*_TASK-*',
211
- 'chore/*_TASK-*',
212
- ];
213
-
214
- function generateProjectId() {
215
- return crypto.randomBytes(4).toString('hex');
85
+ function branchExists(dir, branch) {
86
+ return gitOk(dir, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`]);
216
87
  }
217
88
 
218
- function gitCapture(cwd, args, opts = {}) {
89
+ // 코드 지문 = 작업 트리 전체(추적 안 되는 파일 포함, 무시 파일 제외)의 트리 해시. 커밋하지 않는다.
90
+ // 임시 인덱스는 본진 .taskery/.state/ 아래에 둔다 — Codex 샌드박스에서도 쓰기가 허용된 곳이다(--add-dir <본진>/.taskery)
91
+ function fingerprint(dir) {
92
+ const index = git(dir, ['rev-parse', '--path-format=absolute', '--git-path', 'index']);
93
+ const tmpDir = stateDir(findMain(dir));
94
+ fs.mkdirSync(tmpDir, { recursive: true });
95
+ const tmp = path.join(tmpDir, `taskery-index-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
219
96
  try {
220
- return execFileSync('git', ['-C', cwd, ...args], {
221
- encoding: 'utf8',
222
- stdio: ['ignore', 'pipe', opts.captureStderr ? 'pipe' : 'ignore'],
223
- }).trimEnd();
224
- } catch (e) {
225
- if (opts.allowFail) return null;
226
- throw e;
97
+ if (fs.existsSync(index)) fs.copyFileSync(index, tmp);
98
+ const env = { GIT_INDEX_FILE: tmp };
99
+ git(dir, ['add', '-A'], { env });
100
+ return git(dir, ['write-tree'], { env });
101
+ } finally {
102
+ fs.rmSync(tmp, { force: true });
227
103
  }
228
104
  }
229
105
 
230
- function getMainWorktreePath(cwd = process.cwd()) {
231
- // git ≥ 2.31 — `git rev-parse --path-format=absolute --git-common-dir` 는
232
- // 메인 워크트리의 .git 경로(또는 .git 디렉토리 자체)를 절대 경로로 돌려준다.
233
- // 그 부모가 *메인 워크트리* 절대 경로.
234
- const commonDir = gitCapture(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
235
- if (!commonDir) throw new Error(`메인 워크트리 검출 실패 (cwd=${cwd})`);
236
- return path.dirname(commonDir);
106
+ // extra = 매니페스트에 등록된 빌드 결과 폴더 — 코드로 커밋되지 않게 같은 이름 규칙으로 넣는다(§7)
107
+ function ensureExclude(main, extra = []) {
108
+ const common = git(main, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
109
+ const file = path.join(common, 'info', 'exclude');
110
+ const cur = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
111
+ const have = new Set(cur.split('\n').map((l) => l.trim()));
112
+ const names = [...EXCLUDE_NAMES, ...(Array.isArray(extra) ? extra : [])].map((n) => String(n).replace(/^\/+|\/+$/g, ''));
113
+ const missing = [...new Set(names.map((n) => `/${n}`))].filter((r) => !have.has(r));
114
+ if (missing.length === 0) return [];
115
+ fs.mkdirSync(path.dirname(file), { recursive: true });
116
+ const sep = cur && !cur.endsWith('\n') ? '\n' : '';
117
+ fs.appendFileSync(file, `${sep}# taskery\n${missing.join('\n')}\n`);
118
+ return missing;
237
119
  }
238
120
 
239
- function getProjectId(mainWtPath) {
240
- const m = readManifest(path.join(mainWtPath, MANIFEST_NAME));
241
- if (!m || !m.projectId) {
242
- throw new Error(
243
- `${MANIFEST_NAME}.projectId 누락 — 'npx @angar2/taskery update' 로 마이그레이션 필요.`,
244
- );
245
- }
246
- return m.projectId;
121
+ // Orca 탭 안인가 — Orca가 탭에 넣는 환경 변수로 판단한다
122
+ function inOrca() {
123
+ return !!process.env.ORCA_TERMINAL_HANDLE;
247
124
  }
248
125
 
249
- function getWorktreesRoot(projectId) {
250
- return path.join(TASKERY_HOME, 'worktrees', projectId);
251
- }
126
+ // ─── 셸 명령 실행 (코드 테스트) ───────────────────────
252
127
 
253
- function getWorktreePath(projectId, { taskNum, src, slug }) {
254
- // src = 'BL-NNN' | 'ST-N'(로드맵 Stage) | 'DR' (구형 'RM-NNN' 호환)
255
- const nnn = String(taskNum).padStart(3, '0');
256
- return path.join(getWorktreesRoot(projectId), `TASK-${nnn}_${src}_${slug}`);
128
+ // 명령 하나를 셸로 실행하고 출력(표준 출력+오류)을 모은다. 비동기라 잠금 갱신이 멈추지 않는다
129
+ function runShell(cmd, cwd) {
130
+ return new Promise((resolve) => {
131
+ const started = Date.now();
132
+ const child = spawn('sh', ['-c', `exec 2>&1\n${cmd}`], { cwd, env: process.env });
133
+ let out = '';
134
+ child.stdout.on('data', (d) => {
135
+ out += d.toString();
136
+ if (out.length > 4 * 1024 * 1024) out = out.slice(-2 * 1024 * 1024);
137
+ });
138
+ child.on('close', (code) => resolve({ code, out, ms: Date.now() - started }));
139
+ child.on('error', (e) => resolve({ code: 127, out: String(e.message), ms: Date.now() - started }));
140
+ });
257
141
  }
258
142
 
259
- function getMergeLockPath(projectId) {
260
- return path.join(TASKERY_HOME, `${projectId}.merge.lock`);
143
+ function tail(text, lines) {
144
+ const arr = text.replace(/\s+$/, '').split('\n');
145
+ return arr.slice(-lines).join('\n');
261
146
  }
262
147
 
263
- function getInitLockPath(projectId) {
264
- return path.join(TASKERY_HOME, `${projectId}.init.lock`);
265
- }
148
+ // ─── 매니페스트 · 설치 확인 ───────────────────────────
266
149
 
267
- async function withMergeLock(projectId, fn, opts = {}) {
268
- const lockfile = require('proper-lockfile');
269
- const lockPath = getMergeLockPath(projectId);
270
- mkdirp(path.dirname(lockPath));
271
- // proper-lockfile은 *기존 파일 위에 락*을 잡는다. sentinel 파일 보장.
272
- if (!fs.existsSync(lockPath)) fs.writeFileSync(lockPath, '');
273
- const release = await lockfile.lock(lockPath, {
274
- stale: opts.stale ?? DEFAULT_LOCK_TIMEOUT_MS,
275
- retries: opts.retries ?? {
276
- retries: 5,
277
- factor: 1,
278
- minTimeout: 1000,
279
- maxTimeout: 1000,
280
- },
281
- realpath: false,
282
- });
283
- try {
284
- return await fn();
285
- } finally {
286
- await release();
287
- }
288
- }
150
+ // 0.x 설치본의 매니페스트 이름(리포 맨 위). 1.0은 이 이름을 쓰지 않으니, 있으면 0.x 설치본이다
151
+ const OLD_MANIFEST_NAME = '.taskery-manifest.json';
289
152
 
290
- async function withMetaLock(filePath, fn, opts = {}) {
291
- const lockfile = require('proper-lockfile');
292
- const target = filePath;
293
- if (!fs.existsSync(target)) {
294
- // 락 대상은 파일이어야 함. 없으면 sentinel 빈 파일 만들어 락 가능 상태로.
295
- mkdirp(path.dirname(target));
296
- fs.writeFileSync(target, '');
297
- }
298
- const release = await lockfile.lock(target, {
299
- stale: opts.stale ?? DEFAULT_LOCK_TIMEOUT_MS,
300
- retries: opts.retries ?? {
301
- retries: 5,
302
- factor: 1,
303
- minTimeout: 1000,
304
- maxTimeout: 1000,
305
- },
306
- realpath: false,
307
- });
153
+ // 리포 맨 위에 0.x 매니페스트가 있으면 멈춘다 — update·init 공통(§3-1). caller로 부르는 명령에 맞는 문구를 낸다
154
+ function checkLegacyInstall(main, caller) {
155
+ const old = path.join(main, OLD_MANIFEST_NAME);
156
+ if (!fs.existsSync(old)) return;
157
+ let version = '0.x';
308
158
  try {
309
- return await fn();
310
- } finally {
311
- await release();
312
- }
313
- }
314
-
315
- function withMetaLockSync(filePath, fn, opts = {}) {
316
- // withMetaLock의 동기 변형. manifest 갱신 경로(initPlan → plan.js main)가 전부 sync라
317
- // async 락을 쓰면 호출 사슬 전체를 async로 바꿔야 해 동기 락을 별도로 둔다.
318
- const lockfile = require('proper-lockfile');
319
- if (!fs.existsSync(filePath)) {
320
- mkdirp(path.dirname(filePath));
321
- fs.writeFileSync(filePath, '');
159
+ version = JSON.parse(fs.readFileSync(old, 'utf8')).version || '0.x';
160
+ } catch (e) {
161
+ // 깨진 파일이어도 0.x 설치본으로 취급한다
322
162
  }
323
- // sync API는 retries를 지원하지 않는다(proper-lockfile 제약). 경합 시 즉시 던지는데,
324
- // manifest 쓰기는 사용자가 직접 부르는 명령들뿐이라 동시 실행이 실질적으로 없다.
325
- const release = lockfile.lockSync(filePath, {
326
- stale: opts.stale ?? DEFAULT_LOCK_TIMEOUT_MS,
327
- realpath: false,
328
- });
329
- try {
330
- return fn();
331
- } finally {
332
- release();
163
+ const oldFiles = '옛 taskery 파일(AGENTS.md·CLAUDE.md·.claude/·.codex/·.agents/·.project/·.mcp.json·.taskery-manifest.json)';
164
+ const moveOldDocs = "필요한 옛 문서(백로그·제품 문서)는 옮겨 둔 곳에서 새 .taskery/로 가져온다.";
165
+ if (caller === 'init') {
166
+ fail(
167
+ `init: 이 리포는 taskery ${version}로 설치돼 있어 그 위에 1.0을 설치할 수 없다(1.0은 0.x와 호환되지 않는다). ${oldFiles}을 리포 밖으로 옮긴 뒤 다시 init한다. ${moveOldDocs}`,
168
+ );
333
169
  }
170
+ fail(
171
+ `update: 이 리포는 taskery ${version}로 설치돼 있어 update로 옮길 수 없다(1.0은 0.x와 호환되지 않는다). ${oldFiles}을 리포 밖으로 옮긴 뒤 'npx @angar2/taskery init'으로 새로 설치한다. ${moveOldDocs}`,
172
+ );
334
173
  }
335
174
 
336
- function currentBranch(mainWtPath) {
337
- // 메인 워크트리의 현재 브랜치 = 태스크의 부모 브랜치(0.6.0). detached HEAD면 'HEAD' 반환.
338
- return gitCapture(mainWtPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
175
+ function readManifest(main) {
176
+ const file = path.join(main, MANIFEST_NAME);
177
+ if (!fs.existsSync(file)) return null;
178
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
339
179
  }
340
180
 
341
- function assertMainWorktreeOn(mainWtPath, branch) {
342
- const current = currentBranch(mainWtPath);
343
- if (current !== branch) {
344
- throw new Error(`메인 워크트리가 ${branch} 아님 (현재: ${current}). 부모 브랜치로 체크아웃 필요.`);
345
- }
181
+ function writeManifest(main, m) {
182
+ const file = path.join(main, MANIFEST_NAME);
183
+ fs.mkdirSync(path.dirname(file), { recursive: true });
184
+ fs.writeFileSync(file, JSON.stringify(m, null, 2) + '\n');
346
185
  }
347
186
 
348
- function parseBranchName(branch) {
349
- // 형식: {type}/{dev}_TASK-NNN_{src}_{slug}
350
- // 예: feature/claude_TASK-007_BL-003_login-feature
351
- // bug/angar2_TASK-012_DR_mobile-form-refresh
352
- const m = branch.match(
353
- /^(feature|bug|improve|refactor|docs|chore)\/([^_]+)_TASK-(\d+)_([A-Z]{2}(?:-\d+)?|DR)_(.+)$/,
354
- );
355
- if (!m) return null;
356
- return {
357
- branch,
358
- type: m[1],
359
- dev: m[2],
360
- taskNum: parseInt(m[3], 10),
361
- src: m[4],
362
- slug: m[5],
363
- };
187
+ function requireInstalled(main) {
188
+ const m = readManifest(main);
189
+ if (!m) fail(`taskery가 설치돼 있지 않다 (${main}). 리포 루트에서 'npx @angar2/taskery init'을 먼저 부른다.`);
190
+ return m;
364
191
  }
365
192
 
366
- function getActiveTasks(mainWtPath) {
367
- // SSoT — TASK-* 패턴 작업 브랜치 (분기 직후 빈 브랜치 포함). `--no-merged <부모>` 미사용:
368
- // 워크트리 분기 직후 브랜치는 부모와 동일 commit이라 `--no-merged`가 *완전 머지 상태*로
369
- // 처리 → 빈 결과 → getNextTaskNumber 충돌. 브랜치 존재 자체가 SSoT (정상 흐름은 task-close
370
- // 자동 삭제로 자연 정리, 보존 키워드 잔존분도 *진행중*으로 잡힘이 정합).
371
- const out = gitCapture(mainWtPath, ['branch', '--list', ...BRANCH_PATTERNS]);
372
- if (!out) return [];
373
- return out
374
- .split('\n')
375
- .map((l) => l.replace(/^[*+]?\s*/, '').trim())
376
- .filter(Boolean)
377
- .map(parseBranchName)
378
- .filter(Boolean);
193
+ function getPackageVersion() {
194
+ return JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8')).version;
379
195
  }
380
196
 
381
- function getNextTaskNumber(mainWtPath) {
382
- // 1. 진행중 (SSoT)
383
- const activeNums = getActiveTasks(mainWtPath).map((t) => t.taskNum);
384
- // 2. 머지 히스토리 (GIT_RULE 풍부 메시지) — 모든 ref(`--all`) 스캔.
385
- // 부모 브랜치가 dev로 고정되지 않으므로(0.6.0) 닫힌 태스크는 각자의 부모에 병합돼 있다.
386
- // 특정 부모(과거 'dev') 하나만 grep하면 다른 부모로 닫힌 번호를 놓쳐 채번 충돌 → `--all`로
387
- // 전 ref의 TASK-NNN 최대를 취해 번호를 리포 전역으로 유지.
388
- // --extended-regexp(ERE)에서 `+`는 수량자 — `\+`는 *리터럴 +* 라 "TASK-001"을 못 잡는다.
389
- const log = gitCapture(mainWtPath, [
390
- 'log',
391
- '--all',
392
- '--grep',
393
- 'TASK-[0-9]+',
394
- '--extended-regexp',
395
- '--oneline',
396
- ]);
397
- const merged = log
398
- ? Array.from(log.matchAll(/TASK-(\d+)/g)).map((m) => parseInt(m[1], 10))
399
- : [];
400
- const all = [...activeNums, ...merged];
401
- if (all.length === 0) return 1;
402
- return Math.max(...all) + 1;
403
- }
197
+ // ─── .state · 잠금 ──────────────────────────────────
404
198
 
405
- async function forkTask(mainWtPath, { type, dev, src, slug }) {
406
- // task 분기 — [채번 → 워크트리·브랜치 생성]을 init 락(withMetaLock)으로 원자화.
407
- // 병렬 task-init이 같은 번호를 읽고 각자 브랜치를 만들던 레이스(TOCTOU)를 차단: 락 안에서
408
- // getNextTaskNumber 직후 worktree add까지 끝내므로 다음 호출은 늘어난 번호를 본다.
409
- const projectId = getProjectId(mainWtPath);
410
- return withMetaLock(getInitLockPath(projectId), () => {
411
- // 부모 = 메인 워크트리의 현재 브랜치(0.6.0). dev 하드코딩 제거 — 서 있는 브랜치가 곧 부모.
412
- const parent = currentBranch(mainWtPath);
413
- if (parent === 'HEAD') {
414
- throw new Error('메인 워크트리가 detached HEAD — 부모로 삼을 브랜치에 체크아웃 필요.');
415
- }
416
- // SSoT 안전망 — 같은 항목 출처(BL-NNN/구형 RM-NNN)가 이미 진행중이면 거부.
417
- // DR(별도 ID 없음)·ST-N(Stage 하나에서 복수 task가 정상)은 검사 제외.
418
- if (/^(BL|RM)-/.test(src)) {
419
- const dup = getActiveTasks(mainWtPath).find((t) => t.src === src);
420
- if (dup) {
421
- throw new Error(`${src} 이미 진행중 (${dup.branch}) — 다른 세션이 같은 항목 진행`);
422
- }
423
- }
424
- const taskNum = getNextTaskNumber(mainWtPath); // 락 안 → TOCTOU 차단
425
- const nnn = String(taskNum).padStart(3, '0');
426
- const branch = `${type}/${dev}_TASK-${nnn}_${src}_${slug}`;
427
- const wtPath = getWorktreePath(projectId, { taskNum, src, slug });
428
- mkdirp(getWorktreesRoot(projectId));
429
- // 부모 브랜치를 base로 워크트리·브랜치 생성. 동일 브랜치명은 git이 거부(같은 항목 동시 분기
430
- // 차단). 실패 시 throw → fork.js가 사용자 보고.
431
- execFileSync('git', ['-C', mainWtPath, 'worktree', 'add', wtPath, '-b', branch, parent], {
432
- stdio: 'pipe',
433
- });
434
- return { taskNum, nnn, branch, wtPath, projectId, parent };
435
- });
199
+ function stateDir(main) {
200
+ return path.join(main, '.taskery', '.state');
436
201
  }
437
202
 
438
- function computeNextPlanNumber(mainWtPath) {
439
- // .project/plans/ 하위 'NNN_<slug>' 디렉토리 스캔 → NNN max+1, 3자리 zero-pad 문자열 반환.
440
- // plan = 기능 그룹 단위, 폴더명 앞 3자리 채번(TASK-NNN과 결 맞춤). slug은 호출측(plan-init)이 결합.
441
- // legacy 가드: 'NNN_' 패턴이 아닌 폴더(구버전 vX.X 등)는 legacyDirs로 수집 → plan-init이
442
- // 새 plan 생성 전 경고·수동 이전 안내에 사용(채번 공존 시 활성 plan 갈림 = 원 문제 재발 차단).
443
- const plansDir = path.join(mainWtPath, '.project', 'plans');
444
- if (!fs.existsSync(plansDir)) {
445
- return { next: '001', legacyDirs: [] };
446
- }
447
- const nums = [];
448
- const legacyDirs = [];
449
- for (const ent of fs.readdirSync(plansDir, { withFileTypes: true })) {
450
- if (!ent.isDirectory()) continue;
451
- const m = ent.name.match(/^(\d{3})_/);
452
- if (m) nums.push(parseInt(m[1], 10));
453
- else legacyDirs.push(ent.name);
454
- }
455
- const next = nums.length === 0 ? 1 : Math.max(...nums) + 1;
456
- return { next: String(next).padStart(3, '0'), legacyDirs };
203
+ function pad(num) {
204
+ return String(num).padStart(3, '0');
457
205
  }
458
206
 
459
- // ─── 백로그 (0.1.2+) ────────────────────────────────────────────────
460
-
461
- const BACKLOG_PLACEHOLDER =
462
- '(사용자 발화 또는 task-close 직후 메인 감지로 한 행씩 추가. 빈 상태 default.)';
463
-
464
- // 구형(0.7.0 미만) 활성 plan 저장소 — .project/AGENT-GUIDE.md '## 활성 plan 버전' 섹션.
465
- // 0.7.0에서 SSoT가 manifest.activePlan으로 이관됐으나, update 전 리포를 무중단으로 굴리기 위해
466
- // 읽기 폴백으로만 남긴다. 쓰기는 하지 않는다(_setActivePlan은 manifest만 갱신).
467
- // 헤딩 줄 끝 부가 텍스트(HTML 주석 등) 허용 — project-init 골격이 헤딩에 주석을 달므로 접두 일치로 판정.
468
- // 완전 일치를 요구하면 자기 골격을 자기가 못 읽는 자기모순 (recordion FRICTION 2026-08-06, scaffoldError 6회).
469
- function _readLegacyActivePlan(mainWtPath) {
470
- const guidePath = path.join(mainWtPath, '.project', 'AGENT-GUIDE.md');
471
- if (!fs.existsSync(guidePath)) return null;
472
- const sec = fs.readFileSync(guidePath, 'utf8').match(/^##\s*활성 plan 버전[^\n]*\n+([^\n]+)/m);
473
- if (!sec) return null;
474
- // 첫 토큰 = plan 폴더 이름(' — 설명' 앞부분). '<예: ...>' 자리표시자는 미설정으로 간주.
475
- const firstLine = sec[1].trim();
476
- if (!firstLine || firstLine.startsWith('<')) return null;
477
- return firstLine.split(/\s+/)[0];
207
+ function taskLabel(num) {
208
+ return `TASK-${pad(num)}`;
478
209
  }
479
210
 
480
- function getActiveVersion(mainWtPath) {
481
- // 활성 plan 식별자 = manifest.activePlan (0.7.0+ SSoT). plan = 기능 그룹 단위라
482
- // 식별자는 NNN_slug 폴더명(구형은 vX.X 버전명도 가능).
483
- const manifest = readManifest(path.join(mainWtPath, MANIFEST_NAME));
484
- if (manifest && manifest.activePlan) return manifest.activePlan;
485
-
486
- // 구형 폴백 — update 전 리포. 읽기만 하고 승격은 update가 1회 수행한다.
487
- const legacy = _readLegacyActivePlan(mainWtPath);
488
- if (legacy) return legacy;
489
-
490
- if (!manifest) {
491
- throw new Error(`${MANIFEST_NAME} 부재. 'npx @angar2/taskery init' 먼저 실행.`);
492
- }
493
- throw new Error(
494
- `활성 plan 미설정 (manifest.activePlan 없음) — /plan-init으로 plan을 생성하거나 ` +
495
- `'npx @angar2/taskery plan-switch <NNN_slug>'로 지정 필요.`,
496
- );
211
+ function parseTaskNum(token) {
212
+ const m = String(token || '').match(/^(?:TASK-)?(\d+)$/i);
213
+ if (!m) fail(`태스크 번호를 읽지 못했다: '${token}'. 'TASK-012'나 '12'처럼 넘긴다.`);
214
+ return parseInt(m[1], 10);
497
215
  }
498
216
 
499
- function setActivePlan(mainWtPath, plan) {
500
- // plan-switch — 대상 plan 폴더 실재 검증 후 manifest.activePlan 갱신.
501
- const planDir = path.join(mainWtPath, '.project', 'plans', plan);
502
- if (!fs.existsSync(planDir)) {
503
- throw new Error(`plan 폴더 부재: ${planDir} — 존재하는 plan만 활성으로 지정 가능.`);
504
- }
505
- _setActivePlan(mainWtPath, plan);
506
- return { plan, planDir };
217
+ function writeJsonAtomic(file, data) {
218
+ fs.mkdirSync(path.dirname(file), { recursive: true });
219
+ const tmp = `${file}.${process.pid}.tmp`;
220
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
221
+ fs.renameSync(tmp, file);
507
222
  }
508
223
 
509
- function getBacklogPath(mainWtPath, activeVersion) {
510
- const v = activeVersion ?? getActiveVersion(mainWtPath);
511
- return path.join(mainWtPath, '.project', 'tasks', v, 'BACKLOG.md');
224
+ function statePath(main, num) {
225
+ return path.join(stateDir(main), 'tasks', `${pad(num)}.json`);
512
226
  }
513
227
 
514
- function computeNextBLNumber(content) {
515
- const matches = Array.from(content.matchAll(/\bBL-(\d+)\b/g)).map((m) => parseInt(m[1], 10));
516
- if (matches.length === 0) return 1;
517
- return Math.max(...matches) + 1;
228
+ function readState(main, num) {
229
+ const file = statePath(main, num);
230
+ if (!fs.existsSync(file)) fail(`${taskLabel(num)}의 기록이 없다. 'status'로 열린 태스크 번호를 확인한다.`);
231
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
518
232
  }
519
233
 
520
- function formatBacklogBlock({ id, type, title, slug, summary, target }) {
521
- const blId = `BL-${String(id).padStart(3, '0')}`;
522
- return `- [ ] **${blId}** [${type}] ${title} \`${slug}\`\n - 개요: ${summary}\n - 대상 영역: ${target}`;
234
+ function writeState(main, st) {
235
+ writeJsonAtomic(statePath(main, st.num), st);
523
236
  }
524
237
 
525
- async function appendBacklogItem(mainWtPath, meta) {
526
- // meta: { type, title, slug, summary, target }
527
- const backlogPath = getBacklogPath(mainWtPath);
528
- if (!fs.existsSync(backlogPath)) {
529
- throw new Error(
530
- `${backlogPath} 부재. /plan-init 먼저 호출해 활성 버전 디렉토리 + 빈 BACKLOG.md 생성 필요.`,
531
- );
532
- }
533
- return await withMetaLock(backlogPath, async () => {
534
- const content = fs.readFileSync(backlogPath, 'utf8');
535
- const next = computeNextBLNumber(content);
536
- const block = formatBacklogBlock({ id: next, ...meta });
537
- let updated;
538
- if (content.includes(BACKLOG_PLACEHOLDER)) {
539
- updated = content.replace(BACKLOG_PLACEHOLDER, block);
540
- } else {
541
- updated = content.replace(/\s*$/, '') + '\n\n' + block + '\n';
542
- }
543
- fs.writeFileSync(backlogPath, updated);
544
- return next;
545
- });
546
- }
547
-
548
- function parseBacklogItem(mainWtPath, blId) {
549
- // blId: 'BL-001'. 발견 못하면 null.
550
- const backlogPath = getBacklogPath(mainWtPath);
551
- if (!fs.existsSync(backlogPath)) return null;
552
- const content = fs.readFileSync(backlogPath, 'utf8');
553
- const lines = content.split('\n');
554
- const headRe = new RegExp(
555
- `^- \\[([ x])\\] \\*\\*${blId}\\*\\* \\[(\\w+)\\] (.+?) \`([\\w-]+)\``,
556
- );
557
- let idx = -1;
558
- let head = null;
559
- for (let i = 0; i < lines.length; i++) {
560
- const m = lines[i].match(headRe);
561
- if (m) {
562
- idx = i;
563
- head = m;
564
- break;
565
- }
566
- }
567
- if (idx === -1) return null;
568
- let summary = null;
569
- let target = null;
570
- let taskNums = null;
571
- for (let j = idx + 1; j < lines.length; j++) {
572
- const l = lines[j];
573
- if (l.startsWith('- ') && !l.startsWith(' - ')) break; // 다음 BL 항목
574
- if (l.trim() === '') continue; // 블록 안 빈 줄 허용 (안전)
575
- if (!l.startsWith(' - ')) break;
576
- const ms = l.match(/^ - 개요:\s*(.+)/);
577
- if (ms) { summary = ms[1].trim(); continue; }
578
- const mt = l.match(/^ - 대상 영역:\s*(.+)/);
579
- if (mt) { target = mt[1].trim(); continue; }
580
- const mk = l.match(/^ - TASK:\s*(.+)/);
581
- if (mk) { taskNums = mk[1].trim(); continue; }
582
- }
583
- return {
584
- blId,
585
- status: head[1] === 'x' ? 'checked' : 'pending',
586
- type: head[2],
587
- title: head[3],
588
- slug: head[4],
589
- summary,
590
- target,
591
- taskNums,
592
- };
238
+ function listStates(main) {
239
+ const dir = path.join(stateDir(main), 'tasks');
240
+ if (!fs.existsSync(dir)) return [];
241
+ return fs
242
+ .readdirSync(dir)
243
+ .filter((f) => /^\d+\.json$/.test(f))
244
+ .map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')))
245
+ .sort((a, b) => a.num - b.num);
593
246
  }
594
247
 
595
- async function markBacklogChecked(mainWtPath, blId, taskNum) {
596
- // taskNum: number. [ ] → [x] + TASK 마크 (있으면 콤마 추가, 없으면 신규 줄)
597
- const backlogPath = getBacklogPath(mainWtPath);
598
- if (!fs.existsSync(backlogPath)) {
599
- throw new Error(`${backlogPath} 부재.`);
600
- }
601
- const taskLabel = `TASK-${String(taskNum).padStart(3, '0')}`;
602
- return await withMetaLock(backlogPath, async () => {
603
- const content = fs.readFileSync(backlogPath, 'utf8');
604
- const lines = content.split('\n');
605
- const headRe = new RegExp(`^(- )\\[[ x]\\](\\s+\\*\\*${blId}\\*\\*.+)$`);
606
- let idx = -1;
607
- for (let i = 0; i < lines.length; i++) {
608
- if (headRe.test(lines[i])) {
609
- idx = i;
610
- break;
611
- }
612
- }
613
- if (idx === -1) {
614
- throw new Error(`${blId} 항목 없음.`);
615
- }
616
- lines[idx] = lines[idx].replace(headRe, '$1[x]$2');
617
- // 다음 BL 헤드 또는 파일 끝까지가 같은 블록 — 그 안에서 TASK 줄 검색
618
- let blockEnd = lines.length;
619
- for (let j = idx + 1; j < lines.length; j++) {
620
- if (lines[j].startsWith('- ') && !lines[j].startsWith(' - ')) {
621
- blockEnd = j;
622
- break;
623
- }
624
- }
625
- let taskLineIdx = -1;
626
- for (let j = idx + 1; j < blockEnd; j++) {
627
- if (lines[j].match(/^ - TASK:/)) {
628
- taskLineIdx = j;
629
- break;
630
- }
631
- }
632
- if (taskLineIdx !== -1) {
633
- const existing = lines[taskLineIdx].match(/^ - TASK:\s*(.+)/)[1].trim();
634
- const existingTasks = existing.split(/[,\s]+/).filter(Boolean);
635
- if (!existingTasks.includes(taskLabel)) {
636
- lines[taskLineIdx] = ` - TASK: ${existing}, ${taskLabel}`;
637
- }
638
- } else {
639
- // blockEnd 직전 빈 줄 건너뛰고 삽입
640
- let insertAt = blockEnd;
641
- while (insertAt > idx + 1 && lines[insertAt - 1].trim() === '') insertAt--;
642
- lines.splice(insertAt, 0, ` - TASK: ${taskLabel}`);
643
- }
644
- fs.writeFileSync(backlogPath, lines.join('\n'));
248
+ // 잠금 — .taskery/.state/<name>.lock. wait=true면 풀릴 때까지 기다린다
249
+ async function withLock(main, name, fn) {
250
+ const lockfile = require('proper-lockfile');
251
+ const file = path.join(stateDir(main), `${name}.lock`);
252
+ fs.mkdirSync(path.dirname(file), { recursive: true });
253
+ if (!fs.existsSync(file)) fs.writeFileSync(file, '');
254
+ const release = await lockfile.lock(file, {
255
+ stale: 60000,
256
+ realpath: false,
257
+ retries: { retries: 100000, factor: 1, minTimeout: 300, maxTimeout: 1000 },
645
258
  });
646
- }
647
-
648
- // ─── task 헤더 / 상태 전이 (코드화) ─────────────────────────────────
649
- // 헤더 파싱(6컬럼 · 구 5컬럼 폴백) · task 문서 위치 판정 · 7상태 유효전이 검증을 코드로 단일화.
650
- // 계약: TASK_DOC_RULE §1.1(헤더) · §1.2(7상태) · §1.5(문서 위치).
651
-
652
- // 7×7 유효전이표 (TASK_DOC_RULE §1.2 + FAIL/UNCERTAIN 분기). from → 허용 to 목록.
653
- // - 정상 진행: draft→planned→developing→developed→testing→tested→closed
654
- // - 되돌림: testing→developing (task-test FAIL "코드 결함" 사용자 "고쳐")
655
- // - closed는 종료 상태(빈 목록) — closed-immutable hook이 이후 본 파일 잠금.
656
- const TRANSITIONS = {
657
- draft: ['planned'],
658
- planned: ['developing'],
659
- developing: ['developed'],
660
- developed: ['testing'],
661
- testing: ['tested', 'developing'],
662
- tested: ['closed'],
663
- closed: [],
664
- };
665
-
666
- function _locateHeaderRow(content) {
667
- // 헤더 표(라벨행 → 구분선 → 데이터행)에서 데이터행을 찾아 split 셀과 줄 인덱스 반환.
668
- // 데이터행 셀: `| a | b | c | d | e |` → split('|') = ['', ' a ', ..., ' e ', '']. null이면 표 부재.
669
- const lines = content.split('\n');
670
- let labelIdx = -1;
671
- for (let i = 0; i < lines.length; i++) {
672
- const l = lines[i];
673
- if (l.trimStart().startsWith('|') && l.includes('생성일') && l.includes('상태')) {
674
- labelIdx = i;
675
- break;
676
- }
677
- }
678
- if (labelIdx === -1) return null;
679
- let dataIdx = -1;
680
- for (let j = labelIdx + 1; j < lines.length; j++) {
681
- const t = lines[j].trim();
682
- if (t === '') continue;
683
- if (/^[|\s:-]+$/.test(t)) continue; // 구분선 (|---|---|)
684
- if (lines[j].trimStart().startsWith('|')) {
685
- dataIdx = j;
686
- break;
687
- }
688
- break; // 표 끝
259
+ try {
260
+ return await fn();
261
+ } finally {
262
+ await release();
689
263
  }
690
- if (dataIdx === -1) return null;
691
- return { lines, dataIdx, cells: lines[dataIdx].split('|') };
692
264
  }
693
265
 
694
- function parseTaskHeader(taskMdPath) {
695
- // 헤더를 구조화 → { created, plan, type, size, parent, status }. 표 부재/컬럼 부족 시 throw.
696
- // 6컬럼(0.6.0+): 생성일·플랜·유형·규모·부모 브랜치·상태. 부모 브랜치는 상태 *앞* — 상태를
697
- // 항상 마지막 셀로 유지해 setStatus의 `length-2` 로직이 구·신 양쪽에서 성립하게 함.
698
- // 5컬럼(구): 부모 칸 부재 → parent='dev' 폴백(0.6.0 이전 문서 하위호환).
699
- const content = fs.readFileSync(taskMdPath, 'utf8');
700
- const loc = _locateHeaderRow(content);
701
- if (!loc) throw new Error(`헤더 표 파싱 실패 (TASK_DOC_RULE §1.1 형식 아님): ${taskMdPath}`);
702
- const inner = loc.cells.slice(1, -1).map((c) => c.trim());
703
- if (inner.length < 5) {
704
- throw new Error(`헤더 표 컬럼 부족(${inner.length}/5): ${taskMdPath}`);
705
- }
706
- if (inner.length >= 6) {
707
- const [created, plan, type, size, parent, status] = inner;
708
- return { created, plan, type, size, parent, status };
709
- }
710
- const [created, plan, type, size, status] = inner;
711
- return { created, plan, type, size, parent: 'dev', status };
266
+ // 단계 판정에 쓰는 정의
267
+ function sw(st, name) {
268
+ return st.switch.includes(name);
712
269
  }
713
270
 
714
- function isProjectRegistered(mainWtPath) {
715
- // .gitignore에 .project/ 등록 여부 — check-ignore exit 0 = 등록(퍼블릭 default, 문서는 메인WT 공유),
716
- // exit 1 = 미등록(문서는 워크트리 안, 머지로 부모 반영). task-init Step7 분기와 동일 판정.
717
- try {
718
- execFileSync(
719
- 'git',
720
- ['-C', mainWtPath, 'check-ignore', '-q', path.join(mainWtPath, '.project', 'dummy')],
721
- { stdio: 'ignore' },
722
- );
723
- return true;
724
- } catch (e) {
725
- return false;
726
- }
271
+ // 병합이 필요 없는 태스크 — 개발 꺼짐·브랜치 생략·코드 변경 없음(§5-3)
272
+ function needsNoMerge(st) {
273
+ return !sw(st, 'dev') || st.noBranch || !!(st.commit && st.commit.noCodeChange);
727
274
  }
728
275
 
729
- function resolveTaskDocPath(mainWtPath, wtPath, { taskNum }) {
730
- // task 문서 위치 단일 판정 — 등록 케이스로 base 결정 후 taskNum으로 탐색.
731
- // 단일 파일(`<NNN>_<slug>.md`) / 폴더 승격(`TASK-<NNN>_<slug>/task.md`) 양쪽 + plan 폴더 무관(*) 글롭.
732
- // 발견 못하면 null. 반환: { registered, plan, promoted, dir, file }.
733
- const nnn = String(taskNum).padStart(3, '0');
734
- const registered = isProjectRegistered(mainWtPath);
735
- const base = registered ? mainWtPath : wtPath;
736
- const tasksRoot = path.join(base, '.project', 'tasks');
737
- if (!fs.existsSync(tasksRoot)) return null;
738
- const fileRe = new RegExp(`^${nnn}_.+\\.md$`);
739
- const dirRe = new RegExp(`^TASK-${nnn}_`);
740
- for (const planEnt of fs.readdirSync(tasksRoot, { withFileTypes: true })) {
741
- if (!planEnt.isDirectory()) continue;
742
- const planDir = path.join(tasksRoot, planEnt.name);
743
- for (const ent of fs.readdirSync(planDir, { withFileTypes: true })) {
744
- if (ent.isFile() && fileRe.test(ent.name)) {
745
- return {
746
- registered,
747
- plan: planEnt.name,
748
- promoted: false,
749
- dir: planDir,
750
- file: path.join(planDir, ent.name),
751
- };
752
- }
753
- if (ent.isDirectory() && dirRe.test(ent.name)) {
754
- const f = path.join(planDir, ent.name, 'task.md');
755
- if (fs.existsSync(f)) {
756
- return {
757
- registered,
758
- plan: planEnt.name,
759
- promoted: true,
760
- dir: path.join(planDir, ent.name),
761
- file: f,
762
- };
763
- }
764
- }
765
- }
766
- }
767
- return null;
276
+ // 끝난 태스크 = 마무리 기록이 있는 태스크(병합 기록, 병합이 필요 없으면 commit-task 완료 기록, §5-3)
277
+ function hasFinishRecord(st) {
278
+ if (st.merge && st.merge.commit) return true;
279
+ return needsNoMerge(st) && !!st.commit;
768
280
  }
769
281
 
770
- async function setStatus(taskMdPath, next) {
771
- // 헤더 상태 컬럼을 next로 전이 — 현재 상태 읽고 7×7 표 검증 후 치환 (withMetaLock 직렬화).
772
- // - next가 7상태 아님 → throw. 현재==next → idempotent no-op (changed:false).
773
- // - 유효전이 아님 → throw (잘못된 전이 거부).
774
- if (!Object.prototype.hasOwnProperty.call(TRANSITIONS, next)) {
775
- throw new Error(`알 수 없는 상태: ${next} (유효: ${Object.keys(TRANSITIONS).join(', ')})`);
776
- }
777
- if (!fs.existsSync(taskMdPath)) throw new Error(`task 문서 없음: ${taskMdPath}`);
778
- return await withMetaLock(taskMdPath, async () => {
779
- const content = fs.readFileSync(taskMdPath, 'utf8');
780
- const loc = _locateHeaderRow(content);
781
- if (!loc) throw new Error(`헤더 표 파싱 실패: ${taskMdPath}`);
782
- const trimmed = loc.cells.slice(1, -1).map((c) => c.trim());
783
- const cur = trimmed[trimmed.length - 1]; // 상태 = 항상 마지막 셀 (6컬럼: 부모 브랜치가 그 앞)
784
- if (cur === next) return { changed: false, from: cur, to: next, path: taskMdPath };
785
- const allowed = TRANSITIONS[cur] || [];
786
- if (!allowed.includes(next)) {
787
- throw new Error(
788
- `잘못된 전이: ${cur} → ${next} (허용: ${allowed.length ? allowed.join(', ') : '없음 — 종료 상태'})`,
789
- );
790
- }
791
- const statusCellIdx = loc.cells.length - 2; // 마지막 빈 셀 직전 = 상태
792
- loc.cells[statusCellIdx] = loc.cells[statusCellIdx].replace(cur, next);
793
- loc.lines[loc.dataIdx] = loc.cells.join('|');
794
- fs.writeFileSync(taskMdPath, loc.lines.join('\n'));
795
- return { changed: true, from: cur, to: next, path: taskMdPath };
796
- });
282
+ // 태스크 코드가 있는 폴더 — 워크트리, 분기 생략이면 본진
283
+ function workDir(main, st) {
284
+ return st.worktree || main;
797
285
  }
798
286
 
799
- // ─── task 문서 골격 생성 (코드화, C1) ───────────────────────────────
800
- // fork 직후 §1.3 빈 골격을 코드가 자동 생성 — 생성일=오늘 / status=draft 자동, 파일명 규칙 코드 보장.
287
+ // ─── GIT_RULE.md 맨 위 표 ─────────────────────────────
801
288
 
802
- // 생성일=오늘(로컬 타임존 YYYY-MM-DD). toISOString()은 UTC라 KST 자정~09시 사이 전날로 박힘 → 로컬 산출.
803
- function _todayLocal() {
804
- const d = new Date();
805
- const y = d.getFullYear();
806
- const m = String(d.getMonth() + 1).padStart(2, '0');
807
- const day = String(d.getDate()).padStart(2, '0');
808
- return `${y}-${m}-${day}`;
809
- }
810
-
811
- // 브랜치 타입 → 문서 유형(§1.1). improve↔improvement만 다름.
812
- const DOC_TYPE_OF = {
813
- feature: 'feature',
814
- bug: 'bug',
815
- improve: 'improvement',
816
- refactor: 'refactor',
817
- docs: 'docs',
818
- chore: 'chore',
289
+ const GIT_RULE_DEFAULTS = {
290
+ integration: 'dev',
291
+ branch: '{type}/{dev}_TASK-{num}_{slug}',
292
+ commit: '{tag}: [TASK-{num}] {summary}',
293
+ merge: 'no-ff',
294
+ };
295
+ const GIT_RULE_KEYS = {
296
+ '통합 브랜치': 'integration',
297
+ '브랜치 이름': 'branch',
298
+ '커밋 메시지': 'commit',
299
+ '병합 방식': 'merge',
819
300
  };
820
301
 
821
- function _buildTaskDocPath(base, plan, { taskNum, slug, promoted }) {
822
- const nnn = String(taskNum).padStart(3, '0');
823
- const planDir = path.join(base, '.project', 'tasks', plan);
824
- if (promoted) {
825
- const dir = path.join(planDir, `TASK-${nnn}_${slug}`);
826
- return { dir, file: path.join(dir, 'task.md') };
302
+ function parseGitRuleTable(text) {
303
+ const rule = {};
304
+ for (const line of text.split('\n')) {
305
+ const m = line.match(/^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*$/);
306
+ if (!m) continue;
307
+ const key = GIT_RULE_KEYS[m[1]];
308
+ if (key && !rule[key]) rule[key] = m[2].replace(/`/g, '').trim();
827
309
  }
828
- return { dir: planDir, file: path.join(planDir, `${nnn}_${slug}.md`) };
310
+ return rule;
829
311
  }
830
312
 
831
- function _renderTaskSkeleton({ taskNum, title, created, plan, docType, size, parent }) {
832
- const nnn = String(taskNum).padStart(3, '0');
833
- return [
834
- `# TASK-${nnn} — ${title}`,
835
- '',
836
- '| 생성일 | 플랜 | 유형 | 규모 | 부모 브랜치 | 상태 |',
837
- '|--------|------|------|------|-------------|------|',
838
- `| ${created} | ${plan} | ${docType} | ${size} | ${parent} | draft |`,
839
- '',
840
- '## Requirements',
841
- '',
842
- '(사용자 요구 + 메인 증폭 — `/task-plan`에서 채움)',
843
- '',
844
- '## Scope',
845
- '',
846
- '(영향 범위 — `/task-plan`에서 채움)',
847
- '',
848
- '## Dev Plan',
849
- '',
850
- '(Phase 1, 2, ... — `/task-plan`에서 채움)',
851
- '',
852
- '## Test Plan',
853
- '',
854
- '(테스트 방법 + 검증 명령 — `/task-plan`에서 채움)',
855
- '',
856
- '## Result',
857
- '',
858
- '(진행 + 테스트 결과 — `/task-dev`, `/task-test`에서 채움)',
859
- '',
860
- ].join('\n');
313
+ function readGitRule(main) {
314
+ const file = path.join(main, '.taskery', 'rules', 'GIT_RULE.md');
315
+ const rule = fs.existsSync(file) ? parseGitRuleTable(fs.readFileSync(file, 'utf8')) : {};
316
+ const merged = { ...GIT_RULE_DEFAULTS, ...rule };
317
+ if (!['no-ff', 'ff-only'].includes(merged.merge)) {
318
+ fail(`GIT_RULE.md 표의 병합 방식 '${merged.merge}'을 읽지 못했다. 'no-ff'나 'ff-only'로 적는다.`);
319
+ }
320
+ return merged;
861
321
  }
862
322
 
863
- function scaffoldTaskDoc(mainWtPath, wtPath, meta) {
864
- // meta: { taskNum, slug, title, type(브랜치 타입), size, parent, plan?, promoted?, created? }
865
- // 위치: 등록(.gitignore) 케이스 → 메인WT, 미등록 → 워크트리. plan 미지정 시 활성 plan(manifest.activePlan)에서 검출.
866
- // parent 미지정 시 현재 브랜치 캡처(단독 호출 안전망 — 통상 fork가 넘김).
867
- // taskNum이 고유(fork init 락 채번)라 동일 문서 동시 쓰기 경합 없음 → 락 불요.
868
- const registered = isProjectRegistered(mainWtPath);
869
- const base = registered ? mainWtPath : wtPath;
870
- const plan = meta.plan || getActiveVersion(mainWtPath);
871
- const docType = DOC_TYPE_OF[meta.type] || meta.type;
872
- const created = meta.created || _todayLocal();
873
- const parent = meta.parent || currentBranch(mainWtPath);
874
- const { dir, file } = _buildTaskDocPath(base, plan, {
875
- taskNum: meta.taskNum,
876
- slug: meta.slug,
877
- promoted: !!meta.promoted,
878
- });
879
- if (fs.existsSync(file) && fs.readFileSync(file, 'utf8').trim()) {
880
- throw new Error(`task 문서 이미 존재: ${file}`);
881
- }
882
- mkdirp(dir);
883
- fs.writeFileSync(
884
- file,
885
- _renderTaskSkeleton({
886
- taskNum: meta.taskNum,
887
- title: meta.title,
888
- created,
889
- plan,
890
- docType,
891
- size: meta.size,
892
- parent,
893
- }),
894
- );
895
- return { registered, promoted: !!meta.promoted, plan, file };
323
+ function fillTemplate(tpl, vars) {
324
+ return tpl.replace(/\{(\w+)\}/g, (all, k) => (k in vars ? vars[k] : all));
896
325
  }
897
326
 
898
- // ─── plan 생성 (코드화, C3) ─────────────────────────────────────────
899
- // 채번 + legacy 게이트 + 폴더 mkdir + ROADMAP/PLAN/BACKLOG 골격 + manifest.activePlan 갱신을 코드로.
900
- // LLM 몫(FEATURES/UX-UI delta · ROADMAP Stage 내용 · PLAN 링크)은 골격의 placeholder로 남긴다.
327
+ // ─── 태스크 문서 ─────────────────────────────────────
901
328
 
902
- function _renderRoadmap(plan) {
903
- return [
904
- `# ROADMAP — ${plan}`,
905
- '',
906
- '> 본 plan(기능 그룹) 한정 task 단계. Stage 단위(task 번호 강제 X), 상태 컬럼만 Living.',
907
- '> 프로젝트 거시 빌드 순서는 PROJECT.md, 다음 기능 그룹 후보는 글로벌 BACKLOG.md.',
908
- '',
909
- '## Stage 1 — <영역명>',
910
- '| 작업 단위 | 상태 |',
911
- '|-----------|------|',
912
- '| <한 task 분량 작업> | ⏳ 대기 |',
913
- '',
914
- ].join('\n');
329
+ function docAbs(main, st) {
330
+ return path.join(main, st.doc);
915
331
  }
916
332
 
917
- function _renderPlanIndex(plan) {
918
- return [
919
- `# PLAN — ${plan}`,
920
- '',
921
- '> 이 plan(기능 그룹)의 얇은 인덱스. 각 항목은 루트 문서 섹션 링크 1줄 — 본문은 복제하지 않는다.',
922
- '',
923
- '## 이 기능 그룹이 건드리는 루트 문서',
924
- '- [FEATURES.md › <이 그룹 섹션>](../../FEATURES.md) — <한 줄 요약>',
925
- '- [UX-UI.md › <이 그룹 섹션>](../../UX-UI.md) — <한 줄 요약>',
926
- '- (DATA-MODEL / API-SPEC — task 진행이 구현 동반으로 채움)',
927
- '',
928
- '## task 체크리스트',
929
- '- [ ] <작업 단위> (ROADMAP Stage N)',
930
- '',
931
- ].join('\n');
333
+ // 사람·AI에게 보여 주는 문서 경로 — 워크트리가 있으면 그 안의 링크 너머 경로(같은 파일).
334
+ // 워크트리에 들어간 세션은 본진 경로 편집이 막히기 때문이다. 읽기·쓰기는 docAbs(본진) 그대로
335
+ function docShown(main, st) {
336
+ if (st.worktree && fs.existsSync(st.worktree)) return path.join(st.worktree, st.doc);
337
+ return docAbs(main, st);
932
338
  }
933
339
 
934
- function _renderPlanBacklog(plan) {
935
- return [
936
- `# BACKLOG — ${plan}`,
937
- '',
938
- `> 본 파일은 *${plan} plan 진행 중 누적되는 후속 task 후보* 추적용.`,
939
- '> 글로벌 `.project/BACKLOG.md` (다음 기능 그룹 후보 카탈로그) 와 별개.',
940
- '',
941
- '| 위치 | 영역 | 역할 |',
942
- '|------|------|------|',
943
- '| `.project/BACKLOG.md` (글로벌) | 다음 기능 그룹 후보 카탈로그 (다음 plan에서 검토) | Living document |',
944
- `| \`.project/tasks/${plan}/BACKLOG.md\` (본 파일) | 현재 plan 진행 중 누적된 후속 task 후보 | 한 plan 내 task close 직후 후보 누적 |`,
945
- '',
946
- '## 후속 task 후보',
947
- '',
948
- BACKLOG_PLACEHOLDER,
949
- '',
950
- ].join('\n');
340
+ function readDoc(main, st) {
341
+ const file = docAbs(main, st);
342
+ if (!fs.existsSync(file)) fail(`태스크 문서가 없다: ${docShown(main, st)}`);
343
+ return fs.readFileSync(file, 'utf8');
951
344
  }
952
345
 
953
- function _setActivePlan(mainWtPath, plan) {
954
- // 활성 plan SSoT = manifest.activePlan (0.7.0+). 구형 AGENT-GUIDE.md는 갱신하지 않는다
955
- // (읽기 폴백 전용 — 두 곳에 쓰면 다시 갈린다).
956
- // manifest는 읽고-고쳐-쓰기라 다른 기록자(init/update/add)와 겹치면 필드가 유실될 수 있어 락으로 감싼다.
957
- const manifestPath = path.join(mainWtPath, MANIFEST_NAME);
958
- if (!fs.existsSync(manifestPath)) {
959
- throw new Error(`${MANIFEST_NAME} 부재. 'npx @angar2/taskery init' 먼저 실행.`);
346
+ // `## 제목` 절 본문(다음 `## `까지)
347
+ function section(text, title) {
348
+ const lines = text.split('\n');
349
+ const start = lines.findIndex((l) => l.trim() === `## ${title}`);
350
+ if (start === -1) return null;
351
+ const out = [];
352
+ for (let i = start + 1; i < lines.length; i++) {
353
+ if (/^## /.test(lines[i])) break;
354
+ out.push(lines[i]);
960
355
  }
961
- withMetaLockSync(manifestPath, () => {
962
- const manifest = readManifest(manifestPath);
963
- if (!manifest) throw new Error(`${MANIFEST_NAME} 읽기 실패.`);
964
- manifest.activePlan = plan;
965
- writeManifest(manifest, manifestPath);
966
- });
356
+ return out.join('\n');
967
357
  }
968
358
 
969
- function initPlan(mainWtPath, slug, opts = {}) {
970
- // 채번 → legacy 게이트 → 폴더 + 골격 + manifest.activePlan 갱신. slug 검증은 CLI 측.
971
- // legacy 폴더(NNN_ 아닌 plan 폴더) 잔존 + !force → { gated:true } 반환(CLI가 사용자 confirm 후 force 재호출).
972
- const { next, legacyDirs } = computeNextPlanNumber(mainWtPath);
973
- if (legacyDirs.length && !opts.force) {
974
- return { gated: true, legacyDirs, next };
975
- }
976
- const plan = `${next}_${slug}`;
977
- const planDir = path.join(mainWtPath, '.project', 'plans', plan);
978
- const tasksDir = path.join(mainWtPath, '.project', 'tasks', plan);
979
- if (fs.existsSync(planDir)) {
980
- throw new Error(`plan 폴더 이미 존재: ${planDir}`);
981
- }
982
- mkdirp(planDir);
983
- mkdirp(path.join(tasksDir, 'spec-diffs'));
984
- mkdirp(path.join(tasksDir, 'screenshots'));
985
- mkdirp(path.join(tasksDir, 'mockup'));
986
- fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), _renderRoadmap(plan));
987
- fs.writeFileSync(path.join(planDir, 'PLAN.md'), _renderPlanIndex(plan));
988
- fs.writeFileSync(path.join(tasksDir, 'BACKLOG.md'), _renderPlanBacklog(plan));
989
- _setActivePlan(mainWtPath, plan);
990
- return { gated: false, plan, nnn: next, planDir, tasksDir, legacyDirs };
359
+ function meaningfulLines(body) {
360
+ return (body || '')
361
+ .replace(/<!--[\s\S]*?-->/g, '')
362
+ .split('\n')
363
+ .map((l) => l.trim())
364
+ .filter(Boolean);
991
365
  }
992
366
 
993
- // ─── task close 결정적 준비 (코드화, D1·D2) ─────────────────────────
994
- // closeTask는 *결정적 준비만* 수행: 게이트(status=tested) → Phase 커밋(D2) → status=closed →
995
- // flows/문서 커밋 → 추적마커. 비가역 3종(부모 브랜치 --no-ff 머지 · worktree remove · branch -d)과
996
- // 충돌 해결·머지 락은 스킬(LLM)이 수행 — 위험 최소화 + 충돌은 LLM 판단 영역.
997
-
998
- // 브랜치 타입 → 커밋 태그 (GIT_RULE 6-3).
999
- const TAG_OF = {
1000
- feature: 'feat',
1001
- bug: 'fix',
1002
- improve: 'improve',
1003
- refactor: 'refactor',
1004
- docs: 'docs',
1005
- chore: 'docs',
1006
- };
367
+ // 경로 한 줄 → 경로. 백틱 안을 우선, 없으면 ' — ' 앞, 꼬리 괄호 주석은 뗀다
368
+ function cleanPath(raw) {
369
+ let s = raw.trim();
370
+ const tick = s.match(/`([^`]+)`/);
371
+ if (tick) s = tick[1];
372
+ else s = s.split(/\s+—\s+|\s+-\s+/)[0];
373
+ return s.replace(/\s*[((][^))]*[))]\s*$/, '').trim();
374
+ }
1007
375
 
1008
- function _parseDevPlanPhases(content) {
1009
- // ## Dev Plan 섹션의 `### Phase N — 이름` + `- 파일:` 필드 추출.
1010
- // 다중 파일 표기 지원 — 한 줄(쉼표·가운뎃점 `·` 구분) + `- 파일:` 다음의 들여쓴 하위 목록.
1011
- // `/`는 경로 문자라 분할자로 쓰지 않는다. `(신규)`류 꼬리 괄호 주석은 제거한다.
1012
- const lines = content.split('\n');
1013
- let inDevPlan = false;
376
+ function parseFileList(body) {
377
+ return meaningfulLines(body)
378
+ .filter((l) => /^[-*]\s+/.test(l))
379
+ .map((l) => cleanPath(l.replace(/^[-*]\s+/, '')))
380
+ .filter(Boolean);
381
+ }
382
+
383
+ // `## Phase` 절: `### Phase N — 이름` + `- 파일:`(한 줄 쉼표·가운뎃점 구분 또는 들여쓴 목록) + `- 사유:`
384
+ function parsePhases(text) {
385
+ const body = section(text, 'Phase');
386
+ if (!body) return [];
1014
387
  const phases = [];
1015
388
  let cur = null;
1016
- let fileIndent = null; // `- 파일:` 줄의 들여쓰기 — 이보다 깊은 목록 항목을 파일로 수집
1017
- const pushFiles = (raw) => {
1018
- for (const piece of raw.split(/[,·]/)) {
1019
- const f = piece
1020
- .replace(/`/g, '')
1021
- .trim()
1022
- .replace(/\s*[((][^))]*[))]\s*$/, ''); // 꼬리 괄호 주석 제거
1023
- if (f && !/^[(<]/.test(f)) cur.files.push(f); // placeholder(<...>, (...)) 제외
1024
- }
1025
- };
1026
- for (const l of lines) {
1027
- if (/^##\s+Dev Plan/.test(l)) {
1028
- inDevPlan = true;
1029
- continue;
1030
- }
1031
- if (inDevPlan && /^##\s+/.test(l)) break; // 다음 섹션
1032
- if (!inDevPlan) continue;
1033
- const ph = l.match(/^###\s+Phase\s+(\d+)\s*(?:[—-]\s*)?(.*)$/);
389
+ let fileIndent = null;
390
+ for (const l of body.split('\n')) {
391
+ const ph = l.match(/^###\s+Phase\s+(\d+)\s*(?:[—–-]\s*)?(.*)$/);
1034
392
  if (ph) {
1035
- cur = { num: parseInt(ph[1], 10), name: ph[2].trim(), files: [] };
393
+ cur = { num: parseInt(ph[1], 10), name: ph[2].trim(), files: [], reason: '' };
1036
394
  phases.push(cur);
1037
395
  fileIndent = null;
1038
396
  continue;
1039
397
  }
1040
398
  if (!cur) continue;
1041
- const fm = l.match(/^(\s*)-\s*파일\s*:\s*(.*)$/);
399
+ const fm = l.match(/^(\s*)[-*]\s*파일\s*:\s*(.*)$/);
1042
400
  if (fm) {
1043
401
  fileIndent = fm[1].length;
1044
- if (fm[2].trim()) pushFiles(fm[2]);
402
+ for (const p of fm[2].split(/[,·]/)) if (cleanPath(p)) cur.files.push(cleanPath(p));
403
+ continue;
404
+ }
405
+ const rm = l.match(/^\s*[-*]\s*사유\s*:\s*(.*)$/);
406
+ if (rm) {
407
+ cur.reason = rm[1].trim();
408
+ fileIndent = null;
1045
409
  continue;
1046
410
  }
1047
411
  if (fileIndent != null) {
1048
- const sub = l.match(/^(\s+)-\s+(.+)$/);
412
+ const sub = l.match(/^(\s+)[-*]\s+(.+)$/);
1049
413
  if (sub && sub[1].length > fileIndent) {
1050
- pushFiles(sub[2]);
414
+ if (cleanPath(sub[2])) cur.files.push(cleanPath(sub[2]));
1051
415
  continue;
1052
416
  }
1053
- fileIndent = null; // 다른 필드·비목록 줄에서 하위 목록 수집 종료
417
+ fileIndent = null;
1054
418
  }
1055
419
  }
1056
420
  return phases;
1057
421
  }
1058
422
 
1059
- function _porcelainFiles(wtPath) {
1060
- // git status --porcelain -uall → 변경 파일 경로 목록 (XY 상태코드 3자 제거).
1061
- // -uall: untracked 디렉토리를 개별 파일로 전개 (디렉토리 통째 `src/` 접힘 방지).
1062
- const out = gitCapture(wtPath, ['status', '--porcelain', '-uall'], { allowFail: true });
1063
- if (!out) return [];
1064
- return out
1065
- .split('\n')
1066
- .filter(Boolean)
1067
- .map((l) => l.slice(3).trim())
1068
- .map((p) => (p.includes(' -> ') ? p.split(' -> ')[1] : p)) // rename은 도착 경로
1069
- .filter(Boolean);
423
+ // 완료 기준 — `[AUTO]`·`[USER]`가 붙은 줄을 차례로 1번부터 센다
424
+ function parseCriteria(text) {
425
+ return meaningfulLines(section(text, '완료 기준')).filter((l) =>
426
+ /^(?:\d+[.)]|[-*])\s*\[(AUTO|USER)\]/.test(l),
427
+ );
1070
428
  }
1071
429
 
1072
- // 빌드 산출물 판정 — 소스 변경에 도구가 따라 바꾸는 파생 파일 (Phase 소속을 묻는 것 자체가 무의미).
1073
- // Dev Plan에 명시 안 돼도 미매핑 blocked 대신 마지막 Phase에 자동 편입 (stash·recordion FRICTION: pbxproj 미매핑만 6회).
1074
- const BUILD_ARTIFACT_RE = [
1075
- /\.pbxproj$/,
1076
- /(^|\/)package-lock\.json$/,
1077
- /(^|\/)yarn\.lock$/,
1078
- /(^|\/)pnpm-lock\.yaml$/,
1079
- /(^|\/)Cargo\.lock$/,
1080
- /(^|\/)Gemfile\.lock$/,
1081
- /(^|\/)Podfile\.lock$/,
1082
- ];
1083
-
1084
- function splitUncommittedByPhase(wtPath, taskMdPath, meta) {
1085
- // 코드 변경분(.project/ 외)을 Dev Plan Phase에 매핑해 Phase별 자동 커밋. meta: { taskNum, type }.
1086
- // 매핑 폴백 3규칙 (stash 6회 + recordion 5회 blocked 마찰 반영 — 11회 전부 기계적 편입이었음):
1087
- // 1. 다중 Phase 매핑 → 가장 이른 Phase에 편입 + 커밋 메시지에 '(Phase a·b 변경 포함)' 표기.
1088
- // 뒤 Phase가 앞 Phase 타입·함수를 참조하므로 이른 쪽이 의존 방향과 일치. 중간 커밋 독립 빌드는 비보장(GIT_RULE 명문).
1089
- // 2. 미매핑 + 빌드 산출물(BUILD_ARTIFACT_RE) → 매핑된 그룹 중 마지막 Phase(없으면 Dev Plan 마지막)에
1090
- // 자동 편입 + '(빌드 산출물 자동 편입)' 표기.
1091
- // 3. 미매핑 + 일반 파일 → { ambiguous } 반환(커밋 X) — 계획 누락 가능성이라 사람 판단으로 유지.
1092
- // .project/ 메타(flows/문서/changelog)는 제외 — closeTask가 별도 커밋.
1093
- const codeFiles = _porcelainFiles(wtPath).filter((f) => !f.startsWith('.project/'));
1094
- if (codeFiles.length === 0) return { committed: [], empty: true };
1095
- const phases = _parseDevPlanPhases(fs.readFileSync(taskMdPath, 'utf8'));
1096
- if (phases.length === 0) {
1097
- return { ambiguous: true, reason: 'Dev Plan Phase 파싱 불가', files: codeFiles };
1098
- }
1099
- const matchOf = (f) =>
1100
- phases
1101
- .map((p, i) => ({ p, i }))
1102
- .filter(({ p }) =>
1103
- p.files.some((pf) => {
1104
- const base = pf.replace(/\/$/, '');
1105
- return f === pf || f === base || f.startsWith(base + '/') || base.endsWith('/' + f);
1106
- }),
1107
- )
1108
- .map(({ i }) => i);
1109
- const fileToPhase = {};
1110
- const multiPhase = []; // 규칙 1 적용분 — { file, phases:[num...], assignedTo }
1111
- const artifactFiles = []; // 규칙 2 후보 — 매핑 확정 후 편입 Phase 결정
1112
- for (const f of codeFiles) {
1113
- const m = matchOf(f);
1114
- if (m.length === 1) {
1115
- fileToPhase[f] = m[0];
1116
- } else if (m.length > 1) {
1117
- const earliest = Math.min(...m);
1118
- fileToPhase[f] = earliest;
1119
- multiPhase.push({ file: f, phases: m.map((i) => phases[i].num), assignedTo: phases[earliest].num });
1120
- } else if (BUILD_ARTIFACT_RE.some((re) => re.test(f))) {
1121
- artifactFiles.push(f);
1122
- } else {
1123
- return {
1124
- ambiguous: true,
1125
- reason: `Phase 미매핑: ${f}`,
1126
- files: codeFiles,
1127
- phases: phases.map((p) => ({ num: p.num, name: p.name, files: p.files })),
1128
- };
1129
- }
1130
- }
1131
- // 규칙 2 — 산출물 편입: 매핑된 그룹 중 마지막 Phase, 하나도 없으면 Dev Plan 마지막 Phase
1132
- const autoAssigned = [];
1133
- if (artifactFiles.length > 0) {
1134
- const mappedIdx = Object.values(fileToPhase);
1135
- const lastIdx = mappedIdx.length > 0 ? Math.max(...mappedIdx) : phases.length - 1;
1136
- for (const f of artifactFiles) {
1137
- fileToPhase[f] = lastIdx;
1138
- autoAssigned.push({ file: f, assignedTo: phases[lastIdx].num });
1139
- }
1140
- }
1141
- // 파일별 커밋 메시지 표기 (자동 편입의 근거를 커밋에 남김 — 코드가 몰래 정한 걸 드러낸다)
1142
- const noteOf = {};
1143
- for (const x of multiPhase) noteOf[x.file] = ` (Phase ${x.phases.join('·')} 변경 포함)`;
1144
- for (const x of autoAssigned) noteOf[x.file] = ' (빌드 산출물 자동 편입)';
1145
- // Phase별 그룹 → 순서대로 커밋
1146
- const groups = new Map();
1147
- for (const f of Object.keys(fileToPhase)) {
1148
- const i = fileToPhase[f];
1149
- if (!groups.has(i)) groups.set(i, []);
1150
- groups.get(i).push(f);
1151
- }
1152
- const tag = TAG_OF[meta.type] || 'docs';
1153
- const nnn = String(meta.taskNum).padStart(3, '0');
1154
- const committed = [];
1155
- for (const i of [...groups.keys()].sort((a, b) => a - b)) {
1156
- const p = phases[i];
1157
- const grp = groups.get(i);
1158
- execFileSync('git', ['-C', wtPath, 'add', '--', ...grp], { stdio: 'pipe' });
1159
- const msg =
1160
- `${tag}: [TASK-${nnn}] Phase ${p.num} - ${p.name}\n\n` +
1161
- grp.map((f) => `- ${f}${noteOf[f] || ''}`).join('\n') +
1162
- `\n- 사유: ${p.name}`;
1163
- execFileSync('git', ['-C', wtPath, 'commit', '-m', msg], { stdio: 'pipe' });
1164
- committed.push({ phase: p.num, name: p.name, files: grp });
1165
- }
1166
- const result = { committed };
1167
- if (multiPhase.length > 0) result.multiPhase = multiPhase;
1168
- if (autoAssigned.length > 0) result.autoAssigned = autoAssigned;
1169
- return result;
430
+ function goalLine(text) {
431
+ return meaningfulLines(section(text, '목표'))[0] || '';
1170
432
  }
1171
433
 
1172
- // ─── close 문서 자동화 (recordion FRICTION 2026-08-09 — 문서 갱신 기계화) ─────────
1173
- // CHANGELOG는 close가 직접 append(세션 준수 불요), PLAN 체크·수정이력은 grep 게이트로 차단.
434
+ function plannedFiles(text) {
435
+ const files = parseFileList(section(text, '만질 파일'));
436
+ for (const p of parsePhases(text)) for (const f of p.files) if (!files.includes(f)) files.push(f);
437
+ return files;
438
+ }
1174
439
 
1175
- function _parseTaskTitle(taskMdPath) {
1176
- // task 문서 H1 → 제목 (`# TASK-NNN — 제목`의 제목부. 접두 없으면 전체).
1177
- const m = fs.readFileSync(taskMdPath, 'utf8').match(/^#\s+(.+)$/m);
1178
- if (!m) return null;
1179
- return m[1].replace(/^TASK-\d+\s*[—-]\s*/, '').trim() || null;
440
+ // 경로가 계획 목록(파일 또는 폴더)에 들어 있나
441
+ function fileMatches(file, planned) {
442
+ const base = planned.replace(/\/$/, '');
443
+ return file === base || file.startsWith(base + '/');
1180
444
  }
1181
445
 
1182
- function appendChangelogEntry(base, { taskNum, title, type, phases }) {
1183
- // CHANGELOG 항목을 close가 직접 append — CHANGELOG_RULE §1 두 형식(단일 CHANGELOG.md / 월별) 지원,
1184
- // 없으면 월별 파일 생성. 최신 맨 위 삽입(§3). 재호출 가드: [TASK-NNN] 실재 시 skip
1185
- // (mapping·docs 콜백 후 재호출 중복 차단). base = task 문서와 동일 분기(등록=메인WT / 미등록=워크트리).
1186
- const nnn = String(taskNum).padStart(3, '0');
1187
- const dir = path.join(base, '.project', 'changelog');
1188
- const single = path.join(dir, 'CHANGELOG.md');
1189
- const file = fs.existsSync(single) ? single : path.join(dir, `${_todayLocal().slice(0, 7)}.md`);
1190
- const exists = fs.existsSync(file);
1191
- const cur = exists ? fs.readFileSync(file, 'utf8') : '';
1192
- if (cur.includes(`[TASK-${nnn}]`)) return { file, skipped: true };
1193
- const named = (phases || []).filter((p) => p.num > 0 && p.name);
1194
- const summary = named.length
1195
- ? named.map((p) => ` - Phase ${p.num} — ${p.name}`).join('\n')
1196
- : ` - ${title || `TASK-${nnn}`}`;
1197
- const entry = [
1198
- `## [TASK-${nnn}] ${title || ''}`.trimEnd(),
1199
- '',
1200
- `- **날짜**: ${_todayLocal()}`,
1201
- `- **타입**: ${type}`,
1202
- `- **변경 요약**:`,
1203
- summary,
1204
- ].join('\n');
1205
- let next;
1206
- if (!exists) {
1207
- fs.mkdirSync(dir, { recursive: true });
1208
- next = entry + '\n';
1209
- } else {
1210
- // 첫 `## ` 항목 바로 위 삽입 (코드펜스 내부 제외). 항목이 없으면 헤더 뒤(끝) append.
1211
- const lines = cur.split('\n');
1212
- let fence = false;
1213
- let idx = -1;
1214
- for (let i = 0; i < lines.length; i++) {
1215
- if (/^\s*(```|~~~)/.test(lines[i])) fence = !fence;
1216
- else if (!fence && /^##\s+/.test(lines[i])) {
1217
- idx = i;
1218
- break;
1219
- }
1220
- }
1221
- if (idx === -1) {
1222
- next = cur + (cur.endsWith('\n') ? '' : '\n') + '\n' + entry + '\n';
1223
- } else {
1224
- lines.splice(idx, 0, entry, '');
1225
- next = lines.join('\n');
1226
- }
1227
- }
1228
- fs.writeFileSync(file, next);
1229
- return { file, added: true };
446
+ function quoteMeta(v) {
447
+ return /\s/.test(v) ? `"${v}"` : v;
1230
448
  }
1231
449
 
1232
- function checkDocsGate(base, { taskNum, src, plan }) {
1233
- // 문서 게이트 — close의 모든 변이(mutation) 앞에서 실행. blocked여도 아무것도 안 바뀌어
1234
- // 세션이 문서를 채운 후 그대로 재호출하면 된다. 반환 { ok:true } | { ok:false, missing:[...] }.
1235
- // PLAN.md: 로드맵 출처(ST-/RM-)만 — 체크리스트에 TASK-NNN 표기 검사. BL·DR 출처는 항목
1236
- // 자체가 없으므로 통과. 파일 부재(구형 plan)도 통과.
1237
- // 수정이력: spec-diffs(항상 plan 폴더 공통 — 폴더 승격 무관, TASK_DOC_RULE §1.5)의 본 태스크
1238
- // 파일이 가리키는 대상 문서(DEL 제외)에 TASK-NNN 표기 검사. 대상 부재 = 통과
1239
- // (게이트 실패 방향은 '막는 쪽'이 아니라 '놓치는 쪽' — 정당한 close를 오차단하지 않는다).
1240
- const nnn = String(taskNum).padStart(3, '0');
1241
- const tag = `TASK-${nnn}`;
1242
- const missing = [];
1243
- if (/^(ST|RM)-/i.test(src || '')) {
1244
- const planPath = path.join(base, '.project', 'plans', plan, 'PLAN.md');
1245
- if (fs.existsSync(planPath) && !fs.readFileSync(planPath, 'utf8').includes(tag)) {
1246
- missing.push({ kind: 'plan', file: planPath });
1247
- }
1248
- }
1249
- const specDir = path.join(base, '.project', 'tasks', plan, 'spec-diffs');
1250
- if (fs.existsSync(specDir)) {
1251
- const seen = new Set();
1252
- const mine = fs
1253
- .readdirSync(specDir)
1254
- .filter((f) => f.startsWith(`${nnn}_`) && f.endsWith('.md'));
1255
- for (const f of mine) {
1256
- const content = fs.readFileSync(path.join(specDir, f), 'utf8');
1257
- // 헤딩 `## <.project/문서.md> [NEW|MOD|DEL]` — 꺾쇠 유무 관용 파싱
1258
- for (const m of content.matchAll(/^##\s+<?([^\n>[\]]+?)>?\s*\[(NEW|MOD|DEL)\]/gm)) {
1259
- if (m[2] === 'DEL') continue;
1260
- const rel = m[1].trim();
1261
- const target = fs.existsSync(path.join(base, rel))
1262
- ? path.join(base, rel)
1263
- : path.join(base, '.project', rel);
1264
- if (seen.has(target)) continue;
1265
- seen.add(target);
1266
- if (!fs.existsSync(target)) continue; // 대상 부재 → 통과
1267
- if (!fs.readFileSync(target, 'utf8').includes(tag)) {
1268
- missing.push({ kind: 'history', file: target });
1269
- }
1270
- }
1271
- }
1272
- }
1273
- return missing.length ? { ok: false, missing } : { ok: true };
450
+ function renderMeta(st) {
451
+ const parts = [
452
+ `plan=${st.plan}`,
453
+ `type=${st.type}`,
454
+ `size=${st.size}`,
455
+ `switch=${st.switch.join(',')}`,
456
+ `range=${quoteMeta(st.range)}`,
457
+ `parent=${st.parent}`,
458
+ `by=${st.by}`,
459
+ ];
460
+ if (st.tab) parts.push(`tab=${st.tab}`);
461
+ return `<!-- taskery: ${parts.join(' ')} -->`;
1274
462
  }
1275
463
 
1276
- async function closeTask(mainWtPath, { taskNum }, opts = {}) {
1277
- // 결정적 준비만 — 비가역(머지·정리)·충돌 해결은 스킬이 후속 수행.
1278
- // 순서: status 게이트 → docs 게이트 → Phase 커밋 → setStatus(closed) → CHANGELOG append
1279
- // → (미등록) .project/ 커밋 → 추적 마커. 게이트 2종은 모든 변이 앞.
1280
- // 반환: { blocked } (status/docs 게이트 미충족·매핑 모호) 또는
1281
- // { prepped, branch, parent, wtPath, registered, commits, aheadOfParent, changelog,
1282
- // multiPhase?, autoAssigned? (매핑 폴백 적용 시 내역) }. parent = 스킬 머지 대상.
1283
- const active = getActiveTasks(mainWtPath).find((t) => t.taskNum === taskNum);
1284
- if (!active) {
1285
- throw new Error(`TASK-${String(taskNum).padStart(3, '0')} 진행중 브랜치 없음 (SSoT 조회).`);
1286
- }
1287
- const projectId = getProjectId(mainWtPath);
1288
- const wtPath = getWorktreePath(projectId, { taskNum, src: active.src, slug: active.slug });
1289
- const resolved = resolveTaskDocPath(mainWtPath, wtPath, { taskNum });
1290
- if (!resolved) throw new Error(`TASK-${taskNum} 문서 못 찾음.`);
1291
-
1292
- const header = parseTaskHeader(resolved.file);
1293
- // 부모 = 헤더 기록값(0.6.0, 구 문서는 'dev' 폴백). 메인 워크트리가 그 부모에 서 있어야
1294
- // 스킬 후속 머지가 올바른 브랜치로 간다.
1295
- const parent = header.parent;
1296
- assertMainWorktreeOn(mainWtPath, parent);
1297
- if (header.status !== 'tested') {
1298
- return { blocked: 'status', current: header.status, docPath: resolved.file };
1299
- }
464
+ // ─── 시간 · 단계 표 ─────────────────────────────────
1300
465
 
1301
- // 문서 게이트 — 모든 변이 앞. blocked 시 아무 것도 안 바뀌어 채우고 그대로 재호출.
1302
- const docBase = resolved.registered ? mainWtPath : wtPath;
1303
- const gate = checkDocsGate(docBase, { taskNum, src: active.src, plan: resolved.plan });
1304
- if (!gate.ok) {
1305
- return { blocked: 'docs', missing: gate.missing, docPath: resolved.file };
1306
- }
466
+ function ms(iso) {
467
+ return iso ? new Date(iso).getTime() : null;
468
+ }
1307
469
 
1308
- // Phase 커밋 (D2) — 코드 변경분만
1309
- const split = splitUncommittedByPhase(wtPath, resolved.file, { taskNum, type: active.type });
1310
- if (split.ambiguous) {
1311
- return {
1312
- blocked: 'mapping',
1313
- reason: split.reason,
1314
- files: split.files,
1315
- phases: split.phases,
1316
- wtPath,
1317
- docPath: resolved.file,
1318
- };
470
+ function minutes(msVal) {
471
+ if (msVal == null) return '';
472
+ const m = Math.round(msVal / 60000);
473
+ return m < 1 ? '1분 미만' : `${m}분`;
474
+ }
475
+
476
+ // 단계별 소요 시간(ms). 각 단계 = 그 단계 완료 시각 − 앞 단계들의 마지막 완료 시각
477
+ function stageDurations(st) {
478
+ const t0 = ms(st.times.prepare);
479
+ const tPlan = st.approve ? ms(st.approve.at) : null;
480
+ const tDev = sw(st, 'dev') && st.testCode ? ms(st.testCode.at) : null;
481
+ const tTest = sw(st, 'test') && st.testDone ? ms(st.testDone.at) : null;
482
+ const tClose = st.closed ? ms(st.closed.at) : null;
483
+ const d = {};
484
+ if (tPlan != null) d.plan = Math.max(0, tPlan - t0);
485
+ if (tDev != null) d.dev = Math.max(0, tDev - Math.max(t0, tPlan || t0));
486
+ if (tTest != null) d.test = Math.max(0, tTest - Math.max(t0, tPlan || t0, tDev || t0));
487
+ if (tClose != null) {
488
+ let close = tClose - Math.max(t0, tPlan || t0, tDev || t0, tTest || t0);
489
+ // 병합 확인 대기(commit-task 끝 ~ merge-task 시작)는 뺀다(§4-3)
490
+ if (st.commit && st.mergeStart) close -= Math.max(0, ms(st.mergeStart) - ms(st.commit.at));
491
+ d.close = Math.max(0, close);
492
+ d.total = (d.plan || 0) + (d.dev || 0) + (d.test || 0) + d.close;
1319
493
  }
494
+ return d;
495
+ }
1320
496
 
1321
- // status=closed (문서 위치 무관 — setStatus가 .gitignore 케이스 따라 처리)
1322
- await setStatus(resolved.file, 'closed');
497
+ function stageCells(st) {
498
+ const d = stageDurations(st);
499
+ const planDone = !!st.approve;
500
+ const devDone = !sw(st, 'dev') || !!st.testCode;
501
+ const testDone = !sw(st, 'test') || !!st.testDone;
502
+ const cells = [];
503
+ cells.push(planDone ? `✅ ${minutes(d.plan)}` : '⏳');
504
+ if (!sw(st, 'dev')) cells.push('꺼짐');
505
+ else cells.push(st.testCode ? `✅ ${minutes(d.dev)}` : planDone ? '⏳' : '–');
506
+ if (!sw(st, 'test')) cells.push('꺼짐');
507
+ else if (st.testDone) cells.push(`✅ ${minutes(d.test)}${st.testDone.accepted ? ' (FAIL 수락)' : ''}`);
508
+ else cells.push(planDone && devDone ? '⏳' : '–');
509
+ if (st.closed) cells.push(`✅ ${minutes(d.close)} (합계 ${minutes(d.total)})`);
510
+ else cells.push(planDone && devDone && testDone ? '⏳' : '–');
511
+ return cells;
512
+ }
1323
513
 
1324
- // CHANGELOG 자동 append — 등록=메인WT 직접 쓰기(.project는 gitignore라 커밋 불요) /
1325
- // 미등록=워크트리 쓰기(아래 .project/ 문서 커밋이 함께 수거).
1326
- const changelog = appendChangelogEntry(docBase, {
1327
- taskNum,
1328
- title: _parseTaskTitle(resolved.file),
1329
- type: header.type,
1330
- phases: _parseDevPlanPhases(fs.readFileSync(resolved.file, 'utf8')),
1331
- });
514
+ const TABLE_HEAD = '| 시작·기획 | 개발 | 테스트 | 닫기 |';
1332
515
 
1333
- const commits = [...split.committed];
1334
- // 미등록 케이스: .project/ 변경분(flows / 문서 / changelog)을 커밋
1335
- if (!resolved.registered) {
1336
- const flows = _porcelainFiles(wtPath).filter((f) => f.startsWith('.project/flows/'));
1337
- if (flows.length) {
1338
- execFileSync('git', ['-C', wtPath, 'add', '--', ...flows], { stdio: 'pipe' });
1339
- execFileSync(
1340
- 'git',
1341
- ['-C', wtPath, 'commit', '-m', `docs: [TASK-${String(taskNum).padStart(3, '0')}] flows 갱신`],
1342
- { stdio: 'pipe' },
1343
- );
1344
- commits.push({ phase: 'flows', files: flows });
1345
- }
1346
- const rest = _porcelainFiles(wtPath).filter((f) => f.startsWith('.project/'));
1347
- if (rest.length) {
1348
- execFileSync('git', ['-C', wtPath, 'add', '--', ...rest], { stdio: 'pipe' });
1349
- execFileSync(
1350
- 'git',
1351
- [
1352
- '-C',
1353
- wtPath,
1354
- 'commit',
1355
- '-m',
1356
- `docs: [TASK-${String(taskNum).padStart(3, '0')}] 태스크 문서 완료`,
1357
- ],
1358
- { stdio: 'pipe' },
1359
- );
1360
- commits.push({ phase: 'doc', files: rest });
516
+ function renderTableRow(st) {
517
+ return `| ${stageCells(st).join(' | ')} |`;
518
+ }
519
+
520
+ // 문서의 메타 줄과 단계 표 줄을 .state 기록으로 다시 쓴다(메타·표는 명령만 쓴다, §4-2)
521
+ function syncDoc(main, st) {
522
+ const file = docAbs(main, st);
523
+ if (!fs.existsSync(file)) return;
524
+ const lines = fs.readFileSync(file, 'utf8').split('\n');
525
+ const mi = lines.findIndex((l) => l.startsWith('<!-- taskery:'));
526
+ if (mi !== -1) lines[mi] = renderMeta(st);
527
+ const hi = lines.findIndex((l) => l.trim() === TABLE_HEAD);
528
+ if (hi !== -1 && lines[hi + 2] !== undefined && lines[hi + 2].trim().startsWith('|')) {
529
+ lines[hi + 2] = renderTableRow(st);
530
+ }
531
+ fs.writeFileSync(file, lines.join('\n'));
532
+ }
533
+
534
+ function renderDoc(st) {
535
+ const out = [
536
+ `# ${taskLabel(st.num)} ${st.title}`,
537
+ renderMeta(st),
538
+ '',
539
+ TABLE_HEAD,
540
+ '|---|---|---|---|',
541
+ renderTableRow(st),
542
+ '',
543
+ '## 목표',
544
+ '',
545
+ ];
546
+ if (st.size !== 'small') out.push('## 요구사항', '');
547
+ out.push('## 완료 기준', '', '## 만질 파일', '');
548
+ if (st.size !== 'small') out.push('## Phase', '');
549
+ out.push('## 결정', '', '## 결과', '');
550
+ return out.join('\n');
551
+ }
552
+
553
+ // `## 결과` 절 끝에 한 줄을 붙인다
554
+ function appendResult(main, st, line) {
555
+ const file = docAbs(main, st);
556
+ const lines = fs.readFileSync(file, 'utf8').split('\n');
557
+ let start = lines.findIndex((l) => l.trim() === '## 결과');
558
+ if (start === -1) {
559
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
560
+ lines.push('', '## 결과', '');
561
+ start = lines.length - 2;
562
+ }
563
+ let end = lines.length;
564
+ for (let i = start + 1; i < lines.length; i++) {
565
+ if (/^## /.test(lines[i])) {
566
+ end = i;
567
+ break;
1361
568
  }
1362
569
  }
1363
-
1364
- // 추적 마커 빈커밋 (부모보다 앞선 커밋 0개 — docs/분석 task + .project gitignore 케이스)
1365
- const ahead = parseInt(
1366
- gitCapture(wtPath, ['rev-list', '--count', `${parent}..HEAD`], { allowFail: true }) || '0',
1367
- 10,
1368
- );
1369
- if (ahead === 0) {
1370
- execFileSync(
1371
- 'git',
1372
- [
1373
- '-C',
1374
- wtPath,
1375
- 'commit',
1376
- '--allow-empty',
1377
- '-m',
1378
- `docs: [TASK-${String(taskNum).padStart(3, '0')}] 추적 마커 — 코드 0·.project gitignore (채번 보존)`,
1379
- ],
1380
- { stdio: 'pipe' },
1381
- );
1382
- commits.push({ phase: 'marker', empty: true });
570
+ let at = end;
571
+ while (at > start + 1 && lines[at - 1].trim() === '') at--;
572
+ if (at === start + 1) {
573
+ lines.splice(at, 0, '', line);
574
+ } else {
575
+ lines.splice(at, 0, line);
1383
576
  }
577
+ fs.writeFileSync(file, lines.join('\n'));
578
+ }
1384
579
 
1385
- const aheadFinal = parseInt(
1386
- gitCapture(wtPath, ['rev-list', '--count', `${parent}..HEAD`], { allowFail: true }) || '0',
1387
- 10,
1388
- );
1389
- const prepResult = {
1390
- prepped: true,
1391
- taskNum,
1392
- branch: active.branch,
1393
- parent,
1394
- wtPath,
1395
- registered: resolved.registered,
1396
- docPath: resolved.file,
1397
- commits,
1398
- aheadOfParent: aheadFinal,
1399
- changelog,
1400
- };
1401
- // 매핑 폴백 적용 내역 노출 — 코드가 자동 편입한 걸 스킬·사용자가 볼 수 있게
1402
- if (split.multiPhase) prepResult.multiPhase = split.multiPhase;
1403
- if (split.autoAssigned) prepResult.autoAssigned = split.autoAssigned;
1404
- return prepResult;
580
+ function nowIso() {
581
+ return new Date().toISOString();
582
+ }
583
+
584
+ function clock(iso) {
585
+ const d = new Date(iso);
586
+ const p = (n) => String(n).padStart(2, '0');
587
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
588
+ }
589
+
590
+ function todayLocal() {
591
+ return clock(nowIso()).slice(0, 10);
592
+ }
593
+
594
+ // 범위 메모 갱신 — 태스크 명령에 --range를 붙이면 메타의 범위만 바꾼다(§3-5)
595
+ function updateRange(main, st, range) {
596
+ if (!range || range === st.range) return st;
597
+ st.range = range;
598
+ writeState(main, st);
599
+ syncDoc(main, st);
600
+ return st;
1405
601
  }
1406
602
 
1407
603
  module.exports = {
1408
- LOCAL_SUFFIX,
1409
604
  MANIFEST_NAME,
1410
- // 멀티 플랫폼 (0.2.0+)
1411
- PLATFORMS,
1412
- DEFAULT_PLATFORMS,
1413
- SHARED_DEST,
1414
- platformOf,
1415
- mapSharedDest,
1416
- resolveInstallPlan,
1417
- GITIGNORE_PATTERNS,
1418
- gitignorePatternsFor,
1419
- findTemplateDir,
1420
- sha256,
1421
- walkTemplate,
1422
- mkdirp,
1423
- copyFile,
1424
- writeManifest,
605
+ EXCLUDE_NAMES,
606
+ PLANT_COPY,
607
+ PLANT_LINK,
608
+ TYPES,
609
+ SIZES,
610
+ SWITCHES,
611
+ DEFAULT_RANGE,
612
+ TAG_OF,
613
+ CODE_TEST_NOTICE_MS,
614
+ TaskeryError,
615
+ fail,
616
+ git,
617
+ gitOk,
618
+ findMain,
619
+ currentBranch,
620
+ changedFiles,
621
+ trackedChanges,
622
+ isAncestor,
623
+ branchExists,
624
+ fingerprint,
625
+ ensureExclude,
626
+ inOrca,
627
+ runShell,
628
+ tail,
629
+ OLD_MANIFEST_NAME,
630
+ checkLegacyInstall,
1425
631
  readManifest,
1426
- isLocalOverride,
632
+ writeManifest,
633
+ requireInstalled,
1427
634
  getPackageVersion,
1428
- // 멀티세션 (0.1.2+)
1429
- TASKERY_HOME,
1430
- DEFAULT_STALE_DAYS,
1431
- DEFAULT_LOCK_TIMEOUT_MS,
1432
- BRANCH_PATTERNS,
1433
- generateProjectId,
1434
- gitCapture,
1435
- getMainWorktreePath,
1436
- getProjectId,
1437
- getWorktreesRoot,
1438
- getWorktreePath,
1439
- getMergeLockPath,
1440
- getInitLockPath,
1441
- withMergeLock,
1442
- withMetaLock,
1443
- withMetaLockSync,
1444
- currentBranch,
1445
- assertMainWorktreeOn,
1446
- parseBranchName,
1447
- getActiveTasks,
1448
- getNextTaskNumber,
1449
- forkTask,
1450
- computeNextPlanNumber,
1451
- // 백로그 (0.1.2+)
1452
- BACKLOG_PLACEHOLDER,
1453
- getActiveVersion,
1454
- setActivePlan,
1455
- getBacklogPath,
1456
- computeNextBLNumber,
1457
- formatBacklogBlock,
1458
- appendBacklogItem,
1459
- parseBacklogItem,
1460
- markBacklogChecked,
1461
- // task 헤더 / 상태 전이 (코드화)
1462
- TRANSITIONS,
1463
- parseTaskHeader,
1464
- isProjectRegistered,
1465
- resolveTaskDocPath,
1466
- setStatus,
1467
- // task 문서 골격 (C1)
1468
- DOC_TYPE_OF,
1469
- scaffoldTaskDoc,
1470
- // plan 생성 (C3)
1471
- initPlan,
1472
- // task close 결정적 준비 (D1·D2)
1473
- TAG_OF,
1474
- splitUncommittedByPhase,
1475
- appendChangelogEntry,
1476
- checkDocsGate,
1477
- closeTask,
635
+ stateDir,
636
+ pad,
637
+ taskLabel,
638
+ parseTaskNum,
639
+ writeJsonAtomic,
640
+ readState,
641
+ writeState,
642
+ listStates,
643
+ withLock,
644
+ sw,
645
+ needsNoMerge,
646
+ hasFinishRecord,
647
+ workDir,
648
+ parseGitRuleTable,
649
+ readGitRule,
650
+ fillTemplate,
651
+ docAbs,
652
+ docShown,
653
+ readDoc,
654
+ section,
655
+ meaningfulLines,
656
+ parseFileList,
657
+ parsePhases,
658
+ parseCriteria,
659
+ goalLine,
660
+ plannedFiles,
661
+ fileMatches,
662
+ renderMeta,
663
+ stageDurations,
664
+ stageCells,
665
+ minutes,
666
+ syncDoc,
667
+ renderDoc,
668
+ appendResult,
669
+ nowIso,
670
+ clock,
671
+ todayLocal,
672
+ updateRange,
1478
673
  };