@seanyao/roll 2026.527.1 → 2026.528.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## v2026.528.1
4
+
5
+ ### Added
6
+
7
+ - **`roll test` — 测试隔离运行,不再误伤本机 loop 服务** `[loop]`
8
+
9
+ ### Fixed
10
+
11
+ - **Kimi CLI 升级改名为 kimi-code 后,roll 现在能正常识别** `[loop]`
12
+
3
13
  ## v2026.527.1
4
14
 
5
15
  ### Added
package/README.md CHANGED
@@ -69,6 +69,7 @@ roll loop on # let AI work through the backlog (optional)
69
69
  | Configuration (env vars) | [guide/en/configuration.md](guide/en/configuration.md) | [guide/zh/configuration.md](guide/zh/configuration.md) |
70
70
  | Skill selection guide | [guide/en/skills.md](guide/en/skills.md) | [guide/zh/skills.md](guide/zh/skills.md) |
71
71
  | Slides (deck generator) | [guide/en/slides.md](guide/en/slides.md) | [guide/zh/slides.md](guide/zh/slides.md) |
72
+ | Test isolation (`roll test` + Tart VM) | [guide/en/test-isolation.md](guide/en/test-isolation.md) | [guide/zh/test-isolation.md](guide/zh/test-isolation.md) |
72
73
  | Cross-machine sync | [guide/en/loop.md#cross-machine-sync](guide/en/loop.md#cross-machine-sync) | [guide/zh/loop.md#跨机器同步](guide/zh/loop.md#%E8%B7%A8%E6%9C%BA%E5%99%A8%E5%90%8C%E6%AD%A5) |
73
74
  | Pricing (cost visibility) | [guide/en/pricing.md](guide/en/pricing.md) | [guide/zh/pricing.md](guide/zh/pricing.md) |
74
75
  | FAQ (troubleshooting) | [guide/en/faq.md](guide/en/faq.md) | [guide/zh/faq.md](guide/zh/faq.md) |
package/bin/roll CHANGED
@@ -4,7 +4,7 @@ set -euo pipefail
4
4
  # Roll — AI Agent Convention Manager
5
5
  # Single source of truth for how all AI coding agents behave.
6
6
 
7
- VERSION="2026.527.1"
7
+ VERSION="2026.528.1"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -70,6 +70,11 @@ ai_tool_name() {
70
70
  # Antigravity (agy) reuses ~/.gemini/ from the deprecated Gemini CLI for
71
71
  # its config dir, so a literal `gemini` basename now identifies agy.
72
72
  [[ "$bn" == "gemini" ]] && bn="agy"
73
+ # FIX-126: Kimi upstream renamed its CLI to kimi-code and its config dir
74
+ # to ~/.kimi-code/; map both old and new basenames to the canonical
75
+ # "kimi" agent identifier so downstream argv / config / sync paths stay
76
+ # uniform across the upgrade.
77
+ [[ "$bn" == "kimi-code" ]] && bn="kimi"
73
78
  echo "$bn"
74
79
  }
75
80
 
@@ -266,6 +271,7 @@ _ensure_config_entries() {
266
271
  "ai_claude:~/.claude|CLAUDE.md|CLAUDE.md"
267
272
  "ai_agy:~/.gemini|GEMINI.md|GEMINI.md"
268
273
  "ai_kimi:~/.kimi|AGENTS.md|AGENTS.md"
274
+ "ai_kimi_code:~/.kimi-code|AGENTS.md|AGENTS.md"
269
275
  "ai_codex:~/.codex|AGENTS.md|AGENTS.md"
270
276
  "ai_cursor:~/.cursor|.cursor-rules|.cursor-rules"
271
277
  "ai_trae:~/.trae|user_rules.md|project_rules.md"
@@ -490,6 +496,7 @@ _install_local() {
490
496
  ai_claude: ~/.claude|CLAUDE.md|CLAUDE.md
491
497
  ai_gemini: ~/.gemini|GEMINI.md|GEMINI.md
492
498
  ai_kimi: ~/.kimi|AGENTS.md|AGENTS.md
499
+ ai_kimi_code: ~/.kimi-code|AGENTS.md|AGENTS.md
493
500
  ai_codex: ~/.codex|AGENTS.md|AGENTS.md
494
501
  ai_cursor: ~/.cursor|.cursor-rules|.cursor-rules
495
502
  ai_trae: ~/.trae|user_rules.md|project_rules.md
@@ -782,7 +789,7 @@ cmd_setup() {
782
789
  esac
783
790
  }
784
791
 
785
- local _ai_dirs="$HOME/.claude:$HOME/.gemini:$HOME/.kimi:$HOME/.codex:$HOME/.cursor:$HOME/.trae:$HOME/.config/opencode:$HOME/.openclaw:$HOME/.pi:$HOME/.deepseek:$HOME/.qwen"
792
+ local _ai_dirs="$HOME/.claude:$HOME/.gemini:$HOME/.kimi:$HOME/.kimi-code:$HOME/.codex:$HOME/.cursor:$HOME/.trae:$HOME/.config/opencode:$HOME/.openclaw:$HOME/.pi:$HOME/.deepseek:$HOME/.qwen"
786
793
 
787
794
  _run_setup_step "$ROLL_HOME" _install_local "$force"
788
795
  _record "$(_state_to_marker "$_ROLL_SETUP_STATE")" "Install templates & conventions to ~/.roll"
@@ -1928,6 +1935,18 @@ PY
1928
1935
  return 0
1929
1936
  fi
1930
1937
 
1938
+ # FIX-125: cycle-context tripwire. Apply phase below runs launchctl unload
1939
+ # and rm against ${HOME}/Library/LaunchAgents/<plist> (bin/roll:1957-1958).
1940
+ # From inside a loop cycle this would mutate the host's launchd domain
1941
+ # using another project's identity. Doc-only offboards (no plists) stay
1942
+ # allowed so cycles can still call offboard for non-launchd cleanup.
1943
+ if [ "${#plists[@]}" -gt 0 ] && _loop_in_cycle; then
1944
+ err "Refusing to unload launchd plists from inside a loop cycle (FIX-125)."
1945
+ echo " Run 'roll offboard --confirm' from a terminal outside the cycle," >&2
1946
+ echo " or pause the loop first: 'roll loop pause'." >&2
1947
+ return 1
1948
+ fi
1949
+
1931
1950
  # Apply. Guard every loop with a count check — `set -u` upstream makes
1932
1951
  # naked `"${arr[@]}"` over an empty array a hard error on bash 5.0.
1933
1952
  echo "$(msg offboard.applying_offboard)"
@@ -3133,9 +3152,20 @@ _agent_argv() {
3133
3152
  *) _AGENT_ARGV=(claude -p "$prompt") ;;
3134
3153
  esac ;;
3135
3154
  kimi)
3155
+ # FIX-126: Kimi upstream renamed binary from kimi-cli → kimi-code.
3156
+ # Prefer the new name when present; fall back through legacy names
3157
+ # so users mid-upgrade keep working until they reinstall.
3158
+ local _kimi_bin
3159
+ if command -v kimi-code >/dev/null 2>&1; then
3160
+ _kimi_bin=kimi-code
3161
+ elif command -v kimi-cli >/dev/null 2>&1; then
3162
+ _kimi_bin=kimi-cli
3163
+ else
3164
+ _kimi_bin=kimi
3165
+ fi
3136
3166
  case "$mode" in
3137
- interactive) _AGENT_ARGV=(kimi "$prompt") ;;
3138
- *) _AGENT_ARGV=(kimi --quiet -p "$prompt") ;;
3167
+ interactive) _AGENT_ARGV=("$_kimi_bin" "$prompt") ;;
3168
+ *) _AGENT_ARGV=("$_kimi_bin" --quiet -p "$prompt") ;;
3139
3169
  esac ;;
