@seanyao/roll 2026.601.1 → 2026.601.2

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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## v2026.601.2
4
+
5
+ ### 新功能
6
+
7
+ - **curl 安装器骨架:不靠 npm 也能装 roll(US-INSTALL-001)** — 自包含安装脚本探测 OS(仅放行 macOS / Linux)、preflight 检查 `bash≥3.2`/`python3`/`curl`/`tar` 缺啥报啥、把运行时装到 `~/.local/share/roll/` 并 symlink 进 PATH,重复运行即原地升级。本版先从本地源目录复制(真正的 `curl ... | bash` 远端取数留后续故事)`[loop]`
8
+
9
+ ### 可见性
10
+
11
+ - **peer 评审可靠落盘、能查了(FIX-150a)** — 此前 peer 痕迹碎成三处且大多丢失;统一落盘到项目本地规范路径,新增查询命令翻看历次 peer 记录(发起方 / 对象 / 轮次 / 各方结论 / 耗时),不再依赖 agent 自觉写盘 `[loop]`
12
+ - **三个专用 loop(CI / PR / Alert)空闲时也留心跳(FIX-151)** — 健康空闲时不再零日志让人以为没在跑;每轮补一条轻量存活心跳,status 显示各 loop 上次运行距今多久 `[loop]`
13
+
3
14
  ## v2026.601.1
4
15
 
5
16
  ### 新功能
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.2"
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"
@@ -3928,6 +3970,8 @@ cmd_peer() {
3928
3970
  --yes|--yolo) yolo=true; shift ;;
3929
3971
  status) subcmd="status"; shift ;;
3930
3972
  reset) subcmd="reset"; shift; break ;;
3973
+ log) subcmd="log"; shift ;;
3974
+ runs) subcmd="runs"; shift ;;
3931
3975
  help|--help|-h) subcmd="help"; shift ;;
3932
3976
  *) err "$(msg peer.unknown_option_1)"; exit 1 ;;
3933
3977
  esac
@@ -3936,6 +3980,8 @@ cmd_peer() {
3936
3980
  case "$subcmd" in
3937
3981
  status) cmd_peer_status; return ;;
3938
3982
  reset) cmd_peer_reset "$@"; return ;;
3983
+ log) cmd_peer_log; return ;;
3984
+ runs) cmd_peer_runs "$@"; return ;;
3939
3985
  help) cmd_peer_help; return ;;
3940
3986
  esac
3941
3987
 
