diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e5bc1f7..869cbbe 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ { "name": "goax", "description": "AI 에이전트 결과 일관성을 환경으로 통제하는 4계층 하네스 (Triage·Constitution·Module·Spec/ADR) + Spirit·Mistake Loop. bash+markdown only, 자연어로 도입. Claude Code·OpenCode 지원.", - "version": "0.7.3", + "version": "0.7.4", "author": { "name": "bluecheat", "email": "itsinil@gmail.com" @@ -30,5 +30,5 @@ ] } ], - "version": "0.7.3" + "version": "0.7.4" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index acea508..8fec724 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "goax", - "version": "0.7.3", + "version": "0.7.4", "description": "AX 4-Layer harness — Triage / Constitution / Module / Spec·ADR + Spirit·Mistake Loop. Bash + Markdown only, with Claude-driven onboarding. Multi-CLI: Claude Code (native) + OpenCode (Hybrid compat via AGENTS.md SSOT + opencode.json).", "author": { "name": "bluecheat", diff --git a/VERSION b/VERSION index f38fc53..0a1ffad 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.3 +0.7.4 diff --git a/changelog/0.7.4.md b/changelog/0.7.4.md new file mode 100644 index 0000000..72d9fc6 --- /dev/null +++ b/changelog/0.7.4.md @@ -0,0 +1,27 @@ +# 0.7.4 — 레인의 일부만 맡겨도 원장이 그대로 적어요 + +## 무엇이 좋아졌나 — 누구에게 체감되나 + +- **라운드마다 실제로 맡긴 task 만 디스패치로 적혀요.** 예전엔 `--dispatch` 가 레인 이름만 받아서, 그 레인의 미완료 + task 전부에 시각을 찍었어요. 한 실사용 프로젝트(레인 5개 · 워커 4개 동시)에서 한 레인의 8 task 가 실제 기동보다 77분 + 먼저 디스패치로 적혔고, 아무도 안 맡은 4 task 가 `dispatched_unreported` 와 완료 게이트의 "보고 안 받은 디스패치" 에 + 섞여 경보가 흐려졌어요. 이제 `--dispatch T010,T011` 로 그 task 들만 적어요. `--report` 와 같은 모양이에요. +- **여러 task 를 한 번에 완료 표시해요.** 레인 보고 하나로 여러 task 의 검증이 통과했을 때 `mark-task.sh --task T010,T011`. + +## 바뀐 것 + +- `lanes-dispatch.sh --dispatch <레인 | T010,T011>` + - 레인 이름이면 기존 동작 그대로예요. + - task 목록이면 전부 미완료 · 같은 레인에 배정돼 있어야 해요. 아니면(없는 ID · 완료/보류 · 레인 없음 · 여러 레인) + exit 1 이고 원장은 그대로예요 (`--assign` 과 같은 트랜잭션). + - 이미 디스패치된 task 를 다시 적으면 시각을 갱신하고 `보고:` 를 지운 뒤 `warnings` 에 적어요. 중복 ID 는 한 번만. + - 파일 소유 충돌 검사는 그 task 들이 속한 레인 기준이에요. +- `mark-task.sh --task T010,T011` — 트랜잭션(하나라도 없거나 중복이면 무변경), 이미 켜진 것은 `unchanged_tasks`. + result 에 `tasks` · `lines` · `changed_tasks` · `unchanged_tasks` 를 더했어요. 단일 ID 의 기존 필드는 그대로예요. +- 문서: `spec-implement/references/lane-mode.md` §2 가 task 목록 디스패치를 기본으로 안내해요. `lane` §6 · `spec-implement` §5 · + 스크립트 README 도 같이 고쳤어요. + +## 검증 + +- smoke 738 통과 (macOS bash 3.2). §38 에 task 목록 디스패치 3건(목록만 기록 · 잘못된 목록 4종 거부+무변경 · 재디스패치 경고), + §59 에 다중 ID 2건(부분 적용 없음 · 이미 켜진 것 건너뜀). diff --git a/changelog/README.md b/changelog/README.md index 38bf69e..6ff3e04 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -4,6 +4,7 @@ ## 버전 +- [0.7.4](0.7.4.md) — 레인의 일부만 맡겨도 원장이 그대로 적어요 (`--dispatch T010,T011` · `mark-task --task T010,T011`) - [0.7.3](0.7.3.md) — audit 이 경고만 있는 룰을 만들던 것: 룰 본문은 가이드("어떻게 해야 하나")부터, grep 경고는 커밋을 멈춰야 하는 룰에만 · "집행 사다리" → "막는 방법 우선순위" - [0.7.2](0.7.2.md) — task 하나를 끝냈는데 본문에 이름만 나온 다른 task 까지 완료로 바뀌던 것 (`mark-task.sh`) - [0.7.1](0.7.1.md) — lint 결과가 모델에게 닿고, 품질 설정 완화는 이유부터, 압축 뒤에도 위치를 기억해요 · 응답 이모지 정리 diff --git a/skills/lane/SKILL.md b/skills/lane/SKILL.md index 2853bdc..478011d 100644 --- a/skills/lane/SKILL.md +++ b/skills/lane/SKILL.md @@ -193,6 +193,8 @@ bash .ax/scripts/bash/lanes-dispatch.sh --spec "$SPEC" --assign "T010=A,T011=A,T `result.lane_file_conflicts` 가 비어야 해요 — 같은 파일을 두 레인이 갖고 있으면 나중에 `--dispatch` 가 거부돼요. 레인 이름은 짧게(A·B·C 또는 역할명), task 하나는 레인 하나예요. +디스패치는 `spec-implement` 가 라운드마다 해요 — `--dispatch T010,T011` 로 그 라운드에 실제로 맡긴 +task 만 적고(같은 레인이어야 해요), `--dispatch A` 는 레인의 미완료 전부를 한 번에 맡길 때만 써요. 옮기려면 `--force`. 배정하지 않은 task 는 코디네이터가 직접 하는 일이에요 — 떼어낸 배럴 task(정책 B)가 보통 여기예요. diff --git a/skills/spec-implement/SKILL.md b/skills/spec-implement/SKILL.md index 3e363aa..918d9f6 100644 --- a/skills/spec-implement/SKILL.md +++ b/skills/spec-implement/SKILL.md @@ -228,6 +228,7 @@ TASK_ID="$COMPLETED_TASK_ID" # 예: T013 # 인라인 sed(`.*T013`)는 본문에 다른 task 를 언급한 줄("T009 가 해소")까지 켰어요. bash .ax/scripts/bash/mark-task.sh --spec "$SPEC" --task "$TASK_ID" --json # 의도적 보류는 --state '~' (사유는 task 줄에 `보류: <사유>` 로 적어요) +# 레인 보고로 여러 task 가 한꺼번에 검증을 통과했으면 --task T010,T011 — 하나라도 없으면 아무것도 안 켜요 ``` 각 task 완료 후 1줄 보고 (대화 [y/n] X): diff --git a/skills/spec-implement/references/lane-mode.md b/skills/spec-implement/references/lane-mode.md index ff85e97..63a9e82 100644 --- a/skills/spec-implement/references/lane-mode.md +++ b/skills/spec-implement/references/lane-mode.md @@ -23,13 +23,22 @@ echo "$LEDGER" | jq -r '.result.unassigned_open | join(" ")' - §3.2 strict 조건(L3 + SP-SEC/DATA 등)에 걸리는 task 는 **레인에 넘기지 않아요.** 코디네이터가 `[y/n]` 을 받고 직접 해요 -**2. 디스패치를 원장에 먼저 적고, 그다음 띄워요.** +**2. 디스패치를 원장에 먼저 적고, 그다음 띄워요.** 원장엔 **이번 브리프에 실제로 넣은 task 만** 적어요. ```bash -bash .ax/scripts/bash/lanes-dispatch.sh --spec "$SPEC" --dispatch A --json # 파일 소유 충돌이면 여기서 거부돼요 +bash .ax/scripts/bash/lanes-dispatch.sh --spec "$SPEC" --dispatch T010,T011 --json # 이번 라운드에 맡기는 task 만 +bash .ax/scripts/bash/lanes-dispatch.sh --spec "$SPEC" --dispatch A --json # 레인의 미완료 전부를 한 번에 맡길 때 ``` -**exit 1 이 항상 에러는 아니에요** — 그 레인의 미완료 task 가 전부 이미 디스패치·보고까지 +- **task 목록이 기본이에요.** §1 의 "ready ∩ 레인 A" 는 보통 레인 A 의 일부라서, `--dispatch A` 로 적으면 + 아무도 안 맡은 task 까지 시각이 찍혀요. 그러면 `dispatched_unreported` 와 완료 게이트의 "보고 안 받은 + 디스패치" 에 안 보낸 task 가 섞여 신호가 흐려져요 (실측: 레인 하나의 8 task 가 실제 기동보다 77분 먼저 + 찍혔고, 아무도 안 맡은 4 task 가 경보에 들어갔어요) +- 목록은 전부 미완료 · 같은 레인이어야 해요. 아니면 exit 1 이고 원장은 그대로예요 +- 이미 디스패치된 task 를 다시 적으면 시각을 갱신하고 `보고:` 를 지운 뒤 `warnings` 에 알려요 — 재전송이에요 +- 파일 소유 충돌이면 둘 다 여기서 거부돼요 (그 task 들이 속한 레인 기준) + +**exit 1 이 항상 에러는 아니에요** — 레인 이름으로 보냈는데 그 레인의 미완료 task 가 전부 이미 디스패치·보고까지 끝났으면 (더 보낼 게 없으면) `--dispatch` 가 exit 1 로 끝나요 (`status: error` + "전부 보고까지 받았어요 — 그래도 다시 보내려면 --force"). 이건 종료 조건이지 실패가 아니에요 — §5 로 넘어가서 `ready`·`dispatched_unreported` 를 다시 봐요. 진짜 실패(파일 소유 충돌)는 메시지가 달라요. diff --git a/templates/default/.ax/scripts/bash/README.md b/templates/default/.ax/scripts/bash/README.md index 4157534..45bc301 100644 --- a/templates/default/.ax/scripts/bash/README.md +++ b/templates/default/.ax/scripts/bash/README.md @@ -38,8 +38,8 @@ | `tasks-gate.sh` | spec 완료 게이트 G1~G6 — 미완료 · AC 커버리지 · orphan · 유실 · 레인 원장 · evaluator verdict(`review.md`) | `spec-implement`, `lane`, pre-commit hook | | `lanes-hotfiles.sh` | tasks.md 에서 핫 파일(여러 미완료 task 가 쓰는 파일) + `files:` 누락 task 추출 | `lane` | | `spec-review.sh` | spec 합의 리뷰 원장 — `--snapshot`(sha 고정·라운드) / `--status`(리뷰어별 `review-spec.{architect,evaluator}.md` 의 verdict·sha 집계 → pass) / `--merge`(합본). 필수 여부는 Size 축만 | `spec-validate` | -| `lanes-dispatch.sh` | 레인 디스패치 원장 — `--assign / --dispatch / --report / --status`. tasks.md 의 `레인:`·`디스패치:`·`보고:` 필드를 쓰고, 파일 소유 충돌이면 dispatch 거부 | `lane`, `spec-implement` | -| `mark-task.sh` | tasks.md 체크박스 하나를 켜요 — `--task T013 [--state x\|~]` · `--next`(펜스 밖 첫 미완료). 줄 **앞** ID 만 매칭(본문 언급 무시), 코드 펜스 건너뜀, `tasks.md.lock`, 쓰기 전후 `[x]` 개수 검증 | `spec-implement` | +| `lanes-dispatch.sh` | 레인 디스패치 원장 — `--assign / --dispatch <레인 \| T010,T011> / --report <레인 \| T010,T011> / --status`. tasks.md 의 `레인:`·`디스패치:`·`보고:` 필드를 쓰고, 파일 소유 충돌이면 dispatch 거부 | `lane`, `spec-implement` | +| `mark-task.sh` | tasks.md 체크박스를 켜요 — `--task T013[,T014] [--state x\|~]` · `--next`(펜스 밖 첫 미완료). 줄 **앞** ID 만 매칭(본문 언급 무시), 코드 펜스 건너뜀, `tasks.md.lock`, 쓰기 전후 `[x]` 개수 검증. 여러 ID 는 트랜잭션(하나라도 없으면 무변경) | `spec-implement` | | `zero-init.sh` | 0→1 첫날 팩 설치 (룰은 라이브 `spirit/rules/`, 템플릿은 `_templates/zero/`) — 덮어쓰지 않고 SP 토큰 충돌만 경고 | `zero` | | `zero-domain-risk.sh` | `config.yml` 의 `domain_risk` 블록 통째 교체 (`--show/--set/--default`) — 출고 예시 키가 남으면 triage 가 영원히 default_risk 로 흘러요 | `zero` | | `zero-probe.sh` | 네거티브 프로브 — 일부러 위반을 만들어 차단이 실제로 도는지 확인 | `zero` | diff --git a/templates/default/.ax/scripts/bash/lanes-dispatch.sh b/templates/default/.ax/scripts/bash/lanes-dispatch.sh index 5eb4e8e..6ee3803 100755 --- a/templates/default/.ax/scripts/bash/lanes-dispatch.sh +++ b/templates/default/.ax/scripts/bash/lanes-dispatch.sh @@ -4,7 +4,7 @@ # Usage: # bash lanes-dispatch.sh [--spec ] [--status] [--json] # bash lanes-dispatch.sh [--spec ] --assign "T010=A,T011=A,T020=B" [--force] [--dry-run] [--json] -# bash lanes-dispatch.sh [--spec ] --dispatch [--force] [--dry-run] [--json] +# bash lanes-dispatch.sh [--spec ] --dispatch [--force] [--dry-run] [--json] # bash lanes-dispatch.sh [--spec ] --report [--dry-run] [--json] # # 왜 필요한가 — 코디네이터의 기억은 컨텍스트 창 안에만 있어요. 어떤 task 를 어느 레인에 @@ -30,6 +30,12 @@ # --dispatch 는 기본적으로 **아직 보고를 못 받은** 미완료 task 만 보내요. 이미 보고까지 # 받은 task 를 다시 보내려면 --force (그때만 그 task 의 `보고:` 를 지워요). # --dispatch 는 그 레인이 lane_file_conflicts 에 걸려 있으면 거부해요 (exit 1, --force 로 우회). +# --dispatch T010,T011 처럼 task 목록을 주면 **그 task 들만** 보내요 — 라운드마다 레인의 일부만 +# 워커에 맡길 때예요. 레인 이름으로 보내면 그 레인의 미완료 전부에 시각이 찍혀서, 아무도 안 맡은 +# task 가 "보고 안 받은 디스패치" 로 세져 경보가 흐려졌어요 (실측: 레인 하나의 8 task 가 실제 +# 기동보다 77분 먼저 찍히고, 안 맡긴 4 task 가 경보에 섞임). 목록은 전부 미완료여야 하고 같은 +# 레인에 배정돼 있어야 해요 (아니면 exit 1, 파일 무변경). 이미 디스패치된 task 를 다시 지정하면 +# 시각을 갱신하고 `보고:` 를 지운 뒤 warnings 에 적어요 — 이름을 직접 댄 것 자체가 재전송 의사예요. # 소유자는 정하지 않아요 — 배정은 판단이고 lane skill 이 사람과 해요. 여기선 기록과 검사만. # # `files:` 경로는 비교 전에 정규화해요 (`./` 제거 · 중복 `/` · 끝 `/`) — `src/a.ts` 와 @@ -265,16 +271,46 @@ case "$MODE" in IFS="$oldIFS" ;; dispatch) - [ -n "$LANE" ] || fail "--dispatch 에 레인 이름이 필요해요" - OPEN_IN_LANE=$(printf '%s\n' "$LEDGER" | awk -F'|' -v l="$LANE" '$2=="open" && $4==l{print $1}') - [ -n "$OPEN_IN_LANE" ] || fail "레인 '$LANE' 에 미완료 task 가 없어요 — --assign 으로 먼저 배정하세요" - if [ "$FORCE" = true ]; then - TARGETS=$(printf '%s\n' "$OPEN_IN_LANE" | paste -sd, - || true) + [ -n "$LANE" ] || fail "--dispatch 에 레인 이름 또는 task ID 목록(T010,T011)이 필요해요" + IS_LANE=$(printf '%s\n' "$LEDGER" | awk -F'|' -v l="$LANE" '$4==l{f=1} END{print f?1:0}') + if [ "$IS_LANE" != "1" ] && printf '%s' "$LANE" | tr -d ' ' | grep -Eq '^T[0-9]+(,T[0-9]+)*,?$'; then + # task 목록 — 전부 검증한 뒤에만 써요 (--assign 과 같은 트랜잭션: 반쯤 찍힌 원장은 더 나빠요) + TARGETS=$(printf '%s' "$LANE" | tr -d ' ' | tr ',' '\n' | awk 'NF && !seen[$0]++' | paste -sd, -) + MISSING=""; NOTOPEN=""; NOLANE=""; LANES_SEEN=""; REDISP="" + oldIFS="$IFS"; IFS=',' + for tid in $TARGETS; do + [ -z "$tid" ] && continue + has_id "$tid" || { MISSING="$MISSING$tid "; continue; } + row=$(printf '%s\n' "$LEDGER" | awk -F'|' -v id="$tid" '$1==id{print; exit}') + st=$(printf '%s' "$row" | cut -d'|' -f2) + ln=$(printf '%s' "$row" | cut -d'|' -f4) + dp=$(printf '%s' "$row" | cut -d'|' -f5) + [ "$st" = "open" ] || NOTOPEN="$NOTOPEN$tid($st) " + if [ -z "$ln" ]; then NOLANE="$NOLANE$tid " + else case " $LANES_SEEN " in *" $ln "*) ;; *) LANES_SEEN="${LANES_SEEN:+$LANES_SEEN }$ln" ;; esac + fi + [ -n "$dp" ] && REDISP="$REDISP$tid " + done + IFS="$oldIFS" + [ -n "$MISSING" ] && fail "tasks.md 에 없는 task: ${MISSING% }" + [ -n "$NOTOPEN" ] && fail "미완료가 아닌 task 는 디스패치하지 않아요: ${NOTOPEN% }" + [ -n "$NOLANE" ] && fail "레인이 배정되지 않은 task: ${NOLANE% } — --assign 으로 먼저 배정하세요" + case "$LANES_SEEN" in + *" "*) fail "task 목록이 여러 레인에 걸쳐 있어요 (${LANES_SEEN}) — 디스패치는 레인 하나씩이에요" ;; + esac + LANE="$LANES_SEEN" + [ -n "$REDISP" ] && WARN="이미 디스패치된 task 를 다시 보냈어요: ${REDISP% } — 디스패치 시각을 갱신하고 보고 기록을 지웠어요" else - # 기본 대상 = 디스패치 기록이 없거나 보고를 못 받은 것. 보고까지 받은 task 는 건드리지 않아요 - TARGETS=$(printf '%s\n' "$LEDGER" | awk -F'|' -v l="$LANE" '$2=="open" && $4==l && ($5=="" || $6==""){print $1}' | paste -sd, - || true) - SKIPPED=$(printf '%s\n' "$LEDGER" | awk -F'|' -v l="$LANE" '$2=="open" && $4==l && $5!="" && $6!=""{print $1}' | paste -sd, - || true) - [ -n "$TARGETS" ] || fail "레인 '$LANE' 의 미완료 task 는 전부 디스패치 후 보고까지 받았어요 (${SKIPPED}) — 그래도 다시 보내려면 --force" + OPEN_IN_LANE=$(printf '%s\n' "$LEDGER" | awk -F'|' -v l="$LANE" '$2=="open" && $4==l{print $1}') + [ -n "$OPEN_IN_LANE" ] || fail "레인 '$LANE' 에 미완료 task 가 없어요 — --assign 으로 먼저 배정하세요" + if [ "$FORCE" = true ]; then + TARGETS=$(printf '%s\n' "$OPEN_IN_LANE" | paste -sd, - || true) + else + # 기본 대상 = 디스패치 기록이 없거나 보고를 못 받은 것. 보고까지 받은 task 는 건드리지 않아요 + TARGETS=$(printf '%s\n' "$LEDGER" | awk -F'|' -v l="$LANE" '$2=="open" && $4==l && ($5=="" || $6==""){print $1}' | paste -sd, - || true) + SKIPPED=$(printf '%s\n' "$LEDGER" | awk -F'|' -v l="$LANE" '$2=="open" && $4==l && $5!="" && $6!=""{print $1}' | paste -sd, - || true) + [ -n "$TARGETS" ] || fail "레인 '$LANE' 의 미완료 task 는 전부 디스패치 후 보고까지 받았어요 (${SKIPPED}) — 그래도 다시 보내려면 --force" + fi fi ;; report) diff --git a/templates/default/.ax/scripts/bash/mark-task.sh b/templates/default/.ax/scripts/bash/mark-task.sh index 9a10468..e3aba12 100644 --- a/templates/default/.ax/scripts/bash/mark-task.sh +++ b/templates/default/.ax/scripts/bash/mark-task.sh @@ -1,18 +1,22 @@ #!/usr/bin/env bash -# .ax/scripts/bash/mark-task.sh — tasks.md 체크박스 하나를 켜요 (접두 정확 일치 · 락 · 결과 검증) +# .ax/scripts/bash/mark-task.sh — tasks.md 체크박스를 켜요 (접두 정확 일치 · 락 · 결과 검증) # # Usage: -# bash mark-task.sh [--spec ] --task T013 [--state x|~] [--dry-run] [--json] +# bash mark-task.sh [--spec ] --task T013[,T014,…] [--state x|~] [--dry-run] [--json] # bash mark-task.sh [--spec ] --next [--json] # # 왜: 인라인 sed(`/^- \[ \] .*T013/`)는 줄 어디든 ID 가 있으면 매칭해서, 본문에 다른 task 를 언급한 줄까지 # [x] 로 바꿨어요 (실측 3건이 검증 없이 완료). 락도 없었어요. # 규칙: 줄 **앞** ID 만(`- [ ] T013 ` · `**T013**` · `[T013]`) · 코드 펜스 안은 건너뜀 · tasks.md.lock · -# 쓰기 전후 [x] 개수가 정확히 1 늘었는지(이미 켜져 있으면 0) 확인. +# 쓰기 전후 [x] 개수가 바꾼 줄 수만큼만 늘었는지(이미 켜져 있으면 0) 확인. +# --task T013,T014 — 쉼표로 여러 개. 트랜잭션이에요: 하나라도 없거나 중복이면 아무것도 안 켜요. +# 이미 켜진 것은 건너뛰고 unchanged_tasks 에 적어요. # --next 는 읽기만 해요 — 펜스 밖 첫 미완료 task (템플릿 펜스 안의 예시 줄을 집지 않게). # # Output (--json): -# --task: {"status":"ok","result":{"spec":"…","task":"T013","state":"x","line":57,"changed":true,"done_before":11,"done_after":12}} +# --task: {"status":"ok","result":{"spec":"…","task":"T013","tasks":["T013"],"state":"x","line":57,"lines":[57], +# "changed":true,"changed_tasks":["T013"],"unchanged_tasks":[],"done_before":11,"done_after":12}} +# (여러 개면 task 는 "T013,T014", line 은 첫 task 의 줄) # --next: {"status":"ok","result":{"spec":"…","task":"T014","line":63,"text":"- [ ] T014 …"}} · 없으면 status skipped # Exit: 0 ok · 1 error (task 없음 · ID 중복 · 락 실패 · 검증 불일치) · 2 skipped (--next 에서 미완료 없음) set -euo pipefail @@ -97,31 +101,56 @@ fi [ -n "$TASK" ] || fail "--task 이 필요해요" case "$STATE" in x|"~") ;; *) fail "--state 는 x(완료) 또는 ~(보류) 만 받아요: $STATE" ;; esac -printf '%s' "$TASK" | grep -Eq '^T[0-9]+$' || fail "task ID 형식이 아니에요: $TASK (예: T013)" - -ANY_RE=$(task_line_regex '[ x~]' "$TASK") -OPEN_RE=$(task_line_regex '[ ]' "$TASK") +# 쉼표로 여러 개 — 순서 유지 · 중복 제거. 하나라도 형식이 틀리면 아무것도 안 해요 +IDS=$(printf '%s' "$TASK" | tr -d ' ' | tr ',' '\n' | awk 'NF && !seen[$0]++') +[ -n "$IDS" ] || fail "--task 이 필요해요" +for id in $IDS; do + printf '%s' "$id" | grep -Eq '^T[0-9]+$' || fail "task ID 형식이 아니에요: $id (예: T013 또는 T013,T014)" +done +TASK=$(printf '%s\n' "$IDS" | paste -sd, -) # 락 안에서 읽어요 — 읽기가 "이 실행이 쓰는가" 를 결정하니 창은 여기서 열려요 goax_lock "$LOCK" "${GOAX_LOCK_TIMEOUT:-10}" || fail "tasks.md 락을 못 잡았어요: $LOCK — 다른 프로세스가 쓰는 중이거나 락이 남아 있어요" trap 'goax_unlock "$LOCK"' EXIT -ALL_HITS=$(outside_fences | grep -E $'\t'"${ANY_RE#^}" || true) -N_ALL=$(printf '%s\n' "$ALL_HITS" | grep -c . || true); N_ALL=${N_ALL:-0} -[ "$N_ALL" -ge 1 ] || fail "task $TASK 줄이 없어요 (펜스 밖 · 줄 앞 ID 기준) — tasks.md 형식은 '- [ ] T0NN …' 이에요" -[ "$N_ALL" -eq 1 ] || fail "task $TASK 줄이 ${N_ALL}개예요 — ID 가 중복됐어요. 하나만 남기세요" - -LINE=${ALL_HITS%%$'\t'*} -TEXT=${ALL_HITS#*$'\t'} +# 1단계: 전부 검증만 — 하나라도 없거나 중복이면 파일을 안 건드려요 (반쯤 켜진 체크박스는 더 나빠요) +FENCED=$(outside_fences) +LINES=""; CHANGE_LINES=""; CHANGED_IDS=""; KEPT=""; FIRST_LINE=""; FIRST_TEXT=""; CUR="" +for id in $IDS; do + ANY_RE=$(task_line_regex '[ x~]' "$id") + OPEN_RE=$(task_line_regex '[ ]' "$id") + HITS=$(printf '%s\n' "$FENCED" | grep -E $'\t'"${ANY_RE#^}" || true) + N_ALL=$(printf '%s\n' "$HITS" | grep -c . || true); N_ALL=${N_ALL:-0} + [ "$N_ALL" -ge 1 ] || fail "task $id 줄이 없어요 (펜스 밖 · 줄 앞 ID 기준) — tasks.md 형식은 '- [ ] T0NN …' 이에요" + [ "$N_ALL" -eq 1 ] || fail "task $id 줄이 ${N_ALL}개예요 — ID 가 중복됐어요. 하나만 남기세요" + LN=${HITS%%$'\t'*}; TX=${HITS#*$'\t'} + [ -z "$FIRST_LINE" ] && { FIRST_LINE=$LN; FIRST_TEXT=$TX; } + LINES="${LINES:+$LINES,}$LN" + if printf '%s' "$TX" | grep -Eq "$OPEN_RE"; then + CHANGE_LINES="${CHANGE_LINES:+$CHANGE_LINES,}$LN"; CHANGED_IDS="${CHANGED_IDS:+$CHANGED_IDS,}$id" + else + KEPT="${KEPT:+$KEPT,}$id" + [ -z "$CUR" ] && CUR=$(printf '%s' "$TX" | sed -E 's/^- \[(.)\].*/\1/') + fi +done DONE_BEFORE=$(grep -cE '^- \[x\] ' "$TASKS" || true); DONE_BEFORE=${DONE_BEFORE:-0} +N_CHANGE=$(printf '%s' "$CHANGED_IDS" | awk -F, 'NF{print NF; exit} END{if(!NR) print 0}') + +# result — 단일 ID 때의 모양(task·line·changed)을 그대로 두고 tasks·changed_tasks·lines 를 더해요 +result_json() { # $1 changed(bool) · $2 done_after · $3 dry_run(bool) · $4 state + jq -nc --arg spec "$SPEC" --arg task "$TASK" --arg state "$4" --argjson line "$FIRST_LINE" \ + --arg lines "$LINES" --arg ch "$CHANGED_IDS" --arg kept "$KEPT" \ + --argjson changed "$1" --argjson before "$DONE_BEFORE" --argjson after "$2" --argjson dry "$3" ' + def l: split(",") | map(select(length > 0)); + {spec:$spec, task:$task, tasks:($task|l), state:$state, line:$line, lines:($lines|l|map(tonumber)), + changed:$changed, changed_tasks:($ch|l), unchanged_tasks:($kept|l), + done_before:$before, done_after:$after} + (if $dry then {dry_run:true} else {} end)' +} -if ! printf '%s' "$TEXT" | grep -Eq "$OPEN_RE"; then - # 이미 [x] 또는 [~] — 다시 켤 게 없어요 (idempotent). 상태가 다르면 알려만 줘요 - CUR=$(printf '%s' "$TEXT" | sed -E 's/^- \[(.)\].*/\1/') +if [ "$N_CHANGE" -eq 0 ]; then + # 전부 이미 [x] 또는 [~] — 다시 켤 게 없어요 (idempotent) if [ "$JSON_MODE" = true ]; then - RESULT=$(jq -nc --arg spec "$SPEC" --arg task "$TASK" --arg state "$CUR" --argjson line "$LINE" \ - --argjson before "$DONE_BEFORE" '{spec:$spec, task:$task, state:$state, line:$line, changed:false, done_before:$before, done_after:$before}') - json_output "ok" "$RESULT" "이미 [$CUR] 예요 — 바꾸지 않았어요" + json_output "ok" "$(result_json false "$DONE_BEFORE" false "$CUR")" "이미 [$CUR] 예요 — 바꾸지 않았어요" else goax_log "$TASK 는 이미 [$CUR] 예요 — 바꾸지 않았어요" fi @@ -130,31 +159,32 @@ fi if [ "$DRY_RUN" = true ]; then if [ "$JSON_MODE" = true ]; then - RESULT=$(jq -nc --arg spec "$SPEC" --arg task "$TASK" --arg state "$STATE" --argjson line "$LINE" \ - --argjson before "$DONE_BEFORE" '{spec:$spec, task:$task, state:$state, line:$line, changed:false, dry_run:true, done_before:$before}') - json_output "ok" "$RESULT" "dry-run — ${LINE}행을 [$STATE] 로 바꿀 거예요" + json_output "ok" "$(result_json false "$DONE_BEFORE" true "$STATE")" "dry-run — ${CHANGE_LINES}행을 [$STATE] 로 바꿀 거예요" else - printf '(dry-run) %s행: %s → [%s]\n' "$LINE" "$TEXT" "$STATE" + if [ "$N_CHANGE" -eq 1 ] && [ -z "$KEPT" ]; then + printf '(dry-run) %s행: %s → [%s]\n' "$FIRST_LINE" "$FIRST_TEXT" "$STATE" + else + printf '(dry-run) %s (%s행) → [%s]%s\n' "$CHANGED_IDS" "$CHANGE_LINES" "$STATE" "${KEPT:+ · 이미 켜짐: $KEPT}" + fi fi exit "$EXIT_OK" fi tmp=$(goax_mktemp "$PROJECT_ROOT") || { goax_tmp_error; exit "$EXIT_ERROR"; } -# 그 한 줄만 바꿔요 — 줄 번호로 집어요 (같은 ID 가 둘이면 위에서 이미 막았어요) -awk -v n="$LINE" -v st="$STATE" 'NR==n { sub(/^- \[ \]/, "- [" st "]") } { print }' "$TASKS" > "$tmp" \ +# 그 줄들만 바꿔요 — 줄 번호로 집어요 (같은 ID 가 둘이면 위에서 이미 막았어요) +awk -v ns="$CHANGE_LINES" -v st="$STATE" ' + BEGIN { n = split(ns, a, ","); for (i = 1; i <= n; i++) want[a[i]] = 1 } + (NR in want) { sub(/^- \[ \]/, "- [" st "]") } { print }' "$TASKS" > "$tmp" \ && mv "$tmp" "$TASKS" || { rm -f "$tmp"; fail "tasks.md 갱신 실패: $TASKS"; } -# 사후 검증 — 정확히 한 줄만 바뀌었는지. 한 줄 매칭인데 개수가 2 이상 뛰면 awk 가 잘못 집은 거예요 +# 사후 검증 — 바꾼 줄 수만큼만 늘었는지. 더 뛰면 awk 가 잘못 집은 거예요 DONE_AFTER=$(grep -cE '^- \[x\] ' "$TASKS" || true); DONE_AFTER=${DONE_AFTER:-0} -EXPECT=$DONE_BEFORE; [ "$STATE" = "x" ] && EXPECT=$((DONE_BEFORE + 1)) +EXPECT=$DONE_BEFORE; [ "$STATE" = "x" ] && EXPECT=$((DONE_BEFORE + N_CHANGE)) [ "$DONE_AFTER" -eq "$EXPECT" ] || fail "마킹 뒤 [x] 개수가 어긋나요: 전 $DONE_BEFORE → 후 $DONE_AFTER (기대 $EXPECT). tasks.md 를 눈으로 확인하세요" if [ "$JSON_MODE" = true ]; then - RESULT=$(jq -nc --arg spec "$SPEC" --arg task "$TASK" --arg state "$STATE" --argjson line "$LINE" \ - --argjson before "$DONE_BEFORE" --argjson after "$DONE_AFTER" \ - '{spec:$spec, task:$task, state:$state, line:$line, changed:true, done_before:$before, done_after:$after}') - json_output "ok" "$RESULT" "$TASK → [$STATE] (${DONE_AFTER} 완료)" + json_output "ok" "$(result_json true "$DONE_AFTER" false "$STATE")" "$CHANGED_IDS → [$STATE] (${DONE_AFTER} 완료)${KEPT:+ · 이미 켜져 있던 $KEPT 는 그대로}" else - goax_log "$TASK → [$STATE] (${LINE}행, 완료 ${DONE_BEFORE} → ${DONE_AFTER})" + goax_log "$CHANGED_IDS → [$STATE] (${CHANGE_LINES}행, 완료 ${DONE_BEFORE} → ${DONE_AFTER})${KEPT:+ · 이미 켜져 있던 $KEPT 는 그대로}" fi exit "$EXIT_OK" diff --git a/tests/smoke.sh b/tests/smoke.sh index 74b801c..efd874a 100644 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -2902,6 +2902,40 @@ LGEOF || fail "lanes-dispatch --dispatch — 보고된 task 를 재전송하거나 보고를 지움" lg --dispatch A --force --json | jq -e '.result.changed==3' >/dev/null 2>&1 \ && pass "lanes-dispatch --dispatch --force — 보고된 것까지 재전송" || fail "lanes-dispatch --force — 재전송 안 함" + + # task 목록 디스패치 — 라운드에 실제로 맡긴 task 만 적어요. 레인 이름으로 보내면 안 맡긴 task 까지 + # "보고 안 받은 디스패치" 에 섞였어요 (실측 4건) + cat > "$LT" <<'LGEOF' +- [ ] T001 [AC1] a — files: src/a.ts + 레인: A +- [ ] T002 [AC1] b — files: src/b.ts + 레인: A +- [ ] T003 [AC2] c — files: src/c.ts + 레인: A +- [x] T004 [AC2] d — files: src/d.ts + 레인: A +- [ ] T005 [AC3] e — files: src/e.ts + 레인: B +- [ ] T006 [AC3] f — files: src/f.ts +LGEOF + lg --dispatch T001,T002 --json | jq -e '.status=="ok" and .result.changed==2 and .result.dispatched_unreported==["T001","T002"] + and .warnings==[] and (.next_step|test("레인 .A."))' >/dev/null 2>&1 \ + && [ "$(grep -c '^ 디스패치:' "$LT")" = 2 ] \ + && pass "lanes-dispatch --dispatch T001,T002 — 목록의 task 만 디스패치 (T003 은 그대로)" \ + || fail "lanes-dispatch --dispatch — 목록 밖 task 에도 시각을 찍거나 거부함" + cp "$LT" "$LDG/before.md"; LG_BAD="" + for bad in T001,T005 T004 T006 T999; do + lg --dispatch "$bad" --json >/dev/null 2>&1 && LG_BAD="$LG_BAD $bad(exit0)" + done + [ -z "$LG_BAD" ] && cmp -s "$LT" "$LDG/before.md" \ + && pass "lanes-dispatch --dispatch — 여러 레인 · 완료 · 레인 없음 · 없는 ID 는 exit 1 + 파일 무변경" \ + || fail "lanes-dispatch --dispatch — 잘못된 목록을 받아들임:${LG_BAD:- (파일 변경)}" + lg --report T001 --json >/dev/null 2>&1 + lg --dispatch "T001, T003,T003" --json | jq -e '.result.changed==2 and (.warnings|length)==1 and (.warnings[0]|test("T001")) + and (.warnings[0]|test("T003")|not)' >/dev/null 2>&1 \ + && ! grep -q '^ 보고:' "$LT" \ + && pass "lanes-dispatch --dispatch — 재디스패치는 시각 갱신 · 보고 지움 · warnings (중복 ID 는 한 번)" \ + || fail "lanes-dispatch --dispatch — 재디스패치가 경고 없이 지나가거나 보고를 남김" rm -rf "$LDG" fi @@ -4794,6 +4828,17 @@ mt --task T0091 --state '~' --json >/dev/null && pass "mark-task — 줄 앞 ID 만 켜고 본문 언급·펜스 예시·T0091 은 그대로, 재실행은 변화 없음, --next 는 펜스 밖, 락 해제" \ || fail "mark-task: next=$N1 rc=$R1 $(cat "$MTF" | tr '\n' '|')" mt --task T404 --json >/dev/null; [ $? = 1 ] && pass "mark-task — 없는 ID 는 exit 1 (조용히 성공하지 않아요)" || fail "mark-task 없는 ID 가 성공했어요" +# 여러 ID — 트랜잭션: 하나라도 없으면 아무것도 안 켜고, 이미 켜진 것은 건너뛰어요 +cp "$MTF" "$MT/before" +mt --task T008,T404 --json >/dev/null; MR=$? +[ "$MR" = 1 ] && cmp -s "$MTF" "$MT/before" \ + && pass "mark-task --task T008,T404 — 하나라도 없으면 exit 1 + 파일 무변경" || fail "mark-task 다중 ID 가 부분 적용됨 (rc=$MR)" +mt --task "T009, T008,T010,T008" --json | jq -e '.result.changed==true and .result.changed_tasks==["T008","T010"] + and .result.unchanged_tasks==["T009"] and (.result.done_after - .result.done_before)==2' >/dev/null 2>&1 \ + && grep -qxF -- '- [x] T008 T005 의 상수와 맞춤' "$MTF" && grep -qxF -- '- [x] T010 T009 가 셋 다 해소' "$MTF" \ + && grep -qxF -- '- [~] T0091 다른 것' "$MTF" && [ ! -e "$MTF.lock" ] \ + && pass "mark-task --task T009,T008,T010 — 미완료만 켜고 이미 켜진 건 unchanged, 중복 ID 는 한 번" \ + || fail "mark-task 다중 ID: $(tr '\n' '|' < "$MTF")" rm -rf "$MT" # ───────────────────────────────────────────────────────────