@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 CHANGED
@@ -108,7 +108,8 @@ taskery prune
108
108
 
109
109
  ```
110
110
  my-app/
111
- ├─ CLAUDE.md # AI 에이전트 진입 문서
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
- ├─ FRICTION_LOG.md # taskery 불편사항 누적 로그
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 / AGENT-GUIDE.md / LINKED-REPOS.md / GLOSSARY.md / .env + 제품 관통 문서(SERVICE-POLICY / TECH-STACK / ARCHITECTURE 작성, DATA-MODEL / API-SPEC / FEATURES / UX-UI 골격 — 타입 해당분) |
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
- 이후 메인 세션에 진입(진입 문서 `CLAUDE.md` 또는 `AGENTS.md` 자동 정독)하여 다음 시퀀스로 호출한다. task 5 스킬은 호출과 동시에 상태를 전이시킨다.
241
+ 이후 메인 세션에 진입(진입 문서 자동 정독 — 사용법 전체는 `.project/rules/TASKERY_RULE.md`)하여 다음 시퀀스로 호출한다. task 5 스킬은 호출과 동시에 상태를 전이시킨다.
240
242
 
241
243
  ```
242
244
  /project-init # 진입 메타 문서와 디렉토리 골격 생성
package/bin/close.js CHANGED
@@ -8,10 +8,11 @@
8
8
  * 코드가 수행하고 멈춘다. 이후 비가역(사전 rebase+충돌해결 / 머지 락 / 부모 --no-ff 머지 /
9
9
  * worktree remove / branch -d)은 스킬(task-close)이 git-guard hook + 복구출력 보호 하에 수행.
10
10
  *
11
- * stdout: { prepped, branch, parent, wtPath, registered, commits, aheadOfParent }. parent = 머지 대상. exit 0.
11
+ * stdout: { prepped, branch, parent, wtPath, registered, commits, aheadOfParent, changelog }. parent = 머지 대상. exit 0.
12
12
  * 콜백(스킬이 LLM 판단 후 처리):
13
13
  * - exit 3 + { blocked:'status', current } — status가 tested 아님 (게이트 차단)
14
14
  * - exit 2 + { blocked:'mapping', files, phases, reason } — Phase↔파일 매핑 모호 (수동 커밋 필요)
15
+ * - exit 4 + { blocked:'docs', missing } — PLAN 체크·수정이력 누락 (문서 채운 후 그대로 재호출)
15
16
  * 그 외 실패 시 stderr + exit 1.
16
17
  */
17
18
 
@@ -60,10 +61,21 @@ async function main() {
60
61
  process.stdout.write(JSON.stringify(result) + '\n');
61
62
  process.exit(3);
62
63
  }
