@angar2/taskery 0.5.0 → 0.6.1

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
 
@@ -39,7 +42,7 @@ draft → planned → developing → developed → testing → tested → closed
39
42
  | `developed` | 구현 완료 (자가 검증 통과) |
40
43
  | `testing` | 독립 검증 진행 중 |
41
44
  | `tested` | 독립 검증 완료 |
42
- | `closed` | git 커밋 + dev 병합 완료 |
45
+ | `closed` | git 커밋 + 부모 브랜치 병합 완료 |
43
46
 
44
47
  > 상태별 전이 조건과 task 문서 양식은 [TASK-DOC.md](https://github.com/angar2/taskery/blob/main/plan/TASK-DOC.md)에서 확인 가능
45
48
 
@@ -125,6 +128,8 @@ my-app/
125
128
  │ ├─ GIT_RULE.md # 로컬 깃 운영 규칙
126
129
  │ ├─ CHANGELOG_RULE.md # CHANGELOG 작성 규칙
127
130
  │ ├─ MOCKUP_RULE.md # UX/UI HTML 목업 규칙
131
+ │ ├─ DEV_RULE.local.md # (선택) 이 프로젝트의 구현·테스트 실행 방식
132
+ │ ├─ TEST_RULE.local.md # (선택) 이 프로젝트의 검증 범위·방식
128
133
  │ └─ *.local.md # 사용자 오버라이드 규칙 (패키지 업데이트 대상 제외)
129
134
  ├─ plans/ # plan(기능 그룹)별 PLAN.md / ROADMAP.md (<NNN_slug>/)
130
135
  ├─ tasks/ # task 문서 (<NNN_slug>/BACKLOG.md / spec-diffs / screenshots / mockup 포함)
@@ -153,8 +158,8 @@ my-app/
153
158
  같은 프로젝트에서 여러 메인 세션을 동시에 운영해 독립 task를 병렬로 진행한다.
154
159
 
155
160
  - 새 task를 시작하면(`/task-init`) 작업 폴더(`~/.taskery/worktrees/<projectId>/TASK-NNN_<출처>_<슬러그>/`)와 작업 브랜치를 함께 분기한다.
156
- - 작업 폴더에서 새 메인 세션을 열어 진행하거나, 메인 세션 1개로 모든 작업을 지휘하거나, 메인이 서브 세션을 호출해 병렬로 진행한다 — 운영 방식은 자유. 메인 워크트리(원본 폴더)는 `dev` 전용 상태로 유지된다.
157
- - task 완료(`/task-close`) 시 머지 락으로 직렬화한 후 dev에 `--no-ff` 병합한다. 다른 세션이 먼저 머지해 충돌이 발생하면 본 세션이 단순/의미적/판단 불가 3단계로 해결을 시도한다.
161
+ - 작업 폴더에서 새 메인 세션을 열어 진행하거나, 메인 세션 1개로 모든 작업을 지휘하거나, 메인이 서브 세션을 호출해 병렬로 진행한다 — 운영 방식은 자유. 메인 워크트리(원본 폴더)는 task를 시작한 브랜치(*부모 브랜치*, 기본은 `dev`)를 그대로 유지한다.
162
+ - task 완료(`/task-close`) 시 머지 락으로 직렬화한 후 **그 부모 브랜치**에 `--no-ff` 병합한다(개인은 `dev`, 회사/로드맵은 서 있던 브랜치). 다른 세션이 먼저 머지해 충돌이 발생하면 본 세션이 단순/의미적/판단 불가 3단계로 해결을 시도한다.
158
163
  - 머지 직후 작업 폴더와 작업 브랜치는 자동 정리된다(보존 키워드 사용 시 양쪽 유지). 안전망으로 복구 명령이 함께 출력된다.
159
164
 
160
165
  요건: git ≥ 2.31. 보조 명령 `npx @angar2/taskery status` / `prune`로 진행 현황과 stale 정리를 확인한다.
@@ -189,7 +194,7 @@ my-app/
189
194
  | `/task-plan` | task | task의 요구사항·범위·개발 계획·테스트 계획 작성 |
190
195
  | `/task-dev` | task | 계획에 따른 단계별 구현 + 자가 검증 |
191
196
  | `/task-test` | task | 별도 격리 세션에서 독립 검증 (메인 가정 차단) |
192
- | `/task-close` | task | 최종 검증 후 git 커밋 + dev 브랜치 `--no-ff` 병합 |
197
+ | `/task-close` | task | 최종 검증 후 git 커밋 + 부모 브랜치 `--no-ff` 병합 |
193
198
  | `/add-backlog` | meta | 사용자 발화로 plan(기능 그룹)별 `tasks/<NNN_slug>/BACKLOG.md`에 task 후보 1건씩 추가 (0.1.2+) |
194
199
  | `/log-friction` | meta | 사용자 불편을 `.project/FRICTION_LOG.md`에 한 행 기록 |
195
200
  | `/run-team` | meta | 여러 작업을 묶을 수 있는 단위로 task로 나눠 자동 병렬 처리 — 리더 세션이 팀원(독립 세션)에게 task를 1건씩 분배하고 흐름을 관리 (Claude 전용 · 실험 기능 전제 · 0.3.x+) |
@@ -240,7 +245,7 @@ npx @angar2/taskery init
240
245
  /task-plan TASK-001 # draft → planned : 요구사항·범위·개발 계획·테스트 계획 작성
241
246
  /task-dev TASK-001 # planned → developed : 단계별 구현과 자체 검증
242
247
  /task-test TASK-001 # developed → tested : 별도 격리 세션으로 독립 검증
243
- /task-close TASK-001 # tested → closed : 최종 검증 후 git 커밋과 dev 병합
248
+ /task-close TASK-001 # tested → closed : 최종 검증 후 git 커밋과 부모 브랜치 병합
244
249
 
245
250
  # 불편 발생 시 등록
246
251
  /log-friction # 사용자 불편 한 행 기록
@@ -258,6 +263,7 @@ npx @angar2/taskery init
258
263
  - 사용자가 재구현을 요청하면 → `testing` → `developing`으로 회귀해 `/task-dev`가 자동 발동된다.
259
264
  - 사용자가 결함을 인지한 채 종결을 지시하면 → `testing` → `tested`로 진행하고 결함을 명시한 후 `/task-close`로 이행된다.
260
265
  - UNCERTAIN(자동 검증이 불가능한 시나리오)은 사용자가 직접 검수한 결과를 답하면 메인 세션이 PASS/FAIL로 해석해 위 두 흐름 중 하나로 합류시킨다.
266
+ - 검증에 필요한 테스트가 화면·입력을 점유해 실행을 보류한 경우에는, 지금 실행할지 / 자리를 비울 때로 미룰지 / 창을 띄우지 않는 실행 경로를 마련할지를 사용자가 고른다. 승인 없이 임의로 실행하지 않는다.
261
267
 
262
268
  ---
263
269
 
package/bin/close.js CHANGED
@@ -5,10 +5,10 @@
5
5
  * `npx @angar2/taskery close <TASK-NNN>` — close 결정적 준비.
6
6
  *
7
7
  * 게이트(status=tested) → Phase 커밋(D2 자동 매핑) → status=closed → flows/문서 커밋 → 추적마커까지
8
- * 코드가 수행하고 멈춘다. 이후 비가역(사전 rebase+충돌해결 / 머지 락 / dev --no-ff 머지 /
8
+ * 코드가 수행하고 멈춘다. 이후 비가역(사전 rebase+충돌해결 / 머지 락 / 부모 --no-ff 머지 /
9
9
  * worktree remove / branch -d)은 스킬(task-close)이 git-guard hook + 복구출력 보호 하에 수행.
10
10
  *
11
- * stdout: { prepped, branch, wtPath, registered, commits, aheadOfDev }. exit 0.
11
+ * stdout: { prepped, branch, parent, wtPath, registered, commits, aheadOfParent }. parent = 머지 대상. exit 0.
12
12
  * 콜백(스킬이 LLM 판단 후 처리):
13
13
  * - exit 3 + { blocked:'status', current } — status가 tested 아님 (게이트 차단)
14
14
  * - exit 2 + { blocked:'mapping', files, phases, reason } — Phase↔파일 매핑 모호 (수동 커밋 필요)
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)) {
@@ -96,6 +98,7 @@ async function main() {
96
98
  title: opts.title,
97
99
  type,
98
100
  size: opts.size,
101
+ parent: result.parent,
99
102
  promoted: !!opts.promote,
100
103
  });
