Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -30,5 +30,5 @@
]
}
],
"version": "0.7.4"
"version": "0.7.5"
}
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.7.4
0.7.5
23 changes: 23 additions & 0 deletions changelog/0.7.5.md
Original file line number Diff line number Diff line change
@@ -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 불인정.
1 change: 1 addition & 0 deletions changelog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
4 changes: 3 additions & 1 deletion templates/default/.ax/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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시간 넘은
Expand Down
29 changes: 25 additions & 4 deletions templates/default/.ax/hooks/stop/spec-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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` 에 시각을 적어요).
#
Expand Down Expand Up @@ -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 \
Expand Down Expand Up @@ -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
29 changes: 29 additions & 0 deletions tests/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
Loading