@angar2/taskery 0.8.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +423 -188
  2. package/bin/add.js +27 -99
  3. package/bin/backlog.js +240 -113
  4. package/bin/check.js +166 -0
  5. package/bin/commands.js +151 -0
  6. package/bin/finish.js +353 -0
  7. package/bin/init.js +99 -190
  8. package/bin/install.js +143 -0
  9. package/bin/lib.js +515 -1320
  10. package/bin/mcp.js +31 -263
  11. package/bin/orche.js +240 -0
  12. package/bin/plan.js +58 -120
  13. package/bin/prepare.js +326 -0
  14. package/bin/prune.js +46 -176
  15. package/bin/status.js +57 -151
  16. package/bin/taskery.js +76 -120
  17. package/bin/update.js +66 -266
  18. package/package.json +5 -5
  19. package/template/.taskery/BACKLOG.md +38 -0
  20. package/template/.taskery/rules/CHANGELOG_RULE.md +31 -0
  21. package/template/.taskery/rules/DEV_RULE.local.md +8 -0
  22. package/template/.taskery/rules/GIT_RULE.md +82 -0
  23. package/template/.taskery/rules/MOCKUP_RULE.md +19 -0
  24. package/template/.taskery/rules/TASKERY_RULE.md +112 -0
  25. package/template/.taskery/rules/TASK_DOC_RULE.md +84 -0
  26. package/template/.taskery/rules/TEST_RULE.local.md +16 -0
  27. package/template/AGENTS.md +15 -53
  28. package/template/shared/skills/add-backlog/SKILL.md +8 -38
  29. package/template/shared/skills/log-friction/SKILL.md +10 -88
  30. package/template/shared/skills/plan-init/SKILL.md +10 -98
  31. package/template/shared/skills/project-init/SKILL.md +13 -342
  32. package/template/shared/skills/task-close/SKILL.md +16 -319
  33. package/template/shared/skills/task-dev/SKILL.md +19 -233
  34. package/template/shared/skills/task-init/SKILL.md +11 -68
  35. package/template/shared/skills/task-orche/SKILL.md +35 -0
  36. package/template/shared/skills/task-plan/SKILL.md +19 -498
  37. package/template/shared/skills/task-test/SKILL.md +23 -342
  38. package/bin/close.js +0 -86
  39. package/bin/fork.js +0 -112
  40. package/bin/migrate.js +0 -344
  41. package/bin/set-status.js +0 -98
  42. package/template/.claude/hooks/closed-immutable.sh +0 -77
  43. package/template/.claude/settings.json +0 -18
  44. package/template/.claude/skills/run-team/SKILL.md +0 -145
  45. package/template/.codex/agents/task-tester.toml +0 -41
  46. package/template/.codex/agents/test-plan-author.toml +0 -34
  47. package/template/.codex/config.toml +0 -17
  48. package/template/.codex/hooks/closed-immutable.sh +0 -66
  49. package/template/.project/changelog/.gitkeep +0 -0
  50. package/template/.project/flows/.gitkeep +0 -0
  51. package/template/.project/plans/.gitkeep +0 -0
  52. package/template/.project/rules/CHANGELOG_RULE.md +0 -97
  53. package/template/.project/rules/GIT_RULE.md +0 -325
  54. package/template/.project/rules/MOCKUP_RULE.md +0 -86
  55. package/template/.project/rules/TASKERY_RULE.md +0 -237
  56. package/template/.project/rules/TASK_DOC_RULE.md +0 -668
  57. package/template/.project/shared/received/completed/.gitkeep +0 -0
  58. package/template/.project/shared/sent/completed/.gitkeep +0 -0
  59. package/template/.project/tasks/.gitkeep +0 -0
  60. package/template/shared/hooks/git-guard.sh +0 -108