101
104
  out = { ...result, docPath: doc.file, registered: doc.registered, promoted: doc.promoted };
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 식별자(버전 또는 기능 그룹명) 추출
@@ -247,7 +247,7 @@ function getWorktreesRoot(projectId) {
247
247
  }
248
248
 
249
249
  function getWorktreePath(projectId, { taskNum, src, slug }) {
250
- // src = 'BL-NNN' | 'RM-NNN' | 'DR'
250
+ // src = 'BL-NNN' | 'ST-N'(로드맵 Stage) | 'DR' (구형 'RM-NNN' 호환)
251
251
  const nnn = String(taskNum).padStart(3, '0');
252
252
  return path.join(getWorktreesRoot(projectId), `TASK-${nnn}_${src}_${slug}`);
253
253
  }
@@ -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,14 @@ 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 풍부 메시지)
362
- // --extended-regexp(ERE)에서 `+`는 수량자 — `\+`로 쓰면 *리터럴 +* 가 되어 "TASK-001"을
363
- // 못 잡는다. 그러면 close로 활성 브랜치가 사라진 뒤 채번이 1로 리셋돼 순차 작업이 번호 충돌.
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"을 못 잡는다.
364
364
  const log = gitCapture(mainWtPath, [
365
365
  'log',
366
- 'dev',
366
+ '--all',
367
367
  '--grep',
368
368
  'TASK-[0-9]+',
369
369
  '--extended-regexp',
@@ -383,10 +383,14 @@ async function forkTask(mainWtPath, { type, dev, src, slug }) {
383
383
  // getNextTaskNumber 직후 worktree add까지 끝내므로 다음 호출은 늘어난 번호를 본다.
384
384
  const projectId = getProjectId(mainWtPath);
385
385
  return withMetaLock(getInitLockPath(projectId), () => {
386
- assertDevExists(mainWtPath);
387
- assertMainWorktreeOnDev(mainWtPath);
388
- // SSoT 안전망 — 같은 출처(BL-NNN/RM-NNN)가 이미 진행중이면 거부. DR은 별도 ID 없어 검사 제외.
389
- if (src !== 'DR') {
386
+ // 부모 = 메인 워크트리의 현재 브랜치(0.6.0). dev 하드코딩 제거 — 서 있는 브랜치가 곧 부모.
387
+ const parent = currentBranch(mainWtPath);
388
+ if (parent === 'HEAD') {
389
+ throw new Error('메인 워크트리가 detached HEAD — 부모로 삼을 브랜치에 체크아웃 필요.');
390
+ }
391
+ // SSoT 안전망 — 같은 항목 출처(BL-NNN/구형 RM-NNN)가 이미 진행중이면 거부.
392
+ // DR(별도 ID 없음)·ST-N(Stage 하나에서 복수 task가 정상)은 검사 제외.
393
+ if (/^(BL|RM)-/.test(src)) {
390
394
  const dup = getActiveTasks(mainWtPath).find((t) => t.src === src);
391
395
  if (dup) {
392
396
  throw new Error(`${src} 이미 진행중 (${dup.branch}) — 다른 세션이 같은 항목 진행`);
@@ -397,11 +401,12 @@ async function forkTask(mainWtPath, { type, dev, src, slug }) {
397
401
  const branch = `${type}/${dev}_TASK-${nnn}_${src}_${slug}`;
398
402
  const wtPath = getWorktreePath(projectId, { taskNum, src, slug });
399
403
  mkdirp(getWorktreesRoot(projectId));
400
- // 동일 브랜치명은 git이 거부(같은 항목 동시 분기 차단). 실패 시 throw → fork.js가 사용자 보고.
401
- execFileSync('git', ['-C', mainWtPath, 'worktree', 'add', wtPath, '-b', branch, 'dev'], {
404
+ // 부모 브랜치를 base로 워크트리·브랜치 생성. 동일 브랜치명은 git이 거부(같은 항목 동시 분기
405
+ // 차단). 실패 시 throw → fork.js가 사용자 보고.
406
+ execFileSync('git', ['-C', mainWtPath, 'worktree', 'add', wtPath, '-b', branch, parent], {
402
407
  stdio: 'pipe',
403
408
  });
404
- return { taskNum, nnn, branch, wtPath, projectId };
409
+ return { taskNum, nnn, branch, wtPath, projectId, parent };
405
410
  });
406
411
  }
407
412
 
@@ -434,12 +439,15 @@ const BACKLOG_PLACEHOLDER =
434
439
  function getActiveVersion(mainWtPath) {
435
440
  // .project/AGENT-GUIDE.md '## 활성 plan 버전' 섹션 다음 비어 있지 않은 첫 줄에서 활성 plan 식별자 추출.
436
441
  // plan = 기능 그룹 단위 → 식별자는 vX.X 버전명 또는 기능 그룹 slug(compare-products 등) 모두 가능.
442
+ // 헤딩 줄 끝 부가 텍스트(HTML 주석 등) 허용 — project-init 골격이 헤딩에 주석을 달므로
443
+ // 접두 일치로 판정(_setActivePlan의 findIndex 기준과 동일). 완전 일치 요구 시
444
+ // 자기 골격을 자기가 못 읽는 자기모순 (recordion FRICTION 2026-08-06, scaffoldError 6회).
437
445
  const guidePath = path.join(mainWtPath, '.project', 'AGENT-GUIDE.md');
438
446
  if (!fs.existsSync(guidePath)) {
439
447
  throw new Error(`.project/AGENT-GUIDE.md 부재. /project-init 먼저 호출.`);
440
448
  }
441
449
  const content = fs.readFileSync(guidePath, 'utf8');
442
- const sec = content.match(/##\s*활성 plan 버전\s*\n+([^\n]+)/);
450
+ const sec = content.match(/^##\s*활성 plan 버전[^\n]*\n+([^\n]+)/m);
443
451
  if (!sec) {
444
452
  throw new Error(`.project/AGENT-GUIDE.md '## 활성 plan 버전' 섹션 부재.`);
445
453
  }
@@ -593,7 +601,7 @@ async function markBacklogChecked(mainWtPath, blId, taskNum) {
593
601
  }
594
602
 
595
603
  // ─── task 헤더 / 상태 전이 (코드화) ─────────────────────────────────
596
- // 헤더 5컬럼 파싱 · task 문서 위치 판정 · 7상태 유효전이 검증을 코드로 단일화.
604
+ // 헤더 파싱(6컬럼 · 구 5컬럼 폴백) · task 문서 위치 판정 · 7상태 유효전이 검증을 코드로 단일화.
597
605
  // 계약: TASK_DOC_RULE §1.1(헤더) · §1.2(7상태) · §1.5(문서 위치).
598
606
 
599
607
  // 7×7 유효전이표 (TASK_DOC_RULE §1.2 + FAIL/UNCERTAIN 분기). from → 허용 to 목록.
@@ -639,7 +647,10 @@ function _locateHeaderRow(content) {
639
647
  }
640
648
 
641
649
  function parseTaskHeader(taskMdPath) {
642
- // 헤더 5컬럼을 구조화 → { created, plan, type, size, status }. 표 부재/컬럼 부족 시 throw.
650
+ // 헤더를 구조화 → { created, plan, type, size, parent, status }. 표 부재/컬럼 부족 시 throw.
651
+ // 6컬럼(0.6.0+): 생성일·플랜·유형·규모·부모 브랜치·상태. 부모 브랜치는 상태 *앞* — 상태를
652
+ // 항상 마지막 셀로 유지해 setStatus의 `length-2` 로직이 구·신 양쪽에서 성립하게 함.
653
+ // 5컬럼(구): 부모 칸 부재 → parent='dev' 폴백(0.6.0 이전 문서 하위호환).
643
654
  const content = fs.readFileSync(taskMdPath, 'utf8');
644
655
  const loc = _locateHeaderRow(content);
645
656
  if (!loc) throw new Error(`헤더 표 파싱 실패 (TASK_DOC_RULE §1.1 형식 아님): ${taskMdPath}`);
@@ -647,13 +658,17 @@ function parseTaskHeader(taskMdPath) {
647
658
  if (inner.length < 5) {
648
659
  throw new Error(`헤더 표 컬럼 부족(${inner.length}/5): ${taskMdPath}`);
649
660
  }
661
+ if (inner.length >= 6) {
662
+ const [created, plan, type, size, parent, status] = inner;
663
+ return { created, plan, type, size, parent, status };
664
+ }
650
665
  const [created, plan, type, size, status] = inner;
651
- return { created, plan, type, size, status };
666
+ return { created, plan, type, size, parent: 'dev', status };
652
667
  }
653
668
 
654
669
  function isProjectRegistered(mainWtPath) {
655
670
  // .gitignore에 .project/ 등록 여부 — check-ignore exit 0 = 등록(퍼블릭 default, 문서는 메인WT 공유),
656
- // exit 1 = 미등록(문서는 워크트리 안, 머지로 dev 반영). task-init Step7 분기와 동일 판정.
671
+ // exit 1 = 미등록(문서는 워크트리 안, 머지로 부모 반영). task-init Step7 분기와 동일 판정.
657
672
  try {
658
673
  execFileSync(
659
674
  'git',
@@ -719,7 +734,8 @@ async function setStatus(taskMdPath, next) {
719
734
  const content = fs.readFileSync(taskMdPath, 'utf8');
720
735
  const loc = _locateHeaderRow(content);
721
736
  if (!loc) throw new Error(`헤더 표 파싱 실패: ${taskMdPath}`);
722
- const cur = loc.cells.slice(1, -1).map((c) => c.trim())[4];
737
+ const trimmed = loc.cells.slice(1, -1).map((c) => c.trim());
738
+ const cur = trimmed[trimmed.length - 1]; // 상태 = 항상 마지막 셀 (6컬럼: 부모 브랜치가 그 앞)
723
739
  if (cur === next) return { changed: false, from: cur, to: next, path: taskMdPath };
724
740
  const allowed = TRANSITIONS[cur] || [];
725
741
  if (!allowed.includes(next)) {
@@ -767,14 +783,14 @@ function _buildTaskDocPath(base, plan, { taskNum, slug, promoted }) {
767
783
  return { dir: planDir, file: path.join(planDir, `${nnn}_${slug}.md`) };
768
784
  }
769
785
 
770
- function _renderTaskSkeleton({ taskNum, title, created, plan, docType, size }) {
786
+ function _renderTaskSkeleton({ taskNum, title, created, plan, docType, size, parent }) {
771
787
  const nnn = String(taskNum).padStart(3, '0');
772
788
  return [
773
789
  `# TASK-${nnn} — ${title}`,
774
790
  '',
775
- '| 생성일 | 플랜 | 유형 | 규모 | 상태 |',
776
- '|--------|------|------|------|------|',
777
- `| ${created} | ${plan} | ${docType} | ${size} | draft |`,
791
+ '| 생성일 | 플랜 | 유형 | 규모 | 부모 브랜치 | 상태 |',
792
+ '|--------|------|------|------|-------------|------|',
793
+ `| ${created} | ${plan} | ${docType} | ${size} | ${parent} | draft |`,
778
794
  '',
779
795
  '## Requirements',
780
796
  '',
@@ -800,14 +816,16 @@ function _renderTaskSkeleton({ taskNum, title, created, plan, docType, size }) {
800
816
  }
801
817
 
802
818
  function scaffoldTaskDoc(mainWtPath, wtPath, meta) {
803
- // meta: { taskNum, slug, title, type(브랜치 타입), size, plan?, promoted?, created? }
819
+ // meta: { taskNum, slug, title, type(브랜치 타입), size, parent, plan?, promoted?, created? }
804
820
  // 위치: 등록(.gitignore) 케이스 → 메인WT, 미등록 → 워크트리. plan 미지정 시 AGENT-GUIDE에서 검출.
821
+ // parent 미지정 시 현재 브랜치 캡처(단독 호출 안전망 — 통상 fork가 넘김).
805
822
  // taskNum이 고유(fork init 락 채번)라 동일 문서 동시 쓰기 경합 없음 → 락 불요.
806
823
  const registered = isProjectRegistered(mainWtPath);
807
824
  const base = registered ? mainWtPath : wtPath;
808
825
  const plan = meta.plan || getActiveVersion(mainWtPath);
809
826
  const docType = DOC_TYPE_OF[meta.type] || meta.type;
810
827
  const created = meta.created || _todayLocal();
828
+ const parent = meta.parent || currentBranch(mainWtPath);
811
829
  const { dir, file } = _buildTaskDocPath(base, plan, {
812
830
  taskNum: meta.taskNum,
813
831
  slug: meta.slug,
@@ -826,6 +844,7 @@ function scaffoldTaskDoc(mainWtPath, wtPath, meta) {
826
844
  plan,
827
845
  docType,
828
846
  size: meta.size,
847
+ parent,
829
848
  }),
830
849
  );
831
850
  return { registered, promoted: !!meta.promoted, plan, file };
@@ -934,7 +953,7 @@ function initPlan(mainWtPath, slug, opts = {}) {
934
953
 
935
954
  // ─── task close 결정적 준비 (코드화, D1·D2) ─────────────────────────
936
955
  // closeTask는 *결정적 준비만* 수행: 게이트(status=tested) → Phase 커밋(D2) → status=closed →
937
- // flows/문서 커밋 → 추적마커. 비가역 3종(dev --no-ff 머지 · worktree remove · branch -d)과
956
+ // flows/문서 커밋 → 추적마커. 비가역 3종(부모 브랜치 --no-ff 머지 · worktree remove · branch -d)과
938
957
  // 충돌 해결·머지 락은 스킬(LLM)이 수행 — 위험 최소화 + 충돌은 LLM 판단 영역.
939
958
 
940
959
  // 브랜치 타입 → 커밋 태그 (GIT_RULE 6-3).
@@ -991,10 +1010,26 @@ function _porcelainFiles(wtPath) {
991
1010
  .filter(Boolean);
992
1011
  }
993
1012
 
1013
+ // 빌드 산출물 판정 — 소스 변경에 도구가 따라 바꾸는 파생 파일 (Phase 소속을 묻는 것 자체가 무의미).
1014
+ // Dev Plan에 명시 안 돼도 미매핑 blocked 대신 마지막 Phase에 자동 편입 (stash·recordion FRICTION: pbxproj 미매핑만 6회).
1015
+ const BUILD_ARTIFACT_RE = [
1016
+ /\.pbxproj$/,
1017
+ /(^|\/)package-lock\.json$/,
1018
+ /(^|\/)yarn\.lock$/,
1019
+ /(^|\/)pnpm-lock\.yaml$/,
1020
+ /(^|\/)Cargo\.lock$/,
1021
+ /(^|\/)Gemfile\.lock$/,
1022
+ /(^|\/)Podfile\.lock$/,
1023
+ ];
1024
+
994
1025
  function splitUncommittedByPhase(wtPath, taskMdPath, meta) {
995
1026
  // 코드 변경분(.project/ 외)을 Dev Plan Phase에 매핑해 Phase별 자동 커밋. meta: { taskNum, type }.
996
- // - 모든 코드 파일이 정확히 1 Phase에 매핑 → Phase 순서대로 자동 커밋.
997
- // - 미매핑/다중매핑/Phase 파싱 불가 → { ambiguous } 반환(커밋 X) → 스킬이 수동 처리.
1027
+ // 매핑 폴백 3규칙 (stash 6회 + recordion 5회 blocked 마찰 반영 — 11회 전부 기계적 편입이었음):
1028
+ // 1. 다중 Phase 매핑 → 가장 이른 Phase에 편입 + 커밋 메시지에 '(Phase a·b 변경 포함)' 표기.
1029
+ // 뒤 Phase가 앞 Phase 타입·함수를 참조하므로 이른 쪽이 의존 방향과 일치. 중간 커밋 독립 빌드는 비보장(GIT_RULE 명문).
1030
+ // 2. 미매핑 + 빌드 산출물(BUILD_ARTIFACT_RE) → 매핑된 그룹 중 마지막 Phase(없으면 Dev Plan 마지막)에
1031
+ // 자동 편입 + '(빌드 산출물 자동 편입)' 표기.
1032
+ // 3. 미매핑 + 일반 파일 → { ambiguous } 반환(커밋 X) — 계획 누락 가능성이라 사람 판단으로 유지.
998
1033
  // .project/ 메타(flows/문서/changelog)는 제외 — closeTask가 별도 커밋.
999
1034
  const codeFiles = _porcelainFiles(wtPath).filter((f) => !f.startsWith('.project/'));
1000
1035
  if (codeFiles.length === 0) return { committed: [], empty: true };
@@ -1013,21 +1048,44 @@ function splitUncommittedByPhase(wtPath, taskMdPath, meta) {
1013
1048
  )
1014
1049
  .map(({ i }) => i);
1015
1050
  const fileToPhase = {};
1051
+ const multiPhase = []; // 규칙 1 적용분 — { file, phases:[num...], assignedTo }
1052
+ const artifactFiles = []; // 규칙 2 후보 — 매핑 확정 후 편입 Phase 결정
1016
1053
  for (const f of codeFiles) {
1017
1054
  const m = matchOf(f);
1018
- if (m.length !== 1) {
1055
+ if (m.length === 1) {
1056
+ fileToPhase[f] = m[0];
1057
+ } else if (m.length > 1) {
1058
+ const earliest = Math.min(...m);
1059
+ fileToPhase[f] = earliest;
1060
+ multiPhase.push({ file: f, phases: m.map((i) => phases[i].num), assignedTo: phases[earliest].num });
1061
+ } else if (BUILD_ARTIFACT_RE.some((re) => re.test(f))) {
1062
+ artifactFiles.push(f);
1063
+ } else {
1019
1064
  return {
1020
1065
  ambiguous: true,
1021
- reason: m.length === 0 ? `Phase 미매핑: ${f}` : `다중 Phase 매핑: ${f}`,
1066
+ reason: `Phase 미매핑: ${f}`,
1022
1067
  files: codeFiles,
1023
1068
  phases: phases.map((p) => ({ num: p.num, name: p.name, files: p.files })),
1024
1069
  };
1025
1070
  }
1026
- fileToPhase[f] = m[0];
1027
1071
  }
1072
+ // 규칙 2 — 산출물 편입: 매핑된 그룹 중 마지막 Phase, 하나도 없으면 Dev Plan 마지막 Phase
1073
+ const autoAssigned = [];
1074
+ if (artifactFiles.length > 0) {
1075
+ const mappedIdx = Object.values(fileToPhase);
1076
+ const lastIdx = mappedIdx.length > 0 ? Math.max(...mappedIdx) : phases.length - 1;
1077
+ for (const f of artifactFiles) {
1078
+ fileToPhase[f] = lastIdx;
1079
+ autoAssigned.push({ file: f, assignedTo: phases[lastIdx].num });
1080
+ }
1081
+ }
1082
+ // 파일별 커밋 메시지 표기 (자동 편입의 근거를 커밋에 남김 — 코드가 몰래 정한 걸 드러낸다)
1083
+ const noteOf = {};
1084
+ for (const x of multiPhase) noteOf[x.file] = ` (Phase ${x.phases.join('·')} 변경 포함)`;
1085
+ for (const x of autoAssigned) noteOf[x.file] = ' (빌드 산출물 자동 편입)';
1028
1086
  // Phase별 그룹 → 순서대로 커밋
1029
1087
  const groups = new Map();
1030
- for (const f of codeFiles) {
1088
+ for (const f of Object.keys(fileToPhase)) {
1031
1089
  const i = fileToPhase[f];
1032
1090
  if (!groups.has(i)) groups.set(i, []);
1033
1091
  groups.get(i).push(f);
@@ -1041,28 +1099,36 @@ function splitUncommittedByPhase(wtPath, taskMdPath, meta) {
1041
1099
  execFileSync('git', ['-C', wtPath, 'add', '--', ...grp], { stdio: 'pipe' });
1042
1100
  const msg =
1043
1101
  `${tag}: [TASK-${nnn}] Phase ${p.num} - ${p.name}\n\n` +
1044
- grp.map((f) => `- ${f}`).join('\n') +
1102
+ grp.map((f) => `- ${f}${noteOf[f] || ''}`).join('\n') +
1045
1103
  `\n- 사유: ${p.name}`;
1046
1104
  execFileSync('git', ['-C', wtPath, 'commit', '-m', msg], { stdio: 'pipe' });
1047
1105
  committed.push({ phase: p.num, name: p.name, files: grp });
1048
1106
  }
1049
- return { committed };
1107
+ const result = { committed };
1108
+ if (multiPhase.length > 0) result.multiPhase = multiPhase;
1109
+ if (autoAssigned.length > 0) result.autoAssigned = autoAssigned;
1110
+ return result;
1050
1111
  }
1051
1112
 
1052
1113
  async function closeTask(mainWtPath, { taskNum }, opts = {}) {
1053
1114
  // 결정적 준비만 — 비가역(머지·정리)·충돌 해결은 스킬이 후속 수행.
1054
- // 반환: { blocked } (게이트 미충족/매핑 모호) 또는 { prepped, branch, wtPath, registered, commits, aheadOfDev }.
1115
+ // 반환: { blocked } (게이트 미충족/매핑 모호) 또는
1116
+ // { prepped, branch, parent, wtPath, registered, commits, aheadOfParent,
1117
+ // multiPhase?, autoAssigned? (매핑 폴백 적용 시 내역) }. parent = 스킬 머지 대상.
1055
1118
  const active = getActiveTasks(mainWtPath).find((t) => t.taskNum === taskNum);
1056
1119
  if (!active) {
1057
1120
  throw new Error(`TASK-${String(taskNum).padStart(3, '0')} 진행중 브랜치 없음 (SSoT 조회).`);
1058
1121
  }
1059
- assertMainWorktreeOnDev(mainWtPath);
1060
1122
  const projectId = getProjectId(mainWtPath);
1061
1123
  const wtPath = getWorktreePath(projectId, { taskNum, src: active.src, slug: active.slug });
1062
1124
  const resolved = resolveTaskDocPath(mainWtPath, wtPath, { taskNum });
1063
1125
  if (!resolved) throw new Error(`TASK-${taskNum} 문서 못 찾음.`);
1064
1126
 
1065
1127
  const header = parseTaskHeader(resolved.file);
1128
+ // 부모 = 헤더 기록값(0.6.0, 구 문서는 'dev' 폴백). 메인 워크트리가 그 부모에 서 있어야
1129
+ // 스킬 후속 머지가 올바른 브랜치로 간다.
1130
+ const parent = header.parent;
1131
+ assertMainWorktreeOn(mainWtPath, parent);
1066
1132
  if (header.status !== 'tested') {
1067
1133
  return { blocked: 'status', current: header.status, docPath: resolved.file };
1068
1134
  }
@@ -1114,9 +1180,9 @@ async function closeTask(mainWtPath, { taskNum }, opts = {}) {
1114
1180
  }
1115
1181
  }
1116
1182
 
1117
- // 추적 마커 빈커밋 (dev보다 앞선 커밋 0개 — docs/분석 task + .project gitignore 케이스)
1183
+ // 추적 마커 빈커밋 (부모보다 앞선 커밋 0개 — docs/분석 task + .project gitignore 케이스)
1118
1184
  const ahead = parseInt(
1119
- gitCapture(wtPath, ['rev-list', '--count', 'dev..HEAD'], { allowFail: true }) || '0',
1185
+ gitCapture(wtPath, ['rev-list', '--count', `${parent}..HEAD`], { allowFail: true }) || '0',
1120
1186
  10,
1121
1187
  );
1122
1188
  if (ahead === 0) {
@@ -1136,19 +1202,24 @@ async function closeTask(mainWtPath, { taskNum }, opts = {}) {
1136
1202
  }
1137
1203
 
1138
1204
  const aheadFinal = parseInt(
1139
- gitCapture(wtPath, ['rev-list', '--count', 'dev..HEAD'], { allowFail: true }) || '0',
1205
+ gitCapture(wtPath, ['rev-list', '--count', `${parent}..HEAD`], { allowFail: true }) || '0',
1140
1206
  10,
1141
1207
  );
1142
- return {
1208
+ const prepResult = {
1143
1209
  prepped: true,
1144
1210
  taskNum,
1145
1211
  branch: active.branch,
1212
+ parent,
1146
1213
  wtPath,
1147
1214
  registered: resolved.registered,
1148
1215
  docPath: resolved.file,
1149
1216
  commits,
1150
- aheadOfDev: aheadFinal,
1217
+ aheadOfParent: aheadFinal,
1151
1218
  };
1219
+ // 매핑 폴백 적용 내역 노출 — 코드가 자동 편입한 걸 스킬·사용자가 볼 수 있게
1220
+ if (split.multiPhase) prepResult.multiPhase = split.multiPhase;
1221
+ if (split.autoAssigned) prepResult.autoAssigned = split.autoAssigned;
1222
+ return prepResult;
1152
1223
  }
1153
1224
 
1154
1225
  module.exports = {
@@ -1187,8 +1258,8 @@ module.exports = {
1187
1258
  getInitLockPath,
1188
1259
  withMergeLock,
1189
1260
  withMetaLock,
1190
- assertDevExists,
1191
- assertMainWorktreeOnDev,
1261
+ currentBranch,
1262
+ assertMainWorktreeOn,
1192
1263
  parseBranchName,
1193
1264
  getActiveTasks,
1194
1265
  getNextTaskNumber,
package/bin/mcp.js CHANGED
@@ -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와 함께 주면 골격 생성)'),
@@ -213,6 +213,7 @@ server.registerTool(
213
213
  title: a.title,
214
214
  type: a.type,
215
215
  size: a.size,
216
+ parent: result.parent,
216
217
  promoted: !!a.promote,
217
218
  });
218
219
  out = { ...result, docPath: doc.file, registered: doc.registered, promoted: doc.promoted };
@@ -228,7 +229,7 @@ server.registerTool(
228
229
  'task_close',
229
230
  {
230
231
  description:
231
- 'close 결정적 준비만 — Phase 커밋·status=closed·문서 커밋·추적마커. 게이트(status=tested)·매핑 모호 시 blocked 반환. 비가역(dev --no-ff 머지·worktree/branch 정리·충돌 해결)은 스킬(task-close)이 후속 수행.',
232
+ 'close 결정적 준비만 — Phase 커밋·status=closed·문서 커밋·추적마커. 게이트(status=tested)·매핑 모호 시 blocked 반환. 반환 parent = 스킬 머지 대상 부모 브랜치. 비가역(부모 --no-ff 머지·worktree/branch 정리·충돌 해결)은 스킬(task-close)이 후속 수행.',
232
233
  inputSchema: { task: z.union([z.string(), z.number()]).describe('TASK-NNN 또는 번호') },
233
234
  },
234
235
  handler(async (a) => {
@@ -245,7 +246,7 @@ server.registerTool(
245
246
  `Phase↔파일 매핑 모호 (${result.reason}) — Dev Plan '파일' 필드 확인 후 수동 Phase 커밋 → task_close 재호출.`,
246
247
  );
247
248
  }
248
- return ok(result, `prep 완료. 비가역(사전 rebase·충돌해결·머지 락·dev 머지·정리)은 task-close 스킬이 후속 수행.`);
249
+ return ok(result, `prep 완료 (부모=${result.parent}). 비가역(사전 rebase·충돌해결·머지 락·부모 --no-ff 머지·정리)은 task-close 스킬이 후속 수행.`);
249
250
  }),
250
251
  );
251
252
 
package/bin/status.js CHANGED
@@ -56,11 +56,14 @@ function main() {
56
56
  const lockTimeoutMs = manifest.lock_timeout_ms ?? DEFAULT_LOCK_TIMEOUT_MS;
57
57
 
58
58
  const currentBranch = gitCapture(mainWt, ['rev-parse', '--abbrev-ref', 'HEAD']);
59
- const isOnDev = currentBranch === 'dev';
59
+ const detached = currentBranch === 'HEAD';
60
60
 
61
61
  console.log(`\ntaskery status\n`);
62
62
  console.log(`메인 워크트리: ${mainWt}`);
63
- console.log(`현재 브랜치: ${currentBranch}${isOnDev ? '' : ' ⚠ (메인 워크트리 = dev 정책 위배)'}`);
63
+ // 현재 브랜치 = 새 태스크의 부모 브랜치(0.6.0). detached HEAD면 fork 불가.
64
+ console.log(
65
+ `현재 브랜치(= 새 태스크 부모): ${currentBranch}${detached ? ' ⚠ (detached HEAD — 브랜치 체크아웃 필요)' : ''}`,
66
+ );
64
67
  console.log(`projectId: ${projectId}`);
65
68
  console.log(`stale_days: ${staleDays} / lock_timeout_ms: ${lockTimeoutMs}\n`);
66
69
 
@@ -126,7 +129,7 @@ function main() {
126
129
  );
127
130
  const orphans = folders.filter((f) => !activeFolders.has(f));
128
131
  if (orphans.length > 0) {
129
- console.log(`⚠ SSoT에 없는 워크트리 폴더 ${orphans.length}건 (이미 dev 머지된 잔존?):`);
132
+ console.log(`⚠ SSoT에 없는 워크트리 폴더 ${orphans.length}건 (이미 부모 머지된 잔존?):`);
130
133
  for (const o of orphans) {
131
134
  console.log(` ${path.join(worktreesRoot, o)}`);
132
135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@angar2/taskery",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Lightweight task system for Claude Code & Codex — 1 메인 세션(또는 멀티세션) + 스킬 9종 + catastrophic hook 안전망 2종",
5
5
  "bin": {
6
6
  "taskery": "bin/taskery.js",
@@ -48,7 +48,9 @@ if [ ! -f "$TASK_MD" ]; then
48
48
  exit 0
49
49
  fi
50
50
 
51
- # 헤더 status 추출 (5컬럼 표 — 생성일 / 플랜 / 유형 / 규모 / 상태)
51
+ # 헤더 status 추출 — status는 헤더 표의 *마지막* 셀.
52
+ # 5컬럼(구): 생성일 / 플랜 / 유형 / 규모 / 상태
53
+ # 6컬럼(0.6.0+): 생성일 / 플랜 / 유형 / 규모 / 부모 브랜치 / 상태
52
54
  # python3 정규식 사용 (mac BSD grep -P 미지원)
53
55
  STATUS=$(TM="$TASK_MD" python3 -c "
54
56
  import os, re
@@ -59,8 +61,8 @@ except Exception:
59
61
  raise SystemExit(0)
60
62
  # 7-state whitelist
61
63
  WL = r'(draft|planned|developing|developed|testing|tested|closed)'
62
- # 5-column row: | 생성일 | 플랜 | 유형 | 규모 | 상태 |
63
- m = re.search(rf'^\|[^|]*\|[^|]*\|[^|]*\|[^|]*\|\s*{WL}\s*\|\s*\$', content, re.MULTILINE)
64
+ # status = 마지막 셀. 선행 셀 4개(구 5컬럼) 또는 5개(신 6컬럼) 뒤에 온다.
65
+ m = re.search(rf'^\|(?:[^|]*\|){{4,5}}\s*{WL}\s*\|\s*\$', content, re.MULTILINE)
64
66
  print(m.group(1) if m else '')
65
67
  " 2>/dev/null)
66
68
 
@@ -24,9 +24,12 @@ developer_instructions = """
24
24
  - task.md의 ## Test Plan 시나리오를 순서대로 그대로 수행한다(임의 추가/스킵/변형 금지).
25
25
  - 문 앞 검사(먼저) — 각 [AUTO] 시나리오가 [실행 명령/입력 + 구체적·관측 가능한 기대값(출력값/상태코드/어서션/조회 결과/화면 상태)] 한 쌍을 갖췄는지 확인한다. "정상 동작/잘 됨/확인한다" 같은 소원은 기대값이 아니다. 미달인 [AUTO]는 검사하지 말고 "시험문제 결함(검증 불가)"으로 분류 + 무엇이 빠졌는지 지목한다(PASS 반올림 금지).
26
26
  - 증거 일치 시만 PASS — 자격 갖춘 [AUTO]는 실제로 실행해 *관측 결과가 기대값과 일치할 때만* PASS. 코드 정독 "그럴듯함"·추정으로 PASS 금지. 증거(명령 출력/응답 로그/캡처/조회 결과)를 첨부한다.
27
- - [AUTO]/[USER] 분류 — [USER]는 정답지가 *주관*인 것만(시각 미세 취향 / 색조·2px / UX 느낌) → UNCERTAIN(사람 검수) + 증거 캡처로 리턴. 시각 *객관* 깨짐(요소 빠짐/빈 화면/배치 딴판/색 완전히 틀림)은 [USER]가 아니라 [AUTO] — 실제 화면 캡처 → 승인 목업과 대조해 네가 직접 판정한다(어긋나면 *구체적 어긋남 목록* + FAIL). 시각이라고 무조건 사람에게 넘기지 마라.
28
- - UNCERTAIN 두 종류 — (사람 검수)=정답지가 주관인 영역 / (검증 불가)=[AUTO]인데 대조할 기대값을 구성 못 함=시험문제 결함. 검증 불가는 PASS로 반올림하거나 사람 검수로 떠넘기지 말고, *무엇을 시도했고 왜 기대값을 구성할 수 없는지* 근거를 반드시 기록한다(task-plan으로 Test Plan을 보수해야 하는 신호).
27
+ - [AUTO]/[USER] 분류 — [USER]는 정답지가 *주관*인 것만(시각 미세 취향 / 색조·2px / UX 느낌) → UNCERTAIN(사람 검수) + 증거 캡처로 리턴. 시각 *객관* 깨짐(요소 빠짐/빈 화면/배치 딴판/색 완전히 틀림)은 [USER]가 아니라 [AUTO] — 실제 화면 캡처 → 승인 목업과 대조해 네가 직접 판정한다(어긋나면 *구체적 어긋남 목록* + FAIL). 시각이라고 무조건 사람에게 넘기지 마라. 단 시각 객관의 [AUTO] 승격은 TEST-GUIDE의 *시각 실행* 절에 캡처 실행 경로가 있을 때 한정이다 — 경로가 없어 task.md가 [USER]로 분류했으면 그 분류를 존중한다.
28
+ - UNCERTAIN 세 종류 — (사람 검수)=정답지가 주관인 영역 / (검증 불가)=[AUTO]인데 대조할 기대값을 구성 못 함=시험문제 결함 / (실행 보류)=화면·입력 점유 제약으로 실행하지 않음=환경 제약(사용자 승인 또는 격리 실행 경로 필요. 무엇을 왜 실행하지 않았는지 기록해 리턴). 검증 불가는 PASS로 반올림하거나 사람 검수로 떠넘기지 말고, *무엇을 시도했고 왜 기대값을 구성할 수 없는지* 근거를 반드시 기록한다(task-plan으로 Test Plan을 보수해야 하는 신호).
29
29
  - 신규 테스트 추가 시 카운트 +N / 모든 PASS만으로 단정 X — 신규 *테스트 식별자*(suite/class/function 등)가 runner 로그에 실제 등장했는지 grep으로 확인한다(1 hit 이상, 없으면 Suite 미실행 → FAIL). grep/Read 존재 확인은 보조용 — 실동작 PASS를 대체할 수 없다.
30
+ - 픽스처 현실성 — 픽스처·시드가 실제 산출물과 형식·정밀도가 같은지 의심하고, 의심되면 실제 코드(writer·생성기)로 재생성해 재실행한 결과로 판정한다. 재생성 경로가 없어 대조를 구성할 수 없으면 시험문제 결함(검증 불가)으로 분류한다.
31
+ - 어서션 정면성 — 기대값이 요구사항을 정면으로 겨누는지 확인한다("벗어날 수 없다"면 실제로 벗어나 볼 것. 버튼 disabled 확인은 우회 지표다). 우회 지표로 검사하는 [AUTO] 시나리오는 시험문제 결함으로 분류하고 무엇이 정면 관측인지 지목한다.
32
+ - 화면·입력 점유 금지 — 실제 앱을 띄워 UI를 자동 조작하는 스위트는 사용자의 화면·입력을 점유한다. TEST-GUIDE의 *테스트 실행 환경* 절에 격리 실행 경로(창 숨김·헤드리스 / 컨테이너 / VM / CI)가 있으면 그 경로로 실행하고, 없으면 비점유 부분만 실행하거나 실행 보류(사용자 승인 필요)로 표시해 리턴한다. 헤드리스 모드가 있으면 항상 그쪽을 쓴다.
30
33
  - 코드와 동작만 신뢰한다. 코드 파일 수정 금지. 임시 파일(테스트 스크립트 등)은 종료 후 삭제한다.
31
- - 검증을 마치면 넘겨받은 검사 지시의 결과 형식(종합 판정 PASS/FAIL/UNCERTAIN / 시나리오별 결과 / 검증·테스트 명령 결과 / UNCERTAIN 2종 / 시험문제 결함)을 그대로 리턴한다.
34
+ - 검증을 마치면 넘겨받은 검사 지시의 결과 형식(종합 판정 PASS/FAIL/UNCERTAIN / 시나리오별 결과 / 검증·테스트 명령 결과 / UNCERTAIN 3종 / 시험문제 결함 / 실행 보류)을 그대로 리턴한다.
32
35
  """