@angar2/taskery 0.8.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +423 -188
- package/bin/add.js +27 -99
- package/bin/backlog.js +240 -113
- package/bin/check.js +166 -0
- package/bin/commands.js +151 -0
- package/bin/finish.js +353 -0
- package/bin/init.js +99 -190
- package/bin/install.js +143 -0
- package/bin/lib.js +515 -1320
- package/bin/mcp.js +31 -263
- package/bin/orche.js +240 -0
- package/bin/plan.js +58 -120
- package/bin/prepare.js +326 -0
- package/bin/prune.js +46 -176
- package/bin/status.js +57 -151
- package/bin/taskery.js +76 -120
- package/bin/update.js +66 -266
- package/package.json +5 -5
- package/template/.taskery/BACKLOG.md +38 -0
- package/template/.taskery/rules/CHANGELOG_RULE.md +31 -0
- package/template/.taskery/rules/DEV_RULE.local.md +8 -0
- package/template/.taskery/rules/GIT_RULE.md +82 -0
- package/template/.taskery/rules/MOCKUP_RULE.md +19 -0
- package/template/.taskery/rules/TASKERY_RULE.md +112 -0
- package/template/.taskery/rules/TASK_DOC_RULE.md +84 -0
- package/template/.taskery/rules/TEST_RULE.local.md +16 -0
- package/template/AGENTS.md +15 -53
- package/template/shared/skills/add-backlog/SKILL.md +8 -38
- package/template/shared/skills/log-friction/SKILL.md +10 -88
- package/template/shared/skills/plan-init/SKILL.md +10 -98
- package/template/shared/skills/project-init/SKILL.md +13 -342
- package/template/shared/skills/task-close/SKILL.md +16 -319
- package/template/shared/skills/task-dev/SKILL.md +19 -233
- package/template/shared/skills/task-init/SKILL.md +11 -68
- package/template/shared/skills/task-orche/SKILL.md +35 -0
- package/template/shared/skills/task-plan/SKILL.md +19 -498
- package/template/shared/skills/task-test/SKILL.md +23 -342
- package/bin/close.js +0 -86
- package/bin/fork.js +0 -112
- package/bin/migrate.js +0 -344
- package/bin/set-status.js +0 -98
- package/template/.claude/hooks/closed-immutable.sh +0 -77
- package/template/.claude/settings.json +0 -18
- package/template/.claude/skills/run-team/SKILL.md +0 -145
- package/template/.codex/agents/task-tester.toml +0 -41
- package/template/.codex/agents/test-plan-author.toml +0 -34
- package/template/.codex/config.toml +0 -17
- package/template/.codex/hooks/closed-immutable.sh +0 -66
- package/template/.project/changelog/.gitkeep +0 -0
- package/template/.project/flows/.gitkeep +0 -0
- package/template/.project/plans/.gitkeep +0 -0
- package/template/.project/rules/CHANGELOG_RULE.md +0 -97
- package/template/.project/rules/GIT_RULE.md +0 -325
- package/template/.project/rules/MOCKUP_RULE.md +0 -86
- package/template/.project/rules/TASKERY_RULE.md +0 -237
- package/template/.project/rules/TASK_DOC_RULE.md +0 -668
- package/template/.project/shared/received/completed/.gitkeep +0 -0
- package/template/.project/shared/sent/completed/.gitkeep +0 -0
- package/template/.project/tasks/.gitkeep +0 -0
- package/template/shared/hooks/git-guard.sh +0 -108
package/bin/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
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
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
|
|
163
|
-
|
|
45
|
+
function gitOk(cwd, args) {
|
|
46
|
+
return git(cwd, args, { allowFail: true }) !== null;
|
|
164
47
|
}
|
|
165
48
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
})
|
|
224
|
-
}
|
|
225
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
250
|
-
return path.join(TASKERY_HOME, 'worktrees', projectId);
|
|
251
|
-
}
|
|
126
|
+
// ─── 셸 명령 실행 (코드 테스트) ───────────────────────
|
|
252
127
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
return path.join(TASKERY_HOME, `${projectId}.init.lock`);
|
|
265
|
-
}
|
|
148
|
+
// ─── 매니페스트 · 설치 확인 ───────────────────────────
|
|
266
149
|
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
const
|
|
293
|
-
if (!fs.existsSync(
|
|
294
|
-
|
|
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
|
-
|
|
310
|
-
}
|
|
311
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
337
|
-
|
|
338
|
-
|
|
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
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
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
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
367
|
-
|
|
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
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
|
439
|
-
|
|
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
|
-
|
|
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
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
|
500
|
-
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
510
|
-
|
|
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
|
|
515
|
-
const
|
|
516
|
-
if (
|
|
517
|
-
return
|
|
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
|
|
521
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
649
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
// ───
|
|
800
|
-
// fork 직후 §1.3 빈 골격을 코드가 자동 생성 — 생성일=오늘 / status=draft 자동, 파일명 규칙 코드 보장.
|
|
287
|
+
// ─── GIT_RULE.md 맨 위 표 ─────────────────────────────
|
|
801
288
|
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
|
822
|
-
const
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
|
310
|
+
return rule;
|
|
829
311
|
}
|
|
830
312
|
|
|
831
|
-
function
|
|
832
|
-
const
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
|
864
|
-
|
|
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
|
-
// ───
|
|
899
|
-
// 채번 + legacy 게이트 + 폴더 mkdir + ROADMAP/PLAN/BACKLOG 골격 + manifest.activePlan 갱신을 코드로.
|
|
900
|
-
// LLM 몫(FEATURES/UX-UI delta · ROADMAP Stage 내용 · PLAN 링크)은 골격의 placeholder로 남긴다.
|
|
327
|
+
// ─── 태스크 문서 ─────────────────────────────────────
|
|
901
328
|
|
|
902
|
-
function
|
|
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
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
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
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
-
//
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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
|
|
1018
|
-
|
|
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*)
|
|
399
|
+
const fm = l.match(/^(\s*)[-*]\s*파일\s*:\s*(.*)$/);
|
|
1042
400
|
if (fm) {
|
|
1043
401
|
fileIndent = fm[1].length;
|
|
1044
|
-
|
|
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+)
|
|
412
|
+
const sub = l.match(/^(\s+)[-*]\s+(.+)$/);
|
|
1049
413
|
if (sub && sub[1].length > fileIndent) {
|
|
1050
|
-
|
|
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
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
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
|
-
|
|
1073
|
-
|
|
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
|
-
|
|
1173
|
-
|
|
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
|
-
|
|
1176
|
-
|
|
1177
|
-
const
|
|
1178
|
-
|
|
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
|
|
1183
|
-
|
|
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
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
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
|
-
|
|
1322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
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
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
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
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
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
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
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
|
-
|
|
632
|
+
writeManifest,
|
|
633
|
+
requireInstalled,
|
|
1427
634
|
getPackageVersion,
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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
|
};
|