@angar2/taskery 0.1.0 → 0.1.2
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 +207 -65
- package/bin/init.js +45 -2
- package/bin/lib.js +397 -3
- package/bin/prune.js +189 -0
- package/bin/status.js +148 -0
- package/bin/taskery.js +25 -0
- package/bin/update.js +26 -2
- package/package.json +7 -3
- package/template/.claude/hooks/closed-immutable.sh +5 -4
- package/template/.claude/hooks/git-guard.sh +1 -1
- package/template/.claude/settings.json +1 -2
- package/template/.claude/skills/add-backlog/SKILL.md +117 -0
- package/template/.claude/skills/log-friction/SKILL.md +76 -0
- package/template/.claude/skills/{plan-init.md → plan-init/SKILL.md} +28 -4
- package/template/.claude/skills/{project-init.md → project-init/SKILL.md} +60 -7
- package/template/.claude/skills/task-close/SKILL.md +351 -0
- package/template/.claude/skills/{task-dev.md → task-dev/SKILL.md} +54 -8
- package/template/.claude/skills/task-init/SKILL.md +283 -0
- package/template/.claude/skills/{task-plan.md → task-plan/SKILL.md} +115 -18
- package/template/.claude/skills/{task-test.md → task-test/SKILL.md} +60 -18
- package/template/.project/FRICTION_LOG.md +6 -13
- package/template/.project/rules/CHANGELOG_RULE.md +91 -0
- package/template/.project/rules/GIT_RULE.md +119 -8
- package/template/.project/rules/MOCKUP_RULE.md +85 -0
- package/template/.project/rules/TASK_DOC_RULE.md +78 -76
- package/template/CLAUDE.md +77 -12
- package/template/.claude/hooks/pre-commit-verify.sh +0 -97
- package/template/.claude/skills/refine.md +0 -177
- package/template/.claude/skills/task-close.md +0 -178
- package/template/.claude/skills/task-init.md +0 -142
package/bin/lib.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* bin/lib.js
|
|
3
|
-
* 공통 유틸 — taskery init/create/update 스크립트가 공유.
|
|
3
|
+
* 공통 유틸 — taskery init/create/update/prune/status 스크립트가 공유.
|
|
4
4
|
*
|
|
5
5
|
* 핵심:
|
|
6
6
|
* - findTemplateDir(): 패키지 설치 위치에서 template/ 디렉토리 찾기
|
|
@@ -8,14 +8,37 @@
|
|
|
8
8
|
* - sha256(path): 파일 해시
|
|
9
9
|
* - mkdirp(path): mkdir -p
|
|
10
10
|
* - copyFile(src, dst): 파일 카피 + 디렉토리 자동 생성
|
|
11
|
-
* - writeManifest(manifest, dst): manifest JSON
|
|
11
|
+
* - writeManifest(manifest, dst): manifest JSON 기록
|
|
12
12
|
* - readManifest(path): 기존 manifest Read (없으면 null)
|
|
13
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
|
+
* - withMergeLock(projectId, fn, opts): proper-lockfile로 머지 락 + fn 실행 + 자동 release
|
|
23
|
+
* - withMetaLock(filePath, fn, opts): 메타 파일 쓰기 락 + fn 실행
|
|
24
|
+
* - getActiveTasks(mainWtPath): SSoT 조회 (git branch --no-merged dev --list 'feature/*_TASK-*' ...)
|
|
25
|
+
* - getNextTaskNumber(mainWtPath): (진행중 ∪ dev 머지 히스토리) 최대 + 1
|
|
26
|
+
* - assertMainWorktreeOnDev(mainWtPath): 메인 워크트리가 dev 체크아웃 상태인지 검증
|
|
27
|
+
* - assertDevExists(mainWtPath): dev 브랜치 존재 검증
|
|
28
|
+
*
|
|
29
|
+
* 백로그 (0.1.2+):
|
|
30
|
+
* - getActiveVersion(mainWtPath): .project/AGENT-GUIDE.md에서 활성 plan 버전(vX.X) 추출
|
|
31
|
+
* - getBacklogPath(mainWtPath, activeVersion?): .project/tasks/<vX.X>/BACKLOG.md 절대 경로
|
|
32
|
+
* - parseBacklogItem(mainWtPath, blId): BL-NNN 항목 메타 파싱 ({ status, type, title, slug, summary, target, taskNums })
|
|
33
|
+
* - appendBacklogItem(mainWtPath, meta): withMetaLock + BL-NNN 채번 + 항목 append (placeholder 치환 우선)
|
|
34
|
+
* - markBacklogChecked(mainWtPath, blId, taskNum): withMetaLock + [ ] → [x] + TASK 마크 (다회 진행 시 콤마 추가)
|
|
14
35
|
*/
|
|
15
36
|
|
|
16
37
|
const fs = require('fs');
|
|
17
38
|
const path = require('path');
|
|
39
|
+
const os = require('os');
|
|
18
40
|
const crypto = require('crypto');
|
|
41
|
+
const { execFileSync } = require('child_process');
|
|
19
42
|
|
|
20
43
|
const LOCAL_SUFFIX = '.local.md';
|
|
21
44
|
const MANIFEST_NAME = '.taskery-manifest.json';
|
|
@@ -37,7 +60,7 @@ function sha256(filePath) {
|
|
|
37
60
|
|
|
38
61
|
function walkTemplate(templateDir) {
|
|
39
62
|
// template/ 안 모든 파일 상대경로 + 해시 맵 리턴
|
|
40
|
-
// 결과: { 'CLAUDE.md': 'sha256:...', '.claude/skills/task-init.md': 'sha256:...', ... }
|
|
63
|
+
// 결과: { 'CLAUDE.md': 'sha256:...', '.claude/skills/task-init/SKILL.md': 'sha256:...', ... }
|
|
41
64
|
const result = {};
|
|
42
65
|
function walk(dir, relBase) {
|
|
43
66
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -92,6 +115,349 @@ function getPackageVersion() {
|
|
|
92
115
|
return pkg.version;
|
|
93
116
|
}
|
|
94
117
|
|
|
118
|
+
// === 멀티세션 (0.1.2+) ===
|
|
119
|
+
|
|
120
|
+
const TASKERY_HOME = path.join(os.homedir(), '.taskery');
|
|
121
|
+
const DEFAULT_STALE_DAYS = 30;
|
|
122
|
+
const DEFAULT_LOCK_TIMEOUT_MS = 30000;
|
|
123
|
+
|
|
124
|
+
const BRANCH_PATTERNS = [
|
|
125
|
+
'feature/*_TASK-*',
|
|
126
|
+
'bug/*_TASK-*',
|
|
127
|
+
'improve/*_TASK-*',
|
|
128
|
+
'refactor/*_TASK-*',
|
|
129
|
+
'docs/*_TASK-*',
|
|
130
|
+
'chore/*_TASK-*',
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
function generateProjectId() {
|
|
134
|
+
return crypto.randomBytes(4).toString('hex');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function gitCapture(cwd, args, opts = {}) {
|
|
138
|
+
try {
|
|
139
|
+
return execFileSync('git', ['-C', cwd, ...args], {
|
|
140
|
+
encoding: 'utf8',
|
|
141
|
+
stdio: ['ignore', 'pipe', opts.captureStderr ? 'pipe' : 'ignore'],
|
|
142
|
+
}).trimEnd();
|
|
143
|
+
} catch (e) {
|
|
144
|
+
if (opts.allowFail) return null;
|
|
145
|
+
throw e;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function getMainWorktreePath(cwd = process.cwd()) {
|
|
150
|
+
// git ≥ 2.31 — `git rev-parse --path-format=absolute --git-common-dir` 는
|
|
151
|
+
// 메인 워크트리의 .git 경로(또는 .git 디렉토리 자체)를 절대 경로로 돌려준다.
|
|
152
|
+
// 그 부모가 *메인 워크트리* 절대 경로.
|
|
153
|
+
const commonDir = gitCapture(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
154
|
+
if (!commonDir) throw new Error(`메인 워크트리 검출 실패 (cwd=${cwd})`);
|
|
155
|
+
return path.dirname(commonDir);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getProjectId(mainWtPath) {
|
|
159
|
+
const m = readManifest(path.join(mainWtPath, MANIFEST_NAME));
|
|
160
|
+
if (!m || !m.projectId) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`${MANIFEST_NAME}.projectId 누락 — 'npx @angar2/taskery update' 로 마이그레이션 필요.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return m.projectId;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function getWorktreesRoot(projectId) {
|
|
169
|
+
return path.join(TASKERY_HOME, 'worktrees', projectId);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function getWorktreePath(projectId, { taskNum, src, slug }) {
|
|
173
|
+
// src = 'BL-NNN' | 'RM-NNN' | 'DR'
|
|
174
|
+
const nnn = String(taskNum).padStart(3, '0');
|
|
175
|
+
return path.join(getWorktreesRoot(projectId), `TASK-${nnn}_${src}_${slug}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function getMergeLockPath(projectId) {
|
|
179
|
+
return path.join(TASKERY_HOME, `${projectId}.merge.lock`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function withMergeLock(projectId, fn, opts = {}) {
|
|
183
|
+
const lockfile = require('proper-lockfile');
|
|
184
|
+
const lockPath = getMergeLockPath(projectId);
|
|
185
|
+
mkdirp(path.dirname(lockPath));
|
|
186
|
+
// proper-lockfile은 *기존 파일 위에 락*을 잡는다. sentinel 파일 보장.
|
|
187
|
+
if (!fs.existsSync(lockPath)) fs.writeFileSync(lockPath, '');
|
|
188
|
+
const release = await lockfile.lock(lockPath, {
|
|
189
|
+
stale: opts.stale ?? DEFAULT_LOCK_TIMEOUT_MS,
|
|
190
|
+
retries: opts.retries ?? {
|
|
191
|
+
retries: 5,
|
|
192
|
+
factor: 1,
|
|
193
|
+
minTimeout: 1000,
|
|
194
|
+
maxTimeout: 1000,
|
|
195
|
+
},
|
|
196
|
+
realpath: false,
|
|
197
|
+
});
|
|
198
|
+
try {
|
|
199
|
+
return await fn();
|
|
200
|
+
} finally {
|
|
201
|
+
await release();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function withMetaLock(filePath, fn, opts = {}) {
|
|
206
|
+
const lockfile = require('proper-lockfile');
|
|
207
|
+
const target = filePath;
|
|
208
|
+
if (!fs.existsSync(target)) {
|
|
209
|
+
// 락 대상은 파일이어야 함. 없으면 sentinel 빈 파일 만들어 락 가능 상태로.
|
|
210
|
+
mkdirp(path.dirname(target));
|
|
211
|
+
fs.writeFileSync(target, '');
|
|
212
|
+
}
|
|
213
|
+
const release = await lockfile.lock(target, {
|
|
214
|
+
stale: opts.stale ?? DEFAULT_LOCK_TIMEOUT_MS,
|
|
215
|
+
retries: opts.retries ?? {
|
|
216
|
+
retries: 5,
|
|
217
|
+
factor: 1,
|
|
218
|
+
minTimeout: 1000,
|
|
219
|
+
maxTimeout: 1000,
|
|
220
|
+
},
|
|
221
|
+
realpath: false,
|
|
222
|
+
});
|
|
223
|
+
try {
|
|
224
|
+
return await fn();
|
|
225
|
+
} finally {
|
|
226
|
+
await release();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function assertDevExists(mainWtPath) {
|
|
231
|
+
const ok = gitCapture(mainWtPath, ['rev-parse', '--verify', 'dev'], { allowFail: true });
|
|
232
|
+
if (ok === null) {
|
|
233
|
+
throw new Error('dev 브랜치 부재. 사용자 결정 필요 (생성?).');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function assertMainWorktreeOnDev(mainWtPath) {
|
|
238
|
+
const current = gitCapture(mainWtPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
239
|
+
if (current !== 'dev') {
|
|
240
|
+
throw new Error(`메인 워크트리가 dev 아님 (현재: ${current}). taskery 정책 위배.`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function parseBranchName(branch) {
|
|
245
|
+
// 형식: {type}/{dev}_TASK-NNN_{src}_{slug}
|
|
246
|
+
// 예: feature/claude_TASK-007_BL-003_login-feature
|
|
247
|
+
// bug/angar2_TASK-012_DR_mobile-form-refresh
|
|
248
|
+
const m = branch.match(
|
|
249
|
+
/^(feature|bug|improve|refactor|docs|chore)\/([^_]+)_TASK-(\d+)_([A-Z]{2}(?:-\d+)?|DR)_(.+)$/,
|
|
250
|
+
);
|
|
251
|
+
if (!m) return null;
|
|
252
|
+
return {
|
|
253
|
+
branch,
|
|
254
|
+
type: m[1],
|
|
255
|
+
dev: m[2],
|
|
256
|
+
taskNum: parseInt(m[3], 10),
|
|
257
|
+
src: m[4],
|
|
258
|
+
slug: m[5],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function getActiveTasks(mainWtPath) {
|
|
263
|
+
// SSoT — TASK-* 패턴 작업 브랜치 (분기 직후 빈 브랜치 포함). `--no-merged dev` 미사용:
|
|
264
|
+
// 워크트리 분기 직후 브랜치는 dev와 동일 commit이라 `--no-merged dev`가 *완전 머지 상태*로
|
|
265
|
+
// 처리 → 빈 결과 → getNextTaskNumber 충돌. 브랜치 존재 자체가 SSoT (정상 흐름은 task-close
|
|
266
|
+
// 자동 삭제로 자연 정리, 보존 키워드 잔존분도 *진행중*으로 잡힘이 정합).
|
|
267
|
+
const out = gitCapture(mainWtPath, ['branch', '--list', ...BRANCH_PATTERNS]);
|
|
268
|
+
if (!out) return [];
|
|
269
|
+
return out
|
|
270
|
+
.split('\n')
|
|
271
|
+
.map((l) => l.replace(/^[*+]?\s*/, '').trim())
|
|
272
|
+
.filter(Boolean)
|
|
273
|
+
.map(parseBranchName)
|
|
274
|
+
.filter(Boolean);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function getNextTaskNumber(mainWtPath) {
|
|
278
|
+
// 1. 진행중 (SSoT)
|
|
279
|
+
const activeNums = getActiveTasks(mainWtPath).map((t) => t.taskNum);
|
|
280
|
+
// 2. dev 머지 히스토리 (GIT_RULE 풍부 메시지)
|
|
281
|
+
const log = gitCapture(mainWtPath, [
|
|
282
|
+
'log',
|
|
283
|
+
'dev',
|
|
284
|
+
'--grep',
|
|
285
|
+
'TASK-[0-9]\\+',
|
|
286
|
+
'--extended-regexp',
|
|
287
|
+
'--oneline',
|
|
288
|
+
]);
|
|
289
|
+
const merged = log
|
|
290
|
+
? Array.from(log.matchAll(/TASK-(\d+)/g)).map((m) => parseInt(m[1], 10))
|
|
291
|
+
: [];
|
|
292
|
+
const all = [...activeNums, ...merged];
|
|
293
|
+
if (all.length === 0) return 1;
|
|
294
|
+
return Math.max(...all) + 1;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ─── 백로그 (0.1.2+) ────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
const BACKLOG_PLACEHOLDER =
|
|
300
|
+
'(사용자 발화 또는 task-close 직후 메인 감지로 한 행씩 추가. 빈 상태 default.)';
|
|
301
|
+
|
|
302
|
+
function getActiveVersion(mainWtPath) {
|
|
303
|
+
// .project/AGENT-GUIDE.md '## 활성 plan 버전' 섹션 다음 비어 있지 않은 첫 줄에서 vX.X 추출
|
|
304
|
+
const guidePath = path.join(mainWtPath, '.project', 'AGENT-GUIDE.md');
|
|
305
|
+
if (!fs.existsSync(guidePath)) {
|
|
306
|
+
throw new Error(`.project/AGENT-GUIDE.md 부재. /project-init 먼저 호출.`);
|
|
307
|
+
}
|
|
308
|
+
const content = fs.readFileSync(guidePath, 'utf8');
|
|
309
|
+
const sec = content.match(/##\s*활성 plan 버전\s*\n+([^\n]+)/);
|
|
310
|
+
if (!sec) {
|
|
311
|
+
throw new Error(`.project/AGENT-GUIDE.md '## 활성 plan 버전' 섹션 부재.`);
|
|
312
|
+
}
|
|
313
|
+
const m = sec[1].match(/\bv[\d.]+(?:[-\w]*)?/);
|
|
314
|
+
if (!m) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
`'## 활성 plan 버전' 값에서 vX.X 패턴 추출 실패: ${sec[1].trim()} — /plan-init으로 활성 버전 갱신 필요.`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
return m[0];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function getBacklogPath(mainWtPath, activeVersion) {
|
|
323
|
+
const v = activeVersion ?? getActiveVersion(mainWtPath);
|
|
324
|
+
return path.join(mainWtPath, '.project', 'tasks', v, 'BACKLOG.md');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function computeNextBLNumber(content) {
|
|
328
|
+
const matches = Array.from(content.matchAll(/\bBL-(\d+)\b/g)).map((m) => parseInt(m[1], 10));
|
|
329
|
+
if (matches.length === 0) return 1;
|
|
330
|
+
return Math.max(...matches) + 1;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function formatBacklogBlock({ id, type, title, slug, summary, target }) {
|
|
334
|
+
const blId = `BL-${String(id).padStart(3, '0')}`;
|
|
335
|
+
return `- [ ] **${blId}** [${type}] ${title} \`${slug}\`\n - 개요: ${summary}\n - 대상 영역: ${target}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function appendBacklogItem(mainWtPath, meta) {
|
|
339
|
+
// meta: { type, title, slug, summary, target }
|
|
340
|
+
const backlogPath = getBacklogPath(mainWtPath);
|
|
341
|
+
if (!fs.existsSync(backlogPath)) {
|
|
342
|
+
throw new Error(
|
|
343
|
+
`${backlogPath} 부재. /plan-init 먼저 호출해 활성 버전 디렉토리 + 빈 BACKLOG.md 생성 필요.`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return await withMetaLock(backlogPath, async () => {
|
|
347
|
+
const content = fs.readFileSync(backlogPath, 'utf8');
|
|
348
|
+
const next = computeNextBLNumber(content);
|
|
349
|
+
const block = formatBacklogBlock({ id: next, ...meta });
|
|
350
|
+
let updated;
|
|
351
|
+
if (content.includes(BACKLOG_PLACEHOLDER)) {
|
|
352
|
+
updated = content.replace(BACKLOG_PLACEHOLDER, block);
|
|
353
|
+
} else {
|
|
354
|
+
updated = content.replace(/\s*$/, '') + '\n\n' + block + '\n';
|
|
355
|
+
}
|
|
356
|
+
fs.writeFileSync(backlogPath, updated);
|
|
357
|
+
return next;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function parseBacklogItem(mainWtPath, blId) {
|
|
362
|
+
// blId: 'BL-001'. 발견 못하면 null.
|
|
363
|
+
const backlogPath = getBacklogPath(mainWtPath);
|
|
364
|
+
if (!fs.existsSync(backlogPath)) return null;
|
|
365
|
+
const content = fs.readFileSync(backlogPath, 'utf8');
|
|
366
|
+
const lines = content.split('\n');
|
|
367
|
+
const headRe = new RegExp(
|
|
368
|
+
`^- \\[([ x])\\] \\*\\*${blId}\\*\\* \\[(\\w+)\\] (.+?) \`([\\w-]+)\``,
|
|
369
|
+
);
|
|
370
|
+
let idx = -1;
|
|
371
|
+
let head = null;
|
|
372
|
+
for (let i = 0; i < lines.length; i++) {
|
|
373
|
+
const m = lines[i].match(headRe);
|
|
374
|
+
if (m) {
|
|
375
|
+
idx = i;
|
|
376
|
+
head = m;
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (idx === -1) return null;
|
|
381
|
+
let summary = null;
|
|
382
|
+
let target = null;
|
|
383
|
+
let taskNums = null;
|
|
384
|
+
for (let j = idx + 1; j < lines.length; j++) {
|
|
385
|
+
const l = lines[j];
|
|
386
|
+
if (l.startsWith('- ') && !l.startsWith(' - ')) break; // 다음 BL 항목
|
|
387
|
+
if (l.trim() === '') continue; // 블록 안 빈 줄 허용 (안전)
|
|
388
|
+
if (!l.startsWith(' - ')) break;
|
|
389
|
+
const ms = l.match(/^ - 개요:\s*(.+)/);
|
|
390
|
+
if (ms) { summary = ms[1].trim(); continue; }
|
|
391
|
+
const mt = l.match(/^ - 대상 영역:\s*(.+)/);
|
|
392
|
+
if (mt) { target = mt[1].trim(); continue; }
|
|
393
|
+
const mk = l.match(/^ - TASK:\s*(.+)/);
|
|
394
|
+
if (mk) { taskNums = mk[1].trim(); continue; }
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
blId,
|
|
398
|
+
status: head[1] === 'x' ? 'checked' : 'pending',
|
|
399
|
+
type: head[2],
|
|
400
|
+
title: head[3],
|
|
401
|
+
slug: head[4],
|
|
402
|
+
summary,
|
|
403
|
+
target,
|
|
404
|
+
taskNums,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function markBacklogChecked(mainWtPath, blId, taskNum) {
|
|
409
|
+
// taskNum: number. [ ] → [x] + TASK 마크 (있으면 콤마 추가, 없으면 신규 줄)
|
|
410
|
+
const backlogPath = getBacklogPath(mainWtPath);
|
|
411
|
+
if (!fs.existsSync(backlogPath)) {
|
|
412
|
+
throw new Error(`${backlogPath} 부재.`);
|
|
413
|
+
}
|
|
414
|
+
const taskLabel = `TASK-${String(taskNum).padStart(3, '0')}`;
|
|
415
|
+
return await withMetaLock(backlogPath, async () => {
|
|
416
|
+
const content = fs.readFileSync(backlogPath, 'utf8');
|
|
417
|
+
const lines = content.split('\n');
|
|
418
|
+
const headRe = new RegExp(`^(- )\\[[ x]\\](\\s+\\*\\*${blId}\\*\\*.+)$`);
|
|
419
|
+
let idx = -1;
|
|
420
|
+
for (let i = 0; i < lines.length; i++) {
|
|
421
|
+
if (headRe.test(lines[i])) {
|
|
422
|
+
idx = i;
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (idx === -1) {
|
|
427
|
+
throw new Error(`${blId} 항목 없음.`);
|
|
428
|
+
}
|
|
429
|
+
lines[idx] = lines[idx].replace(headRe, '$1[x]$2');
|
|
430
|
+
// 다음 BL 헤드 또는 파일 끝까지가 같은 블록 — 그 안에서 TASK 줄 검색
|
|
431
|
+
let blockEnd = lines.length;
|
|
432
|
+
for (let j = idx + 1; j < lines.length; j++) {
|
|
433
|
+
if (lines[j].startsWith('- ') && !lines[j].startsWith(' - ')) {
|
|
434
|
+
blockEnd = j;
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
let taskLineIdx = -1;
|
|
439
|
+
for (let j = idx + 1; j < blockEnd; j++) {
|
|
440
|
+
if (lines[j].match(/^ - TASK:/)) {
|
|
441
|
+
taskLineIdx = j;
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (taskLineIdx !== -1) {
|
|
446
|
+
const existing = lines[taskLineIdx].match(/^ - TASK:\s*(.+)/)[1].trim();
|
|
447
|
+
const existingTasks = existing.split(/[,\s]+/).filter(Boolean);
|
|
448
|
+
if (!existingTasks.includes(taskLabel)) {
|
|
449
|
+
lines[taskLineIdx] = ` - TASK: ${existing}, ${taskLabel}`;
|
|
450
|
+
}
|
|
451
|
+
} else {
|
|
452
|
+
// blockEnd 직전 빈 줄 건너뛰고 삽입
|
|
453
|
+
let insertAt = blockEnd;
|
|
454
|
+
while (insertAt > idx + 1 && lines[insertAt - 1].trim() === '') insertAt--;
|
|
455
|
+
lines.splice(insertAt, 0, ` - TASK: ${taskLabel}`);
|
|
456
|
+
}
|
|
457
|
+
fs.writeFileSync(backlogPath, lines.join('\n'));
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
95
461
|
module.exports = {
|
|
96
462
|
LOCAL_SUFFIX,
|
|
97
463
|
MANIFEST_NAME,
|
|
@@ -104,4 +470,32 @@ module.exports = {
|
|
|
104
470
|
readManifest,
|
|
105
471
|
isLocalOverride,
|
|
106
472
|
getPackageVersion,
|
|
473
|
+
// 멀티세션 (0.1.2+)
|
|
474
|
+
TASKERY_HOME,
|
|
475
|
+
DEFAULT_STALE_DAYS,
|
|
476
|
+
DEFAULT_LOCK_TIMEOUT_MS,
|
|
477
|
+
BRANCH_PATTERNS,
|
|
478
|
+
generateProjectId,
|
|
479
|
+
gitCapture,
|
|
480
|
+
getMainWorktreePath,
|
|
481
|
+
getProjectId,
|
|
482
|
+
getWorktreesRoot,
|
|
483
|
+
getWorktreePath,
|
|
484
|
+
getMergeLockPath,
|
|
485
|
+
withMergeLock,
|
|
486
|
+
withMetaLock,
|
|
487
|
+
assertDevExists,
|
|
488
|
+
assertMainWorktreeOnDev,
|
|
489
|
+
parseBranchName,
|
|
490
|
+
getActiveTasks,
|
|
491
|
+
getNextTaskNumber,
|
|
492
|
+
// 백로그 (0.1.2+)
|
|
493
|
+
BACKLOG_PLACEHOLDER,
|
|
494
|
+
getActiveVersion,
|
|
495
|
+
getBacklogPath,
|
|
496
|
+
computeNextBLNumber,
|
|
497
|
+
formatBacklogBlock,
|
|
498
|
+
appendBacklogItem,
|
|
499
|
+
parseBacklogItem,
|
|
500
|
+
markBacklogChecked,
|
|
107
501
|
};
|
package/bin/prune.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* bin/prune.js
|
|
4
|
+
* `npx @angar2/taskery prune` — stale 워크트리 / 브랜치 대화형 정리.
|
|
5
|
+
*
|
|
6
|
+
* 흐름:
|
|
7
|
+
* 1. git worktree prune (자동, 케이스 A 폴더 없는 등록 정리)
|
|
8
|
+
* 2. 의심 항목 목록 표시 (케이스 B/C/D + orphan)
|
|
9
|
+
* 3. 사용자 항목별 보존 / 삭제 선택
|
|
10
|
+
* 4. 선택된 항목 삭제:
|
|
11
|
+
* - 브랜치: git branch -D <name> ← 사용자 명시 선택이므로 -D 허용 (rebase 잔여 등 -d 거부 가능)
|
|
12
|
+
* - 워크트리: git worktree remove --force <path>
|
|
13
|
+
* 5. 결과 보고
|
|
14
|
+
*
|
|
15
|
+
* 본 명령은 *변경 발생 가능* — 사용자 confirm 항목별로 받음.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const readline = require('readline');
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
MANIFEST_NAME,
|
|
24
|
+
readManifest,
|
|
25
|
+
gitCapture,
|
|
26
|
+
getMainWorktreePath,
|
|
27
|
+
getActiveTasks,
|
|
28
|
+
getWorktreesRoot,
|
|
29
|
+
DEFAULT_STALE_DAYS,
|
|
30
|
+
} = require('./lib');
|
|
31
|
+
const { execFileSync } = require('child_process');
|
|
32
|
+
|
|
33
|
+
function ask(rl, q) {
|
|
34
|
+
return new Promise((resolve) => rl.question(q, (a) => resolve(a.trim().toLowerCase())));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function daysAgo(date) {
|
|
38
|
+
return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function main() {
|
|
42
|
+
const mainWt = getMainWorktreePath();
|
|
43
|
+
const manifest = readManifest(path.join(mainWt, MANIFEST_NAME));
|
|
44
|
+
if (!manifest) {
|
|
45
|
+
console.error(`${MANIFEST_NAME} 없음 — 'init' 먼저.`);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const projectId = manifest.projectId;
|
|
49
|
+
const staleDays = manifest.stale_days ?? DEFAULT_STALE_DAYS;
|
|
50
|
+
|
|
51
|
+
console.log(`\ntaskery prune\n`);
|
|
52
|
+
console.log(`메인 워크트리: ${mainWt}`);
|
|
53
|
+
console.log(`stale_days: ${staleDays}\n`);
|
|
54
|
+
|
|
55
|
+
// 1. git worktree prune (자동 — 케이스 A)
|
|
56
|
+
console.log(`Step 1 — git worktree prune (폴더 없는 워크트리 등록 정리)`);
|
|
57
|
+
try {
|
|
58
|
+
execFileSync('git', ['-C', mainWt, 'worktree', 'prune', '-v'], { stdio: 'inherit' });
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.error(` git worktree prune 실패: ${e.message}`);
|
|
61
|
+
}
|
|
62
|
+
console.log();
|
|
63
|
+
|
|
64
|
+
// 2. 의심 항목 수집
|
|
65
|
+
const active = getActiveTasks(mainWt);
|
|
66
|
+
const worktreesRoot = getWorktreesRoot(projectId);
|
|
67
|
+
|
|
68
|
+
// 케이스 B/C/D
|
|
69
|
+
const suspects = [];
|
|
70
|
+
for (const t of active) {
|
|
71
|
+
const wtPath = path.join(
|
|
72
|
+
worktreesRoot,
|
|
73
|
+
`TASK-${String(t.taskNum).padStart(3, '0')}_${t.src}_${t.slug}`,
|
|
74
|
+
);
|
|
75
|
+
const wtExists = fs.existsSync(wtPath);
|
|
76
|
+
const lastCommit = gitCapture(mainWt, ['log', '-1', '--format=%ci', t.branch], {
|
|
77
|
+
allowFail: true,
|
|
78
|
+
});
|
|
79
|
+
const lastDate = lastCommit ? new Date(lastCommit) : null;
|
|
80
|
+
const days = lastDate ? daysAgo(lastDate) : null;
|
|
81
|
+
|
|
82
|
+
let kase = null;
|
|
83
|
+
if (!wtExists) kase = 'A (폴더 없음 — git worktree prune 처리됐을 가능성)';
|
|
84
|
+
else if (days !== null && days >= staleDays) kase = `C (워크트리 폴더 존재 + ${days}일 비활성)`;
|
|
85
|
+
|
|
86
|
+
if (wtExists) {
|
|
87
|
+
const rebaseApply = path.join(wtPath, '.git', 'rebase-apply');
|
|
88
|
+
const rebaseMerge = path.join(wtPath, '.git', 'rebase-merge');
|
|
89
|
+
if (fs.existsSync(rebaseApply) || fs.existsSync(rebaseMerge)) {
|
|
90
|
+
kase = `D (rebase 잔여 상태)`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (kase) {
|
|
95
|
+
suspects.push({ branch: t.branch, wtPath, wtExists, days, kase });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// orphan 워크트리 (SSoT에 없는 폴더)
|
|
100
|
+
const orphans = [];
|
|
101
|
+
if (fs.existsSync(worktreesRoot)) {
|
|
102
|
+
const folders = fs.readdirSync(worktreesRoot).filter((n) => n.startsWith('TASK-'));
|
|
103
|
+
const activeFolders = new Set(
|
|
104
|
+
active.map(
|
|
105
|
+
(t) => `TASK-${String(t.taskNum).padStart(3, '0')}_${t.src}_${t.slug}`,
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
for (const f of folders) {
|
|
109
|
+
if (!activeFolders.has(f)) {
|
|
110
|
+
orphans.push(path.join(worktreesRoot, f));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (suspects.length === 0 && orphans.length === 0) {
|
|
116
|
+
console.log(`✅ 정리할 의심 항목 없음.`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log(`Step 2 — 의심 항목 (${suspects.length + orphans.length}건)\n`);
|
|
121
|
+
|
|
122
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
123
|
+
const toDelete = { branches: [], worktrees: [] };
|
|
124
|
+
|
|
125
|
+
// suspects 처리
|
|
126
|
+
for (const s of suspects) {
|
|
127
|
+
console.log(`[${s.kase}] ${s.branch}`);
|
|
128
|
+
console.log(` 워크트리: ${s.wtPath}${s.wtExists ? '' : ' (폴더 X)'}`);
|
|
129
|
+
if (s.days !== null) console.log(` 마지막 커밋: ${s.days}일 전`);
|
|
130
|
+
const ans = await ask(rl, ` 삭제? (y=브랜치+워크트리 / w=워크트리만 / k=보존, 기본 k) `);
|
|
131
|
+
if (ans === 'y') {
|
|
132
|
+
toDelete.branches.push(s.branch);
|
|
133
|
+
if (s.wtExists) toDelete.worktrees.push(s.wtPath);
|
|
134
|
+
} else if (ans === 'w' && s.wtExists) {
|
|
135
|
+
toDelete.worktrees.push(s.wtPath);
|
|
136
|
+
} else {
|
|
137
|
+
console.log(` 보존`);
|
|
138
|
+
}
|
|
139
|
+
console.log();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// orphan 처리
|
|
143
|
+
for (const o of orphans) {
|
|
144
|
+
console.log(`[orphan] 워크트리 폴더 (SSoT에 없는 브랜치)`);
|
|
145
|
+
console.log(` ${o}`);
|
|
146
|
+
const ans = await ask(rl, ` 워크트리 폴더 삭제? (y/N) `);
|
|
147
|
+
if (ans === 'y') toDelete.worktrees.push(o);
|
|
148
|
+
else console.log(` 보존`);
|
|
149
|
+
console.log();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
rl.close();
|
|
153
|
+
|
|
154
|
+
// 3. 실제 삭제
|
|
155
|
+
if (toDelete.branches.length === 0 && toDelete.worktrees.length === 0) {
|
|
156
|
+
console.log(`삭제 대상 없음.`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log(`\nStep 3 — 삭제 실행\n`);
|
|
161
|
+
|
|
162
|
+
for (const wt of toDelete.worktrees) {
|
|
163
|
+
try {
|
|
164
|
+
execFileSync('git', ['-C', mainWt, 'worktree', 'remove', '--force', wt], {
|
|
165
|
+
stdio: 'inherit',
|
|
166
|
+
});
|
|
167
|
+
console.log(` worktree removed: ${wt}`);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
console.error(` worktree remove 실패 (${wt}): ${e.message}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const b of toDelete.branches) {
|
|
174
|
+
try {
|
|
175
|
+
// -D 강제 삭제 — 사용자 명시 선택 (prune 대화형) 이므로 허용
|
|
176
|
+
execFileSync('git', ['-C', mainWt, 'branch', '-D', b], { stdio: 'inherit' });
|
|
177
|
+
console.log(` branch deleted: ${b}`);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
console.error(` branch -D 실패 (${b}): ${e.message}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
console.log(`\n✅ taskery prune 완료 — 워크트리 ${toDelete.worktrees.length}건 / 브랜치 ${toDelete.branches.length}건 삭제.`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
main().catch((e) => {
|
|
187
|
+
console.error(`taskery prune 실패: ${e.message}`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
});
|