From 8867575ab4b1f08152dd226d5adb0ae59036499f Mon Sep 17 00:00:00 2001 From: bluecheat Date: Fri, 11 Sep 2026 22:58:45 +0900 Subject: [PATCH 1/2] =?UTF-8?q?release:=200.5.11=20=E2=80=94=20=EC=9D=B8?= =?UTF-8?q?=EA=B3=84=20=EB=85=B8=ED=8A=B8=EA=B0=80=20=EC=84=B8=20=EB=B2=88?= =?UTF-8?q?=EC=A7=B8=20=EC=83=81=ED=83=9C=20=ED=8C=8C=EC=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.ax/docs/STATUS.md` 는 어느 리포에도 커밋된 적 없는 세션 상태인데 `.gitignore.template` 에 없었고, 커밋되는 `.ax/docs/` 트리 한가운데 있었어요. 리포 계약(CLAUDE.md)은 "상태 파일은 state.json · current-task.json 둘, 새로 만들지 않아요" 인데 STATUS.md 가 그걸 어겼어요. - 인계 노트를 `.ax/current-task.json` 의 `handoff` 객체로 옮겨요 — now · now_at · next · open · renamed. writer 는 그대로 `status-note.sh` 하나 (CLI 불변, `--show --json` 의 sections/counts/over_cap 불변, `lines` → `items`, `now_at` 추가). 줄 끝 `(YYYY-MM-DDTHH:MMZ)` 스탬프는 `now_at` 필드로 올라와요. - Stop 게이트(`spec-gate.sh`)는 이미 열어 둔 current-task.json 의 `handoff.now` + `now_at` 으로 24시간을 봐요. `harness-pointer.sh` 는 handoff 에 항목이 있으면 경로만 한 줄, `build-memory.sh` 포인터도 같은 조건. - `tier-from-state.sh --reset` 이 `current-task.json.lock` 을 잡아요 — 같은 파일을 쓰는 두 스크립트가 같은 락 문자열을 갖게. reset 은 `handoff` 를 지우지 않아요 (next·open·renamed 는 task 를 넘어 살아요). jq 가 죽으면 원본을 두고 tmp 만 치우고 error 로 답해요. - doctor 가 남은 `.ax/docs/STATUS.md` 를 마이그레이션 잔재(`stale_status_md`)로 알려요 — 자동 삭제·import 없음. - `.ax/MEMORY.md` 는 그대로 — 고유 정보가 없는 재생성 캐시고 이미 gitignore 돼 있어요. 포인터 한 줄만 바뀌어요. - smoke: §34·§35·§41·§43 을 JSON 저장소 기준으로 재작성 + writer in-place 가드 · reset 보존 · 선점 락 7종. 571 통과 / 0 실패 (기준선 559). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XgYfK9moXVF9YCRPvfg8tH --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- CLAUDE.md | 6 +- VERSION | 2 +- changelog/0.5.11.md | 33 +++ changelog/README.md | 1 + docs/skill-routing.md | 5 +- skills/doctor/SKILL.md | 8 +- skills/lane/references/incidents.md | 2 +- skills/onboarding/references/boxes.md | 4 +- skills/spec-implement/SKILL.md | 2 +- skills/triage/SKILL.md | 6 +- skills/up/SKILL.md | 2 +- skills/zero/SKILL.md | 10 +- .../default/.ax/current-task.json.template | 9 +- templates/default/.ax/hooks/README.md | 4 +- templates/default/.ax/hooks/stop/spec-gate.sh | 32 ++- .../hooks/subagent-start/harness-pointer.sh | 9 +- templates/default/.ax/scripts/bash/README.md | 10 +- .../default/.ax/scripts/bash/build-memory.sh | 13 +- templates/default/.ax/scripts/bash/common.sh | 4 +- .../default/.ax/scripts/bash/doctor-scan.sh | 26 ++- .../default/.ax/scripts/bash/reset-task.sh | 1 + .../default/.ax/scripts/bash/status-note.sh | 209 +++++++++--------- .../.ax/scripts/bash/tier-from-state.sh | 23 +- .../default/.ax/scripts/bash/zero-ablation.sh | 10 +- templates/zero/ablation.md | 2 +- templates/zero/product/success-and-stop.md | 2 +- tests/smoke.sh | 125 ++++++++--- 29 files changed, 355 insertions(+), 211 deletions(-) create mode 100644 changelog/0.5.11.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 76b102a..2a87fc1 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.5.10", + "version": "0.5.11", "author": { "name": "bluecheat", "email": "itsinil@gmail.com" @@ -30,5 +30,5 @@ ] } ], - "version": "0.5.10" + "version": "0.5.11" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 285c169..6bfb051 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "goax", - "version": "0.5.10", + "version": "0.5.11", "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/CLAUDE.md b/CLAUDE.md index ed4af1c..6b9a18a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ There is no `npm`/`make`/`gradle`. Anything more complex is invoked through Clau - `templates/default/` — **everything that gets copied into a user project** when the `up` skill runs. - `templates/default/MANIFEST` — installer SSOT. `up` reads this line-by-line; lines ending in `/` are recursive directory copies, `src -> dst` lines do rename copies. **When you add anything under `templates/default/`, update MANIFEST or the installer will not copy it.** Conditional copies (CLAUDE.md, settings.json, config.yml, mistakes/README.md, .gitignore) are intentionally outside MANIFEST and live in `skills/up/SKILL.md` §5–§6. - `templates/default/.ax/scripts/bash/` — deterministic shell tooling (36 scripts). All follow the standard in `templates/default/.ax/scripts/bash/README.md`: `set -euo pipefail`, `--json --dry-run --help` options, `[goax]` stderr prefix, exit codes `0` ok / `1` error / `2` skipped. Their `--json` shape is `{status, result, next_step, warnings, errors}`. SKILL.md files invoke these and parse the JSON; they do not reimplement the logic inline. -- `templates/default/.ax/hooks/{user-prompt,pre-bash,pre-edit,post-edit,pre-commit,subagent-start,stop}/*.sh` — sensors. The companion `.claude/settings.json.template` registers them; `doctor` cross-checks installed hooks against this template (it is the SSOT for which hooks *and which event keys* should be registered). `subagent-start/harness-pointer.sh` hands Constitution/Spirit/active-spec/STATUS **paths** to every non-goax subagent (the harness used to stop at the main session); `stop/spec-gate.sh` blocks the end of a turn once when an `implementing`/`review` spec fails `tasks-gate.sh`, unless STATUS.md already records the halt (bounded by `stop_hook_active` and an 8-per-session counter). The two pre-edit injection hooks dedupe per session via `goax_inject_fresh` (`.ax/.session//`, 4h TTL) — measured 1,200 repeat injections / 529KB in one real session before this. +- `templates/default/.ax/hooks/{user-prompt,pre-bash,pre-edit,post-edit,pre-commit,subagent-start,stop}/*.sh` — sensors. The companion `.claude/settings.json.template` registers them; `doctor` cross-checks installed hooks against this template (it is the SSOT for which hooks *and which event keys* should be registered). `subagent-start/harness-pointer.sh` hands Constitution/Spirit/active-spec/handoff **pointer** to every non-goax subagent (the harness used to stop at the main session); `stop/spec-gate.sh` blocks the end of a turn once when an `implementing`/`review` spec fails `tasks-gate.sh`, unless `current-task.json.handoff.now` records it within 24h (`now_at`) (bounded by `stop_hook_active` and an 8-per-session counter). The two pre-edit injection hooks dedupe per session via `goax_inject_fresh` (`.ax/.session//`, 4h TTL) — measured 1,200 repeat injections / 529KB in one real session before this. - `templates/default/.ax/spirit/{values.md,tone.md,README.md}` — cross-cut Spirit (shared agent personality), the trio the plugin ships. `spirit/rules/` is user-curated (no plugin-shipped instances) — categories get added via onboarding/audit, seeded from `_templates/spirit/ops.md` as an opt-in baseline. Spirit rule headers must match `## SP--: text` — `doctor` lints this. - `templates/default/.ax/_templates/{spec,adr,module,spirit,mistakes}/` — SDD templates the user copies into their own work. `_templates/spec/.origin` is a sha snapshot the installer writes; `check-templates-drift.sh` diffs current vs `.origin` to detect user edits vs plugin updates. - `templates/default/CLAUDE.md.template` — the Layer 1 Constitution that lands in user projects (do not confuse with this file). @@ -50,13 +50,13 @@ Three things must stay true together or the design breaks: - Every SKILL.md begins with `## 시작 전 필수` declaring it loads `.ax/spirit/values.md` and `tone.md`. Triage explicitly fails if those are missing. - Tone throughout the plugin is Korean **`~해요` 체** — this is a deliberate consistency choice (see CONCEPTS §4.1, §7.3) and applies to all user-facing text including SKILL.md bodies, error messages, and ✓ confirmations. - Skills update `.ax/state.json` (HUD signals) and `.ax/current-task.json` (triage→spec→audit context handoff) at well-defined points. Use the existing helpers (`update-state.sh`, jq tmp-mv pattern) — do not invent new state files. -- When a script does `read → jq/awk → tmp.$$ → mv` on a file more than one script or session can touch concurrently (`tasks.md`, `state.json`, `STATUS.md`, `.claude/settings.json`, `AGENTS.md`, `.ax/config.yml`), wrap the read-modify-write in `common.sh`'s `goax_lock "$LOCK" "${GOAX_LOCK_TIMEOUT:-10}"` / `goax_unlock` — without it, concurrent writers silently lose each other's updates. Four rules keep the lock from being decorative. **The window opens at the first read that decides whether this run mutates** — the idempotent `grep -q` probe, not the `mv`; a probe outside the lock means both processes read "not there yet" and both write (measured: five concurrent `zero-init.sh` registered the same hook 3×). **The lock unit is the target file, not the script** — `constitution-apply.sh` takes the same `.lock` in all three of its modes, and two scripts writing the *same* file (`update-state.sh` ↔ `tasks-gate.sh` on `state.json`, `register-spirit-hook.sh` ↔ `zero-init.sh` on `.claude/settings.json`) must build the identical lock path string, or the lock is split in two and does nothing. **The path must be absolute** — scripts that `cd` to the project root hold a relative target, so normalize it (`goax_normalize_path "$TARGET" "$PROJECT_ROOT"`). **`--dry-run` takes no lock and writes nothing** — the lock directory is itself a side effect, and a dry-run that calls an `ensure_file`-style helper before its dry-run branch is still mutating. +- When a script does `read → jq/awk → tmp.$$ → mv` on a file more than one script or session can touch concurrently (`tasks.md`, `state.json`, `current-task.json`, `.claude/settings.json`, `AGENTS.md`, `.ax/config.yml`), wrap the read-modify-write in `common.sh`'s `goax_lock "$LOCK" "${GOAX_LOCK_TIMEOUT:-10}"` / `goax_unlock` — without it, concurrent writers silently lose each other's updates. Four rules keep the lock from being decorative. **The window opens at the first read that decides whether this run mutates** — the idempotent `grep -q` probe, not the `mv`; a probe outside the lock means both processes read "not there yet" and both write (measured: five concurrent `zero-init.sh` registered the same hook 3×). **The lock unit is the target file, not the script** — `constitution-apply.sh` takes the same `.lock` in all three of its modes, and two scripts writing the *same* file (`update-state.sh` ↔ `tasks-gate.sh` on `state.json`, `register-spirit-hook.sh` ↔ `zero-init.sh` on `.claude/settings.json`, `status-note.sh` ↔ `tier-from-state.sh --reset` on `current-task.json`) must build the identical lock path string, or the lock is split in two and does nothing. **The path must be absolute** — scripts that `cd` to the project root hold a relative target, so normalize it (`goax_normalize_path "$TARGET" "$PROJECT_ROOT"`). **`--dry-run` takes no lock and writes nothing** — the lock directory is itself a side effect, and a dry-run that calls an `ensure_file`-style helper before its dry-run branch is still mutating. - Never write a new `[a-z]`/`[A-Z]` bracket range in glob or regex matching — under `en_US.UTF-8` collation, `[a-z]` also matches uppercase letters (sort order is `aAbB…zZ`), which has silently passed values like `Payment` through a `case` guard meant to reject them. Use `[[:lower:]]`/`[[:upper:]]`/`[[:alnum:]]`, or rely on `common.sh`'s top-level `export LC_COLLATE=C` as a safety net for code you can't rewrite yet. - Any parser that walks `tasks.md` line-by-line (task counts, `[P]` conflict checks, ledger fields) must skip fenced code blocks first (`/^[[:space:]]*```/{fence=!fence; next} fence{next}`) — `_templates/spec/tasks.md` itself documents the task-line format inside a fence, and an unguarded parser counts that example as a real task. - The `${CLAUDE_SKILL_DIR}` env var is the official Claude Code variable for finding the plugin root (`${CLAUDE_SKILL_DIR}/../..`). `${CLAUDE_PLUGIN_ROOT}` exists only as a legacy fallback. Do not introduce other variable names. - `spec-validate` runs the plan-time consensus review after the deterministic clarity gate: `spec-review.sh --snapshot` pins the spec sha, then `architect` and `evaluator` (spec mode) run **sequentially in fresh contexts, never seeing each other's file**, each writing `review-spec..md` with a `verdict:` first line; `spec-review.sh --status` aggregates. Required for size L/XL, optional (`--consensus`) for M, never for S — risk is deliberately not a factor (G6 already covers it). Do not let the main session write the reviewer files. - The HUD (`templates/default/.ax/hud/statusline.sh`) shows harness position only: `[goax#ver] | S×R · domain | spec › tasks › impl › review | mistakes:N`. It reads files only (no subprocess scripts) and depends on `current-task.json.phase` — `spec-implement` writes `implementing` at entry and `review` while waiting for the evaluator; without those writes the chain does not move. Heavier values live in `state.json.hud` written by `update-state.sh` (3-way sync with `state.json.template` and `docs/state-ownership.md`). -- **Session handoff lives in `.ax/docs/STATUS.md`, written only through `status-note.sh`** (`--add|--done|--set `, 40-line cap, finished items are deleted — git log and ADRs are the SSOT for what got done). `triage` §1.0 reads it *before* `MEMORY.md`; `spec-implement` writes it on halt, on completion, and when a lane report renames a shared name; `zero` §13 opens it on day one. `MEMORY.md` (`build-memory.sh`) is a rule-token index, not a memory: even in lean mode it keeps the 🔴/🟡 pointers, and only the ADR/spec/module enumerations fold. There is no BM25 index any more (`build-index.sh` was removed in 0.5.3 — no project corpus ever crossed the threshold; bring it back only if `.ax/docs` measures past ~2,000 files). +- **Session handoff lives in `.ax/current-task.json` under `handoff`** (`now[]`·`now_at`·`next[]`·`open[]`·`renamed[]`), written only through `status-note.sh` (`--add|--done|--set `, 40-item cap, finished items are deleted — git log and ADRs are the SSOT for what got done); `now_at` is the freshness stamp the Stop gate reads, and `reset-task.sh` leaves `handoff` alone because `next`/`open`/`renamed` outlive a task. `triage` §1.0 reads it *before* `MEMORY.md`; `spec-implement` writes it on halt, on completion, and when a lane report renames a shared name; `zero` §13 opens it on day one. `MEMORY.md` (`build-memory.sh`) is a rule-token index, not a memory: even in lean mode it keeps the 🔴/🟡 pointers, and only the ADR/spec/module enumerations fold. There is no BM25 index any more (`build-index.sh` was removed in 0.5.3 — no project corpus ever crossed the threshold; bring it back only if `.ax/docs` measures past ~2,000 files). - `doctor` does not count anything inline. Its findings come from scripts: `doctor-scan.sh` (migration residue · hook registration against `settings.json.template` · Constitution-vs-installed mechanism · **reach map** — whether each rule source actually lands in a session: AGENTS.md without a `CLAUDE.md` that imports it reaches nothing), `spirit-lint.sh`, `rules-index.sh`, `check-rule-enforcement.sh`, `check-sensor-liveness.sh`. Add a new check as a script first, then a doctor section that reads its JSON. - `spec-implement` is the only executor. It runs single-lane (sequential) unless `tasks.md` carries `레인:` ledger fields, in which case it is the coordinator: it stamps `디스패치:` via `lanes-dispatch.sh`, spawns `lane-worker` agents, stamps `보고:` when a report arrives, re-runs the task's `검증:` command itself, and only then flips the checkbox. Lanes never flip checkboxes and never write the ledger. Completion is `tasks-gate.sh` G1–G6; G5 is the ledger, G6 is the fresh-context `evaluator` verdict. The coordinator must not write `review.md` itself. When you add an execution edge (who invokes whom), wire it in the receiving SKILL.md, not just in the sender — the `lane`→`spec-implement` and triage-matrix→`evaluator` handoffs were prose-only until they were wired here. diff --git a/VERSION b/VERSION index 50c76ef..69626fb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.10 +0.5.11 diff --git a/changelog/0.5.11.md b/changelog/0.5.11.md new file mode 100644 index 0000000..8a29caa --- /dev/null +++ b/changelog/0.5.11.md @@ -0,0 +1,33 @@ +# 0.5.11 — 인계 노트가 세 번째 상태 파일이던 것 + +> `.ax/docs/STATUS.md` 는 어느 리포에도 커밋된 적 없는 세션 상태인데 `.gitignore.template` 에 없었고, +> 커밋되는 `.ax/docs/` 트리 한가운데 있었어요. 리포 계약은 "상태 파일은 state.json · current-task.json 둘, +> 새로 만들지 않아요" 예요. STATUS.md 가 그걸 어겼어요. + +## 무엇이 바뀌었나 + +- 인계 노트가 `.ax/current-task.json` 의 `handoff` 객체로 들어가요 — now · now_at · next · open · renamed. + 쓰는 길은 그대로 `status-note.sh` 하나 (CLI · `--show --json` 의 sections/counts/over_cap 불변, `lines` → `items`, `now_at` 추가). +- 스탬프가 줄 끝 `(YYYY-MM-DDTHH:MMZ)` 에서 `now_at` 필드로 올라와요. Stop 게이트는 그 필드로 24시간을 봐요. +- `tier-from-state.sh --reset` 이 `current-task.json.lock` 을 잡아요 — 같은 파일을 쓰는 두 스크립트가 같은 락 문자열을 갖게. + reset 은 `handoff` 를 지우지 않아요 (next·open·renamed 는 task 를 넘어 살아야 해요). +- doctor 가 남은 `.ax/docs/STATUS.md` 를 마이그레이션 잔재로 알려요 (자동 삭제·import 없음 — 왜인지는 아래). +- `.ax/MEMORY.md` 는 그대로 둬요 — 고유 정보가 없는 재생성 캐시고 이미 gitignore 돼 있어요. 바뀐 건 인계 노트 포인터 한 줄. + +## 행동 변화 + +- `status-note.sh` 변이 모드는 `current-task.json` 이 없으면 exit 1 로 거절해요 (`/up` 먼저). 최소 파일을 만들면 `->` seed 가 영원히 막혀요. +- 상한 40 은 이제 "항목 40개" 예요 (헤더·빈 줄이 없어져 실효 예산이 조금 늘어요). +- jq 가 없으면 `status-note.sh` 가 exit 2 로 건너뛰어요 — `zero-ablation.sh --on` 의 기한 체크박스 기록(`|| true`)도 조용히 같이 건너뛰어요. 예전 텍스트 모드는 awk 로 돌아서 jq 없이도 썼어요. +- `--show --json` 의 `lines` → `items`, `now_at` 필드가 새로 생겨요. +- 기존 STATUS.md 의 줄은 자동으로 옮기지 않아요. 필요한 줄만 `status-note.sh --add <절> "…"` 로 옮기고 파일을 지워요. doctor 가 남은 파일을 잔재로 알려요. + +## 왜 이 자리인가 + +**A 의 정직한 비용 (PR 본문에 그대로 실어요).** (1) `current-task.json` 을 쓰는 코드가 하나 늘어 **같은 파일 동시 쓰기 노출**이 생겨요 — 스크립트 writer 둘은 같은 락으로 직렬화하지만 SKILL.md 인라인 jq 5곳은 무락이에요 (두 세션이 같은 리포를 동시에 쓸 때만 문제, 인라인 jq 끼리는 오늘도 같은 노출). (2) writer 가 객체를 통째로 재조립하면 `handoff` 가 조용히 사라져요 — 그래서 smoke 에 "in-place jq 만" 구조 가드를 계약으로 넣어요. (3) `current-task.json` 이 **task 를 넘어 사는 하위 객체**를 하나 갖게 돼요 — `reset-task.sh` 가 `handoff` 를 남기는 걸 문서(`CLAUDE.md` · `skill-routing.md` · `reset-task.sh` 헤더)와 smoke 로 고정해요. 이 셋을 감수하는 이유는 파일 수를 늘리지 않는 것이 이번 과제의 목적 그 자체이기 때문이에요. + +**MEMORY.md 를 그대로 두는 이유.** STATUS.md 의 문제는 "고유 정보가 상태 파일이 아닌 곳에, gitignore 도 없이" 있었던 거예요. MEMORY.md 는 셋 다 해당하지 않아요 — 고유 정보가 없고, 재생성이고, gitignore 돼 있어요. `state.json.hud` 가 `update-state.sh` 의 렌더 캐시인 것과 같은 지위예요. 이번 결정에 필요한 변경은 **인계 노트 포인터 한 줄을 `handoff` 기준으로 바꾸는 것**뿐이라 그것만 해요. + +## 검증 + +- smoke 571 통과 / 0 실패 (기준선 559 → +12). 재작성·추가한 검사: 인계 노트 형식 고정 · 동시 쓰기(`--init`·`--add`·`tier-from-state --reset`) 무손실 · dry-run 무락 · 선점 락 exit 1 유지 · writer in-place 가드 · doctor STATUS.md 잔재 통지. diff --git a/changelog/README.md b/changelog/README.md index 9e199fb..e248eab 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -4,6 +4,7 @@ ## 버전 +- [0.5.11](0.5.11.md) — 인계 노트가 세 번째 상태 파일이던 것 (`.ax/docs/STATUS.md` → `current-task.json` 의 `handoff`) - [0.5.10](0.5.10.md) — dry-run 이 spec 번호를 태우던 것 (예약은 쓰기인데 호출부가 --dry-run 을 안 넘김) + 중첩 `.suggested` gitignore 구멍 - [0.5.9](0.5.9.md) — I5 가 pre-commit hook 을 늘 미등록으로 세던 것 (glob 디스패처를 안 봐서 — 문서는 처음부터 맞게 적혀 있었어요) - [0.5.8](0.5.8.md) — 한글을 반으로 가르며 awk 를 죽이던 길이 상한, 그 실패가 ok 로 나가던 것 (`GOAX_AWK_CLIP` 낱말 경계 clip SSOT + build-memory 자기 점검) diff --git a/docs/skill-routing.md b/docs/skill-routing.md index 72f8c17..ca0eff3 100644 --- a/docs/skill-routing.md +++ b/docs/skill-routing.md @@ -68,11 +68,14 @@ idle → triaged → spec → spec_checked → tasks → implementing → review | `review` | spec-implement §8.1 (evaluator 대기) | `review ●` | | `idle` | spec-implement §8.2 (`reset-task.sh`) | `idle` | +`handoff` 하위 객체 (`current-task.json.handoff`) — 누가 쓰나: `status-note.sh` 만. 언제: halt·완료·레인 보고·zero §13·onboarding 9단계. +`reset-task.sh` 의 phase 리셋에 살아남아요 (next·open·renamed 는 task 를 넘어 살아야 해요). + `done`·`blocked` 는 폐기됐어요 — 완료는 `reset-task.sh` 가 곧바로 idle 로 닫고, 막힘은 `blocked_by` 배열이 표현해요. 각 skill이 phase 갱신. `doctor`가 phase 보고 결손 진단. -**세션 간 인계는 phase 가 아니라 `.ax/docs/STATUS.md`** 예요 (`status-note.sh` — 지금 상태 · 다음 · 열린 질문 · 이번에 바뀐 이름). `triage` 가 매 작업 진입 때 MEMORY.md 보다 먼저 읽고, `spec-implement` 가 halt·완료·레인 보고 시점에 갱신하고, `zero` 가 첫날 끝에 개설해요. 대화가 압축되면 사라지는 것만 담고, 끝난 항목은 지워요. +**세션 간 인계는 phase 가 아니라 `current-task.json` 의 `handoff`** 예요 (`status-note.sh` — 지금 상태 · 다음 · 열린 질문 · 이번에 바뀐 이름). `triage` 가 매 작업 진입 때 MEMORY.md 보다 먼저 읽고, `spec-implement` 가 halt·완료·레인 보고 시점에 갱신하고, `zero` 가 첫날 끝에 개설해요. 대화가 압축되면 사라지는 것만 담고, 끝난 항목은 지워요. ## Mistake Loop — capture(mistake) vs review(audit) 분리 diff --git a/skills/doctor/SKILL.md b/skills/doctor/SKILL.md index 7b6d83f..fddfb17 100644 --- a/skills/doctor/SKILL.md +++ b/skills/doctor/SKILL.md @@ -58,7 +58,7 @@ Spirit 카테고리 수를 `spirit-lint.sh` 로, mistake 카테고리 집계를 | **Sensors — Liveness** | 장치 생사 C1~C4 | `check-sensor-liveness.sh` (§3.10) | | **번호 무결성** | spec/ADR 중복 번호 | `next-spec-num.sh --check-duplicates` (§3.11) | | **동봉본** | vendored skills 신선도 | `vendor-skills.sh --check` (§3.12) | -| **인계 노트** | STATUS.md 기한(`- [ ] YYYY-MM-DD`) 임박·초과 — zero 의 가정 검증 · ablation 재검토 | `doctor-scan.sh handoff` (§3.6~) | +| **인계 노트** | current-task.json `handoff` 기한(`- [ ] YYYY-MM-DD`) 임박·초과 — zero 의 가정 검증 · ablation 재검토 | `doctor-scan.sh handoff` (§3.6~) | 각 항목 ✅ / ⚠️ / ❌. 규칙은 §4. @@ -122,6 +122,7 @@ SCAN=$(bash "$ROOT/.ax/scripts/bash/doctor-scan.sh" --json ${PLUGIN_ROOT:+--plug S_FIND=$(echo "$SCAN" | jq -r '.result.findings // 0') S_GI=$(echo "$SCAN" | jq -r '.result.migration.gitignore_missing | join(", ")') S_OS=$(echo "$SCAN" | jq -r '.result.migration.stale_output_style') +S_SM=$(echo "$SCAN" | jq -r '.result.migration.stale_status_md') S_SG=$(echo "$SCAN" | jq -r '.result.migration.suggested | join(", ")') S_SR=$(echo "$SCAN" | jq -r '.result.migration.spec_readme_stale | length') S_SE=$(echo "$SCAN" | jq -r '.result.migration.spec_empty_dirs | length') @@ -139,10 +140,11 @@ D_BAD=$(echo "$SCAN" | jq -r '.result.handoff.deadlines[] | select(.status!="ok" 🧹 마이그레이션 잔재 ⚠️ .gitignore 누락 엔트리 — ⚠️ .ax/spirit/rules/output-style.md — plugin meta 로 분류되어 출고에서 제거됨 + ⚠️ .ax/docs/STATUS.md — 인계 노트가 current-task.json 으로 옮겨져 남은 잔재 ← S_SM == true 일 때만 ⚠️ 미처리 .suggested — (머지 후 rm) ⚠️ spec README.md 잔재 건 · 빈 checklists/contracts 건 (slim 정책 — rm/rmdir 권장) ``` -다음 단계 `[m] ✅ 마이그레이션 잔재 처리 — .gitignore 보강 + 잔재 제거 (사용자 동의 후, git 영향)`. +다음 단계 `[m] ✅ 마이그레이션 잔재 처리 — .gitignore 보강 + 잔재 제거 (STATUS.md 는 남길 줄을 status-note.sh --add 로 옮긴 뒤 rm · 사용자 동의 후, git 영향)`. **3.7 hook 등록** (`H_CHK=false` 면 통째로 skip — plugin 경로 미도출. `H_MISS` 비었으면 본 표의 ✅ 만): ``` @@ -170,7 +172,7 @@ D_BAD=$(echo "$SCAN" | jq -r '.result.handoff.deadlines[] | select(.status!="ok" ``` 다음 단계 `[reach] ✅ 배관 잇기 — constitution: CLAUDE.md 에 '@AGENTS.md' 한 줄 / spirit-universal: Constitution CONVENTION 절에 @import / scoped·module: [s] hook 등록`. **라벨이 완벽해도 배관이 끊기면 룰은 0개예요** — 이 표가 doctor 에서 가장 먼저 봐야 할 줄이에요. -**인계 노트 기한** (`D_BAD` 비었으면 생략) — `.ax/docs/STATUS.md` 의 `- [ ] YYYY-MM-DD …` 를 I3 와 같은 규칙(≤7일 임박 · 초과)으로 봐요. zero 의 "1순위 가정 검증" 과 "룰 ablation 재검토" 가 여기 살아요 — 날짜가 문서 안에만 있으면 아무도 안 봐요. +**인계 노트 기한** (`D_BAD` 비었으면 생략) — `.ax/current-task.json` 의 `handoff` 에 있는 `- [ ] YYYY-MM-DD …` 를 I3 와 같은 규칙(≤7일 임박 · 초과)으로 봐요. zero 의 "1순위 가정 검증" 과 "룰 ablation 재검토" 가 여기 살아요 — 날짜가 문서 안에만 있으면 아무도 안 봐요. ``` 📅 인계 노트 기한 ⚠️ overdue 2026-03-01 (-12일) — 룰 ablation 재검토 (.ax/_templates/zero/ablation.md) diff --git a/skills/lane/references/incidents.md b/skills/lane/references/incidents.md index 3a36a7c..93b086a 100644 --- a/skills/lane/references/incidents.md +++ b/skills/lane/references/incidents.md @@ -50,7 +50,7 @@ 실측: 한 레인이 `Pill` 의 축을 `tone` → `variant` 로 바꿨어요. 다른 레인이 그걸 모른 채 치환했으면 조용히 깨졌을 거예요 (레인 간 인수인계로 막았어요). 두 레인 다 -자기 파일만 만졌는데도요. 그래서 바뀐 공유 이름은 `.ax/docs/STATUS.md` 의 "이번에 바뀐 이름" +자기 파일만 만졌는데도요. 그래서 바뀐 공유 이름은 인계 노트 `renamed` 에 코디네이터가 적고(`status-note.sh --add renamed`), 레인이 전부 끝난 뒤 전체 검증을 한 번 더 돌려요. ## 서브에이전트 보고가 잘리는 문제 diff --git a/skills/onboarding/references/boxes.md b/skills/onboarding/references/boxes.md index da952bc..6dfe73f 100644 --- a/skills/onboarding/references/boxes.md +++ b/skills/onboarding/references/boxes.md @@ -408,8 +408,8 @@ universal 룰(secrets·파괴 명령·보호 경로)은 이미 hook 이 있어 📂 .ax/ runtime 파일은 .gitignore 자동 처리됨 .ax/state.json, .ax/current-task.json — per-machine 상태, PR에 들어가지 않음 -📝 인계 노트 — .ax/docs/STATUS.md - 다음 세션이 처음 읽는 파일이에요. "다음" 절에 첫 할 일 1~3개가 들어갔어요. +📝 인계 노트 — .ax/current-task.json (handoff) · status-note.sh --show + 다음 세션이 처음 읽는 자리예요. "다음" 절에 첫 할 일 1~3개가 들어갔어요. 📋 Mistake Loop — 주 1회 audit 권장 실수는 "실수 기록해줘" 로 캡처해요 (.ax/mistakes/). HUD 의 mistakes:N 이 누적을 보여줘요. diff --git a/skills/spec-implement/SKILL.md b/skills/spec-implement/SKILL.md index 471d921..ff6a1a8 100644 --- a/skills/spec-implement/SKILL.md +++ b/skills/spec-implement/SKILL.md @@ -400,7 +400,7 @@ jq '.last_skill = "spec-implement" | .skill_calls = ((.skill_calls // 0) + 1) | ## 관련 룰 - `references/lane-mode.md` — 레인 모드 코디네이터 루프 (`LANE_N > 0` 일 때만) -- `.ax/docs/STATUS.md` — 인계 노트 (`status-note.sh`). halt·완료·레인 보고 시점에 갱신해요 +- `.ax/current-task.json` `handoff` — 인계 노트 (`status-note.sh`). halt·완료·레인 보고 시점에 갱신해요 - `.ax/docs/reference/confirmation-policy.md` — friction 결정 규칙 SSOT (C1~C5) - `.ax/docs/reference/triage-matrix.md` — size×risk 별 friction 모드 매핑 diff --git a/skills/triage/SKILL.md b/skills/triage/SKILL.md index 3ee5a0e..4c6082b 100644 --- a/skills/triage/SKILL.md +++ b/skills/triage/SKILL.md @@ -35,11 +35,11 @@ description: "사용자가 새 작업·기능·수정·리팩토링·버그 fix LLM 분류 전에 **bash로 후보 자료를 좁혀요**. 큰 프로젝트(>1000 파일)일수록 이게 정확도·속도를 결정해요. -### 1.0 STATUS.md → MEMORY.md — 인계 노트 먼저, 그다음 룰 토큰 +### 1.0 인계 노트 → MEMORY.md — 인계 노트 먼저, 그다음 룰 토큰 검색 전에 두 파일을 순서대로 읽어요. 둘 다 작아요. -**① `.ax/docs/STATUS.md` — 세션 간 인계 노트.** 결정은 ADR, 진행은 tasks.md, 단계는 current-task.json 에 +**① `.ax/current-task.json` 의 `handoff` — 세션 간 인계 노트.** 결정은 ADR, 진행은 tasks.md, 단계는 current-task.json 의 `phase` 에 있지만 "막힌 것 · 열린 질문 · 이번에 바뀐 공유 이름 · 다음 세션이 처음 할 일" 은 여기에만 있어요. 대화가 압축되면 사라지는 것들이라 파일로 받아요. @@ -50,7 +50,7 @@ bash .ax/scripts/bash/status-note.sh --show --json # result.sections.{now,next - `next` 에 항목이 있고 사용자 요청이 그것과 같으면 → 새 분류 없이 그 작업으로 이어가요 (3.5단계에서 `phase` 유지) - `open` 에 사용자 결정 대기가 있고 지금 요청이 그 결정에 걸리면 → 0단계 의도 확인에서 **그 질문부터** 물어요 - `renamed` 는 키워드 추출(1.1)에 넣어요 — 옛 이름으로 검색하면 못 찾아요 -- 파일이 없으면 그냥 넘어가요 (첫 세션이거나 `zero`·`spec-implement` 가 아직 안 적은 거예요) +- `exists:false` 면 그냥 넘어가요 (첫 세션이거나 `zero`·`spec-implement` 가 아직 안 적은 거예요) **② `.ax/MEMORY.md` — 룰 토큰 인덱스.** 재생성하고 읽어요: diff --git a/skills/up/SKILL.md b/skills/up/SKILL.md index a43c164..97a05dc 100644 --- a/skills/up/SKILL.md +++ b/skills/up/SKILL.md @@ -197,7 +197,7 @@ external spec 중 하나라도) `.ax/.onboarding-pending` 마커를 작성하고 **→ `zero` skill 을 이어서 발동해요.** 제품 정의 역면접 → PRD v0.1 → 되돌리기 비싼 결정 ADR → `domain_risk` 최초 설정 → -스캐폴드 → 집행 배관 + 네거티브 프로브 → 첫 배포 → STATUS 개설. +스캐폴드 → 집행 배관 + 네거티브 프로브 → 첫 배포 → 인계 노트 개설. 순서가 메시지예요 — **제품·비즈니스가 앞이고 기술이 뒤예요.** 사용자가 지금 당장 시작하지 않겠다고 하면 그대로 멈춰요. 나중에 `"새 프로젝트 시작"`·`/zero` 로 diff --git a/skills/zero/SKILL.md b/skills/zero/SKILL.md index c6d96f1..7b7d4f5 100644 --- a/skills/zero/SKILL.md +++ b/skills/zero/SKILL.md @@ -1,6 +1,6 @@ --- name: zero -description: "zero to one — 아이디어 하나를 팔 수 있는 제품으로 끌고 가요. '/zero', 'zero to one', '0에서 시작', '새 제품 시작', '처음부터 만들자', '아이디어부터', 'PRD 부터', 'greenfield', '빈 리포에서 시작'. up 의 greenfield 분기가 넘겨받아요. 앞단(문제·대상·가치가설·안 만들 것·단위경제·성공중단기준·첫 사용자) → 중단(PRD·되돌리기 비싼 결정 ADR·시안 게이트) → 뒷단(domain_risk·스캐폴드·집행 배관·첫 배포·STATUS). 대신 정하지 않고 역면접으로 끌어내요." +description: "zero to one — 아이디어 하나를 팔 수 있는 제품으로 끌고 가요. '/zero', 'zero to one', '0에서 시작', '새 제품 시작', '처음부터 만들자', '아이디어부터', 'PRD 부터', 'greenfield', '빈 리포에서 시작'. up 의 greenfield 분기가 넘겨받아요. 앞단(문제·대상·가치가설·안 만들 것·단위경제·성공중단기준·첫 사용자) → 중단(PRD·되돌리기 비싼 결정 ADR·시안 게이트) → 뒷단(domain_risk·스캐폴드·집행 배관·첫 배포·인계 노트). 대신 정하지 않고 역면접으로 끌어내요." --- # goax zero — 아이디어에서 팔 수 있는 것까지 @@ -150,7 +150,7 @@ bash .ax/scripts/bash/zero-init.sh --plugin-dir "$PLUGIN_ROOT" --json - **중단 기준** — 시간·돈·의지 셋 다. 셋 중 하나가 먼저 바닥나요 - **처음 10명** — 이름을 댈 수 있어야 해요. 못 대면 그게 1순위 위험 가정이에요 - **다음 100명** — 채널·비용·준비물 -- 다음 검토일 (`.ax/docs/STATUS.md` 에 체크박스로 박아요) +- 다음 검토일 (인계 노트 `next` 에 체크박스로 박아요, `status-note.sh --add next`) **산출물**: `.ax/docs/product/{success-and-stop,first-users}.md` **안 하면**: 접어야 할 때 접는 대신 기능을 더 붙여요. 그리고 다 만든 뒤에 @@ -288,9 +288,9 @@ bash .ax/scripts/bash/zero-probe.sh --json # 차단이 살아 있는지 확 **산출물**: 배포 주소 또는 빌드 번호 + 그 로그 줄 **안 하면**: 심사·서명 같은 긴 리드타임을 출시 직전에 만나요. -## 13. STATUS 개설 + 다음 라운드 인계 +## 13. 인계 노트 개설 + 다음 라운드 인계 -`.ax/docs/STATUS.md` 는 **공용 인계 노트**예요 — zero 뿐 아니라 `triage` 가 매 작업 진입 때 먼저 읽고, +`.ax/current-task.json` 의 `handoff` 는 **공용 인계 노트**예요 — zero 뿐 아니라 `triage` 가 매 작업 진입 때 먼저 읽고, `spec-implement` 가 halt·완료·레인 보고 시점에 갱신해요. 손으로 쓰지 말고 스크립트로 적어요 (형식이 고정돼야 다음 세션이 파싱해요). **끝난 항목은 지웁니다.** 완료 사실의 SSOT 는 `git log` 와 ADR 이에요. @@ -302,7 +302,7 @@ bash .ax/scripts/bash/status-note.sh --add next "- [ ] <6개월 뒤 날짜> 룰 bash .ax/scripts/bash/status-note.sh --add open "<미룬 축 — 있을 때만>" --json ``` -`## 다음` 의 두 체크박스(1순위 가정 검증 · ablation 재검토)는 빠지면 안 돼요 — 문서 안에만 있는 +`next` 의 두 체크박스(1순위 가정 검증 · ablation 재검토)는 빠지면 안 돼요 — 문서 안에만 있는 날짜는 아무도 안 봐요. 인계 출력: diff --git a/templates/default/.ax/current-task.json.template b/templates/default/.ax/current-task.json.template index 0d3728d..96a27d8 100644 --- a/templates/default/.ax/current-task.json.template +++ b/templates/default/.ax/current-task.json.template @@ -11,5 +11,12 @@ "updated_at": null, "phase": "idle", "intent_notes": {}, - "blocked_by": [] + "blocked_by": [], + "handoff": { + "now": [], + "now_at": null, + "next": [], + "open": [], + "renamed": [] + } } diff --git a/templates/default/.ax/hooks/README.md b/templates/default/.ax/hooks/README.md index 2c2d690..5cd6d97 100644 --- a/templates/default/.ax/hooks/README.md +++ b/templates/default/.ax/hooks/README.md @@ -25,8 +25,8 @@ 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` 로 오고 그땐 무조건 통과 — 무한 루프는 공식 계약이 막아요. 인계 노트 -(`.ax/docs/STATUS.md` "지금 상태")에 그 spec 이 **24시간 안에** 적혀 있으면 멈추는 게 의도라고 보고 잡지 않아요 — -`status-note.sh --set now` 이 줄 끝에 `(YYYY-MM-DDTHH:MMZ)` 를 박고 게이트가 그 시각을 봐요. 시각이 없는 옛 +(`current-task.json` 의 `handoff.now`)에 그 spec 이 **24시간 안에** 적혀 있으면 멈추는 게 의도라고 보고 잡지 않아요 — +`status-note.sh --set now` 가 `handoff.now_at` 필드에 시각을 적고 게이트가 그 시각을 봐요. 시각이 없는 옛 노트는 인정하지 않아요 (예전엔 spec 이름만 있으면 통과라서 몇 주 전 노트 한 줄이 새 세션의 게이트를 영구히 침묵시켰어요). 지울 땐 `status-note.sh --clear now`. 이 훅은 지나가며 `.ax/.session/*` 의 24시간 넘은 디렉토리도 지워요 — 그 청소가 pre-edit 훅에만 있어서 Edit 없는 세션은 아무것도 못 지웠거든요. diff --git a/templates/default/.ax/hooks/stop/spec-gate.sh b/templates/default/.ax/hooks/stop/spec-gate.sh index e287b26..49dfebb 100755 --- a/templates/default/.ax/hooks/stop/spec-gate.sh +++ b/templates/default/.ax/hooks/stop/spec-gate.sh @@ -8,9 +8,9 @@ # # 무엇을 하나 — 차단이 아니라 **한 턴 더** 주는 거예요. 세 가지 중 하나를 하고 끝내라고 해요: # (1) 남은 task 를 마저 (2) 의도적 보류면 `- [~] … 보류: <사유>` (3) 여기서 멈추는 거면 인계 노트 -# (3) 이 적혀 있으면(STATUS.md "지금 상태" 에 spec 이름 + 24시간 안의 시각) 다시 안 잡아요 — +# (3) 이 적혀 있으면(current-task.json `handoff.now` 에 spec 이름 + `now_at` 이 24시간 안) 다시 안 잡아요 — # 멈추는 게 의도인 거니까요. 시각을 보는 이유는 옛 노트 한 줄이 새 세션의 게이트를 -# 영구히 침묵시키면 안 되기 때문이에요 (status-note.sh --set now 가 시각을 같이 박아요). +# 영구히 침묵시키면 안 되기 때문이에요 (status-note.sh --set now 가 `now_at` 에 시각을 적어요). # # 안전장치 셋: # - `stop_hook_active=true` (이미 한 번 붙잡은 뒤의 재시도) → 즉시 통과. 무한 루프는 공식 계약이 막아요 @@ -51,21 +51,17 @@ SPEC=$(basename "$SPEC_DIR") # 인계 노트에 이미 적혀 있으면 멈추는 게 의도예요 — 다시 안 잡아요. # 단 **24시간 안에 찍힌 노트만** 인정해요. 예전엔 "지금 상태" 에 spec 이름이 있기만 하면 # 통과라서, 몇 주 전 노트 한 줄이 새 세션의 게이트를 영구히 침묵시켰어요 (다른 session_id 로 -# 몇 번을 불러도 빈 출력). `status-note.sh --set now` 가 줄 끝에 (YYYY-MM-DDTHH:MMZ) 를 박아요 — -# 시각이 아예 없는 옛 노트는 인정하지 않아요. -NOTE="$PROJECT_ROOT/.ax/docs/STATUS.md" -if [ -f "$NOTE" ]; then - NOW_SEC=$(awk '/^## /{inb=($0 ~ /^## 지금 상태/)} inb' "$NOTE") - if printf '%s' "$NOW_SEC" | grep -qF "$SPEC"; then - NOTE_TS=$(printf '%s' "$NOW_SEC" \ - | grep -oE '\([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}Z\)' | tail -1 | tr -d '()') - if [ -n "$NOTE_TS" ]; then - NOTE_E=$(date -j -u -f '%Y-%m-%dT%H:%MZ' "$NOTE_TS" +%s 2>/dev/null \ - || date -u -d "$NOTE_TS" +%s 2>/dev/null || echo "") - if [ -n "$NOTE_E" ]; then - AGE=$(( $(date -u +%s) - NOTE_E )); [ "$AGE" -lt 0 ] && AGE=0 - [ "$AGE" -lt 86400 ] && exit 0 - fi +# 몇 번을 불러도 빈 출력). `status-note.sh --set now` 가 `handoff.now_at` 에 시각을 적어요 — +# 시각이 없는 노트는 인정하지 않아요. 인계 노트는 이미 열어 둔 current-task.json 안에 있어요. +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_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 \ + || date -u -d "$NOTE_TS" +%s 2>/dev/null || echo "") + if [ -n "$NOTE_E" ]; then + AGE=$(( $(date -u +%s) - NOTE_E )); [ "$AGE" -lt 0 ] && AGE=0 + [ "$AGE" -lt 86400 ] && exit 0 fi fi fi @@ -112,7 +108,7 @@ REASON="[goax] spec ${SPEC} 가 ${PHASE} 인데 완료 게이트 미통과 — $ (3) 여기서 멈추는 거면 인계 노트에 적고 끝내세요 — 다음 세션이 대화가 아니라 파일에서 읽어요: bash .ax/scripts/bash/status-note.sh --set now \"spec ${SPEC} ${PHASE} 에서 멈춤 — <어디까지 · 왜>\" bash .ax/scripts/bash/status-note.sh --add next \"<다음 세션이 처음 할 일>\" -인계 노트의 '지금 상태' 에 ${SPEC} 가 **24시간 안에** 적혀 있으면 이 게이트는 다시 잡지 않아요 (시각은 status-note.sh 가 박아요). (세션당 최대 ${CAP}회 · $((COUNT + 1))/${CAP} · sensors.mode=off 면 침묵)" +인계 노트의 '지금 상태' 에 ${SPEC} 가 **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/templates/default/.ax/hooks/subagent-start/harness-pointer.sh b/templates/default/.ax/hooks/subagent-start/harness-pointer.sh index a0dacbd..71da43b 100755 --- a/templates/default/.ax/hooks/subagent-start/harness-pointer.sh +++ b/templates/default/.ax/hooks/subagent-start/harness-pointer.sh @@ -39,7 +39,7 @@ done # Spirit [ -f .ax/spirit/values.md ] && LINES="${LINES} Spirit · 가치·말투 → .ax/spirit/values.md · .ax/spirit/tone.md"$'\n' -# Layer 3 — 진행 중인 spec +# Layer 3 — 진행 중인 spec · 인계 노트 (둘 다 current-task.json 하나에서) if [ -f .ax/current-task.json ]; then PHASE=$(jq -r '.phase // "idle"' .ax/current-task.json 2>/dev/null || echo idle) SPEC_DIR=$(jq -r '.spec_dir // empty' .ax/current-task.json 2>/dev/null || true) @@ -48,11 +48,12 @@ if [ -f .ax/current-task.json ]; then [ -f "$SPEC_DIR/tasks.md" ] && LINES="${LINES} · ${SPEC_DIR}/tasks.md" LINES="${LINES}"$'\n' fi + # 인계 노트 — handoff 네 절에 항목이 하나라도 있으면 경로만 (본문·명령은 싣지 않아요) + HANDOFF_N=$(jq -r '(.handoff // {}) | [.now[]?, .next[]?, .open[]?, .renamed[]?] | length' .ax/current-task.json 2>/dev/null || echo 0) + [ "${HANDOFF_N:-0}" -gt 0 ] 2>/dev/null \ + && LINES="${LINES} 인계 노트 → .ax/current-task.json (handoff: 막힌 것 · 열린 질문 · 바뀐 이름)"$'\n' fi -# 인계 노트 -[ -f .ax/docs/STATUS.md ] && LINES="${LINES} 인계 노트 → .ax/docs/STATUS.md (막힌 것 · 열린 질문 · 바뀐 이름)"$'\n' - [ -z "$LINES" ] && exit 0 CTX="[goax] 이 프로젝트에는 하네스가 있어요. 편집이나 판단 전에 아래를 Read 하세요 (경로만 드려요): diff --git a/templates/default/.ax/scripts/bash/README.md b/templates/default/.ax/scripts/bash/README.md index 730bb73..e2aa726 100644 --- a/templates/default/.ax/scripts/bash/README.md +++ b/templates/default/.ax/scripts/bash/README.md @@ -27,10 +27,10 @@ | `update-state.sh` | `.ax/` 실측 → `.ax/state.json` (layers/cross_cut/sensors_mode + HUD 캐시 `hud.{plugin_version,review_required,cached_at}`) 갱신 | `up`, `onboarding`, `audit`, `doctor`, `hud`, `mistake`, `spec-validate`, `spec-implement` | | `triage-search.sh` | KEYWORDS 로 6 군데(specs/adrs/mistakes/rules/modules/imported) 검색 + 동의어 확장 + 매칭수 랭킹 + 스니펫 + 도메인 boost | `triage` | | `build-memory.sh` | `.ax/` 상태 → `.ax/MEMORY.md` 한 줄 포인터 인덱스 재생성 (triage 가 먼저 read) | `triage` | -| `status-note.sh` | 세션 간 인계 노트 `.ax/docs/STATUS.md` — `--show/--init/--add/--done/--set `. 형식 고정 · 40줄 상한 · 끝난 항목은 지움 | `triage`(읽기), `spec-implement`, `zero`, `onboarding` | +| `status-note.sh` | 세션 간 인계 노트 — `.ax/current-task.json` 의 `handoff` — `--show/--init/--add/--done/--set `. 형식 고정 · 40개 항목 상한 · 끝난 항목은 지움 · jq 필수(없으면 exit 2) | `triage`(읽기), `spec-implement`, `zero`, `onboarding` | | `spirit-lint.sh` | Spirit 무결성 — 필수 파일 · frontmatter · `## SP-CAT-NNN:` 헤더 형식 · 토큰 중복(spirit ↔ modules) · placeholder. 자동 수정 없음 | `doctor` ("spirit 점검") | | `rules-index.sh` | 룰 통합 인덱스 — Constitution(🔴/🟡/🔵 시그널 라인) + Spirit + Module 의 `SP-*` 를 한 목록으로. `--level/--source/--category/--find` | `doctor` ("rules 보여줘"), `/goax` | -| `doctor-scan.sh` | doctor 의 인라인 진단 셋 — 마이그레이션 잔재 · template 기준 hook 등록(파일 + **이벤트 키**) · 문서↔실제 메커니즘 · **도달 지도**(룰 소스별 배관 생사) · **인계 노트 기한**(STATUS.md `- [ ] YYYY-MM-DD`, I3 규칙) | `doctor` | +| `doctor-scan.sh` | doctor 의 인라인 진단 셋 — 마이그레이션 잔재(`.ax/docs/STATUS.md` 잔재 통지 포함) · template 기준 hook 등록(파일 + **이벤트 키**) · 문서↔실제 메커니즘 · **도달 지도**(룰 소스별 배관 생사) · **인계 노트 기한**(`current-task.json` `handoff` `- [ ] YYYY-MM-DD`, I3 규칙) | `doctor` | | `constitution-apply.sh` | onboarding Q5 의 Constitution 블록 적용 — `--block` prepend(기존 본문 `---` 아래 보존) · `--scan-duplicates` · `--drop-exact`(사용자 [a] 뒤에만) · `--append-index` | `onboarding` | | `tasks-plan.sh` | tasks.md → ready / blocked / parallel + `[P]` 파일 겹침 violations. 항목별 승격 — wave(배리어) 없음, 자동 실행 없음 | `lane`, `spec-implement` | | `tasks-gate.sh` | spec 완료 게이트 G1~G6 — 미완료 · AC 커버리지 · orphan · 유실 · 레인 원장 · evaluator verdict(`review.md`) | `spec-implement`, `lane`, pre-commit hook | @@ -41,7 +41,7 @@ | `zero-domain-risk.sh` | `config.yml` 의 `domain_risk` 블록 통째 교체 (`--show/--set/--default`) — 출고 예시 키가 남으면 triage 가 영원히 default_risk 로 흘러요 | `zero` | | `zero-probe.sh` | 네거티브 프로브 — 일부러 위반을 만들어 차단이 실제로 도는지 확인 | `zero` | | `zero-verify.sh` | `config.yml commands` 를 파이프 없이 실행하고 증거 블록 생성 — 안 돌린 게이트도 보고 (하나도 안 돌면 exit 2) | `zero` | -| `zero-ablation.sh` | 산문 룰 전체를 끄고 무엇이 깨지는지 재는 ablation (`--off/--on/--status`) — `--on` 이 회차를 기록하고 다음 기한(+180일)을 STATUS.md 에 체크박스로 (doctor 가 추적) | `zero`, `doctor` | +| `zero-ablation.sh` | 산문 룰 전체를 끄고 무엇이 깨지는지 재는 ablation (`--off/--on/--status`) — `--on` 이 회차를 기록하고 다음 기한(+180일)을 인계 노트 `next` 에 체크박스로 (doctor 가 추적) | `zero`, `doctor` | | `zero-guard-bash.sh` | **(`.ax/hooks/pre-bash/` 에 설치 — 이 디렉터리 밖)** pre-bash 가드: `git add -A` 차단(exit 2) · 검증 명령 파이프 경고. hook 규약이라 `--json` 표준 밖이에요 | (hook) | | `vendor-skills.sh` | goax skill/command/agent 를 저장소에 동봉(`--plugin-dir` cp) — 모노레포처럼 ADE 루트 ≠ 프로젝트 루트일 때 `.goax-root` 포인터도 씀. `--check` 로 동봉본 ↔ plugin 버전 비교만 | `vendor`, `doctor` | @@ -93,7 +93,7 @@ LLM(SKILL.md)이 이 JSON을 받아 사용자에게 ✓ 메시지 출력. 결정 ## 쓰기 규약 — 여러 세션이 같은 파일을 건드릴 때 -`tasks.md`·`state.json`·`STATUS.md`·`.claude/settings.json`·`AGENTS.md`·`.ax/config.yml` 처럼 여러 +`tasks.md`·`state.json`·`current-task.json`·`.claude/settings.json`·`AGENTS.md`·`.ax/config.yml` 처럼 여러 스크립트·여러 세션이 같은 파일을 `read → 가공 → tmp.$$ → mv` 하는 자리는 **락 없이는 동시 쓰기에서 갱신이 유실돼요** (읽은 뒤 서로를 못 보고 덮어써요). `common.sh` 의 헬퍼로 감싸요: @@ -153,7 +153,7 @@ fence { next } ## 설계 원칙 (왜 이렇게 만들었나) 1. **LLM 선의 의존 제거**까지 마크다운에 박힌 bash를 LLM이 매번 재해석. 동일 작업이 매번 다르게 실행될 위험. 스크립트로 분리 = 결정론 -2. **데이터 흐름 SSOT** — `.ax/current-task.json`이 triage→spec→audit 사이 컨텍스트 전달 +2. **데이터 흐름 SSOT** — `.ax/current-task.json`이 triage→spec→audit 사이 컨텍스트 전달 + 세션 간 인계(handoff) 3. **빠른 실행** — bash 한 번 호출이 LLM 재추론보다 100× 빠름 4. **테스트 가능** — `tests/smoke.sh`가 `bash -n` + `--help` + `--json` syntax 검증 5. **단일 OS** — bash만, PowerShell pair 없음 (Linux/macOS only) diff --git a/templates/default/.ax/scripts/bash/build-memory.sh b/templates/default/.ax/scripts/bash/build-memory.sh index 22fe31a..3cf7ae9 100755 --- a/templates/default/.ax/scripts/bash/build-memory.sh +++ b/templates/default/.ax/scripts/bash/build-memory.sh @@ -25,8 +25,8 @@ # 크기 게이트: 인덱스가 가리킬 본문 합계 < GOAX_MEMORY_LEAN_BYTES(기본 12000)면 자동 lean. # 작은 프로젝트에선 인덱스가 오버헤드라 열거를 접고 포인터만 남겨요. lean 이어도 🔴/🟡 룰 토큰과 # 현재 작업은 항상 남아요 — 이게 빠지면 triage 의 CRITICAL/MANDATORY 도달이 끊겨요. 접히는 건 열거뿐. -# 세션 간 인계(막힌 것·열린 질문·바뀐 이름·다음 할 일)는 이 인덱스가 아니라 .ax/docs/STATUS.md 예요 -# (status-note.sh). 있으면 맨 위에 포인터 한 줄을 넣어요. +# 세션 간 인계(막힌 것·열린 질문·바뀐 이름·다음 할 일)는 이 인덱스가 아니라 .ax/current-task.json 의 +# handoff 객체예요 (status-note.sh). 항목이 있으면 맨 위에 포인터 한 줄을 넣어요 (jq 있을 때만). # 의존: grep, sed, awk, find. jq 권장(현재 작업 파싱) — 없으면 그 섹션만 생략. set -u @@ -198,8 +198,13 @@ emit_memory() { printf '\n' printf '_생성: %s_\n' "$NOW" [ -n "$MODE_NOTE" ] && printf '%s\n' "$MODE_NOTE" - # 인계 노트가 있으면 첫 포인터 — triage 는 이걸 MEMORY.md 보다 먼저 읽어요 (세션 간 인계) - [ -f .ax/docs/STATUS.md ] && printf '_인계 노트: .ax/docs/STATUS.md — 다음 · 열린 질문 · 바뀐 이름 (status-note.sh --show)_\n' + # 인계 노트에 항목이 있으면 첫 포인터 — triage 는 이걸 MEMORY.md 보다 먼저 읽어요 (세션 간 인계). + # current-task.json 을 파싱하는 아래 "현재 작업" 과 같은 HAS_JQ 가드예요 — jq 없으면 포인터도 생략. + if [ "$HAS_JQ" = true ] && [ -f .ax/current-task.json ]; then + local hn + hn=$(jq -r '(.handoff // {}) | [.now[]?, .next[]?, .open[]?, .renamed[]?] | length' .ax/current-task.json 2>/dev/null || echo 0) + [ "${hn:-0}" -gt 0 ] 2>/dev/null && printf '_인계 노트: .ax/current-task.json handoff — 지금 · 다음 · 열린 질문 · 바뀐 이름 (status-note.sh --show)_\n' + fi printf '\n' # 현재 작업 diff --git a/templates/default/.ax/scripts/bash/common.sh b/templates/default/.ax/scripts/bash/common.sh index 194739d..6cd7ebc 100755 --- a/templates/default/.ax/scripts/bash/common.sh +++ b/templates/default/.ax/scripts/bash/common.sh @@ -391,8 +391,8 @@ goax_rules_matching() { # mkdir 은 POSIX 에서 원자적이라 flock(리눅스 전용) 없이 macOS/BSD 에서도 상호배제가 돼요. # # 같은 `tmp.$$` && `mv` 패턴을 쓰는 스크립트는 전부 이 헬퍼를 거쳐야 해요: -# lanes-dispatch · tasks-gate · status-note · register-spirit-hook · build-memory · -# zero-init · zero-domain-risk · constitution-apply · update-state +# lanes-dispatch · tasks-gate · status-note · tier-from-state(--reset) · register-spirit-hook · +# build-memory · zero-init · zero-domain-risk · constitution-apply · update-state # # Usage: # goax_lock "$FILE.lock" || fail "다른 프로세스가 원장을 쓰는 중이에요" diff --git a/templates/default/.ax/scripts/bash/doctor-scan.sh b/templates/default/.ax/scripts/bash/doctor-scan.sh index 786577f..36f550f 100755 --- a/templates/default/.ax/scripts/bash/doctor-scan.sh +++ b/templates/default/.ax/scripts/bash/doctor-scan.sh @@ -6,14 +6,16 @@ # # 검사 (doctor SKILL.md 3.6 · 3.7(3) · 3.8 이 산문 bash 로 하던 것 + 신규 도달 지도): # migration .gitignore 누락 엔트리 · spirit/rules/output-style.md 잔재 · 미처리 .suggested · -# spec README.md 잔재 · 빈 checklists/contracts 디렉토리 +# spec README.md 잔재 · 빈 checklists/contracts 디렉토리 · .ax/docs/STATUS.md 잔재 +# (인계 노트가 current-task.json handoff 로 옮겨진 뒤 남은 파일 — 자동 import 안 해요) # hooks settings.json.template(SSOT) 이 선언한 (이벤트 키, .ax/hooks/*.sh) **쌍** 전부가 # .claude/settings.json 의 같은 이벤트 아래 있는지 — 훅은 등록된 이벤트에서만 발화하니 # Stop 훅이 PreToolUse 에 적혀 있으면 미등록이에요 # (파일 자체가 없으면 missing_files — 초기 설치 미완). PLUGIN_DIR 없으면 skip # doc_actual CLAUDE.md/AGENTS.md 가 설명하는 path-scoped 메커니즘(hook vs 폐기된 shim) ↔ 실제 설치 상태 # hooks.events settings.json 에 template 의 이벤트 키(UserPromptSubmit·PreToolUse·PostToolUse·SubagentStart·Stop)가 다 있는지 -# handoff 인계 노트(.ax/docs/STATUS.md)의 `- [ ] YYYY-MM-DD …` 기한 — ≤7일 임박 · 초과 (I3 와 같은 규칙) +# handoff 인계 노트(.ax/current-task.json handoff — now·next·open·renamed 네 절 전부)의 +# `- [ ] YYYY-MM-DD …` 기한 — ≤7일 임박 · 초과 (I3 와 같은 규칙) # reach 도달 지도 — 룰 소스마다 "어떤 배관으로 세션에 닿는가, 그 배관이 살아 있는가": # constitution AGENTS.md 본문이 Claude Code 에 닿으려면 CLAUDE.md 가 있고 @AGENTS.md 를 import 해야 해요. # AGENTS.md 만 있으면 Constitution 이 어디에도 안 가요 (Claude Code 는 AGENTS.md 를 안 읽어요) @@ -45,7 +47,7 @@ while [ $# -gt 0 ]; do shift done if [ "$SHOW_HELP" = true ]; then - sed -n '2,29p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + sed -n '2,31p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' exit "$EXIT_OK" fi if ! command -v jq >/dev/null 2>&1; then @@ -84,6 +86,7 @@ while IFS= read -r line; do "; fi done <<< "$GI_EXPECT" STALE_OS=false; [ -f .ax/spirit/rules/output-style.md ] && STALE_OS=true +STALE_SM=false; [ -f .ax/docs/STATUS.md ] && STALE_SM=true SUGGESTED=$(find .ax -maxdepth 2 -name '*.suggested' 2>/dev/null | sed 's|^\./||' | sort) SPEC_README=""; SPEC_EMPTY="" while IFS= read -r d; do @@ -97,12 +100,12 @@ while IFS= read -r d; do done < <(find .ax/docs/spec -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sed 's|^\./||' | sort) GI_N=$(printf '%s' "$GI_MISSING" | grep -c . || true); SG_N=$(printf '%s' "$SUGGESTED" | grep -c . || true) SR_N=$(printf '%s' "$SPEC_README" | grep -c . || true); SE_N=$(printf '%s' "$SPEC_EMPTY" | grep -c . || true) -MIG_N=$(( GI_N + SG_N + SR_N + SE_N )); [ "$STALE_OS" = true ] && MIG_N=$((MIG_N + 1)) +MIG_N=$(( GI_N + SG_N + SR_N + SE_N )); [ "$STALE_OS" = true ] && MIG_N=$((MIG_N + 1)); [ "$STALE_SM" = true ] && MIG_N=$((MIG_N + 1)) FINDINGS=$((FINDINGS + MIG_N)) -MIG=$(jq -nc --argjson gi "$(printf '%s' "$GI_MISSING" | to_json_arr)" --argjson os "$STALE_OS" \ +MIG=$(jq -nc --argjson gi "$(printf '%s' "$GI_MISSING" | to_json_arr)" --argjson os "$STALE_OS" --argjson sm "$STALE_SM" \ --argjson sg "$(printf '%s' "$SUGGESTED" | to_json_arr)" --argjson sr "$(printf '%s' "$SPEC_README" | to_json_arr)" \ --argjson se "$(printf '%s' "$SPEC_EMPTY" | to_json_arr)" --arg n "$MIG_N" \ - '{gitignore_missing:$gi,stale_output_style:$os,suggested:$sg,spec_readme_stale:$sr,spec_empty_dirs:$se,findings:($n|tonumber)}') + '{gitignore_missing:$gi,stale_output_style:$os,stale_status_md:$sm,suggested:$sg,spec_readme_stale:$sr,spec_empty_dirs:$se,findings:($n|tonumber)}') # ── hooks — template SSOT 기반 ──────────────────────────────────── TPL="$PLUGIN_DIR/templates/default/.claude/settings.json.template" @@ -255,9 +258,10 @@ fi # ── handoff — 인계 노트의 기한 (`- [ ] YYYY-MM-DD …`) — I3 와 같은 규칙: ≤7일 임박 · 초과 ── # zero 의 "1순위 가정 검증" 과 "룰 ablation 재검토" 가 여기 살아요. 날짜가 문서 안에만 있으면 아무도 안 봐요. +# 인계 노트는 current-task.json 의 handoff 객체예요 — 네 절(now·next·open·renamed)을 전부 훑어요. to_epoch() { date -j -u -f '%Y-%m-%d' "$1" +%s 2>/dev/null || date -u -d "$1" +%s 2>/dev/null || echo ""; } -NOTE=".ax/docs/STATUS.md"; DL_JSON="[]"; DL_IMM=0; DL_OVER=0; NOTE_PRESENT=false -if [ -f "$NOTE" ]; then +NOTE=".ax/current-task.json"; DL_JSON="[]"; DL_IMM=0; DL_OVER=0; NOTE_PRESENT=false +if [ -f "$NOTE" ] && jq -e '.handoff | type == "object"' "$NOTE" >/dev/null 2>&1; then NOTE_PRESENT=true TODAY_E=$(to_epoch "$(date +%F)") while IFS= read -r ln; do @@ -269,7 +273,8 @@ if [ -f "$NOTE" ]; then st=ok; [ "$days" -le 7 ] && st=imminent; [ "$days" -lt 0 ] && st=overdue [ "$st" = imminent ] && DL_IMM=$((DL_IMM + 1)); [ "$st" = overdue ] && DL_OVER=$((DL_OVER + 1)) DL_JSON=$(printf '%s' "$DL_JSON" | jq -c --arg d "$d" --arg t "$txt" --arg n "$days" --arg s "$st" '. + [{date:$d,text:$t,days_left:($n|tonumber),status:$s}]') - done < <(grep -E '^- \[ \] [0-9]{4}-[0-9]{2}-[0-9]{2}' "$NOTE" 2>/dev/null || true) + done < <(jq -r '(.handoff // {}) | [.now[]?, .next[]?, .open[]?, .renamed[]?] | .[]' "$NOTE" 2>/dev/null \ + | grep -E '^- \[ \] [0-9]{4}-[0-9]{2}-[0-9]{2}' || true) fi FINDINGS=$((FINDINGS + DL_IMM + DL_OVER)) HANDOFF=$(jq -nc --argjson p "$NOTE_PRESENT" --argjson d "$DL_JSON" --arg i "$DL_IMM" --arg o "$DL_OVER" \ @@ -287,6 +292,7 @@ printf '🩺 doctor-scan — %s\n' "$PROJECT_ROOT" printf '\n🧹 마이그레이션 잔재 — %s건\n' "$MIG_N" [ "$GI_N" -gt 0 ] && printf ' ⚠ .gitignore 누락 %s줄: %s\n' "$GI_N" "$(printf '%s' "$GI_MISSING" | tr '\n' ' ')" [ "$STALE_OS" = true ] && printf ' ⚠ .ax/spirit/rules/output-style.md — 출고에서 제거된 잔재\n' +[ "$STALE_SM" = true ] && printf ' ⚠ .ax/docs/STATUS.md — 인계 노트가 .ax/current-task.json 으로 옮겨져 남은 잔재 (필요한 줄은 status-note.sh --add 로 옮기고 rm)\n' [ "$SG_N" -gt 0 ] && printf ' ⚠ 미처리 .suggested %s개: %s\n' "$SG_N" "$(printf '%s' "$SUGGESTED" | tr '\n' ' ')" [ "$SR_N" -gt 0 ] && printf ' ⚠ spec README.md 잔재 %s건\n' "$SR_N" [ "$SE_N" -gt 0 ] && printf ' ⚠ spec 빈 디렉토리 %s건\n' "$SE_N" @@ -297,7 +303,7 @@ if [ "$HOOKS_CHECKED" = true ]; then [ "$MF_N" -gt 0 ] && printf ' ✗ 파일 자체가 없음 (초기 설치 미완 → /up): %s\n' "$(printf '%s' "$MISSING_FILES" | tr '\n' ' ')" [ "$EVM_N" -gt 0 ] && printf ' ⚠ 이벤트 키 미등록: %s — settings.json 에 그 이벤트가 아예 없어요\n' "$(printf '%s' "$EV_MISS" | tr '\n' ' ')" else printf 'skip (plugin 경로 미도출 — --plugin-dir)\n'; fi -printf '\n📅 인계 노트 기한 — 임박 %s · 초과 %s\n' "$DL_IMM" "$DL_OVER" +printf '\n📅 인계 노트 기한 (current-task.json handoff) — 임박 %s · 초과 %s\n' "$DL_IMM" "$DL_OVER" printf '%s' "$DL_JSON" | jq -r '.[] | select(.status!="ok") | " ⚠ \(.status) \(.date) (\(.days_left)일) — \(.text)"' printf '\n📑 문서 ↔ 실제 — %s건\n' "$MM_N" printf '%s' "$MM" | sed '/^$/d; s/^/ ⚠ /' diff --git a/templates/default/.ax/scripts/bash/reset-task.sh b/templates/default/.ax/scripts/bash/reset-task.sh index 9e69c94..526e378 100755 --- a/templates/default/.ax/scripts/bash/reset-task.sh +++ b/templates/default/.ax/scripts/bash/reset-task.sh @@ -8,6 +8,7 @@ # # 효과: # - current-task.json → phase=idle, size/risk/domain/spec_* 모두 null, intent_notes={} +# - handoff(인계 노트)는 남겨요 — next·open·renamed 는 task 를 넘어 살아요 # - .triage-nudged 마커 삭제 → 다음 사용자 메시지에 nudge 재발동 가능 # # Usage: diff --git a/templates/default/.ax/scripts/bash/status-note.sh b/templates/default/.ax/scripts/bash/status-note.sh index cf93ad0..e7038ba 100755 --- a/templates/default/.ax/scripts/bash/status-note.sh +++ b/templates/default/.ax/scripts/bash/status-note.sh @@ -1,33 +1,37 @@ #!/usr/bin/env bash -# .ax/scripts/bash/status-note.sh — 세션 간 인계 노트 `.ax/docs/STATUS.md` +# .ax/scripts/bash/status-note.sh — 세션 간 인계 노트 (`.ax/current-task.json` 의 `handoff` 객체) # # Usage: # bash status-note.sh --show [--json] # 4절 파싱 (다음 세션·triage 가 읽어요) -# bash status-note.sh --init [--json] # 없으면 골격 생성, 있으면 빠진 절만 추가 +# bash status-note.sh --init [--json] # handoff 객체 보장 — 없으면 만들고, 있으면 빠진 키만 채워요 # bash status-note.sh --add <절> "<한 줄>" [--json] # 절에 항목 추가 (같은 줄이 있으면 무시) # bash status-note.sh --done <절> "<부분 문자열>" [--json] # 매칭 항목 제거 — 끝난 건 지워요 -# bash status-note.sh --set <절> "<본문>" [--json] # 절 통째 교체 (여러 줄은 \n 으로) +# bash status-note.sh --set <절> "<본문>" [--json] # 절 통째 교체 (여러 줄은 \n 으로 — 역슬래시 escape 를 해석해요) # bash status-note.sh --clear <절> [--json] # 절 비우기 (`--set <절> ""` 과 동일) # 절: now | next | open | renamed -# now ## 지금 상태 spec-implement 가 halt·완료·레인 보고 시점에 갱신 -# next ## 다음 다음 세션이 처음 할 일 1~3개 -# open ## 열린 질문 사용자 결정 대기 -# renamed ## 이번에 바뀐 이름 레인 보고의 "다른 레인에 넘길 것" — 이름 대조의 SSOT +# now 지금 상태 spec-implement 가 halt·완료·레인 보고 시점에 갱신 +# next 다음 다음 세션이 처음 할 일 1~3개 +# open 열린 질문 사용자 결정 대기 +# renamed 이번에 바뀐 이름 레인 보고의 "다른 레인에 넘길 것" — 이름 대조의 SSOT # -# `--set now` 은 마지막 줄 끝에 `(YYYY-MM-DDTHH:MMZ)` 를 박아요. Stop 게이트 +# 저장소는 `.ax/current-task.json` 의 `handoff` 객체예요 — {now:[], now_at, next:[], open:[], renamed:[]}. +# 상태 파일은 state.json · current-task.json 둘뿐이라 새 파일을 만들지 않아요. handoff 는 task 를 +# 넘어 살아요 (reset-task.sh 가 안 지워요). 파일이 없으면 변이 모드는 거절해요 — `/up` 으로 설치를 +# 마쳐요 (여기서 최소 파일을 만들면 설치가 정본 템플릿을 영영 못 깔아요). +# +# `--set now` 은 `now_at` 에 현재 UTC 분(YYYY-MM-DDTHH:MMZ)을 적어요. Stop 게이트 # (`.ax/hooks/stop/spec-gate.sh`) 가 "인계 노트에 적혀 있으니 의도된 halt" 로 인정하는 건 -# **24시간 안에 찍힌 노트만**이에요. 시각이 없으면 옛 노트 한 줄이 새 세션의 게이트를 -# 영원히 침묵시켜요 (다른 session_id 로 몇 번을 불러도 안 잡히던 구멍). +# **24시간 안에 찍힌 노트만**이에요. `--add now` 는 시각을 건드리지 않아요 — 게이트의 침묵 창이 +# 넓어지면 안 되니까요. `now` 가 비면 `now_at` 도 null 이에요. # # 왜 필요한가 — 결정은 ADR, 진행은 tasks.md, 단계는 current-task.json 에 있는데 "막힌 것·열린 질문· # 이번에 바뀐 공유 이름·다음 세션이 처음 해야 할 것" 은 어디에도 없었어요. 대화가 압축되면 사라져요. -# 형식이 고정돼야 다음 세션이 파싱하니까 쓰는 쪽은 스크립트, 무엇을 적을지는 skill 이 정해요. -# 상한 40줄 — 넘으면 warning. 완료 사실의 SSOT 는 git log · ADR 이라 끝난 항목은 지워요. +# 상한 40개(네 절 항목 합) — 넘으면 warning. 완료 사실의 SSOT 는 git log · ADR 이라 끝난 항목은 지워요. # # Output (--show --json): -# {"status":"ok|warning","result":{"path":".ax/docs/STATUS.md","exists":true,"lines":N,"over_cap":false, -# "sections":{"now":[…],"next":[…],"open":[…],"renamed":[…]},"counts":{…}},…} -# Exit: 0 ok · 1 error +# {"status":"ok|warning","result":{"path":".ax/current-task.json","exists":true,"items":N,"over_cap":false, +# "now_at":"YYYY-MM-DDTHH:MMZ"|null,"sections":{"now":[…],"next":[…],"open":[…],"renamed":[…]},"counts":{…}},…} +# Exit: 0 ok · 1 error · 2 skipped (jq 없음) set -euo pipefail SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -52,16 +56,23 @@ while [ $# -gt 0 ]; do [ $# -gt 0 ] && shift done if [ "$SHOW_HELP" = true ]; then - sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + sed -n '2,34p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' exit "$EXIT_OK" fi [ -n "$MODE" ] || MODE=show fail() { if [ "$JSON_MODE" = true ]; then json_error "$1"; fi; goax_error "$1"; exit "$EXIT_ERROR"; } +# 저장소가 JSON 이라 jq 가 필수예요 — 없으면 README 규약대로 skip (exit 2) +if ! command -v jq >/dev/null 2>&1; then + if [ "$JSON_MODE" = true ]; then json_skip "jq 가 필요해요 — 인계 노트가 .ax/current-task.json 안에 있어요"; fi + goax_warn "jq 가 없어 skip"; exit "$EXIT_SKIPPED" +fi +case "$CAP" in ''|*[!0-9]*) fail "--cap 은 정수예요 (받은 값: '${CAP}')" ;; esac + PROJECT_ROOT=$(find_project_root) || exit "$EXIT_ERROR" -FILE="$PROJECT_ROOT/.ax/docs/STATUS.md" -REL=".ax/docs/STATUS.md" +FILE="$PROJECT_ROOT/.ax/current-task.json" +REL=".ax/current-task.json" sec_title() { case "$1" in @@ -79,142 +90,130 @@ if [ "$MODE" != show ] && [ "$MODE" != init ]; then [ "$MODE" = set ] || [ -n "$TEXT" ] || fail "--${MODE} 에는 본문이 필요해요" fi -# `지금 상태` 는 시각을 같이 박아요 — Stop 게이트가 24시간 안의 노트만 인정해요. -# 이미 박혀 있으면 지우고 다시 박아요 (두 번 붙는 걸 막아요). -stamp_now() { # stdin 본문 → 마지막 줄 끝에 (YYYY-MM-DDTHH:MMZ) - sed -E 's/[[:space:]]*\([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}Z\)[[:space:]]*$//' \ - | awk -v s=" ($(date -u +%Y-%m-%dT%H:%MZ))" \ - '{ l[NR] = $0 } END { for (i = 1; i <= NR; i++) print l[i] (i == NR ? s : "") }' -} +# handoff 객체가 있나 — 파일이 없거나 키가 없으면(업그레이드 전 설치) false. 읽기는 관대해요. +has_handoff() { [ -f "$FILE" ] && jq -e '.handoff | type == "object"' "$FILE" >/dev/null 2>&1; } -# 골격 — zero 가 만든 2절짜리도 여기서 4절로 승격돼요 -ensure_file() { - mkdir -p "$(dirname "$FILE")" - if [ ! -f "$FILE" ]; then - printf '# STATUS — 세션 간 인계 노트\n\n> 끝난 항목은 지워요. 완료의 SSOT 는 git log · ADR 이에요. 상한 %s줄.\n\n' "$CAP" > "$FILE" - fi - for s in now next open renamed; do - t=$(sec_title "$s") - grep -qE "^## ${t}[[:space:]]*$" "$FILE" || printf '\n## %s\n' "$t" >> "$FILE" - done -} +# 네 절 항목 합 — 상한은 이 총량 하나로 재요 +count_items() { jq -r '(.handoff // {}) | [.now[]?, .next[]?, .open[]?, .renamed[]?] | length' "$FILE" 2>/dev/null || echo 0; } -# 절 본문 추출 (헤더 제외, 빈 줄 제외) -section_lines() { - local t; t=$(sec_title "$1") - [ -f "$FILE" ] || return 0 - awk -v t="$t" ' - /^## / { inb = ($0 ~ ("^## " t "[ \t]*$")); next } - inb && $0 !~ /^[ \t]*$/ { print }' "$FILE" +# in-place 갱신 — handoff 밖의 키(task_id·phase·…)는 다른 writer 의 것이라 그대로 둬요 +write_jq() { # $@ = jq 인자 (필터가 마지막) — tmp.$$ → mv + local tmp="$FILE.tmp.$$" + jq "$@" "$FILE" > "$tmp" || { rm -f "$tmp"; fail "$REL 을 읽지 못했어요 — JSON 이 깨졌는지 봐요"; } + mv "$tmp" "$FILE" } -# 절 본문 교체 — stdin 이 새 본문. 본문은 파일로 넘겨요 (macOS awk 는 -v 값에 개행을 못 받아요) -replace_section() { - local t tmp bodyf; t=$(sec_title "$1"); tmp="$FILE.tmp.$$"; bodyf="$FILE.body.$$" - cat > "$bodyf" - awk -v t="$t" -v bodyf="$bodyf" ' - /^## / { - if (inb) { inb = 0 } - if ($0 ~ ("^## " t "[ \t]*$")) { - print - while ((getline l < bodyf) > 0) { if (l !~ /^[ \t]*$/) print l } - close(bodyf); print ""; inb = 1; next - } - } - inb { next } - { print }' "$FILE" > "$tmp" && mv "$tmp" "$FILE" - rm -f "$bodyf" +# handoff 객체 보장 — 없으면 만들고, 있으면 빠진 키만 채워요. 다른 키는 만들지 않아요. +ensure_handoff() { + write_jq '.handoff = ((.handoff | if type == "object" then . else {} end) + | {now: (.now // []), now_at: (.now_at // null), next: (.next // []), open: (.open // []), renamed: (.renamed // [])})' } emit_show() { - local lines over - if [ ! -f "$FILE" ]; then + local empty='{"now":[],"next":[],"open":[],"renamed":[]}' zero='{"now":0,"next":0,"open":0,"renamed":0}' + if ! has_handoff; then + local why + if [ -f "$FILE" ]; then why='첫 인계는 --add next "…" 로 시작해요'; else why="$REL 이 없어요 (/up)"; fi if [ "$JSON_MODE" = true ]; then - json_output "ok" '{"path":"'"$REL"'","exists":false,"lines":0,"over_cap":false,"sections":{"now":[],"next":[],"open":[],"renamed":[]},"counts":{"now":0,"next":0,"open":0,"renamed":0}}' "인계 노트 없음 — status-note.sh --init 또는 --add 로 시작해요" + json_output "ok" '{"path":"'"$REL"'","exists":false,"items":0,"over_cap":false,"now_at":null,"sections":'"$empty"',"counts":'"$zero"'}' "인계 노트 없음 — $why" else - printf '📝 %s 없음 — 첫 인계는 --add next "…" 로 시작해요\n' "$REL" + printf '📝 인계 노트 없음 — %s\n' "$why" fi return 0 fi - lines=$(wc -l < "$FILE" | tr -d ' '); over=false; [ "$lines" -gt "$CAP" ] && over=true + local res items over st nx + res=$(jq -c --arg p "$REL" --argjson cap "$CAP" ' + (.handoff | if type == "object" then . else {} end) as $h + | {now: ($h.now // []), next: ($h.next // []), open: ($h.open // []), renamed: ($h.renamed // [])} as $s + | ([$s[][]] | length) as $n + | {path: $p, exists: true, items: $n, over_cap: ($n > $cap), now_at: ($h.now_at // null), sections: $s, + counts: {now: ($s.now | length), next: ($s.next | length), open: ($s.open | length), renamed: ($s.renamed | length)}}' "$FILE") \ + || fail "$REL 을 읽지 못했어요 — JSON 이 깨졌는지 봐요" + items=$(printf '%s' "$res" | jq -r '.items'); over=$(printf '%s' "$res" | jq -r '.over_cap') if [ "$JSON_MODE" = true ]; then - local sj="{}" cj="{}" s arr n - for s in now next open renamed; do - arr=$(section_lines "$s" | jq -R . | jq -sc .) - n=$(printf '%s' "$arr" | jq 'length') - sj=$(printf '%s' "$sj" | jq -c --arg k "$s" --argjson v "$arr" '. + {($k): $v}') - cj=$(printf '%s' "$cj" | jq -c --arg k "$s" --argjson v "$n" '. + {($k): $v}') - done - local res st="ok" nx="인계 노트 ${lines}줄" - res=$(jq -nc --arg p "$REL" --arg l "$lines" --argjson o "$over" --argjson s "$sj" --argjson c "$cj" \ - '{path:$p,exists:true,lines:($l|tonumber),over_cap:$o,sections:$s,counts:$c}') - [ "$over" = true ] && { st="warning"; nx="인계 노트 ${lines}줄 — 상한 ${CAP}줄을 넘었어요. 끝난 항목을 지우세요 (SSOT 는 git log · ADR)"; } + st="ok"; nx="인계 노트 ${items}개 항목" + [ "$over" = true ] && { st="warning"; nx="인계 노트 ${items}개 항목 — 상한 ${CAP}개를 넘었어요. 끝난 항목을 지우세요 (SSOT 는 git log · ADR)"; } json_output "$st" "$res" "$nx" else - printf '📝 %s (%s줄%s)\n' "$REL" "$lines" "$([ "$over" = true ] && printf ' — ⚠ 상한 %s줄 초과' "$CAP")" + printf '📝 %s handoff (%s개 항목 · 상한 %s%s)\n' "$REL" "$items" "$CAP" "$([ "$over" = true ] && printf ' · ⚠ 초과')" + local s at + at=$(printf '%s' "$res" | jq -r '.now_at // "—"') for s in now next open renamed; do - printf '\n## %s\n' "$(sec_title "$s")" - section_lines "$s" | sed 's/^/ /' + if [ "$s" = now ]; then printf '\n## %s (%s)\n' "$(sec_title "$s")" "$at"; else printf '\n## %s\n' "$(sec_title "$s")"; fi + printf '%s' "$res" | jq -r --arg s "$s" '.sections[$s][]' | sed 's/^/ /' done fi } -# 변이 모드는 "바꿀지 말지를 정하는 첫 읽기" 부터 락 안이에요 — ensure_file 의 절 추가도, +# 변이 모드는 "바꿀지 말지를 정하는 첫 읽기" 부터 락 안이에요 — ensure_handoff 의 키 존재 판정도, # --add 의 중복 판정도 읽고 나서 쓰는 자리라 락 밖이면 두 세션이 서로를 덮어써요. # dry-run 은 아무것도 안 쓰니까 락도 안 잡아요 (락 디렉토리 자체가 부작용이에요). -LOCK="$FILE.lock" -if [ "$MODE" != show ] && [ "$DRY_RUN" != true ]; then - goax_lock "$LOCK" "${GOAX_LOCK_TIMEOUT:-10}" || fail "다른 프로세스가 $REL 을 쓰는 중이에요 — 잠시 뒤 다시 해요" +# 락 단위는 파일 — tier-from-state.sh --reset 도 같은 문자열을 잡아요. +# 파일 부재는 dry-run 도 알려야 해요 — 최소 파일을 만들어 주지 않아요 (설치 seed 가 막혀요). +LOCK="$(goax_normalize_path "$PROJECT_ROOT/.ax/current-task.json" "$PROJECT_ROOT").lock" +if [ "$MODE" != show ]; then + [ -f "$FILE" ] || fail "$REL 이 없어요 — /up 으로 설치를 마쳐요" + if [ "$DRY_RUN" != true ]; then + goax_lock "$LOCK" "${GOAX_LOCK_TIMEOUT:-10}" || fail "다른 프로세스가 $REL 을 쓰는 중이에요 — 잠시 뒤 다시 해요" + fi fi case "$MODE" in show) emit_show ;; init) if [ "$DRY_RUN" = true ]; then - [ "$JSON_MODE" = true ] && json_output "ok" '{"path":"'"$REL"'","dry_run":true}' "골격만 확인 — 파일 안 썼어요" || goax_log "dry-run — $REL 안 썼어요" + [ "$JSON_MODE" = true ] && json_output "ok" '{"path":"'"$REL"'","dry_run":true}' "handoff 객체만 확인 — 파일 안 썼어요" || goax_log "dry-run — $REL 안 썼어요" else - ensure_file - [ "$JSON_MODE" = true ] && json_output "ok" '{"path":"'"$REL"'","initialized":true}' "4절 골격 준비됨 — --add 로 채워요" || goax_log "$REL 준비됨 (4절)" + ensure_handoff + [ "$JSON_MODE" = true ] && json_output "ok" '{"path":"'"$REL"'","initialized":true}' "handoff 준비됨 (now·next·open·renamed) — --add 로 채워요" || goax_log "$REL handoff 준비됨" fi ;; add) line="$TEXT"; case "$line" in -*|'- '*) ;; *) line="- $line" ;; esac - if section_lines "$SEC" | grep -qxF -- "$line"; then + if jq -e --arg s "$SEC" --arg v "$line" '(.handoff // {})[$s] // [] | any(. == $v)' "$FILE" >/dev/null 2>&1; then [ "$JSON_MODE" = true ] && json_output "ok" '{"path":"'"$REL"'","added":false,"reason":"duplicate"}' "이미 있는 줄이라 그대로 뒀어요" || goax_log "이미 있는 줄 — 추가 안 함" elif [ "$DRY_RUN" = true ]; then [ "$JSON_MODE" = true ] && json_output "ok" '{"path":"'"$REL"'","added":false,"dry_run":true}' "dry-run — 안 썼어요" || goax_log "dry-run — 안 썼어요" else - ensure_file - { section_lines "$SEC"; printf '%s\n' "$line"; } | replace_section "$SEC" - n=$(wc -l < "$FILE" | tr -d ' ') + ensure_handoff + write_jq --arg s "$SEC" --arg v "$line" '.handoff[$s] += [$v]' + n=$(count_items) if [ "$n" -gt "$CAP" ]; then - [ "$JSON_MODE" = true ] && json_output "warning" '{"path":"'"$REL"'","added":true,"lines":'"$n"',"over_cap":true}' "추가했지만 ${n}줄 — 상한 ${CAP}줄 초과. 끝난 항목을 지우세요" || goax_warn "추가됨 — ${n}줄, 상한 ${CAP}줄 초과" + [ "$JSON_MODE" = true ] && json_output "warning" '{"path":"'"$REL"'","added":true,"items":'"$n"',"over_cap":true}' "추가했지만 ${n}개 — 상한 ${CAP}개 초과. 끝난 항목을 지우세요" || goax_warn "추가됨 — ${n}개, 상한 ${CAP}개 초과" else - [ "$JSON_MODE" = true ] && json_output "ok" '{"path":"'"$REL"'","added":true,"lines":'"$n"',"over_cap":false}' "$(sec_title "$SEC") 에 1줄 추가" || goax_log "$(sec_title "$SEC") 에 추가 — $line" + [ "$JSON_MODE" = true ] && json_output "ok" '{"path":"'"$REL"'","added":true,"items":'"$n"',"over_cap":false}' "$(sec_title "$SEC") 에 1개 추가" || goax_log "$(sec_title "$SEC") 에 추가 — $line" fi fi ;; done) - [ -f "$FILE" ] || fail "$REL 이 없어요" - before=$(section_lines "$SEC" | grep -c . || true) - remaining=$(section_lines "$SEC" | grep -vF -- "$TEXT" || true) - after=$(printf '%s' "$remaining" | grep -c . || true) - removed=$(( before - after )) + # handoff 가 없으면 지울 것도 없어요 — 객체를 만들지 않아요 + removed=0 + if has_handoff; then + removed=$(jq -r --arg s "$SEC" --arg t "$TEXT" '(.handoff[$s] // []) | map(select(contains($t))) | length' "$FILE" 2>/dev/null || echo 0) + fi if [ "$DRY_RUN" = true ]; then - [ "$JSON_MODE" = true ] && json_output "ok" '{"path":"'"$REL"'","removed":'"$removed"',"dry_run":true}' "dry-run — ${removed}줄 지워질 예정" || goax_log "dry-run — ${removed}줄 지워질 예정" + [ "$JSON_MODE" = true ] && json_output "ok" '{"path":"'"$REL"'","removed":'"$removed"',"dry_run":true}' "dry-run — ${removed}개 지워질 예정" || goax_log "dry-run — ${removed}개 지워질 예정" else - printf '%s' "$remaining" | replace_section "$SEC" - [ "$JSON_MODE" = true ] && json_output "ok" '{"path":"'"$REL"'","removed":'"$removed"'}' "$(sec_title "$SEC") 에서 ${removed}줄 제거" || goax_log "$(sec_title "$SEC") 에서 ${removed}줄 제거" + # now 가 비면 now_at 도 null — 남은 줄이 있으면 시각은 그대로예요 + [ "$removed" -gt 0 ] && write_jq --arg s "$SEC" --arg t "$TEXT" '.handoff[$s] = ((.handoff[$s] // []) | map(select(contains($t) | not))) + | if $s == "now" and (.handoff.now | length) == 0 then .handoff.now_at = null else . end' + [ "$JSON_MODE" = true ] && json_output "ok" '{"path":"'"$REL"'","removed":'"$removed"'}' "$(sec_title "$SEC") 에서 ${removed}개 제거" || goax_log "$(sec_title "$SEC") 에서 ${removed}개 제거" fi ;; set) + # \n 확장 → 줄로 나눔 → 빈 줄 버림. `- ` 보정은 안 해요 (본문은 통째로 caller 의 것) body=$(printf '%b' "$TEXT") - if [ "$SEC" = now ] && [ -n "$body" ]; then body=$(printf '%s\n' "$body" | stamp_now); fi + arr=$(printf '%s\n' "$body" | awk '!/^[[:space:]]*$/' | jq -R . | jq -sc .) + stamp=""; [ "$SEC" = now ] && stamp=$(date -u +%Y-%m-%dT%H:%MZ) if [ "$DRY_RUN" = true ]; then [ "$JSON_MODE" = true ] && json_output "ok" '{"path":"'"$REL"'","dry_run":true}' "dry-run — 안 썼어요" || goax_log "dry-run — 안 썼어요" else - ensure_file - printf '%s' "$body" | replace_section "$SEC" - n=$(wc -l < "$FILE" | tr -d ' ') - st=ok; [ "$n" -gt "$CAP" ] && st=warning - [ "$JSON_MODE" = true ] && json_output "$st" '{"path":"'"$REL"'","replaced":true,"lines":'"$n"',"over_cap":'"$([ "$st" = warning ] && echo true || echo false)"'}' "$(sec_title "$SEC") 교체 — ${n}줄" || goax_log "$(sec_title "$SEC") 교체 — ${n}줄" + ensure_handoff + # now 는 본문이 있으면 현재 UTC 분을 now_at 에 (여러 줄이어도 하나), 비면 null + write_jq --arg s "$SEC" --argjson v "$arr" --arg ts "$stamp" '.handoff[$s] = $v + | if $s == "now" then .handoff.now_at = (if ($v | length) > 0 then $ts else null end) else . end' + n=$(count_items); over=false; [ "$n" -gt "$CAP" ] && over=true + st=ok; [ "$over" = true ] && st=warning + res=$(jq -nc --arg p "$REL" --argjson n "$n" --argjson o "$over" --argjson a "$(jq -c '.handoff.now_at // null' "$FILE")" \ + '{path: $p, replaced: true, items: $n, over_cap: $o, now_at: $a}') + [ "$JSON_MODE" = true ] && json_output "$st" "$res" "$(sec_title "$SEC") 교체 — ${n}개 항목$([ "$over" = true ] && printf ' (상한 %s개 초과)' "$CAP")" || goax_log "$(sec_title "$SEC") 교체 — ${n}개 항목" fi ;; esac if [ "$MODE" != show ] && [ "$DRY_RUN" != true ]; then goax_unlock "$LOCK"; fi diff --git a/templates/default/.ax/scripts/bash/tier-from-state.sh b/templates/default/.ax/scripts/bash/tier-from-state.sh index d01e7e7..8f95490 100755 --- a/templates/default/.ax/scripts/bash/tier-from-state.sh +++ b/templates/default/.ax/scripts/bash/tier-from-state.sh @@ -3,6 +3,7 @@ # # Usage: # bash tier-from-state.sh [--json] [--size S|M|L|XL] [--risk L0|L1|L2|L3] [--help] +# bash tier-from-state.sh --reset [--json] # current-task.json → idle (handoff 는 남겨요) # # 우선순위: # 1. CLI --size --risk 인자 @@ -50,7 +51,7 @@ while [ $# -gt 0 ]; do done if [ "$SHOW_HELP" = true ]; then - sed -n '2,22p' "${BASH_SOURCE[0]}" | sed 's/^# //' + sed -n '2,23p' "${BASH_SOURCE[0]}" | sed 's/^# //' exit "$EXIT_OK" fi @@ -58,13 +59,22 @@ PROJECT_ROOT=$(find_project_root) || exit "$EXIT_ERROR" TASK_FILE="$PROJECT_ROOT/.ax/current-task.json" # --reset: current-task.json을 phase=idle 로 리셋 (작업 완료 후 호출) -# 새 작업이 들어오면 triage가 다시 발동해야 하므로 size/risk/domain 등도 모두 null +# 새 작업이 들어오면 triage가 다시 발동해야 하므로 size/risk/domain 등도 모두 null. +# 필드를 하나씩 null 로 두는 이유 — 인계 노트(handoff: next·open·renamed)는 task 를 넘어 살아야 해요. +# 객체를 통째로 다시 만들면 handoff 가 조용히 사라져요. if [ "$RESET" = true ]; then if [ ! -f "$TASK_FILE" ]; then TEMPLATE="$PROJECT_ROOT/.ax/current-task.json.template" [ -f "$TEMPLATE" ] && cp "$TEMPLATE" "$TASK_FILE" fi if [ -f "$TASK_FILE" ] && command -v jq >/dev/null 2>&1; then + # status-note.sh 와 같은 파일을 써요 — 락 단위는 파일이라 락 경로 문자열도 같아야 해요 + LOCK="$(goax_normalize_path "$PROJECT_ROOT/.ax/current-task.json" "$PROJECT_ROOT").lock" + if ! goax_lock "$LOCK" "${GOAX_LOCK_TIMEOUT:-10}"; then + MSG="다른 프로세스가 .ax/current-task.json 을 쓰는 중이에요 — 잠시 뒤 다시 해요" + if [ "$JSON_MODE" = true ]; then json_error "$MSG"; fi + goax_error "$MSG"; exit "$EXIT_ERROR" + fi jq '.task_id = null | .description = null | .size = null @@ -78,7 +88,14 @@ if [ "$RESET" = true ]; then | .phase = "idle" | .intent_notes = {} | .blocked_by = []' "$TASK_FILE" \ - > "${TASK_FILE}.tmp" && mv "${TASK_FILE}.tmp" "$TASK_FILE" + > "${TASK_FILE}.tmp" && mv "${TASK_FILE}.tmp" "$TASK_FILE" || { + # jq 가 죽으면(JSON 깨짐) 원본은 그대로 두고 tmp 만 치워요 — ok 로 넘기지 않아요 + rm -f "${TASK_FILE}.tmp"; goax_unlock "$LOCK" + MSG=".ax/current-task.json 을 읽지 못했어요 — JSON 이 깨졌는지 봐요" + if [ "$JSON_MODE" = true ]; then json_error "$MSG"; fi + goax_error "$MSG"; exit "$EXIT_ERROR" + } + goax_unlock "$LOCK" fi # 다음 nudge 허용 — 마커 정리 rm -f "$PROJECT_ROOT/.ax/.triage-nudged" diff --git a/templates/default/.ax/scripts/bash/zero-ablation.sh b/templates/default/.ax/scripts/bash/zero-ablation.sh index 392b6f4..03c01f1 100755 --- a/templates/default/.ax/scripts/bash/zero-ablation.sh +++ b/templates/default/.ax/scripts/bash/zero-ablation.sh @@ -18,8 +18,8 @@ # --off `.ax/spirit/rules/.md` → `.md.ablated` 로 rename (내용 보존) # --on `.ablated` 를 전부 되돌려요 # --status 지금 꺼진 룰과 켜진 룰, 마지막 회차(.ax/.ablation-last)와 다음 기한(+180일) -# --on 은 한 회차의 끝 — 마지막 회차를 기록하고 다음 기한을 .ax/docs/STATUS.md "다음" 에 체크박스로 넣어요 -# (status-note.sh). doctor 가 그 날짜를 I3 처럼 추적해요 (임박·초과) +# --on 은 한 회차의 끝 — 마지막 회차를 기록하고 다음 기한을 인계 노트(status-note.sh --show) "다음" 에 +# 체크박스로 넣어요 (status-note.sh --add next). doctor 가 그 날짜를 I3 처럼 추적해요 (임박·초과) # # `.ax/spirit/rules/*.md.ablated` 는 확장자가 `.md` 가 아니라서 주입 훅·doctor 가 안 봐요. # 절차 문서: `.ax/_templates/zero/ablation.md` @@ -145,7 +145,7 @@ if [ "$ACTIVE_N" -eq 0 ] && [ "$ABLATED_N" -eq 0 ]; then fi # ── 회차 기록 — --on 이 한 회차의 끝이에요. 다음 기한(6개월)을 인계 노트에 체크박스로 박아요. -# 날짜가 문서 안에만 있으면 이 절차는 영영 안 돌아요 — doctor 가 STATUS.md 의 기한을 I3 처럼 추적해요. +# 날짜가 문서 안에만 있으면 이 절차는 영영 안 돌아요 — doctor 가 인계 노트(status-note.sh --show)의 기한을 I3 처럼 추적해요. LAST_FILE="$PROJECT_ROOT/.ax/.ablation-last" if [ "$MODE" = "on" ] && [ "$DRY_RUN" = false ] && [ "$CHANGED" -gt 0 ]; then date +%F > "$LAST_FILE" 2>/dev/null || true @@ -153,7 +153,7 @@ if [ "$MODE" = "on" ] && [ "$DRY_RUN" = false ] && [ "$CHANGED" -gt 0 ]; then if [ -n "$DUE" ] && [ -f "$SCRIPT_DIR/status-note.sh" ]; then bash "$SCRIPT_DIR/status-note.sh" --done next "룰 ablation 재검토" --json >/dev/null 2>&1 || true bash "$SCRIPT_DIR/status-note.sh" --add next "- [ ] $DUE 룰 ablation 재검토 (.ax/_templates/zero/ablation.md)" --json >/dev/null 2>&1 || true - NEXT="$NEXT · 다음 회차 $DUE 를 .ax/docs/STATUS.md 에 적었어요" + NEXT="$NEXT · 다음 회차 $DUE 를 인계 노트(status-note.sh --show)에 적었어요" fi fi LAST_ROUND=""; [ -f "$LAST_FILE" ] && LAST_ROUND=$(tr -d '[:space:]' < "$LAST_FILE" 2>/dev/null || true) @@ -162,7 +162,7 @@ if [ -n "$LAST_ROUND" ]; then NEXT_DUE=$(date -j -v+180d -f '%Y-%m-%d' "$LAST_ROUND" +%F 2>/dev/null || date -d "$LAST_ROUND +180 days" +%F 2>/dev/null || true) fi if [ "$MODE" = "status" ] && [ -z "$LAST_ROUND" ]; then - NEXT="$NEXT · 아직 한 회차도 안 돌았어요 — 첫 기한은 zero §13 이 STATUS.md 에 적어요" + NEXT="$NEXT · 아직 한 회차도 안 돌았어요 — 첫 기한은 zero §13 이 인계 노트(status-note.sh --show)에 적어요" fi # 되돌리지 못한 게 있으면 ok 로 넘기지 않아요 — 사용자가 합쳐야 끝나는 상태예요 diff --git a/templates/zero/ablation.md b/templates/zero/ablation.md index e521466..3613977 100644 --- a/templates/zero/ablation.md +++ b/templates/zero/ablation.md @@ -5,7 +5,7 @@ > 먹는 부채로 남고, 지시가 늘수록 준수율이 떨어져요. > > **주기: 6개월마다, 또는 주 모델 세대가 바뀔 때 (둘 중 먼저).** -> 다음 예정일을 `.ax/docs/STATUS.md` 의 "다음" 에 체크박스로 적어 두세요 — +> 다음 예정일을 인계 노트 `next` 에 체크박스로 적어 두세요 (`status-note.sh --add next`) — > 날짜가 어디에도 없으면 이 절차는 영영 안 돌아요. ## 절차 diff --git a/templates/zero/product/success-and-stop.md b/templates/zero/product/success-and-stop.md index 284e417..1adae70 100644 --- a/templates/zero/product/success-and-stop.md +++ b/templates/zero/product/success-and-stop.md @@ -33,7 +33,7 @@ - 그날 볼 것: <위 표의 값들> - 그날 결정할 것: <계속 / 피벗 / 중단> -`.ax/docs/STATUS.md` 의 "다음" 에 이 날짜를 체크박스로 넣어요. 문서 안에만 있는 날짜는 +인계 노트 `next` (`status-note.sh --add next`) 에 이 날짜를 체크박스로 넣어요. 문서 안에만 있는 날짜는 지나갑니다. ## 4. 판정 기록 diff --git a/tests/smoke.sh b/tests/smoke.sh index 981197d..b194b88 100644 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -2214,7 +2214,7 @@ grep -q 'hud' "$REPO/templates/default/.ax/hud/state.json.template" && grep -q ' section "34. 폐기·스크립트화 — spirit-lint · rules-index · doctor-scan · status-note · constitution-apply" # ─────────────────────────────────────────────────────────── # rules·spirit skill 은 "grep 해서 찍어라" 산문이었고, doctor 3.6~3.8 은 SKILL.md 안의 bash 였어요. -# 스크립트가 됐으니 픽스처로 판정을 고정해요. 인계 노트(STATUS.md)는 형식이 고정돼야 다음 세션이 파싱해요. +# 스크립트가 됐으니 픽스처로 판정을 고정해요. 인계 노트(current-task.json handoff)는 형식이 고정돼야 다음 세션이 파싱해요. if ! command -v jq >/dev/null 2>&1; then pass "§34 skip (jq 없음)" else @@ -2230,6 +2230,7 @@ else printf '# X\n\n## CRITICAL\n\n🔴 **`AX:CRITICAL:001`** — 결제 API 는 멱등성 키 필수\n\n## MANDATORY\n\n🟡 **`AX:MANDATORY:001`** ADR 필수\n\n## CONVENTION\n\n@.ax/spirit/rules/security.md\n' > "$RS/AGENTS.md" touch "$RS/.ax/docs/spec/003-x/README.md"; printf '.ax/state.json\n' > "$RS/.gitignore" echo '{"hooks":{}}' > "$RS/.claude/settings.json" + echo '{"phase":"idle"}' > "$RS/.ax/current-task.json" rs() { GOAX_PROJECT_DIR="$RS" bash "$RS/.ax/scripts/bash/$1" "${@:2}" 2>/dev/null; } # spirit-lint — 비표준 헤더 · 교차 중복 · 카운트 @@ -2280,8 +2281,25 @@ else && pass "status-note --done — 끝난 항목 제거" || fail "status-note --done 실패" for i in $(seq 1 40); do rs status-note.sh --add open "q$i" >/dev/null; done rs status-note.sh --show --json | jq -e '.status=="warning" and .result.over_cap==true' >/dev/null \ - && pass "status-note — 40줄 상한 초과 warning" || fail "status-note — 상한 초과를 ok 로" - grep -q '^## 이번에 바뀐 이름' "$RS/.ax/docs/STATUS.md" && pass "status-note — 4절 헤더 형식 고정" || fail "status-note — 절 헤더 누락" + && pass "status-note — 40개 상한 초과 warning" || fail "status-note — 상한 초과를 ok 로" + jq -e '.handoff | keys == ["next","now","now_at","open","renamed"] and .now_at != null' "$RS/.ax/current-task.json" >/dev/null \ + && jq -e '.phase=="idle"' "$RS/.ax/current-task.json" >/dev/null \ + && pass "status-note — handoff 키 5개 고정 + now_at 스탬프 + 다른 키 보존" \ + || fail "status-note — handoff 형식/스탬프/키 보존 불일치: $(jq -c '{keys:(.handoff|keys?),now_at:.handoff.now_at,phase}' "$RS/.ax/current-task.json" 2>/dev/null)" + # now_at 이 바뀌는 건 --set now 뿐이에요 — --add now 가 갱신하면 Stop 게이트의 24시간 침묵 창이 넓어져요 + BEFORE=$(jq -r .handoff.now_at "$RS/.ax/current-task.json") + rs status-note.sh --add now "x" --json >/dev/null + [ "$(jq -r .handoff.now_at "$RS/.ax/current-task.json")" = "$BEFORE" ] \ + && pass "status-note --add now — now_at 불변 (게이트 침묵 창 불변)" \ + || fail "status-note --add now — now_at 이 바뀜 ($BEFORE → $(jq -r .handoff.now_at "$RS/.ax/current-task.json"))" + rs status-note.sh --done now "x" --json >/dev/null + jq -e --arg b "$BEFORE" '(.handoff.now|length)==2 and .handoff.now_at==$b' "$RS/.ax/current-task.json" >/dev/null \ + && pass "status-note --done now — 남은 줄이 있으면 now_at 유지" \ + || fail "status-note --done now — now/now_at 불일치: $(jq -c '.handoff|{now,now_at}' "$RS/.ax/current-task.json" 2>/dev/null)" + rs status-note.sh --clear now --json >/dev/null + jq -e '.handoff.now==[] and .handoff.now_at==null' "$RS/.ax/current-task.json" >/dev/null \ + && pass "status-note --clear now — now 비움 + now_at null" \ + || fail "status-note --clear now — now/now_at 잔존: $(jq -c '.handoff|{now,now_at}' "$RS/.ax/current-task.json" 2>/dev/null)" # constitution-apply — prepend 보존 · 중복 스캔은 구 본문만 · drop 은 [a] 뒤 · 인덱스 printf '# X — Constitution\n\n## META — 핵심 가드레일\n\n🔴 **`AX:CRITICAL:001`** — 결제 API 는 멱등성 키 필수\n' > "$RS/block.md" @@ -2301,6 +2319,13 @@ else rm -rf "$RS" fi +# writer 보존 — current-task.json 은 skill 다섯 곳이 인라인 jq 로 `.x = …` 갱신해요. 누가 객체를 통째로 +# 재조립하면 (jq -n · echo/printf 리다이렉트) 그 skill 이 모르는 키(handoff 등)가 조용히 사라져요. +# 합법 writer 는 전부 `> ….tmp && mv` 라 "current-task.json 으로 곧장 리다이렉트" 하나만 잡으면 돼요 (산문·`.tmp` 는 비매치) +CTW=$(grep -rnE '>[[:space:]]*"?[^" ]*current-task\.json"?[[:space:]]*(#.*|<<.*)?$' "$REPO/skills" 2>/dev/null || true) +[ -z "$CTW" ] && pass "current-task.json writer — 전부 in-place jq (미지 키 보존)" \ + || fail "current-task.json writer — 통째 재조립 (미지 키 유실): $CTW" + # 엣지 연결 — 폐기된 skill 의 트리거를 누가 받는지, 새 스크립트를 누가 부르는지 파일에 적혀 있어야 해요. grep -q 'spirit-lint.sh' "$REPO/skills/doctor/SKILL.md" && grep -q 'rules-index.sh' "$REPO/skills/doctor/SKILL.md" && grep -q 'doctor-scan.sh' "$REPO/skills/doctor/SKILL.md" \ && pass "doctor — spirit-lint · rules-index · doctor-scan 을 실제로 호출" || fail "doctor — 스크립트 호출 엣지 없음" @@ -2308,13 +2333,13 @@ grep -q 'spirit 점검' "$REPO/skills/doctor/SKILL.md" && grep -q 'rules 보여 && pass "doctor — 폐기된 spirit·rules 트리거를 description 에서 받음" || fail "doctor — spirit/rules 트리거 미인수 (자연어 라우팅 끊김)" grep -q 'rules-index.sh' "$REPO/commands/goax.md" && grep -q 'spirit-lint.sh' "$REPO/commands/goax.md" \ && pass "/goax 인덱스 — rules·spirit 행이 스크립트를 가리킴" || fail "/goax 인덱스 — 폐기 skill 잔재" -grep -q 'status-note.sh --show' "$REPO/skills/triage/SKILL.md" && grep -q 'STATUS.md' "$REPO/skills/triage/SKILL.md" \ - && pass "triage — STATUS.md 를 MEMORY.md 보다 먼저 읽음" || fail "triage — 인계 노트 선독 없음" +grep -q 'status-note.sh --show' "$REPO/skills/triage/SKILL.md" && grep -q 'handoff' "$REPO/skills/triage/SKILL.md" \ + && pass "triage — 인계 노트를 MEMORY.md 보다 먼저 읽음" || fail "triage — 인계 노트 선독 없음" grep -q 'status-note.sh' "$REPO/skills/spec-implement/SKILL.md" && grep -q 'references/lane-mode.md' "$REPO/skills/spec-implement/SKILL.md" \ && pass "spec-implement — halt·완료 시 status-note 갱신 + 레인 루프는 references" || fail "spec-implement — 인계 노트/레인 참조 없음" grep -q 'status-note.sh --add renamed' "$REPO/skills/spec-implement/references/lane-mode.md" \ && pass "lane-mode — 레인 보고의 바뀐 이름을 인계 노트로" || fail "lane-mode — renamed 인계 없음" -grep -q 'status-note.sh' "$REPO/skills/zero/SKILL.md" && pass "zero — STATUS 개설을 스크립트로" || fail "zero — STATUS 를 손으로 씀" +grep -q 'status-note.sh' "$REPO/skills/zero/SKILL.md" && pass "zero — 인계 노트 개설을 스크립트로" || fail "zero — 인계 노트를 손으로 씀" grep -qE '^disallowedTools:.*Write.*Edit' "$REPO/agents/lane-scout.md" \ && pass "lane-scout — disallowedTools 로 편집 금지 (allowlist 아님)" || fail "lane-scout — 편집 금지가 산문뿐" grep -qE '^tools:' "$REPO/agents/lane-scout.md" && fail "lane-scout — tools: allowlist 사용 (한 항목이라도 안 풀리면 에이전트가 안 뜸)" || true @@ -2362,6 +2387,8 @@ else [ -z "$(printf '{"session_id":"s3","stop_hook_active":false}' | hx $STOP)" ] \ && pass "stop 게이트 — 인계 노트에 spec 이 적혀 있으면 통과 (멈추는 게 의도)" || fail "stop 게이트 — 인계 노트를 무시" CLAUDE_PROJECT_DIR="$HX" bash "$HX/.ax/scripts/bash/status-note.sh" --set now "" --json >/dev/null 2>&1 + jq -e '.handoff.now==[] and .handoff.now_at==null' "$HX/.ax/current-task.json" >/dev/null 2>&1 \ + && pass "status-note --set now \"\" — now 비움 + now_at null" || fail "status-note --set now \"\" — now/now_at 잔존: $(jq -c '.handoff|{now,now_at}' "$HX/.ax/current-task.json" 2>/dev/null)" echo '{"phase":"spec","spec_dir":".ax/docs/spec/014-x"}' > "$HX/.ax/current-task.json" [ -z "$(printf '{"session_id":"s4","stop_hook_active":false}' | hx $STOP)" ] \ && pass "stop 게이트 — 계획 단계(phase=spec)엔 안 잡음" || fail "stop 게이트 — 계획 단계를 잡음" @@ -2379,6 +2406,15 @@ else && pass "subagent-start — goax 자기 에이전트는 건너뜀 (이미 spirit 선언)" || fail "subagent-start — 자기 에이전트에도 주입" echo "$O" | jq -e '(.hookSpecificOutput.additionalContext|length) < 600' >/dev/null 2>&1 \ && pass "subagent-start — 경로만 (600자 미만, 본문 주입 아님)" || fail "subagent-start — 본문을 밀어 넣음" + # 인계 노트 포인터 — handoff 에 항목이 있을 때만, 경로만 (명령은 싣지 않아요) + CLAUDE_PROJECT_DIR="$HX" bash "$HX/.ax/scripts/bash/status-note.sh" --add open "q" --json >/dev/null 2>&1 + OH=$(printf '{"session_id":"s1","hook_event_name":"SubagentStart","agent_type":"Explore"}' | hx .ax/hooks/subagent-start/harness-pointer.sh) + echo "$OH" | jq -e '.hookSpecificOutput.additionalContext | test("current-task.json") and test("handoff") and (test("status-note.sh")|not) and length < 600' >/dev/null 2>&1 \ + && pass "subagent-start — handoff 가 있으면 인계 노트 경로 한 줄 (명령 없음 · 600자 미만)" || fail "subagent-start — 인계 노트 포인터 불일치: ${OH:0:160}" + CLAUDE_PROJECT_DIR="$HX" bash "$HX/.ax/scripts/bash/status-note.sh" --done open "q" --json >/dev/null 2>&1 + printf '{"session_id":"s1","hook_event_name":"SubagentStart","agent_type":"Explore"}' | hx .ax/hooks/subagent-start/harness-pointer.sh \ + | jq -e '.hookSpecificOutput.additionalContext | test("인계 노트") | not' >/dev/null 2>&1 \ + && pass "subagent-start — handoff 가 비면 인계 노트 줄 없음" || fail "subagent-start — 빈 handoff 에도 인계 노트 줄" # 주입 중복 제거 J='{"session_id":"d1","tool_input":{"file_path":"'"$HX"'/src/A.kt"}}' @@ -2393,20 +2429,39 @@ else echo "$M1" | jq -e '.hookSpecificOutput.additionalContext|test("Layer 2") and test("Layer 3")' >/dev/null 2>&1 && [ -z "$M2" ] \ && pass "module-rules-inject — 모듈·spec 포인터도 세션당 한 번" || fail "module-rules-inject — 중복 주입: [$M2]" - # doctor-scan — 이벤트 키 · 인계 노트 기한 - printf '## 다음\n- [ ] 2020-01-01 룰 ablation 재검토\n- [ ] 2099-01-01 far\n' > "$HX/.ax/docs/STATUS.md" + # doctor-scan — 이벤트 키 · 인계 노트 기한 · STATUS.md 잔재 + # 기한 줄은 writer 로 심어요 — 형식 보정까지 실사용과 같은 길을 타야 doctor 의 파싱이 실질이에요 + CLAUDE_PROJECT_DIR="$HX" bash "$HX/.ax/scripts/bash/status-note.sh" --add next "- [ ] 2020-01-01 룰 ablation 재검토" --json >/dev/null 2>&1 + CLAUDE_PROJECT_DIR="$HX" bash "$HX/.ax/scripts/bash/status-note.sh" --add next "- [ ] 2099-01-01 far" --json >/dev/null 2>&1 DS=$(GOAX_PROJECT_DIR="$HX" bash "$HX/.ax/scripts/bash/doctor-scan.sh" --json --plugin-dir "$REPO" 2>/dev/null) echo "$DS" | jq -e '(.result.hooks.events.missing|index("Stop"))!=null and (.result.hooks.events.missing|index("SubagentStart"))!=null' >/dev/null 2>&1 \ && pass "doctor-scan — settings.json 에 Stop·SubagentStart 키가 없으면 events.missing" || fail "doctor-scan — 이벤트 키 검사 없음" echo "$DS" | jq -e '.result.handoff.overdue==1 and .result.handoff.imminent==0 and .result.handoff.deadlines[0].status=="overdue"' >/dev/null 2>&1 \ && pass "doctor-scan — 인계 노트 기한 초과 1 · 먼 기한은 ok (I3 규칙)" || fail "doctor-scan — 기한 판정 불일치: $(echo "$DS" | jq -c .result.handoff)" + # 옛 인계 노트 파일이 남아 있으면 잔재로 알려요 — 자동 삭제·import 는 없어요 + mkdir -p "$HX/.ax/docs"; touch "$HX/.ax/docs/STATUS.md" + DS2=$(GOAX_PROJECT_DIR="$HX" bash "$HX/.ax/scripts/bash/doctor-scan.sh" --json --plugin-dir "$REPO" 2>/dev/null) + echo "$DS2" | jq -e --argjson before "$(echo "$DS" | jq '.result.findings // -1')" '.result.migration.stale_status_md==true and .result.findings==$before+1' >/dev/null 2>&1 \ + && pass "doctor-scan — .ax/docs/STATUS.md 잔재 → migration.stale_status_md · findings +1" \ + || fail "doctor-scan — STATUS.md 잔재 미검출: stale=$(echo "$DS2" | jq -r '.result.migration.stale_status_md') findings=$(echo "$DS" | jq -r .result.findings)→$(echo "$DS2" | jq -r .result.findings)" + rm -f "$HX/.ax/docs/STATUS.md" + GOAX_PROJECT_DIR="$HX" bash "$HX/.ax/scripts/bash/doctor-scan.sh" --json --plugin-dir "$REPO" 2>/dev/null | jq -e '.result.migration.stale_status_md==false' >/dev/null 2>&1 \ + && pass "doctor-scan — STATUS.md 없으면 stale_status_md false" || fail "doctor-scan — STATUS.md 없는데 잔재로 봄" # zero-ablation — 회차 기록 + 다음 기한 GOAX_PROJECT_DIR="$HX" bash "$HX/.ax/scripts/bash/zero-ablation.sh" --off --json >/dev/null 2>&1 AB=$(GOAX_PROJECT_DIR="$HX" bash "$HX/.ax/scripts/bash/zero-ablation.sh" --on --json 2>/dev/null) - echo "$AB" | jq -e '.result.last_round!="" and .result.next_due!=""' >/dev/null 2>&1 && grep -q '^- \[ \] 20[0-9][0-9]-[0-9][0-9]-[0-9][0-9] 룰 ablation 재검토' "$HX/.ax/docs/STATUS.md" \ - && ! grep -q '2020-01-01 룰 ablation' "$HX/.ax/docs/STATUS.md" \ - && pass "zero-ablation --on — 회차 기록 + 다음 기한(+180일)을 STATUS.md 체크박스로 (옛 기한은 제거)" || fail "zero-ablation — 회차/기한 기록 실패: $(echo "$AB" | jq -c .result)" + echo "$AB" | jq -e '.result.last_round!="" and .result.next_due!=""' >/dev/null 2>&1 \ + && jq -e '.handoff.next | any(test("^- \\[ \\] 20[0-9]{2}-[0-9]{2}-[0-9]{2} 룰 ablation 재검토"))' "$HX/.ax/current-task.json" >/dev/null 2>&1 \ + && jq -e '.handoff.next | any(test("2020-01-01 룰 ablation")) | not' "$HX/.ax/current-task.json" >/dev/null 2>&1 \ + && pass "zero-ablation --on — 회차 기록 + 다음 기한(+180일)을 인계 노트 체크박스로 (옛 기한은 제거)" || fail "zero-ablation — 회차/기한 기록 실패: $(echo "$AB" | jq -c .result) next=$(jq -c .handoff.next "$HX/.ax/current-task.json" 2>/dev/null)" + + # tier-from-state --reset — task 필드는 비우고 handoff 는 남겨요 (next·open·renamed 는 task 를 넘어 살아요). + # HX 의 phase 는 아직 implementing 이라 idle 판정이 실질이에요 — 이 검사가 §35 의 마지막이어야 해요. + CLAUDE_PROJECT_DIR="$HX" bash "$HX/.ax/scripts/bash/status-note.sh" --add next "살아남기" --json >/dev/null 2>&1 + GOAX_PROJECT_DIR="$HX" bash "$HX/.ax/scripts/bash/tier-from-state.sh" --reset --json >/dev/null 2>&1 + jq -e '.phase=="idle" and (.handoff.next|index("- 살아남기"))!=null' "$HX/.ax/current-task.json" >/dev/null 2>&1 \ + && pass "tier-from-state --reset — handoff 는 지우지 않아요" || fail "tier-from-state --reset — phase/handoff 불일치: $(jq -c '{phase,next:.handoff.next}' "$HX/.ax/current-task.json" 2>/dev/null)" rm -rf "$HX" fi @@ -2421,6 +2476,8 @@ grep -q '"SubagentStart"' "$REPO/templates/default/.claude/settings.json.templat && pass "settings.json.template — SubagentStart · Stop 이벤트 등록" || fail "settings.json.template — 새 이벤트 미등록" grep -q 'events.missing' "$REPO/skills/doctor/SKILL.md" && grep -q 'handoff.deadlines' "$REPO/skills/doctor/SKILL.md" \ && pass "doctor — 이벤트 키 · 인계 노트 기한을 실제로 읽음" || fail "doctor — 새 검사 결과를 안 읽음" +grep -q 'stale_status_md' "$REPO/skills/doctor/SKILL.md" \ + && pass "doctor — STATUS.md 잔재(stale_status_md)를 실제로 읽음" || fail "doctor — 잔재 통지 결과를 안 읽음" grep -q 'goax_inject_fresh' "$REPO/templates/default/.ax/hooks/pre-edit/spirit-rules-inject.sh" && grep -q 'goax_inject_fresh' "$REPO/templates/default/.ax/hooks/pre-edit/module-rules-inject.sh" \ && pass "주입 훅 둘 다 goax_inject_fresh 로 세션 dedupe" || fail "주입 훅 dedupe 누락" @@ -2863,12 +2920,12 @@ else echo '{"phase":"implementing","spec_dir":".ax/docs/spec/014-x","size":"M","risk":"L1"}' > "$ST/.ax/current-task.json" echo '{"hooks":{}}' > "$ST/.claude/settings.json" CLAUDE_PROJECT_DIR="$ST" bash "$ST/.ax/scripts/bash/status-note.sh" --set now "spec 014-x 에서 멈춤 — T001" --json >/dev/null 2>&1 - grep -qE '\([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}Z\)$' "$ST/.ax/docs/STATUS.md" \ - && pass "status-note --set now — 마지막 줄에 (YYYY-MM-DDTHH:MMZ) 시각" || fail "status-note --set now — 시각 없음 (기한 판정 불가)" + jq -r '.handoff.now_at' "$ST/.ax/current-task.json" 2>/dev/null | grep -qE '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}Z$' \ + && pass "status-note --set now — now_at 에 YYYY-MM-DDTHH:MMZ" || fail "status-note --set now — now_at 없음 (기한 판정 불가): $(jq -c '.handoff.now_at' "$ST/.ax/current-task.json" 2>/dev/null)" [ -z "$(printf '{"session_id":"f1","stop_hook_active":false}' | CLAUDE_PROJECT_DIR="$ST" bash "$ST/.ax/hooks/stop/spec-gate.sh" 2>/dev/null)" ] \ && pass "stop 게이트 — 방금 쓴 인계 노트는 인정 (통과)" || fail "stop 게이트 — 신선한 노트를 무시" # 시각만 과거로 바꿔요 — 노트 내용은 그대로인데 24시간이 지났어요 - sed 's/([0-9-]*T[0-9:]*Z)$/(2020-01-01T00:00Z)/' "$ST/.ax/docs/STATUS.md" > "$ST/status.tmp" && mv "$ST/status.tmp" "$ST/.ax/docs/STATUS.md" + jq '.handoff.now_at="2020-01-01T00:00Z"' "$ST/.ax/current-task.json" > "$ST/ct.tmp" && mv "$ST/ct.tmp" "$ST/.ax/current-task.json" printf '{"session_id":"f2","stop_hook_active":false}' | CLAUDE_PROJECT_DIR="$ST" bash "$ST/.ax/hooks/stop/spec-gate.sh" 2>/dev/null \ | jq -e '.decision=="block"' >/dev/null 2>&1 \ && pass "stop 게이트 — 24시간 지난 인계 노트는 불인정 (영구 통과증 아님)" || fail "stop 게이트 — 오래된 노트로 영구 통과" @@ -3200,7 +3257,7 @@ fi # ─────────────────────────────────────────────────────────── section "43. 파일 쓰기 락 — 같은 파일을 두 프로세스가 쓸 때" # ─────────────────────────────────────────────────────────── -# §36 이 원장(tasks.md·state.json)을 봤다면 여기는 나머지 넷이에요 — STATUS.md · +# §36 이 원장(tasks.md·state.json)을 봤다면 여기는 나머지 넷이에요 — current-task.json(handoff) · # .claude/settings.json · AGENTS.md · .ax/config.yml. 락 창이 `mv` 가 아니라 **"바꿀지 정하는 # 첫 읽기"** 부터여야 멱등 프로브가 보호돼요. 실측(수정 전): zero-init 5개 동시 실행이 같은 # 훅을 3번 등록했고, status-note --add 동시 2개는 10회 중 10회 한쪽을 잃었어요. @@ -3212,7 +3269,7 @@ if ! command -v jq >/dev/null 2>&1; then else LK=$(mktemp -d) mkdir -p "$LK/.ax/scripts/bash" "$LK/.ax/docs" "$LK/.claude" - cp "$REPO/templates/default/.ax/scripts/bash/"{common,status-note,register-spirit-hook,build-memory,zero-init,constitution-apply,zero-domain-risk}.sh "$LK/.ax/scripts/bash/" + cp "$REPO/templates/default/.ax/scripts/bash/"{common,status-note,tier-from-state,register-spirit-hook,build-memory,zero-init,constitution-apply,zero-domain-risk}.sh "$LK/.ax/scripts/bash/" LKB="$LK/.ax/scripts/bash" # 최소 대상 — 출고 AGENTS.md 는 이미 `## 4계층 인덱스` 와 AX 토큰을 담고 있어서 # prepend·index 두 모드가 전부 skip(exit 2) 로 빠져요. 경합을 재려면 둘 다 없어야 해요. @@ -3220,7 +3277,8 @@ else printf '{"hooks":{}}\n' > "$LK/.claude/settings.json" printf '# 픽스처 — Constitution\n\n## 개요\n\n회귀용 최소 대상이에요.\n' > "$LK/AGENTS.md" printf 'default_risk: L1\ndomain_risk:\n payment: L3\n search: L1\n billing: L2\n auth: L3\n' > "$LK/.ax/config.yml" - rm -rf "$LK/.ax/docs/STATUS.md" "$LK"/*.lock "$LK"/.ax/*.lock "$LK"/.claude/*.lock "$LK"/.ax/docs/*.lock + echo '{"phase":"idle"}' > "$LK/.ax/current-task.json" + rm -rf "$LK"/*.lock "$LK"/.ax/*.lock "$LK"/.claude/*.lock "$LK"/.ax/docs/*.lock } printf '## FIXTURE — 회귀용 가드레일\n\n🔴 **`AX:CRITICAL:901`** — 시크릿을 커밋하지 않아요.\n' > "$LK/block.md" printf '## 4계층 인덱스\n\n- Layer 1 — Constitution\n' > "$LK/.ax/AGENTS.md.suggested" @@ -3264,9 +3322,10 @@ else for i in 1 2 3 4 5; do ( lkrun status-note.sh --init --json >/dev/null 2>&1; echo $? > "$LK/rc.$i" ) & done wait for i in 1 2 3 4 5; do rc4="$rc4$(cat "$LK/rc.$i")"; done - [ "$(grep -c '^## 다음' "$LK/.ax/docs/STATUS.md")" -eq 1 ] && [ "$rc4" = "00000" ] \ - && pass "status-note --init ×5 동시 — 절 헤더가 한 번씩만 (전부 exit 0)" \ - || fail "status-note --init ×5 동시 — '## 다음' $(grep -c '^## 다음' "$LK/.ax/docs/STATUS.md")회 / rc=$rc4" + jq -e '.handoff | keys == ["next","now","now_at","open","renamed"]' "$LK/.ax/current-task.json" >/dev/null 2>&1 \ + && jq -e '.phase=="idle"' "$LK/.ax/current-task.json" >/dev/null 2>&1 && [ "$rc4" = "00000" ] \ + && pass "status-note --init ×5 동시 — handoff 키 5개 정확히 · phase 보존 (전부 exit 0)" \ + || fail "status-note --init ×5 동시 — $(jq -c '{keys:(.handoff|keys?),phase}' "$LK/.ax/current-task.json" 2>/dev/null) / rc=$rc4" # 2) 교차 실행 — 락 경로가 갈리면 여기서 빨개져요 lost=0 @@ -3275,7 +3334,7 @@ else ( lkrun status-note.sh --add next "항목A" --json >/dev/null 2>&1 ) & ( lkrun status-note.sh --add next "항목B" --json >/dev/null 2>&1 ) & wait - grep -q '항목A' "$LK/.ax/docs/STATUS.md" && grep -q '항목B' "$LK/.ax/docs/STATUS.md" || lost=$((lost+1)) + jq -e '.handoff.next | (index("- 항목A")!=null and index("- 항목B")!=null)' "$LK/.ax/current-task.json" >/dev/null 2>&1 || lost=$((lost+1)) done [ "$lost" -eq 0 ] && pass "status-note --add 동시 10회 — 항목 유실 0 (수정 전 10/10 유실)" \ || fail "status-note --add 동시 10회 — ${lost}회 유실" @@ -3293,6 +3352,19 @@ else [ "$both" -eq 5 ] && pass "register-spirit-hook ‖ zero-init 5회 — 두 훅 다 생존 (락 경로 문자열 동일)" \ || fail "register-spirit-hook ‖ zero-init — $both/5회만 둘 다 생존" + # current-task.json 을 쓰는 두 스크립트 — status-note 의 handoff 추가와 tier-from-state 의 task 필드 리셋이 + # 같은 락을 잡아야 해요. seed 는 implementing — idle 로 심으면 phase 판정이 공허해요. + ct_both=0 + for i in 1 2 3 4 5; do + seed_lk; echo '{"phase":"implementing"}' > "$LK/.ax/current-task.json" + ( lkrun status-note.sh --add next "항목$i" --json >/dev/null 2>&1 ) & + ( lkrun tier-from-state.sh --reset --json >/dev/null 2>&1 ) & + wait + jq -e --arg i "- 항목$i" '(.handoff.next|index($i))!=null and .phase=="idle"' "$LK/.ax/current-task.json" >/dev/null 2>&1 && ct_both=$((ct_both+1)) + done + [ "$ct_both" -eq 5 ] && pass "status-note --add ‖ tier-from-state --reset 5회 — 둘 다 생존 (락 경로 문자열 동일)" \ + || fail "status-note --add ‖ tier-from-state --reset — $ct_both/5회만 둘 다 생존" + survive=0 for i in 1 2 3 4 5; do seed_lk @@ -3335,14 +3407,14 @@ else lkrun zero-init.sh --plugin-dir "$REPO" --dry-run --json >/dev/null 2>&1 lkrun constitution-apply.sh --block "$LK/block.md" --target AGENTS.md --dry-run --json >/dev/null 2>&1 lkrun zero-domain-risk.sh --set "payment=L3" --dry-run --json >/dev/null 2>&1 - [ "$(find "$LK" -name '*.lock' | wc -l | tr -d ' ')" -eq 0 ] && [ ! -f "$LK/.ax/docs/STATUS.md" ] \ - && pass "--dry-run 7종 — .lock 을 안 만들고 STATUS.md 도 안 만듦" \ - || fail "--dry-run — .lock $(find "$LK" -name '*.lock' | wc -l | tr -d ' ')개 / STATUS.md $([ -f "$LK/.ax/docs/STATUS.md" ] && echo 생성됨 || echo 없음)" + [ "$(find "$LK" -name '*.lock' | wc -l | tr -d ' ')" -eq 0 ] && jq -e 'has("handoff")|not' "$LK/.ax/current-task.json" >/dev/null 2>&1 \ + && pass "--dry-run 7종 — .lock 을 안 만들고 handoff 도 안 만듦" \ + || fail "--dry-run — .lock $(find "$LK" -name '*.lock' | wc -l | tr -d ' ')개 / handoff $(jq -r 'has("handoff")' "$LK/.ax/current-task.json" 2>/dev/null)" # 4) 계약 — 이미 잡힌 락 앞에서는 exit 1 + {"status":"error"} (기본 10s 를 기다리지 않게 =1) seed_lk lkrun status-note.sh --init --json >/dev/null 2>&1 - mkdir -p "$LK/.ax/docs/STATUS.md.lock" "$LK/.claude/settings.json.lock" "$LK/.ax/MEMORY.md.lock" \ + mkdir -p "$LK/.ax/current-task.json.lock" "$LK/.claude/settings.json.lock" "$LK/.ax/MEMORY.md.lock" \ "$LK/AGENTS.md.lock" "$LK/.ax/config.yml.lock" lock_bad=0 check_locked() { # check_locked <라벨> <스크립트> [인자…] @@ -3360,7 +3432,8 @@ else check_locked zero-init zero-init.sh --plugin-dir "$REPO" --json check_locked constitution-apply constitution-apply.sh --block "$LK/block.md" --target AGENTS.md --force --json check_locked zero-domain-risk zero-domain-risk.sh --set "payment=L2" --json - [ "$lock_bad" -eq 0 ] && pass "락 대기 초과 6종 — exit 1 + {\"status\":\"error\"} 봉투 (GOAX_LOCK_TIMEOUT=1)" + check_locked tier-from-state tier-from-state.sh --reset --json + [ "$lock_bad" -eq 0 ] && pass "락 대기 초과 7종 — exit 1 + {\"status\":\"error\"} 봉투 (GOAX_LOCK_TIMEOUT=1)" # 손으로 만든 락엔 pid 파일이 없고 stale 문턱에 닿기 전에 타임아웃 나서, 뺏기지 않아요 [ "$(find "$LK" -name '*.lock' -type d | wc -l | tr -d ' ')" -eq 5 ] \ && pass "락 대기 초과 — 남의 락을 뺏지 않음 (5개 그대로)" \ From 3d33e4a0344c240ca04c6d27b1520e1dca41feba Mon Sep 17 00:00:00 2001 From: bluecheat Date: Fri, 11 Sep 2026 22:58:45 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(review):=200.5.11=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20=E2=80=94=20changelog=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EB=9D=BC=EB=B2=A8=20=C2=B7=20writer=20=EA=B0=80?= =?UTF-8?q?=EB=93=9C=20=EB=B2=94=EC=9C=84=20=C2=B7=20--help=20=ED=81=B4?= =?UTF-8?q?=EB=A6=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - changelog/0.5.11.md: "A 의 정직한 비용" 의 "A" 는 플래닝 세션의 옵션 라벨이라 독자에게 정의가 없었어요 — "current-task.json 에 두는 것의 정직한 비용" 으로. - tests/smoke.sh: current-task.json writer in-place 가드가 skills/ 만 훑었어요 — agents/ · templates/default/.ax/scripts/bash/ 까지. 후속 update-task.sh 같은 writer 스크립트가 자동으로 계약 아래 들어와요. - tier-from-state.sh --help: 헤더 주석이 27행까지인데 2,23p 로 잘라 Output 4줄이 안 나왔어요 (기존 결함). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LDjdeZ7Tjc5fhXGDpopXC6 --- changelog/0.5.11.md | 2 +- templates/default/.ax/scripts/bash/tier-from-state.sh | 2 +- tests/smoke.sh | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/changelog/0.5.11.md b/changelog/0.5.11.md index 8a29caa..12a268f 100644 --- a/changelog/0.5.11.md +++ b/changelog/0.5.11.md @@ -24,7 +24,7 @@ ## 왜 이 자리인가 -**A 의 정직한 비용 (PR 본문에 그대로 실어요).** (1) `current-task.json` 을 쓰는 코드가 하나 늘어 **같은 파일 동시 쓰기 노출**이 생겨요 — 스크립트 writer 둘은 같은 락으로 직렬화하지만 SKILL.md 인라인 jq 5곳은 무락이에요 (두 세션이 같은 리포를 동시에 쓸 때만 문제, 인라인 jq 끼리는 오늘도 같은 노출). (2) writer 가 객체를 통째로 재조립하면 `handoff` 가 조용히 사라져요 — 그래서 smoke 에 "in-place jq 만" 구조 가드를 계약으로 넣어요. (3) `current-task.json` 이 **task 를 넘어 사는 하위 객체**를 하나 갖게 돼요 — `reset-task.sh` 가 `handoff` 를 남기는 걸 문서(`CLAUDE.md` · `skill-routing.md` · `reset-task.sh` 헤더)와 smoke 로 고정해요. 이 셋을 감수하는 이유는 파일 수를 늘리지 않는 것이 이번 과제의 목적 그 자체이기 때문이에요. +**`current-task.json` 에 두는 것의 정직한 비용.** (1) `current-task.json` 을 쓰는 코드가 하나 늘어 **같은 파일 동시 쓰기 노출**이 생겨요 — 스크립트 writer 둘은 같은 락으로 직렬화하지만 SKILL.md 인라인 jq 5곳은 무락이에요 (두 세션이 같은 리포를 동시에 쓸 때만 문제, 인라인 jq 끼리는 오늘도 같은 노출). (2) writer 가 객체를 통째로 재조립하면 `handoff` 가 조용히 사라져요 — 그래서 smoke 에 "in-place jq 만" 구조 가드를 계약으로 넣어요. (3) `current-task.json` 이 **task 를 넘어 사는 하위 객체**를 하나 갖게 돼요 — `reset-task.sh` 가 `handoff` 를 남기는 걸 문서(`CLAUDE.md` · `skill-routing.md` · `reset-task.sh` 헤더)와 smoke 로 고정해요. 이 셋을 감수하는 이유는 파일 수를 늘리지 않는 것이 이번 과제의 목적 그 자체이기 때문이에요. **MEMORY.md 를 그대로 두는 이유.** STATUS.md 의 문제는 "고유 정보가 상태 파일이 아닌 곳에, gitignore 도 없이" 있었던 거예요. MEMORY.md 는 셋 다 해당하지 않아요 — 고유 정보가 없고, 재생성이고, gitignore 돼 있어요. `state.json.hud` 가 `update-state.sh` 의 렌더 캐시인 것과 같은 지위예요. 이번 결정에 필요한 변경은 **인계 노트 포인터 한 줄을 `handoff` 기준으로 바꾸는 것**뿐이라 그것만 해요. diff --git a/templates/default/.ax/scripts/bash/tier-from-state.sh b/templates/default/.ax/scripts/bash/tier-from-state.sh index 8f95490..0049aeb 100755 --- a/templates/default/.ax/scripts/bash/tier-from-state.sh +++ b/templates/default/.ax/scripts/bash/tier-from-state.sh @@ -51,7 +51,7 @@ while [ $# -gt 0 ]; do done if [ "$SHOW_HELP" = true ]; then - sed -n '2,23p' "${BASH_SOURCE[0]}" | sed 's/^# //' + sed -n '2,27p' "${BASH_SOURCE[0]}" | sed 's/^# //' exit "$EXIT_OK" fi diff --git a/tests/smoke.sh b/tests/smoke.sh index b194b88..6cc9ebb 100644 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -2321,8 +2321,9 @@ fi # writer 보존 — current-task.json 은 skill 다섯 곳이 인라인 jq 로 `.x = …` 갱신해요. 누가 객체를 통째로 # 재조립하면 (jq -n · echo/printf 리다이렉트) 그 skill 이 모르는 키(handoff 등)가 조용히 사라져요. -# 합법 writer 는 전부 `> ….tmp && mv` 라 "current-task.json 으로 곧장 리다이렉트" 하나만 잡으면 돼요 (산문·`.tmp` 는 비매치) -CTW=$(grep -rnE '>[[:space:]]*"?[^" ]*current-task\.json"?[[:space:]]*(#.*|<<.*)?$' "$REPO/skills" 2>/dev/null || true) +# 합법 writer 는 전부 `> ….tmp && mv` 라 "current-task.json 으로 곧장 리다이렉트" 하나만 잡으면 돼요 (산문·`.tmp` 는 비매치). +# skills 만이 아니라 agents · 출고 스크립트도 같은 계약이에요 — 나중에 생길 writer 스크립트가 자동으로 여기 걸려요. +CTW=$(grep -rnE '>[[:space:]]*"?[^" ]*current-task\.json"?[[:space:]]*(#.*|<<.*)?$' "$REPO/skills" "$REPO/agents" "$REPO/templates/default/.ax/scripts/bash" 2>/dev/null || true) [ -z "$CTW" ] && pass "current-task.json writer — 전부 in-place jq (미지 키 보존)" \ || fail "current-task.json writer — 통째 재조립 (미지 키 유실): $CTW"