3140
3170
  deepseek)
3141
3171
  # deepseek has the same argv shape in both modes (positional prompt).
@@ -4417,6 +4447,397 @@ cmd_agent() {
4417
4447
  esac
4418
4448
  }
4419
4449
 
4450
+ # ═══════════════════════════════════════════════════════════════════════════════
4451
+ # ISOLATION — pluggable adapter for running tests in an isolated environment
4452
+ # (US-ISO-001). Phase 1 supports two providers: `none` (default — direct host
4453
+ # execution) and `tart` (US-ISO-002 — macOS VM). The dispatcher reads
4454
+ # .roll/local.yaml's `test_isolation.type` and routes to
4455
+ # `_isolation_<type>_<method>`. See .roll/features/engineering-infrastructure/
4456
+ # dev-vm-isolation-plan.md for the full interface contract.
4457
+ # ═══════════════════════════════════════════════════════════════════════════════
4458
+
4459
+ _ISOLATION_SUPPORTED_TYPES="none tart"
4460
+
4461
+ # Read test_isolation.type from .roll/local.yaml. Falls back to "none" when
4462
+ # the file or key is missing. Uses python3+yaml for nested-key parsing,
4463
+ # matching the parser used by cmd_offboard.
4464
+ _isolation_get_type() {
4465
+ local val=""
4466
+ if [[ -f .roll/local.yaml ]] && command -v python3 >/dev/null 2>&1; then
4467
+ val=$(python3 - <<'PY' 2>/dev/null
4468
+ import sys
4469
+ try:
4470
+ import yaml
4471
+ except ImportError:
4472
+ sys.exit(0)
4473
+ try:
4474
+ data = yaml.safe_load(open(".roll/local.yaml")) or {}
4475
+ except Exception:
4476
+ sys.exit(0)
4477
+ section = data.get("test_isolation")
4478
+ if isinstance(section, dict):
4479
+ t = section.get("type")
4480
+ if isinstance(t, str) and t:
4481
+ print(t)
4482
+ PY
4483
+ )
4484
+ fi
4485
+ if [[ -z "$val" ]]; then
4486
+ val="none"
4487
+ fi
4488
+ printf '%s\n' "$val"
4489
+ }
4490
+
4491
+ # Dispatch an isolation-adapter method to the configured provider.
4492
+ # Usage: _isolation_dispatch <method> [args...]
4493
+ # Methods: init / provision / exec / status / reset / destroy
4494
+ _isolation_dispatch() {
4495
+ local method="$1"; shift || true
4496
+ if [[ -z "$method" ]]; then
4497
+ err "isolation: missing method"
4498
+ echo " usage: _isolation_dispatch <init|provision|exec|status|reset|destroy> [args...]" >&2
4499
+ return 1
4500
+ fi
4501
+
4502
+ # Resolve provider; emit a fallback-INFO line only when the config file is
4503
+ # missing (so an explicit `type: none` stays quiet). Goes to stderr so the
4504
+ # actual dispatch output (e.g. exec stdout) stays clean.
4505
+ local type; type=$(_isolation_get_type)
4506
+ if [[ "$type" = "none" ]] && [[ ! -f .roll/local.yaml ]]; then
4507
+ info "isolation: no test_isolation config, falling back to type=none (host)" >&2
4508
+ fi
4509
+
4510
+ # Reject unknown types up front so the error names the provider, not the
4511
+ # missing function — this is the difference between "you typed it wrong"
4512
+ # and "the adapter is broken".
4513
+ local supported_ok=0 t
4514
+ for t in $_ISOLATION_SUPPORTED_TYPES; do
4515
+ [[ "$type" = "$t" ]] && supported_ok=1
4516
+ done
4517
+ if (( ! supported_ok )); then
4518
+ err "isolation: unknown type '$type' in .roll/local.yaml"
4519
+ echo " supported types: ${_ISOLATION_SUPPORTED_TYPES// /, }" >&2
4520
+ return 1
4521
+ fi
4522
+
4523
+ local fn="_isolation_${type}_${method}"
4524
+ if ! declare -F "$fn" >/dev/null 2>&1; then
4525
+ err "isolation: provider '$type' has no '${method}' implementation"
4526
+ return 1
4527
+ fi
4528
+ "$fn" "$@"
4529
+ }
4530
+
4531
+ # ── `none` adapter (default — direct host execution) ──────────────────────
4532
+ # init / provision / destroy are no-ops; exec runs the command in the host
4533
+ # shell unchanged; status is always 'ready'; reset is a benign no-op
4534
+ # (US-ISO-004 will print an explanatory message when invoked via roll test).
4535
+ _isolation_none_init() { return 0; }
4536
+ _isolation_none_provision() { return 0; }
4537
+ _isolation_none_exec() { "$@"; }
4538
+ _isolation_none_status() { echo "ready"; return 0; }
4539
+ _isolation_none_reset() {
4540
+ # US-ISO-004 AC: type=none has nothing to reset; print explanation but
4541
+ # exit 0 (not a failure — host execution is already as clean as it gets).
4542
+ info "isolation type 'none' has nothing to reset (host execution is stateless)" >&2
4543
+ return 0
4544
+ }
4545
+ _isolation_none_destroy() { return 0; }
4546
+
4547
+ # ─── reset lock (US-ISO-004) ──────────────────────────────────────────────
4548
+ # A single lockfile under .roll/ prevents two `roll test --reset` runs from
4549
+ # racing, and forces concurrent `roll test` test-execution paths to bail
4550
+ # fast rather than blocking on a half-rebuilt VM. --where is read-only and
4551
+ # deliberately bypasses the lock.
4552
+ _isolation_reset_lock_path() {
4553
+ echo ".roll/.iso-reset.lock"
4554
+ }
4555
+
4556
+ _isolation_reset_lock_held() {
4557
+ [[ -f "$(_isolation_reset_lock_path)" ]]
4558
+ }
4559
+
4560
+ # Returns 0 if the caller now holds the lock; 1 if someone else does.
4561
+ _isolation_reset_acquire_lock() {
4562
+ local lock; lock=$(_isolation_reset_lock_path)
4563
+ if [[ -f "$lock" ]]; then
4564
+ return 1
4565
+ fi
4566
+ mkdir -p "$(dirname "$lock")"
4567
+ echo "$$" > "$lock"
4568
+ return 0
4569
+ }
4570
+
4571
+ _isolation_reset_release_lock() {
4572
+ rm -f "$(_isolation_reset_lock_path)"
4573
+ }
4574
+
4575
+ # ── `tart` adapter (US-ISO-002 — macOS Apple Silicon VM via Tart) ─────────
4576
+ # Test override hooks (used by unit tests; default values keep prod stable):
4577
+ # _TART_VM_NAME — VM identifier (default: roll-dev-test)
4578
+ # _TART_BASE_IMAGE — OCI base image (default: cirruslabs macos-tahoe-base)
4579
+ # _TART_SSH_USER — SSH user inside the VM (default: admin)
4580
+
4581
+ _isolation_tart_vm_name() { printf '%s\n' "${_TART_VM_NAME:-roll-dev-test}"; }
4582
+ _isolation_tart_base_image() { printf '%s\n' "${_TART_BASE_IMAGE:-ghcr.io/cirruslabs/macos-tahoe-base:latest}"; }
4583
+ _isolation_tart_ssh_user() { printf '%s\n' "${_TART_SSH_USER:-admin}"; }
4584
+
4585
+ _isolation_tart_check_platform() {
4586
+ if [[ "$(uname)" != "Darwin" ]] || [[ "$(uname -m)" != "arm64" ]]; then
4587
+ err "Tart 仅支持 Apple Silicon macOS"
4588
+ err "Tart only supports Apple Silicon macOS"
4589
+ return 1
4590
+ fi
4591
+ return 0
4592
+ }
4593
+
4594
+ _isolation_tart_check_binary() {
4595
+ if ! command -v tart >/dev/null 2>&1; then
4596
+ err "tart binary not found"
4597
+ err " install via: brew install cirruslabs/cli/tart"
4598
+ return 1
4599
+ fi
4600
+ return 0
4601
+ }
4602
+
4603
+ # Returns 0 with the VM name on stdout when the VM is in `tart list`,
4604
+ # returns 1 silently otherwise. Caller decides what to do.
4605
+ _isolation_tart_vm_present() {
4606
+ local name; name=$(_isolation_tart_vm_name)
4607
+ tart list 2>/dev/null | awk -v n="$name" '$1 == n { found=1 } END { exit !found }'
4608
+ }
4609
+
4610
+ # Returns the VM's IP on stdout when reachable; exit non-zero when the VM
4611
+ # is stopped or `tart ip` fails for any other reason.
4612
+ _isolation_tart_ip() {
4613
+ local name; name=$(_isolation_tart_vm_name)
4614
+ local ip; ip=$(tart ip "$name" 2>/dev/null) || return 1
4615
+ [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1
4616
+ printf '%s\n' "$ip"
4617
+ }
4618
+
4619
+ # Status state machine — see dev-vm-isolation-plan.md §4.
4620
+ # Returns one of: not-installed | stopped | running | ready
4621
+ _isolation_tart_status() {
4622
+ _isolation_tart_check_platform >/dev/null 2>&1 || { echo "not-installed"; return 0; }
4623
+ command -v tart >/dev/null 2>&1 || { echo "not-installed"; return 0; }
4624
+ _isolation_tart_vm_present || { echo "not-installed"; return 0; }
4625
+ local ip
4626
+ if ! ip=$(_isolation_tart_ip); then
4627
+ echo "stopped"
4628
+ return 0
4629
+ fi
4630
+ # VM up. Is it provisioned? A trivial SSH probe is the cheapest check.
4631
+ local user; user=$(_isolation_tart_ssh_user)
4632
+ if ssh -o BatchMode=yes -o ConnectTimeout=3 -o StrictHostKeyChecking=no \
4633
+ "${user}@${ip}" "true" >/dev/null 2>&1; then
4634
+ echo "ready"
4635
+ else
4636
+ echo "running"
4637
+ fi
4638
+ return 0
4639
+ }
4640
+
4641
+ # init: ensure the base image is cloned into our VM slot. Idempotent —
4642
+ # `tart clone` is skipped when the VM already exists.
4643
+ _isolation_tart_init() {
4644
+ _isolation_tart_check_platform || return 1
4645
+ _isolation_tart_check_binary || return 1
4646
+ local name; name=$(_isolation_tart_vm_name)
4647
+ if _isolation_tart_vm_present; then
4648
+ return 0
4649
+ fi
4650
+ local img; img=$(_isolation_tart_base_image)
4651
+ tart clone "$img" "$name"
4652
+ }
4653
+
4654
+ # provision: ensure runtime deps are installed inside the VM. Idempotent —
4655
+ # brew install no-ops for already-installed packages. Requires the VM to
4656
+ # be running with SSH responsive (caller's responsibility, usually exec).
4657
+ _isolation_tart_provision() {
4658
+ _isolation_tart_check_platform || return 1
4659
+ _isolation_tart_check_binary || return 1
4660
+ local ip; ip=$(_isolation_tart_ip) || { err "tart provision: VM not running"; return 1; }
4661
+ local user; user=$(_isolation_tart_ssh_user)
4662
+ ssh -o BatchMode=yes -o StrictHostKeyChecking=no \
4663
+ "${user}@${ip}" "brew list bats >/dev/null 2>&1 || brew install bats-core; \
4664
+ brew list node >/dev/null 2>&1 || brew install node; \
4665
+ brew list bash >/dev/null 2>&1 || brew install bash"
4666
+ }
4667
+
4668
+ # exec: run the command inside the VM. Auto-starts the VM if it's stopped.
4669
+ # Mounts the host worktree at /Volumes/My Shared Files/roll (Tart virtiofs).
4670
+ _isolation_tart_exec() {
4671
+ _isolation_tart_check_platform || return 1
4672
+ _isolation_tart_check_binary || return 1
4673
+ local name; name=$(_isolation_tart_vm_name)
4674
+ local ip
4675
+ if ! ip=$(_isolation_tart_ip); then
4676
+ # VM stopped — start it in the background with the repo mounted.
4677
+ local repo_root; repo_root="$(pwd -P)"
4678
+ tart run --dir="roll:${repo_root}" "$name" >/dev/null 2>&1 &
4679
+ # Wait up to ~30s for IP to come up.
4680
+ local i=0
4681
+ while (( i < 30 )); do
4682
+ if ip=$(_isolation_tart_ip); then break; fi
4683
+ sleep 1
4684
+ i=$((i + 1))
4685
+ done
4686
+ [[ -n "${ip:-}" ]] || { err "tart exec: VM failed to start in 30s"; return 1; }
4687
+ fi
4688
+ local user; user=$(_isolation_tart_ssh_user)
4689
+ ssh -o BatchMode=yes -o StrictHostKeyChecking=no "${user}@${ip}" "$@"
4690
+ }
4691
+
4692
+ # reset: stop, delete, re-clone from base image, then re-provision.
4693
+ # Target: ≤90s (caller's perception); actual depends on tart clone speed.
4694
+ # Clone is called directly (not via init) so the sequence is unconditional —
4695
+ # tart's own "VM exists" check still no-ops re-clone if delete didn't take.
4696
+ _isolation_tart_reset() {
4697
+ _isolation_tart_check_platform || return 1
4698
+ _isolation_tart_check_binary || return 1
4699
+ local name; name=$(_isolation_tart_vm_name)
4700
+ local img; img=$(_isolation_tart_base_image)
4701
+ tart stop "$name" 2>/dev/null || true
4702
+ tart delete "$name" 2>/dev/null || true
4703
+ tart clone "$img" "$name" || return 1
4704
+ _isolation_tart_provision || true # provision may fail mid-reset; surface
4705
+ # via subsequent status check.
4706
+ }
4707
+
4708
+ # destroy: stop + delete. Doesn't rebuild.
4709
+ _isolation_tart_destroy() {
4710
+ _isolation_tart_check_platform || return 1
4711
+ _isolation_tart_check_binary || return 1
4712
+ local name; name=$(_isolation_tart_vm_name)
4713
+ tart stop "$name" 2>/dev/null || true
4714
+ tart delete "$name" 2>/dev/null || true
4715
+ return 0
4716
+ }
4717
+
4718
+ # ─── cmd_test ────────────────────────────────────────────────────────────
4719
+ # US-ISO-003: `roll test` — runs the project's test suite through the
4720
+ # isolation dispatcher. The configured `test_isolation.type` determines
4721
+ # where the tests execute (host shell vs Tart VM). When type=tart and
4722
+ # the VM fails to start, the failure surfaces non-zero — no silent
4723
+ # fallback to host, since that would lie about where the tests ran.
4724
+
4725
+ # Print where the test suite will execute. Format is machine-readable
4726
+ # (one token, optionally with a colon-separated detail) so scripts can
4727
+ # parse it: `host`, `tart:<ip>`, `tart:stopped`, `tart:not-installed`, …
4728
+ _cmd_test_where() {
4729
+ local type; type=$(_isolation_get_type)
4730
+ case "$type" in
4731
+ none)
4732
+ echo "host"
4733
+ ;;
4734
+ tart)
4735
+ local st; st=$(_isolation_tart_status)
4736
+ case "$st" in
4737
+ ready|running)
4738
+ local ip
4739
+ if ip=$(_isolation_tart_ip 2>/dev/null); then
4740
+ echo "tart:${ip}"
4741
+ else
4742
+ echo "tart:${st}"
4743
+ fi
4744
+ ;;
4745
+ *)
4746
+ echo "tart:${st}"
4747
+ ;;
4748
+ esac
4749
+ ;;
4750
+ *)
4751
+ echo "unknown:${type}"
4752
+ ;;
4753
+ esac
4754
+ }
4755
+
4756
+ cmd_test() {
4757
+ # US-ISO-005: `--help` / `-h` anywhere in pre-`--` args shows help and
4758
+ # exits 0, so `roll test --reset --help` is a help lookup, not a reset.
4759
+ # Args appearing after `--` are forwarded verbatim and not intercepted.
4760
+ local _a
4761
+ for _a in "$@"; do
4762
+ case "$_a" in
4763
+ --) break ;;
4764
+ --help|-h) set -- --help; break ;;
4765
+ esac
4766
+ done
4767
+ case "${1:-}" in
4768
+ --help|-h)
4769
+ cat <<'EOF'
4770
+ Usage: roll test [--where | --reset] [--] [<extra-args>...]
4771
+
4772
+ Runs the project's test suite through the isolation adapter chosen in
4773
+ .roll/local.yaml:
4774
+
4775
+ test_isolation:
4776
+ type: none (default) Direct host execution — same shell as `npm test`.
4777
+ type: tart Inside the Apple-Silicon `roll-dev-test` Tart VM,
4778
+ so tests can't reach the host's launchd / shared
4779
+ roll state. Tart isn't auto-installed; run
4780
+ `brew install cirruslabs/cli/tart` first.
4781
+
4782
+ Flags:
4783
+ --where Print where tests will run, then exit (e.g. `host`,
4784
+ `tart:192.168.64.5`, `tart:stopped`).
4785
+ --reset Rebuild the isolation environment to a clean baseline.
4786
+ type=tart: stop → delete → clone → provision (~90s).
4787
+ type=none: prints a note and exits 0 (host is stateless).
4788
+ Holds a lockfile under .roll/.iso-reset.lock; concurrent
4789
+ `roll test` invocations fast-fail with a clear error.
4790
+ --help, -h Show this help.
4791
+
4792
+ Examples:
4793
+ roll test Run the suite in whatever the config says.
4794
+ roll test -- --tier=fast Forward arguments to npm test.
4795
+ roll test --where Don't run; just report routing.
4796
+ roll test --reset Rebuild the VM (or host no-op).
4797
+
4798
+ When type=tart and the VM can't be reached, the command exits non-zero
4799
+ rather than silently falling back to host execution.
4800
+ EOF
4801
+ return 0
4802
+ ;;
4803
+ --where)
4804
+ _cmd_test_where
4805
+ return 0
4806
+ ;;
4807
+ --reset)
4808
+ # Refuse if another reset is in progress — fast-fail beats blocking
4809
+ # on a half-rebuilt VM (US-ISO-004 AC).
4810
+ if _isolation_reset_lock_held; then
4811
+ err "roll test --reset: another reset is already in progress"
4812
+ echo " lock: $(_isolation_reset_lock_path) (delete manually if stale)" >&2
4813
+ return 1
4814
+ fi
4815
+ _isolation_reset_acquire_lock || {
4816
+ err "roll test --reset: failed to acquire reset lock"
4817
+ return 1
4818
+ }
4819
+ # Make sure the lock comes off no matter how dispatch exits.
4820
+ trap '_isolation_reset_release_lock' RETURN
4821
+ _isolation_dispatch reset
4822
+ return $?
4823
+ ;;
4824
+ --)
4825
+ shift
4826
+ ;;
4827
+ esac
4828
+
4829
+ # Test-execution path. If a reset is in progress, bail rather than racing
4830
+ # into a half-rebuilt VM — user can `roll test --where` to inspect state.
4831
+ if _isolation_reset_lock_held; then
4832
+ err "roll test: a reset is in progress (lock: $(_isolation_reset_lock_path))"
4833
+ echo " re-run once the reset completes, or delete the lockfile if stale" >&2
4834
+ return 1
4835
+ fi
4836
+
4837
+ # Pass remaining args through to npm test inside the configured adapter.
4838
+ _isolation_dispatch exec npm test "$@"
4839
+ }
4840
+
4420
4841
  # ═══════════════════════════════════════════════════════════════════════════════
