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