@angar2/taskery 0.6.0 → 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 CHANGED
@@ -16,6 +16,9 @@ AI 코딩 에이전트의 자율 개발을 위한 Task 기반의 가드레일
16
16
  | 에이전트가 catastrophic 사고를 일으킬 수 있음 | 필수 hook으로 차단 — 정상 흐름에는 무간섭 |
17
17
  | 에이전트가 작성한 코드의 자가검증으로 인해 문제를 놓침 (confirmation bias) | 테스트 시 별도 격리 세션 호출(`/task-test`) — 메인 세션의 가정 없이 독립 검증 |
18
18
  | 단일 메인 세션 운영으로 인해 task를 직렬로 진행해야 하는 병목 | 같은 프로젝트에서 여러 메인 세션이 독립 작업 폴더(worktree)로 병렬 진행 — 머지 시점에 직렬화로 정합성 유지 |
19
+ | 검수 중 한 줄만 고쳐도 전체 테스트를 다시 돌려 대기 시간이 길어짐 | 수정 구간에는 변경이 닿는 테스트만 실행 — 전체 실행은 task당 1회로 제한 |
20
+ | 테스트가 앱 화면을 띄우고 조작해 그동안 컴퓨터를 쓸 수 없음 | 화면·입력을 점유하는 테스트는 창을 띄우지 않는 실행 경로가 준비된 경우에만 자동 실행 — 없으면 사용자 승인 후 실행 |
21
+ | 프로젝트마다 개발·테스트 방식이 다른데 공통 규칙만 적용됨 | 프로젝트 전용 규칙 파일(`*.local.md`)을 각 스킬이 읽어 우선 적용 — 패키지를 갱신해도 덮어쓰지 않음 |
19
22
 
20
23
  > catastrophic 사고 예시 — git 운영 정책 위반, 검증 우회, 완료된 task 문서 재수정 등
21
24
 
@@ -105,7 +108,8 @@ taskery prune
105
108
 
106
109
  ```
107
110
  my-app/
108
- ├─ CLAUDE.md # AI 에이전트 진입 문서
111
+ ├─ AGENTS.md # AI 에이전트 진입 문서 (본문 단일 소스)
112
+ ├─ CLAUDE.md # Claude Code용 — `@AGENTS.md` 임포트 한 줄
109
113
  ├─ .taskery-manifest.json # 패키지 업데이트 추적
110
114
  ├─ .gitignore
111
115
  ├─ .claude/
@@ -114,17 +118,19 @@ my-app/
114
118
  │ └─ hooks/<hook-name>.sh # 2 hook
115
119
  └─ .project/
116
120
  ├─ PROJECT.md # 프로젝트 개요
117
- ├─ AGENT-GUIDE.md # AI 에이전트 가이드
118
121
  ├─ LINKED-REPOS.md # 관계 리포지토리 정보
119
122
  ├─ GLOSSARY.md # 도메인 용어집 (영문/한글 표기 일관성)
120
123
  ├─ .env # 사용자 설정 환경변수 (관계 리포지토리 환경변수 등)
121
124
  ├─ SERVICE-POLICY.md / TECH-STACK.md / ARCHITECTURE.md # 제품 관통 문서 — 정적 (project-init 작성)
122
125
  ├─ DATA-MODEL.md / API-SPEC.md / FEATURES.md / UX-UI.md # 제품 관통 문서 — 성장 (골격 → plan·task가 채움)
123
126
  ├─ rules/
127
+ │ ├─ TASKERY_RULE.md # taskery 사용 설명서 (세션 필독)
124
128
  │ ├─ TASK_DOC_RULE.md # task 문서 작성 규칙
125
129
  │ ├─ GIT_RULE.md # 로컬 깃 운영 규칙
126
130
  │ ├─ CHANGELOG_RULE.md # CHANGELOG 작성 규칙
127
131
  │ ├─ MOCKUP_RULE.md # UX/UI HTML 목업 규칙
132
+ │ ├─ DEV_RULE.local.md # 이 프로젝트의 구현 정책 (모든 리포 필수)
133
+ │ ├─ TEST_RULE.local.md # 이 프로젝트의 검증 실행 경로·범위 (모든 리포 필수)
128
134
  │ └─ *.local.md # 사용자 오버라이드 규칙 (패키지 업데이트 대상 제외)
129
135
  ├─ plans/ # plan(기능 그룹)별 PLAN.md / ROADMAP.md (<NNN_slug>/)
130
136
  ├─ tasks/ # task 문서 (<NNN_slug>/BACKLOG.md / spec-diffs / screenshots / mockup 포함)
@@ -133,17 +139,18 @@ my-app/
133
139
  ├─ shared/ # 관계 리포지토리 소통 메세지함
134
140
  │ ├─ sent/completed/
135
141
  │ └─ received/completed/
136
- ├─ FRICTION_LOG.md # taskery 불편사항 누적 로그
137
- └─ TEST-GUIDE.md # 검증 방법 (방식별 이 프로젝트 실행 경로 — /task-plan이 채움)
142
+ └─ FRICTION_LOG.md # taskery 불편사항 누적 로그 (/log-friction 첫 기록 시 생성)
138
143
  ```