4421
4842
  # LOOP — autonomous BACKLOG executor management
4422
4843
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -6768,11 +7189,33 @@ _loop_attach() {
6768
7189
  exec tmux attach -t "$session"
6769
7190
  }
6770
7191
 
7192
+ # FIX-125: detect whether we are running inside a loop cycle. Cycle context
7193
+ # is signalled by env vars exported by the cycle runner (ROLL_LOOP_AGENT,
7194
+ # bin/roll:5736) or by the outer cycle script (ROLL_CYCLE_LOG_RAW,
7195
+ # bin/roll:6044). Used by callers that touch canonical ${HOME}/Library/LaunchAgents
7196
+ # directly (_loop_gc, cmd_offboard) to refuse host-loop mutations from inside
7197
+ # a cycle. Read-only ops are unaffected.
7198
+ _loop_in_cycle() {
7199
+ [[ -n "${ROLL_LOOP_AGENT:-}" || -n "${ROLL_CYCLE_LOG_RAW:-}" ]]
7200
+ }
7201
+
6771
7202
  # US-LOOP-021: garbage-collect orphan slugs, tmp debris, and expired backups.
6772
7203
  # Usage: _loop_gc [--dry-run] [--keep-days N]
6773
7204
  # Keeps backups/migrated files within N days (default 30).
