@angar2/taskery 0.1.1 → 0.1.3

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
@@ -15,6 +15,7 @@ AI 코딩 에이전트의 자율 개발을 위한 Task 기반의 가드레일
15
15
  | 작업 컨텍스트가 휘발되어 이전 결정·사유를 추적하기 어려움 | task 문서로 컨텍스트와 히스토리 기록 — 작업 중 참조 가능 |
16
16
  | 에이전트가 catastrophic 사고를 일으킬 수 있음 | 필수 hook으로 차단 — 정상 흐름에는 무간섭 |
17
17
  | 에이전트가 작성한 코드의 자가검증으로 인해 문제를 놓침 (confirmation bias) | 테스트 시 별도 격리 세션 호출(`/task-test`) — 메인 세션의 가정 없이 독립 검증 |
18
+ | 단일 메인 세션 운영으로 인해 task를 직렬로 진행해야 하는 병목 | 같은 프로젝트에서 여러 메인 세션이 독립 작업 폴더(worktree)로 병렬 진행 — 머지 시점에 직렬화로 정합성 유지 |
18
19
 
19
20
  > catastrophic 사고 예시 — git 운영 정책 위반, 검증 우회, 완료된 task 문서 재수정 등
20
21
 
@@ -46,7 +47,7 @@ draft → planned → developing → developed → testing → tested → closed
46
47
 
47
48
  ## 빠른 시작
48
49
 
49
- > **요구 사항**: Node.js ≥ 18.
50
+ > **요구 사항**: Node.js ≥ 18, git ≥ 2.31 (멀티세션 워크트리 기능 사용 시).
50
51
 
51
52
  ### npx
52
53
 
@@ -62,6 +63,10 @@ npx @angar2/taskery init
62
63
 
63
64
  # 최신 버전 머지 갱신
64
65
  npx @angar2/taskery update
66
+
67
+ # 멀티세션 보조 명령
68
+ npx @angar2/taskery status # 진행중 태스크 / 워크트리 / 머지 락 현황
69
+ npx @angar2/taskery prune # stale 워크트리 / 브랜치 대화형 정리
65
70
  ```
66
71
 
67
72
  ### 글로벌 npm install
@@ -80,6 +85,10 @@ taskery init
80
85
 
81
86
  # 최신 버전 머지 갱신
82
87
  taskery update
88
+
89
+ # 멀티세션 보조 명령
90
+ taskery status
91
+ taskery prune
83
92
  ```
84
93
 
85
94
  ---
@@ -95,19 +104,22 @@ my-app/
95
104
  ├─ .gitignore
96
105
  ├─ .claude/
97
106
  │ ├─ settings.json # hook 등록 (PreToolUse 매칭)
98
- │ ├─ skills/<skil-name>/SKILL.md # 8 skill
99
- │ └─ hooks/<hook-name>.sh # 3 hook
107
+ │ ├─ skills/<skill-name>/SKILL.md # 9 skill
108
+ │ └─ hooks/<hook-name>.sh # 2 hook
100
109
  └─ .project/
101
110
  ├─ PROJECT.md # 프로젝트 개요
102
111
  ├─ AGENT-GUIDE.md # AI 에이전트 가이드
103
112
  ├─ LINKED-REPOS.md # 관계 리포지토리 정보
113
+ ├─ GLOSSARY.md # 도메인 용어집 (영문/한글 표기 일관성)
104
114
  ├─ .env # 사용자 설정 환경변수 (관계 리포지토리 환경변수 등)
105
115
  ├─ rules/
106
116
  │ ├─ TASK_DOC_RULE.md # task 문서 작성 규칙
107
117
  │ ├─ GIT_RULE.md # 로컬 깃 운영 규칙
118
+ │ ├─ CHANGELOG_RULE.md # CHANGELOG 작성 규칙
119
+ │ ├─ MOCKUP_RULE.md # UX/UI HTML 목업 규칙
108
120
  │ └─ *.local.md # 사용자 오버라이드 규칙 (패키지 업데이트 대상 제외)
109
121
  ├─ plans/ # plan 문서
110
- ├─ tasks/ # task 문서
122
+ ├─ tasks/ # task 문서 (vX.X/BACKLOG.md / spec-diffs / screenshots / mockup 포함)
111
123
  ├─ flows/ # 서비스 로직 플로우 정보
112
124
  ├─ changelog/ # 수정사항 정보
113
125
  ├─ shared/ # 관계 리포지토리 소통 메세지함
@@ -118,6 +130,30 @@ my-app/
118
130
 
119
131
  ---
120
132
 