139
144
 
140
145
  `.project/`는 플랫폼 무관 공통 자산이다. 진입 문서와 에이전트 자산 위치는 선택한 플랫폼에 따라 갈린다.
141
146
 
142
147
  | 플랫폼 | 진입 문서 | 스킬 위치 | hook 위치 | hook 등록 |
143
148
  |--------|----------|----------|----------|----------|
144
- | 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` |
145
150
  | Codex | `AGENTS.md` | `.agents/skills/` | `.codex/hooks/` | `.codex/config.toml` |
146
151
 
152
+ 진입 문서 본문은 `AGENTS.md` 한 벌이다. Claude Code는 `AGENTS.md`를 자동으로 읽지 않으므로 `CLAUDE.md`가 이를 임포트한다.
153
+
147
154
  > 스킬 9종과 `git-guard.sh`는 양 플랫폼 동일 자산이다. 진입 문서, hook 등록 방식, 완료 보호 hook(`closed-immutable.sh`)의 구현만 플랫폼별로 다르다. `init`에서 둘 다 선택하면 폴더가 갈려 충돌 없이 공존한다.
148
155
 
149
156
  ---
@@ -202,7 +209,7 @@ my-app/
202
209
 
203
210
  | 스킬 | 생성 위치 | 생성 문서 |
204
211
  |------|---------|---------|
205
- | `/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) |
206
213
  | `/plan-init` | `.project/plans/<NNN_slug>/` | PLAN.md / ROADMAP.md + 제품 관통 문서 FEATURES/UX-UI에 기능 그룹 의도 추가 (제품 관통 문서 전체를 한꺼번에 만들지는 않음) |
207
214
  | `/task-init` | `.project/tasks/<NNN_slug>/` | `<NNN>_<slug>.md` (단일 파일) 또는 `TASK-<NNN>_<slug>/task.md` (사용자 명시 시 폴더 승격) |
208
215
 
@@ -231,7 +238,7 @@ cd <project-name>
231
238
  npx @angar2/taskery init
232
239
  ```
233
240
 
234
- 이후 메인 세션에 진입(진입 문서 `CLAUDE.md` 또는 `AGENTS.md` 자동 정독)하여 다음 시퀀스로 호출한다. task 5 스킬은 호출과 동시에 상태를 전이시킨다.
241
+ 이후 메인 세션에 진입(진입 문서 자동 정독 — 사용법 전체는 `.project/rules/TASKERY_RULE.md`)하여 다음 시퀀스로 호출한다. task 5 스킬은 호출과 동시에 상태를 전이시킨다.
235
242
 
236
243
  ```
237
244
  /project-init # 진입 메타 문서와 디렉토리 골격 생성
@@ -258,6 +265,7 @@ npx @angar2/taskery init
258
265
  - 사용자가 재구현을 요청하면 → `testing` → `developing`으로 회귀해 `/task-dev`가 자동 발동된다.
259
266
  - 사용자가 결함을 인지한 채 종결을 지시하면 → `testing` → `tested`로 진행하고 결함을 명시한 후 `/task-close`로 이행된다.
