@seanyao/roll 2026.601.1 → 2026.601.3

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,28 @@
1
1
  # Changelog
2
2
 
3
+ ## v2026.601.3
4
+
5
+ ### 可见性
6
+
7
+ - **kimi cycle 现在也能看到 token 和成本(FIX-154)** — 以前 dashboard 对 kimi 那一行全是 `—/—`,看不到主力 agent 花了多少钱;现在 cycle 跑完读 kimi-code 的 `wire.jsonl`,把 token 数和成本写进事件流,RECENT 视图和成本总闸都看得见 `[loop]`
8
+
9
+ ### 稳定性
10
+
11
+ - **loop 把活派给 AI 后现在真会动手,不再空转零产出(FIX-152)** — kimi 等对话式 agent 拿到 SKILL.md 会把它当成"贴过来的文档"反问"What would you like me to do?",8 秒空返没交付;技能正文前置一条 agent 无关的自主执行指令,kimi/claude/pi/codex/agy 现在都会直接动手 `[loop]`
12
+ - **agy 在 loop / cron 自动化里不再卡 tty 等待(FIX-153)** — antigravity(agy)默认要 tty 批准操作,自动化场景拿不到 tty 就一直挂着等;现在 headless 模式自动加 `-p` 和跳过权限标记,跑得到结果 `[loop]`
13
+ - **测试不再在桌面弹空报错终端(FIX-155)** — bats 测试跑完临时沙箱删了,但 peer auto-attach 弹的 Terminal 窗口指向那个已不存在的路径,桌面堆一堆空报错的死窗口;给 peer 弹窗补上和 loop 弹窗一样的测试守卫,测试上下文不再弹 `[loop]`
14
+
15
+ ## v2026.601.2
16
+
17
+ ### 新功能
18
+
19
+ - **curl 安装器骨架:不靠 npm 也能装 roll(US-INSTALL-001)** — 自包含安装脚本探测 OS(仅放行 macOS / Linux)、preflight 检查 `bash≥3.2`/`python3`/`curl`/`tar` 缺啥报啥、把运行时装到 `~/.local/share/roll/` 并 symlink 进 PATH,重复运行即原地升级。本版先从本地源目录复制(真正的 `curl ... | bash` 远端取数留后续故事)`[loop]`
20
+
21
+ ### 可见性
22
+
23
+ - **peer 评审可靠落盘、能查了(FIX-150a)** — 此前 peer 痕迹碎成三处且大多丢失;统一落盘到项目本地规范路径,新增查询命令翻看历次 peer 记录(发起方 / 对象 / 轮次 / 各方结论 / 耗时),不再依赖 agent 自觉写盘 `[loop]`
24
+ - **三个专用 loop(CI / PR / Alert)空闲时也留心跳(FIX-151)** — 健康空闲时不再零日志让人以为没在跑;每轮补一条轻量存活心跳,status 显示各 loop 上次运行距今多久 `[loop]`
25
+
3
26
  ## v2026.601.1
4
27
 
5
28
  ### 新功能
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.601.1"
7
+ VERSION="2026.601.3"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -3713,6 +3713,48 @@ _peer_ensure_state_dir() {
3713
3713
  mkdir -p "${_PEER_STATE_DIR}/logs"
3714
3714
  }
3715
3715
 
3716
+ # FIX-150a: project-local peer data directory (analogous to loop runtime dir).
3717
+ _peer_project_dir() {
3718
+ local proj
3719
+ proj=$(pwd -P 2>/dev/null || pwd)
3720
+ # FIX-056: normalize path to canonical case on macOS case-insensitive filesystem.
3721
+ if [[ "$(uname -s 2>/dev/null)" == "Darwin" ]]; then
3722
+ local _canon
3723
+ _canon=$(realpath "$proj" 2>/dev/null) && proj="$_canon"
3724
+ fi
3725
+ # When inside a git worktree, resolve to main tree (same pattern as _project_slug).
3726
+ local _common
3727
+ _common=$(git -C "$proj" rev-parse --git-common-dir 2>/dev/null)
3728
+ if [[ -n "$_common" && "$_common" == *"/.git" ]]; then
3729
+ proj="${_common%/.git}"
3730
+ fi
3731
+ echo "${proj}/.roll/peer"
3732
+ }
3733
+
3734
+ _peer_ensure_project_dir() {
3735
+ local dir
3736
+ dir=$(_peer_project_dir)
3737
+ mkdir -p "$dir/logs"
3738
+ }
3739
+
3740
+ # FIX-150a: write a structured JSONL record to the project-local peer runs file.
3741
+ _peer_write_record() {
3742
+ local from_tool="$1"
3743
+ local to_tool="$2"
3744
+ local round="$3"
3745
+ local verdict="$4"
3746
+ local tag="$5"
3747
+ local duration_sec="$6"
3748
+ local dir
3749
+ dir=$(_peer_project_dir)
3750
+ mkdir -p "$dir"
3751
+ local ts
3752
+ ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
3753
+ printf '{"ts":"%s","from":"%s","to":"%s","round":%s,"verdict":"%s","tag":"%s","duration_sec":%s}\n' \
3754
+ "$ts" "$from_tool" "$to_tool" "$round" "$verdict" "$tag" "$duration_sec" \
3755
+ >> "$dir/runs.jsonl"
3756
+ }
3757
+
3716
3758
  _peer_state_file() {
3717
3759
  local pair="$1"
3718
3760
  local key="$2"
@@ -3799,6 +3841,8 @@ _peer_route() {
3799
3841
  _peer_auto_attach() {
3800
3842
  local session="$1"
3801
3843
  [ "$(uname)" = "Darwin" ] || return 0
3844
+ [ -n "${BATS_TEST_NUMBER:-}" ] && return 0
3845
+ [ -n "${ROLL_LOOP_NO_POPUP:-}" ] && return 0
3802
3846
  [ -f "$_LOOP_MUTE_FILE" ] && return 0
3803
3847
  local attach_cmd="${_SHARED_ROOT}/loop/attach-${session}.command"
3804
3848
  # Drop `exec` so the wrapping shell survives `tmux attach` exiting; pause
@@ -3928,6 +3972,8 @@ cmd_peer() {
3928
3972
  --yes|--yolo) yolo=true; shift ;;
3929
3973
  status) subcmd="status"; shift ;;
3930
3974
  reset) subcmd="reset"; shift; break ;;
3975
+ log) subcmd="log"; shift ;;
3976
+ runs) subcmd="runs"; shift ;;
3931
3977
  help|--help|-h) subcmd="help"; shift ;;
3932
3978
  *) err "$(msg peer.unknown_option_1)"; exit 1 ;;
3933
3979
  esac
@@ -3936,6 +3982,8 @@ cmd_peer() {
3936
3982
  case "$subcmd" in
3937
3983
  status) cmd_peer_status; return ;;
3938
3984
  reset) cmd_peer_reset "$@"; return ;;
3985
+ log) cmd_peer_log; return ;;
3986
+ runs) cmd_peer_runs "$@"; return ;;
3939
3987
  help) cmd_peer_help; return ;;