133
+ ## 멀티세션 (병렬 작업)
134
+
135
+ 같은 프로젝트에서 여러 메인 세션을 동시에 운영해 독립 task를 병렬로 진행한다.
136
+
137
+ - 새 task를 시작하면(`/task-init`) 작업 폴더(`~/.taskery/worktrees/<projectId>/TASK-NNN_<출처>_<슬러그>/`)와 작업 브랜치를 함께 분기한다.
138
+ - 작업 폴더에서 새 메인 세션을 열어 진행하거나, 메인 세션 1개로 모든 작업을 지휘하거나, 메인이 서브 세션을 호출해 병렬로 진행한다 — 운영 방식은 자유. 메인 워크트리(원본 폴더)는 `dev` 전용 상태로 유지된다.
139
+ - task 완료(`/task-close`) 시 머지 락으로 직렬화한 후 dev에 `--no-ff` 병합한다. 다른 세션이 먼저 머지해 충돌이 발생하면 본 세션이 단순/의미적/판단 불가 3단계로 해결을 시도한다.
140
+ - 머지 직후 작업 폴더와 작업 브랜치는 자동 정리된다(보존 키워드 사용 시 양쪽 유지). 안전망으로 복구 명령이 함께 출력된다.
141
+
142
+ 요건: git ≥ 2.31. 보조 명령 `npx @angar2/taskery status` / `prune`로 진행 현황과 stale 정리를 확인한다.
143
+
144
+ ---
145
+
146
+ ## 백로그 메모
147
+
148
+ 작업 흐름 중 발견되는 추가 기능과 버그를 *그 자리에서 짧게 적어 둔다*. 메인 세션이 사용자 발화를 받아 `/add-backlog`로 1건씩 활성 plan 버전의 `tasks/<vX.X>/BACKLOG.md`에 누적한다.
149
+
150
+ - 항목 형식: 체크박스 + 식별자(BL-NNN) + 유형 + 제목 + 슬러그. 그 아래 *개요*(추정 원인 / 구상)와 *대상 영역*(관여할 파일·모듈) 2줄.
151
+ - 시작 시점: 사용자가 *"BL-NNN 진행"*이라고 말하면 `/task-init`이 그 항목 메타로 task를 분기하고 BACKLOG.md 항목을 *확인* 마킹(`[x]`)하며 `- TASK: TASK-NNN`을 함께 남긴다. *완료*가 아니라 *task로 옮겼다*는 의미만 담는다.
152
+ - 완료 추적: git 머지 히스토리와 `taskery status`. BACKLOG.md는 메모지 역할만 한다.
153
+ - plan 후보 카탈로그(글로벌 `.project/BACKLOG.md`)는 다음 plan 버전 기획용으로 분리 관리한다.
154
+
155
+ ---
156
+
121
157
  ## Skills
122
158
 
123
159
  스킬은 호출 시점에 따라 4 카테고리(`project` / `plan` / `task` / `meta`)로 분류된다.
@@ -136,6 +172,7 @@ my-app/
136
172
  | `/task-dev` | task | 계획에 따른 단계별 구현 + 자가 검증 |
137
173
  | `/task-test` | task | 별도 격리 세션에서 독립 검증 (메인 가정 차단) |
138
174
  | `/task-close` | task | 최종 검증 후 git 커밋 + dev 브랜치 `--no-ff` 병합 |
175
+ | `/add-backlog` | meta | 사용자 발화로 버전별 `tasks/<vX.X>/BACKLOG.md`에 task 후보 1건씩 추가 (0.1.2+) |
139
176
  | `/log-friction` | meta | 사용자 불편을 `.project/FRICTION_LOG.md`에 한 행 기록 |
140
177
 
141
178
  각 스킬은 슬래시로 직접 호출하거나, 사용자 발화의 의미가 스킬의 frontmatter description과 매칭되면 메인 세션이 자동으로 발동시킨다.
@@ -157,7 +194,6 @@ my-app/
157
194
  | Hook | 작동 시점 | 차단 대상 |
158
195
  |------|---------|---------|
159
196
  | `git-guard.sh` | git 명령 실행 직전 | 주력 브랜치 직접 커밋 / `--force` / `--no-verify` / 강제 브랜치 삭제 / `reset --hard` / `clean -fd` |
160
- | `pre-commit-verify.sh` | `git commit` 실행 직전 | 검증 명령(린트·타입체크·빌드·테스트) 통과 실패 commit |
161
197
  | `closed-immutable.sh` | 파일 수정 직전 | 완료(`closed`)된 task.md 본 파일 재수정 (관련 spec-diff·스크린샷은 자유) |
162
198
 
163
199
  hook은 catastrophic 사고만 차단한다. 정상 흐름에는 간섭하지 않는다.
@@ -193,6 +229,7 @@ npx @angar2/taskery init
193
229
 
194
230
  - *"로그인 기능 추가해줘"* / *"이 버그 고쳐줘"* → `/task-init` 자동 발동 (새 task 시작 의도)
