@angar2/taskery 0.8.1 → 1.0.0

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