3940
3988
  esac
3941
3989
 
@@ -3985,6 +4033,9 @@ cmd_peer() {
3985
4033
  fi
3986
4034
  fi
3987
4035
 
4036
+ local start_epoch
4037
+ start_epoch=$(date +%s)
4038
+
3988
4039
  local context=""
3989
4040
  if [[ -n "$context_file" && -f "$context_file" ]]; then
3990
4041
  context="$(cat "$context_file")"
@@ -4004,9 +4055,10 @@ cmd_peer() {
4004
4055
  fi
4005
4056
  fi
4006
4057
 
4007
- _peer_ensure_state_dir
4058
+ # FIX-150a: write logs to project-local path; keep global state dir for adaptive routing.
4059
+ _peer_ensure_project_dir
4008
4060
  local log_file
4009
- log_file="${_PEER_STATE_DIR}/logs/$(date +%Y%m%d_%H%M%S)_${from_tool}_${to_tool}.md"
4061
+ log_file="$(_peer_project_dir)/logs/$(date +%Y%m%d_%H%M%S)_${from_tool}_${to_tool}.md"
4010
4062
  {
4011
4063
  echo "# Peer Review Log"
4012
4064
  echo ""
@@ -4051,6 +4103,11 @@ cmd_peer() {
4051
4103
 
4052
4104
  _peer_update_state "$pair" "$resolution"
4053
4105
 
4106
+ # FIX-150a: write structured record for observability.
4107
+ local duration_sec=0
4108
+ duration_sec=$(( $(date +%s) - start_epoch ))
4109
+ _peer_write_record "$from_tool" "$to_tool" "$round" "$resolution" "$tag" "$duration_sec"
4110
+
4054
4111
  echo ""
4055
4112
  echo -e "$(msg peer.peer_review_result_peer_review ${BOLD} ${NC})"
4056
4113
  echo " Pair: $pair"
@@ -4185,10 +4242,72 @@ cmd_peer_help() {
4185
4242
  echo ""
4186
4243
  echo "Subcommands:"
4187
4244
  echo "$(msg peer_help.status_show_peer_review_state)"
4245
+ echo "$(msg peer_help.log_show_latest_peer_transcript)"
4246
+ echo "$(msg peer_help.runs_show_recent_peer_review_runs)"
4188
4247
  echo "$(msg peer_help.reset_pair_all_reset_peer_state)"
4189
4248
  echo "$(msg peer_help.help_show_this_help)"
4190
4249
  }
4191
4250
 
4251
+ # FIX-150a: `roll peer runs [N]` — show recent peer review runs (project-local).
4252
+ cmd_peer_runs() {
4253
+ local n=10
4254
+ while [[ $# -gt 0 ]]; do
4255
+ case "$1" in
4256
+ [0-9]*) n="$1"; shift ;;
4257
+ *) shift ;;
4258
+ esac
4259
+ done
4260
+
4261
+ local dir
4262
+ dir=$(_peer_project_dir)
4263
+ local runs_file="$dir/runs.jsonl"
4264
+
4265
+ if ! command -v jq >/dev/null 2>&1; then
4266
+ err "$(msg peer.jq_required_for_roll_peer_runs)"
4267
+ return 1
4268
+ fi
4269
+
4270
+ if [[ ! -f "$runs_file" ]] || [[ ! -s "$runs_file" ]]; then
4271
+ echo "$(msg peer.no_peer_runs_yet)"
4272
+ return 0
4273
+ fi
4274
+
4275
+ local reversed
4276
+ reversed=$(awk '{a[NR]=$0} END{for(i=NR; i>=1; i--) print a[i]}' "$runs_file")
4277
+ local recent
4278
+ recent=$(printf '%s\n' "$reversed" | head -n "$n")
4279
+
4280
+ echo -e "${BOLD}Peer Review Runs${NC}"
4281
+ echo ""
4282
+ printf "%-19s %-8s %-10s %-5s %-10s %s\n" "Time" "From" "To" "Rnd" "Verdict" "Tag"
4283
+ printf "%s\n" "─────────────────── ──────── ────────── ───── ────────── ──────────"
4284
+
4285
+ while IFS= read -r line; do
4286
+ [[ -z "$line" ]] && continue
4287
+ local ts from to round verdict tag
4288
+ ts=$(printf '%s' "$line" | jq -r '.ts // "—"')
4289
+ from=$(printf '%s' "$line" | jq -r '.from // "—"')
4290
+ to=$(printf '%s' "$line" | jq -r '.to // "—"')
4291
+ round=$(printf '%s' "$line" | jq -r '.round // "—"')
4292
+ verdict=$(printf '%s' "$line" | jq -r '.verdict // "—"')
4293
+ tag=$(printf '%s' "$line" | jq -r '.tag // "—"')
4294
+ printf "%-19s %-8s %-10s %-5s %-10s %s\n" "$ts" "$from" "$to" "$round" "$verdict" "$tag"
4295
+ done <<<"$recent"
4296
+ }
4297
+
4298
+ # FIX-150a: `roll peer log` — show the latest peer review transcript.
4299
+ cmd_peer_log() {
4300
+ local dir
4301
+ dir=$(_peer_project_dir)
4302
+ local latest
4303
+ latest=$(ls "$dir/logs"/*.md 2>/dev/null | sort | tail -1 || true)
4304
+ if [[ -z "$latest" || ! -f "$latest" ]]; then
4305
+ echo "$(msg peer.no_peer_logs_found)"
4306
+ return 0
4307
+ fi
4308
+ cat "$latest"
4309
+ }
4310
+
4192
4311
  # ═══════════════════════════════════════════════════════════════════════════════
4193
4312
  # AGENT — per-project agent configuration
4194
4313
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -4294,10 +4413,12 @@ _agent_argv() {
4294
4413
  # late 2025. agy reuses ~/.gemini/ for config and reads GEMINI.md
4295
4414
  # natively, so the convention sync target is unchanged — only the
4296
4415
  # invoked binary changes.
4416
+ # FIX-153: non-interactive modes must use -p (headless) +
4417
+ # --dangerously-skip-permissions so the agent does not hang waiting
4418
+ # for a tty approval that never comes in loop/cron contexts.
4297
4419
  case "$mode" in
4298
4420
  interactive) _AGENT_ARGV=(agy -i "$prompt") ;;
4299
- text|peer) _AGENT_ARGV=(agy "$prompt") ;;
4300
- *) _AGENT_ARGV=(agy "$prompt") ;;
4421
+ *) _AGENT_ARGV=(agy -p --dangerously-skip-permissions "$prompt") ;;
4301
4422
  esac ;;
4302
4423
  qwen)
4303
4424
  # qwen has the same argv shape in both modes (positional prompt).
@@ -7615,6 +7736,84 @@ _loop_event_rotate() {
7615
7736
  fi
7616
7737
  }
7617
7738
 
7739
+ # FIX-151: write a lightweight tick heartbeat for dedicated loops (pr/ci/alert).
7740
+ # Appends one JSONL line per tick; rotates by line count to control bloat.
7741
+ _loop_write_tick() {
7742
+ local loop_type="${1:-}" outcome="${2:-idle}" note="${3:-}"
7743
+ [ -n "$loop_type" ] || return 0
7744
+ local slug tick_file
7745
+ slug=$(_project_slug 2>/dev/null || basename "$PWD")
7746
+ local _rt_dir
7747
+ _rt_dir=$(_loop_runtime_dir "$slug" 2>/dev/null || echo "")
7748
+ if [ -n "$_rt_dir" ]; then
7749
+ tick_file="${_rt_dir}/${loop_type}-tick.jsonl"
7750
+ else
7751
+ tick_file="${_SHARED_ROOT:-$HOME/.shared/roll}/loop/${loop_type}-tick-${slug}.jsonl"
7752
+ fi
7753
+ mkdir -p "$(dirname "$tick_file")" 2>/dev/null || true
7754
+ local ts
7755
+ ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
7756
+ printf '{"ts":"%s","loop":"%s","outcome":"%s","note":"%s"}\n' \
7757
+ "$ts" "$loop_type" "$outcome" "$note" >> "$tick_file"
7758
+
7759
+ # Rotate: alert loop (1 min) → 1000 lines (~16h); ci/pr (5 min) → 500 lines (~42h)
7760
+ local max_lines=500
7761
+ [ "$loop_type" = "alert" ] && max_lines=1000
7762
+ local line_count
7763
+ line_count=$(wc -l < "$tick_file" 2>/dev/null | tr -d ' \t' || echo 0)
7764
+ case "$line_count" in ''|*[!0-9]*) line_count=0 ;; esac
7765
+ if [ "$line_count" -gt "$max_lines" ]; then
7766
+ tail -n "$max_lines" "$tick_file" > "${tick_file}.tmp" && mv "${tick_file}.tmp" "$tick_file"
7767
+ fi
7768
+ }
7769
+
7770
+ # FIX-151: read the last tick line from a dedicated loop's tick file.
7771
+ # Optional second arg selects a JSON field (ts, loop, outcome, note).
7772
+ _loop_read_last_tick() {
7773
+ local loop_type="${1:-}" field="${2:-}"
7774
+ [ -n "$loop_type" ] || return 0
7775
+ local slug tick_file
7776
+ slug=$(_project_slug 2>/dev/null || basename "$PWD")
7777
+ local _rt_dir
7778
+ _rt_dir=$(_loop_runtime_dir "$slug" 2>/dev/null || echo "")
7779
+ if [ -n "$_rt_dir" ]; then
7780
+ tick_file="${_rt_dir}/${loop_type}-tick.jsonl"
7781
+ else
7782
+ tick_file="${_SHARED_ROOT:-$HOME/.shared/roll}/loop/${loop_type}-tick-${slug}.jsonl"
7783
+ fi
7784
+ [ -f "$tick_file" ] || return 0
7785
+ local last
7786
+ last=$(tail -1 "$tick_file" 2>/dev/null || echo "")
7787
+ [ -n "$last" ] || return 0
7788
+ if [ -n "$field" ]; then
7789
+ echo "$last" | awk -F'"' '{for(i=2;i<NF;i+=2) if($i=="'"$field"'") {print $(i+2); exit}}' 2>/dev/null || echo ""
7790
+ else
7791
+ printf '%s\n' "$last"
7792
+ fi
7793
+ }
7794
+
7795
+ # FIX-151: compute human-readable age of the last tick for a dedicated loop.
7796
+ # Prints something like "5s", "3m", "2h" or empty string if no tick.
7797
+ _loop_tick_age() {
7798
+ local loop_type="${1:-}"
7799
+ [ -n "$loop_type" ] || return 0
7800
+ local ts
7801
+ ts=$(_loop_read_last_tick "$loop_type" "ts")
7802
+ [ -n "$ts" ] || return 0
7803
+ local tick_epoch now_epoch age
7804
+ tick_epoch=$(date -d "$ts" +%s 2>/dev/null || date -jf "%Y-%m-%dT%H:%M:%SZ" "$ts" +%s 2>/dev/null || echo 0)
7805
+ [ "$tick_epoch" -gt 0 ] || return 0
7806
+ now_epoch=$(date +%s)
7807
+ age=$(( now_epoch - tick_epoch ))
7808
+ if [ "$age" -lt 60 ]; then
7809
+ echo "${age}s"
7810
+ elif [ "$age" -lt 3600 ]; then
7811
+ echo "$(( age / 60 ))m"
7812
+ else
7813
+ echo "$(( age / 3600 ))h"
7814
+ fi
7815
+ }
7816
+
7618
7817
  # US-OBS-014: after a loop cycle reaches a terminal cycle_end (done or idle),
7619
7818
  # fire a best-effort, background status-snapshot push to roll-meta so the
7620
7819
  # remote-watch prompt always sees ≤35min-fresh data — no user-side cron needed.
@@ -7967,7 +8166,11 @@ _write_loop_runner_script() {
7967
8166
  # US-LOOP-026: post-cycle single-shot usage writer for non-claude agents.
7968
8167
  # pi -p text mode prints no usage, so we recover it from pi's session jsonl
7969
8168
  # exactly once per cycle (loop-fmt passthrough is display-only).
8169
+ # FIX-154: kimi-code's `-p` mode also writes nothing to stdout but persists
8170
+ # usage to wire.jsonl; kimi_emit covers that path. bin/roll dispatches by
8171
+ # agent (pi/deepseek → pi_emit, kimi → kimi_emit).
7970
8172
  local pi_emit_script="${ROLL_PKG_DIR}/lib/agent_usage/pi_emit.py"
8173
+ local kimi_emit_script="${ROLL_PKG_DIR}/lib/agent_usage/kimi_emit.py"
7971
8174
  local roll_bin="${ROLL_PKG_DIR}/bin/roll"
7972
8175
  # US-EVAL-002: pure-function rubric scorer (US-EVAL-001). Baked in at
7973
8176
  # generation time so the inner runner can compute result_eval at cycle finish.
@@ -8636,23 +8839,37 @@ else
8636
8839
  _phase_end agent_invoke ok
8637
8840
  fi
8638
8841
 
8639
- # US-LOOP-026: non-claude agents (pi/deepseek/kimi) print no usage in -p text
8640
- # mode. Recover token+cost once per cycle from the agent's session jsonl and
8641
- # append a single authoritative usage event. Done here (not in loop-fmt's
8642
- # per-attempt passthrough) so retries can't write N duplicate events that the
8643
- # dashboard's same-label SUM would inflate. Runs before the timeout-abort exit
8644
- # so partial cycles still get whatever usage the session recorded. The events
8645
- # path is resolved exactly like _loop_event (rt_dir first, shared fallback) so
8646
- # pi_emit appends to the same file the reader consumes.
8647
- if [ "\$(_project_agent)" != "claude" ] && [ -f "${pi_emit_script}" ]; then
8842
+ # US-LOOP-026 + FIX-154: non-claude agents (pi/deepseek/kimi) print no usage
8843
+ # in -p text mode. Recover token+cost once per cycle from the agent's session
8844
+ # jsonl and append a single authoritative usage event. Done here (not in
8845
+ # loop-fmt's per-attempt passthrough) so retries can't write N duplicate
8846
+ # events that the dashboard's same-label SUM would inflate. Runs before the
8847
+ # timeout-abort exit so partial cycles still get whatever usage the session
8848
+ # recorded. The events path is resolved exactly like _loop_event (rt_dir
8849
+ # first, shared fallback) so the emitter appends to the same file the reader
8850
+ # consumes. Dispatch by agent so each emitter reads the right session format
8851
+ # (pi.usage_from_session vs kimi.usage_from_session).
8852
+ if [ "\$(_project_agent)" != "claude" ]; then
8648
8853
  _pi_rt=\$(_loop_runtime_dir "${slug}" 2>/dev/null || echo "")
8649
8854
  if [ -n "\$_pi_rt" ]; then
8650
8855
  _pi_evfile="\${_pi_rt}/events.ndjson"
8651
8856
  else
8652
8857
  _pi_evfile="\${_SHARED_ROOT:-\$HOME/.shared/roll}/loop/events-${slug}.ndjson"
8653
8858
  fi
8654
- python3 "${pi_emit_script}" --cwd "\$WT" --cycle "\${CYCLE_ID}" \\
8655
- --slug "${slug}" --events "\$_pi_evfile" 2>/dev/null || true
8859
+ case "\$(_project_agent)" in
8860
+ kimi)
8861
+ if [ -f "${kimi_emit_script}" ]; then
8862
+ python3 "${kimi_emit_script}" --cwd "\$WT" --cycle "\${CYCLE_ID}" \\
8863
+ --slug "${slug}" --events "\$_pi_evfile" 2>/dev/null || true
8864
+ fi
8865
+ ;;
8866
+ *)
8867
+ if [ -f "${pi_emit_script}" ]; then
8868
+ python3 "${pi_emit_script}" --cwd "\$WT" --cycle "\${CYCLE_ID}" \\
8869
+ --slug "${slug}" --events "\$_pi_evfile" 2>/dev/null || true
8870
+ fi
8871
+ ;;
8872
+ esac
8656
8873
  fi
8657
8874
 
8658
8875
  # FIX-057: timed out — skip publish; EXIT trap writes cycle_end blocked + ALERT.
@@ -9228,7 +9445,15 @@ _agent_skill_cmd() {
9228
9445
  for ((i = 1; i < prompt_idx; i++)); do
9229
9446
  out+=" ${_AGENT_ARGV[i]}"
9230
9447
  done
9231
- echo "${out} \"\$(${strip})\""
9448
+ # FIX-152: prepend an explicit autonomous-execution directive ahead of the bare
9449
+ # SKILL.md body. Without it, conversational `-p` agents (notably kimi-code) read
9450
+ # the skill doc as pasted context and reply "what would you like me to do?",
9451
+ # returning in seconds with zero output → the cycle ends idle, no delivery.
9452
+ # pi/deepseek/claude tolerate the bare doc, but the directive is agent-agnostic
9453
+ # and hardens every autonomous cron skill (loop/dream/brief share this chokepoint).
9454
+ # Must stay free of " $ ` \ so it survives the later `eval` of the cycle command.
9455
+ local _autorun='[roll 自主模式] 你正在无人值守的自动化循环中运行,这不是对话。请立即、完整地执行下面这份技能文档描述的工作流,直到完成交付或写出 ALERT 为止;严禁反问、严禁等待确认、严禁只复述或总结而不动手。技能文档如下: '
9456
+ echo "${out} \"${_autorun}\$(${strip})\""
9232
9457
  }
9233
9458
 
9234
9459
  # FIX-134: build the full per-cycle agent command at RUNTIME, routing-aware.
@@ -9783,8 +10008,13 @@ _legacy_loop_status() {
9783
10008
  fi
9784
10009
  echo -e " ${YELLOW}loop ⏸ paused${NC}${_dur} run: roll loop resume"
9785
10010
  else
10011
+ local _tick_age=""
10012
+ case "$svc" in pr|ci|alert)
10013
+ _tick_age=$(_loop_tick_age "$svc")
10014
+ [ -n "$_tick_age" ] && _tick_age=" tick ${_tick_age}"
10015
+ esac
9786
10016
  case "$state" in
9787
- enabled) echo -e " ${GREEN}${svc} ● enabled${NC}" ;;
10017
+ enabled) echo -e " ${GREEN}${svc} ● enabled${NC}${_tick_age}" ;;
9788
10018
  stale|installed-off) echo -e " ${YELLOW}${svc} ⚠ STALE — plist present but not loaded${NC} run: roll loop on" ;;
9789
10019
  not-installed) echo -e " ${RED}${svc} ○ not installed${NC} run: roll setup" ;;
9790
10020
  esac
@@ -11193,16 +11423,16 @@ _loop_pr_merge_self_eager() {
11193
11423
  # Walks open PRs and routes each by classification.
11194
11424
  # Lenient on gh unavailability — returns 0 so the loop continues to BACKLOG.
11195
11425
  _loop_pr_inbox() {
11196
- local slug; _gh_resolve slug || return 0
11426
+ local slug; _gh_resolve slug || { _loop_write_tick "pr" "idle" "gh_unavailable"; return 0; }
11197
11427
  local prs_json
11198
11428
  prs_json=$(gh -R "$slug" pr list --state open \
11199
11429
  --json number,headRefName,author,title \
11200
- 2>/dev/null) || return 0
11201
- [ -n "$prs_json" ] || return 0
11202
- [ "$prs_json" = "[]" ] && return 0
11430
+ 2>/dev/null) || { _loop_write_tick "pr" "idle" "gh_error"; return 0; }
11431
+ [ -n "$prs_json" ] || { _loop_write_tick "pr" "idle" "empty_response"; return 0; }
11432
+ [ "$prs_json" = "[]" ] && { _loop_write_tick "pr" "idle" "no_open_prs"; return 0; }
11203
11433
 
11204
11434
  local count; count=$(echo "$prs_json" | jq 'length' 2>/dev/null || echo 0)
11205
- [ "${count:-0}" -gt 0 ] || return 0
11435
+ [ "${count:-0}" -gt 0 ] || { _loop_write_tick "pr" "idle" "zero_prs"; return 0; }
11206
11436
 
11207
11437
  local i=0
11208
11438
  while [ "$i" -lt "$count" ]; do
@@ -11270,6 +11500,7 @@ _loop_pr_inbox() {
11270
11500
 
11271
11501
  i=$((i + 1))
11272
11502
  done
11503
+ _loop_write_tick "pr" "acted" "inbox_done"
11273
11504
  return 0
11274
11505
  }
11275
11506
 
@@ -11778,13 +12009,13 @@ _ci_detect_degradation() {
11778
12009
  # accumulated history. Lenient on gh unavailability (missing / failed list →
11779
12010
  # return 0) so the service never errors out a tick.
11780
12011
  _ci_scan() {
11781
- local slug; _gh_resolve slug 2>/dev/null || return 0
12012
+ local slug; _gh_resolve slug 2>/dev/null || { _loop_write_tick "ci" "idle" "gh_unavailable"; return 0; }
11782
12013
 
11783
12014
  local runs_json
11784
12015
  runs_json=$(gh -R "$slug" run list --branch main \
11785
12016
  --json databaseId,workflowName,name,conclusion,status,createdAt,updatedAt \
11786
- 2>/dev/null) || return 0
11787
- [ -n "$runs_json" ] || return 0
12017
+ 2>/dev/null) || { _loop_write_tick "ci" "idle" "gh_error"; return 0; }
12018
+ [ -n "$runs_json" ] || { _loop_write_tick "ci" "idle" "empty_response"; return 0; }
11788
12019
 
11789
12020
  # An empty list ("[]") still falls through to the detectors below: they run
11790
12021
  # over accumulated history, not just this tick's runs.
@@ -11810,6 +12041,7 @@ _ci_scan() {
11810
12041
 
11811
12042
  _ci_detect_flaky
11812
12043
  _ci_detect_degradation
12044
+ _loop_write_tick "ci" "acted" "scan_done"
11813
12045
  return 0
11814
12046
  }
11815
12047
 
@@ -11977,12 +12209,12 @@ _alert_rotate() {
11977
12209
  # A missing/empty alert file is a no-op (no rotate, no log). Loop-safe.
11978
12210
  _alert_dispatch() {
11979
12211
  local file="${1:-$_LOOP_ALERT}"
11980
- [ -n "$file" ] && [ -f "$file" ] || return 0
12212
+ [ -n "$file" ] && [ -f "$file" ] || { _loop_write_tick "alert" "idle" "no_file"; return 0; }
11981
12213
  # Empty file → nothing to consume, leave it in place.
11982
- [ -s "$file" ] || return 0
12214
+ [ -s "$file" ] || { _loop_write_tick "alert" "idle" "empty_file"; return 0; }
11983
12215
 
11984
12216
  local parsed; parsed=$(_alert_parse_file "$file")
11985
- [ -n "$parsed" ] || { _alert_rotate "$file"; return 0; }
12217
+ [ -n "$parsed" ] || { _alert_rotate "$file"; _loop_write_tick "alert" "idle" "no_parsed"; return 0; }
11986
12218
 
11987
12219
  local line ts level category message notify
11988
12220
  local _oifs="$IFS"
@@ -12008,6 +12240,7 @@ _alert_dispatch() {
12008
12240
  IFS="$_oifs"
12009
12241
 
12010
12242
  _alert_rotate "$file"
12243
+ _loop_write_tick "alert" "acted" "dispatch_done"
12011
12244
  return 0
12012
12245
  }
12013
12246
 
@@ -14399,9 +14632,11 @@ _dash_refactor_pending() {
14399
14632
 
14400
14633
  # ② Peer layer: last result + days ago from peer log, empty if no log.
14401
14634
  _dash_last_peer() {
14402
- local peer_log_dir="${HOME}/.shared/roll/peer"
14635
+ # FIX-150a: read from project-local peer logs (was ~/.shared/roll/peer/*.log).
14636
+ local peer_log_dir
14637
+ peer_log_dir=$(_peer_project_dir)/logs
14403
14638
  local latest
14404
- latest=$(ls "$peer_log_dir"/*.log 2>/dev/null | sort | tail -1 || true)
14639
+ latest=$(ls "$peer_log_dir"/*.md 2>/dev/null | sort | tail -1 || true)
14405
14640
  [[ -z "$latest" || ! -f "$latest" ]] && return 0
14406
14641
  local result
14407
14642
  result=$(grep -oE '(AGREE|REFINE|OBJECT|ESCALATE)' "$latest" 2>/dev/null | tail -1 || true)
@@ -1,29 +1,33 @@
1
1
  """
2
2
  kimi (Moonshot Kimi CLI) agent usage extractor.
3
3
 
4
- Like openai and gemini (and unlike pi, which persists usage to session
5
- files), the Kimi CLI prints a token-usage summary to stdout at the end of a
6
- session. So this plugin implements the standard ``extract()`` registry
7
- contract: scrape the passthrough stdout lines for the usage / model lines.
4
+ Two paths are supported, mirroring pi.py:
8
5
 
9
- Recognised lines (case-insensitive, robust to thousands separators)::
6
+ 1. ``extract()`` — the registry stdout-scrape contract, kept for legacy
7
+ callers (and as a fallback when session files are absent).
8
+ 2. ``usage_from_session()`` — authoritative recovery from kimi-code's
9
+ persisted session files at ``~/.kimi-code/sessions/wd_*/session_*/agents/main/wire.jsonl``.
10
+ Each wire file is NDJSON with one or more ``{"type":"usage.record","model":...,"usage":{...}}``
11
+ lines whose token fields are summed per cycle.
10
12
 
11
- Model: kimi-k2
12
- Tokens: input=15300 output=3120
13
+ FIX-154 added the session path so loop cycles run by kimi-code (the
14
+ default agent today) no longer show ``—/—`` for tokens and cost in the
15
+ RECENT dashboard.
13
16
 
14
- The Kimi CLI's "usage" / session-summary block is also accepted::
17
+ The stdout-scrape contract still recognises (case-insensitive)::
15
18
 
19
+ Model: kimi-k2
20
+ Tokens: input=15300 output=3120
16
21
  Input tokens: 15,300
17
22
  Output tokens: 3,120
18
23
  Total tokens: 18,420
19
- model: kimi-k2
20
24
 
21
25
  When an explicit USD cost line isn't present, cost is computed from
22
- ``lib/model_prices.py`` (list price) so the dashboard never shows ``—``
23
- for a recognised kimi cycle. Returns None if no usage line is found,
24
- so the caller falls back to the null payload (US-LOOP-010 compatible).
26
+ ``lib/model_prices.py`` (list price).
25
27
  """
26
28
 
29
+ import glob
30
+ import json
27
31
  import os
28
32
  import re
29
33
  import sys
@@ -125,3 +129,150 @@ def extract(stdin_lines: list[str]) -> Optional[dict]:
125
129
  "cost_list_usd": cost,
126
130
  "duration_ms": None,
127
131
  }
132
+
133
+
134
+ # ── Session-file extraction (authoritative, FIX-154) ───────────────────────
135
+
136
+ # kimi-code persists every CLI session under
137
+ # ``~/.kimi-code/sessions/wd_<cwd-basename>_<8-hex>/session_<uuid>/agents/main/wire.jsonl``
138
+ # where ``<cwd-basename>`` is the basename of the cycle's worktree
139
+ # (e.g. ``roll-ecf079-cycle-20260601-170905-54957``).
140
+ # Each wire file is NDJSON; one or more lines have::
141
+ #
142
+ # {"type": "usage.record", "model": "kimi-code/kimi-for-coding",
143
+ # "usage": {"inputOther": <int>, "output": <int>,
144
+ # "inputCacheRead": <int>, "inputCacheCreation": <int>},
145
+ # "usageScope": "turn", "time": <ms>}
146
+ #
147
+ # We sum across all matching wire files (retries reuse the same worktree).
148
+
149
+
150
+ def _kimi_sessions_base_dir(base_dir: Optional[str]) -> str:
151
+ """Resolve kimi-code's sessions root: arg → env → default."""
152
+ return (
153
+ base_dir
154
+ or os.environ.get("ROLL_KIMI_SESSIONS_DIR")
155
+ or os.path.expanduser("~/.kimi-code/sessions")
156
+ )
157
+
158
+
159
+ def _sum_wire_file(path: str) -> Optional[dict]:
160
+ """Sum ``usage.record`` lines in a single kimi wire.jsonl.
161
+
162
+ Returns a usage dict or None when no usage records are found.
163
+ Field mapping kimi → roll::
164
+
165
+ inputOther → input_tokens
166
+ output → output_tokens
167
+ inputCacheRead → cache_read_tokens
168
+ inputCacheCreation → cache_creation_tokens
169
+ """
170
+ tin = tout = tcr = tcw = 0
171
+ model = None
172
+ seen = False
173
+ try:
174
+ with open(path) as f:
175
+ for line in f:
176
+ line = line.strip()
177
+ if not line:
178
+ continue
179
+ try:
180
+ o = json.loads(line)
181
+ except json.JSONDecodeError:
182
+ continue
183
+ if o.get("type") != "usage.record":
184
+ continue
185
+ u = o.get("usage") or {}
186
+ seen = True
187
+ if o.get("model"):
188
+ model = o["model"]
189
+ tin += int(u.get("inputOther") or 0)
190
+ tout += int(u.get("output") or 0)
191
+ tcr += int(u.get("inputCacheRead") or 0)
192
+ tcw += int(u.get("inputCacheCreation") or 0)
193
+ except OSError:
194
+ return None
195
+ if not seen:
196
+ return None
197
+ return {
198
+ "model": model or _DEFAULT_MODEL,
199
+ "input_tokens": tin,
200
+ "output_tokens": tout,
201
+ "cache_creation_tokens": tcw,
202
+ "cache_read_tokens": tcr,
203
+ "duration_ms": None,
204
+ }
205
+
206
+
207
+ def usage_from_session(
208
+ cwd: Optional[str] = None,
209
+ cycle_id: Optional[str] = None,
210
+ slug: Optional[str] = None,
211
+ base_dir: Optional[str] = None,
212
+ ) -> Optional[dict]:
213
+ """Recover a kimi cycle's usage by reading its persisted wire file(s).
214
+
215
+ Matching: scan ``<base>/wd_*/session_*/agents/main/wire.jsonl`` and
216
+ select files whose ``wd_*`` directory name contains the worktree
217
+ basename (authoritative when ``cwd`` is given) or the ``cycle_id``
218
+ substring (fallback).
219
+
220
+ Retries can produce multiple wire files for the same cycle; their
221
+ usage is SUMMED so token totals reflect retry work too.
222
+
223
+ Returns the merged usage dict (tokens + model), or None when nothing
224
+ matches / zero tokens — caller writes nothing in that case, preserving
225
+ "n/a, not fake zero".
226
+ """
227
+ base = _kimi_sessions_base_dir(base_dir)
228
+ files = sorted(glob.glob(
229
+ os.path.join(base, "wd_*", "session_*", "agents", "main", "wire.jsonl")
230
+ ))
231
+ if not files:
232
+ return None
233
+
234
+ cwd_basename = os.path.basename(cwd.rstrip("/")) if cwd else None
235
+ matched = []
236
+ for path in files:
237
+ # Session dir name: wd_<cwd-basename>_<8-hex>
238
+ # Path: <base>/wd_<cwd-basename>_<hash>/session_<uuid>/agents/main/wire.jsonl
239
+ wd_seg = path[len(base):].lstrip(os.sep).split(os.sep, 1)[0]
240
+ if cwd_basename and ("wd_%s_" % cwd_basename) in (wd_seg + "_"):
241
+ matched.append(path)
242
+ continue
243
+ if cycle_id and ("cycle-%s" % cycle_id) in wd_seg:
244
+ matched.append(path)
245
+
246
+ if not matched:
247
+ return None
248
+
249
+ agg = {
250
+ "model": None,
251
+ "input_tokens": 0,
252
+ "output_tokens": 0,
253
+ "cache_creation_tokens": 0,
254
+ "cache_read_tokens": 0,
255
+ "duration_ms": None,
256
+ }
257
+ got = False
258
+ for path in matched:
259
+ s = _sum_wire_file(path)
260
+ if s is None:
261
+ continue
262
+ got = True
263
+ agg["model"] = agg["model"] or s["model"]
264
+ agg["input_tokens"] += s["input_tokens"]
265
+ agg["output_tokens"] += s["output_tokens"]
266
+ agg["cache_creation_tokens"] += s["cache_creation_tokens"]
267
+ agg["cache_read_tokens"] += s["cache_read_tokens"]
268
+
269
+ if not got:
270
+ return None
271
+ has_tokens = (
272
+ agg["input_tokens"] or agg["output_tokens"]
273
+ or agg["cache_creation_tokens"] or agg["cache_read_tokens"]
274
+ )
275
+ if not has_tokens:
276
+ return None
277
+ agg["model"] = agg["model"] or _DEFAULT_MODEL
278
+ return agg
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ kimi_emit — write ONE authoritative usage event for a finished kimi cycle.
4
+
5
+ Mirror of ``pi_emit.py``: invoked once by bin/roll after the agent phase
6
+ when ROLL_LOOP_AGENT == "kimi". Recovers the cycle's real usage from
7
+ kimi-code's persisted ``wire.jsonl`` files via ``kimi.usage_from_session``
8
+ and appends a single ``stage=="usage"`` event to the loop events file.
9
+
10
+ Exactly one event per cycle — the dashboard SUMS token fields across
11
+ same-label usage events, so a per-retry write path would inflate ×N.
12
+
13
+ Cost is frozen at the active price snapshot via
14
+ ``model_prices.compute_list_cost`` in the model's native currency.
15
+
16
+ When ``usage_from_session`` finds nothing (no matching session, zero
17
+ tokens) we write nothing — preserving "show n/a, not a fake zero".
18
+ """
19
+
20
+ import argparse
21
+ import importlib.util
22
+ import json
23
+ import os
24
+ import sys
25
+ from datetime import datetime, timezone
26
+
27
+ _THIS_DIR = os.path.dirname(os.path.abspath(__file__))
28
+ _LIB_DIR = os.path.dirname(_THIS_DIR)
29
+
30
+
31
+ def _load_model_prices():
32
+ spec = importlib.util.spec_from_file_location(
33
+ "model_prices", os.path.join(_LIB_DIR, "model_prices.py")
34
+ )
35
+ mp = importlib.util.module_from_spec(spec)
36
+ spec.loader.exec_module(mp)
37
+ return mp
38
+
39
+
40
+ def _load_kimi():
41
+ spec = importlib.util.spec_from_file_location(
42
+ "agent_usage_kimi", os.path.join(_THIS_DIR, "kimi.py")
43
+ )
44
+ kimi = importlib.util.module_from_spec(spec)
45
+ spec.loader.exec_module(kimi)
46
+ return kimi
47
+
48
+
49
+ def build_event(cwd=None, cycle_id=None, slug=None, base_dir=None):
50
+ """Return the (line dict) usage event for a kimi cycle, or None to skip."""
51
+ kimi = _load_kimi()
52
+ u = kimi.usage_from_session(
53
+ cwd=cwd, cycle_id=cycle_id, slug=slug, base_dir=base_dir
54
+ )
55
+ if u is None:
56
+ return None
57
+
58
+ mp = _load_model_prices()
59
+ model = u.get("model") or "kimi-k2.5"
60
+ totals = {
61
+ "input_tokens": int(u.get("input_tokens") or 0),
62
+ "output_tokens": int(u.get("output_tokens") or 0),
63
+ "cache_creation_tokens": int(u.get("cache_creation_tokens") or 0),
64
+ "cache_read_tokens": int(u.get("cache_read_tokens") or 0),
65
+ }
66
+ cost_list = mp.compute_list_cost(model, **totals)
67
+ currency = mp.currency_for(model)
68
+
69
+ payload = {
70
+ "model": model,
71
+ "input_tokens": totals["input_tokens"],
72
+ "output_tokens": totals["output_tokens"],
73
+ "cache_creation_tokens": totals["cache_creation_tokens"],
74
+ "cache_read_tokens": totals["cache_read_tokens"],
75
+ "duration_ms": u.get("duration_ms"),
76
+ "cost_list_usd": cost_list,
77
+ "cost_currency": currency,
78
+ "prices_version": getattr(mp, "VERSION", None),
79
+ }
80
+ return {
81
+ "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
82
+ "stage": "usage",
83
+ "label": cycle_id,
84
+ "detail": payload,
85
+ "outcome": "ok",
86
+ }
87
+
88
+
89
+ def _default_events_path(slug, shared):
90
+ base = shared or os.environ.get("LOOP_SHARED_ROOT") \
91
+ or os.path.expanduser("~/.shared/roll")
92
+ return os.path.join(base, "loop", "events-%s.ndjson" % slug)
93
+
94
+
95
+ def main(argv=None):
96
+ ap = argparse.ArgumentParser(description="emit one kimi usage event")
97
+ ap.add_argument("--cwd", help="cycle worktree path (authoritative match)")
98
+ ap.add_argument("--cycle", help="cycle id (label + dir-name fallback)")
99
+ ap.add_argument("--slug", help="project slug (events filename)")
100
+ ap.add_argument("--shared", help="shared root (for default events path)")
101
+ ap.add_argument("--events", help="explicit events file path (preferred)")
102
+ ap.add_argument("--base-dir", help="kimi sessions root override (tests)")
103
+ args = ap.parse_args(argv)
104
+
105
+ event = build_event(
106
+ cwd=args.cwd, cycle_id=args.cycle, slug=args.slug, base_dir=args.base_dir
107
+ )
108
+ if event is None:
109
+ return 0 # nothing recoverable — write nothing (n/a, not fake zero)
110
+
111
+ evfile = args.events or _default_events_path(args.slug, args.shared)
112
+ try:
113
+ os.makedirs(os.path.dirname(evfile), exist_ok=True)
114
+ with open(evfile, "a") as f:
115
+ f.write(json.dumps(event) + "\n")
116
+ except OSError as e:
117
+ print("[kimi_emit] failed to write %s: %s" % (evfile, e), file=sys.stderr)
118
+ return 1
119
+ return 0
120
+
121
+
122
+ if __name__ == "__main__":
123
+ sys.exit(main())
package/lib/i18n/peer.sh CHANGED
@@ -32,3 +32,10 @@ _i18n_set en peer.en_peer_review "[EN:启动 peer review: %s → %s (第 %s 轮,
32
32
  _i18n_set zh peer.en_peer_review "启动 peer review: %s → %s (第 %s 轮, tag: %s)"
33
33
  _i18n_set en peer.en_enter_n "[EN:按 Enter 执行或输入 n 取消。%s 秒后自动执行......]"
34
34
  _i18n_set zh peer.en_enter_n "按 Enter 执行或输入 n 取消。%s 秒后自动执行..."
35
+
36
+ _i18n_set en peer.no_peer_runs_yet "No peer review runs yet."
37
+ _i18n_set zh peer.no_peer_runs_yet "还没有 peer review 记录。"
38
+ _i18n_set en peer.no_peer_logs_found "No peer logs found."
39
+ _i18n_set zh peer.no_peer_logs_found "还没有 peer 日志。"
40
+ _i18n_set en peer.jq_required_for_roll_peer_runs "jq is required for 'roll peer runs'."
41
+ _i18n_set zh peer.jq_required_for_roll_peer_runs "'roll peer runs' 需要安装 jq。"
@@ -15,6 +15,10 @@ _i18n_set en peer_help.yes_yolo_skip_opt_out_prompt " --yes, --yolo Skip
15
15
  _i18n_set zh peer_help.yes_yolo_skip_opt_out_prompt "跳过确认提示"
16
16
  _i18n_set en peer_help.status_show_peer_review_state " status Show peer review state"
17
17
  _i18n_set zh peer_help.status_show_peer_review_state "显示状态"
18
+ _i18n_set en peer_help.log_show_latest_peer_transcript " log Show latest peer review transcript"
19
+ _i18n_set zh peer_help.log_show_latest_peer_transcript "查看最新 peer 日志"
20
+ _i18n_set en peer_help.runs_show_recent_peer_review_runs " runs [N] Show recent peer review runs"
21
+ _i18n_set zh peer_help.runs_show_recent_peer_review_runs "查看最近 peer review 记录"
18
22
  _i18n_set en peer_help.reset_pair_all_reset_peer_state " reset <pair|--all> Reset peer state"
19
23
  _i18n_set zh peer_help.reset_pair_all_reset_peer_state "重置状态"
20
24
  _i18n_set en peer_help.help_show_this_help " help Show this help"
@@ -162,7 +162,11 @@ def _resolve_project_path(slug: str) -> Optional[Path]:
162
162
 
163
163
 
164
164
  def _loop_runtime_dir_py(slug: str) -> Optional[Path]:
165
- """Mirror bin/roll's _loop_runtime_dir: return <project>/.roll/loop."""
165
+ """Mirror bin/roll's _loop_runtime_dir: return <project>/.roll/loop.
166
+ Honors ROLL_PROJECT_RUNTIME_DIR env override (test sandbox)."""
167
+ env_rt = os.environ.get("ROLL_PROJECT_RUNTIME_DIR", "").strip()
168
+ if env_rt:
169
+ return Path(env_rt)
166
170
  proj = _resolve_project_path(slug)
167
171
  if proj is None:
168
172
  return None
@@ -1189,6 +1193,11 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
1189
1193
  _sl = _daily_schedule_line(_svc, now=now)
1190
1194
  if _sl:
1191
1195
  print(" " + c("dim", _sl))
1196
+ # FIX-151: dedicated loop (pr/ci/alert) last-tick age
1197
+ for _loop in ("pr", "ci", "alert"):
1198
+ _tl = _tick_age_line(_loop, now=now)
1199
+ if _tl:
1200
+ print(" " + c("dim", _tl))
1192
1201
  print()
1193
1202
 
1194
1203
  print(c("faint", "─" * COLS))
@@ -1436,6 +1445,42 @@ def _daily_schedule_line(svc: str, now: Optional[datetime] = None) -> Optional[s
1436
1445
  return f"{svc}: daily (legacy interval)"
1437
1446
 
1438
1447
 
1448
+ def _tick_age_line(loop_type: str, now: Optional[datetime] = None) -> Optional[str]:
1449
+ """FIX-151: read the last tick for a dedicated loop (pr/ci/alert) and return
1450
+ a human-readable age line, or None if no tick file exists."""
1451
+ slug = project_slug()
1452
+ rt_dir = _loop_runtime_dir_py(slug)
1453
+ if rt_dir is not None:
1454
+ tick_file = rt_dir / f"{loop_type}-tick.jsonl"
1455
+ else:
1456
+ tick_file = shared_root() / "loop" / f"{loop_type}-tick-{slug}.jsonl"
1457
+ if not tick_file.exists():
1458
+ return None
1459
+ try:
1460
+ last_line = tick_file.read_text().strip().splitlines()[-1]
1461
+ except (IndexError, OSError):
1462
+ return None
1463
+ # Extract ts field from JSONL
1464
+ m = re.search(r'"ts":"([^"]+)"', last_line)
1465
+ if not m:
1466
+ return None
1467
+ ts_str = m.group(1)
1468
+ try:
1469
+ # Parse ISO 8601 UTC timestamp
1470
+ tick_dt = datetime.strptime(ts_str, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
1471
+ except ValueError:
1472
+ return None
1473
+ base = now or datetime.now(timezone.utc)
1474
+ age_sec = int((base - tick_dt).total_seconds())
1475
+ if age_sec < 60:
1476
+ age_str = f"{age_sec}s"
1477
+ elif age_sec < 3600:
1478
+ age_str = f"{age_sec // 60}m"
1479
+ else:
1480
+ age_str = f"{age_sec // 3600}h"
1481
+ return f"{loop_type}: tick {age_str} ago"
1482
+
1483
+
1439
1484
  def _detect_install_state() -> str:
1440
1485
  """FIX-095 / FIX-098: classify the launchd install state of the loop service.
1441
1486
 
package/lib/roll-peer.py CHANGED
@@ -99,7 +99,7 @@ _FIXTURE_VERDICT = {
99
99
  "reason": "2 rounds · 5 turns · all blocks resolved",
100
100
  }
101
101
 
102
- _FIXTURE_ARTIFACT = "~/.roll/.peer-state/logs/20260519_213700_claude_codex.md"
102
+ _FIXTURE_ARTIFACT = ".roll/peer/logs/20260519_213700_claude_codex.md"
103
103
  _FIXTURE_NEXT = [
104
104
  ("Continue execution", "claude resumes work on US-AUTH-014"),
105
105
  ("Inspect log", "open the artifact above to replay the transcript"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.601.1",
3
+ "version": "2026.601.3",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -180,7 +180,7 @@ Running
180
180
 
181
181
  ### Per Peer Pair (e.g., kimi→claude)
182
182
 
183
- Stored in `~/.shared/roll/peer/state.yaml`:
183
+ Stored in `~/.roll/.peer-state/` (flat key files per pair):
184
184
 
185
185
  ```yaml
186
186
  kimi→claude:
@@ -269,7 +269,7 @@ When peer review is manually triggered by a human (via `/peer`, "叫上 peer", e
269
269
  - Relay the peer's response **verbatim** before adding your own analysis.
270
270
  - After the peer's reply, the reviewer's own analysis block must explicitly state whether the peer's root cause and fix direction match the reviewer's own (independent) conclusion — that comparison is what determines the next round's action.
271
271
  - If a peer call fails or times out, report it immediately inline and either retry or ESCALATE.
272
- - Negotiation log is still written to `~/.shared/roll/peer/logs/` as usual.
272
+ - Negotiation log is written to `<project>/.roll/peer/logs/` as usual.
273
273
 
274
274
  **Why inline, not tmux:** When a human manually triggers peer review inside an agent's interactive session, the conversation IS the visible interface. tmux auto-attach is only relevant for CLI-launched background sessions (`bin/roll peer`), not for skill invocations.
275
275
 
@@ -300,8 +300,9 @@ When Attacker and Defender reach a stalemate (both tests pass but interpretation
300
300
 
301
301
  ## Output Artifacts
302
302
 
303
- - **Negotiation log**: `~/.shared/roll/peer/logs/<timestamp>_<from>_<to>.md`
304
- - **State file**: `~/.shared/roll/peer/state.yaml`
303
+ - **Negotiation log**: `<project>/.roll/peer/logs/<timestamp>_<from>_<to>.md`
304
+ - **Structured record**: `<project>/.roll/peer/runs.jsonl`
305
+ - **State file**: `~/.roll/.peer-state/`
305
306
  - **Decision record**: If AGREE, append summary to `docs/decisions/` or `.roll/backlog.md` (optional)
306
307
 
307
308
  ## Configuration
@@ -327,7 +328,7 @@ peer:
327
328
 
328
329
  ## Limitations
329
330
 
330
- 1. **Reverse link reliability**: Direct CLI calls are preferred. Reliability varies by tool — see Peer Invocation Reference table. If a peer fails consistently, the adaptive streak tracker marks it `abandoned` and falls back to the next candidate. File mailbox (`~/.shared/roll/peer/mailbox/`) is the last-resort fallback.
331
+ 1. **Reverse link reliability**: Direct CLI calls are preferred. Reliability varies by tool — see Peer Invocation Reference table. If a peer fails consistently, the adaptive streak tracker marks it `abandoned` and falls back to the next candidate. File mailbox (`<project>/.roll/peer/mailbox/`) is the last-resort fallback.
331
332
  - `deepseek serve --http` is the most reliable option when available — prefer it over direct `deepseek` CLI invocation.
332
333
  - `codex exec` has known TTY/Ink issues in non-interactive environments; treat as low-priority fallback.
333
334
  2. **Cost**: Every peer review consumes tokens on both sides. Only trigger for tasks where the cost of a wrong decision exceeds the cost of peer review. DeepSeek is the most cost-effective peer for general use.