6774
7205
  # Retention order: ROLL_LOOP_GC_RETENTION_DAYS env > .roll/local.yaml loop_gc.retention_days > 30.
6775
7206
  _loop_gc() {
7207
+ # FIX-125: refuse from inside a loop cycle. Phase 1 below scans/mutates
7208
+ # ${HOME}/Library/LaunchAgents directly (bin/roll:6814,6847) — running it
7209
+ # from a cycle would let one project's tick remove another project's plist
7210
+ # under the host's launchd domain. Read-only ops (status, runs) are
7211
+ # unaffected; only the GC mutator is gated.
7212
+ if _loop_in_cycle; then
7213
+ echo "roll loop gc: refusing — cycle-context tripwire (FIX-125)" >&2
7214
+ echo " This command scans ~/Library/LaunchAgents directly. Running it" >&2
7215
+ echo " from inside a loop cycle is a known host-state corruption path." >&2
7216
+ return 1
7217
+ fi
7218
+
6776
7219
  local dry_run=false
6777
7220
  local keep_days=30
6778
7221
 
@@ -9854,6 +10297,7 @@ main() {
9854
10297
  doctor) cmd_doctor "$@" ;;
9855
10298
  review-pr) cmd_review_pr "$@" ;;
9856
10299
  slides) cmd_slides "$@" ;;