package/bin/migrate.js DELETED
@@ -1,344 +0,0 @@
1
- // 0.7.0 문서 체계 재편 마이그레이션 — update가 기존 설치본을 새 구조로 이행시킨다
2
- /**
3
- * bin/migrate.js
4
- * `npx @angar2/taskery update`가 호출하는 0.7.0 이행 로직 모음.
5
- *
6
- * 이행 4종:
7
- * 1. ingestActivePlan — 구형 AGENT-GUIDE.md '## 활성 plan 버전' → manifest.activePlan (1회)
8
- * 2. migrateEntryDocs — 진입 문서 재편. 기존 CLAUDE.md/AGENTS.md의 *리포 값* 4섹션을 추출해
9
- * 새 AGENTS.md에 주입하고, CLAUDE.md는 `@AGENTS.md` 한 줄로 축약.
10
- * **update 본 루프보다 먼저 실행하고, 처리한 경로를 본 루프에서 제외한다.**
11
- * 본 루프에 맡기면 '사용자 customize 검출' 프롬프트가 뜨는데,
12
- * 덮으면 리포 값이 .bak에만 남고 안 덮으면 구판 전문이 남아 새 판과 충돌한다.
13
- * 3. migrateGitignore — AGENTS.md가 agnostic으로 바뀌어 모든 리포에 새로 깔리는데
14
- * .gitignore 기록은 init에만 있어 누락된다. 누락 패턴 append 제안.
15
- * 4. migrateTestGuide — 폐지되는 TEST-GUIDE.md의 축적 내용을 TEST_RULE.local.md로 1회 이관.
16
- *
17
- * 원칙: 사용자 값은 절대 유실시키지 않는다. 추출 실패 시 어느 파일도 덮지 않고 `.new`로 보류한다.
18
- */
19
-
20
- const fs = require('fs');
21
- const path = require('path');
22
-
23
- const { sha256, copyFile, gitignorePatternsFor } = require('./lib');
24
-
25
- // 진입 문서에서 이식할 *리포 값* 섹션. 헤딩 텍스트 접두로 매칭한다 —
26
- // 실제 문서의 헤딩은 '## 검수 실행 명령 (해당 시 — 앱 실행·검수가 필요한 프로젝트)'처럼
27
- // 뒤에 부가 텍스트가 붙는다. 완전 일치를 요구하면 못 찾는다.
28
- const REPO_VALUE_SECTIONS = ['프로젝트 메타', '검증 명령', '테스트 명령', '검수 실행 명령'];
29
- // 이 중 없어도 이행을 계속하는 항목 — 앱 검수가 없는 프로젝트는 이 절을 지웠을 수 있다.
30
- const OPTIONAL_SECTIONS = new Set(['검수 실행 명령']);
31
- // 진입 문서 이행 완료 마커 — manifest에 기록해 재실행을 막는다.
32
- const ENTRY_DOCS_MARKER = '0.7.0';
33
-
34
- function isBefore070(version) {
35
- if (typeof version !== 'string') return true;
36
- const [maj = 0, min = 0] = version.split('.').map((n) => parseInt(n, 10) || 0);
37
- return maj < 1 && min < 7;
38
- }
39
-
40
- // 마크다운을 '## ' 헤딩 단위로 쪼개 { key, heading, body } 목록으로 만든다.
41
- // 코드펜스 내부는 헤딩으로 보지 않는다 — 셸 주석(`## ...`)이 든 예시 블록을 헤딩으로 오인하면
42
- // 그 뒤 본문이 조용히 잘려 사용자 값이 유실된다(경고도 안 뜬다).
43
- function splitSections(content) {
44
- const lines = content.split('\n');
45
- const out = [];
46
- let cur = null;
47
- let inFence = false;
48
- for (const line of lines) {
49
- if (/^\s*(```|~~~)/.test(line)) inFence = !inFence;
50
- const m = inFence ? null : line.match(/^##\s+(.*)$/);
51
- if (m) {
52
- if (cur) out.push(cur);
53
- cur = { heading: line, title: m[1].trim(), body: [] };
54
- } else if (cur) {
55
- cur.body.push(line);
56
- }
57
- }
58
- if (cur) out.push(cur);
59
- return out;
60
- }
61
-
62
- function findSectionBody(sections, key) {
63
- const hit = sections.find((s) => s.title.startsWith(key));
64
- if (!hit) return null;
65
- // 뒤쪽 빈 줄과 구분선(---)은 새 템플릿 쪽 서식을 쓰므로 떼어낸다.
66
- const body = hit.body.slice();
67
- while (body.length && (body[body.length - 1].trim() === '' || body[body.length - 1].trim() === '---')) {
68
- body.pop();
69
- }
70
- while (body.length && body[0].trim() === '') body.shift();
71
- return body.join('\n');
72
- }
73
-
74
- // 새 AGENTS.md 템플릿의 해당 섹션 본문을 추출한 값으로 갈아끼운다.
75
- function injectSections(templateContent, extracted) {
76
- const lines = templateContent.split('\n');
77
- const heads = new Set();
78
- let fence = false;
79
- lines.forEach((l, idx) => {
80
- if (/^\s*(```|~~~)/.test(l)) fence = !fence;
81
- else if (!fence && /^##\s+/.test(l)) heads.add(idx);
82
- });
83
- const out = [];
84
- let i = 0;
85
- while (i < lines.length) {
86
- const m = heads.has(i) ? lines[i].match(/^##\s+(.*)$/) : null;
87
- const key = m && REPO_VALUE_SECTIONS.find((k) => m[1].trim().startsWith(k));
88
- if (!key || extracted[key] == null) {
89
- out.push(lines[i]);
90
- i++;
91
- continue;
92
- }
93
- out.push(lines[i]); // 헤딩은 새 템플릿 것을 유지
94
- i++;
95
- // 기존 본문(다음 '## ' 직전까지)을 건너뛰면서, 끝의 빈 줄/구분선은 보존한다.
96
- const skipped = [];
97
- while (i < lines.length && !heads.has(i)) {
98
- skipped.push(lines[i]);
99
- i++;
100
- }
101
- const tail = [];
102
- while (skipped.length && (skipped[skipped.length - 1].trim() === '' || skipped[skipped.length - 1].trim() === '---')) {
103
- tail.unshift(skipped.pop());
104
- }
105
- out.push('', extracted[key], ...tail);
106
- }
107
- return out.join('\n');
108
- }
109
-
110
- /**
111
- * 1. 활성 plan 이식 — manifest.activePlan이 이미 있으면 그것을 그대로 보존한다.
112
- * (update가 manifest를 새로 조립하므로 보존을 명시하지 않으면 매번 증발한다.)
113
- */
114
- function ingestActivePlan(cwd, oldManifest) {
115
- if (oldManifest.activePlan) return { activePlan: oldManifest.activePlan, ingested: false };
116
- const guidePath = path.join(cwd, '.project', 'AGENT-GUIDE.md');
117
- if (!fs.existsSync(guidePath)) return { activePlan: null, ingested: false };
118
- const sec = fs.readFileSync(guidePath, 'utf8').match(/^##\s*활성 plan 버전[^\n]*\n+([^\n]+)/m);
119
- if (!sec) return { activePlan: null, ingested: false };
120
- const firstLine = sec[1].trim();
121
- if (!firstLine || firstLine.startsWith('<')) return { activePlan: null, ingested: false };
122
- return { activePlan: firstLine.split(/\s+/)[0], ingested: true };
123
- }
124
-
125
- /**
126
- * 2. 진입 문서 재편.
127
- * @returns {{ handled: string[], entries: object, notes: string[], held: boolean }}
128
- * handled — update 본 루프에서 제외할 설치 경로
129
- * entries — manifest.files에 기록할 항목 (설치 경로 → { hash, core, managed })
130
- */
131
- function migrateEntryDocs(cwd, templateDir, platforms, oldManifest) {
132
- const notes = [];
133
- const entries = {};
134
- const skip = { handled: [], entries, notes, held: false, done: false };
135
-
136
- const agentsPath = path.join(cwd, 'AGENTS.md');
137
- const claudePath = path.join(cwd, 'CLAUDE.md');
138
- const hasAgents = fs.existsSync(agentsPath);
139
- const hasClaude = fs.existsSync(claudePath);
140
- if (!hasAgents && !hasClaude) return skip;
141
-
142
- // 이미 이행된 리포는 건너뛴다. 판정은 manifest 마커(ENTRY_DOCS_MARKER) 하나로만 한다.
143
- // - 파일 구조로 추정하면 플랫폼별로 오판한다: codex 단독은 CLAUDE.md가 없어 "이행됨"으로 읽혀
144
- // 구판 AGENTS.md가 본 루프의 customize 프롬프트로 넘어가고 리포 값이 .bak에만 남았다.
145
- // - 버전으로도 거르지 않는다: update는 이행이 보류(held)돼도 manifest 버전을 올리므로
146
- // 버전 게이트를 두면 보류된 리포가 영영 재시도되지 않는다("다음 update가 다시 시도합니다"가 거짓이 된다).
147
- // - 신규 설치는 init이 마커를 박아 두므로 이 한 줄로 걸러진다.
148
- if (oldManifest.entryDocs === ENTRY_DOCS_MARKER) return skip;
149
-
150
- // 소스 선정: AGENTS.md 우선(양쪽은 미러라 값이 같다), 없으면 CLAUDE.md.
151
- const srcPath = hasAgents ? agentsPath : claudePath;
152
- const srcName = hasAgents ? 'AGENTS.md' : 'CLAUDE.md';
153
- const sections = splitSections(fs.readFileSync(srcPath, 'utf8'));
154
-
155
- const extracted = {};
156
- const missing = [];
157
- for (const key of REPO_VALUE_SECTIONS) {
158
- const body = findSectionBody(sections, key);
159
- if (body == null) missing.push(key);
160
- else extracted[key] = body;
161
- }
162
- const fatalMissing = missing.filter((k) => !OPTIONAL_SECTIONS.has(k));
163
-
164
- const newAgentsSrc = path.join(templateDir, 'AGENTS.md');
165
- const newAgents = fs.readFileSync(newAgentsSrc, 'utf8');
166
-
167
- if (fatalMissing.length > 0) {
168
- // 값 유실 방지 — 어느 파일도 덮지 않고 새 판을 .new로 남긴다. manifest도 갱신하지 않아
169
- // 다음 update가 다시 시도한다.
170
- fs.writeFileSync(path.join(cwd, 'AGENTS.md.new'), newAgents);
171
- notes.push(
172
- `⚠ 진입 문서 이행 보류 — ${srcName}에서 필수 섹션을 찾지 못함 (${fatalMissing.join(', ')}).`,
173
- ` 새 판을 AGENTS.md.new 로 두었습니다. 리포 값을 직접 옮긴 후 AGENTS.md 로 바꿔주세요.`,
174
- ` (기존 파일은 그대로 보존되었고 manifest도 갱신하지 않아 다음 update가 다시 시도합니다.)`,
175
- );
176
- return { handled: ['AGENTS.md', 'CLAUDE.md'], entries, notes, held: true, done: false };
177
- }
178
-
179
- // AGENTS.md 생성 — 새 템플릿에 리포 값 주입.
180
- // .bak은 *최초 1회만* 남긴다 — 이미 있으면 그게 사용자의 원본이다. 덮으면 원본이 사라진다.
181
- if (fs.existsSync(agentsPath) && !fs.existsSync(agentsPath + '.bak')) {
182
- fs.copyFileSync(agentsPath, agentsPath + '.bak');
183
- }
184
- fs.writeFileSync(agentsPath, injectSections(newAgents, extracted));
185
- entries['AGENTS.md'] = { hash: sha256(newAgentsSrc), core: true, managed: true };
186
-
187
- const injectedCount = REPO_VALUE_SECTIONS.length - missing.length;
188
- notes.push(`진입 문서 재편 — ${srcName}에서 리포 값 ${injectedCount}개 섹션을 AGENTS.md로 이식.`);
189
- if (missing.length > 0) {
190
- notes.push(` (없어서 새 템플릿 기본값으로 둔 절: ${missing.join(', ')})`);
191
- }
192
-
193
- const handled = ['AGENTS.md'];
194
-
195
- // CLAUDE.md는 `@AGENTS.md` 한 줄로 축약 (claude 플랫폼일 때만).
196
- if (platforms.includes('claude')) {
197
- const claudeSrc = path.join(templateDir, 'CLAUDE.md');
198
- if (hasClaude && !fs.existsSync(claudePath + '.bak')) {
199
- fs.copyFileSync(claudePath, claudePath + '.bak');
200
- notes.push(` 기존 CLAUDE.md는 CLAUDE.md.bak 으로 백업.`);
201
- }
202
- copyFile(claudeSrc, claudePath);
203
- entries['CLAUDE.md'] = { hash: sha256(claudeSrc), core: true, managed: true };
204
- handled.push('CLAUDE.md');
205
- notes.push(` CLAUDE.md는 '@AGENTS.md' 임포트 한 줄로 축약.`);
206
- }
207
-
208
- return { handled, entries, notes, held: false, done: true };
209
- }
210
-
211
- /**
212
- * 3. .gitignore 누락 패턴 — 이미 taskery 패턴을 쓰는 리포에만 제안한다.
213
- * (init에서 등록을 거부한 리포는 패턴이 하나도 없으므로 건드리지 않는다.)
214
- * @returns {{ missing: string[], apply: function }} — missing이 비면 제안 불필요
215
- */
216
- function planGitignore(cwd, platforms) {
217
- const gitignorePath = path.join(cwd, '.gitignore');
218
- if (!fs.existsSync(gitignorePath)) return { missing: [] };
219
- const wanted = gitignorePatternsFor(platforms);
220
- const existing = new Set(
221
- fs.readFileSync(gitignorePath, 'utf8').split('\n').map((l) => l.trim()),
222
- );
223
- const already = wanted.filter((p) => existing.has(p));
224
- if (already.length === 0) return { missing: [] }; // taskery 패턴 미사용 리포 — 선택 존중
225
- const missing = wanted.filter((p) => !existing.has(p));
226
- return {
227
- missing,
228
- apply() {
229
- fs.appendFileSync(gitignorePath, `\n${missing.join('\n')}\n`);
230
- },
231
- };
232
- }
233
-
234
- // TEST_RULE.local.md 필수 절 골격(TASKERY_RULE §8) — 이관 본문에 없으면 append한다.
235
- // 문안은 project-init 골격과 동일 유지. '방식별 실행 경로'는 이관 본문 자체가 그 내용이라 제외.
236
- const TEST_RULE_REQUIRED_SECTIONS = [
237
- {
238
- key: '테스트 실행 환경',
239
- block: [
240
- '## 테스트 실행 환경 (격리 실행 경로)',
241
- '',
242
- '> 실제 앱을 띄워 UI를 자동 조작하는 스위트를 **사용자의 화면·마우스·키보드를 뺏지 않고** 돌리는 경로.',
243
- '> 여기가 비어 있으면 자동 게이트·격리 세션은 그 스위트를 실행하지 않고 사용자 승인을 받는다.',
244
- '> 적을 수 있는 것: 창 숨김·헤드리스 플래그 / 가상 디스플레이·컨테이너 / VM / CI 러너 위임 / 점유 스위트를 제외하는 한정 실행 옵션.',
245
- '> 결정 자체도 기록 대상 — 격리 경로를 두지 않기로 했다면 *"없음 — 매번 승인(사용자 결정 YYYY-MM-DD)"*로 적어 미결정 상태와 구분한다.',
246
- '',
247
- '<아직 미정 — 여기에 이 리포의 격리 실행 경로를 채운다>',
248
- ].join('\n'),
249
- },
250
- {
251
- key: '범위·방식 정책',
252
- block: [
253
- '## 범위·방식 정책',
254
- '',
255
- '> 무엇을 얼마나 검증하나 — 수정 루프에서의 실행 범위, 테스트 신설 상한 등.',
256
- '',
257
- '<아직 미정 — 여기에 이 리포의 범위 정책을 채운다>',
258
- ].join('\n'),
259
- },
260
- ];
261
-
262
- /**
263
- * 4. TEST-GUIDE.md → TEST_RULE.local.md 이관.
264
- * 폐지되는 문서라 그대로 두면 새 참조처들이 읽지 않아 축적 내용이 고아가 된다.
265
- * 원본은 지우지 않는다(사용자 파일 불가침).
266
- * @returns {{ available: boolean, apply: function }} — apply()는 append한 필수 절 key 목록을 반환
267
- */
268
- function planTestGuide(cwd) {
269
- const guidePath = path.join(cwd, '.project', 'TEST-GUIDE.md');
270
- const localPath = path.join(cwd, '.project', 'rules', 'TEST_RULE.local.md');
271
- if (!fs.existsSync(guidePath)) return { available: false };
272
- if (fs.existsSync(localPath)) {
273
- // 둘 다 있으면 자동 병합하지 않는다(사용자 파일 훼손 위험). 다만 침묵하면
274
- // TEST-GUIDE에 쌓인 내용이 아무도 안 읽는 채로 남으므로 반드시 알린다.
275
- return { available: false, conflict: true };
276
- }
277
- return {
278
- available: true,
279
- apply() {
280
- const body = fs.readFileSync(guidePath, 'utf8');
281
- // 머리말만 새 역할에 맞게 갈아끼우고 본문(축적된 실행 경로)은 그대로 옮긴다.
282
- const stripped = body.replace(/^#\s*TEST-GUIDE[^\n]*\n(>[^\n]*\n)*/, '');
283
- const header = [
284
- '# TEST_RULE.local.md — 이 리포의 검증 규칙',
285
- '',
286
- '> 이 프로젝트에서 각 테스트 방식을 *실제로 어떻게 실행하나* + *무엇을 얼마나 검증하나*의 단일 소스.',
287
- '> `.project/TEST-GUIDE.md`(0.7.0에서 폐지)의 내용을 이관한 문서다. 구성과 의무는 `TASKERY_RULE` §8 참조.',
288
- '> `*.local.md`는 taskery update가 건드리지 않는다 — 이 리포가 소유한다.',
289
- '',
290
- ].join('\n');
291
- // 필수 절 골격 보강 — TEST-GUIDE에는 없던 절(TASKERY_RULE §8)이라 이관만으로는 빠진다.
292
- // 헤딩은 접두 일치로 검사한다(완전 일치 요구 금지 — 뒤에 부가 텍스트가 붙는다).
293
- const titles = splitSections(stripped).map((s) => s.title);
294
- const appended = TEST_RULE_REQUIRED_SECTIONS.filter(
295
- (sec) => !titles.some((t) => t.startsWith(sec.key)),
296
- );
297
- const tail = appended.length
298
- ? `${stripped.endsWith('\n') ? '' : '\n'}\n${appended.map((s) => s.block).join('\n\n')}\n`
299
- : '';
300
- fs.mkdirSync(path.dirname(localPath), { recursive: true });
301
- fs.writeFileSync(localPath, header + stripped + tail);
302
- return appended.map((s) => s.key);
303
- },
304
- };
305
- }
306
-
307
- /**
308
- * 5. `.gitignore` 병합 — **덮어쓰기 금지.**
309
- * template/.gitignore는 manifest 관리 자산이라 update의 '신규' 분기가 그대로 복사해버리는데,
310
- * 실사용 리포는 대부분 .gitignore를 manifest에 안 들고 있다(init 이전부터 있던 파일).
311
- * 그대로 두면 사용자의 node_modules·빌드 산출물·프로젝트 고유 무시 규칙이 통째로 사라진다.
312
- * → 없으면 복사, 있으면 *빠진 줄만 덧붙인다*. 기존 줄은 절대 건드리지 않는다.
313
- * @returns {{ merged: boolean, added: string[] }}
314
- */
315
- function mergeGitignore(srcPath, dstPath) {
316
- if (!fs.existsSync(dstPath)) {
317
- copyFile(srcPath, dstPath);
318
- return { merged: false, added: [] };
319
- }
320
- const cur = fs.readFileSync(dstPath, 'utf8');
321
- const have = new Set(cur.split('\n').map((l) => l.trim()));
322
- const added = fs
323
- .readFileSync(srcPath, 'utf8')
324
- .split('\n')
325
- .map((l) => l.trim())
326
- .filter((l) => l && !l.startsWith('#') && !have.has(l));
327
- if (added.length === 0) return { merged: true, added: [] };
328
- const lead = cur.endsWith('\n') ? '' : '\n';
329
- fs.appendFileSync(dstPath, `${lead}\n# taskery 템플릿 추가분\n${added.join('\n')}\n`);
330
- return { merged: true, added };
331
- }
332
-
333
- module.exports = {
334
- ENTRY_DOCS_MARKER,
335
- mergeGitignore,
336
- isBefore070,
337
- splitSections,
338
- findSectionBody,
339
- injectSections,
340
- ingestActivePlan,
341
- migrateEntryDocs,
342
- planGitignore,
343
- planTestGuide,
344
- };
package/bin/set-status.js DELETED
@@ -1,98 +0,0 @@
1
- #!/usr/bin/env node
2
- // task 상태 전이 CLI — 7×7 유효전이 검증 후 헤더 상태 컬럼 치환 (잘못된 전이 거부)
3
- /**
4
- * bin/set-status.js
5
- * `npx @angar2/taskery set-status <TASK-NNN> <state>` — task 헤더 상태 전이.
6
- *
7
- * 현재 상태를 코드로 읽고 TASK_DOC_RULE §1.2 유효전이표로 검증 → 통과 시 헤더 치환.
8
- * 잘못된 전이(예: draft→closed)는 거부. 같은 상태 재지정은 idempotent no-op.
9
- * task 문서 위치는 resolveTaskDocPath가 .gitignore 케이스로 단일 판정.
10
- *
11
- * closed 전이는 본 CLI가 거부 — `/task-close`(close 파이프라인)가 내부 setStatus로 처리.
12
- * stdout: { taskNum, from, to, changed, path }. 실패 시 stderr + exit 1.
13
- */
14
-
15
- const path = require('path');
16
-
17
- const {
18
- MANIFEST_NAME,
19
- readManifest,
20
- getMainWorktreePath,
21
- gitCapture,
22
- resolveTaskDocPath,
23
- setStatus,
24
- TRANSITIONS,
25
- getActiveTasks,
26
- getWorktreePath,
27
- getProjectId,
28
- } = require('./lib');
29
-
30
- async function main() {
31
- const [taskArg, state] = process.argv.slice(2);
32
-
33
- if (!taskArg || !state) {
34
- console.error('사용법: npx @angar2/taskery set-status <TASK-NNN> <state>');
35
- console.error(`state: ${Object.keys(TRANSITIONS).join(' | ')}`);
36
- process.exit(1);
37
- }
38
- const m = String(taskArg).match(/(\d+)/);
39
- if (!m) {
40
- console.error(`TASK 번호 파싱 실패 (받음: ${taskArg}). 예: TASK-007`);
41
- process.exit(1);
42
- }
43
- const taskNum = parseInt(m[1], 10);
44
-
45
- if (!Object.prototype.hasOwnProperty.call(TRANSITIONS, state)) {
46
- console.error(`알 수 없는 상태: ${state} (유효: ${Object.keys(TRANSITIONS).join(', ')})`);
47
- process.exit(1);
48
- }
49
- if (state === 'closed') {
50
- console.error(`closed 전이는 set-status가 아니라 '/task-close'(close 파이프라인)가 담당.`);
51
- process.exit(1);
52
- }
53
-
54
- let mainWt;
55
- try {
56
- mainWt = getMainWorktreePath();
57
- } catch (e) {
58
- console.error(`taskery set-status 실패: ${e.message}`);
59
- process.exit(1);
60
- }
61
- if (!readManifest(path.join(mainWt, MANIFEST_NAME))) {
62
- console.error(`${MANIFEST_NAME} 없음 — 'npx @angar2/taskery init' 먼저 실행 필요.`);
63
- process.exit(1);
64
- }
65
-
66
- // 현재 작업트리 toplevel = 미등록 케이스의 문서 base.
67
- const wtPath = gitCapture(process.cwd(), ['rev-parse', '--show-toplevel'], { allowFail: true }) || mainWt;
68
-
69
- let resolved = resolveTaskDocPath(mainWt, wtPath, { taskNum });
70
-
71
- // 미등록 케이스에서 메인 워크트리(또는 다른 task의 워크트리)에서 호출하면 문서가 그 트리에 없다.
72
- // 호출 위치를 강요하는 대신 SSoT에서 해당 task의 워크트리를 역조회해 한 번 더 찾는다
73
- // — 스킬이 "호출 위치 자유"를 전제하므로 CLI가 그 약속을 지켜야 한다.
74
- if (!resolved) {
75
- const hit = getActiveTasks(mainWt).find((t) => t.taskNum === taskNum);
76
- if (hit) {
77
- const owner = getWorktreePath(getProjectId(mainWt), hit);
78
- if (owner !== wtPath) resolved = resolveTaskDocPath(mainWt, owner, { taskNum });
79
- }
80
- }
81
-
82
- if (!resolved) {
83
- console.error(
84
- `TASK-${String(taskNum).padStart(3, '0')} 문서를 찾지 못함 (.project/tasks/*/ 아래 단일 파일/폴더 승격 모두 탐색 + 진행중 task 워크트리 역조회).`,
85
- );
86
- process.exit(1);
87
- }
88
-
89
- try {
90
- const result = await setStatus(resolved.file, state);
91
- process.stdout.write(JSON.stringify({ taskNum, ...result }) + '\n');
92
- } catch (e) {
93
- console.error(`taskery set-status 실패: ${e.message}`);
94
- process.exit(1);
95
- }
96
- }
97
-
98
- main();
@@ -1,77 +0,0 @@
1
- #!/bin/bash
2
- # .claude/hooks/closed-immutable.sh
3
- # PreToolUse(Write|Edit) 훅 — closed 상태 task.md 재수정 차단
4
- #
5
- # 잡는 것 (단순화 — task.md 본 파일만 보호):
6
- # 1. .project/tasks/<NNN_slug>/<NNN>_<slug>.md (단일 파일)
7
- # 2. .project/tasks/<NNN_slug>/TASK-<NNN>_<slug>/task.md (폴더 승격)
8
- # → 헤더 status가 'closed'면 차단
9
- #
10
- # 차단 안 함 (의도적):
11
- # - .project/tasks/<NNN_slug>/spec-diffs/*.md — 역사적 자료, 자유 수정
12
- # - .project/tasks/<NNN_slug>/screenshots/* — 자유
13
- # - .project/tasks/<NNN_slug>/mockup/*.html — UX/UI 목업, 자유 수정 (<NNN_slug> 공통)
14
- # - 폴더 승격 task 폴더 안 추가 자료 (서브 문서 등) — 자유
15
- # - 코드 영역 — closed task 코드 재수정은 *새 task로 처리*가 정상 흐름
16
- #
17
- # 우회 (의도적으로 closed task를 풀어 다시 진행해야 하는 경우):
18
- # - 헤더 status를 closed → developing(또는 다른 상태)으로 직접 sed로 변경 (Bash 영역)
19
- # - 그 후 정상 Edit 가능
20
- #
21
- # Catastrophic 영역 (완료 보호). 잘 지키면 작동 0회 (무해).
22
-
23
- INPUT=$(cat)
24
-
25
- # tool_input.file_path 추출
26
- FILE_PATH=$(echo "$INPUT" | python3 -c "
27
- import sys, json
28
- try:
29
- d = json.load(sys.stdin)
30
- print(d.get('tool_input', {}).get('file_path', ''))
31
- except Exception:
32
- print('')
33
- " 2>/dev/null)
34
-
35
- # task.md 본 파일 매칭 (단일 + 폴더 승격)
36
- # - 단일: .project/tasks/<NNN_slug>/<NNN>_<slug>.md (NNN_<slug> 패턴 — plan 폴더 직속, spec-diffs/screenshots/mockup 하위 X)
37
- # - 폴더: .project/tasks/<NNN_slug>/TASK-<NNN>_<slug>/task.md
38
- # plan 폴더명은 한 세그먼트([^/]+)면 무엇이든 매칭 (NNN_slug 신규 / 구버전 vX.X 모두). spec-diffs/screenshots/mockup 직속 파일은 추가 세그먼트라 매칭 안 됨 (보호 대상 X)
39
- if ! echo "$FILE_PATH" | grep -qE '\.project/tasks/[^/]+/([0-9]+_[^/]+\.md|TASK-[^/]+/task\.md)$'; then
40
- exit 0
41
- fi
42
-
43
- # task.md = file_path 자체
44
- TASK_MD="$FILE_PATH"
45
-
46
- # task.md 존재 안 하면 통과 (신규 생성 케이스)
47
- if [ ! -f "$TASK_MD" ]; then
48
- exit 0
49
- fi
50
-
51
- # 헤더 status 추출 — status는 헤더 표의 *마지막* 셀.
52
- # 5컬럼(구): 생성일 / 플랜 / 유형 / 규모 / 상태
53
- # 6컬럼(0.6.0+): 생성일 / 플랜 / 유형 / 규모 / 부모 브랜치 / 상태
54
- # python3 정규식 사용 (mac BSD grep -P 미지원)
55
- STATUS=$(TM="$TASK_MD" python3 -c "
56
- import os, re
57
- try:
58
- content = open(os.environ['TM']).read()
59
- except Exception:
60
- print('')
61
- raise SystemExit(0)
62
- # 7-state whitelist
63
- WL = r'(draft|planned|developing|developed|testing|tested|closed)'
64
- # status = 마지막 셀. 선행 셀 4개(구 5컬럼) 또는 5개(신 6컬럼) 뒤에 온다.
65
- m = re.search(rf'^\|(?:[^|]*\|){{4,5}}\s*{WL}\s*\|\s*\$', content, re.MULTILINE)
66
- print(m.group(1) if m else '')
67
- " 2>/dev/null)
68
-
69
- # closed 상태면 차단
70
- if [ "$STATUS" = "closed" ]; then
71
- echo "closed-immutable: '$FILE_PATH' 차단. task '$TASK_MD' 상태가 'closed' — 재수정 금지." >&2
72
- echo " 의도적 재진입 필요 시: sed로 헤더 status를 'developing' 또는 다른 상태로 직접 변경 후 Edit 가능." >&2
73
- echo " *closed task 코드 / spec-diffs / screenshots / mockup*은 보호 영역 외 — 자유 수정. 단 코드 변경은 *새 task로 처리*가 정상 흐름." >&2
74
- exit 2
75
- fi
76
-
77
- exit 0
@@ -1,18 +0,0 @@
1
- {
2
- "hooks": {
3
- "PreToolUse": [
4
- {
5
- "matcher": "Bash",
6
- "hooks": [
7
- { "type": "command", "command": ".claude/hooks/git-guard.sh" }
8
- ]
9
- },
10
- {
11
- "matcher": "Write|Edit",
12
- "hooks": [
13
- { "type": "command", "command": ".claude/hooks/closed-immutable.sh" }
14
- ]
15
- }
16
- ]
17
- }
18
- }