195
231
  - *"기획 다 됐으니 이제 구현 시작해"* → `/task-dev` 자동 발동 (planned task의 다음 단계)
232
+ - *"이 부분도 백로그에 추가해줘"* / *"나중에 할 일로 적어둬"* → `/add-backlog` 자동 발동 (백로그 추가 의도)
196
233
  - *"이거 진짜 불편하다"* / *"이 부분 답답하네"* → `/log-friction` 자동 발동 (불만 발화 캐치)
197
234
 
198
235
  **실패·불확정 분기** — `/task-test`가 FAIL 또는 UNCERTAIN을 반환하면 메인 세션이 사용자에게 판단을 묻고, 사용자의 자연어 답변에서 의도를 해석해 다음 흐름을 *자동 발동*한다(별도 슬래시 호출 없이 진행됨).
@@ -208,9 +245,9 @@ npx @angar2/taskery init
208
245
  spec 문서는 GitHub에서 참고할 수 있다.
209
246
 
210
247
  - [OVERVIEW.md](https://github.com/angar2/taskery/blob/main/plan/OVERVIEW.md) — 시스템 진입 가이드와 전체 구조 개요.
211
- - [SKILLS.md](https://github.com/angar2/taskery/blob/main/plan/SKILLS.md) — 스킬 8종의 상세 명세와 호출 흐름.
248
+ - [SKILLS.md](https://github.com/angar2/taskery/blob/main/plan/SKILLS.md) — 스킬 9종의 상세 명세와 호출 흐름.
212
249
  - [TASK-DOC.md](https://github.com/angar2/taskery/blob/main/plan/TASK-DOC.md) — task 문서의 작성 양식과 7 상태 머신의 동작 정의.
213
- - [HOOKS.md](https://github.com/angar2/taskery/blob/main/plan/HOOKS.md) — catastrophic hook 3종의 정책과 예외 처리 절차.
250
+ - [HOOKS.md](https://github.com/angar2/taskery/blob/main/plan/HOOKS.md) — catastrophic hook 2종의 정책과 예외 처리 절차.
214
251
  - [DISTRIBUTION.md](https://github.com/angar2/taskery/blob/main/plan/DISTRIBUTION.md) — npx 배포 메커니즘과 자산 갱신 로직.
215
252
  - [DECISIONS.md](https://github.com/angar2/taskery/blob/main/plan/DECISIONS.md) — 시스템 설계의 핵심 의사결정과 변경 이력.
216
253
  - [PLAYBOOK.md](https://github.com/angar2/taskery/blob/main/plan/PLAYBOOK.md) — 향후 도입 가능한 기능 후보 목록.
package/bin/init.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * 1. cwd에 .taskery-manifest.json 이미 있으면 경고 + confirm
8
8
  * 2. template/ 안 *모든 파일* cwd로 카피 (디렉토리 자동 생성)
9
9
  * 3. *.local.md 사용자 오버라이드 충돌 검사 (덮어쓰지 X)
10
- * 4. .taskery-manifest.json 박음 (version + installed_at + files{path,hash,core,managed} 맵)
10
+ * 4. .taskery-manifest.json 작성 (version + installed_at + files{path,hash,core,managed} 맵)
11
11
  * 5. 결과 요약 출력 + 다음 단계 안내 (`/project-init`)
12
12
  */
13
13
 
@@ -24,6 +24,9 @@ const {
24
24
  readManifest,
25
25
  getPackageVersion,
26
26
  isLocalOverride,
27
+ generateProjectId,
28
+ DEFAULT_STALE_DAYS,
29
+ DEFAULT_LOCK_TIMEOUT_MS,
27
30
  } = require('./lib');
28
31
 
29
32
  async function confirm(msg) {
@@ -93,14 +96,54 @@ async function main() {
93
96
  console.log(` copy: ${rel}`);
94
97
  }
95
98
 
96
- // 4. manifest 박음
99
+ // 4. manifest 작성 — 멀티세션(0.1.2+): projectId / stale_days / lock_timeout_ms 포함
97
100
  const manifest = {
98
101
  version: getPackageVersion(),
99
102
  installed_at: new Date().toISOString(),
103
+ projectId: generateProjectId(),
104
+ stale_days: DEFAULT_STALE_DAYS,
105
+ lock_timeout_ms: DEFAULT_LOCK_TIMEOUT_MS,
100
106
  files: manifestFiles,
101
107
  };
102
108
  writeManifest(manifest, manifestPath);
103
109
 
110
+ // 4.5. .gitignore prompt (stash FRICTION_LOG #26 반영)
111
+ // 공개 repo면 내부 워크플로 파일 노출 회피 — taskery 내부 영역 .gitignore 등록 제안.
112
+ // 사용자 NO면 패스 (그대로 둠). 이미 등록되어 있으면 스킵.
113
+ const gitignorePath = path.join(cwd, '.gitignore');
114
+ const taskeryPatterns = [
115
+ '.project/',
116
+ '.claude/',
117
+ 'CLAUDE.md',
118
+ '.taskery-manifest.json',
119
+ ];
120
+ const addGitignore = await confirm(
121
+ `\ntaskery 내부 파일(.project/, .claude/, CLAUDE.md, .taskery-manifest.json)을 .gitignore에 등록할까?\n (공개 repo면 권장 — 내부 워크플로 파일 노출 회피)`,
122
+ );
123
+ if (addGitignore) {
124
+ const existing = fs.existsSync(gitignorePath)
125
+ ? fs.readFileSync(gitignorePath, 'utf8').split('\n')
126
+ : [];
127
+ const existingSet = new Set(existing.map((l) => l.trim()));
128
+ const newPatterns = taskeryPatterns.filter((p) => !existingSet.has(p));
129
+ if (newPatterns.length > 0) {
130
+ const needsLeadingNewline =
131
+ existing.length > 0 && existing[existing.length - 1] !== '';
132
+ const block = [
133
+ needsLeadingNewline ? '' : null,
134
+ '# taskery 내부 파일',
135
+ ...newPatterns,
136
+ '',
137
+ ].filter((l) => l !== null);
138
+ fs.appendFileSync(gitignorePath, block.join('\n'));
139
+ console.log(` .gitignore 갱신 — ${newPatterns.length}개 패턴 추가`);
140
+ } else {
141
+ console.log(` .gitignore — taskery 패턴 이미 등록됨, 스킵`);
142
+ }
143
+ } else {
144
+ console.log(` .gitignore — 사용자 선택으로 패스`);
145
+ }
146
+
104
147
  // 5. 결과 보고
105
148
  console.log(`\n✅ taskery init 완료`);
106
149
  console.log(` 카피: ${copied}개 / 스킵: ${skipped}개`);
package/bin/lib.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * bin/lib.js
3
- * 공통 유틸 — taskery init/create/update 스크립트가 공유.
3
+ * 공통 유틸 — taskery init/create/update/prune/status 스크립트가 공유.
4
4
  *
5
5
  * 핵심:
6
6
  * - findTemplateDir(): 패키지 설치 위치에서 template/ 디렉토리 찾기
@@ -8,14 +8,37 @@
8
8
  * - sha256(path): 파일 해시
9
9
  * - mkdirp(path): mkdir -p
10
10
  * - copyFile(src, dst): 파일 카피 + 디렉토리 자동 생성
11
- * - writeManifest(manifest, dst): manifest JSON 박음
11
+ * - writeManifest(manifest, dst): manifest JSON 기록
12
12
  * - readManifest(path): 기존 manifest Read (없으면 null)
13
13
  * - LOCAL_SUFFIX: '.local.md' suffix 검사 (npx 미터치 룰)
14
+ *
15
+ * 멀티세션 (0.1.2+):
16
+ * - generateProjectId(): 새 projectId 생성 (8자 hex)
17
+ * - getMainWorktreePath(cwd): cwd가 속한 git 트리의 *메인 워크트리* 절대 경로
18
+ * - getProjectId(mainWtPath): manifest에서 projectId 추출
19
+ * - getWorktreesRoot(projectId): ~/.taskery/worktrees/<projectId>
20
+ * - getWorktreePath(projectId, { taskNum, src, slug }): 특정 task 워크트리 경로
21
+ * - getMergeLockPath(projectId): ~/.taskery/<projectId>.merge.lock
22
+ * - withMergeLock(projectId, fn, opts): proper-lockfile로 머지 락 + fn 실행 + 자동 release
23
+ * - withMetaLock(filePath, fn, opts): 메타 파일 쓰기 락 + fn 실행
24
+ * - getActiveTasks(mainWtPath): SSoT 조회 (git branch --no-merged dev --list 'feature/*_TASK-*' ...)
25
+ * - getNextTaskNumber(mainWtPath): (진행중 ∪ dev 머지 히스토리) 최대 + 1
26
+ * - assertMainWorktreeOnDev(mainWtPath): 메인 워크트리가 dev 체크아웃 상태인지 검증
27
+ * - assertDevExists(mainWtPath): dev 브랜치 존재 검증
28
+ *
29
+ * 백로그 (0.1.2+):
30
+ * - getActiveVersion(mainWtPath): .project/AGENT-GUIDE.md에서 활성 plan 버전(vX.X) 추출
31
+ * - getBacklogPath(mainWtPath, activeVersion?): .project/tasks/<vX.X>/BACKLOG.md 절대 경로
32
+ * - parseBacklogItem(mainWtPath, blId): BL-NNN 항목 메타 파싱 ({ status, type, title, slug, summary, target, taskNums })
33
+ * - appendBacklogItem(mainWtPath, meta): withMetaLock + BL-NNN 채번 + 항목 append (placeholder 치환 우선)
34
+ * - markBacklogChecked(mainWtPath, blId, taskNum): withMetaLock + [ ] → [x] + TASK 마크 (다회 진행 시 콤마 추가)
14
35
  */
15
36
 
16
37
  const fs = require('fs');
17
38
  const path = require('path');
39
+ const os = require('os');
18
40
  const crypto = require('crypto');
41
+ const { execFileSync } = require('child_process');
19
42
 
20
43
  const LOCAL_SUFFIX = '.local.md';
21
44
  const MANIFEST_NAME = '.taskery-manifest.json';
@@ -92,6 +115,349 @@ function getPackageVersion() {
92
115
  return pkg.version;
93
116
  }
94
117
 
118
+ // === 멀티세션 (0.1.2+) ===
119
+
120
+ const TASKERY_HOME = path.join(os.homedir(), '.taskery');
121
+ const DEFAULT_STALE_DAYS = 30;
122
+ const DEFAULT_LOCK_TIMEOUT_MS = 30000;
123
+
124
+ const BRANCH_PATTERNS = [
125
+ 'feature/*_TASK-*',
126
+ 'bug/*_TASK-*',
127
+ 'improve/*_TASK-*',
128
+ 'refactor/*_TASK-*',
129
+ 'docs/*_TASK-*',
130
+ 'chore/*_TASK-*',
131
+ ];
132
+
133
+ function generateProjectId() {
134
+ return crypto.randomBytes(4).toString('hex');
135
+ }
136
+
137
+ function gitCapture(cwd, args, opts = {}) {
138
+ try {
139
+ return execFileSync('git', ['-C', cwd, ...args], {
140
+ encoding: 'utf8',
141
+ stdio: ['ignore', 'pipe', opts.captureStderr ? 'pipe' : 'ignore'],
142
+ }).trimEnd();
143
+ } catch (e) {
144
+ if (opts.allowFail) return null;
145
+ throw e;
146
+ }
147
+ }
148
+
149
+ function getMainWorktreePath(cwd = process.cwd()) {
150
+ // git ≥ 2.31 — `git rev-parse --path-format=absolute --git-common-dir` 는
151
+ // 메인 워크트리의 .git 경로(또는 .git 디렉토리 자체)를 절대 경로로 돌려준다.
152
+ // 그 부모가 *메인 워크트리* 절대 경로.
153
+ const commonDir = gitCapture(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
154
+ if (!commonDir) throw new Error(`메인 워크트리 검출 실패 (cwd=${cwd})`);
155
+ return path.dirname(commonDir);
156
+ }
157
+
158
+ function getProjectId(mainWtPath) {
159
+ const m = readManifest(path.join(mainWtPath, MANIFEST_NAME));
160
+ if (!m || !m.projectId) {
161
+ throw new Error(
162
+ `${MANIFEST_NAME}.projectId 누락 — 'npx @angar2/taskery update' 로 마이그레이션 필요.`,
163
+ );
164
+ }
165
+ return m.projectId;
166
+ }
167
+
168
+ function getWorktreesRoot(projectId) {
169
+ return path.join(TASKERY_HOME, 'worktrees', projectId);
170
+ }
171
+
172
+ function getWorktreePath(projectId, { taskNum, src, slug }) {
173
+ // src = 'BL-NNN' | 'RM-NNN' | 'DR'
174
+ const nnn = String(taskNum).padStart(3, '0');
175
+ return path.join(getWorktreesRoot(projectId), `TASK-${nnn}_${src}_${slug}`);
176
+ }
177
+
178
+ function getMergeLockPath(projectId) {
179
+ return path.join(TASKERY_HOME, `${projectId}.merge.lock`);
180
+ }
181
+
182
+ async function withMergeLock(projectId, fn, opts = {}) {
183
+ const lockfile = require('proper-lockfile');
184
+ const lockPath = getMergeLockPath(projectId);
185
+ mkdirp(path.dirname(lockPath));
186
+ // proper-lockfile은 *기존 파일 위에 락*을 잡는다. sentinel 파일 보장.
187
+ if (!fs.existsSync(lockPath)) fs.writeFileSync(lockPath, '');
188
+ const release = await lockfile.lock(lockPath, {
189
+ stale: opts.stale ?? DEFAULT_LOCK_TIMEOUT_MS,
190
+ retries: opts.retries ?? {
191
+ retries: 5,
192
+ factor: 1,
193
+ minTimeout: 1000,
194
+ maxTimeout: 1000,
195
+ },
196
+ realpath: false,
197
+ });
198
+ try {
199
+ return await fn();
200
+ } finally {
201
+ await release();
202
+ }
203
+ }
204
+
205
+ async function withMetaLock(filePath, fn, opts = {}) {
206
+ const lockfile = require('proper-lockfile');
207
+ const target = filePath;
208
+ if (!fs.existsSync(target)) {
209
+ // 락 대상은 파일이어야 함. 없으면 sentinel 빈 파일 만들어 락 가능 상태로.
210
+ mkdirp(path.dirname(target));
211
+ fs.writeFileSync(target, '');
212
+ }
213
+ const release = await lockfile.lock(target, {
214
+ stale: opts.stale ?? DEFAULT_LOCK_TIMEOUT_MS,
215
+ retries: opts.retries ?? {
216
+ retries: 5,
217
+ factor: 1,
218
+ minTimeout: 1000,
219
+ maxTimeout: 1000,
220
+ },
221
+ realpath: false,
222
+ });
223
+ try {
224
+ return await fn();
225
+ } finally {
226
+ await release();
227
+ }
228
+ }
229
+
230
+ function assertDevExists(mainWtPath) {
231
+ const ok = gitCapture(mainWtPath, ['rev-parse', '--verify', 'dev'], { allowFail: true });
232
+ if (ok === null) {
233
+ throw new Error('dev 브랜치 부재. 사용자 결정 필요 (생성?).');
234
+ }
235
+ }
236
+
237
+ function assertMainWorktreeOnDev(mainWtPath) {
238
+ const current = gitCapture(mainWtPath, ['rev-parse', '--abbrev-ref', 'HEAD']);
239
+ if (current !== 'dev') {
240
+ throw new Error(`메인 워크트리가 dev 아님 (현재: ${current}). taskery 정책 위배.`);
241
+ }
242
+ }
243
+
244
+ function parseBranchName(branch) {
245
+ // 형식: {type}/{dev}_TASK-NNN_{src}_{slug}
246
+ // 예: feature/claude_TASK-007_BL-003_login-feature
247
+ // bug/angar2_TASK-012_DR_mobile-form-refresh
248
+ const m = branch.match(
249
+ /^(feature|bug|improve|refactor|docs|chore)\/([^_]+)_TASK-(\d+)_([A-Z]{2}(?:-\d+)?|DR)_(.+)$/,
250
+ );
251
+ if (!m) return null;
252
+ return {
253
+ branch,
254
+ type: m[1],
255
+ dev: m[2],
256
+ taskNum: parseInt(m[3], 10),
257
+ src: m[4],
258
+ slug: m[5],
259
+ };
260
+ }
261
+
262
+ function getActiveTasks(mainWtPath) {
263
+ // SSoT — TASK-* 패턴 작업 브랜치 (분기 직후 빈 브랜치 포함). `--no-merged dev` 미사용:
264
+ // 워크트리 분기 직후 브랜치는 dev와 동일 commit이라 `--no-merged dev`가 *완전 머지 상태*로
265
+ // 처리 → 빈 결과 → getNextTaskNumber 충돌. 브랜치 존재 자체가 SSoT (정상 흐름은 task-close
266
+ // 자동 삭제로 자연 정리, 보존 키워드 잔존분도 *진행중*으로 잡힘이 정합).
267
+ const out = gitCapture(mainWtPath, ['branch', '--list', ...BRANCH_PATTERNS]);
268
+ if (!out) return [];
269
+ return out
270
+ .split('\n')
271
+ .map((l) => l.replace(/^[*+]?\s*/, '').trim())
272
+ .filter(Boolean)
273
+ .map(parseBranchName)
274
+ .filter(Boolean);
275
+ }
276
+
277
+ function getNextTaskNumber(mainWtPath) {
278
+ // 1. 진행중 (SSoT)
279
+ const activeNums = getActiveTasks(mainWtPath).map((t) => t.taskNum);
280
+ // 2. dev 머지 히스토리 (GIT_RULE 풍부 메시지)
281
+ const log = gitCapture(mainWtPath, [
282
+ 'log',
283
+ 'dev',
284
+ '--grep',
285
+ 'TASK-[0-9]\\+',
286
+ '--extended-regexp',
287
+ '--oneline',
288
+ ]);
289
+ const merged = log
290
+ ? Array.from(log.matchAll(/TASK-(\d+)/g)).map((m) => parseInt(m[1], 10))
291
+ : [];
292
+ const all = [...activeNums, ...merged];
293
+ if (all.length === 0) return 1;
294
+ return Math.max(...all) + 1;
295
+ }
296
+
297
+ // ─── 백로그 (0.1.2+) ────────────────────────────────────────────────
298
+
299
+ const BACKLOG_PLACEHOLDER =
300
+ '(사용자 발화 또는 task-close 직후 메인 감지로 한 행씩 추가. 빈 상태 default.)';
301
+
302
+ function getActiveVersion(mainWtPath) {
303
+ // .project/AGENT-GUIDE.md '## 활성 plan 버전' 섹션 다음 비어 있지 않은 첫 줄에서 vX.X 추출
304
+ const guidePath = path.join(mainWtPath, '.project', 'AGENT-GUIDE.md');
305
+ if (!fs.existsSync(guidePath)) {
306
+ throw new Error(`.project/AGENT-GUIDE.md 부재. /project-init 먼저 호출.`);
307
+ }
308
+ const content = fs.readFileSync(guidePath, 'utf8');
309
+ const sec = content.match(/##\s*활성 plan 버전\s*\n+([^\n]+)/);
310
+ if (!sec) {
311
+ throw new Error(`.project/AGENT-GUIDE.md '## 활성 plan 버전' 섹션 부재.`);
312
+ }
313
+ const m = sec[1].match(/\bv[\d.]+(?:[-\w]*)?/);
314
+ if (!m) {
315
+ throw new Error(
316
+ `'## 활성 plan 버전' 값에서 vX.X 패턴 추출 실패: ${sec[1].trim()} — /plan-init으로 활성 버전 갱신 필요.`,
317
+ );
318
+ }
319
+ return m[0];
320
+ }
321
+
322
+ function getBacklogPath(mainWtPath, activeVersion) {
323
+ const v = activeVersion ?? getActiveVersion(mainWtPath);
324
+ return path.join(mainWtPath, '.project', 'tasks', v, 'BACKLOG.md');
325
+ }
326
+
327
+ function computeNextBLNumber(content) {
328
+ const matches = Array.from(content.matchAll(/\bBL-(\d+)\b/g)).map((m) => parseInt(m[1], 10));
329
+ if (matches.length === 0) return 1;
330
+ return Math.max(...matches) + 1;
331
+ }
332
+
333
+ function formatBacklogBlock({ id, type, title, slug, summary, target }) {
334
+ const blId = `BL-${String(id).padStart(3, '0')}`;
335
+ return `- [ ] **${blId}** [${type}] ${title} \`${slug}\`\n - 개요: ${summary}\n - 대상 영역: ${target}`;
336
+ }
337
+
338
+ async function appendBacklogItem(mainWtPath, meta) {
339
+ // meta: { type, title, slug, summary, target }
340
+ const backlogPath = getBacklogPath(mainWtPath);
341
+ if (!fs.existsSync(backlogPath)) {
342
+ throw new Error(
343
+ `${backlogPath} 부재. /plan-init 먼저 호출해 활성 버전 디렉토리 + 빈 BACKLOG.md 생성 필요.`,
344
+ );
345
+ }
346
+ return await withMetaLock(backlogPath, async () => {
347
+ const content = fs.readFileSync(backlogPath, 'utf8');
348
+ const next = computeNextBLNumber(content);
349
+ const block = formatBacklogBlock({ id: next, ...meta });
350
+ let updated;
351
+ if (content.includes(BACKLOG_PLACEHOLDER)) {
352
+ updated = content.replace(BACKLOG_PLACEHOLDER, block);
353
+ } else {
354
+ updated = content.replace(/\s*$/, '') + '\n\n' + block + '\n';
355
+ }
356
+ fs.writeFileSync(backlogPath, updated);
357
+ return next;
358
+ });
359
+ }
360
+
361
+ function parseBacklogItem(mainWtPath, blId) {
362
+ // blId: 'BL-001'. 발견 못하면 null.
363
+ const backlogPath = getBacklogPath(mainWtPath);
364
+ if (!fs.existsSync(backlogPath)) return null;
365
+ const content = fs.readFileSync(backlogPath, 'utf8');
366
+ const lines = content.split('\n');
367
+ const headRe = new RegExp(
368
+ `^- \\[([ x])\\] \\*\\*${blId}\\*\\* \\[(\\w+)\\] (.+?) \`([\\w-]+)\``,
369
+ );
370
+ let idx = -1;
371
+ let head = null;
372
+ for (let i = 0; i < lines.length; i++) {
373
+ const m = lines[i].match(headRe);
374
+ if (m) {
375
+ idx = i;
376
+ head = m;
377
+ break;
378
+ }
379
+ }
380
+ if (idx === -1) return null;
381
+ let summary = null;
382
+ let target = null;
383
+ let taskNums = null;
384
+ for (let j = idx + 1; j < lines.length; j++) {
385
+ const l = lines[j];
386
+ if (l.startsWith('- ') && !l.startsWith(' - ')) break; // 다음 BL 항목
387
+ if (l.trim() === '') continue; // 블록 안 빈 줄 허용 (안전)
388
+ if (!l.startsWith(' - ')) break;
389
+ const ms = l.match(/^ - 개요:\s*(.+)/);
390
+ if (ms) { summary = ms[1].trim(); continue; }
391
+ const mt = l.match(/^ - 대상 영역:\s*(.+)/);
392
+ if (mt) { target = mt[1].trim(); continue; }
393
+ const mk = l.match(/^ - TASK:\s*(.+)/);
394
+ if (mk) { taskNums = mk[1].trim(); continue; }
395
+ }
396
+ return {
397
+ blId,
398
+ status: head[1] === 'x' ? 'checked' : 'pending',
399
+ type: head[2],
400
+ title: head[3],
401
+ slug: head[4],
402
+ summary,
403
+ target,
404
+ taskNums,
405
+ };
406
+ }
407
+
408
+ async function markBacklogChecked(mainWtPath, blId, taskNum) {
409
+ // taskNum: number. [ ] → [x] + TASK 마크 (있으면 콤마 추가, 없으면 신규 줄)
410
+ const backlogPath = getBacklogPath(mainWtPath);
411
+ if (!fs.existsSync(backlogPath)) {
412
+ throw new Error(`${backlogPath} 부재.`);
413
+ }
414
+ const taskLabel = `TASK-${String(taskNum).padStart(3, '0')}`;
415
+ return await withMetaLock(backlogPath, async () => {
416
+ const content = fs.readFileSync(backlogPath, 'utf8');
417
+ const lines = content.split('\n');
418
+ const headRe = new RegExp(`^(- )\\[[ x]\\](\\s+\\*\\*${blId}\\*\\*.+)$`);
419
+ let idx = -1;
420
+ for (let i = 0; i < lines.length; i++) {
421
+ if (headRe.test(lines[i])) {
422
+ idx = i;
423
+ break;
424
+ }
425
+ }
426
+ if (idx === -1) {
427
+ throw new Error(`${blId} 항목 없음.`);
428
+ }
429
+ lines[idx] = lines[idx].replace(headRe, '$1[x]$2');
430
+ // 다음 BL 헤드 또는 파일 끝까지가 같은 블록 — 그 안에서 TASK 줄 검색
431
+ let blockEnd = lines.length;
432
+ for (let j = idx + 1; j < lines.length; j++) {
433
+ if (lines[j].startsWith('- ') && !lines[j].startsWith(' - ')) {
434
+ blockEnd = j;
435
+ break;
436
+ }
437
+ }
438
+ let taskLineIdx = -1;
439
+ for (let j = idx + 1; j < blockEnd; j++) {
440
+ if (lines[j].match(/^ - TASK:/)) {
441
+ taskLineIdx = j;
442
+ break;
443
+ }
444
+ }
445
+ if (taskLineIdx !== -1) {
446
+ const existing = lines[taskLineIdx].match(/^ - TASK:\s*(.+)/)[1].trim();
447
+ const existingTasks = existing.split(/[,\s]+/).filter(Boolean);
448
+ if (!existingTasks.includes(taskLabel)) {
449
+ lines[taskLineIdx] = ` - TASK: ${existing}, ${taskLabel}`;
450
+ }
451
+ } else {
452
+ // blockEnd 직전 빈 줄 건너뛰고 삽입
453
+ let insertAt = blockEnd;
454
+ while (insertAt > idx + 1 && lines[insertAt - 1].trim() === '') insertAt--;
455
+ lines.splice(insertAt, 0, ` - TASK: ${taskLabel}`);
456
+ }
457
+ fs.writeFileSync(backlogPath, lines.join('\n'));
458
+ });
459
+ }
460
+
95
461
  module.exports = {
96
462
  LOCAL_SUFFIX,
97
463
  MANIFEST_NAME,
@@ -104,4 +470,32 @@ module.exports = {
104
470
  readManifest,
105
471
  isLocalOverride,
106
472
  getPackageVersion,
473
+ // 멀티세션 (0.1.2+)
474
+ TASKERY_HOME,
475
+ DEFAULT_STALE_DAYS,
476
+ DEFAULT_LOCK_TIMEOUT_MS,
477
+ BRANCH_PATTERNS,
478
+ generateProjectId,
479
+ gitCapture,
480
+ getMainWorktreePath,
481
+ getProjectId,
482
+ getWorktreesRoot,
483
+ getWorktreePath,
484
+ getMergeLockPath,
485
+ withMergeLock,
486
+ withMetaLock,
487
+ assertDevExists,
488
+ assertMainWorktreeOnDev,
489
+ parseBranchName,
490
+ getActiveTasks,
491
+ getNextTaskNumber,
492
+ // 백로그 (0.1.2+)
493
+ BACKLOG_PLACEHOLDER,
494
+ getActiveVersion,
495
+ getBacklogPath,
496
+ computeNextBLNumber,
497
+ formatBacklogBlock,
498
+ appendBacklogItem,
499
+ parseBacklogItem,
500
+ markBacklogChecked,
107
501
  };