@angar2/taskery 0.4.0 → 0.6.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/bin/lib.js CHANGED
@@ -22,11 +22,11 @@
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
- * - getActiveTasks(mainWtPath): SSoT 조회 (git branch --no-merged dev --list 'feature/*_TASK-*' ...)
26
- * - getNextTaskNumber(mainWtPath): (진행중 ∪ dev 머지 히스토리) 최대 + 1
27
- * - forkTask(mainWtPath, {type,dev,src,slug}): init 락 안에서 채번+워크트리·브랜치 생성 원자 실행
28
- * - assertMainWorktreeOnDev(mainWtPath): 메인 워크트리가 dev 체크아웃 상태인지 검증
29
- * - assertDevExists(mainWtPath): dev 브랜치 존재 검증
25
+ * - getActiveTasks(mainWtPath): SSoT 조회 (git branch --list 'feature/*_TASK-*' ...)
26
+ * - getNextTaskNumber(mainWtPath): (진행중 ∪ 전 ref 머지 히스토리) 최대 + 1 (부모 무관 전역 채번)
27
+ * - forkTask(mainWtPath, {type,dev,src,slug}): init 락 안에서 부모(현재 브랜치) 캡처+채번+워크트리·브랜치 생성 원자 실행
28
+ * - currentBranch(mainWtPath): 메인 워크트리 현재 브랜치 = 부모 브랜치(detached면 'HEAD')
29
+ * - assertMainWorktreeOn(mainWtPath, branch): 메인 워크트리가 지정 브랜치 체크아웃 상태인지 검증
30
30
  *
31
31
  * 백로그 (0.1.2+):
32
32
  * - getActiveVersion(mainWtPath): .project/AGENT-GUIDE.md에서 활성 plan 식별자(버전 또는 기능 그룹명) 추출
@@ -308,17 +308,15 @@ async function withMetaLock(filePath, fn, opts = {}) {
308
308
  }
309
309
  }
310
310
 
