@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 +23 -0
- package/bin/roll +266 -31
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/agent_usage/kimi.py +163 -12
- package/lib/agent_usage/kimi_emit.py +123 -0
- package/lib/i18n/peer.sh +7 -0
- package/lib/i18n/peer_help.sh +4 -0
- package/lib/roll-loop-status.py +46 -1
- package/lib/roll-peer.py +1 -1
- package/package.json +1 -1
- package/skills/roll-peer/SKILL.md +6 -5
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.
|
|
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
|
-
|
|
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="$
|
|
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
|
-
|
|
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
|
|
8640
|
-
# mode. Recover token+cost once per cycle from the agent's session
|
|
8641
|
-
# append a single authoritative usage event. Done here (not in
|
|
8642
|
-
# per-attempt passthrough) so retries can't write N duplicate
|
|
8643
|
-
# dashboard's same-label SUM would inflate. Runs before the
|
|
8644
|
-
# so partial cycles still get whatever usage the session
|
|
8645
|
-
# path is resolved exactly like _loop_event (rt_dir
|
|
8646
|
-
#
|
|
8647
|
-
|
|
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
|
-
|
|
8655
|
-
|
|
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
|
-
|
|
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
|
|
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"/*.
|
|
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)
|
|
Binary file
|
package/lib/agent_usage/kimi.py
CHANGED
|
@@ -1,29 +1,33 @@
|
|
|
1
1
|
"""
|
|
2
2
|
kimi (Moonshot Kimi CLI) agent usage extractor.
|
|
3
3
|
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
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)
|
|
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。"
|
package/lib/i18n/peer_help.sh
CHANGED
|
@@ -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"
|
package/lib/roll-loop-status.py
CHANGED
|
@@ -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 = "
|
|
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
|
@@ -180,7 +180,7 @@ Running
|
|
|
180
180
|
|
|
181
181
|
### Per Peer Pair (e.g., kimi→claude)
|
|
182
182
|
|
|
183
|
-
Stored in `~/.
|
|
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
|
|
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**:
|
|
304
|
-
- **
|
|
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 (
|
|
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.
|