diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ead330..86c5671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.13] — 2026-06-07 + +### Added + +- **`burn --fresh`** — delete the artifact before running, for a clean slate. +- **`burn --timeout` perl fallback** — when neither `timeout` nor `gtimeout` (coreutils) + is on PATH, the run is bounded with a `perl` alarm (stock macOS ships neither). Only + when all three are missing does the run go unbounded (still warned). +- **`burn` summary line** — on finish, one line: tank used, reroute count, elapsed time, + artifact size. + +### Fixed + +- **`burn` no longer counts a STALE artifact as success.** A leftover artifact from a + previous run could make a failed task look like it succeeded. Success is now judged by + the artifact appearing *or its timestamp changing*, via the existing GNU-stat-first + `_clikae_mtime` helper (portable across macOS/Linux; whole-second resolution — `--fresh` + sidesteps a same-second overwrite). +- **Accurate agy docs & strings.** Comments and i18n that said agy "hardcodes ~/.gemini + and ignores env" were imprecise: agy's *state* follows `$HOME`, but its *login* is one + global Keychain entry — so different accounts can't be routed per-shell, which is why + switching is global. Also removed a dead, incorrect "agy can't be renamed" string (it + can — rename carries the per-tank Keychain login). +- **`remove agy `** now names a concrete other tank to switch to. + ## [0.5.12] — 2026-06-05 ### Added diff --git a/README.md b/README.md index bcebdad..fdf3926 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.12-blue.svg)](CHANGELOG.md) +[![Status](https://img.shields.io/badge/status-v0.5.13-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.13** — **`burn` hardening** from two dogfoods: a stale artifact no longer reads as success (judged by timestamp change; `--fresh` for a clean slate), `--timeout` falls back to a `perl` alarm when coreutils is absent, and a one-line run summary. Plus accurate agy docs/strings (state follows `$HOME`, login is one global Keychain entry). - **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. diff --git a/bin/clikae b/bin/clikae index 7077b8e..a0cf4fa 100755 --- a/bin/clikae +++ b/bin/clikae @@ -5,7 +5,7 @@ set -eo pipefail -CLIKAE_VERSION="0.5.12" +CLIKAE_VERSION="0.5.13" # Resolve install root (handles symlink chains, e.g., via Homebrew) __resolve_self() { diff --git a/lib/commands/antigravity.sh b/lib/commands/antigravity.sh index dd87aa7..a2cdc81 100644 --- a/lib/commands/antigravity.sh +++ b/lib/commands/antigravity.sh @@ -2,13 +2,13 @@ # lib/commands/antigravity.sh — Antigravity (agy) folded into clikae's standard # grammar. See docs/grammar.md §6. # -# WHY agy is special: its CLI (`agy`) hardcodes state under ~/.gemini and ignores -# every env var / has no config-dir flag (verified on a real install). clikae's -# clean per-shell model can't switch it, so the only way to give it multiple -# tanks is to SWAP ~/.gemini between per-tank directories via a symlink. That is -# GLOBAL (one tank active at a time across ALL terminals) and mutates your real -# home dir — so the first `init agy` asks before taking over, and it's reversible -# with `clikae agy --release`. +# WHY agy is special: its CLI (`agy`) keeps its login as one global Keychain entry +# (state follows $HOME, but the account doesn't) and has no per-account config-dir +# flag. clikae's clean per-shell model can't switch the account, so the only way to +# give it multiple tanks is to SWAP ~/.gemini between per-tank directories via a +# symlink (carrying each tank's Keychain login). That is GLOBAL (one tank active at +# a time across ALL terminals) and mutates your real home dir — so the first +# `init agy` asks before taking over, and it's reversible with `clikae agy --release`. # # The user types the SAME verbs as every other engine — there is no `agy enable` # / `add` / `use` / `disable` subcommand tree any more: @@ -155,7 +155,7 @@ _agy_takeover() { tank its own account clikae copies that login between Keychain slots on every switch. The token moves Keychain→Keychain and is never written to disk. • It is GLOBAL: only one agy tank is active at a time across ALL terminals - (agy ignores per-shell env). Don't run two tanks at once. + (the login is one global Keychain entry). Don't run two tanks at once. • Swapping while agy is running can corrupt that session. Reversible: 'clikae agy --release' restores a normal ~/.gemini (your tanks and their stashed logins are kept). @@ -277,7 +277,8 @@ _agy_remove() { # More than one tank remains. if [ "$name" = "$active" ]; then - log_fail "'$name' is the active agy tank. Switch to another first: clikae agy " + local _other; _other="$(_agy_tank_names | grep -vx "$name" | head -1)" + log_fail "'$name' is the active agy tank. Switch to another first: clikae agy ${_other:-}" fi rm -rf "${slots:?}/$name" _agy_kc_forget "$name" @@ -292,11 +293,11 @@ Usage: clikae agy [tank] [-- args...] switch agy to and run it clikae remove agy remove an agy tank clikae agy --release restore a normal ~/.gemini, keep tanks -Antigravity (agy) hardcodes ~/.gemini and ignores env vars, so clikae can't -switch it per-shell like other engines. Instead it swaps ~/.gemini between tank -dirs via a symlink — a GLOBAL power mode: one agy tank is active at a time across -ALL terminals. The first `init agy` asks before taking over ~/.gemini; it's -reversible with `clikae agy --release`. +Antigravity (agy) keeps its login as one global Keychain entry, so clikae can't +switch the account per-shell like other engines. Instead it swaps ~/.gemini between +tank dirs via a symlink (carrying each tank's login) — a GLOBAL power mode: one agy +tank is active at a time across ALL terminals. The first `init agy` asks before +taking over ~/.gemini; it's reversible with `clikae agy --release`. EOF } diff --git a/lib/commands/burn.sh b/lib/commands/burn.sh index 612fcf3..e206622 100644 --- a/lib/commands/burn.sh +++ b/lib/commands/burn.sh @@ -24,15 +24,17 @@ produce (never the exit code — codex exec exits 0 even when it hit its limit a wrote nothing), and if the tank ran dry, re-fire the SAME command on the next tank in your reserve. - --artifact the file the task must produce; its presence = success. + --artifact the file the task must produce. Success = it appears, or (if + it already existed) its timestamp changes — a STALE file from + a previous run is NOT counted as success. + --fresh delete before running, for a clean slate. --to explicit next hop on a dry tank (/ or a bare tank of this engine). Otherwise burn walks this engine's other tanks. A cross-engine --to runs the SAME command under that engine — only sensible if the command is engine-agnostic. - --timeout bound the run. NEEDS `timeout` or `gtimeout` (GNU coreutils) - on PATH — stock macOS has neither, so WITHOUT it the run is - NOT bounded and a warning is printed (install: `brew install - coreutils` for `gtimeout`). + --timeout bound the run. Uses `timeout`/`gtimeout` (coreutils) if present, + else a `perl` alarm (SIGALRM, direct child only). With none of + the three on PATH the run is NOT bounded and a warning is printed. --no-reroute run once; on a dry tank, stop instead of falling through. --allow-active let auto-reroute use a tank an interactive session is on. By default the reserve SKIPS such tanks (rerouting a headless @@ -51,6 +53,10 @@ Examples: burn is the headless sibling of the interactive switch: pre-stage inputs to /tmp (never hand a tank slow iCloud-backed I/O), and make tasks idempotent + artifact- checked so a dropped one just re-fires elsewhere. + +Boundary: burn only fits tasks whose success is a FILE you can name — codegen, +analysis, transforms. It CANNOT judge work whose proof is runtime behaviour (a UI +renders, a server answers); that still needs a human to verify. EOF } @@ -95,12 +101,23 @@ EOF _burn_timeout_bin() { if command -v timeout >/dev/null 2>&1; then printf 'timeout'; return 0; fi if command -v gtimeout >/dev/null 2>&1; then printf 'gtimeout'; return 0; fi - log_warn "--timeout needs \`timeout\`/\`gtimeout\` (coreutils) on PATH — running WITHOUT a time bound." + if command -v perl >/dev/null 2>&1; then printf 'perl'; return 0; fi + log_warn "--timeout needs \`timeout\`/\`gtimeout\` (coreutils) or \`perl\` on PATH — running WITHOUT a time bound." return 0 } +# Artifact freshness uses _clikae_mtime (lib/core/adapter_loader.sh) — epoch mtime, +# 0 if absent, GNU-stat-first for Linux portability — so a STALE file from a prior +# run can't be mistaken for this run's success (2026-06-06 tugtile dogfood #2). +# Whole-second resolution; a same-second overwrite is invisible (--fresh sidesteps it). + +# _burn_size -> byte count, or "?" if absent (for the summary line). +_burn_size() { + if [ -e "$1" ]; then wc -c < "$1" 2>/dev/null | tr -d ' '; else printf '?'; fi +} + cmd_burn() { - local cli="" tank="" artifact="" to="" timeout_s="" reroute=1 allow_active=0 + local cli="" tank="" artifact="" to="" timeout_s="" reroute=1 allow_active=0 fresh=0 local -a cmd=() while [ $# -gt 0 ]; do case "$1" in @@ -110,6 +127,7 @@ cmd_burn() { --timeout) shift; [ $# -gt 0 ] || log_fail "--timeout needs seconds"; timeout_s="$1"; shift ;; --no-reroute) reroute=0; shift ;; --allow-active) allow_active=1; shift ;; + --fresh) fresh=1; shift ;; --) shift; cmd=("$@"); break ;; -*) log_fail "Unknown flag: $1 (try: clikae burn --help)" ;; *) if [ -z "$cli" ]; then cli="$1" @@ -133,6 +151,18 @@ cmd_burn() { command -v "$binary" >/dev/null 2>&1 || log_fail "'$binary' is not on PATH." local envvar; envvar="$(adapter_meta_env_var 2>/dev/null || true)" # for the in-use guard + # #2 (tugtile dogfood): snapshot the artifact so a STALE file from a prior run + # isn't mistaken for success. --fresh clears it; otherwise warn + judge by mtime. + if [ "$fresh" -eq 1 ] && [ -e "$artifact" ]; then + rm -f "$artifact" 2>/dev/null + if [ -e "$artifact" ]; then log_warn "--fresh could not remove $artifact (judging by timestamp instead)." + else log_info "--fresh: cleared $artifact"; fi + elif [ -e "$artifact" ]; then + log_warn "artifact already exists: $artifact — judging success by a timestamp change (use --fresh for a clean slate)." + fi + local art_pre; art_pre="$(_clikae_mtime "$artifact")" # 0 when absent + local t0=$SECONDS + local cur="$tank" tried="" dried_accts="" reset out rc while :; do validate_name profile "$cur" @@ -145,7 +175,10 @@ cmd_burn() { local -a runner=() if [ -n "$timeout_s" ]; then local _tbin; _tbin="$(_burn_timeout_bin)" - [ -n "$_tbin" ] && runner=("$_tbin" "$timeout_s") + case "$_tbin" in + timeout|gtimeout) runner=("$_tbin" "$timeout_s") ;; + perl) runner=(perl -e 'alarm shift; exec @ARGV or exit 127' "$timeout_s") ;; + esac fi rc=0 out="$( @@ -167,13 +200,15 @@ KV # Remember this dried tank's account so the reserve skips its same-quota siblings (P1). local _acct; _acct="$(_limit_tank_account "$cli" "$cur" 2>/dev/null || true)" [ -n "$_acct" ] && dried_accts="${dried_accts}${_acct}"$'\n' - elif [ -e "$artifact" ]; then + elif [ -e "$artifact" ] && [ "$(_clikae_mtime "$artifact")" != "$art_pre" ]; then dry_store_clear "$cli" "$cur" # a real success recovered this tank log_ok "Done on $cli/$cur — artifact present: $artifact" + log_info "summary: tank=$cli/$cur reroutes=$(printf '%s' "$tried" | wc -w | tr -d ' ') elapsed=$((SECONDS - t0))s artifact=$(_burn_size "$artifact")B" return 0 else - log_err "$cli/$cur produced no artifact and shows no limit — a real task failure (rc=$rc), not a dry tank." + log_err "$cli/$cur produced no fresh artifact and shows no limit — a real task failure (rc=$rc), not a dry tank." printf '%s\n' "$out" | tail -n 5 | sed 's/^/ /' + log_info "summary: tank=$cli/$cur reroutes=$(printf '%s' "$tried" | wc -w | tr -d ' ') elapsed=$((SECONDS - t0))s artifact=none" return 1 fi diff --git a/lib/commands/list.sh b/lib/commands/list.sh index 4d3314b..cccc8ef 100644 --- a/lib/commands/list.sh +++ b/lib/commands/list.sh @@ -104,10 +104,10 @@ EOF printf '%s' "$enriched" | _list_render_json else printf '%s' "$enriched" | _list_render_table "$show_paths" - # Flag agy's nature so nobody tries `burn agy`: it's global/single-account, so - # there's no per-tank headless burn — interactive switch only. + # Flag agy's nature so nobody tries `burn agy`: its login is global (one account + # across all shells), so there's no per-tank headless burn — interactive switch only. case $'\n'"$enriched" in - *$'\n'"agy"$'\037'*) printf '\n%b agy is global/single-account — interactive switch only (clikae agy ); not burnable.%b\n' "$__C_DIM" "$__C_RESET" ;; + *$'\n'"agy"$'\037'*) printf '\n%b agy login is global — one account active at a time, interactive switch only (clikae agy ); not burnable.%b\n' "$__C_DIM" "$__C_RESET" ;; esac fi } diff --git a/lib/commands/rename.sh b/lib/commands/rename.sh index a93879e..fe2d572 100644 --- a/lib/commands/rename.sh +++ b/lib/commands/rename.sh @@ -66,8 +66,8 @@ EOF validate_name profile "$new" [ "$old" != "$new" ] || log_fail "Old and new names are the same ('$old')." - # agy is a symlink-managed target, not an env adapter: rename moves the slot dir - # and repoints ~/.gemini if it's the active one. (No alias/Keychain to carry.) + # agy is a symlink-managed target, not an env adapter: rename moves the slot dir, + # repoints ~/.gemini if it's the active one, and carries the per-tank Keychain login. if [ "$cli" = "agy" ] || [ "$cli" = "antigravity" ]; then # shellcheck source=./antigravity.sh source "$CLIKAE_LIB/commands/antigravity.sh" diff --git a/lib/core/adapter_loader.sh b/lib/core/adapter_loader.sh index c8cb385..36c6cf5 100644 --- a/lib/core/adapter_loader.sh +++ b/lib/core/adapter_loader.sh @@ -141,14 +141,14 @@ load_adapter() { local cli="$1" local f="$CLIKAE_LIB/adapters/$cli.sh" if [ ! -f "$f" ]; then - # agy (Antigravity) is a known engine with no adapter on purpose: it ignores - # env vars and hardcodes ~/.gemini, so it's switched globally, not per-shell - # or per-config-dir. Give that guidance instead of the generic "no adapter" - # whenever a generic path (env/app/alias/run/relay/migrate) is handed it. + # agy (Antigravity) is a known engine with no adapter on purpose: its login is + # one global Keychain entry (state follows $HOME, but the account doesn't), so + # accounts switch globally — not per-shell. Give that guidance instead of the + # generic "no adapter" whenever a generic path (env/app/alias/run/relay/migrate) is handed it. case "$cli" in agy|antigravity) - log_err "agy (Antigravity) is global — clikae can't route it per-shell or per-config-dir." - log_dim "Switch the machine-wide login with: clikae agy (see clikae agy --help)" + log_err "agy (Antigravity) login is global — clikae can't route its account per-shell." + log_dim "Switch the global login with: clikae agy (see clikae agy --help)" exit 1 ;; esac log_err "No built-in adapter for '$cli'." diff --git a/lib/core/i18n.sh b/lib/core/i18n.sh index e9dbdb4..d410fae 100644 --- a/lib/core/i18n.sh +++ b/lib/core/i18n.sh @@ -61,7 +61,7 @@ i18n_load() { T_ACTIVE_HERE="active here" T_ALSO_AVAILABLE="Also available" T_NO_TANK_DEFAULT="no tank yet — opens default" - T_AGY_NOTE="single-account · opens ~/.gemini directly" + T_AGY_NOTE="single-account · global login (one account, all shells)" T_LAUNCH="launch" T_MORE="more" T_INCOGNITO="Incognito" @@ -120,7 +120,6 @@ i18n_load() { T_RENAME_CURRENTLY="currently" T_RENAME_NEW="New name: " T_RENAME_CANCEL="Cancelled — name unchanged." - T_RENAME_NO_AGY="agy tanks can't be renamed (it's a global ~/.gemini target, not a per-shell engine)." # filter / help / misc T_FILTER_PROMPT="filter: " T_FILTER_NONE="no matches" @@ -150,7 +149,7 @@ i18n_load() { T_ACTIVE_HERE="使用中" T_ALSO_AVAILABLE="その他" T_NO_TANK_DEFAULT="タンク未作成 — 既定で開く" - T_AGY_NOTE="シングルアカウント · ~/.gemini を直接開く" + T_AGY_NOTE="シングルアカウント · グローバルログイン(全シェル共通)" T_LAUNCH="起動" T_MORE="その他" T_INCOGNITO="シークレット" @@ -205,7 +204,6 @@ i18n_load() { T_RENAME_CURRENTLY="現在" T_RENAME_NEW="新しい名前: " T_RENAME_CANCEL="キャンセル — 名前は変更されません。" - T_RENAME_NO_AGY="agy タンクは名前変更できません(~/.gemini を使うグローバル対象のため)。" T_FILTER_PROMPT="絞込: " T_FILTER_NONE="一致なし" T_HELP_TITLE="clikae — キー操作" @@ -232,7 +230,7 @@ i18n_load() { T_ACTIVE_HERE="使用中" T_ALSO_AVAILABLE="也可開啟" T_NO_TANK_DEFAULT="尚無油箱 — 開預設" - T_AGY_NOTE="單帳號 · 直接開 ~/.gemini" + T_AGY_NOTE="單帳號 · 全域登入(所有 shell 共用)" T_LAUNCH="啟動" T_MORE="更多" T_INCOGNITO="無痕" @@ -287,7 +285,6 @@ i18n_load() { T_RENAME_CURRENTLY="目前" T_RENAME_NEW="新名稱:" T_RENAME_CANCEL="已取消 — 名稱未變更。" - T_RENAME_NO_AGY="agy 油箱無法改名(它是吃 ~/.gemini 的全域對象,不是 per-shell 引擎)。" T_FILTER_PROMPT="篩選:" T_FILTER_NONE="無相符項目" T_HELP_TITLE="clikae — 按鍵" diff --git a/lib/targets/antigravity.sh b/lib/targets/antigravity.sh index 1a31289..f217de8 100644 --- a/lib/targets/antigravity.sh +++ b/lib/targets/antigravity.sh @@ -2,11 +2,12 @@ # lib/targets/antigravity.sh — a handoff TARGET for Google's Antigravity CLI (agy). # # Why a "target" and not an adapter: clikae adapters manage *switchable* profiles, -# which needs a config-dir env var (or a flag) to point a CLI at a per-profile -# directory. Antigravity's CLI (`agy`) hardcodes its state under ~/.gemini with no -# such override — investigated on a real install: neither $ANTIGRAVITY_EXECUTABLE_DATA_DIR -# nor $HOME relocates it, and there's no config-dir flag. So we can't give it -# multiple fuel tanks. +# which needs a config-dir env var (or a flag) to point a CLI at a per-profile dir. +# agy's STATE dir does follow $HOME (os.UserHomeDir), but its LOGIN doesn't — the +# account is one global Keychain entry, and no env (ANTIGRAVITY_EXECUTABLE_DATA_DIR, +# GEMINI_HOME, …) or flag re-routes it. So accounts can't be switched per-shell; +# clikae's opt-in multi-account mode swaps them GLOBALLY (~/.gemini symlink + Keychain +# stash), one active at a time. # # What we CAN do is hand a session OFF to it: when a Claude/Codex tank runs dry, # `clikae handoff --to antigravity` starts `agy` seeded with the handoff @@ -18,11 +19,11 @@ target_meta_name() { echo "Antigravity (agy)"; } target_meta_binary() { echo "agy"; } -# A one-line note for the dashboard. agy hardcodes ~/.gemini and ignores env, so -# by default it's single-account (launch-only). Localised via T_AGY_NOTE (from -# lib/core/i18n.sh), with the English string as a fallback when i18n isn't loaded +# A one-line note for the dashboard. agy's login is global (one account across all +# shells), so by default it's single-account (launch-only). Localised via T_AGY_NOTE +# (from lib/core/i18n.sh), with the English string as a fallback when i18n isn't loaded # (e.g. a unit test that sources this target file in isolation). -target_meta_note() { echo "${T_AGY_NOTE:-single-account · opens ~/.gemini directly}"; } +target_meta_note() { echo "${T_AGY_NOTE:-single-account · global login (one account, all shells)}"; } # When the opt-in multi-account mode is enabled (see `clikae antigravity`), the # active account is whichever slot the ~/.gemini symlink points at. The dashboard diff --git a/tests/bats/burn.bats b/tests/bats/burn.bats index 913145d..7704427 100644 --- a/tests/bats/burn.bats +++ b/tests/bats/burn.bats @@ -174,3 +174,51 @@ _seed_email() { printf '{"emailAddress": "%s"}\n' "$3" > "$CLIKAE_HOME/profiles/ run _burn_next_same_engine claude "claude/a" "one@example.com" CLAUDE_CONFIG_DIR 0 [ "$output" = "b" ] } + +# --- #2 (tugtile dogfood): a STALE artifact must not be mistaken for success --- + +@test "burn does NOT count a STALE artifact as success (judges by mtime change)" { + _stub_codex + clikae init codex T1 + local A="$BATS_TEST_TMPDIR/out.md" + echo "leftover from a previous run" > "$A" # stale artifact already present + run clikae burn codex T1 --artifact "$A" -- noop # this task writes nothing + [ "$status" -ne 0 ] + [[ "$output" == *"already exists"* ]] || false # warned about the stale file + [[ "$output" == *"real task failure"* ]] || false # not a false "Done" +} + +@test "burn --fresh clears a stale artifact before running" { + _stub_codex + clikae init codex T1 + local A="$BATS_TEST_TMPDIR/out.md" + echo "leftover" > "$A" + run clikae burn codex T1 --artifact "$A" --fresh -- run "$A" # run recreates it + [ "$status" -eq 0 ] + [[ "$output" == *"cleared"* ]] || false + [ -f "$A" ] +} + +@test "burn counts an OVERWRITTEN pre-existing artifact as success (mtime advanced)" { + # The path the absent->present tests miss: artifact PRE-EXISTS with an old mtime and + # the task rewrites it. Catches the GNU/BSD stat-order bug (broken on Linux/CI). + _stub_codex + clikae init codex T1 + local A="$BATS_TEST_TMPDIR/out.md" + echo "old" > "$A"; touch -t 202001010000 "$A" # force an OLD mtime + run clikae burn codex T1 --artifact "$A" -- run "$A" # stub rewrites $A (mtime -> now) + [ "$status" -eq 0 ] + [[ "$output" == *"Done on codex/T1"* ]] || false +} + +# --- #3 (tugtile dogfood): perl alarm fallback when no coreutils timeout --- + +@test "_burn_timeout_bin: falls back to perl when no timeout/gtimeout" { + # shellcheck source=/dev/null + . "$CLIKAE_TEST_ROOT/lib/core/log.sh" + . "$CLIKAE_TEST_ROOT/lib/commands/burn.sh" + mkdir -p "$BATS_TEST_TMPDIR/perlbin" + printf '#!/usr/bin/env bash\n' > "$BATS_TEST_TMPDIR/perlbin/perl"; chmod +x "$BATS_TEST_TMPDIR/perlbin/perl" + local out; out="$(PATH="$BATS_TEST_TMPDIR/perlbin" _burn_timeout_bin)" # only perl, no (g)timeout + [ "$out" = "perl" ] +}