311
- function assertDevExists(mainWtPath) {
312
- const ok = gitCapture(mainWtPath, ['rev-parse', '--verify', 'dev'], { allowFail: true });
313
- if (ok === null) {
314
- throw new Error('dev 브랜치 부재. 사용자 결정 필요 (생성?).');
315
- }
311
+ function currentBranch(mainWtPath) {
312
+ // 메인 워크트리의 현재 브랜치 = 태스크의 부모 브랜치(0.6.0). detached HEAD면 'HEAD' 반환.
313
+ return gitCapture(mainWtPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
316
314
  }
317
315
 
318
- function assertMainWorktreeOnDev(mainWtPath) {
319
- const current = gitCapture(mainWtPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
320
- if (current !== 'dev') {
321
- throw new Error(`메인 워크트리가 dev 아님 (현재: ${current}). taskery 정책 위배.`);
316
+ function assertMainWorktreeOn(mainWtPath, branch) {
317
+ const current = currentBranch(mainWtPath);
318
+ if (current !== branch) {
319
+ throw new Error(`메인 워크트리가 ${branch} 아님 (현재: ${current}). 부모 브랜치로 체크아웃 필요.`);
322
320
  }
323
321
  }
324
322
 
@@ -341,8 +339,8 @@ function parseBranchName(branch) {
341
339
  }
342
340
 
343
341
  function getActiveTasks(mainWtPath) {
344
- // SSoT — TASK-* 패턴 작업 브랜치 (분기 직후 빈 브랜치 포함). `--no-merged dev` 미사용:
345
- // 워크트리 분기 직후 브랜치는 dev와 동일 commit이라 `--no-merged dev`가 *완전 머지 상태*로
342
+ // SSoT — TASK-* 패턴 작업 브랜치 (분기 직후 빈 브랜치 포함). `--no-merged <부모>` 미사용:
343
+ // 워크트리 분기 직후 브랜치는 부모와 동일 commit이라 `--no-merged`가 *완전 머지 상태*로
346
344
  // 처리 → 빈 결과 → getNextTaskNumber 충돌. 브랜치 존재 자체가 SSoT (정상 흐름은 task-close
347
345
  // 자동 삭제로 자연 정리, 보존 키워드 잔존분도 *진행중*으로 잡힘이 정합).
348
346
  const out = gitCapture(mainWtPath, ['branch', '--list', ...BRANCH_PATTERNS]);
@@ -358,12 +356,16 @@ function getActiveTasks(mainWtPath) {
358
356
  function getNextTaskNumber(mainWtPath) {
359
357
  // 1. 진행중 (SSoT)
360
358
  const activeNums = getActiveTasks(mainWtPath).map((t) => t.taskNum);
361
- // 2. dev 머지 히스토리 (GIT_RULE 풍부 메시지)
359
+ // 2. 머지 히스토리 (GIT_RULE 풍부 메시지) — 모든 ref(`--all`) 스캔.
360
+ // 부모 브랜치가 dev로 고정되지 않으므로(0.6.0) 닫힌 태스크는 각자의 부모에 병합돼 있다.
361
+ // 특정 부모(과거 'dev') 하나만 grep하면 다른 부모로 닫힌 번호를 놓쳐 채번 충돌 → `--all`로
362
+ // 전 ref의 TASK-NNN 최대를 취해 번호를 리포 전역으로 유지.
363
+ // --extended-regexp(ERE)에서 `+`는 수량자 — `\+`는 *리터럴 +* 라 "TASK-001"을 못 잡는다.
362
364
  const log = gitCapture(mainWtPath, [
363
365
  'log',
364
- 'dev',
366
+ '--all',
365
367
  '--grep',
366
- 'TASK-[0-9]\\+',
368
+ 'TASK-[0-9]+',
367
369
  '--extended-regexp',
368
370
  '--oneline',
369
371
  ]);
@@ -381,8 +383,11 @@ async function forkTask(mainWtPath, { type, dev, src, slug }) {
381
383
  // getNextTaskNumber 직후 worktree add까지 끝내므로 다음 호출은 늘어난 번호를 본다.
382
384
  const projectId = getProjectId(mainWtPath);
383
385
  return withMetaLock(getInitLockPath(projectId), () => {
384
- assertDevExists(mainWtPath);
385
- assertMainWorktreeOnDev(mainWtPath);
386
+ // 부모 = 메인 워크트리의 현재 브랜치(0.6.0). dev 하드코딩 제거 — 서 있는 브랜치가 곧 부모.
387
+ const parent = currentBranch(mainWtPath);
388
+ if (parent === 'HEAD') {
389
+ throw new Error('메인 워크트리가 detached HEAD — 부모로 삼을 브랜치에 체크아웃 필요.');
390
+ }
386
391
  // SSoT 안전망 — 같은 출처(BL-NNN/RM-NNN)가 이미 진행중이면 거부. DR은 별도 ID 없어 검사 제외.
387
392
  if (src !== 'DR') {
388
393
  const dup = getActiveTasks(mainWtPath).find((t) => t.src === src);
@@ -395,11 +400,12 @@ async function forkTask(mainWtPath, { type, dev, src, slug }) {
395
400
  const branch = `${type}/${dev}_TASK-${nnn}_${src}_${slug}`;
396
401
  const wtPath = getWorktreePath(projectId, { taskNum, src, slug });
397
402
  mkdirp(getWorktreesRoot(projectId));
398
- // 동일 브랜치명은 git이 거부(같은 항목 동시 분기 차단). 실패 시 throw → fork.js가 사용자 보고.
399
- execFileSync('git', ['-C', mainWtPath, 'worktree', 'add', wtPath, '-b', branch, 'dev'], {
403
+ // 부모 브랜치를 base로 워크트리·브랜치 생성. 동일 브랜치명은 git이 거부(같은 항목 동시 분기
404
+ // 차단). 실패 시 throw → fork.js가 사용자 보고.
405
+ execFileSync('git', ['-C', mainWtPath, 'worktree', 'add', wtPath, '-b', branch, parent], {
400
406
  stdio: 'pipe',
401
407
  });
402
- return { taskNum, nnn, branch, wtPath, projectId };
408
+ return { taskNum, nnn, branch, wtPath, projectId, parent };
403
409
  });
404
410
  }
405
411
 
@@ -590,6 +596,581 @@ async function markBacklogChecked(mainWtPath, blId, taskNum) {
590
596
  });
591
597
  }
592
598
 
599
+ // ─── task 헤더 / 상태 전이 (코드화) ─────────────────────────────────
600
+ // 헤더 파싱(6컬럼 · 구 5컬럼 폴백) · task 문서 위치 판정 · 7상태 유효전이 검증을 코드로 단일화.
601
+ // 계약: TASK_DOC_RULE §1.1(헤더) · §1.2(7상태) · §1.5(문서 위치).
602
+
603
+ // 7×7 유효전이표 (TASK_DOC_RULE §1.2 + FAIL/UNCERTAIN 분기). from → 허용 to 목록.
604
+ // - 정상 진행: draft→planned→developing→developed→testing→tested→closed
605
+ // - 되돌림: testing→developing (task-test FAIL "코드 결함" 사용자 "고쳐")
606
+ // - closed는 종료 상태(빈 목록) — closed-immutable hook이 이후 본 파일 잠금.
607
+ const TRANSITIONS = {
608
+ draft: ['planned'],
609
+ planned: ['developing'],
610
+ developing: ['developed'],
611
+ developed: ['testing'],
612
+ testing: ['tested', 'developing'],
613
+ tested: ['closed'],
614
+ closed: [],
615
+ };
616
+
617
+ function _locateHeaderRow(content) {
618
+ // 헤더 표(라벨행 → 구분선 → 데이터행)에서 데이터행을 찾아 split 셀과 줄 인덱스 반환.
619
+ // 데이터행 셀: `| a | b | c | d | e |` → split('|') = ['', ' a ', ..., ' e ', '']. null이면 표 부재.
620
+ const lines = content.split('\n');
621
+ let labelIdx = -1;
622
+ for (let i = 0; i < lines.length; i++) {
623
+ const l = lines[i];
624
+ if (l.trimStart().startsWith('|') && l.includes('생성일') && l.includes('상태')) {
625
+ labelIdx = i;
626
+ break;
627
+ }
628
+ }
629
+ if (labelIdx === -1) return null;
630
+ let dataIdx = -1;
631
+ for (let j = labelIdx + 1; j < lines.length; j++) {
632
+ const t = lines[j].trim();
633
+ if (t === '') continue;
634
+ if (/^[|\s:-]+$/.test(t)) continue; // 구분선 (|---|---|)
635
+ if (lines[j].trimStart().startsWith('|')) {
636
+ dataIdx = j;
637
+ break;
638
+ }
639
+ break; // 표 끝
640
+ }
641
+ if (dataIdx === -1) return null;
642
+ return { lines, dataIdx, cells: lines[dataIdx].split('|') };
643
+ }
644
+
645
+ function parseTaskHeader(taskMdPath) {
646
+ // 헤더를 구조화 → { created, plan, type, size, parent, status }. 표 부재/컬럼 부족 시 throw.
647
+ // 6컬럼(0.6.0+): 생성일·플랜·유형·규모·부모 브랜치·상태. 부모 브랜치는 상태 *앞* — 상태를
648
+ // 항상 마지막 셀로 유지해 setStatus의 `length-2` 로직이 구·신 양쪽에서 성립하게 함.
649
+ // 5컬럼(구): 부모 칸 부재 → parent='dev' 폴백(0.6.0 이전 문서 하위호환).
650
+ const content = fs.readFileSync(taskMdPath, 'utf8');
651
+ const loc = _locateHeaderRow(content);
652
+ if (!loc) throw new Error(`헤더 표 파싱 실패 (TASK_DOC_RULE §1.1 형식 아님): ${taskMdPath}`);
653
+ const inner = loc.cells.slice(1, -1).map((c) => c.trim());
654
+ if (inner.length < 5) {
655
+ throw new Error(`헤더 표 컬럼 부족(${inner.length}/5): ${taskMdPath}`);
656
+ }
657
+ if (inner.length >= 6) {
658
+ const [created, plan, type, size, parent, status] = inner;
659
+ return { created, plan, type, size, parent, status };
660
+ }
661
+ const [created, plan, type, size, status] = inner;
662
+ return { created, plan, type, size, parent: 'dev', status };
663
+ }
664
+
665
+ function isProjectRegistered(mainWtPath) {
666
+ // .gitignore에 .project/ 등록 여부 — check-ignore exit 0 = 등록(퍼블릭 default, 문서는 메인WT 공유),
667
+ // exit 1 = 미등록(문서는 워크트리 안, 머지로 부모 반영). task-init Step7 분기와 동일 판정.
668
+ try {
669
+ execFileSync(
670
+ 'git',
671
+ ['-C', mainWtPath, 'check-ignore', '-q', path.join(mainWtPath, '.project', 'dummy')],
672
+ { stdio: 'ignore' },
673
+ );
674
+ return true;
675
+ } catch (e) {
676
+ return false;
677
+ }
678
+ }
679
+
680
+ function resolveTaskDocPath(mainWtPath, wtPath, { taskNum }) {
681
+ // task 문서 위치 단일 판정 — 등록 케이스로 base 결정 후 taskNum으로 탐색.
682
+ // 단일 파일(`<NNN>_<slug>.md`) / 폴더 승격(`TASK-<NNN>_<slug>/task.md`) 양쪽 + plan 폴더 무관(*) 글롭.
683
+ // 발견 못하면 null. 반환: { registered, plan, promoted, dir, file }.
684
+ const nnn = String(taskNum).padStart(3, '0');
685
+ const registered = isProjectRegistered(mainWtPath);
686
+ const base = registered ? mainWtPath : wtPath;
687
+ const tasksRoot = path.join(base, '.project', 'tasks');
688
+ if (!fs.existsSync(tasksRoot)) return null;
689
+ const fileRe = new RegExp(`^${nnn}_.+\\.md$`);
690
+ const dirRe = new RegExp(`^TASK-${nnn}_`);
691
+ for (const planEnt of fs.readdirSync(tasksRoot, { withFileTypes: true })) {
692
+ if (!planEnt.isDirectory()) continue;
693
+ const planDir = path.join(tasksRoot, planEnt.name);
694
+ for (const ent of fs.readdirSync(planDir, { withFileTypes: true })) {
695
+ if (ent.isFile() && fileRe.test(ent.name)) {
696
+ return {
697
+ registered,
698
+ plan: planEnt.name,
699
+ promoted: false,
700
+ dir: planDir,
701
+ file: path.join(planDir, ent.name),
702
+ };
703
+ }
704
+ if (ent.isDirectory() && dirRe.test(ent.name)) {
705
+ const f = path.join(planDir, ent.name, 'task.md');
706
+ if (fs.existsSync(f)) {
707
+ return {
708
+ registered,
709
+ plan: planEnt.name,
710
+ promoted: true,
711
+ dir: path.join(planDir, ent.name),
712
+ file: f,
713
+ };
714
+ }
715
+ }
716
+ }
717
+ }
718
+ return null;
719
+ }
720
+
721
+ async function setStatus(taskMdPath, next) {
722
+ // 헤더 상태 컬럼을 next로 전이 — 현재 상태 읽고 7×7 표 검증 후 치환 (withMetaLock 직렬화).
723
+ // - next가 7상태 아님 → throw. 현재==next → idempotent no-op (changed:false).
724
+ // - 유효전이 아님 → throw (잘못된 전이 거부).
725
+ if (!Object.prototype.hasOwnProperty.call(TRANSITIONS, next)) {
726
+ throw new Error(`알 수 없는 상태: ${next} (유효: ${Object.keys(TRANSITIONS).join(', ')})`);
727
+ }
728
+ if (!fs.existsSync(taskMdPath)) throw new Error(`task 문서 없음: ${taskMdPath}`);
729
+ return await withMetaLock(taskMdPath, async () => {
730
+ const content = fs.readFileSync(taskMdPath, 'utf8');
731
+ const loc = _locateHeaderRow(content);
732
+ if (!loc) throw new Error(`헤더 표 파싱 실패: ${taskMdPath}`);
733
+ const trimmed = loc.cells.slice(1, -1).map((c) => c.trim());
734
+ const cur = trimmed[trimmed.length - 1]; // 상태 = 항상 마지막 셀 (6컬럼: 부모 브랜치가 그 앞)
735
+ if (cur === next) return { changed: false, from: cur, to: next, path: taskMdPath };
736
+ const allowed = TRANSITIONS[cur] || [];
737
+ if (!allowed.includes(next)) {
738
+ throw new Error(
739
+ `잘못된 전이: ${cur} → ${next} (허용: ${allowed.length ? allowed.join(', ') : '없음 — 종료 상태'})`,
740
+ );
741
+ }
742
+ const statusCellIdx = loc.cells.length - 2; // 마지막 빈 셀 직전 = 상태
743
+ loc.cells[statusCellIdx] = loc.cells[statusCellIdx].replace(cur, next);
744
+ loc.lines[loc.dataIdx] = loc.cells.join('|');
745
+ fs.writeFileSync(taskMdPath, loc.lines.join('\n'));
746
+ return { changed: true, from: cur, to: next, path: taskMdPath };
747
+ });
748
+ }
749
+
750
+ // ─── task 문서 골격 생성 (코드화, C1) ───────────────────────────────
751
+ // fork 직후 §1.3 빈 골격을 코드가 자동 생성 — 생성일=오늘 / status=draft 자동, 파일명 규칙 코드 보장.
752
+
753
+ // 생성일=오늘(로컬 타임존 YYYY-MM-DD). toISOString()은 UTC라 KST 자정~09시 사이 전날로 박힘 → 로컬 산출.
754
+ function _todayLocal() {
755
+ const d = new Date();
756
+ const y = d.getFullYear();
757
+ const m = String(d.getMonth() + 1).padStart(2, '0');
758
+ const day = String(d.getDate()).padStart(2, '0');
759
+ return `${y}-${m}-${day}`;
760
+ }
761
+
762
+ // 브랜치 타입 → 문서 유형(§1.1). improve↔improvement만 다름.
763
+ const DOC_TYPE_OF = {
764
+ feature: 'feature',
765
+ bug: 'bug',
766
+ improve: 'improvement',
767
+ refactor: 'refactor',
768
+ docs: 'docs',
769
+ chore: 'chore',
770
+ };
771
+
772
+ function _buildTaskDocPath(base, plan, { taskNum, slug, promoted }) {
773
+ const nnn = String(taskNum).padStart(3, '0');
774
+ const planDir = path.join(base, '.project', 'tasks', plan);
775
+ if (promoted) {
776
+ const dir = path.join(planDir, `TASK-${nnn}_${slug}`);
777
+ return { dir, file: path.join(dir, 'task.md') };
778
+ }
779
+ return { dir: planDir, file: path.join(planDir, `${nnn}_${slug}.md`) };
780
+ }
781
+
782
+ function _renderTaskSkeleton({ taskNum, title, created, plan, docType, size, parent }) {
783
+ const nnn = String(taskNum).padStart(3, '0');
784
+ return [
785
+ `# TASK-${nnn} — ${title}`,
786
+ '',
787
+ '| 생성일 | 플랜 | 유형 | 규모 | 부모 브랜치 | 상태 |',
788
+ '|--------|------|------|------|-------------|------|',
789
+ `| ${created} | ${plan} | ${docType} | ${size} | ${parent} | draft |`,
790
+ '',
791
+ '## Requirements',
792
+ '',
793
+ '(사용자 요구 + 메인 증폭 — `/task-plan`에서 채움)',
794
+ '',
795
+ '## Scope',
796
+ '',
797
+ '(영향 범위 — `/task-plan`에서 채움)',
798
+ '',
799
+ '## Dev Plan',
800
+ '',
801
+ '(Phase 1, 2, ... — `/task-plan`에서 채움)',
802
+ '',
803
+ '## Test Plan',
804
+ '',
805
+ '(테스트 방법 + 검증 명령 — `/task-plan`에서 채움)',
806
+ '',
807
+ '## Result',
808
+ '',
809
+ '(진행 + 테스트 결과 — `/task-dev`, `/task-test`에서 채움)',
810
+ '',
811
+ ].join('\n');
812
+ }
813
+
814
+ function scaffoldTaskDoc(mainWtPath, wtPath, meta) {
815
+ // meta: { taskNum, slug, title, type(브랜치 타입), size, parent, plan?, promoted?, created? }
816
+ // 위치: 등록(.gitignore) 케이스 → 메인WT, 미등록 → 워크트리. plan 미지정 시 AGENT-GUIDE에서 검출.
817
+ // parent 미지정 시 현재 브랜치 캡처(단독 호출 안전망 — 통상 fork가 넘김).
818
+ // taskNum이 고유(fork init 락 채번)라 동일 문서 동시 쓰기 경합 없음 → 락 불요.
819
+ const registered = isProjectRegistered(mainWtPath);
820
+ const base = registered ? mainWtPath : wtPath;
821
+ const plan = meta.plan || getActiveVersion(mainWtPath);
822
+ const docType = DOC_TYPE_OF[meta.type] || meta.type;
823
+ const created = meta.created || _todayLocal();
824
+ const parent = meta.parent || currentBranch(mainWtPath);
825
+ const { dir, file } = _buildTaskDocPath(base, plan, {
826
+ taskNum: meta.taskNum,
827
+ slug: meta.slug,
828
+ promoted: !!meta.promoted,
829
+ });
830
+ if (fs.existsSync(file) && fs.readFileSync(file, 'utf8').trim()) {
831
+ throw new Error(`task 문서 이미 존재: ${file}`);
832
+ }
833
+ mkdirp(dir);
834
+ fs.writeFileSync(
835
+ file,
836
+ _renderTaskSkeleton({
837
+ taskNum: meta.taskNum,
838
+ title: meta.title,
839
+ created,
840
+ plan,
841
+ docType,
842
+ size: meta.size,
843
+ parent,
844
+ }),
845
+ );
846
+ return { registered, promoted: !!meta.promoted, plan, file };
847
+ }
848
+
849
+ // ─── plan 생성 (코드화, C3) ─────────────────────────────────────────
850
+ // 채번 + legacy 게이트 + 폴더 mkdir + ROADMAP/PLAN/BACKLOG 골격 + AGENT-GUIDE 활성 plan 갱신을 코드로.
851
+ // LLM 몫(FEATURES/UX-UI delta · ROADMAP Stage 내용 · PLAN 링크)은 골격의 placeholder로 남긴다.
852
+
853
+ function _renderRoadmap(plan) {
854
+ return [
855
+ `# ROADMAP — ${plan}`,
856
+ '',
857
+ '> 본 plan(기능 그룹) 한정 task 단계. Stage 단위(task 번호 강제 X), 상태 컬럼만 Living.',
858
+ '> 프로젝트 거시 빌드 순서는 PROJECT.md, 다음 기능 그룹 후보는 글로벌 BACKLOG.md.',
859
+ '',
860
+ '## Stage 1 — <영역명>',
861
+ '| 작업 단위 | 상태 |',
862
+ '|-----------|------|',
863
+ '| <한 task 분량 작업> | ⏳ 대기 |',
864
+ '',
865
+ ].join('\n');
866
+ }
867
+
868
+ function _renderPlanIndex(plan) {
869
+ return [
870
+ `# PLAN — ${plan}`,
871
+ '',
872
+ '> 이 plan(기능 그룹)의 얇은 인덱스. 각 항목은 루트 문서 섹션 링크 1줄 — 본문은 복제하지 않는다.',
873
+ '',
874
+ '## 이 기능 그룹이 건드리는 루트 문서',
875
+ '- [FEATURES.md › <이 그룹 섹션>](../../FEATURES.md) — <한 줄 요약>',
876
+ '- [UX-UI.md › <이 그룹 섹션>](../../UX-UI.md) — <한 줄 요약>',
877
+ '- (DATA-MODEL / API-SPEC — task 진행이 구현 동반으로 채움)',
878
+ '',
879
+ '## task 체크리스트',
880
+ '- [ ] <작업 단위> (ROADMAP Stage N)',
881
+ '',
882
+ ].join('\n');
883
+ }
884
+
885
+ function _renderPlanBacklog(plan) {
886
+ return [
887
+ `# BACKLOG — ${plan}`,
888
+ '',
889
+ `> 본 파일은 *${plan} plan 진행 중 누적되는 후속 task 후보* 추적용.`,
890
+ '> 글로벌 `.project/BACKLOG.md` (다음 기능 그룹 후보 카탈로그) 와 별개.',
891
+ '',
892
+ '| 위치 | 영역 | 역할 |',
893
+ '|------|------|------|',
894
+ '| `.project/BACKLOG.md` (글로벌) | 다음 기능 그룹 후보 카탈로그 (다음 plan에서 검토) | Living document |',
895
+ `| \`.project/tasks/${plan}/BACKLOG.md\` (본 파일) | 현재 plan 진행 중 누적된 후속 task 후보 | 한 plan 내 task close 직후 후보 누적 |`,
896
+ '',
897
+ '## 후속 task 후보',
898
+ '',
899
+ BACKLOG_PLACEHOLDER,
900
+ '',
901
+ ].join('\n');
902
+ }
903
+
904
+ 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 버전' 섹션 부재.");
914
+ }
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'));
924
+ }
925
+
926
+ function initPlan(mainWtPath, slug, opts = {}) {
927
+ // 채번 → legacy 게이트 → 폴더 + 골격 + AGENT-GUIDE 갱신. slug 검증은 CLI 측.
928
+ // legacy 폴더(NNN_ 아닌 plan 폴더) 잔존 + !force → { gated:true } 반환(CLI가 사용자 confirm 후 force 재호출).
929
+ const { next, legacyDirs } = computeNextPlanNumber(mainWtPath);
930
+ if (legacyDirs.length && !opts.force) {
931
+ return { gated: true, legacyDirs, next };
932
+ }
933
+ const plan = `${next}_${slug}`;
934
+ const planDir = path.join(mainWtPath, '.project', 'plans', plan);
935
+ const tasksDir = path.join(mainWtPath, '.project', 'tasks', plan);
936
+ if (fs.existsSync(planDir)) {
937
+ throw new Error(`plan 폴더 이미 존재: ${planDir}`);
938
+ }
939
+ mkdirp(planDir);
940
+ mkdirp(path.join(tasksDir, 'spec-diffs'));
941
+ mkdirp(path.join(tasksDir, 'screenshots'));
942
+ mkdirp(path.join(tasksDir, 'mockup'));
943
+ fs.writeFileSync(path.join(planDir, 'ROADMAP.md'), _renderRoadmap(plan));
944
+ fs.writeFileSync(path.join(planDir, 'PLAN.md'), _renderPlanIndex(plan));
945
+ fs.writeFileSync(path.join(tasksDir, 'BACKLOG.md'), _renderPlanBacklog(plan));
946
+ _setActivePlan(mainWtPath, plan);
947
+ return { gated: false, plan, nnn: next, planDir, tasksDir, legacyDirs };
948
+ }
949
+
950
+ // ─── task close 결정적 준비 (코드화, D1·D2) ─────────────────────────
951
+ // closeTask는 *결정적 준비만* 수행: 게이트(status=tested) → Phase 커밋(D2) → status=closed →
952
+ // flows/문서 커밋 → 추적마커. 비가역 3종(부모 브랜치 --no-ff 머지 · worktree remove · branch -d)과
953
+ // 충돌 해결·머지 락은 스킬(LLM)이 수행 — 위험 최소화 + 충돌은 LLM 판단 영역.
954
+
955
+ // 브랜치 타입 → 커밋 태그 (GIT_RULE 6-3).
956
+ const TAG_OF = {
957
+ feature: 'feat',
958
+ bug: 'fix',
959
+ improve: 'improve',
960
+ refactor: 'refactor',
961
+ docs: 'docs',
962
+ chore: 'docs',
963
+ };
964
+
965
+ function _parseDevPlanPhases(content) {
966
+ // ## Dev Plan 섹션의 `### Phase N — 이름` + `- 파일:` 필드 추출.
967
+ const lines = content.split('\n');
968
+ let inDevPlan = false;
969
+ const phases = [];
970
+ let cur = null;
971
+ for (const l of lines) {
972
+ if (/^##\s+Dev Plan/.test(l)) {
973
+ inDevPlan = true;
974
+ continue;
975
+ }
976
+ if (inDevPlan && /^##\s+/.test(l)) break; // 다음 섹션
977
+ if (!inDevPlan) continue;
978
+ const ph = l.match(/^###\s+Phase\s+(\d+)\s*(?:[—-]\s*)?(.*)$/);
979
+ if (ph) {
980
+ cur = { num: parseInt(ph[1], 10), name: ph[2].trim(), files: [] };
981
+ phases.push(cur);
982
+ continue;
983
+ }
984
+ if (!cur) continue;
985
+ const fm = l.match(/^\s*-\s*파일\s*:\s*(.+)$/);
986
+ if (fm) {
987
+ cur.files = fm[1]
988
+ .split(',')
989
+ .map((s) => s.replace(/`/g, '').trim())
990
+ .filter((s) => s && !/^[(<]/.test(s)); // placeholder(<...>, (...)) 제외
991
+ }
992
+ }
993
+ return phases;
994
+ }
995
+
996
+ function _porcelainFiles(wtPath) {
997
+ // git status --porcelain -uall → 변경 파일 경로 목록 (XY 상태코드 3자 제거).
998
+ // -uall: untracked 디렉토리를 개별 파일로 전개 (디렉토리 통째 `src/` 접힘 방지).
999
+ const out = gitCapture(wtPath, ['status', '--porcelain', '-uall'], { allowFail: true });
1000
+ if (!out) return [];
1001
+ return out
1002
+ .split('\n')
1003
+ .filter(Boolean)
1004
+ .map((l) => l.slice(3).trim())
1005
+ .map((p) => (p.includes(' -> ') ? p.split(' -> ')[1] : p)) // rename은 도착 경로
1006
+ .filter(Boolean);
1007
+ }
1008
+
1009
+ function splitUncommittedByPhase(wtPath, taskMdPath, meta) {
1010
+ // 코드 변경분(.project/ 외)을 Dev Plan Phase에 매핑해 Phase별 자동 커밋. meta: { taskNum, type }.
1011
+ // - 모든 코드 파일이 정확히 1 Phase에 매핑 → Phase 순서대로 자동 커밋.
1012
+ // - 미매핑/다중매핑/Phase 파싱 불가 → { ambiguous } 반환(커밋 X) → 스킬이 수동 처리.
1013
+ // .project/ 메타(flows/문서/changelog)는 제외 — closeTask가 별도 커밋.
1014
+ const codeFiles = _porcelainFiles(wtPath).filter((f) => !f.startsWith('.project/'));
1015
+ if (codeFiles.length === 0) return { committed: [], empty: true };
1016
+ const phases = _parseDevPlanPhases(fs.readFileSync(taskMdPath, 'utf8'));
1017
+ if (phases.length === 0) {
1018
+ return { ambiguous: true, reason: 'Dev Plan Phase 파싱 불가', files: codeFiles };
1019
+ }
1020
+ const matchOf = (f) =>
1021
+ phases
1022
+ .map((p, i) => ({ p, i }))
1023
+ .filter(({ p }) =>
1024
+ p.files.some((pf) => {
1025
+ const base = pf.replace(/\/$/, '');
1026
+ return f === pf || f === base || f.startsWith(base + '/') || base.endsWith('/' + f);
1027
+ }),
1028
+ )
1029
+ .map(({ i }) => i);
1030
+ const fileToPhase = {};
1031
+ for (const f of codeFiles) {
1032
+ const m = matchOf(f);
1033
+ if (m.length !== 1) {
1034
+ return {
1035
+ ambiguous: true,
1036
+ reason: m.length === 0 ? `Phase 미매핑: ${f}` : `다중 Phase 매핑: ${f}`,
1037
+ files: codeFiles,
1038
+ phases: phases.map((p) => ({ num: p.num, name: p.name, files: p.files })),
1039
+ };
1040
+ }
1041
+ fileToPhase[f] = m[0];
1042
+ }
1043
+ // Phase별 그룹 → 순서대로 커밋
1044
+ const groups = new Map();
1045
+ for (const f of codeFiles) {
1046
+ const i = fileToPhase[f];
1047
+ if (!groups.has(i)) groups.set(i, []);
1048
+ groups.get(i).push(f);
1049
+ }
1050
+ const tag = TAG_OF[meta.type] || 'docs';
1051
+ const nnn = String(meta.taskNum).padStart(3, '0');
1052
+ const committed = [];
1053
+ for (const i of [...groups.keys()].sort((a, b) => a - b)) {
1054
+ const p = phases[i];
1055
+ const grp = groups.get(i);
1056
+ execFileSync('git', ['-C', wtPath, 'add', '--', ...grp], { stdio: 'pipe' });
1057
+ const msg =
1058
+ `${tag}: [TASK-${nnn}] Phase ${p.num} - ${p.name}\n\n` +
1059
+ grp.map((f) => `- ${f}`).join('\n') +
1060
+ `\n- 사유: ${p.name}`;
1061
+ execFileSync('git', ['-C', wtPath, 'commit', '-m', msg], { stdio: 'pipe' });
1062
+ committed.push({ phase: p.num, name: p.name, files: grp });
1063
+ }
1064
+ return { committed };
1065
+ }
1066
+
1067
+ async function closeTask(mainWtPath, { taskNum }, opts = {}) {
1068
+ // 결정적 준비만 — 비가역(머지·정리)·충돌 해결은 스킬이 후속 수행.
1069
+ // 반환: { blocked } (게이트 미충족/매핑 모호) 또는
1070
+ // { prepped, branch, parent, wtPath, registered, commits, aheadOfParent }. parent = 스킬 머지 대상.
1071
+ const active = getActiveTasks(mainWtPath).find((t) => t.taskNum === taskNum);
1072
+ if (!active) {
1073
+ throw new Error(`TASK-${String(taskNum).padStart(3, '0')} 진행중 브랜치 없음 (SSoT 조회).`);
1074
+ }
1075
+ const projectId = getProjectId(mainWtPath);
1076
+ const wtPath = getWorktreePath(projectId, { taskNum, src: active.src, slug: active.slug });
1077
+ const resolved = resolveTaskDocPath(mainWtPath, wtPath, { taskNum });
1078
+ if (!resolved) throw new Error(`TASK-${taskNum} 문서 못 찾음.`);
1079
+
1080
+ const header = parseTaskHeader(resolved.file);
1081
+ // 부모 = 헤더 기록값(0.6.0, 구 문서는 'dev' 폴백). 메인 워크트리가 그 부모에 서 있어야
1082
+ // 스킬 후속 머지가 올바른 브랜치로 간다.
1083
+ const parent = header.parent;
1084
+ assertMainWorktreeOn(mainWtPath, parent);
1085
+ if (header.status !== 'tested') {
1086
+ return { blocked: 'status', current: header.status, docPath: resolved.file };
1087
+ }
1088
+
1089
+ // Phase 커밋 (D2) — 코드 변경분만
1090
+ const split = splitUncommittedByPhase(wtPath, resolved.file, { taskNum, type: active.type });
1091
+ if (split.ambiguous) {
1092
+ return {
1093
+ blocked: 'mapping',
1094
+ reason: split.reason,
1095
+ files: split.files,
1096
+ phases: split.phases,
1097
+ wtPath,
1098
+ docPath: resolved.file,
1099
+ };
1100
+ }
1101
+
1102
+ // status=closed (문서 위치 무관 — setStatus가 .gitignore 케이스 따라 처리)
1103
+ await setStatus(resolved.file, 'closed');
1104
+
1105
+ const commits = [...split.committed];
1106
+ // 미등록 케이스: .project/ 변경분(flows / 문서 / changelog)을 커밋
1107
+ if (!resolved.registered) {
1108
+ const flows = _porcelainFiles(wtPath).filter((f) => f.startsWith('.project/flows/'));
1109
+ if (flows.length) {
1110
+ execFileSync('git', ['-C', wtPath, 'add', '--', ...flows], { stdio: 'pipe' });
1111
+ execFileSync(
1112
+ 'git',
1113
+ ['-C', wtPath, 'commit', '-m', `docs: [TASK-${String(taskNum).padStart(3, '0')}] flows 갱신`],
1114
+ { stdio: 'pipe' },
1115
+ );
1116
+ commits.push({ phase: 'flows', files: flows });
1117
+ }
1118
+ const rest = _porcelainFiles(wtPath).filter((f) => f.startsWith('.project/'));
1119
+ if (rest.length) {
1120
+ execFileSync('git', ['-C', wtPath, 'add', '--', ...rest], { stdio: 'pipe' });
1121
+ execFileSync(
1122
+ 'git',
1123
+ [
1124
+ '-C',
1125
+ wtPath,
1126
+ 'commit',
1127
+ '-m',
1128
+ `docs: [TASK-${String(taskNum).padStart(3, '0')}] 태스크 문서 완료`,
1129
+ ],
1130
+ { stdio: 'pipe' },
1131
+ );
1132
+ commits.push({ phase: 'doc', files: rest });
1133
+ }
1134
+ }
1135
+
1136
+ // 추적 마커 빈커밋 (부모보다 앞선 커밋 0개 — docs/분석 task + .project gitignore 케이스)
1137
+ const ahead = parseInt(
1138
+ gitCapture(wtPath, ['rev-list', '--count', `${parent}..HEAD`], { allowFail: true }) || '0',
1139
+ 10,
1140
+ );
1141
+ if (ahead === 0) {
1142
+ execFileSync(
1143
+ 'git',
1144
+ [
1145
+ '-C',
1146
+ wtPath,
1147
+ 'commit',
1148
+ '--allow-empty',
1149
+ '-m',
1150
+ `docs: [TASK-${String(taskNum).padStart(3, '0')}] 추적 마커 — 코드 0·.project gitignore (채번 보존)`,
1151
+ ],
1152
+ { stdio: 'pipe' },
1153
+ );
1154
+ commits.push({ phase: 'marker', empty: true });
1155
+ }
1156
+
1157
+ const aheadFinal = parseInt(
1158
+ gitCapture(wtPath, ['rev-list', '--count', `${parent}..HEAD`], { allowFail: true }) || '0',
1159
+ 10,
1160
+ );
1161
+ return {
1162
+ prepped: true,
1163
+ taskNum,
1164
+ branch: active.branch,
1165
+ parent,
1166
+ wtPath,
1167
+ registered: resolved.registered,
1168
+ docPath: resolved.file,
1169
+ commits,
1170
+ aheadOfParent: aheadFinal,
1171
+ };
1172
+ }
1173
+
593
1174
  module.exports = {
594
1175
  LOCAL_SUFFIX,
595
1176
  MANIFEST_NAME,
@@ -626,8 +1207,8 @@ module.exports = {
626
1207
  getInitLockPath,
627
1208
  withMergeLock,
628
1209
  withMetaLock,
629
- assertDevExists,
630
- assertMainWorktreeOnDev,
1210
+ currentBranch,
1211
+ assertMainWorktreeOn,
631
1212
  parseBranchName,
632
1213
  getActiveTasks,
633
1214
  getNextTaskNumber,
@@ -642,4 +1223,19 @@ module.exports = {
642
1223
  appendBacklogItem,
643
1224
  parseBacklogItem,
644
1225
  markBacklogChecked,
1226
+ // task 헤더 / 상태 전이 (코드화)
1227
+ TRANSITIONS,
1228
+ parseTaskHeader,
1229
+ isProjectRegistered,
1230
+ resolveTaskDocPath,
1231
+ setStatus,
1232
+ // task 문서 골격 (C1)
1233
+ DOC_TYPE_OF,
1234
+ scaffoldTaskDoc,
1235
+ // plan 생성 (C3)
1236
+ initPlan,
1237
+ // task close 결정적 준비 (D1·D2)
1238
+ TAG_OF,
1239
+ splitUncommittedByPhase,
1240
+ closeTask,
645
1241
  };