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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)).
Expand Down
8 changes: 7 additions & 1 deletion bin/clikae
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 20 additions & 5 deletions docs/HANDOFF-world-class-gaps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<n>` 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/<engine>/<tank>`
(`lib/core/dry_store.sh:38`, format `<epoch>\t<phrase>`), `autonomy`,
`auto-relay-consent`, `cache/weekly/`. All are bare files with no version marker.
Expand All @@ -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.
4 changes: 4 additions & 0 deletions lib/core/dry_store.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ dry_store_path() { printf '%s/dry/%s/%s\n' "$CLIKAE_HOME" "$1" "$2"; }
# dry_store_mark <engine> <tank> [reset_phrase] -> record that this tank is dry as
# of NOW, carrying the vendor's verbatim reset phrase (may be empty). One line:
# "<epoch>\t<reset_phrase>". 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")"
Expand Down
4 changes: 4 additions & 0 deletions lib/core/profile_store.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
62 changes: 62 additions & 0 deletions lib/core/state_version.sh
Original file line number Diff line number Diff line change
@@ -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→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_<n>` (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
}
74 changes: 74 additions & 0 deletions tests/bats/state-version.bats
Original file line number Diff line number Diff line change
@@ -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
}
Loading