10300
+ test) cmd_test "$@" ;;
9857
10301
  prices) cmd_prices "$@" ;;
9858
10302
  changelog) cmd_changelog "$@" ;;
9859
10303
  version|--version|-v) echo "roll v${VERSION}" ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.527.1",
3
+ "version": "2026.528.1",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -121,7 +121,7 @@ Document structure (two-layer separation):
121
121
  3. **FIX / IDEA detail files use ID-prefixed filenames**: `.roll/features/<epic>/FIX-097.md`, not `.roll/features/<epic>/some-descriptive-slug.md`. Reason: a single FIX is one card, not a long-lived feature; the ID is the most stable handle, descriptive slugs date quickly and break links. US can keep feature-slug naming (US lives inside a multi-Story feature file). Quick lookup: `ls .roll/features/<epic>/FIX-*.md` finds all bugs in that area without grepping content.
122
122
  4. .roll/backlog.md only contains index rows (one row per US), **do not write** AC / Files / Notes
123
123
  5. Domain model files go in `.roll/domain/` — create on first greenfield design, update incrementally
124
- 6. **Do not** write to `~/.kimi/` or any global config directory
124
+ 6. **Do not** write to `~/.kimi/`, `~/.kimi-code/`, or any global config directory
125
125
 
126
126
  **File path resolution order:**
127
127
  1. Determine Feature ownership (based on the requirement domain: compiler / ingest / qa / ...)