260
267
  - UNCERTAIN(자동 검증이 불가능한 시나리오)은 사용자가 직접 검수한 결과를 답하면 메인 세션이 PASS/FAIL로 해석해 위 두 흐름 중 하나로 합류시킨다.
268
+ - 검증에 필요한 테스트가 화면·입력을 점유해 실행을 보류한 경우에는, 지금 실행할지 / 자리를 비울 때로 미룰지 / 창을 띄우지 않는 실행 경로를 마련할지를 사용자가 고른다. 승인 없이 임의로 실행하지 않는다.
261
269
 
262
270
  ---
263
271
 
package/bin/fork.js CHANGED
@@ -57,8 +57,10 @@ async function main() {
57
57
  console.error(`type은 ${VALID_TYPES.join(' | ')} 중 하나 (받음: ${type})`);
58
58
  process.exit(1);
59
59
  }
60
- if (!/^(BL-\d+|RM-\d+|DR)$/.test(src)) {
61
- console.error(`src는 BL-NNN | RM-NNN | DR 형식 (받음: ${src})`);
60
+ // ST-N = 로드맵 Stage 출처 (ROADMAP은 Stage 단위·RM 채번 없음 — recordion FRICTION 2026-08-03 반영).
61
+ // RM-NNN은 구형 호환으로 계속 수용.
62
+ if (!/^(BL-\d+|RM-\d+|ST-\d+|DR)$/.test(src)) {
63
+ console.error(`src는 BL-NNN | ST-N(로드맵 Stage) | DR 형식 (받음: ${src}. 구형 RM-NNN도 호환)`);
62
64
  process.exit(1);
63
65
  }
64
66
  if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
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 패턴 합성 (중복 제거)
@@ -247,7 +251,7 @@ function getWorktreesRoot(projectId) {
247
251
  }
248
252
 
249
253
  function getWorktreePath(projectId, { taskNum, src, slug }) {
250
- // src = 'BL-NNN' | 'RM-NNN' | 'DR'
254
+ // src = 'BL-NNN' | 'ST-N'(로드맵 Stage) | 'DR' (구형 'RM-NNN' 호환)
251
255
  const nnn = String(taskNum).padStart(3, '0');
252
256
  return path.join(getWorktreesRoot(projectId), `TASK-${nnn}_${src}_${slug}`);
253
257
  }
@@ -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']);
@@ -388,8 +413,9 @@ async function forkTask(mainWtPath, { type, dev, src, slug }) {
388
413
  if (parent === 'HEAD') {
389
414
  throw new Error('메인 워크트리가 detached HEAD — 부모로 삼을 브랜치에 체크아웃 필요.');
390
415
  }
391
- // SSoT 안전망 — 같은 출처(BL-NNN/RM-NNN)가 이미 진행중이면 거부. DR은 별도 ID 없어 검사 제외.
392
- if (src !== 'DR') {
416
+ // SSoT 안전망 — 같은 항목 출처(BL-NNN/구형 RM-NNN)가 이미 진행중이면 거부.
417
+ // DR(별도 ID 없음)·ST-N(Stage 하나에서 복수 task가 정상)은 검사 제외.
418
+ if (/^(BL|RM)-/.test(src)) {
393
419
  const dup = getActiveTasks(mainWtPath).find((t) => t.src === src);
394
420
  if (dup) {
395
421
  throw new Error(`${src} 이미 진행중 (${dup.branch}) — 다른 세션이 같은 항목 진행`);
@@ -435,28 +461,51 @@ function computeNextPlanNumber(mainWtPath) {
435
461
  const BACKLOG_PLACEHOLDER =
436
462
  '(사용자 발화 또는 task-close 직후 메인 감지로 한 행씩 추가. 빈 상태 default.)';
437
463
 
438
- function getActiveVersion(mainWtPath) {
439
- // .project/AGENT-GUIDE.md '## 활성 plan 버전' 섹션 다음 비어 있지 않은 첫 줄에서 활성 plan 식별자 추출.
440
- // plan = 기능 그룹 단위 → 식별자는 vX.X 버전명 또는 기능 그룹 slug(compare-products 등) 모두 가능.
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) {
441
470
  const guidePath = path.join(mainWtPath, '.project', 'AGENT-GUIDE.md');
442
- if (!fs.existsSync(guidePath)) {
443
- throw new Error(`.project/AGENT-GUIDE.md 부재. /project-init 먼저 호출.`);
444
- }
445
- const content = fs.readFileSync(guidePath, 'utf8');
446
- const sec = content.match(/##\s*활성 plan 버전\s*\n+([^\n]+)/);
447
- if (!sec) {
448
- throw new Error(`.project/AGENT-GUIDE.md '## 활성 plan 버전' 섹션 부재.`);
449
- }
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;
450
474
  // 첫 토큰 = plan 폴더 이름(' — 설명' 앞부분). '<예: ...>' 자리표시자는 미설정으로 간주.
451
475
  const firstLine = sec[1].trim();
452
- if (!firstLine || firstLine.startsWith('<')) {
453
- throw new Error(
454
- `'## 활성 plan 버전' 값 미설정(${firstLine || '빈 줄'}) — /plan-init으로 활성 plan 갱신 필요.`,
455
- );
456
- }
476
+ if (!firstLine || firstLine.startsWith('<')) return null;
457
477
  return firstLine.split(/\s+/)[0];
458
478
  }
459
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
+
460
509
  function getBacklogPath(mainWtPath, activeVersion) {
461
510
  const v = activeVersion ?? getActiveVersion(mainWtPath);
462
511
  return path.join(mainWtPath, '.project', 'tasks', v, 'BACKLOG.md');
@@ -813,7 +862,7 @@ function _renderTaskSkeleton({ taskNum, title, created, plan, docType, size, par
813
862
 
814
863
  function scaffoldTaskDoc(mainWtPath, wtPath, meta) {
815
864
  // meta: { taskNum, slug, title, type(브랜치 타입), size, parent, plan?, promoted?, created? }
816
- // 위치: 등록(.gitignore) 케이스 → 메인WT, 미등록 → 워크트리. plan 미지정 시 AGENT-GUIDE에서 검출.
865
+ // 위치: 등록(.gitignore) 케이스 → 메인WT, 미등록 → 워크트리. plan 미지정 시 활성 plan(manifest.activePlan)에서 검출.
817
866
  // parent 미지정 시 현재 브랜치 캡처(단독 호출 안전망 — 통상 fork가 넘김).
818
867
  // taskNum이 고유(fork init 락 채번)라 동일 문서 동시 쓰기 경합 없음 → 락 불요.
819
868
  const registered = isProjectRegistered(mainWtPath);
@@ -847,7 +896,7 @@ function scaffoldTaskDoc(mainWtPath, wtPath, meta) {
847
896
  }
848
897
 
849
898
  // ─── plan 생성 (코드화, C3) ─────────────────────────────────────────
850
- // 채번 + legacy 게이트 + 폴더 mkdir + ROADMAP/PLAN/BACKLOG 골격 + AGENT-GUIDE 활성 plan 갱신을 코드로.
899
+ // 채번 + legacy 게이트 + 폴더 mkdir + ROADMAP/PLAN/BACKLOG 골격 + manifest.activePlan 갱신을 코드로.
851
900
  // LLM 몫(FEATURES/UX-UI delta · ROADMAP Stage 내용 · PLAN 링크)은 골격의 placeholder로 남긴다.
852
901
 
853
902
  function _renderRoadmap(plan) {
@@ -902,29 +951,23 @@ function _renderPlanBacklog(plan) {
902
951
  }
903
952
 
904
953
  function _setActivePlan(mainWtPath, plan) {
905
- // AGENT-GUIDE.md '## 활성 plan 버전' 섹션 다음 비어있지 않은 첫 줄을 plan 값으로 교체(헤딩 텍스트 불변).
906
- const guidePath = path.join(mainWtPath, '.project', 'AGENT-GUIDE.md');
907
- if (!fs.existsSync(guidePath)) {
908
- throw new Error('.project/AGENT-GUIDE.md 부재. /project-init 먼저 호출.');
909
- }
910
- const lines = fs.readFileSync(guidePath, 'utf8').split('\n');
911
- const hIdx = lines.findIndex((l) => /^##\s*활성 plan 버전/.test(l));
912
- if (hIdx === -1) {
913
- 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' 먼저 실행.`);
914
960
  }
915
- const valueLine = `${plan} — \`.project/plans/${plan}/\` 참조`;
916
- let i = hIdx + 1;
917
- while (i < lines.length && lines[i].trim() === '') i++;
918
- if (i < lines.length && !/^#/.test(lines[i])) {
919
- lines[i] = valueLine; // 기존 값 줄 교체
920
- } else {
921
- lines.splice(hIdx + 1, 0, '', valueLine); // 값 줄 부재 → 헤딩 뒤 삽입
922
- }
923
- 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
+ });
924
967
  }
925
968
 
926
969
  function initPlan(mainWtPath, slug, opts = {}) {
927
- // 채번 → legacy 게이트 → 폴더 + 골격 + AGENT-GUIDE 갱신. slug 검증은 CLI 측.
970
+ // 채번 → legacy 게이트 → 폴더 + 골격 + manifest.activePlan 갱신. slug 검증은 CLI 측.
928
971
  // legacy 폴더(NNN_ 아닌 plan 폴더) 잔존 + !force → { gated:true } 반환(CLI가 사용자 confirm 후 force 재호출).
929
972
  const { next, legacyDirs } = computeNextPlanNumber(mainWtPath);
930
973
  if (legacyDirs.length && !opts.force) {
@@ -1006,10 +1049,26 @@ function _porcelainFiles(wtPath) {
1006
1049
  .filter(Boolean);
1007
1050
  }
1008
1051
 
1052
+ // 빌드 산출물 판정 — 소스 변경에 도구가 따라 바꾸는 파생 파일 (Phase 소속을 묻는 것 자체가 무의미).
1053
+ // Dev Plan에 명시 안 돼도 미매핑 blocked 대신 마지막 Phase에 자동 편입 (stash·recordion FRICTION: pbxproj 미매핑만 6회).
1054
+ const BUILD_ARTIFACT_RE = [
1055
+ /\.pbxproj$/,
1056
+ /(^|\/)package-lock\.json$/,
1057
+ /(^|\/)yarn\.lock$/,
1058
+ /(^|\/)pnpm-lock\.yaml$/,
1059
+ /(^|\/)Cargo\.lock$/,
1060
+ /(^|\/)Gemfile\.lock$/,
1061
+ /(^|\/)Podfile\.lock$/,
1062
+ ];
1063
+
1009
1064
  function splitUncommittedByPhase(wtPath, taskMdPath, meta) {
1010
1065
  // 코드 변경분(.project/ 외)을 Dev Plan Phase에 매핑해 Phase별 자동 커밋. meta: { taskNum, type }.
1011
- // - 모든 코드 파일이 정확히 1 Phase에 매핑 → Phase 순서대로 자동 커밋.
1012
- // - 미매핑/다중매핑/Phase 파싱 불가 → { ambiguous } 반환(커밋 X) → 스킬이 수동 처리.
1066
+ // 매핑 폴백 3규칙 (stash 6회 + recordion 5회 blocked 마찰 반영 — 11회 전부 기계적 편입이었음):
1067
+ // 1. 다중 Phase 매핑 → 가장 이른 Phase에 편입 + 커밋 메시지에 '(Phase a·b 변경 포함)' 표기.
1068
+ // 뒤 Phase가 앞 Phase 타입·함수를 참조하므로 이른 쪽이 의존 방향과 일치. 중간 커밋 독립 빌드는 비보장(GIT_RULE 명문).
1069
+ // 2. 미매핑 + 빌드 산출물(BUILD_ARTIFACT_RE) → 매핑된 그룹 중 마지막 Phase(없으면 Dev Plan 마지막)에
1070
+ // 자동 편입 + '(빌드 산출물 자동 편입)' 표기.
1071
+ // 3. 미매핑 + 일반 파일 → { ambiguous } 반환(커밋 X) — 계획 누락 가능성이라 사람 판단으로 유지.
1013
1072
  // .project/ 메타(flows/문서/changelog)는 제외 — closeTask가 별도 커밋.
1014
1073
  const codeFiles = _porcelainFiles(wtPath).filter((f) => !f.startsWith('.project/'));
1015
1074
  if (codeFiles.length === 0) return { committed: [], empty: true };
@@ -1028,21 +1087,44 @@ function splitUncommittedByPhase(wtPath, taskMdPath, meta) {
1028
1087
  )
1029
1088
  .map(({ i }) => i);
1030
1089
  const fileToPhase = {};
1090
+ const multiPhase = []; // 규칙 1 적용분 — { file, phases:[num...], assignedTo }
1091
+ const artifactFiles = []; // 규칙 2 후보 — 매핑 확정 후 편입 Phase 결정
1031
1092
  for (const f of codeFiles) {
1032
1093
  const m = matchOf(f);
1033
- if (m.length !== 1) {
1094
+ if (m.length === 1) {
1095
+ fileToPhase[f] = m[0];
1096
+ } else if (m.length > 1) {
1097
+ const earliest = Math.min(...m);
1098
+ fileToPhase[f] = earliest;
1099
+ multiPhase.push({ file: f, phases: m.map((i) => phases[i].num), assignedTo: phases[earliest].num });
1100
+ } else if (BUILD_ARTIFACT_RE.some((re) => re.test(f))) {
1101
+ artifactFiles.push(f);
1102
+ } else {
1034
1103
  return {
1035
1104
  ambiguous: true,
1036
- reason: m.length === 0 ? `Phase 미매핑: ${f}` : `다중 Phase 매핑: ${f}`,
1105
+ reason: `Phase 미매핑: ${f}`,
1037
1106
  files: codeFiles,
1038
1107
  phases: phases.map((p) => ({ num: p.num, name: p.name, files: p.files })),
1039
1108
  };
1040
1109
  }
1041
- fileToPhase[f] = m[0];
1042
1110
  }
1111
+ // 규칙 2 — 산출물 편입: 매핑된 그룹 중 마지막 Phase, 하나도 없으면 Dev Plan 마지막 Phase
1112
+ const autoAssigned = [];
1113
+ if (artifactFiles.length > 0) {
1114
+ const mappedIdx = Object.values(fileToPhase);
1115
+ const lastIdx = mappedIdx.length > 0 ? Math.max(...mappedIdx) : phases.length - 1;
1116
+ for (const f of artifactFiles) {
1117
+ fileToPhase[f] = lastIdx;
1118
+ autoAssigned.push({ file: f, assignedTo: phases[lastIdx].num });
1119
+ }
1120
+ }
1121
+ // 파일별 커밋 메시지 표기 (자동 편입의 근거를 커밋에 남김 — 코드가 몰래 정한 걸 드러낸다)
1122
+ const noteOf = {};
1123
+ for (const x of multiPhase) noteOf[x.file] = ` (Phase ${x.phases.join('·')} 변경 포함)`;
1124
+ for (const x of autoAssigned) noteOf[x.file] = ' (빌드 산출물 자동 편입)';
1043
1125
  // Phase별 그룹 → 순서대로 커밋
1044
1126
  const groups = new Map();
1045
- for (const f of codeFiles) {
1127
+ for (const f of Object.keys(fileToPhase)) {
1046
1128
  const i = fileToPhase[f];
1047
1129
  if (!groups.has(i)) groups.set(i, []);
1048
1130
  groups.get(i).push(f);
@@ -1056,18 +1138,22 @@ function splitUncommittedByPhase(wtPath, taskMdPath, meta) {
1056
1138
  execFileSync('git', ['-C', wtPath, 'add', '--', ...grp], { stdio: 'pipe' });
1057
1139
  const msg =
1058
1140
  `${tag}: [TASK-${nnn}] Phase ${p.num} - ${p.name}\n\n` +
1059
- grp.map((f) => `- ${f}`).join('\n') +
1141
+ grp.map((f) => `- ${f}${noteOf[f] || ''}`).join('\n') +
1060
1142
  `\n- 사유: ${p.name}`;
1061
1143
  execFileSync('git', ['-C', wtPath, 'commit', '-m', msg], { stdio: 'pipe' });
1062
1144
  committed.push({ phase: p.num, name: p.name, files: grp });
1063
1145
  }
1064
- return { committed };
1146
+ const result = { committed };
1147
+ if (multiPhase.length > 0) result.multiPhase = multiPhase;
1148
+ if (autoAssigned.length > 0) result.autoAssigned = autoAssigned;
1149
+ return result;
1065
1150
  }
1066
1151
 
1067
1152
  async function closeTask(mainWtPath, { taskNum }, opts = {}) {
1068
1153
  // 결정적 준비만 — 비가역(머지·정리)·충돌 해결은 스킬이 후속 수행.
1069
1154
  // 반환: { blocked } (게이트 미충족/매핑 모호) 또는
1070
- // { prepped, branch, parent, wtPath, registered, commits, aheadOfParent }. parent = 스킬 머지 대상.
1155
+ // { prepped, branch, parent, wtPath, registered, commits, aheadOfParent,
1156
+ // multiPhase?, autoAssigned? (매핑 폴백 적용 시 내역) }. parent = 스킬 머지 대상.
1071
1157
  const active = getActiveTasks(mainWtPath).find((t) => t.taskNum === taskNum);
1072
1158
  if (!active) {
1073
1159
  throw new Error(`TASK-${String(taskNum).padStart(3, '0')} 진행중 브랜치 없음 (SSoT 조회).`);
@@ -1158,7 +1244,7 @@ async function closeTask(mainWtPath, { taskNum }, opts = {}) {
1158
1244
  gitCapture(wtPath, ['rev-list', '--count', `${parent}..HEAD`], { allowFail: true }) || '0',
1159
1245
  10,
1160
1246
  );
1161
- return {
1247
+ const prepResult = {
1162
1248
  prepped: true,
1163
1249
  taskNum,
1164
1250
  branch: active.branch,
@@ -1169,6 +1255,10 @@ async function closeTask(mainWtPath, { taskNum }, opts = {}) {
1169
1255
  commits,
1170
1256
  aheadOfParent: aheadFinal,
1171
1257
  };
1258
+ // 매핑 폴백 적용 내역 노출 — 코드가 자동 편입한 걸 스킬·사용자가 볼 수 있게
1259
+ if (split.multiPhase) prepResult.multiPhase = split.multiPhase;
1260
+ if (split.autoAssigned) prepResult.autoAssigned = split.autoAssigned;
1261
+ return prepResult;
1172
1262
  }
1173
1263
 
1174
1264
  module.exports = {
@@ -1207,6 +1297,7 @@ module.exports = {
1207
1297
  getInitLockPath,
1208
1298
  withMergeLock,
1209
1299
  withMetaLock,
1300
+ withMetaLockSync,
1210
1301
  currentBranch,
1211
1302
  assertMainWorktreeOn,
1212
1303
  parseBranchName,
@@ -1217,6 +1308,7 @@ module.exports = {
1217
1308
  // 백로그 (0.1.2+)
1218
1309
  BACKLOG_PLACEHOLDER,
1219
1310
  getActiveVersion,
1311
+ setActivePlan,
1220
1312
  getBacklogPath,
1221
1313
  computeNextBLNumber,
1222
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 생성 (채번+골격+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 게이트 무시'),
@@ -189,7 +189,7 @@ server.registerTool(
189
189
  inputSchema: {
190
190
  type: z.enum(VALID_TYPES),
191
191
  dev: z.string().describe('개발자 식별자 (claude / angar2 등)'),
192
- src: z.string().regex(/^(BL-\d+|RM-\d+|DR)$/, 'BL-NNN | RM-NNN | DR'),
192
+ src: z.string().regex(/^(BL-\d+|RM-\d+|ST-\d+|DR)$/, 'BL-NNN | ST-N(로드맵 Stage) | DR (구형 RM-NNN 호환)'),
193
193
  slug: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, '영어 kebab-case'),
194
194
  size: z.enum(VALID_SIZES).optional(),
195
195
  title: z.string().optional().describe('한국어 제목 (size와 함께 주면 골격 생성)'),