@@ -3985,6 +4031,9 @@ cmd_peer() {
3985
4031
  fi
3986
4032
  fi
3987
4033
 
4034
+ local start_epoch
4035
+ start_epoch=$(date +%s)
4036
+
3988
4037
  local context=""
3989
4038
  if [[ -n "$context_file" && -f "$context_file" ]]; then
3990
4039
  context="$(cat "$context_file")"
@@ -4004,9 +4053,10 @@ cmd_peer() {
4004
4053
  fi
4005
4054
  fi
4006
4055
 
4007
- _peer_ensure_state_dir
4056
+ # FIX-150a: write logs to project-local path; keep global state dir for adaptive routing.
4057
+ _peer_ensure_project_dir
4008
4058
  local log_file
4009
- log_file="${_PEER_STATE_DIR}/logs/$(date +%Y%m%d_%H%M%S)_${from_tool}_${to_tool}.md"
4059
+ log_file="$(_peer_project_dir)/logs/$(date +%Y%m%d_%H%M%S)_${from_tool}_${to_tool}.md"
4010
4060
  {
4011
4061
  echo "# Peer Review Log"
4012
4062
  echo ""
@@ -4051,6 +4101,11 @@ cmd_peer() {
4051
4101
 
4052
4102
  _peer_update_state "$pair" "$resolution"
4053
4103
 
4104
+ # FIX-150a: write structured record for observability.
4105
+ local duration_sec=0
4106
+ duration_sec=$(( $(date +%s) - start_epoch ))
4107
+ _peer_write_record "$from_tool" "$to_tool" "$round" "$resolution" "$tag" "$duration_sec"
4108
+
4054
4109
  echo ""
4055
4110
  echo -e "$(msg peer.peer_review_result_peer_review ${BOLD} ${NC})"
4056
4111
  echo " Pair: $pair"
@@ -4185,10 +4240,72 @@ cmd_peer_help() {
4185
4240
  echo ""
4186
4241
  echo "Subcommands:"
4187
4242
  echo "$(msg peer_help.status_show_peer_review_state)"
4243
+ echo "$(msg peer_help.log_show_latest_peer_transcript)"
4244
+ echo "$(msg peer_help.runs_show_recent_peer_review_runs)"
4188
4245
  echo "$(msg peer_help.reset_pair_all_reset_peer_state)"
4189
4246
  echo "$(msg peer_help.help_show_this_help)"
4190
4247
  }
4191
4248
 
4249
+ # FIX-150a: `roll peer runs [N]` — show recent peer review runs (project-local).
4250
+ cmd_peer_runs() {
4251
+ local n=10
4252
+ while [[ $# -gt 0 ]]; do
4253
+ case "$1" in
4254
+ [0-9]*) n="$1"; shift ;;
4255
+ *) shift ;;
4256
+ esac
4257
+ done
4258
+
4259
+ local dir
4260
+ dir=$(_peer_project_dir)
4261
+ local runs_file="$dir/runs.jsonl"
4262
+
4263
+ if ! command -v jq >/dev/null 2>&1; then
4264
+ err "$(msg peer.jq_required_for_roll_peer_runs)"
4265
+ return 1
4266
+ fi
4267
+
4268
+ if [[ ! -f "$runs_file" ]] || [[ ! -s "$runs_file" ]]; then
4269
+ echo "$(msg peer.no_peer_runs_yet)"
4270
+ return 0
4271
+ fi
4272
+
4273
+ local reversed
4274
+ reversed=$(awk '{a[NR]=$0} END{for(i=NR; i>=1; i--) print a[i]}' "$runs_file")
4275
+ local recent
4276
+ recent=$(printf '%s\n' "$reversed" | head -n "$n")
4277
+
4278
+ echo -e "${BOLD}Peer Review Runs${NC}"
4279
+ echo ""
4280
+ printf "%-19s %-8s %-10s %-5s %-10s %s\n" "Time" "From" "To" "Rnd" "Verdict" "Tag"
4281
+ printf "%s\n" "─────────────────── ──────── ────────── ───── ────────── ──────────"
4282
+
4283
+ while IFS= read -r line; do
4284
+ [[ -z "$line" ]] && continue
4285
+ local ts from to round verdict tag
4286
+ ts=$(printf '%s' "$line" | jq -r '.ts // "—"')
4287
+ from=$(printf '%s' "$line" | jq -r '.from // "—"')
4288
+ to=$(printf '%s' "$line" | jq -r '.to // "—"')
4289
+ round=$(printf '%s' "$line" | jq -r '.round // "—"')
4290
+ verdict=$(printf '%s' "$line" | jq -r '.verdict // "—"')
4291
+ tag=$(printf '%s' "$line" | jq -r '.tag // "—"')
4292
+ printf "%-19s %-8s %-10s %-5s %-10s %s\n" "$ts" "$from" "$to" "$round" "$verdict" "$tag"
4293
+ done <<<"$recent"
4294
+ }
4295
+
4296
+ # FIX-150a: `roll peer log` — show the latest peer review transcript.
4297
+ cmd_peer_log() {
4298
+ local dir
4299
+ dir=$(_peer_project_dir)
4300
+ local latest
4301
+ latest=$(ls "$dir/logs"/*.md 2>/dev/null | sort | tail -1 || true)
4302
+ if [[ -z "$latest" || ! -f "$latest" ]]; then
4303
+ echo "$(msg peer.no_peer_logs_found)"
4304
+ return 0
4305
+ fi
4306
+ cat "$latest"
4307
+ }
4308
+
4192
4309
  # ═══════════════════════════════════════════════════════════════════════════════
4193
4310
  # AGENT — per-project agent configuration
4194
4311
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -7615,6 +7732,84 @@ _loop_event_rotate() {
7615
7732
  fi
7616
7733
  }
7617
7734
 
7735
+ # FIX-151: write a lightweight tick heartbeat for dedicated loops (pr/ci/alert).
7736
+ # Appends one JSONL line per tick; rotates by line count to control bloat.
7737
+ _loop_write_tick() {
7738
+ local loop_type="${1:-}" outcome="${2:-idle}" note="${3:-}"
7739
+ [ -n "$loop_type" ] || return 0
7740
+ local slug tick_file
7741
+ slug=$(_project_slug 2>/dev/null || basename "$PWD")
7742
+ local _rt_dir
7743
+ _rt_dir=$(_loop_runtime_dir "$slug" 2>/dev/null || echo "")
7744
+ if [ -n "$_rt_dir" ]; then
7745
+ tick_file="${_rt_dir}/${loop_type}-tick.jsonl"
7746
+ else
7747
+ tick_file="${_SHARED_ROOT:-$HOME/.shared/roll}/loop/${loop_type}-tick-${slug}.jsonl"
7748
+ fi
7749
+ mkdir -p "$(dirname "$tick_file")" 2>/dev/null || true
7750
+ local ts
7751
+ ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
7752
+ printf '{"ts":"%s","loop":"%s","outcome":"%s","note":"%s"}\n' \
7753
+ "$ts" "$loop_type" "$outcome" "$note" >> "$tick_file"
7754
+
7755
+ # Rotate: alert loop (1 min) → 1000 lines (~16h); ci/pr (5 min) → 500 lines (~42h)
7756
+ local max_lines=500
7757
+ [ "$loop_type" = "alert" ] && max_lines=1000
7758
+ local line_count
7759
+ line_count=$(wc -l < "$tick_file" 2>/dev/null | tr -d ' \t' || echo 0)
7760
+ case "$line_count" in ''|*[!0-9]*) line_count=0 ;; esac
7761
+ if [ "$line_count" -gt "$max_lines" ]; then
7762
+ tail -n "$max_lines" "$tick_file" > "${tick_file}.tmp" && mv "${tick_file}.tmp" "$tick_file"
7763
+ fi
7764
+ }
7765
+
7766
+ # FIX-151: read the last tick line from a dedicated loop's tick file.
7767
+ # Optional second arg selects a JSON field (ts, loop, outcome, note).
7768
+ _loop_read_last_tick() {
7769
+ local loop_type="${1:-}" field="${2:-}"
7770
+ [ -n "$loop_type" ] || return 0
7771
+ local slug tick_file
7772
+ slug=$(_project_slug 2>/dev/null || basename "$PWD")
7773
+ local _rt_dir
7774
+ _rt_dir=$(_loop_runtime_dir "$slug" 2>/dev/null || echo "")
7775
+ if [ -n "$_rt_dir" ]; then
7776
+ tick_file="${_rt_dir}/${loop_type}-tick.jsonl"
7777
+ else
7778
+ tick_file="${_SHARED_ROOT:-$HOME/.shared/roll}/loop/${loop_type}-tick-${slug}.jsonl"
7779
+ fi
7780
+ [ -f "$tick_file" ] || return 0
7781
+ local last
7782
+ last=$(tail -1 "$tick_file" 2>/dev/null || echo "")
7783
+ [ -n "$last" ] || return 0
7784
+ if [ -n "$field" ]; then
7785
+ echo "$last" | awk -F'"' '{for(i=2;i<NF;i+=2) if($i=="'"$field"'") {print $(i+2); exit}}' 2>/dev/null || echo ""
7786
+ else
7787
+ printf '%s\n' "$last"
7788
+ fi
7789
+ }
7790
+
7791
+ # FIX-151: compute human-readable age of the last tick for a dedicated loop.
7792
+ # Prints something like "5s", "3m", "2h" or empty string if no tick.
7793
+ _loop_tick_age() {
7794
+ local loop_type="${1:-}"
7795
+ [ -n "$loop_type" ] || return 0
7796
+ local ts
7797
+ ts=$(_loop_read_last_tick "$loop_type" "ts")
7798
+ [ -n "$ts" ] || return 0
7799
+ local tick_epoch now_epoch age
7800
+ tick_epoch=$(date -d "$ts" +%s 2>/dev/null || date -jf "%Y-%m-%dT%H:%M:%SZ" "$ts" +%s 2>/dev/null || echo 0)
7801
+ [ "$tick_epoch" -gt 0 ] || return 0
7802
+ now_epoch=$(date +%s)
7803
+ age=$(( now_epoch - tick_epoch ))
7804
+ if [ "$age" -lt 60 ]; then
7805
+ echo "${age}s"
7806
+ elif [ "$age" -lt 3600 ]; then
7807
+ echo "$(( age / 60 ))m"
7808
+ else
7809
+ echo "$(( age / 3600 ))h"
7810
+ fi
7811
+ }
7812
+
7618
7813
  # US-OBS-014: after a loop cycle reaches a terminal cycle_end (done or idle),
7619
7814
  # fire a best-effort, background status-snapshot push to roll-meta so the
7620
7815
  # remote-watch prompt always sees ≤35min-fresh data — no user-side cron needed.
@@ -9783,8 +9978,13 @@ _legacy_loop_status() {
9783
9978
  fi
9784
9979
  echo -e " ${YELLOW}loop ⏸ paused${NC}${_dur} run: roll loop resume"
9785
9980
  else
9981
+ local _tick_age=""
9982
+ case "$svc" in pr|ci|alert)
9983
+ _tick_age=$(_loop_tick_age "$svc")
9984
+ [ -n "$_tick_age" ] && _tick_age=" tick ${_tick_age}"
9985
+ esac
9786
9986
  case "$state" in
9787
- enabled) echo -e " ${GREEN}${svc} ● enabled${NC}" ;;
9987
+ enabled) echo -e " ${GREEN}${svc} ● enabled${NC}${_tick_age}" ;;
9788
9988
  stale|installed-off) echo -e " ${YELLOW}${svc} ⚠ STALE — plist present but not loaded${NC} run: roll loop on" ;;
9789
9989
  not-installed) echo -e " ${RED}${svc} ○ not installed${NC} run: roll setup" ;;
9790
9990
  esac
@@ -11193,16 +11393,16 @@ _loop_pr_merge_self_eager() {
11193
11393
  # Walks open PRs and routes each by classification.
11194
11394
  # Lenient on gh unavailability — returns 0 so the loop continues to BACKLOG.
11195
11395
  _loop_pr_inbox() {
11196
- local slug; _gh_resolve slug || return 0
11396
+ local slug; _gh_resolve slug || { _loop_write_tick "pr" "idle" "gh_unavailable"; return 0; }
11197
11397
  local prs_json
11198
11398
  prs_json=$(gh -R "$slug" pr list --state open \
11199
11399
  --json number,headRefName,author,title \
11200
- 2>/dev/null) || return 0
11201
- [ -n "$prs_json" ] || return 0
11202
- [ "$prs_json" = "[]" ] && return 0
11400
+ 2>/dev/null) || { _loop_write_tick "pr" "idle" "gh_error"; return 0; }
11401
+ [ -n "$prs_json" ] || { _loop_write_tick "pr" "idle" "empty_response"; return 0; }
11402
+ [ "$prs_json" = "[]" ] && { _loop_write_tick "pr" "idle" "no_open_prs"; return 0; }
11203
11403
 
11204
11404
  local count; count=$(echo "$prs_json" | jq 'length' 2>/dev/null || echo 0)
11205
- [ "${count:-0}" -gt 0 ] || return 0
11405
+ [ "${count:-0}" -gt 0 ] || { _loop_write_tick "pr" "idle" "zero_prs"; return 0; }
11206
11406
 
11207
11407
  local i=0
11208
11408
  while [ "$i" -lt "$count" ]; do
@@ -11270,6 +11470,7 @@ _loop_pr_inbox() {
11270
11470
 
11271
11471
  i=$((i + 1))
11272
11472
  done
11473
+ _loop_write_tick "pr" "acted" "inbox_done"
11273
11474
  return 0
11274
11475
  }
11275
11476
 
@@ -11778,13 +11979,13 @@ _ci_detect_degradation() {
11778
11979
  # accumulated history. Lenient on gh unavailability (missing / failed list →
11779
11980
  # return 0) so the service never errors out a tick.
11780
11981
  _ci_scan() {
11781
- local slug; _gh_resolve slug 2>/dev/null || return 0
11982
+ local slug; _gh_resolve slug 2>/dev/null || { _loop_write_tick "ci" "idle" "gh_unavailable"; return 0; }
11782
11983
 
11783
11984
  local runs_json
11784
11985
  runs_json=$(gh -R "$slug" run list --branch main \
11785
11986
  --json databaseId,workflowName,name,conclusion,status,createdAt,updatedAt \
11786
- 2>/dev/null) || return 0
11787
- [ -n "$runs_json" ] || return 0
11987
+ 2>/dev/null) || { _loop_write_tick "ci" "idle" "gh_error"; return 0; }
11988
+ [ -n "$runs_json" ] || { _loop_write_tick "ci" "idle" "empty_response"; return 0; }
11788
11989
 
11789
11990
  # An empty list ("[]") still falls through to the detectors below: they run
11790
11991
  # over accumulated history, not just this tick's runs.
@@ -11810,6 +12011,7 @@ _ci_scan() {
11810
12011
 
11811
12012
  _ci_detect_flaky
11812
12013
  _ci_detect_degradation
12014
+ _loop_write_tick "ci" "acted" "scan_done"
11813
12015
  return 0
11814
12016
  }
11815
12017
 
@@ -11977,12 +12179,12 @@ _alert_rotate() {
11977
12179
  # A missing/empty alert file is a no-op (no rotate, no log). Loop-safe.
11978
12180
  _alert_dispatch() {
11979
12181
  local file="${1:-$_LOOP_ALERT}"
11980
- [ -n "$file" ] && [ -f "$file" ] || return 0
12182
+ [ -n "$file" ] && [ -f "$file" ] || { _loop_write_tick "alert" "idle" "no_file"; return 0; }
11981
12183
  # Empty file → nothing to consume, leave it in place.
11982
- [ -s "$file" ] || return 0
12184
+ [ -s "$file" ] || { _loop_write_tick "alert" "idle" "empty_file"; return 0; }
11983
12185
 
11984
12186
  local parsed; parsed=$(_alert_parse_file "$file")
11985
- [ -n "$parsed" ] || { _alert_rotate "$file"; return 0; }
12187
+ [ -n "$parsed" ] || { _alert_rotate "$file"; _loop_write_tick "alert" "idle" "no_parsed"; return 0; }
11986
12188
 
11987
12189
  local line ts level category message notify
11988
12190
  local _oifs="$IFS"
@@ -12008,6 +12210,7 @@ _alert_dispatch() {
12008
12210
  IFS="$_oifs"
12009
12211
 
12010
12212
  _alert_rotate "$file"
12213
+ _loop_write_tick "alert" "acted" "dispatch_done"
12011
12214
  return 0
12012
12215
  }
12013
12216
 
@@ -14399,9 +14602,11 @@ _dash_refactor_pending() {
14399
14602
 
14400
14603
  # ② Peer layer: last result + days ago from peer log, empty if no log.
14401
14604
  _dash_last_peer() {
14402
- local peer_log_dir="${HOME}/.shared/roll/peer"
14605
+ # FIX-150a: read from project-local peer logs (was ~/.shared/roll/peer/*.log).
14606
+ local peer_log_dir
14607
+ peer_log_dir=$(_peer_project_dir)/logs
14403
14608
  local latest
14404
- latest=$(ls "$peer_log_dir"/*.log 2>/dev/null | sort | tail -1 || true)
14609
+ latest=$(ls "$peer_log_dir"/*.md 2>/dev/null | sort | tail -1 || true)
14405
14610
  [[ -z "$latest" || ! -f "$latest" ]] && return 0
14406
14611
  local result
14407
14612
  result=$(grep -oE '(AGREE|REFINE|OBJECT|ESCALATE)' "$latest" 2>/dev/null | tail -1 || true)
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.2",
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.