From 0c1de4193002934f08f9209280fd52b003d35dee Mon Sep 17 00:00:00 2001 From: chodaict Date: Fri, 5 Jun 2026 22:07:22 +0900 Subject: [PATCH] =?UTF-8?q?Release=20v0.5.12=20=E2=80=94=20state=20schema?= =?UTF-8?q?=20versioning=20(clears=20the=20world-class=20punch-list)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything under $CLIKAE_HOME/ was un-versioned: the moment any on-disk format needs a new field there'd be no way to tell old from new and no migration path (dim ⑤, the portfolio-wide weak dimension). This is the minimum fix. - lib/core/state_version.sh: a $CLIKAE_HOME/version integer (CLIKAE_STATE_VERSION — the STATE schema version, bumped only on a format change, not per release) + a forward-migration runner. state_version_check on startup migrates n→n+1 via _state_migrate_ hooks when the on-disk version is older, warns (not downgrades) when newer, no-ops when current. A missing file = the original un-versioned layout = v1 (migrates cleanly to a future v2). - Read-only-preserving: stamped when state is CREATED (ensure_profile --create → state_version_ensure), so steady-state read commands never write it. The "bare clikae changes nothing on disk" guarantee still holds (test-verified). - dry_store's line format documented as governed by the schema version (evolve via a migration, not by parsing two shapes). - Deliberately 克制: one version file + one runner, no framework. tests/bats/state-version.bats: 8 cases (stamp on init, no-file=v1, migrate older, migrate-from-unversioned, warn-on-newer, no-op-when-current, no-op-no-dir). bats -r tests = 345/345; shellcheck -S warning = 0. With this, clikae's quality punch-list (docs/HANDOFF-world-class-gaps.md) is empty. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 13 ++++++ README.md | 3 +- bin/clikae | 8 +++- docs/HANDOFF-world-class-gaps.md | 25 ++++++++--- lib/core/dry_store.sh | 4 ++ lib/core/profile_store.sh | 4 ++ lib/core/state_version.sh | 62 ++++++++++++++++++++++++++ tests/bats/state-version.bats | 74 ++++++++++++++++++++++++++++++++ 8 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 lib/core/state_version.sh create mode 100644 tests/bats/state-version.bats diff --git a/CHANGELOG.md b/CHANGELOG.md index 740c7d5..5ead330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.12] — 2026-06-05 + +### Added + +- **State schema versioning.** Everything under `$CLIKAE_HOME/` now carries a + `version` marker, so a future change to an on-disk format is safe: clikae reads it + on startup and runs a forward migration if an older clikae last wrote your state + (and warns, rather than downgrading, if a *newer* one did). Deliberately minimal — + one version file + one migration runner, no framework. It's stamped when state is + created, so read commands stay read-only; a pre-existing install with no marker is + treated as the original layout and migrates cleanly when needed. (Invisible in + normal use — this is groundwork for safe format changes later.) + ## [0.5.11] — 2026-06-05 ### Fixed diff --git a/README.md b/README.md index 27c19bf..bcebdad 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ > *"Kirikae" (切り替え, ki-ri-ka-e) is Japanese for "switching".* [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Status](https://img.shields.io/badge/status-v0.5.11-blue.svg)](CHANGELOG.md) +[![Status](https://img.shields.io/badge/status-v0.5.12-blue.svg)](CHANGELOG.md) > ⚠️ **Unofficial.** `clikae` is a community tool. It is not affiliated with, endorsed by, or sponsored by any of the CLI vendors it integrates with. "Claude" is a trademark of Anthropic, PBC; other CLI names are trademarks of their respective owners. @@ -108,6 +108,7 @@ clikae # your home board (run `clikae doctor` for a h - **v0.5.5** — **Antigravity / agy becomes real multi-account** (each tank carries its own Google login via the macOS Keychain); **codex sessions join the home board's Continue list** (true cross-engine resume); **`clikae burn`** runs a headless task on a tank and re-fires it on the next when one runs dry (verified by artifact, not exit code); and a **cross-shell in-use guard** so `rename`/`migrate`/`remove` won't move a tank a session in another terminal is still using. - **v0.5.6** — hardened that in-use guard to be truly best-effort: a restricted `ps` (CI runners, locked-down hosts) no longer aborts `rename`/`migrate`/`remove`. - **v0.5.7** — the board shows only **burnable fuel tanks** (tool-CLI tanks live in `clikae tanks`); **`clikae app --board`** makes a launcher for the menu, not one tank; **Ghostty launchers** use a trusted config file (no "Allow Ghostty to execute…" dialog) and are re-signed for Apple Silicon; and switching to a tank whose CLI isn't installed gives a **helpful install hint** instead of `exec: … not found`. +- **v0.5.12** — **state schema versioning**: `$CLIKAE_HOME/` now carries a `version` marker + a forward-migration runner, so future on-disk format changes are safe (read-only-preserving, invisible in normal use). With this, clikae's quality punch-list is empty. - **v0.5.11** — reliability + honesty polish: `clikae watch` starts dependably; `clikae to codex` says "fresh start" instead of promising a resume it can't do; auto-reroute won't dead-end on agy; `clikae tanks` shows an agy tank's real account; and a new **["is this a bug?" Expectations guide](docs/EXPECTATIONS.md)** for behaviours that look surprising but are deliberate. Plus doc corrections (the board's language key is `l`, not `h`). - **v0.5.10** — **`burn` won't spend the quota you're using.** Its auto-reroute now skips a tank an interactive session is live on (`--allow-active` to override) and skips tanks that share an already-dry account — closing the original "燒爆" footgun (a headless job rerouting onto your live conversation's tank). Plus a clearer agy burn-refusal + a `clikae tanks` footnote that agy is interactive-only. - **v0.5.9** — **a quiet "✨ update available" notice** on the board (codex-style: update now / skip / skip-this-version; auto-detects `brew` vs `curl`, throttled + opt-out via `CLIKAE_NO_UPDATE_CHECK`); **carry a session to another tank even when it's not dry** (a third Continue choice — a deliberate account switch that keeps the conversation); a **"· seen HH:MM" tag** on codex/agy reset times so a snapshot reset reads honestly (codex reports UTC headless); and `burn --timeout` now **discloses** it needs coreutils (plus the two world-class P1 fixes — see [docs/HANDOFF-world-class-gaps.md](docs/HANDOFF-world-class-gaps.md)). diff --git a/bin/clikae b/bin/clikae index 858c6ec..7077b8e 100755 --- a/bin/clikae +++ b/bin/clikae @@ -5,7 +5,7 @@ set -eo pipefail -CLIKAE_VERSION="0.5.11" +CLIKAE_VERSION="0.5.12" # Resolve install root (handles symlink chains, e.g., via Homebrew) __resolve_self() { @@ -58,6 +58,12 @@ source "$CLIKAE_LIB/core/history.sh" source "$CLIKAE_LIB/core/autonomy.sh" # shellcheck source=../lib/core/proc.sh source "$CLIKAE_LIB/core/proc.sh" +# shellcheck source=../lib/core/state_version.sh +source "$CLIKAE_LIB/core/state_version.sh" + +# Migrate the on-disk state forward if an older clikae last wrote it (no-op when the +# state is current or absent; read-only safe — see lib/core/state_version.sh). +state_version_check # Subcommand dispatch (see docs/grammar.md §4). Resolve the first argument: # 1. a reserved command (or alias) -> run it directly; diff --git a/docs/HANDOFF-world-class-gaps.md b/docs/HANDOFF-world-class-gaps.md index ec6d3d4..6e2b9d3 100644 --- a/docs/HANDOFF-world-class-gaps.md +++ b/docs/HANDOFF-world-class-gaps.md @@ -49,7 +49,20 @@ covers what's left to make clikae pass the bar. non-zero with the disambiguation message listing both candidates, and does NOT switch. The historical "burned the wrong account" neighbourhood is now guarded. -### P2 — state files have no schema version / migration (dim ⑤, portfolio-wide weak) +### ✅ P2 — state files have no schema version / migration — DONE (v0.5.12) + +> **Resolved.** `lib/core/state_version.sh`: a `$CLIKAE_HOME/version` integer +> (`CLIKAE_STATE_VERSION`, the STATE schema version — bumped only on a format change, +> not per release) + a forward-migration runner (`state_version_check` on startup; +> `_state_migrate_` hooks run n→n+1). Stamped when state is created +> (`ensure_profile --create` → `state_version_ensure`), so read commands stay +> read-only (the "bare clikae changes nothing on disk" guarantee holds — verified by +> test). A missing version file = the original un-versioned layout = v1 (migrates +> cleanly to a future v2). A newer-than-binary version warns instead of downgrading. +> Tests in `tests/bats/state-version.bats`. Kept 克制 — one file + one runner, no +> framework. **clikae now clears the 世界第一讚 bar** (the original writeup follows). + +### (original) P2 — state files have no schema version / migration (dim ⑤, portfolio-wide weak) - **Where:** everything under `$CLIKAE_HOME/` — `profiles/`, `order`, `dry//` (`lib/core/dry_store.sh:38`, format `\t`), `autonomy`, `auto-relay-consent`, `cache/weekly/`. All are bare files with no version marker. @@ -69,8 +82,10 @@ covers what's left to make clikae pass the bar. - Full audit + 7-dim scorecard + reusable self-check method: Obsidian vault → `Notes/世界第一讚自檢(bleedblend · clikae).md` and the standard itself → `Notes/世界第一讚(CVER 品質標準).md`. -- clikae's strongest dims today are ⑥ restraint and ② root-cause; the weak ones are - ⑤ (above) and the P1 test-coverage gaps. **The two P1s are now fixed (v0.5.9); P2 - (state schema versioning) is the one item left** to clear the bar. +- clikae's strongest dims today are ⑥ restraint and ② root-cause; the weak ones WERE + ⑤ (state versioning) and the P1 test-coverage gaps. **All cleared: the two P1s in + v0.5.9, P2 in v0.5.12. The punch-list is empty — clikae clears the 世界第一讚 bar.** + (A separate "implementation vs expectation" audit on 2026-06-05 also fixed a + shipped `clikae watch` crash + doc drift in v0.5.11; see that CHANGELOG.) -— left by Claude a, 2026-06-05. P1s closed in v0.5.9 (2026-06-05). +— left by Claude a, 2026-06-05. P1s closed v0.5.9; expectation audit v0.5.11; P2 closed v0.5.12. diff --git a/lib/core/dry_store.sh b/lib/core/dry_store.sh index 8cca012..b8868bb 100644 --- a/lib/core/dry_store.sh +++ b/lib/core/dry_store.sh @@ -29,6 +29,10 @@ dry_store_path() { printf '%s/dry/%s/%s\n' "$CLIKAE_HOME" "$1" "$2"; } # dry_store_mark [reset_phrase] -> record that this tank is dry as # of NOW, carrying the vendor's verbatim reset phrase (may be empty). One line: # "\t". A write failure is non-fatal. +# +# Format note: this line layout is part of the $CLIKAE_HOME state schema (see +# lib/core/state_version.sh / CLIKAE_STATE_VERSION). If it ever needs a new field, +# bump the schema version and add a migration rather than parsing both shapes here. dry_store_mark() { local engine="$1" tank="$2" reset="${3:-}" f now f="$(dry_store_path "$engine" "$tank")" diff --git a/lib/core/profile_store.sh b/lib/core/profile_store.sh index b5dcdde..a0c7d47 100644 --- a/lib/core/profile_store.sh +++ b/lib/core/profile_store.sh @@ -207,6 +207,10 @@ ensure_profile() { case "$mode" in --create) mkdir -p "$d" + # Stamp the state-schema version alongside the first state we create, so an + # existing install is always identifiable for future migrations (read commands + # then never need to write it). Guarded — older callers may not have it sourced. + declare -F state_version_ensure >/dev/null 2>&1 && state_version_ensure ;; --require) [ -d "$d" ] || log_fail "Profile not found: $cli/$profile (expected at $d)" diff --git a/lib/core/state_version.sh b/lib/core/state_version.sh new file mode 100644 index 0000000..fdff8fd --- /dev/null +++ b/lib/core/state_version.sh @@ -0,0 +1,62 @@ +# shellcheck shell=bash +# lib/core/state_version.sh — a schema version for everything under $CLIKAE_HOME. +# +# The state dir (profiles/, order, dry/, autonomy, cache/, …) was un-versioned, so +# the moment any on-disk FORMAT needs a new field there'd be no way to tell old from +# new and no migration path. This is the minimum fix: one `$CLIKAE_HOME/version` +# integer + one forward-migration runner. Deliberately restrained — NOT a migration +# framework; just enough that a future format change is safe. +# +# The version is the STATE SCHEMA version, NOT the clikae binary version — it bumps +# only when an on-disk format changes (rare), so most releases don't touch it. +# +# Read-only safe: a steady-state read command never writes here. The version file is +# stamped when state is CREATED (ensure_profile --create → state_version_ensure), and +# state_version_check only writes when it actually runs a migration (a one-time event +# the first time you run a clikae new enough to need it). A missing version file means +# "the original, pre-versioning layout" = v1, handled without any write. + +# Bump ONLY when a state format changes, and add a `_state_migrate_` (n→n+1) below. +CLIKAE_STATE_VERSION=1 + +_state_version_file() { printf '%s/version' "$CLIKAE_HOME"; } + +# state_version_read -> the on-disk state schema version as an integer. A missing or +# unparseable file means the original un-versioned layout, which IS v1 — so we never +# treat "no file" as v0 (there was never a v0 to migrate from). +state_version_read() { + local f v=""; f="$(_state_version_file)" + [ -f "$f" ] && v="$(tr -dc '0-9' < "$f" 2>/dev/null)" + [ -n "$v" ] || v=1 + printf '%s' "$v" +} + +# state_version_ensure -> stamp the CURRENT schema version. Called when state is +# created/migrated (a write is happening anyway), so read commands stay read-only. +state_version_ensure() { + [ -d "$CLIKAE_HOME" ] || mkdir -p "$CLIKAE_HOME" 2>/dev/null || return 0 + printf '%s\n' "$CLIKAE_STATE_VERSION" > "$(_state_version_file)" 2>/dev/null || true +} + +# state_version_check -> on startup: if the on-disk state is OLDER than this binary +# expects, run the forward migrations and re-stamp; if NEWER, warn (you're running an +# older clikae than last wrote your state); if equal, do nothing (no write). Migrations +# are `_state_migrate_` (migrate state from version n to n+1). No-op when there's no +# state dir yet. +state_version_check() { + [ -d "$CLIKAE_HOME" ] || return 0 + local cur; cur="$(state_version_read)" + if [ "$cur" -gt "$CLIKAE_STATE_VERSION" ]; then + log_warn "Your ~/.clikae was last written by a newer clikae (state v$cur > this binary's v$CLIKAE_STATE_VERSION). Upgrade clikae, or proceed with care." + return 0 + fi + [ "$cur" -lt "$CLIKAE_STATE_VERSION" ] || return 0 # current → nothing to do, no write + local n + for ((n = cur; n < CLIKAE_STATE_VERSION; n++)); do + if declare -F "_state_migrate_$n" >/dev/null 2>&1; then + "_state_migrate_$n" || { log_warn "clikae: state migration v$n→v$((n + 1)) failed — left as-is."; return 0; } + log_dim "clikae: migrated state v$n → v$((n + 1))." + fi + done + state_version_ensure +} diff --git a/tests/bats/state-version.bats b/tests/bats/state-version.bats new file mode 100644 index 0000000..f840cd4 --- /dev/null +++ b/tests/bats/state-version.bats @@ -0,0 +1,74 @@ +#!/usr/bin/env bats +# tests/bats/state-version.bats — the $CLIKAE_HOME state-schema version + forward +# migration runner (lib/core/state_version.sh). The minimum that makes a future +# on-disk format change safe. (`[[ … ]]` carry `|| false`; see tests/README.md.) + +load '../helpers' + +_src() { + export CLIKAE_LIB="$CLIKAE_TEST_ROOT/lib" + # shellcheck source=/dev/null + . "$CLIKAE_TEST_ROOT/lib/core/log.sh" + . "$CLIKAE_TEST_ROOT/lib/core/state_version.sh" +} + +@test "init stamps the state schema version" { + clikae init claude work + [ -f "$CLIKAE_HOME/version" ] + run cat "$CLIKAE_HOME/version" + [ "$output" = "1" ] +} + +@test "state_version_read: no file = the original un-versioned layout = v1" { + _src; mkdir -p "$CLIKAE_HOME" + run state_version_read + [ "$output" = "1" ] +} + +@test "state_version_read: reads the stamped integer" { + _src; mkdir -p "$CLIKAE_HOME"; printf '3\n' > "$CLIKAE_HOME/version" + run state_version_read + [ "$output" = "3" ] +} + +@test "state_version_check: current version is a no-op (writes nothing)" { + _src; mkdir -p "$CLIKAE_HOME"; printf '1\n' > "$CLIKAE_HOME/version" + local before; before="$(find "$CLIKAE_HOME" | sort; echo --; cat "$CLIKAE_HOME/version")" + state_version_check + local after; after="$(find "$CLIKAE_HOME" | sort; echo --; cat "$CLIKAE_HOME/version")" + [ "$before" = "$after" ] +} + +@test "state_version_check: an OLDER on-disk version runs the migration and re-stamps" { + _src; mkdir -p "$CLIKAE_HOME"; printf '1\n' > "$CLIKAE_HOME/version" + CLIKAE_STATE_VERSION=2 + _state_migrate_1() { touch "$CLIKAE_HOME/.migrated_1"; } + state_version_check + [ -f "$CLIKAE_HOME/.migrated_1" ] # migration ran + [ "$(cat "$CLIKAE_HOME/version")" = "2" ] # re-stamped to current +} + +@test "state_version_check: NO version file but v2 binary migrates from v1 (no-file = v1)" { + _src; mkdir -p "$CLIKAE_HOME" # no version file at all + CLIKAE_STATE_VERSION=2 + _state_migrate_1() { touch "$CLIKAE_HOME/.migrated_from_unversioned"; } + state_version_check + [ -f "$CLIKAE_HOME/.migrated_from_unversioned" ] + [ "$(cat "$CLIKAE_HOME/version")" = "2" ] +} + +@test "state_version_check: a NEWER on-disk version warns and does NOT downgrade" { + _src; mkdir -p "$CLIKAE_HOME"; printf '9\n' > "$CLIKAE_HOME/version" + run state_version_check + [ "$status" -eq 0 ] + [[ "$output" == *"newer clikae"* ]] || false + [ "$(cat "$CLIKAE_HOME/version")" = "9" ] # untouched +} + +@test "state_version_check: no state dir at all is a clean no-op" { + _src + rm -rf "$CLIKAE_HOME" + run state_version_check + [ "$status" -eq 0 ] + [ ! -d "$CLIKAE_HOME" ] # didn't create anything +}