@angar2/taskery 0.6.1 → 0.8.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 +11 -9
- package/bin/close.js +13 -1
- package/bin/init.js +7 -2
- package/bin/lib.js +238 -52
- package/bin/mcp.js +2 -2
- package/bin/migrate.js +344 -0
- package/bin/plan.js +61 -21
- package/bin/set-status.js +18 -3
- package/bin/status.js +12 -0
- package/bin/taskery.js +7 -2
- package/bin/update.js +75 -0
- package/package.json +1 -1
- package/template/.claude/skills/run-team/SKILL.md +4 -4
- package/template/.codex/agents/task-tester.toml +14 -8
- package/template/.codex/agents/test-plan-author.toml +34 -0
- package/template/.project/rules/CHANGELOG_RULE.md +27 -21
- package/template/.project/rules/GIT_RULE.md +7 -4
- package/template/.project/rules/MOCKUP_RULE.md +1 -1
- package/template/.project/rules/TASKERY_RULE.md +237 -0
- package/template/.project/rules/TASK_DOC_RULE.md +14 -3
- package/template/AGENTS.md +26 -179
- package/template/CLAUDE.md +1 -208
- package/template/shared/skills/log-friction/SKILL.md +21 -1
- package/template/shared/skills/plan-init/SKILL.md +8 -8
- package/template/shared/skills/project-init/SKILL.md +80 -43
- package/template/shared/skills/task-close/SKILL.md +24 -14
- package/template/shared/skills/task-dev/SKILL.md +17 -16
- package/template/shared/skills/task-init/SKILL.md +3 -3
- package/template/shared/skills/task-plan/SKILL.md +134 -19
- package/template/shared/skills/task-test/SKILL.md +39 -22
- package/template/.project/FRICTION_LOG.md +0 -16
- package/template/.project/TEST-GUIDE.md +0 -62
package/bin/migrate.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
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/plan.js
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// plan(기능 그룹)
|
|
2
|
+
// plan(기능 그룹) 생성·전환 CLI — 채번+폴더+ROADMAP/PLAN/BACKLOG 골격+활성plan 갱신 원자 실행
|
|
3
3
|
/**
|
|
4
4
|
* bin/plan.js
|
|
5
|
-
* `npx @angar2/taskery plan-init <slug> [--force]`
|
|
5
|
+
* `npx @angar2/taskery plan-init <slug> [--force]` — plan 생성.
|
|
6
|
+
* `npx @angar2/taskery plan-switch <NNN_slug>` — 활성 plan 전환.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
* + ROADMAP/PLAN/BACKLOG 골격 Write + AGENT-GUIDE 활성 plan 갱신을 코드가 수행.
|
|
9
|
-
* 성공 시 JSON 한 줄 — { plan, nnn, planDir, tasksDir }. 이후 ROADMAP Stage 내용·PLAN 링크·
|
|
10
|
-
* FEATURES/UX-UI delta는 LLM(plan-init 스킬)이 골격 placeholder를 채운다.
|
|
8
|
+
* dispatcher가 op('init' | 'switch')를 선행 인자로 주입한다 (backlog.js와 동일 패턴).
|
|
11
9
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* init: 채번(NNN) + legacy 게이트 + 폴더 mkdir(plans/<NNN_slug> + tasks/<NNN_slug>/{spec-diffs,screenshots,mockup})
|
|
11
|
+
* + ROADMAP/PLAN/BACKLOG 골격 Write + manifest.activePlan 갱신을 코드가 수행.
|
|
12
|
+
* 성공 시 JSON 한 줄 — { plan, nnn, planDir, tasksDir }. 이후 ROADMAP Stage 내용·PLAN 링크·
|
|
13
|
+
* FEATURES/UX-UI delta는 LLM(plan-init 스킬)이 골격 placeholder를 채운다.
|
|
14
|
+
* legacy 폴더(NNN_ 아닌 plan 폴더) 잔존 + --force 없음 → exit 2 + { gated:true, legacyDirs }
|
|
15
|
+
* (스킬이 사용자 confirm 후 --force로 재호출).
|
|
16
|
+
*
|
|
17
|
+
* switch: 대상 plan 폴더 실재 검증 후 manifest.activePlan 갱신. 성공 시 JSON — { plan, planDir }.
|
|
18
|
+
*
|
|
19
|
+
* 그 외 실패 시 stderr + exit 1.
|
|
14
20
|
*/
|
|
15
21
|
|
|
16
22
|
const path = require('path');
|
|
@@ -20,10 +26,25 @@ const {
|
|
|
20
26
|
readManifest,
|
|
21
27
|
getMainWorktreePath,
|
|
22
28
|
initPlan,
|
|
29
|
+
setActivePlan,
|
|
23
30
|
} = require('./lib');
|
|
24
31
|
|
|
25
|
-
function
|
|
26
|
-
|
|
32
|
+
function resolveMainWt() {
|
|
33
|
+
let mainWt;
|
|
34
|
+
try {
|
|
35
|
+
mainWt = getMainWorktreePath();
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.error(`taskery plan 실패: ${e.message}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
if (!readManifest(path.join(mainWt, MANIFEST_NAME))) {
|
|
41
|
+
console.error(`${MANIFEST_NAME} 없음 — 'npx @angar2/taskery init' 먼저 실행 필요.`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
return mainWt;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function runInit(argv) {
|
|
27
48
|
const positional = [];
|
|
28
49
|
let force = false;
|
|
29
50
|
for (const a of argv) {
|
|
@@ -42,17 +63,7 @@ function main() {
|
|
|
42
63
|
process.exit(1);
|
|
43
64
|
}
|
|
44
65
|
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
mainWt = getMainWorktreePath();
|
|
48
|
-
} catch (e) {
|
|
49
|
-
console.error(`taskery plan-init 실패: ${e.message}`);
|
|
50
|
-
process.exit(1);
|
|
51
|
-
}
|
|
52
|
-
if (!readManifest(path.join(mainWt, MANIFEST_NAME))) {
|
|
53
|
-
console.error(`${MANIFEST_NAME} 없음 — 'npx @angar2/taskery init' 먼저 실행 필요.`);
|
|
54
|
-
process.exit(1);
|
|
55
|
-
}
|
|
66
|
+
const mainWt = resolveMainWt();
|
|
56
67
|
|
|
57
68
|
let result;
|
|
58
69
|
try {
|
|
@@ -82,4 +93,33 @@ function main() {
|
|
|
82
93
|
);
|
|
83
94
|
}
|
|
84
95
|
|
|
96
|
+
function runSwitch(argv) {
|
|
97
|
+
const plan = argv.filter((a) => !a.startsWith('--'))[0];
|
|
98
|
+
if (!plan) {
|
|
99
|
+
console.error('사용법: npx @angar2/taskery plan-switch <NNN_slug>');
|
|
100
|
+
console.error('예: npx @angar2/taskery plan-switch 002_compare-products');
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const mainWt = resolveMainWt();
|
|
105
|
+
|
|
106
|
+
let result;
|
|
107
|
+
try {
|
|
108
|
+
result = setActivePlan(mainWt, plan);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
console.error(`taskery plan-switch 실패: ${e.message}`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
process.stdout.write(JSON.stringify({ plan: result.plan, planDir: result.planDir }) + '\n');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function main() {
|
|
118
|
+
const [op, ...rest] = process.argv.slice(2);
|
|
119
|
+
if (op === 'init') return runInit(rest);
|
|
120
|
+
if (op === 'switch') return runSwitch(rest);
|
|
121
|
+
console.error(`taskery plan: 알 수 없는 op '${op}' (init | switch)`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
85
125
|
main();
|
package/bin/set-status.js
CHANGED
|
@@ -22,6 +22,9 @@ const {
|
|
|
22
22
|
resolveTaskDocPath,
|
|
23
23
|
setStatus,
|
|
24
24
|
TRANSITIONS,
|
|
25
|
+
getActiveTasks,
|
|
26
|
+
getWorktreePath,
|
|
27
|
+
getProjectId,
|
|
25
28
|
} = require('./lib');
|
|
26
29
|
|
|
27
30
|
async function main() {
|
|
@@ -60,13 +63,25 @@ async function main() {
|
|
|
60
63
|
process.exit(1);
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
// 현재 작업트리 toplevel = 미등록 케이스의 문서 base
|
|
66
|
+
// 현재 작업트리 toplevel = 미등록 케이스의 문서 base.
|
|
64
67
|
const wtPath = gitCapture(process.cwd(), ['rev-parse', '--show-toplevel'], { allowFail: true }) || mainWt;
|
|
65
68
|
|
|
66
|
-
|
|
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
|
+
|
|
67
82
|
if (!resolved) {
|
|
68
83
|
console.error(
|
|
69
|
-
`TASK-${String(taskNum).padStart(3, '0')} 문서를 찾지 못함 (.project/tasks/*/ 아래 단일 파일/폴더 승격 모두 탐색).`,
|
|
84
|
+
`TASK-${String(taskNum).padStart(3, '0')} 문서를 찾지 못함 (.project/tasks/*/ 아래 단일 파일/폴더 승격 모두 탐색 + 진행중 task 워크트리 역조회).`,
|
|
70
85
|
);
|
|
71
86
|
process.exit(1);
|
|
72
87
|
}
|
package/bin/status.js
CHANGED
|
@@ -23,6 +23,7 @@ const {
|
|
|
23
23
|
gitCapture,
|
|
24
24
|
getMainWorktreePath,
|
|
25
25
|
getActiveTasks,
|
|
26
|
+
getActiveVersion,
|
|
26
27
|
getWorktreesRoot,
|
|
27
28
|
getMergeLockPath,
|
|
28
29
|
DEFAULT_STALE_DAYS,
|
|
@@ -65,6 +66,17 @@ function main() {
|
|
|
65
66
|
`현재 브랜치(= 새 태스크 부모): ${currentBranch}${detached ? ' ⚠ (detached HEAD — 브랜치 체크아웃 필요)' : ''}`,
|
|
66
67
|
);
|
|
67
68
|
console.log(`projectId: ${projectId}`);
|
|
69
|
+
// 활성 plan(0.7.0+ manifest SSoT) — 스킬·진입 문서가 "활성 plan은 status로 확인"이라
|
|
70
|
+
// 지시하므로 여기서 반드시 출력한다. 미설정이면 다음 행동까지 안내한다.
|
|
71
|
+
let activePlan = null;
|
|
72
|
+
try {
|
|
73
|
+
activePlan = getActiveVersion(mainWt);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
activePlan = null;
|
|
76
|
+
}
|
|
77
|
+
console.log(
|
|
78
|
+
`활성 plan: ${activePlan ?? '미설정 — /plan-init으로 생성하거나 plan-switch <NNN_slug>로 지정'}`,
|
|
79
|
+
);
|
|
68
80
|
console.log(`stale_days: ${staleDays} / lock_timeout_ms: ${lockTimeoutMs}\n`);
|
|
69
81
|
|
|
70
82
|
// 2. 머지 락 상태
|
package/bin/taskery.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* fork — task 분기 (채번+워크트리+브랜치 생성 init 락 원자 실행, 0.3.2+) + 골격 task.md 자동 생성(--size/--title)
|
|
13
13
|
* backlog-add/get/mark — 활성 plan BACKLOG.md 조작 (채번/조회/확인 마킹, 코드화)
|
|
14
14
|
* set-status — task 헤더 상태 전이 (7×7 유효전이 검증, 코드화)
|
|
15
|
-
* plan-init — plan 생성 (채번+폴더+ROADMAP/PLAN/BACKLOG 골격+
|
|
15
|
+
* plan-init — plan 생성 (채번+폴더+ROADMAP/PLAN/BACKLOG 골격+manifest.activePlan 갱신, 코드화)
|
|
16
|
+
* plan-switch — 활성 plan 전환 (manifest.activePlan 갱신, 0.7.0+)
|
|
16
17
|
* close — close 결정적 준비 (Phase커밋+status=closed+문서커밋+추적마커; 비가역 머지/정리는 스킬, 코드화)
|
|
17
18
|
* mcp — stdio MCP 서버 기동 (lib core를 도구 8종으로 노출; Claude/Codex 등록)
|
|
18
19
|
* help — 사용법 출력
|
|
@@ -47,6 +48,7 @@ function help() {
|
|
|
47
48
|
npx @angar2/taskery backlog-mark <BL-NNN> <TASK-NNN> 백로그 확인 마킹
|
|
48
49
|
npx @angar2/taskery set-status <TASK-NNN> <state> task 상태 전이 (유효전이 검증)
|
|
49
50
|
npx @angar2/taskery plan-init <slug> [--force] plan 생성 (채번+폴더+골격+활성plan 갱신)
|
|
51
|
+
npx @angar2/taskery plan-switch <NNN_slug> 활성 plan 전환
|
|
50
52
|
npx @angar2/taskery close <TASK-NNN> close 결정적 준비 (Phase커밋+status=closed+추적마커)
|
|
51
53
|
npx @angar2/taskery mcp MCP stdio 서버 기동 (Claude .mcp.json / Codex codex mcp add 등록)
|
|
52
54
|
npx @angar2/taskery help 도움말
|
|
@@ -105,7 +107,10 @@ switch (sub) {
|
|
|
105
107
|
runScript('set-status.js');
|
|
106
108
|
break;
|
|
107
109
|
case 'plan-init':
|
|
108
|
-
runScript('plan.js');
|
|
110
|
+
runScript('plan.js', ['init']);
|
|
111
|
+
break;
|
|
112
|
+
case 'plan-switch':
|
|
113
|
+
runScript('plan.js', ['switch']);
|
|
109
114
|
break;
|
|
110
115
|
case 'close':
|
|
111
116
|
runScript('close.js');
|
package/bin/update.js
CHANGED
|
@@ -38,6 +38,15 @@ const {
|
|
|
38
38
|
resolveInstallPlan,
|
|
39
39
|
} = require('./lib');
|
|
40
40
|
|
|
41
|
+
const {
|
|
42
|
+
ENTRY_DOCS_MARKER,
|
|
43
|
+
mergeGitignore,
|
|
44
|
+
ingestActivePlan,
|
|
45
|
+
migrateEntryDocs,
|
|
46
|
+
planGitignore,
|
|
47
|
+
planTestGuide,
|
|
48
|
+
} = require('./migrate');
|
|
49
|
+
|
|
41
50
|
async function confirm(msg) {
|
|
42
51
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
43
52
|
return new Promise((resolve) => {
|
|
@@ -82,8 +91,22 @@ async function main() {
|
|
|
82
91
|
const newFiles = {};
|
|
83
92
|
const summary = { unchanged: 0, new: 0, autoUpdated: 0, customizedReplaced: 0, skipped: 0, removed: 0 };
|
|
84
93
|
|
|
94
|
+
// 2.5. 0.7.0 진입 문서 재편 — **본 루프보다 먼저.**
|
|
95
|
+
// CLAUDE.md/AGENTS.md를 본 루프에 맡기면 '사용자 customize 검출' 프롬프트가 뜨는데,
|
|
96
|
+
// 덮으면 리포 값이 .bak에만 남고 안 덮으면 구판 전문이 새 판과 함께 남아 규칙이 충돌한다.
|
|
97
|
+
// 이행이 최종 상태까지 기록하고, 처리한 경로는 아래 루프에서 제외한다.
|
|
98
|
+
const entryMig = migrateEntryDocs(cwd, templateDir, platforms, oldManifest);
|
|
99
|
+
const entryHandled = new Set(entryMig.handled);
|
|
100
|
+
for (const line of entryMig.notes) console.log(` ${line}`);
|
|
101
|
+
Object.assign(newFiles, entryMig.entries);
|
|
102
|
+
|
|
85
103
|
// 3. 새 template 파일 분기 (rel = 설치 경로 installRel)
|
|
86
104
|
for (const [rel, { hash: newHash, templateRel }] of Object.entries(newTemplateFiles)) {
|
|
105
|
+
if (entryHandled.has(rel)) {
|
|
106
|
+
// 2.5에서 이행 완료(또는 보류) — 본 루프가 다시 손대면 이행 결과를 덮는다.
|
|
107
|
+
if (entryMig.held && oldFiles[rel]) newFiles[rel] = oldFiles[rel];
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
87
110
|
if (isLocalOverride(rel)) {
|
|
88
111
|
// template에 .local.md 들어가면 안 되지만 방어적 스킵
|
|
89
112
|
console.log(` skip (.local override 영역): ${rel}`);
|
|
@@ -95,6 +118,17 @@ async function main() {
|
|
|
95
118
|
const dst = path.join(cwd, rel);
|
|
96
119
|
const oldEntry = oldFiles[rel];
|
|
97
120
|
|
|
121
|
+
// .gitignore는 *절대 덮어쓰지 않는다* — 사용자 무시 규칙이 통째로 날아간다.
|
|
122
|
+
// 없으면 복사, 있으면 빠진 줄만 덧붙인다(migrate.mergeGitignore).
|
|
123
|
+
if (rel === '.gitignore') {
|
|
124
|
+
const r = mergeGitignore(src, dst);
|
|
125
|
+
newFiles[rel] = { hash: newHash, core: true, managed: true };
|
|
126
|
+
if (!r.merged) { summary.new++; console.log(` new: ${rel}`); }
|
|
127
|
+
else if (r.added.length) { summary.autoUpdated++; console.log(` merge: ${rel} — ${r.added.length}줄 추가 (기존 줄 보존)`); }
|
|
128
|
+
else { summary.unchanged++; }
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
98
132
|
// a. 신규 — manifest에 없음
|
|
99
133
|
if (!oldEntry) {
|
|
100
134
|
copyFile(src, dst);
|
|
@@ -173,6 +207,12 @@ async function main() {
|
|
|
173
207
|
: (migration.lock_timeout_ms = true, DEFAULT_LOCK_TIMEOUT_MS);
|
|
174
208
|
if (!Array.isArray(oldManifest.platforms)) migration.platforms = true;
|
|
175
209
|
|
|
210
|
+
// 활성 plan(0.7.0+) — 기존 값이 있으면 **반드시 그대로 보존**한다. newManifest는 아래에서
|
|
211
|
+
// 명시 필드 목록으로 새로 조립되므로, 여기서 넘기지 않으면 update 한 번에 활성 plan이
|
|
212
|
+
// 소리 없이 사라지고 구형 AGENT-GUIDE 폴백의 낡은 값으로 되돌아간다.
|
|
213
|
+
const { activePlan, ingested: activePlanIngested } = ingestActivePlan(cwd, oldManifest);
|
|
214
|
+
if (activePlanIngested) migration.activePlan = true;
|
|
215
|
+
|
|
176
216
|
const newManifest = {
|
|
177
217
|
version: getPackageVersion(),
|
|
178
218
|
installed_at: oldManifest.installed_at,
|
|
@@ -181,10 +221,45 @@ async function main() {
|
|
|
181
221
|
platforms,
|
|
182
222
|
stale_days,
|
|
183
223
|
lock_timeout_ms,
|
|
224
|
+
activePlan: activePlan ?? null,
|
|
225
|
+
// 진입 문서 이행 완료 마커 — 있으면 다음 update가 이행을 재실행하지 않는다.
|
|
226
|
+
entryDocs: entryMig.done ? ENTRY_DOCS_MARKER : (oldManifest.entryDocs ?? null),
|
|
184
227
|
files: newFiles,
|
|
185
228
|
};
|
|
186
229
|
writeManifest(newManifest, manifestPath);
|
|
187
230
|
|
|
231
|
+
// 5.5. 부수 이행 — .gitignore 누락 패턴 / TEST-GUIDE 이관. 둘 다 사용자 승인 후에만.
|
|
232
|
+
const gi = planGitignore(cwd, platforms);
|
|
233
|
+
if (gi.missing.length > 0) {
|
|
234
|
+
console.log(`\n taskery 자산 중 .gitignore에 없는 항목: ${gi.missing.join(', ')}`);
|
|
235
|
+
console.log(` (등록하지 않으면 이 파일들이 커밋에 섞여 들어갑니다.)`);
|
|
236
|
+
if (await confirm(` .gitignore에 추가할까?`)) {
|
|
237
|
+
gi.apply();
|
|
238
|
+
console.log(` .gitignore 갱신 — ${gi.missing.length}개 패턴 추가`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const tg = planTestGuide(cwd);
|
|
243
|
+
if (tg.conflict) {
|
|
244
|
+
// 자동 병합은 사용자 파일을 훼손할 수 있어 하지 않는다. 다만 침묵하면 TEST-GUIDE에 쌓인
|
|
245
|
+
// 내용이 아무도 안 읽는 채로 남으므로 반드시 알린다.
|
|
246
|
+
console.log(
|
|
247
|
+
`\n ⚠ .project/TEST-GUIDE.md(폐지)와 .project/rules/TEST_RULE.local.md가 모두 있습니다.`,
|
|
248
|
+
);
|
|
249
|
+
console.log(` TEST-GUIDE 쪽 내용은 이제 아무 스킬도 읽지 않습니다 — 필요한 항목을 직접 옮겨주세요.`);
|
|
250
|
+
}
|
|
251
|
+
if (tg.available) {
|
|
252
|
+
console.log(`\n .project/TEST-GUIDE.md는 0.7.0에서 폐지되고 역할이 TEST_RULE.local.md로 옮겨졌습니다.`);
|
|
253
|
+
console.log(` 지금 옮기지 않으면 /task-plan·/task-dev가 기존 내용을 읽지 않습니다. (원본은 보존)`);
|
|
254
|
+
if (await confirm(` 내용을 .project/rules/TEST_RULE.local.md로 이관할까?`)) {
|
|
255
|
+
const appended = tg.apply();
|
|
256
|
+
console.log(` 이관 완료 — .project/rules/TEST_RULE.local.md (원본 TEST-GUIDE.md 보존)`);
|
|
257
|
+
if (appended.length > 0) {
|
|
258
|
+
console.log(` 누락 절 골격 ${appended.length}개 추가(${appended.join(' / ')}) — 내용은 직접 채워주세요.`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
188
263
|
const migratedFields = Object.entries(migration)
|
|
189
264
|
.filter(([, added]) => added)
|
|
190
265
|
.map(([k]) => k);
|