@angar2/taskery 0.8.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +437 -188
- package/bin/add.js +27 -99
- package/bin/backlog.js +240 -113
- package/bin/check.js +182 -0
- package/bin/commands.js +151 -0
- package/bin/finish.js +365 -0
- package/bin/init.js +99 -190
- package/bin/install.js +143 -0
- package/bin/lib.js +591 -1336
- package/bin/mcp.js +31 -263
- package/bin/orche.js +240 -0
- package/bin/plan.js +58 -120
- package/bin/prepare.js +329 -0
- package/bin/prune.js +46 -176
- package/bin/status.js +57 -151
- package/bin/taskery.js +76 -120
- package/bin/update.js +66 -269
- 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 +104 -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/legacy-quotes.js +0 -60
- package/bin/migrate.js +0 -481
- 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,1485 +1,740 @@
|
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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;
|
|
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'];
|
|
13
|
+
|
|
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;
|
|
21
|
+
|
|
22
|
+
// 명령이 멈출 때 쓰는 오류 — 메시지가 곧 "왜 멈췄고 다음에 무엇을 하라"이다
|
|
23
|
+
class TaskeryError extends Error {}
|
|
24
|
+
function fail(message) {
|
|
25
|
+
throw new TaskeryError(message);
|
|
107
26
|
}
|
|
108
27
|
|
|
109
|
-
//
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
128
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
165
|
-
return result;
|
|
72
|
+
return files;
|
|
166
73
|
}
|
|
167
74
|
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
173
|
-
|
|
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
|
|
182
|
-
|
|
85
|
+
function branchExists(dir, branch) {
|
|
86
|
+
return gitOk(dir, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`]);
|
|
183
87
|
}
|
|
184
88
|
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
return pkg.version;
|
|
121
|
+
// Orca 탭 안인가 — Orca가 탭에 넣는 환경 변수로 판단한다
|
|
122
|
+
function inOrca() {
|
|
123
|
+
return !!process.env.ORCA_TERMINAL_HANDLE;
|
|
203
124
|
}
|
|
204
125
|
|
|
205
|
-
//
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
161
|
+
// 깨진 파일이어도 0.x 설치본으로 취급한다
|
|
162
|
+
}
|
|
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
|
+
);
|
|
233
169
|
}
|
|
170
|
+
fail(
|
|
171
|
+
`update: 이 리포는 taskery ${version}로 설치돼 있어 update로 옮길 수 없다(1.0은 0.x와 호환되지 않는다). ${oldFiles}을 리포 밖으로 옮긴 뒤 'npx @angar2/taskery init'으로 새로 설치한다. ${moveOldDocs}`,
|
|
172
|
+
);
|
|
234
173
|
}
|
|
235
174
|
|
|
236
|
-
function
|
|
237
|
-
|
|
238
|
-
|
|
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);
|
|
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'));
|
|
243
179
|
}
|
|
244
180
|
|
|
245
|
-
function
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
`${MANIFEST_NAME}.projectId 누락 — 'npx @angar2/taskery update' 로 마이그레이션 필요.`,
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
|
-
return m.projectId;
|
|
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');
|
|
253
185
|
}
|
|
254
186
|
|
|
255
|
-
function
|
|
256
|
-
|
|
187
|
+
function requireInstalled(main) {
|
|
188
|
+
const m = readManifest(main);
|
|
189
|
+
if (!m) fail(`taskery가 설치돼 있지 않다 (${main}). 리포 루트에서 'npx @angar2/taskery init'을 먼저 부른다.`);
|
|
190
|
+
return m;
|
|
257
191
|
}
|
|
258
192
|
|
|
259
|
-
function
|
|
260
|
-
|
|
261
|
-
const nnn = String(taskNum).padStart(3, '0');
|
|
262
|
-
return path.join(getWorktreesRoot(projectId), `TASK-${nnn}_${src}_${slug}`);
|
|
193
|
+
function getPackageVersion() {
|
|
194
|
+
return JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8')).version;
|
|
263
195
|
}
|
|
264
196
|
|
|
265
|
-
|
|
266
|
-
return path.join(TASKERY_HOME, `${projectId}.merge.lock`);
|
|
267
|
-
}
|
|
197
|
+
// ─── .state · 잠금 ──────────────────────────────────
|
|
268
198
|
|
|
269
|
-
function
|
|
270
|
-
return path.join(
|
|
199
|
+
function stateDir(main) {
|
|
200
|
+
return path.join(main, '.taskery', '.state');
|
|
271
201
|
}
|
|
272
202
|
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
}
|
|
203
|
+
function pad(num) {
|
|
204
|
+
return String(num).padStart(3, '0');
|
|
294
205
|
}
|
|
295
206
|
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
}
|
|
207
|
+
function taskLabel(num) {
|
|
208
|
+
return `TASK-${pad(num)}`;
|
|
319
209
|
}
|
|
320
210
|
|
|
321
|
-
function
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
}
|
|
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);
|
|
340
215
|
}
|
|
341
216
|
|
|
342
|
-
function
|
|
343
|
-
|
|
344
|
-
|
|
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);
|
|
345
222
|
}
|
|
346
223
|
|
|
347
|
-
function
|
|
348
|
-
|
|
349
|
-
if (current !== branch) {
|
|
350
|
-
throw new Error(`메인 워크트리가 ${branch} 아님 (현재: ${current}). 부모 브랜치로 체크아웃 필요.`);
|
|
351
|
-
}
|
|
224
|
+
function statePath(main, num) {
|
|
225
|
+
return path.join(stateDir(main), 'tasks', `${pad(num)}.json`);
|
|
352
226
|
}
|
|
353
227
|
|
|
354
|
-
function
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
};
|
|
370
|
-
}
|
|
371
|
-
|
|
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);
|
|
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'));
|
|
385
232
|
}
|
|
386
233
|
|
|
387
|
-
function
|
|
388
|
-
|
|
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;
|
|
409
|
-
}
|
|
410
|
-
|
|
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
|
-
});
|
|
234
|
+
function writeState(main, st) {
|
|
235
|
+
writeJsonAtomic(statePath(main, st.num), st);
|
|
442
236
|
}
|
|
443
237
|
|
|
444
|
-
function
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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 };
|
|
463
|
-
}
|
|
464
|
-
|
|
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];
|
|
484
|
-
}
|
|
485
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
return
|
|
266
|
+
// 단계 판정에 쓰는 정의
|
|
267
|
+
function sw(st, name) {
|
|
268
|
+
return st.switch.includes(name);
|
|
518
269
|
}
|
|
519
270
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
if (l.trimStart().startsWith('|') && l.includes('생성일') && l.includes('상태')) {
|
|
680
|
-
labelIdx = i;
|
|
681
|
-
break;
|
|
682
|
-
}
|
|
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();
|
|
683
309
|
}
|
|
684
|
-
|
|
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 };
|
|
310
|
+
return rule;
|
|
718
311
|
}
|
|
719
312
|
|
|
720
|
-
function
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
['-C', mainWtPath, 'check-ignore', '-q', path.join(mainWtPath, '.project', 'dummy')],
|
|
727
|
-
{ stdio: 'ignore' },
|
|
728
|
-
);
|
|
729
|
-
return true;
|
|
730
|
-
} catch (e) {
|
|
731
|
-
return false;
|
|
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'로 적는다.`);
|
|
732
319
|
}
|
|
320
|
+
return merged;
|
|
733
321
|
}
|
|
734
322
|
|
|
735
|
-
function
|
|
736
|
-
|
|
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
|
-
}
|
|
772
|
-
}
|
|
773
|
-
return null;
|
|
323
|
+
function fillTemplate(tpl, vars) {
|
|
324
|
+
return tpl.replace(/\{(\w+)\}/g, (all, k) => (k in vars ? vars[k] : all));
|
|
774
325
|
}
|
|
775
326
|
|
|
776
|
-
|
|
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
|
-
});
|
|
803
|
-
}
|
|
327
|
+
// ─── 태스크 문서 ─────────────────────────────────────
|
|
804
328
|
|
|
805
|
-
|
|
806
|
-
|
|
329
|
+
function docAbs(main, st) {
|
|
330
|
+
return path.join(main, st.doc);
|
|
331
|
+
}
|
|
807
332
|
|
|
808
|
-
//
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
const day = String(d.getDate()).padStart(2, '0');
|
|
814
|
-
return `${y}-${m}-${day}`;
|
|
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);
|
|
815
338
|
}
|
|
816
339
|
|
|
817
|
-
|
|
818
|
-
const
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
refactor: 'refactor',
|
|
823
|
-
docs: 'docs',
|
|
824
|
-
chore: 'chore',
|
|
825
|
-
};
|
|
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');
|
|
344
|
+
}
|
|
826
345
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
346
|
+
// 줄마다 코드 블록 안인지 — ``` 또는 ~~~로 시작하는 줄이 블록을 열고 닫는다. 닫히지 않은 블록은 문서 끝에서 닫힌 것으로 본다
|
|
347
|
+
function fenceMask(lines) {
|
|
348
|
+
const mask = [];
|
|
349
|
+
let open = false;
|
|
350
|
+
for (const l of lines) {
|
|
351
|
+
if (/^\s*(```|~~~)/.test(l)) {
|
|
352
|
+
mask.push(true);
|
|
353
|
+
open = !open;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
mask.push(open);
|
|
833
357
|
}
|
|
834
|
-
return
|
|
358
|
+
return mask;
|
|
835
359
|
}
|
|
836
360
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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');
|
|
867
|
-
}
|
|
868
|
-
|
|
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}`);
|
|
361
|
+
// `## 제목` 절 본문(다음 `## `까지). 코드 블록 안의 `## ` 줄은 절 제목으로 보지 않는다
|
|
362
|
+
function section(text, title) {
|
|
363
|
+
const lines = text.split('\n');
|
|
364
|
+
const fenced = fenceMask(lines);
|
|
365
|
+
const start = lines.findIndex((l, i) => !fenced[i] && l.trim() === `## ${title}`);
|
|
366
|
+
if (start === -1) return null;
|
|
367
|
+
const out = [];
|
|
368
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
369
|
+
if (!fenced[i] && /^## /.test(lines[i])) break;
|
|
370
|
+
out.push(lines[i]);
|
|
887
371
|
}
|
|
888
|
-
|
|
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 };
|
|
372
|
+
return out.join('\n');
|
|
902
373
|
}
|
|
903
374
|
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
|
|
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');
|
|
375
|
+
// 1.0.1 문서 = `## 개발 계획` 또는 `## 테스트 계획` 절이 있다(renderDoc이 둘 중 하나는 반드시 만든다).
|
|
376
|
+
// 없으면 1.0.0 문서(목표·완료 기준·만질 파일·Phase 절)로 읽는다
|
|
377
|
+
function isNewDoc(text) {
|
|
378
|
+
return section(text, '개발 계획') !== null || section(text, '테스트 계획') !== null;
|
|
921
379
|
}
|
|
922
380
|
|
|
923
|
-
function
|
|
924
|
-
return
|
|
925
|
-
|
|
926
|
-
''
|
|
927
|
-
|
|
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');
|
|
381
|
+
function meaningfulLines(body) {
|
|
382
|
+
return (body || '')
|
|
383
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
384
|
+
.split('\n')
|
|
385
|
+
.map((l) => l.trim())
|
|
386
|
+
.filter(Boolean);
|
|
938
387
|
}
|
|
939
388
|
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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');
|
|
389
|
+
// 경로 한 줄 → 경로. 백틱 안을 우선, 없으면 ' — ' 앞, 꼬리 괄호 주석은 뗀다
|
|
390
|
+
function cleanPath(raw) {
|
|
391
|
+
let s = raw.trim();
|
|
392
|
+
const tick = s.match(/`([^`]+)`/);
|
|
393
|
+
if (tick) s = tick[1];
|
|
394
|
+
else s = s.split(/\s+—\s+|\s+-\s+/)[0];
|
|
395
|
+
return s.replace(/\s*[((][^))]*[))]\s*$/, '').trim();
|
|
957
396
|
}
|
|
958
397
|
|
|
959
|
-
function
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
});
|
|
398
|
+
function parseFileList(body) {
|
|
399
|
+
return meaningfulLines(body)
|
|
400
|
+
.filter((l) => /^[-*]\s+/.test(l))
|
|
401
|
+
.map((l) => cleanPath(l.replace(/^[-*]\s+/, '')))
|
|
402
|
+
.filter(Boolean);
|
|
973
403
|
}
|
|
974
404
|
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
const
|
|
979
|
-
if (
|
|
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 };
|
|
997
|
-
}
|
|
998
|
-
|
|
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;
|
|
405
|
+
// Phase: `### Phase N — 이름` + `- 파일:`(한 줄 쉼표·가운뎃점 구분 또는 들여쓴 목록) + `- 사유:`
|
|
406
|
+
// 1.0.1 문서는 `## 개발 계획` 안에, 1.0.0 문서는 `## Phase` 절에 있다
|
|
407
|
+
function parsePhases(text) {
|
|
408
|
+
const body = section(text, isNewDoc(text) ? '개발 계획' : 'Phase');
|
|
409
|
+
if (!body) return [];
|
|
1020
410
|
const phases = [];
|
|
1021
411
|
let cur = null;
|
|
1022
|
-
let fileIndent = null;
|
|
1023
|
-
const
|
|
1024
|
-
|
|
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*)?(.*)$/);
|
|
412
|
+
let fileIndent = null;
|
|
413
|
+
for (const l of body.split('\n')) {
|
|
414
|
+
const ph = l.match(/^###\s+Phase\s+(\d+)\s*(?:[—–-]\s*)?(.*)$/);
|
|
1040
415
|
if (ph) {
|
|
1041
|
-
cur = { num: parseInt(ph[1], 10), name: ph[2].trim(), files: [] };
|
|
416
|
+
cur = { num: parseInt(ph[1], 10), name: ph[2].trim(), files: [], reason: '' };
|
|
1042
417
|
phases.push(cur);
|
|
1043
418
|
fileIndent = null;
|
|
1044
419
|
continue;
|
|
1045
420
|
}
|
|
1046
421
|
if (!cur) continue;
|
|
1047
|
-
const fm = l.match(/^(\s*)
|
|
422
|
+
const fm = l.match(/^(\s*)[-*]\s*파일\s*:\s*(.*)$/);
|
|
1048
423
|
if (fm) {
|
|
1049
424
|
fileIndent = fm[1].length;
|
|
1050
|
-
|
|
425
|
+
for (const p of fm[2].split(/[,·]/)) if (cleanPath(p)) cur.files.push(cleanPath(p));
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const rm = l.match(/^\s*[-*]\s*사유\s*:\s*(.*)$/);
|
|
429
|
+
if (rm) {
|
|
430
|
+
cur.reason = rm[1].trim();
|
|
431
|
+
fileIndent = null;
|
|
1051
432
|
continue;
|
|
1052
433
|
}
|
|
1053
434
|
if (fileIndent != null) {
|
|
1054
|
-
const sub = l.match(/^(\s+)
|
|
435
|
+
const sub = l.match(/^(\s+)[-*]\s+(.+)$/);
|
|
1055
436
|
if (sub && sub[1].length > fileIndent) {
|
|
1056
|
-
|
|
437
|
+
if (cleanPath(sub[2])) cur.files.push(cleanPath(sub[2]));
|
|
1057
438
|
continue;
|
|
1058
439
|
}
|
|
1059
|
-
fileIndent = null;
|
|
440
|
+
fileIndent = null;
|
|
1060
441
|
}
|
|
1061
442
|
}
|
|
1062
443
|
return phases;
|
|
1063
444
|
}
|
|
1064
445
|
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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);
|
|
446
|
+
// 완료 기준 — `[AUTO]`·`[USER]`가 붙은 줄을 차례로 1번부터 센다
|
|
447
|
+
function parseCriteria(text) {
|
|
448
|
+
return meaningfulLines(section(text, isNewDoc(text) ? '테스트 계획' : '완료 기준')).filter((l) =>
|
|
449
|
+
/^(?:\d+[.)]|[-*])\s*\[(AUTO|USER)\]/.test(l),
|
|
450
|
+
);
|
|
1076
451
|
}
|
|
1077
452
|
|
|
1078
|
-
//
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
];
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
const
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
453
|
+
// 목표 = 1.0.1은 `## 요구사항` 첫 줄, 1.0.0은 `## 목표` 첫 줄(1.0.0 medium은 두 절이 다 있다)
|
|
454
|
+
function goalLine(text) {
|
|
455
|
+
return meaningfulLines(section(text, isNewDoc(text) ? '요구사항' : '목표'))[0] || '';
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// approve-plan의 목표 검사 — 문제가 없으면 null
|
|
459
|
+
function goalProblem(text) {
|
|
460
|
+
const goal = goalLine(text);
|
|
461
|
+
if (!isNewDoc(text)) return goal ? null : '`## 목표`가 비어 있다';
|
|
462
|
+
if (!goal) return '`## 요구사항` 첫 줄(목표)이 비어 있다 — 무엇을 왜 하는지 한 문장';
|
|
463
|
+
if (/^(?:[-*]|\d+[.)])\s/.test(goal)) return '`## 요구사항` 첫 줄은 목록 기호 없는 목표 한 문장이어야 한다(무엇을 왜) — 요구 목록은 그 아래에';
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// 계획 파일 — 1.0.1은 `## 개발 계획`의 Phase `- 파일:`(Phase가 있으면 그것만), 없으면 목록.
|
|
468
|
+
// 1.0.0은 `## 만질 파일` + `## Phase`
|
|
469
|
+
function plannedFiles(text) {
|
|
470
|
+
if (isNewDoc(text)) {
|
|
471
|
+
const phases = parsePhases(text);
|
|
472
|
+
if (!phases.length) return parseFileList(section(text, '개발 계획'));
|
|
473
|
+
const files = [];
|
|
474
|
+
for (const p of phases) for (const f of p.files) if (!files.includes(f)) files.push(f);
|
|
475
|
+
return files;
|
|
476
|
+
}
|
|
477
|
+
const files = parseFileList(section(text, '만질 파일'));
|
|
478
|
+
for (const p of parsePhases(text)) for (const f of p.files) if (!files.includes(f)) files.push(f);
|
|
479
|
+
return files;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// 경로가 계획 목록(파일 또는 폴더)에 들어 있나
|
|
483
|
+
function fileMatches(file, planned) {
|
|
484
|
+
const base = planned.replace(/\/$/, '');
|
|
485
|
+
return file === base || file.startsWith(base + '/');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ─── 헤더 표 ────────────────────────────────────────
|
|
489
|
+
|
|
490
|
+
const HEADER_HEAD = '| 생성일 | 플랜 | 유형 | 크기 | 스위치 | 범위 | 부모 브랜치 | 브랜치 | 상태 |';
|
|
491
|
+
const HEADER_SEP = '|---|---|---|---|---|---|---|---|---|';
|
|
492
|
+
const SWITCH_LABEL = { plan: '기획', dev: '개발', test: '테스트' };
|
|
493
|
+
|
|
494
|
+
function cell(v) {
|
|
495
|
+
return String(v == null ? '' : v).replace(/\r?\n/g, ' ').replace(/\|/g, '\\|');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function renderHeaderRow(st) {
|
|
499
|
+
const cells = [
|
|
500
|
+
clock(st.times.prepare).slice(0, 10),
|
|
501
|
+
st.item ? `${st.plan} (항목 ${st.item})` : st.plan,
|
|
502
|
+
st.type,
|
|
503
|
+
st.size,
|
|
504
|
+
['plan', 'dev', 'test'].filter((n) => sw(st, n)).map((n) => SWITCH_LABEL[n]).join('·'),
|
|
505
|
+
st.range,
|
|
506
|
+
st.parent,
|
|
507
|
+
st.noBranch ? '분기 없음' : st.branch,
|
|
508
|
+
!st.closed ? '열림' : st.closed.finished ? '닫힘(완료)' : '닫힘(포기)',
|
|
509
|
+
];
|
|
510
|
+
return `| ${cells.map(cell).join(' | ')} |`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ─── 시간 · 단계 표 ─────────────────────────────────
|
|
514
|
+
|
|
515
|
+
function ms(iso) {
|
|
516
|
+
return iso ? new Date(iso).getTime() : null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function minutes(msVal) {
|
|
520
|
+
if (msVal == null) return '';
|
|
521
|
+
const m = Math.round(msVal / 60000);
|
|
522
|
+
return m < 1 ? '1분 미만' : `${m}분`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// 단계별 소요 시간(ms). 각 단계 = 그 단계 완료 시각 − 앞 단계들의 마지막 완료 시각
|
|
526
|
+
function stageDurations(st) {
|
|
527
|
+
const t0 = ms(st.times.prepare);
|
|
528
|
+
const tPlan = st.approve ? ms(st.approve.at) : null;
|
|
529
|
+
const tDev = sw(st, 'dev') && st.testCode ? ms(st.testCode.at) : null;
|
|
530
|
+
const tTest = sw(st, 'test') && st.testDone ? ms(st.testDone.at) : null;
|
|
531
|
+
const tClose = st.closed ? ms(st.closed.at) : null;
|
|
532
|
+
const d = {};
|
|
533
|
+
if (tPlan != null) d.plan = Math.max(0, tPlan - t0);
|
|
534
|
+
if (tDev != null) d.dev = Math.max(0, tDev - Math.max(t0, tPlan || t0));
|
|
535
|
+
if (tTest != null) d.test = Math.max(0, tTest - Math.max(t0, tPlan || t0, tDev || t0));
|
|
536
|
+
if (tClose != null) {
|
|
537
|
+
let close = tClose - Math.max(t0, tPlan || t0, tDev || t0, tTest || t0);
|
|
538
|
+
// 병합 확인 대기(commit-task 끝 ~ merge-task 시작)는 뺀다(§4-3)
|
|
539
|
+
if (st.commit && st.mergeStart) close -= Math.max(0, ms(st.mergeStart) - ms(st.commit.at));
|
|
540
|
+
d.close = Math.max(0, close);
|
|
541
|
+
d.total = (d.plan || 0) + (d.dev || 0) + (d.test || 0) + d.close;
|
|
542
|
+
}
|
|
543
|
+
return d;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function stageCells(st) {
|
|
547
|
+
const d = stageDurations(st);
|
|
548
|
+
const planDone = !!st.approve;
|
|
549
|
+
const devDone = !sw(st, 'dev') || !!st.testCode;
|
|
550
|
+
const testDone = !sw(st, 'test') || !!st.testDone;
|
|
551
|
+
const cells = [];
|
|
552
|
+
cells.push(planDone ? `✅ ${minutes(d.plan)}` : '⏳');
|
|
553
|
+
if (!sw(st, 'dev')) cells.push('꺼짐');
|
|
554
|
+
else cells.push(st.testCode ? `✅ ${minutes(d.dev)}` : planDone ? '⏳' : '–');
|
|
555
|
+
if (!sw(st, 'test')) cells.push('꺼짐');
|
|
556
|
+
else if (st.testDone) cells.push(`✅ ${minutes(d.test)}${st.testDone.accepted ? ' (FAIL 수락)' : ''}`);
|
|
557
|
+
else cells.push(planDone && devDone ? '⏳' : '–');
|
|
558
|
+
if (st.closed) cells.push(`✅ ${minutes(d.close)} (합계 ${minutes(d.total)})`);
|
|
559
|
+
else cells.push(planDone && devDone && testDone ? '⏳' : '–');
|
|
560
|
+
return cells;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const TABLE_HEAD = '| 시작·기획 | 개발 | 테스트 | 닫기 |';
|
|
564
|
+
|
|
565
|
+
function renderTableRow(st) {
|
|
566
|
+
return `| ${stageCells(st).join(' | ')} |`;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// 문서 머리(첫 `## ` 앞)에서 표 머리 줄을 찾는다
|
|
570
|
+
function headIndex(lines, head) {
|
|
571
|
+
let end = lines.findIndex((l) => /^## /.test(l));
|
|
572
|
+
if (end === -1) end = lines.length;
|
|
573
|
+
for (let i = 0; i < end; i++) {
|
|
574
|
+
if (lines[i].trim() === head) return lines[i + 2] !== undefined && i + 2 < end && lines[i + 2].trim().startsWith('|') ? i : -1;
|
|
1171
575
|
}
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
const
|
|
1193
|
-
|
|
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(),
|
|
576
|
+
return -1;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// 문서의 헤더 표와 단계 표 줄을 .state 기록으로 다시 쓴다(두 표는 명령만 쓴다).
|
|
580
|
+
// 1.0.0 문서는 제목 바로 아래 메타 줄을 헤더 표로 바꾼다
|
|
581
|
+
function syncDoc(main, st) {
|
|
582
|
+
const file = docAbs(main, st);
|
|
583
|
+
if (!fs.existsSync(file)) return;
|
|
584
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
|
585
|
+
if (headIndex(lines, HEADER_HEAD) === -1 && lines[1] !== undefined && lines[1].startsWith('<!-- taskery:')) {
|
|
586
|
+
lines.splice(1, 1, '', HEADER_HEAD, HEADER_SEP, renderHeaderRow(st));
|
|
587
|
+
}
|
|
588
|
+
const hi = headIndex(lines, HEADER_HEAD);
|
|
589
|
+
if (hi !== -1) lines[hi + 2] = renderHeaderRow(st);
|
|
590
|
+
const ti = headIndex(lines, TABLE_HEAD);
|
|
591
|
+
if (ti !== -1) lines[ti + 2] = renderTableRow(st);
|
|
592
|
+
fs.writeFileSync(file, lines.join('\n'));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function renderDoc(st) {
|
|
596
|
+
const out = [
|
|
597
|
+
`# ${taskLabel(st.num)} ${st.title}`,
|
|
1205
598
|
'',
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
-
}
|
|
599
|
+
HEADER_HEAD,
|
|
600
|
+
HEADER_SEP,
|
|
601
|
+
renderHeaderRow(st),
|
|
602
|
+
'',
|
|
603
|
+
TABLE_HEAD,
|
|
604
|
+
'|---|---|---|---|',
|
|
605
|
+
renderTableRow(st),
|
|
606
|
+
'',
|
|
607
|
+
'## 요구사항',
|
|
608
|
+
'',
|
|
609
|
+
];
|
|
610
|
+
if (sw(st, 'dev')) out.push('## 개발 계획', '');
|
|
611
|
+
if (sw(st, 'test')) out.push('## 테스트 계획', '');
|
|
612
|
+
out.push('## 결정', '', '## 결과', '');
|
|
613
|
+
return out.join('\n');
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// `## 결과` 절 끝에 한 줄을 붙인다(명령만 쓴다). 문서가 없으면 아무것도 하지 않는다
|
|
617
|
+
function appendResult(main, st, line) {
|
|
618
|
+
const file = docAbs(main, st);
|
|
619
|
+
if (!fs.existsSync(file)) return;
|
|
620
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n');
|
|
621
|
+
const fenced = fenceMask(lines);
|
|
622
|
+
let start = lines.findIndex((l, i) => !fenced[i] && l.trim() === '## 결과');
|
|
623
|
+
if (start === -1) {
|
|
624
|
+
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
|
|
625
|
+
lines.push('', '## 결과', '');
|
|
626
|
+
start = lines.length - 2;
|
|
627
|
+
}
|
|
628
|
+
let end = lines.length;
|
|
629
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
630
|
+
if (!fenced[i] && /^## /.test(lines[i])) {
|
|
631
|
+
end = i;
|
|
632
|
+
break;
|
|
1277
633
|
}
|
|
1278
634
|
}
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
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
|
-
}
|
|
1306
|
-
|
|
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
|
-
}
|
|
1313
|
-
|
|
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
|
-
};
|
|
635
|
+
let at = end;
|
|
636
|
+
while (at > start + 1 && lines[at - 1].trim() === '') at--;
|
|
637
|
+
if (at === start + 1) {
|
|
638
|
+
lines.splice(at, 0, '', line);
|
|
639
|
+
} else {
|
|
640
|
+
lines.splice(at, 0, line);
|
|
1325
641
|
}
|
|
642
|
+
fs.writeFileSync(file, lines.join('\n'));
|
|
643
|
+
}
|
|
1326
644
|
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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
|
-
});
|
|
645
|
+
function nowIso() {
|
|
646
|
+
return new Date().toISOString();
|
|
647
|
+
}
|
|
1338
648
|
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
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 });
|
|
1367
|
-
}
|
|
1368
|
-
}
|
|
649
|
+
function clock(iso) {
|
|
650
|
+
const d = new Date(iso);
|
|
651
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
652
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
653
|
+
}
|
|
1369
654
|
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
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 });
|
|
1389
|
-
}
|
|
655
|
+
function todayLocal() {
|
|
656
|
+
return clock(nowIso()).slice(0, 10);
|
|
657
|
+
}
|
|
1390
658
|
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
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;
|
|
659
|
+
// 범위 갱신 — 태스크 명령에 --range를 붙이면 헤더 표의 범위 칸만 바꾼다(§3-5)
|
|
660
|
+
function updateRange(main, st, range) {
|
|
661
|
+
if (!range || range === st.range) return st;
|
|
662
|
+
st.range = range;
|
|
663
|
+
writeState(main, st);
|
|
664
|
+
syncDoc(main, st);
|
|
665
|
+
return st;
|
|
1411
666
|
}
|
|
1412
667
|
|
|
1413
668
|
module.exports = {
|
|
1414
|
-
LOCAL_SUFFIX,
|
|
1415
669
|
MANIFEST_NAME,
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
670
|
+
EXCLUDE_NAMES,
|
|
671
|
+
PLANT_COPY,
|
|
672
|
+
PLANT_LINK,
|
|
673
|
+
TYPES,
|
|
674
|
+
SIZES,
|
|
675
|
+
SWITCHES,
|
|
676
|
+
DEFAULT_RANGE,
|
|
677
|
+
TAG_OF,
|
|
678
|
+
CODE_TEST_NOTICE_MS,
|
|
679
|
+
TaskeryError,
|
|
680
|
+
fail,
|
|
681
|
+
git,
|
|
682
|
+
gitOk,
|
|
683
|
+
findMain,
|
|
684
|
+
currentBranch,
|
|
685
|
+
changedFiles,
|
|
686
|
+
trackedChanges,
|
|
687
|
+
isAncestor,
|
|
688
|
+
branchExists,
|
|
689
|
+
fingerprint,
|
|
690
|
+
ensureExclude,
|
|
691
|
+
inOrca,
|
|
692
|
+
runShell,
|
|
693
|
+
tail,
|
|
694
|
+
OLD_MANIFEST_NAME,
|
|
695
|
+
checkLegacyInstall,
|
|
1432
696
|
readManifest,
|
|
1433
|
-
|
|
697
|
+
writeManifest,
|
|
698
|
+
requireInstalled,
|
|
1434
699
|
getPackageVersion,
|
|
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
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
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,
|
|
700
|
+
stateDir,
|
|
701
|
+
pad,
|
|
702
|
+
taskLabel,
|
|
703
|
+
parseTaskNum,
|
|
704
|
+
writeJsonAtomic,
|
|
705
|
+
readState,
|
|
706
|
+
writeState,
|
|
707
|
+
listStates,
|
|
708
|
+
withLock,
|
|
709
|
+
sw,
|
|
710
|
+
needsNoMerge,
|
|
711
|
+
hasFinishRecord,
|
|
712
|
+
workDir,
|
|
713
|
+
parseGitRuleTable,
|
|
714
|
+
readGitRule,
|
|
715
|
+
fillTemplate,
|
|
716
|
+
docAbs,
|
|
717
|
+
docShown,
|
|
718
|
+
readDoc,
|
|
719
|
+
section,
|
|
720
|
+
isNewDoc,
|
|
721
|
+
meaningfulLines,
|
|
722
|
+
parseFileList,
|
|
723
|
+
parsePhases,
|
|
724
|
+
parseCriteria,
|
|
725
|
+
goalLine,
|
|
726
|
+
goalProblem,
|
|
727
|
+
plannedFiles,
|
|
728
|
+
fileMatches,
|
|
729
|
+
renderHeaderRow,
|
|
730
|
+
stageDurations,
|
|
731
|
+
stageCells,
|
|
732
|
+
minutes,
|
|
733
|
+
syncDoc,
|
|
734
|
+
renderDoc,
|
|
735
|
+
appendResult,
|
|
736
|
+
nowIso,
|
|
737
|
+
clock,
|
|
738
|
+
todayLocal,
|
|
739
|
+
updateRange,
|
|
1485
740
|
};
|