@angar2/taskery 0.6.1 → 0.7.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/init.js +7 -2
- package/bin/lib.js +86 -45
- package/bin/mcp.js +2 -2
- package/bin/migrate.js +306 -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 +72 -0
- package/package.json +1 -1
- package/template/.claude/skills/run-team/SKILL.md +4 -4
- package/template/.codex/agents/task-tester.toml +5 -5
- package/template/.project/rules/CHANGELOG_RULE.md +1 -1
- package/template/.project/rules/GIT_RULE.md +5 -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 +3 -2
- 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 +79 -43
- package/template/shared/skills/task-close/SKILL.md +13 -6
- 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 +14 -14
- package/template/shared/skills/task-test/SKILL.md +18 -20
- package/template/.project/FRICTION_LOG.md +0 -16
- package/template/.project/TEST-GUIDE.md +0 -62
package/README.md
CHANGED
|
@@ -108,7 +108,8 @@ taskery prune
|
|
|
108
108
|
|
|
109
109
|
```
|
|
110
110
|
my-app/
|
|
111
|
-
├─
|
|
111
|
+
├─ AGENTS.md # AI 에이전트 진입 문서 (본문 단일 소스)
|
|
112
|
+
├─ CLAUDE.md # Claude Code용 — `@AGENTS.md` 임포트 한 줄
|
|
112
113
|
├─ .taskery-manifest.json # 패키지 업데이트 추적
|
|
113
114
|
├─ .gitignore
|
|
114
115
|
├─ .claude/
|
|
@@ -117,19 +118,19 @@ my-app/
|
|
|
117
118
|
│ └─ hooks/<hook-name>.sh # 2 hook
|
|
118
119
|
└─ .project/
|
|
119
120
|
├─ PROJECT.md # 프로젝트 개요
|
|
120
|
-
├─ AGENT-GUIDE.md # AI 에이전트 가이드
|
|
121
121
|
├─ LINKED-REPOS.md # 관계 리포지토리 정보
|
|
122
122
|
├─ GLOSSARY.md # 도메인 용어집 (영문/한글 표기 일관성)
|
|
123
123
|
├─ .env # 사용자 설정 환경변수 (관계 리포지토리 환경변수 등)
|
|
124
124
|
├─ SERVICE-POLICY.md / TECH-STACK.md / ARCHITECTURE.md # 제품 관통 문서 — 정적 (project-init 작성)
|
|
125
125
|
├─ DATA-MODEL.md / API-SPEC.md / FEATURES.md / UX-UI.md # 제품 관통 문서 — 성장 (골격 → plan·task가 채움)
|
|
126
126
|
├─ rules/
|
|
127
|
+
│ ├─ TASKERY_RULE.md # taskery 사용 설명서 (세션 필독)
|
|
127
128
|
│ ├─ TASK_DOC_RULE.md # task 문서 작성 규칙
|
|
128
129
|
│ ├─ GIT_RULE.md # 로컬 깃 운영 규칙
|
|
129
130
|
│ ├─ CHANGELOG_RULE.md # CHANGELOG 작성 규칙
|
|
130
131
|
│ ├─ MOCKUP_RULE.md # UX/UI HTML 목업 규칙
|
|
131
|
-
│ ├─ DEV_RULE.local.md #
|
|
132
|
-
│ ├─ TEST_RULE.local.md #
|
|
132
|
+
│ ├─ DEV_RULE.local.md # 이 프로젝트의 구현 정책 (모든 리포 필수)
|
|
133
|
+
│ ├─ TEST_RULE.local.md # 이 프로젝트의 검증 실행 경로·범위 (모든 리포 필수)
|
|
133
134
|
│ └─ *.local.md # 사용자 오버라이드 규칙 (패키지 업데이트 대상 제외)
|
|
134
135
|
├─ plans/ # plan(기능 그룹)별 PLAN.md / ROADMAP.md (<NNN_slug>/)
|
|
135
136
|
├─ tasks/ # task 문서 (<NNN_slug>/BACKLOG.md / spec-diffs / screenshots / mockup 포함)
|
|
@@ -138,17 +139,18 @@ my-app/
|
|
|
138
139
|
├─ shared/ # 관계 리포지토리 소통 메세지함
|
|
139
140
|
│ ├─ sent/completed/
|
|
140
141
|
│ └─ received/completed/
|
|
141
|
-
|
|
142
|
-
└─ TEST-GUIDE.md # 검증 방법 (방식별 이 프로젝트 실행 경로 — /task-plan이 채움)
|
|
142
|
+
└─ FRICTION_LOG.md # taskery 불편사항 누적 로그 (/log-friction 첫 기록 시 생성)
|
|
143
143
|
```
|
|
144
144
|
|
|
145
145
|
`.project/`는 플랫폼 무관 공통 자산이다. 진입 문서와 에이전트 자산 위치는 선택한 플랫폼에 따라 갈린다.
|
|
146
146
|
|
|
147
147
|
| 플랫폼 | 진입 문서 | 스킬 위치 | hook 위치 | hook 등록 |
|
|
148
148
|
|--------|----------|----------|----------|----------|
|
|
149
|
-
| Claude Code | `CLAUDE.md` | `.claude/skills/` | `.claude/hooks/` | `.claude/settings.json` |
|
|
149
|
+
| Claude Code | `CLAUDE.md` (→ `AGENTS.md` 임포트) | `.claude/skills/` | `.claude/hooks/` | `.claude/settings.json` |
|
|
150
150
|
| Codex | `AGENTS.md` | `.agents/skills/` | `.codex/hooks/` | `.codex/config.toml` |
|
|
151
151
|
|
|
152
|
+
진입 문서 본문은 `AGENTS.md` 한 벌이다. Claude Code는 `AGENTS.md`를 자동으로 읽지 않으므로 `CLAUDE.md`가 이를 임포트한다.
|
|
153
|
+
|
|
152
154
|
> 스킬 9종과 `git-guard.sh`는 양 플랫폼 동일 자산이다. 진입 문서, hook 등록 방식, 완료 보호 hook(`closed-immutable.sh`)의 구현만 플랫폼별로 다르다. `init`에서 둘 다 선택하면 폴더가 갈려 충돌 없이 공존한다.
|
|
153
155
|
|
|
154
156
|
---
|
|
@@ -207,7 +209,7 @@ my-app/
|
|
|
207
209
|
|
|
208
210
|
| 스킬 | 생성 위치 | 생성 문서 |
|
|
209
211
|
|------|---------|---------|
|
|
210
|
-
| `/project-init` | `.project/` | PROJECT.md /
|
|
212
|
+
| `/project-init` | `.project/` | PROJECT.md / LINKED-REPOS.md / GLOSSARY.md / .env + 제품 관통 문서(SERVICE-POLICY / TECH-STACK / ARCHITECTURE 작성, DATA-MODEL / API-SPEC / FEATURES / UX-UI 골격 — 타입 해당분) + 리포 로컬 룰 초안(TEST_RULE.local.md / DEV_RULE.local.md) |
|
|
211
213
|
| `/plan-init` | `.project/plans/<NNN_slug>/` | PLAN.md / ROADMAP.md + 제품 관통 문서 FEATURES/UX-UI에 기능 그룹 의도 추가 (제품 관통 문서 전체를 한꺼번에 만들지는 않음) |
|
|
212
214
|
| `/task-init` | `.project/tasks/<NNN_slug>/` | `<NNN>_<slug>.md` (단일 파일) 또는 `TASK-<NNN>_<slug>/task.md` (사용자 명시 시 폴더 승격) |
|
|
213
215
|
|
|
@@ -236,7 +238,7 @@ cd <project-name>
|
|
|
236
238
|
npx @angar2/taskery init
|
|
237
239
|
```
|
|
238
240
|
|
|
239
|
-
이후 메인 세션에 진입(진입 문서
|
|
241
|
+
이후 메인 세션에 진입(진입 문서 자동 정독 — 사용법 전체는 `.project/rules/TASKERY_RULE.md`)하여 다음 시퀀스로 호출한다. task 5 스킬은 호출과 동시에 상태를 전이시킨다.
|
|
240
242
|
|
|
241
243
|
```
|
|
242
244
|
/project-init # 진입 메타 문서와 디렉토리 골격 생성
|
package/bin/init.js
CHANGED
|
@@ -31,6 +31,8 @@ const {
|
|
|
31
31
|
gitignorePatternsFor,
|
|
32
32
|
} = require('./lib');
|
|
33
33
|
|
|
34
|
+
const { ENTRY_DOCS_MARKER } = require('./migrate');
|
|
35
|
+
|
|
34
36
|
async function confirm(msg) {
|
|
35
37
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
36
38
|
return new Promise((resolve) => {
|
|
@@ -125,6 +127,8 @@ async function main() {
|
|
|
125
127
|
platforms,
|
|
126
128
|
stale_days: DEFAULT_STALE_DAYS,
|
|
127
129
|
lock_timeout_ms: DEFAULT_LOCK_TIMEOUT_MS,
|
|
130
|
+
activePlan: null, // /plan-init이 채운다 (0.7.0+ 활성 plan SSoT)
|
|
131
|
+
entryDocs: ENTRY_DOCS_MARKER, // 신규 설치는 이미 새 진입 문서 체계 — update가 이행을 시도하지 않도록
|
|
128
132
|
files: manifestFiles,
|
|
129
133
|
};
|
|
130
134
|
writeManifest(manifest, manifestPath);
|
|
@@ -189,10 +193,11 @@ async function main() {
|
|
|
189
193
|
console.log(` 카피: ${copied}개 / 스킵: ${skipped}개`);
|
|
190
194
|
console.log(` 플랫폼: ${platforms.join(', ')}`);
|
|
191
195
|
console.log(` manifest: ${MANIFEST_NAME}\n`);
|
|
192
|
-
const entryFile = platforms.includes('claude') ? 'CLAUDE.md' : 'AGENTS.md';
|
|
193
196
|
console.log(`다음 단계:`);
|
|
194
197
|
let step = 1;
|
|
195
|
-
|
|
198
|
+
// 진입 문서 본문은 AGENTS.md 한 벌 — CLAUDE.md는 `@AGENTS.md` 임포트 한 줄이라 채울 것이 없다.
|
|
199
|
+
console.log(` ${step++}. AGENTS.md 정독 + 프로젝트 메타 + 검증/테스트 명령 채우기`);
|
|
200
|
+
console.log(` (.project/rules/TASKERY_RULE.md = taskery 사용 설명서 — 세션이 필독)`);
|
|
196
201
|
if (platforms.includes('claude')) {
|
|
197
202
|
console.log(` ${step++}. Claude 첫 세션에서 taskery MCP 서버 승인 (.mcp.json — 구조화 도구 8종; 미승인 시 npx CLI 폴백)`);
|
|
198
203
|
}
|
package/bin/lib.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* - getInitLockPath(projectId): ~/.taskery/<projectId>.init.lock (task 분기 직렬화용)
|
|
23
23
|
* - withMergeLock(projectId, fn, opts): proper-lockfile로 머지 락 + fn 실행 + 자동 release
|
|
24
24
|
* - withMetaLock(filePath, fn, opts): 메타 파일 쓰기 락 + fn 실행
|
|
25
|
+
* - withMetaLockSync(filePath, fn, opts): 위의 동기 변형 (sync 호출 체인용 — manifest 갱신)
|
|
25
26
|
* - getActiveTasks(mainWtPath): SSoT 조회 (git branch --list 'feature/*_TASK-*' ...)
|
|
26
27
|
* - getNextTaskNumber(mainWtPath): (진행중 ∪ 전 ref 머지 히스토리) 최대 + 1 (부모 무관 전역 채번)
|
|
27
28
|
* - forkTask(mainWtPath, {type,dev,src,slug}): init 락 안에서 부모(현재 브랜치) 캡처+채번+워크트리·브랜치 생성 원자 실행
|
|
@@ -29,7 +30,8 @@
|
|
|
29
30
|
* - assertMainWorktreeOn(mainWtPath, branch): 메인 워크트리가 지정 브랜치 체크아웃 상태인지 검증
|
|
30
31
|
*
|
|
31
32
|
* 백로그 (0.1.2+):
|
|
32
|
-
* - getActiveVersion(mainWtPath): .
|
|
33
|
+
* - getActiveVersion(mainWtPath): 활성 plan 식별자 — manifest.activePlan 우선, 구형 AGENT-GUIDE.md 폴백 (0.7.0+)
|
|
34
|
+
* - setActivePlan(mainWtPath, plan): manifest.activePlan 갱신 (plan 폴더 실재 검증 포함 — plan-switch)
|
|
33
35
|
* - getBacklogPath(mainWtPath, activeVersion?): .project/tasks/<활성 plan>/BACKLOG.md 절대 경로
|
|
34
36
|
* - parseBacklogItem(mainWtPath, blId): BL-NNN 항목 메타 파싱 ({ status, type, title, slug, summary, target, taskNums })
|
|
35
37
|
* - appendBacklogItem(mainWtPath, meta): withMetaLock + BL-NNN 채번 + 항목 append (placeholder 치환 우선)
|
|
@@ -63,8 +65,10 @@ const SHARED_DEST = {
|
|
|
63
65
|
function platformOf(relPath) {
|
|
64
66
|
if (relPath.startsWith('shared/')) return 'shared'; // 공통 — 설치 시 플랫폼별 매핑
|
|
65
67
|
if (relPath === 'CLAUDE.md' || relPath.startsWith('.claude/')) return 'claude';
|
|
66
|
-
if (relPath
|
|
67
|
-
|
|
68
|
+
if (relPath.startsWith('.codex/')) return 'codex';
|
|
69
|
+
// AGENTS.md는 0.7.0부터 agnostic — 진입 문서 본문의 단일 소스이고
|
|
70
|
+
// CLAUDE.md는 이를 `@AGENTS.md`로 임포트만 한다(Claude Code는 AGENTS.md를 자동 로드하지 않음).
|
|
71
|
+
return 'agnostic'; // AGENTS.md, .project/, .gitignore 등 — 항상 설치
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
// shared/ 상대경로를 특정 플랫폼의 설치 경로로 변환.
|
|
@@ -104,9 +108,9 @@ function resolveInstallPlan(templateFiles, platforms, { includeAgnostic = true }
|
|
|
104
108
|
|
|
105
109
|
// .gitignore 등록 패턴 (플랫폼별)
|
|
106
110
|
const GITIGNORE_PATTERNS = {
|
|
107
|
-
agnostic: ['.project/', '.taskery-manifest.json'],
|
|
111
|
+
agnostic: ['.project/', '.taskery-manifest.json', 'AGENTS.md'],
|
|
108
112
|
claude: ['.claude/', 'CLAUDE.md'],
|
|
109
|
-
codex: ['.codex/', '.agents/'
|
|
113
|
+
codex: ['.codex/', '.agents/'],
|
|
110
114
|
};
|
|
111
115
|
|
|
112
116
|
// 선택 플랫폼 + agnostic의 gitignore 패턴 합성 (중복 제거)
|
|
@@ -308,6 +312,27 @@ async function withMetaLock(filePath, fn, opts = {}) {
|
|
|
308
312
|
}
|
|
309
313
|
}
|
|
310
314
|
|
|
315
|
+
function withMetaLockSync(filePath, fn, opts = {}) {
|
|
316
|
+
// withMetaLock의 동기 변형. manifest 갱신 경로(initPlan → plan.js main)가 전부 sync라
|
|
317
|
+
// async 락을 쓰면 호출 사슬 전체를 async로 바꿔야 해 동기 락을 별도로 둔다.
|
|
318
|
+
const lockfile = require('proper-lockfile');
|
|
319
|
+
if (!fs.existsSync(filePath)) {
|
|
320
|
+
mkdirp(path.dirname(filePath));
|
|
321
|
+
fs.writeFileSync(filePath, '');
|
|
322
|
+
}
|
|
323
|
+
// sync API는 retries를 지원하지 않는다(proper-lockfile 제약). 경합 시 즉시 던지는데,
|
|
324
|
+
// manifest 쓰기는 사용자가 직접 부르는 명령들뿐이라 동시 실행이 실질적으로 없다.
|
|
325
|
+
const release = lockfile.lockSync(filePath, {
|
|
326
|
+
stale: opts.stale ?? DEFAULT_LOCK_TIMEOUT_MS,
|
|
327
|
+
realpath: false,
|
|
328
|
+
});
|
|
329
|
+
try {
|
|
330
|
+
return fn();
|
|
331
|
+
} finally {
|
|
332
|
+
release();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
311
336
|
function currentBranch(mainWtPath) {
|
|
312
337
|
// 메인 워크트리의 현재 브랜치 = 태스크의 부모 브랜치(0.6.0). detached HEAD면 'HEAD' 반환.
|
|
313
338
|
return gitCapture(mainWtPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
@@ -436,31 +461,51 @@ function computeNextPlanNumber(mainWtPath) {
|
|
|
436
461
|
const BACKLOG_PLACEHOLDER =
|
|
437
462
|
'(사용자 발화 또는 task-close 직후 메인 감지로 한 행씩 추가. 빈 상태 default.)';
|
|
438
463
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
464
|
+
// 구형(0.7.0 미만) 활성 plan 저장소 — .project/AGENT-GUIDE.md '## 활성 plan 버전' 섹션.
|
|
465
|
+
// 0.7.0에서 SSoT가 manifest.activePlan으로 이관됐으나, update 전 리포를 무중단으로 굴리기 위해
|
|
466
|
+
// 읽기 폴백으로만 남긴다. 쓰기는 하지 않는다(_setActivePlan은 manifest만 갱신).
|
|
467
|
+
// 헤딩 줄 끝 부가 텍스트(HTML 주석 등) 허용 — project-init 골격이 헤딩에 주석을 달므로 접두 일치로 판정.
|
|
468
|
+
// 완전 일치를 요구하면 자기 골격을 자기가 못 읽는 자기모순 (recordion FRICTION 2026-08-06, scaffoldError 6회).
|
|
469
|
+
function _readLegacyActivePlan(mainWtPath) {
|
|
445
470
|
const guidePath = path.join(mainWtPath, '.project', 'AGENT-GUIDE.md');
|
|
446
|
-
if (!fs.existsSync(guidePath))
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const content = fs.readFileSync(guidePath, 'utf8');
|
|
450
|
-
const sec = content.match(/^##\s*활성 plan 버전[^\n]*\n+([^\n]+)/m);
|
|
451
|
-
if (!sec) {
|
|
452
|
-
throw new Error(`.project/AGENT-GUIDE.md '## 활성 plan 버전' 섹션 부재.`);
|
|
453
|
-
}
|
|
471
|
+
if (!fs.existsSync(guidePath)) return null;
|
|
472
|
+
const sec = fs.readFileSync(guidePath, 'utf8').match(/^##\s*활성 plan 버전[^\n]*\n+([^\n]+)/m);
|
|
473
|
+
if (!sec) return null;
|
|
454
474
|
// 첫 토큰 = plan 폴더 이름(' — 설명' 앞부분). '<예: ...>' 자리표시자는 미설정으로 간주.
|
|
455
475
|
const firstLine = sec[1].trim();
|
|
456
|
-
if (!firstLine || firstLine.startsWith('<'))
|
|
457
|
-
throw new Error(
|
|
458
|
-
`'## 활성 plan 버전' 값 미설정(${firstLine || '빈 줄'}) — /plan-init으로 활성 plan 갱신 필요.`,
|
|
459
|
-
);
|
|
460
|
-
}
|
|
476
|
+
if (!firstLine || firstLine.startsWith('<')) return null;
|
|
461
477
|
return firstLine.split(/\s+/)[0];
|
|
462
478
|
}
|
|
463
479
|
|
|
480
|
+
function getActiveVersion(mainWtPath) {
|
|
481
|
+
// 활성 plan 식별자 = manifest.activePlan (0.7.0+ SSoT). plan = 기능 그룹 단위라
|
|
482
|
+
// 식별자는 NNN_slug 폴더명(구형은 vX.X 버전명도 가능).
|
|
483
|
+
const manifest = readManifest(path.join(mainWtPath, MANIFEST_NAME));
|
|
484
|
+
if (manifest && manifest.activePlan) return manifest.activePlan;
|
|
485
|
+
|
|
486
|
+
// 구형 폴백 — update 전 리포. 읽기만 하고 승격은 update가 1회 수행한다.
|
|
487
|
+
const legacy = _readLegacyActivePlan(mainWtPath);
|
|
488
|
+
if (legacy) return legacy;
|
|
489
|
+
|
|
490
|
+
if (!manifest) {
|
|
491
|
+
throw new Error(`${MANIFEST_NAME} 부재. 'npx @angar2/taskery init' 먼저 실행.`);
|
|
492
|
+
}
|
|
493
|
+
throw new Error(
|
|
494
|
+
`활성 plan 미설정 (manifest.activePlan 없음) — /plan-init으로 plan을 생성하거나 ` +
|
|
495
|
+
`'npx @angar2/taskery plan-switch <NNN_slug>'로 지정 필요.`,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function setActivePlan(mainWtPath, plan) {
|
|
500
|
+
// plan-switch — 대상 plan 폴더 실재 검증 후 manifest.activePlan 갱신.
|
|
501
|
+
const planDir = path.join(mainWtPath, '.project', 'plans', plan);
|
|
502
|
+
if (!fs.existsSync(planDir)) {
|
|
503
|
+
throw new Error(`plan 폴더 부재: ${planDir} — 존재하는 plan만 활성으로 지정 가능.`);
|
|
504
|
+
}
|
|
505
|
+
_setActivePlan(mainWtPath, plan);
|
|
506
|
+
return { plan, planDir };
|
|
507
|
+
}
|
|
508
|
+
|
|
464
509
|
function getBacklogPath(mainWtPath, activeVersion) {
|
|
465
510
|
const v = activeVersion ?? getActiveVersion(mainWtPath);
|
|
466
511
|
return path.join(mainWtPath, '.project', 'tasks', v, 'BACKLOG.md');
|
|
@@ -817,7 +862,7 @@ function _renderTaskSkeleton({ taskNum, title, created, plan, docType, size, par
|
|
|
817
862
|
|
|
818
863
|
function scaffoldTaskDoc(mainWtPath, wtPath, meta) {
|
|
819
864
|
// meta: { taskNum, slug, title, type(브랜치 타입), size, parent, plan?, promoted?, created? }
|
|
820
|
-
// 위치: 등록(.gitignore) 케이스 → 메인WT, 미등록 → 워크트리. plan 미지정 시
|
|
865
|
+
// 위치: 등록(.gitignore) 케이스 → 메인WT, 미등록 → 워크트리. plan 미지정 시 활성 plan(manifest.activePlan)에서 검출.
|
|
821
866
|
// parent 미지정 시 현재 브랜치 캡처(단독 호출 안전망 — 통상 fork가 넘김).
|
|
822
867
|
// taskNum이 고유(fork init 락 채번)라 동일 문서 동시 쓰기 경합 없음 → 락 불요.
|
|
823
868
|
const registered = isProjectRegistered(mainWtPath);
|
|
@@ -851,7 +896,7 @@ function scaffoldTaskDoc(mainWtPath, wtPath, meta) {
|
|
|
851
896
|
}
|
|
852
897
|
|
|
853
898
|
// ─── plan 생성 (코드화, C3) ─────────────────────────────────────────
|
|
854
|
-
// 채번 + legacy 게이트 + 폴더 mkdir + ROADMAP/PLAN/BACKLOG 골격 +
|
|
899
|
+
// 채번 + legacy 게이트 + 폴더 mkdir + ROADMAP/PLAN/BACKLOG 골격 + manifest.activePlan 갱신을 코드로.
|
|
855
900
|
// LLM 몫(FEATURES/UX-UI delta · ROADMAP Stage 내용 · PLAN 링크)은 골격의 placeholder로 남긴다.
|
|
856
901
|
|
|
857
902
|
function _renderRoadmap(plan) {
|
|
@@ -906,29 +951,23 @@ function _renderPlanBacklog(plan) {
|
|
|
906
951
|
}
|
|
907
952
|
|
|
908
953
|
function _setActivePlan(mainWtPath, plan) {
|
|
909
|
-
//
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
const hIdx = lines.findIndex((l) => /^##\s*활성 plan 버전/.test(l));
|
|
916
|
-
if (hIdx === -1) {
|
|
917
|
-
throw new Error("AGENT-GUIDE.md '## 활성 plan 버전' 섹션 부재.");
|
|
954
|
+
// 활성 plan SSoT = manifest.activePlan (0.7.0+). 구형 AGENT-GUIDE.md는 갱신하지 않는다
|
|
955
|
+
// (읽기 폴백 전용 — 두 곳에 쓰면 다시 갈린다).
|
|
956
|
+
// manifest는 읽고-고쳐-쓰기라 다른 기록자(init/update/add)와 겹치면 필드가 유실될 수 있어 락으로 감싼다.
|
|
957
|
+
const manifestPath = path.join(mainWtPath, MANIFEST_NAME);
|
|
958
|
+
if (!fs.existsSync(manifestPath)) {
|
|
959
|
+
throw new Error(`${MANIFEST_NAME} 부재. 'npx @angar2/taskery init' 먼저 실행.`);
|
|
918
960
|
}
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
}
|
|
925
|
-
lines.splice(hIdx + 1, 0, '', valueLine); // 값 줄 부재 → 헤딩 뒤 삽입
|
|
926
|
-
}
|
|
927
|
-
fs.writeFileSync(guidePath, lines.join('\n'));
|
|
961
|
+
withMetaLockSync(manifestPath, () => {
|
|
962
|
+
const manifest = readManifest(manifestPath);
|
|
963
|
+
if (!manifest) throw new Error(`${MANIFEST_NAME} 읽기 실패.`);
|
|
964
|
+
manifest.activePlan = plan;
|
|
965
|
+
writeManifest(manifest, manifestPath);
|
|
966
|
+
});
|
|
928
967
|
}
|
|
929
968
|
|
|
930
969
|
function initPlan(mainWtPath, slug, opts = {}) {
|
|
931
|
-
// 채번 → legacy 게이트 → 폴더 + 골격 +
|
|
970
|
+
// 채번 → legacy 게이트 → 폴더 + 골격 + manifest.activePlan 갱신. slug 검증은 CLI 측.
|
|
932
971
|
// legacy 폴더(NNN_ 아닌 plan 폴더) 잔존 + !force → { gated:true } 반환(CLI가 사용자 confirm 후 force 재호출).
|
|
933
972
|
const { next, legacyDirs } = computeNextPlanNumber(mainWtPath);
|
|
934
973
|
if (legacyDirs.length && !opts.force) {
|
|
@@ -1258,6 +1297,7 @@ module.exports = {
|
|
|
1258
1297
|
getInitLockPath,
|
|
1259
1298
|
withMergeLock,
|
|
1260
1299
|
withMetaLock,
|
|
1300
|
+
withMetaLockSync,
|
|
1261
1301
|
currentBranch,
|
|
1262
1302
|
assertMainWorktreeOn,
|
|
1263
1303
|
parseBranchName,
|
|
@@ -1268,6 +1308,7 @@ module.exports = {
|
|
|
1268
1308
|
// 백로그 (0.1.2+)
|
|
1269
1309
|
BACKLOG_PLACEHOLDER,
|
|
1270
1310
|
getActiveVersion,
|
|
1311
|
+
setActivePlan,
|
|
1271
1312
|
getBacklogPath,
|
|
1272
1313
|
computeNextBLNumber,
|
|
1273
1314
|
formatBacklogBlock,
|
package/bin/mcp.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 도구 8종 = CLI 명령과 동일한 lib.js 함수의 다른 입구(네이티브 구조화 인자/반환).
|
|
8
8
|
* backlog_add / backlog_get / backlog_mark — 활성 plan BACKLOG.md 조작
|
|
9
9
|
* set_status — task 헤더 상태 전이 (7×7 유효전이 검증, closed 거부)
|
|
10
|
-
* plan_init — plan 생성 (채번+골격+
|
|
10
|
+
* plan_init — plan 생성 (채번+골격+manifest.activePlan 갱신)
|
|
11
11
|
* task_init — task 분기 (fork 원자 채번 + §1.3 골격 자동 생성)
|
|
12
12
|
* task_close — close 결정적 준비만 (Phase커밋·status=closed·마커). 비가역 머지/정리는 스킬.
|
|
13
13
|
* status — 멀티세션 현황 + 활성 plan
|
|
@@ -159,7 +159,7 @@ server.registerTool(
|
|
|
159
159
|
'plan_init',
|
|
160
160
|
{
|
|
161
161
|
description:
|
|
162
|
-
'plan(기능 그룹) 생성 — 채번 + 폴더 + ROADMAP/PLAN/BACKLOG 골격 +
|
|
162
|
+
'plan(기능 그룹) 생성 — 채번 + 폴더 + ROADMAP/PLAN/BACKLOG 골격 + 활성 plan 갱신. 이후 골격 내용은 LLM이 채움. legacy 폴더 잔존 시 gated 반환(force로 재호출).',
|
|
163
163
|
inputSchema: {
|
|
164
164
|
slug: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, '영어 kebab-case'),
|
|
165
165
|
force: z.boolean().optional().describe('legacy 게이트 무시'),
|
package/bin/migrate.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
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
|
+
/**
|
|
235
|
+
* 4. TEST-GUIDE.md → TEST_RULE.local.md 이관.
|
|
236
|
+
* 폐지되는 문서라 그대로 두면 새 참조처들이 읽지 않아 축적 내용이 고아가 된다.
|
|
237
|
+
* 원본은 지우지 않는다(사용자 파일 불가침).
|
|
238
|
+
* @returns {{ available: boolean, apply: function }}
|
|
239
|
+
*/
|
|
240
|
+
function planTestGuide(cwd) {
|
|
241
|
+
const guidePath = path.join(cwd, '.project', 'TEST-GUIDE.md');
|
|
242
|
+
const localPath = path.join(cwd, '.project', 'rules', 'TEST_RULE.local.md');
|
|
243
|
+
if (!fs.existsSync(guidePath)) return { available: false };
|
|
244
|
+
if (fs.existsSync(localPath)) {
|
|
245
|
+
// 둘 다 있으면 자동 병합하지 않는다(사용자 파일 훼손 위험). 다만 침묵하면
|
|
246
|
+
// TEST-GUIDE에 쌓인 내용이 아무도 안 읽는 채로 남으므로 반드시 알린다.
|
|
247
|
+
return { available: false, conflict: true };
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
available: true,
|
|
251
|
+
apply() {
|
|
252
|
+
const body = fs.readFileSync(guidePath, 'utf8');
|
|
253
|
+
// 머리말만 새 역할에 맞게 갈아끼우고 본문(축적된 실행 경로)은 그대로 옮긴다.
|
|
254
|
+
const stripped = body.replace(/^#\s*TEST-GUIDE[^\n]*\n(>[^\n]*\n)*/, '');
|
|
255
|
+
const header = [
|
|
256
|
+
'# TEST_RULE.local.md — 이 리포의 검증 규칙',
|
|
257
|
+
'',
|
|
258
|
+
'> 이 프로젝트에서 각 테스트 방식을 *실제로 어떻게 실행하나* + *무엇을 얼마나 검증하나*의 단일 소스.',
|
|
259
|
+
'> `.project/TEST-GUIDE.md`(0.7.0에서 폐지)의 내용을 이관한 문서다. 구성과 의무는 `TASKERY_RULE` §8 참조.',
|
|
260
|
+
'> `*.local.md`는 taskery update가 건드리지 않는다 — 이 리포가 소유한다.',
|
|
261
|
+
'',
|
|
262
|
+
].join('\n');
|
|
263
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
264
|
+
fs.writeFileSync(localPath, header + stripped);
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 5. `.gitignore` 병합 — **덮어쓰기 금지.**
|
|
271
|
+
* template/.gitignore는 manifest 관리 자산이라 update의 '신규' 분기가 그대로 복사해버리는데,
|
|
272
|
+
* 실사용 리포는 대부분 .gitignore를 manifest에 안 들고 있다(init 이전부터 있던 파일).
|
|
273
|
+
* 그대로 두면 사용자의 node_modules·빌드 산출물·프로젝트 고유 무시 규칙이 통째로 사라진다.
|
|
274
|
+
* → 없으면 복사, 있으면 *빠진 줄만 덧붙인다*. 기존 줄은 절대 건드리지 않는다.
|
|
275
|
+
* @returns {{ merged: boolean, added: string[] }}
|
|
276
|
+
*/
|
|
277
|
+
function mergeGitignore(srcPath, dstPath) {
|
|
278
|
+
if (!fs.existsSync(dstPath)) {
|
|
279
|
+
copyFile(srcPath, dstPath);
|
|
280
|
+
return { merged: false, added: [] };
|
|
281
|
+
}
|
|
282
|
+
const cur = fs.readFileSync(dstPath, 'utf8');
|
|
283
|
+
const have = new Set(cur.split('\n').map((l) => l.trim()));
|
|
284
|
+
const added = fs
|
|
285
|
+
.readFileSync(srcPath, 'utf8')
|
|
286
|
+
.split('\n')
|
|
287
|
+
.map((l) => l.trim())
|
|
288
|
+
.filter((l) => l && !l.startsWith('#') && !have.has(l));
|
|
289
|
+
if (added.length === 0) return { merged: true, added: [] };
|
|
290
|
+
const lead = cur.endsWith('\n') ? '' : '\n';
|
|
291
|
+
fs.appendFileSync(dstPath, `${lead}\n# taskery 템플릿 추가분\n${added.join('\n')}\n`);
|
|
292
|
+
return { merged: true, added };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
module.exports = {
|
|
296
|
+
ENTRY_DOCS_MARKER,
|
|
297
|
+
mergeGitignore,
|
|
298
|
+
isBefore070,
|
|
299
|
+
splitSections,
|
|
300
|
+
findSectionBody,
|
|
301
|
+
injectSections,
|
|
302
|
+
ingestActivePlan,
|
|
303
|
+
migrateEntryDocs,
|
|
304
|
+
planGitignore,
|
|
305
|
+
planTestGuide,
|
|
306
|
+
};
|