diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 869cbbe..4906989 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.4", + "version": "0.7.5", "author": { "name": "bluecheat", "email": "itsinil@gmail.com" @@ -30,5 +30,5 @@ ] } ], - "version": "0.7.4" + "version": "0.7.5" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 8fec724..797ab3f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "goax", - "version": "0.7.4", + "version": "0.7.5", "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 0a1ffad..8bd6ba8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.4 +0.7.5 diff --git a/changelog/0.7.5.md b/changelog/0.7.5.md new file mode 100644 index 0000000..dbd52f5 --- /dev/null +++ b/changelog/0.7.5.md @@ -0,0 +1,23 @@ +# 0.7.5 — 인계 노트에 spec ID 만 적어도 Stop 게이트가 알아봐요 + +## 무엇이 좋아졌나 — 누구에게 체감되나 + +- **spec ID 로 적은 인계 노트를 인정해요.** Stop 게이트(`stop/spec-gate.sh`)는 인계 노트의 '지금 상태' 에서 spec + 디렉터리의 **전체 이름**(`2026-10-03-1b92-sharing-movement-web-mvp`)만 찾았어요. 다른 스크립트의 `--spec` 은 + `2026-10-03-1b92` 같은 ID 로 찾는데요. 한 실사용 프로젝트에서 `status-note.sh --set now "spec 2026-10-03-1b92 + implementing — …"` 으로 적었는데 게이트가 "인계 노트에 적혀 있지 않다" 며 한 번 더 막았고, 전체 이름으로 다시 + 적어야 통과했어요. 이제 ID(`YYYY-MM-DD-<4hex>`, 옛 순번 `NNN`)든 전체 이름이든 통과해요. +- **안내 문구가 ID 로 적는 법을 보여줘요.** 게이트가 멈춰 세울 때 내놓는 `status-note.sh --set now` 예시가 spec ID 를 + 쓰고, ID 나 전체 이름이 있으면 다시 잡지 않는다고 알려요. + +## 바뀐 것 + +- `stop/spec-gate.sh`: spec ID 는 `common.sh` 의 `goax_doc_key` 로 뽑아요 (형식 판별 SSOT). ID 는 앞뒤가 ASCII 영숫자가 + 아닐 때만 인정해요 — 옛 순번 `014` 가 `T0140`·`2014` 에 걸리지 않고, `1b92에서` 처럼 한글이 바로 붙어도 알아봐요. + 전체 이름 부분 일치는 예전 그대로예요. 24시간(`now_at`) 조건도 그대로예요. +- `hooks/README.md` Stop 게이트 절. + +## 검증 + +- smoke 738 통과 (macOS bash 3.2). Stop 게이트 5건 추가: 옛 순번 ID · 경계(`T0140`·`2014` 불인정) · 안내 문구의 ID 예시 · + 새 ID/한글 붙은 ID/전체 이름 · 다른 spec 의 ID 불인정. diff --git a/changelog/README.md b/changelog/README.md index 6ff3e04..5e6f319 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -4,6 +4,7 @@ ## 버전 +- [0.7.5](0.7.5.md) — 인계 노트에 spec ID 만 적어도 Stop 게이트가 알아봐요 - [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`) diff --git a/templates/default/.ax/hooks/README.md b/templates/default/.ax/hooks/README.md index e0ddabc..9cf2075 100644 --- a/templates/default/.ax/hooks/README.md +++ b/templates/default/.ax/hooks/README.md @@ -69,7 +69,9 @@ compaction 뒤엔 4시간 TTL 이 다시 줄 여지를 남겨요. 세션 id 가 `stop/spec-gate.sh` 는 `current-task.json` 의 phase 가 `implementing`·`review` 이고 `tasks-gate.sh` 가 위반을 보고할 때만 `{"decision":"block","reason":…}` 로 한 턴을 더 줘요. Claude Code 가 재시도할 땐 `stop_hook_active=true` 로 오고 그땐 무조건 통과 — 무한 루프는 공식 계약이 막아요. 인계 노트 -(`current-task.json` 의 `handoff.now`)에 그 spec 이 **24시간 안에** 적혀 있으면 멈추는 게 의도라고 보고 잡지 않아요 — +(`current-task.json` 의 `handoff.now`)에 그 spec 이 **24시간 안에** 적혀 있으면 멈추는 게 의도라고 보고 잡지 않아요. +spec 은 ID(`2026-10-03-1b92`, 옛 순번 `014`)로 적어도 되고 디렉터리 전체 이름으로 적어도 돼요 — 다른 스크립트의 +`--spec` 과 같은 기준이에요. ID 는 앞뒤가 영숫자가 아닐 때만 인정해요 (`014` 가 `T0140` 에 걸리지 않게). `status-note.sh --set now` 가 `handoff.now_at` 필드에 시각을 적고 게이트가 그 시각을 봐요. 시각이 없는 옛 노트는 인정하지 않아요 (예전엔 spec 이름만 있으면 통과라서 몇 주 전 노트 한 줄이 새 세션의 게이트를 영구히 꺼 버렸어요). 지울 땐 `status-note.sh --clear now`. 이 훅은 실행될 때 `.ax/.session/*` 의 24시간 넘은 diff --git a/templates/default/.ax/hooks/stop/spec-gate.sh b/templates/default/.ax/hooks/stop/spec-gate.sh index ca4c800..bba3bf0 100755 --- a/templates/default/.ax/hooks/stop/spec-gate.sh +++ b/templates/default/.ax/hooks/stop/spec-gate.sh @@ -8,7 +8,7 @@ # # 무엇을 하나 — 차단이 아니라 **한 턴 더** 주는 거예요. 세 가지 중 하나를 하고 끝내라고 해요: # (1) 남은 task 를 마저 (2) 의도적 보류면 `- [~] … 보류: <사유>` (3) 여기서 멈추는 거면 인계 노트 -# (3) 이 적혀 있으면(current-task.json `handoff.now` 에 spec 이름 + `now_at` 이 24시간 안) 다시 안 잡아요 — +# (3) 이 적혀 있으면(current-task.json `handoff.now` 에 spec ID 또는 전체 이름 + `now_at` 이 24시간 안) 다시 안 잡아요 — # 멈추는 게 의도인 거니까요. 시각을 보는 이유는 옛 노트 한 줄이 새 세션의 게이트를 # 영구히 꺼 버리면 안 되기 때문이에요 (status-note.sh --set now 가 `now_at` 에 시각을 적어요). # @@ -49,14 +49,35 @@ case "$PHASE" in implementing|review) ;; *) exit 0 ;; esac SPEC_DIR=$(jq -r '.spec_dir // empty' "$TASK_FILE" 2>/dev/null || true) [ -n "$SPEC_DIR" ] || exit 0 SPEC=$(basename "$SPEC_DIR") +# spec ID — 새 형식은 `YYYY-MM-DD-<4hex>`, 옛 형식은 순번 `NNN`. 다른 스크립트의 --spec 이 이 ID 접두로 찾으니 +# 인계 노트도 ID 로 적으면 인정해요. 예전엔 디렉터리 전체 이름만 grep 해서 "spec 2026-10-03-1b92 implementing" +# 이라고 적어도 게이트가 한 번 더 막았어요 (실측). 형식 판별은 common.sh 의 goax_doc_key 하나로 해요. +SPEC_ID="" +type goax_doc_key >/dev/null 2>&1 && SPEC_ID=$(printf '%s\n' "$SPEC" | goax_doc_key 2>/dev/null) +[ -n "$SPEC_ID" ] || SPEC_ID="$SPEC" # 인계 노트에 이미 적혀 있으면 멈추는 게 의도예요 — 다시 안 잡아요. # 단 **24시간 안에 찍힌 노트만** 인정해요. 예전엔 "지금 상태" 에 spec 이름이 있기만 하면 # 통과라서, 몇 주 전 노트 한 줄이 새 세션의 게이트를 영구히 꺼 버렸어요 (다른 session_id 로 # 몇 번을 불러도 빈 출력). `status-note.sh --set now` 가 `handoff.now_at` 에 시각을 적어요 — # 시각이 없는 노트는 인정하지 않아요. 인계 노트는 이미 열어 둔 current-task.json 안에 있어요. +# 매칭은 전체 이름(부분 문자열) 또는 ID(앞뒤가 ASCII 영숫자가 아닐 때만 — 옛 순번 `014` 가 `T0140`·`2014` 에 +# 걸리지 않게). 경계를 [[:alnum:]] 로 안 보는 건 UTF-8 로케일의 awk 가 한글도 alnum 으로 봐서 "1b92에서" 를 놓치기 때문이에요. NOW_SEC=$(jq -r '(.handoff.now // []) | join("\n")' "$TASK_FILE" 2>/dev/null || true) -if [ -n "$NOW_SEC" ] && printf '%s' "$NOW_SEC" | grep -qF "$SPEC"; then +note_names_spec() { + printf '%s' "$NOW_SEC" | grep -qF "$SPEC" && return 0 + printf '%s\n' "$NOW_SEC" | awk -v id="$SPEC_ID" ' + BEGIN { an = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" } + function word(c) { return c != "" && index(an, c) > 0 } + { s = $0 + while ((i = index(s, id)) > 0) { + pre = (i > 1) ? substr(s, i - 1, 1) : ""; post = substr(s, i + length(id), 1) + if (!word(pre) && !word(post)) { found = 1; exit } + s = substr(s, i + 1) + } } + END { exit found ? 0 : 1 }' +} +if [ -n "$NOW_SEC" ] && note_names_spec; then NOTE_TS=$(jq -r '.handoff.now_at // empty' "$TASK_FILE" 2>/dev/null || true) if [ -n "$NOTE_TS" ]; then NOTE_E=$(date -j -u -f '%Y-%m-%dT%H:%MZ' "$NOTE_TS" +%s 2>/dev/null \ @@ -108,9 +129,9 @@ REASON="[goax] spec ${SPEC} 가 ${PHASE} 인데 완료 게이트 미통과 — $ (1) 남은 task 를 마저 진행 (spec-implement) (2) 의도적 보류면 tasks.md 에 \`- [~] T0NN … 보류: <사유>\` 로 표기 (3) 여기서 멈추는 거면 인계 노트에 적고 끝내세요 — 다음 세션이 대화가 아니라 파일에서 읽어요: - bash .ax/scripts/bash/status-note.sh --set now \"spec ${SPEC} ${PHASE} 에서 멈춤 — <어디까지 · 왜>\" + bash .ax/scripts/bash/status-note.sh --set now \"spec ${SPEC_ID} ${PHASE} 에서 멈춤 — <어디까지 · 왜>\" bash .ax/scripts/bash/status-note.sh --add next \"<다음 세션이 처음 할 일>\" -인계 노트의 '지금 상태' 에 ${SPEC} 가 **24시간 안에** 적혀 있으면 이 게이트는 다시 잡지 않아요 (시각은 status-note.sh 가 \`now_at\` 에 적어요). (세션당 최대 ${CAP}회 · $((COUNT + 1))/${CAP} · sensors.mode=off 면 통과)" +인계 노트의 '지금 상태' 에 spec ID(${SPEC_ID}) 나 전체 이름이 **24시간 안에** 적혀 있으면 이 게이트는 다시 잡지 않아요 (시각은 status-note.sh 가 \`now_at\` 에 적어요). (세션당 최대 ${CAP}회 · $((COUNT + 1))/${CAP} · sensors.mode=off 면 통과)" jq -nc --arg r "$REASON" '{decision:"block", reason:$r}' exit 0 diff --git a/tests/smoke.sh b/tests/smoke.sh index efd874a..afb47fc 100644 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -3219,6 +3219,35 @@ else | jq -e '.decision=="block"' >/dev/null 2>&1 \ && pass "stop 게이트 — 24시간 지난 인계 노트는 불인정 (영구 통과증 아님)" || fail "stop 게이트 — 오래된 노트로 영구 통과" + # spec ID 로 적은 인계 노트도 인정 — 다른 스크립트의 --spec 처럼. 전체 이름만 찾던 때는 + # "spec 2026-10-03-1b92 implementing" 이라고 적어도 한 번 더 막았어요 (실측) + sg() { printf '{"session_id":"%s","stop_hook_active":false}' "$1" | CLAUDE_PROJECT_DIR="$ST" bash "$ST/.ax/hooks/stop/spec-gate.sh" 2>/dev/null; } + note() { CLAUDE_PROJECT_DIR="$ST" bash "$ST/.ax/scripts/bash/status-note.sh" --set now "$1" --json >/dev/null 2>&1; } + note "spec 014 에서 멈춤 — T001" + [ -z "$(sg i1)" ] && pass "stop 게이트 — 옛 순번 ID(014)로 적은 노트도 인정" || fail "stop 게이트 — 옛 순번 ID 노트를 무시" + note "T0140 작업 중 · 2014 년 자료" + sg i2 | jq -e '.decision=="block"' >/dev/null 2>&1 \ + && pass "stop 게이트 — ID 는 경계가 있을 때만 (T0140·2014 는 014 가 아니에요)" || fail "stop 게이트 — 014 가 T0140 에 걸림" + mkdir -p "$ST/.ax/docs/spec/2026-10-03-1b92-sharing-web" + cp "$ST/.ax/docs/spec/014-x/"{spec,tasks}.md "$ST/.ax/docs/spec/2026-10-03-1b92-sharing-web/" + echo '{"phase":"implementing","spec_dir":".ax/docs/spec/2026-10-03-1b92-sharing-web","size":"M","risk":"L1"}' > "$ST/.ax/current-task.json" + SG_R=$(sg i3 | jq -r '.reason // empty') + case "$SG_R" in + *'--set now "spec 2026-10-03-1b92 implementing'*'spec ID(2026-10-03-1b92)'*) + pass "stop 게이트 — 안내 문구의 예시가 spec ID 로 (ID 로 적으면 통과한다고 알림)" ;; + *) fail "stop 게이트 — 안내 문구에 ID 예시 없음: ${SG_R:0:200}" ;; + esac + note "spec 2026-10-03-1b92 implementing — T001 까지" + I4=$(sg i4); note "2026-10-03-1b92에서 멈춤"; I5=$(sg i5) + note "spec 2026-10-03-1b92-sharing-web 에서 멈춤"; I6=$(sg i6) + [ -z "$I4" ] && [ -z "$I5" ] && [ -z "$I6" ] \ + && pass "stop 게이트 — 새 ID(YYYY-MM-DD-hex)·한글이 붙은 ID·전체 이름 노트 모두 인정" \ + || fail "stop 게이트 — 새 ID 노트 불인정: [${I4:0:40}][${I5:0:40}][${I6:0:40}]" + note "spec 2026-10-03-1b93 implementing" + sg i7 | jq -e '.decision=="block"' >/dev/null 2>&1 \ + && pass "stop 게이트 — 다른 spec 의 ID 는 불인정" || fail "stop 게이트 — 다른 spec ID 로 통과" + echo '{"phase":"implementing","spec_dir":".ax/docs/spec/014-x","size":"M","risk":"L1"}' > "$ST/.ax/current-task.json" + printf '{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"bash .ax/hooks/stop/spec-gate.sh"}]}],"Stop":[]}}' > "$ST/.claude/settings.json" GOAX_PROJECT_DIR="$ST" bash "$ST/.ax/scripts/bash/doctor-scan.sh" --json --plugin-dir "$REPO" 2>/dev/null \ | jq -e '.result.hooks.missing | index("Stop .ax/hooks/stop/spec-gate.sh")' >/dev/null 2>&1 \