64
+ if (result.blocked === 'docs') {
65
+ console.error(`문서 게이트 차단 — 아래 문서에 TASK 표기 누락. 채운 후 그대로 재호출(변이 없음).`);
66
+ for (const miss of result.missing) {
67
+ console.error(` - [${miss.kind === 'plan' ? 'PLAN 체크리스트' : '수정 이력'}] ${miss.file}`);
68
+ }
69
+ process.stdout.write(JSON.stringify(result) + '\n');
70
+ process.exit(4);
71
+ }
63
72
  if (result.blocked === 'mapping') {
64
73
  console.error(
65
74
  `Phase↔파일 매핑 모호 (${result.reason}) — Dev Plan '파일' 필드 확인 후 수동 Phase 커밋 필요.`,
66
75
  );
76
+ console.error(
77
+ `미매핑 파일이 Dev Plan에 적혀 있다면 '- 파일:' 표기 형식(쉼표/가운뎃점/들여쓴 목록)을 확인 — 줄 전체가 경로 하나로 읽혔을 수 있습니다.`,
78
+ );
67
79
  process.stdout.write(JSON.stringify(result) + '\n');
68
80
  process.exit(2);
69
81
  }
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
- console.log(` ${step++}. ${entryFile} 정독 + 프로젝트 메타 + 검증 명령 채우기`);
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): .project/AGENT-GUIDE.md에서 활성 plan 식별자(버전 또는 기능 그룹명) 추출
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 === 'AGENTS.md' || relPath.startsWith('.codex/')) return 'codex';
67
- return 'agnostic'; // .project/, .gitignore 등 — 항상 설치
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/', 'AGENTS.md'],
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
- function getActiveVersion(mainWtPath) {
440
- // .project/AGENT-GUIDE.md '## 활성 plan 버전' 섹션 다음 비어 있지 않은 첫 줄에서 활성 plan 식별자 추출.
441
- // plan = 기능 그룹 단위 → 식별자는 vX.X 버전명 또는 기능 그룹 slug(compare-products 등) 모두 가능.
442
- // 헤딩 줄 끝 부가 텍스트(HTML 주석 등) 허용 — project-init 골격이 헤딩에 주석을 달므로
443
- // 접두 일치로 판정(_setActivePlan의 findIndex 기준과 동일). 완전 일치 요구 시
444
- // 자기 골격을 자기가 못 읽는 자기모순 (recordion FRICTION 2026-08-06, scaffoldError 6회).
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
- throw new Error(`.project/AGENT-GUIDE.md 부재. /project-init 먼저 호출.`);
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 미지정 시 AGENT-GUIDE에서 검출.
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 골격 + AGENT-GUIDE 활성 plan 갱신을 코드로.
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
- // AGENT-GUIDE.md '## 활성 plan 버전' 섹션 다음 비어있지 않은 첫 줄을 plan 값으로 교체(헤딩 텍스트 불변).
910
- const guidePath = path.join(mainWtPath, '.project', 'AGENT-GUIDE.md');
911
- if (!fs.existsSync(guidePath)) {
912
- throw new Error('.project/AGENT-GUIDE.md 부재. /project-init 먼저 호출.');
913
- }
914
- const lines = fs.readFileSync(guidePath, 'utf8').split('\n');
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
- const valueLine = `${plan} — \`.project/plans/${plan}/\` 참조`;
920
- let i = hIdx + 1;
921
- while (i < lines.length && lines[i].trim() === '') i++;
922
- if (i < lines.length && !/^#/.test(lines[i])) {
923
- lines[i] = valueLine; // 기존 값 줄 교체
924
- } else {
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 게이트 → 폴더 + 골격 + AGENT-GUIDE 갱신. slug 검증은 CLI 측.
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) {
@@ -968,10 +1007,22 @@ const TAG_OF = {
968
1007
 
969
1008
  function _parseDevPlanPhases(content) {
970
1009
  // ## Dev Plan 섹션의 `### Phase N — 이름` + `- 파일:` 필드 추출.
1010
+ // 다중 파일 표기 지원 — 한 줄(쉼표·가운뎃점 `·` 구분) + `- 파일:` 다음의 들여쓴 하위 목록.
1011
+ // `/`는 경로 문자라 분할자로 쓰지 않는다. `(신규)`류 꼬리 괄호 주석은 제거한다.
971
1012
  const lines = content.split('\n');
972
1013
  let inDevPlan = false;
973
1014
  const phases = [];
974
1015
  let cur = null;
1016
+ let fileIndent = null; // `- 파일:` 줄의 들여쓰기 — 이보다 깊은 목록 항목을 파일로 수집
1017
+ const pushFiles = (raw) => {
1018
+ for (const piece of raw.split(/[,·]/)) {
1019
+ const f = piece
1020
+ .replace(/`/g, '')
1021
+ .trim()
1022
+ .replace(/\s*[((][^))]*[))]\s*$/, ''); // 꼬리 괄호 주석 제거
1023
+ if (f && !/^[(<]/.test(f)) cur.files.push(f); // placeholder(<...>, (...)) 제외
1024
+ }
1025
+ };
975
1026
  for (const l of lines) {
976
1027
  if (/^##\s+Dev Plan/.test(l)) {
977
1028
  inDevPlan = true;
@@ -983,15 +1034,23 @@ function _parseDevPlanPhases(content) {
983
1034
  if (ph) {
984
1035
  cur = { num: parseInt(ph[1], 10), name: ph[2].trim(), files: [] };
985
1036
  phases.push(cur);
1037
+ fileIndent = null;
986
1038
  continue;
987
1039
  }
988
1040
  if (!cur) continue;
989
- const fm = l.match(/^\s*-\s*파일\s*:\s*(.+)$/);
1041
+ const fm = l.match(/^(\s*)-\s*파일\s*:\s*(.*)$/);
990
1042
  if (fm) {
991
- cur.files = fm[1]
992
- .split(',')
993
- .map((s) => s.replace(/`/g, '').trim())
994
- .filter((s) => s && !/^[(<]/.test(s)); // placeholder(<...>, (...)) 제외
1043
+ fileIndent = fm[1].length;
1044
+ if (fm[2].trim()) pushFiles(fm[2]);
1045
+ continue;
1046
+ }
1047
+ if (fileIndent != null) {
1048
+ const sub = l.match(/^(\s+)-\s+(.+)$/);
1049
+ if (sub && sub[1].length > fileIndent) {
1050
+ pushFiles(sub[2]);
1051
+ continue;
1052
+ }
1053
+ fileIndent = null; // 다른 필드·비목록 줄에서 하위 목록 수집 종료
995
1054
  }
996
1055
  }
997
1056
  return phases;
@@ -1110,10 +1169,116 @@ function splitUncommittedByPhase(wtPath, taskMdPath, meta) {
1110
1169
  return result;
1111
1170
  }
1112
1171
 
1172
+ // ─── close 문서 자동화 (recordion FRICTION 2026-08-09 — 문서 갱신 기계화) ─────────
1173
+ // CHANGELOG는 close가 직접 append(세션 준수 불요), PLAN 체크·수정이력은 grep 게이트로 차단.
1174
+
1175
+ function _parseTaskTitle(taskMdPath) {
1176
+ // task 문서 H1 → 제목 (`# TASK-NNN — 제목`의 제목부. 접두 없으면 전체).
1177
+ const m = fs.readFileSync(taskMdPath, 'utf8').match(/^#\s+(.+)$/m);
1178
+ if (!m) return null;
1179
+ return m[1].replace(/^TASK-\d+\s*[—-]\s*/, '').trim() || null;
1180
+ }
1181
+
1182
+ function appendChangelogEntry(base, { taskNum, title, type, phases }) {
1183
+ // CHANGELOG 항목을 close가 직접 append — CHANGELOG_RULE §1 두 형식(단일 CHANGELOG.md / 월별) 지원,
1184
+ // 없으면 월별 파일 생성. 최신 맨 위 삽입(§3). 재호출 가드: [TASK-NNN] 실재 시 skip
1185
+ // (mapping·docs 콜백 후 재호출 중복 차단). base = task 문서와 동일 분기(등록=메인WT / 미등록=워크트리).
1186
+ const nnn = String(taskNum).padStart(3, '0');
1187
+ const dir = path.join(base, '.project', 'changelog');
1188
+ const single = path.join(dir, 'CHANGELOG.md');
1189
+ const file = fs.existsSync(single) ? single : path.join(dir, `${_todayLocal().slice(0, 7)}.md`);
1190
+ const exists = fs.existsSync(file);
1191
+ const cur = exists ? fs.readFileSync(file, 'utf8') : '';
1192
+ if (cur.includes(`[TASK-${nnn}]`)) return { file, skipped: true };
1193
+ const named = (phases || []).filter((p) => p.num > 0 && p.name);
1194
+ const summary = named.length
1195
+ ? named.map((p) => ` - Phase ${p.num} — ${p.name}`).join('\n')
1196
+ : ` - ${title || `TASK-${nnn}`}`;
1197
+ const entry = [
1198
+ `## [TASK-${nnn}] ${title || ''}`.trimEnd(),
1199
+ '',
1200
+ `- **날짜**: ${_todayLocal()}`,
1201
+ `- **타입**: ${type}`,
1202
+ `- **변경 요약**:`,
1203
+ summary,
1204
+ ].join('\n');
1205
+ let next;
1206
+ if (!exists) {
1207
+ fs.mkdirSync(dir, { recursive: true });
1208
+ next = entry + '\n';
1209
+ } else {
1210
+ // 첫 `## ` 항목 바로 위 삽입 (코드펜스 내부 제외). 항목이 없으면 헤더 뒤(끝) append.
1211
+ const lines = cur.split('\n');
1212
+ let fence = false;
1213
+ let idx = -1;
1214
+ for (let i = 0; i < lines.length; i++) {
1215
+ if (/^\s*(```|~~~)/.test(lines[i])) fence = !fence;
1216
+ else if (!fence && /^##\s+/.test(lines[i])) {
1217
+ idx = i;
1218
+ break;
1219
+ }
1220
+ }
1221
+ if (idx === -1) {
1222
+ next = cur + (cur.endsWith('\n') ? '' : '\n') + '\n' + entry + '\n';
1223
+ } else {
1224
+ lines.splice(idx, 0, entry, '');
1225
+ next = lines.join('\n');
1226
+ }
1227
+ }
1228
+ fs.writeFileSync(file, next);
1229
+ return { file, added: true };
1230
+ }
1231
+
1232
+ function checkDocsGate(base, { taskNum, src, plan }) {
1233
+ // 문서 게이트 — close의 모든 변이(mutation) 앞에서 실행. blocked여도 아무것도 안 바뀌어
1234
+ // 세션이 문서를 채운 후 그대로 재호출하면 된다. 반환 { ok:true } | { ok:false, missing:[...] }.
1235
+ // PLAN.md: 로드맵 출처(ST-/RM-)만 — 체크리스트에 TASK-NNN 표기 검사. BL·DR 출처는 항목
1236
+ // 자체가 없으므로 통과. 파일 부재(구형 plan)도 통과.
1237
+ // 수정이력: spec-diffs(항상 plan 폴더 공통 — 폴더 승격 무관, TASK_DOC_RULE §1.5)의 본 태스크
1238
+ // 파일이 가리키는 대상 문서(DEL 제외)에 TASK-NNN 표기 검사. 대상 부재 = 통과
1239
+ // (게이트 실패 방향은 '막는 쪽'이 아니라 '놓치는 쪽' — 정당한 close를 오차단하지 않는다).
1240
+ const nnn = String(taskNum).padStart(3, '0');
1241
+ const tag = `TASK-${nnn}`;
1242
+ const missing = [];
1243
+ if (/^(ST|RM)-/i.test(src || '')) {
1244
+ const planPath = path.join(base, '.project', 'plans', plan, 'PLAN.md');
1245
+ if (fs.existsSync(planPath) && !fs.readFileSync(planPath, 'utf8').includes(tag)) {
1246
+ missing.push({ kind: 'plan', file: planPath });
1247
+ }
1248
+ }
1249
+ const specDir = path.join(base, '.project', 'tasks', plan, 'spec-diffs');
1250
+ if (fs.existsSync(specDir)) {
1251
+ const seen = new Set();
1252
+ const mine = fs
1253
+ .readdirSync(specDir)
1254
+ .filter((f) => f.startsWith(`${nnn}_`) && f.endsWith('.md'));
1255
+ for (const f of mine) {
1256
+ const content = fs.readFileSync(path.join(specDir, f), 'utf8');
1257
+ // 헤딩 `## <.project/문서.md> [NEW|MOD|DEL]` — 꺾쇠 유무 관용 파싱
1258
+ for (const m of content.matchAll(/^##\s+<?([^\n>[\]]+?)>?\s*\[(NEW|MOD|DEL)\]/gm)) {
1259
+ if (m[2] === 'DEL') continue;
1260
+ const rel = m[1].trim();
1261
+ const target = fs.existsSync(path.join(base, rel))
1262
+ ? path.join(base, rel)
1263
+ : path.join(base, '.project', rel);
1264
+ if (seen.has(target)) continue;
1265
+ seen.add(target);
1266
+ if (!fs.existsSync(target)) continue; // 대상 부재 → 통과
1267
+ if (!fs.readFileSync(target, 'utf8').includes(tag)) {
1268
+ missing.push({ kind: 'history', file: target });
1269
+ }
1270
+ }
1271
+ }
1272
+ }
1273
+ return missing.length ? { ok: false, missing } : { ok: true };
1274
+ }
1275
+
1113
1276
  async function closeTask(mainWtPath, { taskNum }, opts = {}) {
1114
1277
  // 결정적 준비만 — 비가역(머지·정리)·충돌 해결은 스킬이 후속 수행.
1115
- // 반환: { blocked } (게이트 미충족/매핑 모호) 또는
1116
- // { prepped, branch, parent, wtPath, registered, commits, aheadOfParent,
1278
+ // 순서: status 게이트 → docs 게이트 → Phase 커밋 → setStatus(closed) → CHANGELOG append
1279
+ // → (미등록) .project/ 커밋 → 추적 마커. 게이트 2종은 모든 변이 앞.
1280
+ // 반환: { blocked } (status/docs 게이트 미충족·매핑 모호) 또는
1281
+ // { prepped, branch, parent, wtPath, registered, commits, aheadOfParent, changelog,
1117
1282
  // multiPhase?, autoAssigned? (매핑 폴백 적용 시 내역) }. parent = 스킬 머지 대상.
1118
1283
  const active = getActiveTasks(mainWtPath).find((t) => t.taskNum === taskNum);
1119
1284
  if (!active) {
@@ -1133,6 +1298,13 @@ async function closeTask(mainWtPath, { taskNum }, opts = {}) {
1133
1298
  return { blocked: 'status', current: header.status, docPath: resolved.file };
1134
1299
  }
1135
1300
 
1301
+ // 문서 게이트 — 모든 변이 앞. blocked 시 아무 것도 안 바뀌어 채우고 그대로 재호출.
1302
+ const docBase = resolved.registered ? mainWtPath : wtPath;
1303
+ const gate = checkDocsGate(docBase, { taskNum, src: active.src, plan: resolved.plan });
1304
+ if (!gate.ok) {
1305
+ return { blocked: 'docs', missing: gate.missing, docPath: resolved.file };
1306
+ }
1307
+
1136
1308
  // Phase 커밋 (D2) — 코드 변경분만
1137
1309
  const split = splitUncommittedByPhase(wtPath, resolved.file, { taskNum, type: active.type });
1138
1310
  if (split.ambiguous) {
@@ -1149,6 +1321,15 @@ async function closeTask(mainWtPath, { taskNum }, opts = {}) {
1149
1321
  // status=closed (문서 위치 무관 — setStatus가 .gitignore 케이스 따라 처리)
1150
1322
  await setStatus(resolved.file, 'closed');
1151
1323
 
1324
+ // CHANGELOG 자동 append — 등록=메인WT 직접 쓰기(.project는 gitignore라 커밋 불요) /
1325
+ // 미등록=워크트리 쓰기(아래 .project/ 문서 커밋이 함께 수거).
1326
+ const changelog = appendChangelogEntry(docBase, {
1327
+ taskNum,
1328
+ title: _parseTaskTitle(resolved.file),
1329
+ type: header.type,
1330
+ phases: _parseDevPlanPhases(fs.readFileSync(resolved.file, 'utf8')),
1331
+ });
1332
+
1152
1333
  const commits = [...split.committed];
1153
1334
  // 미등록 케이스: .project/ 변경분(flows / 문서 / changelog)을 커밋
1154
1335
  if (!resolved.registered) {
@@ -1215,6 +1396,7 @@ async function closeTask(mainWtPath, { taskNum }, opts = {}) {
1215
1396
  docPath: resolved.file,
1216
1397
  commits,
1217
1398
  aheadOfParent: aheadFinal,
1399
+ changelog,
1218
1400
  };
1219
1401
  // 매핑 폴백 적용 내역 노출 — 코드가 자동 편입한 걸 스킬·사용자가 볼 수 있게
1220
1402
  if (split.multiPhase) prepResult.multiPhase = split.multiPhase;
@@ -1258,6 +1440,7 @@ module.exports = {
1258
1440
  getInitLockPath,
1259
1441
  withMergeLock,
1260
1442
  withMetaLock,
1443
+ withMetaLockSync,
1261
1444
  currentBranch,
1262
1445
  assertMainWorktreeOn,
1263
1446
  parseBranchName,
@@ -1268,6 +1451,7 @@ module.exports = {
1268
1451
  // 백로그 (0.1.2+)
1269
1452
  BACKLOG_PLACEHOLDER,
1270
1453
  getActiveVersion,
1454
+ setActivePlan,
1271
1455
  getBacklogPath,
1272
1456
  computeNextBLNumber,
1273
1457
  formatBacklogBlock,
@@ -1288,5 +1472,7 @@ module.exports = {
1288
1472
  // task close 결정적 준비 (D1·D2)
1289
1473
  TAG_OF,
1290
1474
  splitUncommittedByPhase,
1475
+ appendChangelogEntry,
1476
+ checkDocsGate,
1291
1477
  closeTask,
1292
1478
  };
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 생성 (채번+골격+AGENT-GUIDE 갱신)
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 골격 + AGENT-GUIDE 활성plan 갱신. 이후 골격 내용은 LLM이 채움. legacy 폴더 잔존 시 gated 반환(force로 재호출).',
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 게이트 무시'),