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