@seanyao/roll 2026.517.1 → 2026.517.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 +19 -0
- package/bin/roll +174 -5
- package/lib/loop-fmt.py +220 -146
- package/package.json +1 -1
- package/skills/roll-.dream/SKILL.md +14 -1
- package/skills/roll-build/SKILL.md +7 -0
- package/skills/roll-loop/SKILL.md +12 -0
- package/skills/roll-peer/SKILL.md +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2026.517.3
|
|
4
|
+
- **New**: dream 现在察觉功能目录过期 — 落后时不再悄悄无人知晓 `[dream]`
|
|
5
|
+
- **New**: `roll loop events` — 查看 loop 每轮的详细事件流,任务选择、评审结果、CI 状态、合并全都有迹可查 `[loop]`
|
|
6
|
+
- **Improved**: loop 实时输出不再一眼看不出重点 — TCR 纪律、评审决议、CI gate 突出显示,工具日志不再喧宾夺主 `[loop]`
|
|
7
|
+
|
|
8
|
+
## v2026.517.2
|
|
9
|
+
- **Fixed**: `roll loop runs` 现在能看到刚跑完的循环记录(不再无历史) `[loop]`
|
|
10
|
+
- **Fixed**: `roll dream`、`roll brief`、`roll loop` 的定时任务不再被 Claude 升级后的弹窗拦住,悄悄失效
|
|
11
|
+
|
|
3
12
|
## v2026.517.1
|
|
4
13
|
|
|
5
14
|
- **New**: loop 自动修复 story 引入的 CI 红 — 不再每次 CI 红都停下等人,修不好才写 ALERT `[loop]`
|
|
15
|
+
- **New**: Roll 官网上线 — 装、用、原理一站讲清楚
|
|
16
|
+
- **Fixed**: mac 休眠不再打断 loop cycle — 全程保持唤醒 `[loop]`
|
|
17
|
+
- **Fixed**: agent 假死时 loop 自动接管,不再无限挂起 `[loop]`
|
|
18
|
+
- **Fixed**: PR / 合并失败时 — loop 仍能把代码备份到独立分支不丢失 `[loop]`
|
|
19
|
+
- **Fixed**: loop 启动时自动恢复上一轮中断的工作,意外中断的代码不再失踪 `[loop]`
|
|
20
|
+
- **Fixed**: `roll loop now` 现在卡住状态也会先自愈再启动 `[loop]`
|
|
21
|
+
- **Fixed**: 自治 loop 不再被权限弹窗卡住 `[loop]`
|
|
22
|
+
- **Fixed**: `roll peer` 多轮 review 不再中途断线 `[peer]`
|
|
23
|
+
- **Fixed**: `roll loop runs` 现在跨子目录都能显示历史 `[loop]`
|
|
24
|
+
- **Fixed**: loop 空跑也会清理 worktree,不再随时间堆积 `[loop]`
|
|
6
25
|
|
|
7
26
|
## v2026.515.1
|
|
8
27
|
|
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.517.
|
|
7
|
+
VERSION="2026.517.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"
|
|
@@ -1898,6 +1898,20 @@ _agent_cmd_str() {
|
|
|
1898
1898
|
printf '%s' "$out"
|
|
1899
1899
|
}
|
|
1900
1900
|
|
|
1901
|
+
# Splice --dangerously-skip-permissions into _AGENT_ARGV for claude. Used by
|
|
1902
|
+
# trusted, human-triggered, or autonomous flows that should not be blocked by
|
|
1903
|
+
# Claude Code's pre-write "approve diff" UX (which silently never gets
|
|
1904
|
+
# approved in `claude -p` pipe mode). No-op for non-claude agents and for
|
|
1905
|
+
# already-bypassed argvs.
|
|
1906
|
+
_agent_bypass_claude_perms() {
|
|
1907
|
+
[[ "${_AGENT_ARGV[0]}" == "claude" ]] || return 0
|
|
1908
|
+
local arg
|
|
1909
|
+
for arg in "${_AGENT_ARGV[@]}"; do
|
|
1910
|
+
[[ "$arg" == "--dangerously-skip-permissions" ]] && return 0
|
|
1911
|
+
done
|
|
1912
|
+
_AGENT_ARGV=("${_AGENT_ARGV[@]:0:2}" --dangerously-skip-permissions "${_AGENT_ARGV[@]:2}")
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1901
1915
|
_agent_run_skill() {
|
|
1902
1916
|
local skill="$1"
|
|
1903
1917
|
local agent; agent=$(_project_agent)
|
|
@@ -2059,7 +2073,7 @@ _LAUNCHD_DIR="${HOME}/Library/LaunchAgents"
|
|
|
2059
2073
|
# Returns a filesystem-safe slug combining the project basename and a 6-char
|
|
2060
2074
|
# hash of the full path, ensuring uniqueness across sibling dirs with same name.
|
|
2061
2075
|
_project_slug() {
|
|
2062
|
-
local path="$1"
|
|
2076
|
+
local path="${1:-$(pwd -P 2>/dev/null || pwd)}"
|
|
2063
2077
|
# FIX-034: when inside a git worktree, git-common-dir returns the main tree's
|
|
2064
2078
|
# absolute .git path; resolve to the main tree so worktree and main-tree runs
|
|
2065
2079
|
# produce the same slug.
|
|
@@ -2101,6 +2115,55 @@ _loop_derive_minute() {
|
|
|
2101
2115
|
echo $(( (hash_dec + offset) % 55 + 1 ))
|
|
2102
2116
|
}
|
|
2103
2117
|
|
|
2118
|
+
# US-LOOP-001: structured event emission for cycle observability.
|
|
2119
|
+
# Writes a tab-separated line to stdout (for tmux/attach display) and appends
|
|
2120
|
+
# a JSON line to the per-project NDJSON event file under _SHARED_ROOT/loop/.
|
|
2121
|
+
# Args: <stage> <label> <detail> <outcome>
|
|
2122
|
+
_loop_event() {
|
|
2123
|
+
local stage="$1" label="$2" detail="$3" outcome="$4"
|
|
2124
|
+
local ts slug evfile json
|
|
2125
|
+
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
2126
|
+
slug=$(_project_slug 2>/dev/null || basename "$PWD")
|
|
2127
|
+
evfile="${_SHARED_ROOT:-$HOME/.shared/roll}/loop/events-${slug}.ndjson"
|
|
2128
|
+
mkdir -p "$(dirname "$evfile")"
|
|
2129
|
+
|
|
2130
|
+
# stdout: tab-separated for tmux display
|
|
2131
|
+
printf '%s\t%s\t%s\t%s\t%s\n' "$ts" "$stage" "$label" "$detail" "$outcome"
|
|
2132
|
+
|
|
2133
|
+
# JSON line appended to NDJSON file; serialized with flock (Linux) or
|
|
2134
|
+
# lockf (macOS/BSD) — fall back to unguarded append when neither is available.
|
|
2135
|
+
json=$(printf '{"ts":"%s","stage":"%s","label":"%s","detail":"%s","outcome":"%s"}\n' \
|
|
2136
|
+
"$ts" "$stage" "$label" "$detail" "$outcome")
|
|
2137
|
+
if command -v flock >/dev/null 2>&1; then
|
|
2138
|
+
(
|
|
2139
|
+
flock -x 9
|
|
2140
|
+
printf '%s\n' "$json" >> "$evfile"
|
|
2141
|
+
) 9>>"${evfile}.lock"
|
|
2142
|
+
elif command -v lockf >/dev/null 2>&1; then
|
|
2143
|
+
lockf -s "${evfile}.lock" sh -c "printf '%s\n' $(printf '%q' "$json") >> $(printf '%q' "$evfile")"
|
|
2144
|
+
else
|
|
2145
|
+
printf '%s\n' "$json" >> "$evfile"
|
|
2146
|
+
fi
|
|
2147
|
+
|
|
2148
|
+
# File rotation: if >10MB, rotate keeping last 5
|
|
2149
|
+
_loop_event_rotate "$evfile"
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
_loop_event_rotate() {
|
|
2153
|
+
local f="$1"
|
|
2154
|
+
local size
|
|
2155
|
+
size=$(stat -f%z "$f" 2>/dev/null || stat -c%s "$f" 2>/dev/null || echo 0)
|
|
2156
|
+
if [ "$size" -gt 10485760 ]; then
|
|
2157
|
+
# rotate: .4→remove, .3→.4, .2→.3, .1→.2, current→.1
|
|
2158
|
+
rm -f "${f}.4"
|
|
2159
|
+
for i in 3 2 1; do
|
|
2160
|
+
[ -f "${f}.$i" ] && mv "${f}.$i" "${f}.$((i+1))"
|
|
2161
|
+
done
|
|
2162
|
+
mv "$f" "${f}.1"
|
|
2163
|
+
touch "$f"
|
|
2164
|
+
fi
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2104
2167
|
_launchd_label() {
|
|
2105
2168
|
local service="$1" project_path="$2"
|
|
2106
2169
|
printf 'com.roll.%s.%s' "$service" "$(_project_slug "$project_path")"
|
|
@@ -2223,6 +2286,7 @@ set +e
|
|
|
2223
2286
|
# On any failure (no remote, no main, etc.) fall back to running in the
|
|
2224
2287
|
# project's main tree (degraded — no isolation, like pre-037 behavior).
|
|
2225
2288
|
CYCLE_ID="\$(date -u +%Y%m%d-%H%M%S)-\$\$"
|
|
2289
|
+
CYCLE_START=\$(date -u +%s)
|
|
2226
2290
|
WT="\$(_worktree_path "${slug}" "cycle-\${CYCLE_ID}")"
|
|
2227
2291
|
BRANCH="loop/cycle-\${CYCLE_ID}"
|
|
2228
2292
|
_USE_WORKTREE=0
|
|
@@ -2264,6 +2328,7 @@ if _worktree_fetch_origin main \\
|
|
|
2264
2328
|
_USE_WORKTREE=1
|
|
2265
2329
|
_worktree_submodule_init "\$WT" 2>/dev/null || true
|
|
2266
2330
|
echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
|
|
2331
|
+
_loop_event cycle_start "\${CYCLE_ID}" "" "" || true
|
|
2267
2332
|
else
|
|
2268
2333
|
# P3 fix: skip the cycle entirely when worktree isolation fails.
|
|
2269
2334
|
# --dangerously-skip-permissions is only safe paired with worktree isolation;
|
|
@@ -2288,6 +2353,25 @@ for _attempt in 1 2 3; do
|
|
|
2288
2353
|
fi
|
|
2289
2354
|
done
|
|
2290
2355
|
|
|
2356
|
+
# FIX-044: capture cycle data from worktree before cleanup removes it
|
|
2357
|
+
_cycle_tcr=0
|
|
2358
|
+
_cycle_status="idle"
|
|
2359
|
+
_cycle_built="[]"
|
|
2360
|
+
if [ "\$_USE_WORKTREE" = "1" ]; then
|
|
2361
|
+
if [ "\$_exit" -ne 0 ]; then
|
|
2362
|
+
_cycle_status="failed"
|
|
2363
|
+
else
|
|
2364
|
+
_cycle_commits_pre=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
|
|
2365
|
+
if [ "\$_cycle_commits_pre" -gt 0 ]; then
|
|
2366
|
+
_cycle_status="built"
|
|
2367
|
+
_cycle_tcr=\$(cd "\$WT" && git log --oneline origin/main..HEAD -- 2>/dev/null | grep -c ' tcr:' || echo 0)
|
|
2368
|
+
if command -v jq >/dev/null 2>&1; then
|
|
2369
|
+
_cycle_built=\$(cd "\$WT" && git diff origin/main -- BACKLOG.md 2>/dev/null | grep '✅ Done' | grep -oE '\[[A-Z]+-[0-9]+\]' | sed 's/^.//;s/.\$//' | jq -R -s 'split("\n") | map(select(length>0))' 2>/dev/null || echo "[]")
|
|
2370
|
+
fi
|
|
2371
|
+
fi
|
|
2372
|
+
fi
|
|
2373
|
+
fi
|
|
2374
|
+
|
|
2291
2375
|
# US-AUTO-038: diff snapshot vs current and delete any claude/* branches this
|
|
2292
2376
|
# session pushed to origin. Runs regardless of claude's exit code (cleanup is
|
|
2293
2377
|
# orthogonal to success/failure) and is silent on non-GitHub / unreachable.
|
|
@@ -2306,6 +2390,7 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
|
|
|
2306
2390
|
_cycle_commits=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
|
|
2307
2391
|
if [ "\$_cycle_commits" -eq 0 ]; then
|
|
2308
2392
|
_worktree_cleanup "\$WT" "\$BRANCH"
|
|
2393
|
+
_loop_event idle "\${CYCLE_ID}" "" "" || true
|
|
2309
2394
|
echo "[loop] cycle \${CYCLE_ID}: idle (no new commits); worktree cleaned"
|
|
2310
2395
|
else
|
|
2311
2396
|
if ( cd "\$WT" && _loop_is_doc_only_change ); then
|
|
@@ -2316,6 +2401,7 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
|
|
|
2316
2401
|
_publish_status=\$?
|
|
2317
2402
|
if [ "\$_publish_status" -eq 0 ]; then
|
|
2318
2403
|
_worktree_cleanup "\$WT" "\$BRANCH"
|
|
2404
|
+
_loop_event cycle_end "\${CYCLE_ID}" "" "done" || true
|
|
2319
2405
|
echo "[loop] cycle \${CYCLE_ID}: published; worktree cleaned"
|
|
2320
2406
|
elif [ "\$_publish_status" -eq 2 ]; then
|
|
2321
2407
|
if ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
|
|
@@ -2360,6 +2446,32 @@ fi
|
|
|
2360
2446
|
|
|
2361
2447
|
# US-AUTO-040: fallback GC — delete remote loop/cycle-* branches already merged to main.
|
|
2362
2448
|
_loop_cleanup_stale_cycle_branches "${project_path}" || true
|
|
2449
|
+
|
|
2450
|
+
# FIX-044 / Step 5: Write loop cycle run summary to runs.jsonl
|
|
2451
|
+
# Deterministic — runs in shell regardless of whether agent executes SKILL.md Step 5.
|
|
2452
|
+
# Idempotent: skips if a record for this run_id already exists (agent may also write).
|
|
2453
|
+
_runs_dst="${HOME}/.shared/roll/loop/runs.jsonl"
|
|
2454
|
+
mkdir -p "\$(dirname "\$_runs_dst")"
|
|
2455
|
+
_cycle_end=\$(date -u +%s)
|
|
2456
|
+
_cycle_dur=\$(( _cycle_end - CYCLE_START ))
|
|
2457
|
+
_ts=\$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
2458
|
+
_run_id="loop-\${CYCLE_ID%-*}"
|
|
2459
|
+
if command -v jq >/dev/null 2>&1 && ! grep -qF "\"run_id\":\"\$_run_id\"" "\$_runs_dst" 2>/dev/null; then
|
|
2460
|
+
jq -nc \\
|
|
2461
|
+
--arg ts "\$_ts" \\
|
|
2462
|
+
--arg project "${slug}" \\
|
|
2463
|
+
--arg run_id "\$_run_id" \\
|
|
2464
|
+
--arg status "\$_cycle_status" \\
|
|
2465
|
+
--argjson built "\$_cycle_built" \\
|
|
2466
|
+
--argjson skipped '[]' \\
|
|
2467
|
+
--argjson alerts '[]' \\
|
|
2468
|
+
--argjson tcr_count "\$_cycle_tcr" \\
|
|
2469
|
+
--argjson duration_sec "\$_cycle_dur" \\
|
|
2470
|
+
'{ts:\$ts, project:\$project, run_id:\$run_id, status:\$status,
|
|
2471
|
+
built:\$built, skipped:\$skipped, alerts:\$alerts,
|
|
2472
|
+
tcr_count:\$tcr_count, duration_sec:\$duration_sec}' \\
|
|
2473
|
+
>> "\$_runs_dst" 2>/dev/null || true
|
|
2474
|
+
fi
|
|
2363
2475
|
INNER
|
|
2364
2476
|
chmod +x "$inner_path"
|
|
2365
2477
|
|
|
@@ -2587,6 +2699,11 @@ _agent_skill_cmd() {
|
|
|
2587
2699
|
err "Unknown agent '${agent}'. Run: roll agent use <claude|kimi|deepseek|pi|codex|opencode>"
|
|
2588
2700
|
return 1
|
|
2589
2701
|
}
|
|
2702
|
+
# Cron-installed skills (dream / brief / loop) run autonomously and need to
|
|
2703
|
+
# Edit files (docs/dream/, docs/briefs/, BACKLOG, etc.). Claude Code 2.1.x's
|
|
2704
|
+
# pre-write approval UX silently blocks `claude -p` from applying edits in
|
|
2705
|
+
# non-interactive pipe mode — bypass it for the cron context.
|
|
2706
|
+
_agent_bypass_claude_perms
|
|
2590
2707
|
# In cron context, use absolute claude path so a fresh shell can find it.
|
|
2591
2708
|
[[ "$agent" == "claude" ]] && _AGENT_ARGV[0]="$(command -v claude 2>/dev/null || echo claude)"
|
|
2592
2709
|
# Drop the prompt sentinel (always last), re-emit head args + quoted $(strip).
|
|
@@ -2607,6 +2724,7 @@ cmd_loop() {
|
|
|
2607
2724
|
status) _loop_status ;;
|
|
2608
2725
|
monitor) _loop_monitor "${1:-3}" ;;
|
|
2609
2726
|
runs) _loop_runs "$@" ;;
|
|
2727
|
+
events) _loop_event_log "${1:-20}" ;;
|
|
2610
2728
|
attach) _loop_attach ;;
|
|
2611
2729
|
mute) _loop_mute ;;
|
|
2612
2730
|
unmute) _loop_unmute ;;
|
|
@@ -2614,7 +2732,7 @@ cmd_loop() {
|
|
|
2614
2732
|
resume) _loop_resume ;;
|
|
2615
2733
|
reset) _loop_reset ;;
|
|
2616
2734
|
notify) _notify "${1:-roll}" "${2:-}" ;;
|
|
2617
|
-
*) err "Usage: roll loop <on|off|now|test|status|monitor|runs|attach|mute|unmute|pause|resume|reset|notify>"; exit 1 ;;
|
|
2735
|
+
*) err "Usage: roll loop <on|off|now|test|status|monitor|runs|events|attach|mute|unmute|pause|resume|reset|notify>"; exit 1 ;;
|
|
2618
2736
|
esac
|
|
2619
2737
|
}
|
|
2620
2738
|
|
|
@@ -3298,8 +3416,12 @@ _loop_diagnose_open_prs() {
|
|
|
3298
3416
|
# When gh unavailable: returns 0 (graceful skip).
|
|
3299
3417
|
_loop_enforce_ci() {
|
|
3300
3418
|
local story_id="$1"
|
|
3301
|
-
|
|
3302
|
-
_ci_wait 300
|
|
3419
|
+
local _ci_result
|
|
3420
|
+
if _ci_wait 300; then
|
|
3421
|
+
_loop_event ci "$story_id" "" "ok" 2>/dev/null || true
|
|
3422
|
+
return 0
|
|
3423
|
+
fi
|
|
3424
|
+
_loop_event ci "$story_id" "" "red" 2>/dev/null || true
|
|
3303
3425
|
|
|
3304
3426
|
mkdir -p "$(dirname "$_LOOP_ALERT")"
|
|
3305
3427
|
cat > "$_LOOP_ALERT" << EOF
|
|
@@ -4126,6 +4248,7 @@ _loop_publish_pr() {
|
|
|
4126
4248
|
fi
|
|
4127
4249
|
gh -R "$slug" pr merge "$branch" --auto --squash --delete-branch >/dev/null 2>&1 \
|
|
4128
4250
|
|| _worktree_alert "_loop_publish_pr: gh pr merge --auto failed for ${branch} (PR ${pr_url} left open)"
|
|
4251
|
+
_loop_event pr "$branch" "$pr_url" "ok" 2>/dev/null || true
|
|
4129
4252
|
echo "$pr_url"
|
|
4130
4253
|
return 0
|
|
4131
4254
|
}
|
|
@@ -4317,11 +4440,57 @@ _loop_monitor() {
|
|
|
4317
4440
|
echo -e " ${YELLOW}(no log yet)${NC}"
|
|
4318
4441
|
fi
|
|
4319
4442
|
|
|
4443
|
+
# Event stream (US-LOOP-001): last 10 events from NDJSON event file
|
|
4444
|
+
local slug; slug=$(_project_slug "$project_path")
|
|
4445
|
+
local evfile="${_SHARED_ROOT}/loop/events-${slug}.ndjson"
|
|
4446
|
+
echo ""
|
|
4447
|
+
echo -e " ─────────────────────────────────────────────────────"
|
|
4448
|
+
echo -e " ${BOLD}Cycle Events 事件流${NC} (last 10)"
|
|
4449
|
+
if [[ -f "$evfile" && -s "$evfile" ]]; then
|
|
4450
|
+
tail -n 10 "$evfile" | python3 -c "
|
|
4451
|
+
import sys, json
|
|
4452
|
+
for line in sys.stdin:
|
|
4453
|
+
try:
|
|
4454
|
+
e = json.loads(line)
|
|
4455
|
+
stage = e.get('stage','')
|
|
4456
|
+
label = e.get('label','')
|
|
4457
|
+
detail = e.get('detail','')
|
|
4458
|
+
outcome = e.get('outcome','')
|
|
4459
|
+
ts = e.get('ts','')
|
|
4460
|
+
print(f' {ts} {stage:<14} {label:<22} {detail} {outcome}')
|
|
4461
|
+
except: pass
|
|
4462
|
+
" 2>/dev/null || tail -n 10 "$evfile" | sed 's/^/ /'
|
|
4463
|
+
else
|
|
4464
|
+
echo -e " ${YELLOW}(no events yet — events are emitted after the first cycle)${NC}"
|
|
4465
|
+
fi
|
|
4466
|
+
|
|
4320
4467
|
echo ""
|
|
4321
4468
|
sleep "$interval"
|
|
4322
4469
|
done
|
|
4323
4470
|
}
|
|
4324
4471
|
|
|
4472
|
+
# _loop_event_log: show last N events from the project's NDJSON event file.
|
|
4473
|
+
# Used by: roll loop events [N]
|
|
4474
|
+
_loop_event_log() {
|
|
4475
|
+
local n="${1:-20}"
|
|
4476
|
+
local project_path; project_path=$(pwd -P)
|
|
4477
|
+
local slug; slug=$(_project_slug "$project_path")
|
|
4478
|
+
local evfile="${_SHARED_ROOT}/loop/events-${slug}.ndjson"
|
|
4479
|
+
if [ ! -f "$evfile" ]; then
|
|
4480
|
+
echo "[monitor] No event log found for project: $slug"
|
|
4481
|
+
return 1
|
|
4482
|
+
fi
|
|
4483
|
+
# Show last N events, formatted
|
|
4484
|
+
tail -n "$n" "$evfile" | python3 -c "
|
|
4485
|
+
import sys, json
|
|
4486
|
+
for line in sys.stdin:
|
|
4487
|
+
try:
|
|
4488
|
+
e = json.loads(line)
|
|
4489
|
+
print(f\" {e.get('ts','')} {e.get('stage',''):12s} {e.get('label',''):20s} {e.get('detail','')} {e.get('outcome','')}\")
|
|
4490
|
+
except: pass
|
|
4491
|
+
"
|
|
4492
|
+
}
|
|
4493
|
+
|
|
4325
4494
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
4326
4495
|
# BRIEF — owner-facing project digest
|
|
4327
4496
|
# ═══════════════════════════════════════════════════════════════════════════════
|
package/lib/loop-fmt.py
CHANGED
|
@@ -1,180 +1,254 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
loop-fmt.py — stream-json →
|
|
3
|
+
loop-fmt.py — 3-tier stream-json → tmux formatter for roll loop.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Color codes: no external deps, plain ANSI.
|
|
5
|
+
Tier 3 (suppressed): init, thinking, Read/Glob/Grep, non-error results, plain Bash
|
|
6
|
+
Tier 2 (muted): Edit/Write → ✏ path
|
|
7
|
+
Tier 1 (signal): tcr commit, story skill, peer verdict, ci gate, pr merge, errors
|
|
10
8
|
"""
|
|
11
|
-
|
|
12
9
|
import sys
|
|
13
10
|
import json
|
|
14
11
|
import re
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
CYAN
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
RED
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
DARK_GRAY = "\033[90m"
|
|
15
|
+
CYAN = "\033[36m"
|
|
16
|
+
WHITE = "\033[97m"
|
|
17
|
+
GREEN = "\033[32m"
|
|
18
|
+
RED = "\033[31m"
|
|
19
|
+
RESET = "\033[0m"
|
|
20
|
+
|
|
21
|
+
SUPPRESS_TOOLS = {"Read", "Glob", "Grep", "ReadMcpResourceTool", "ListMcpResourcesTool",
|
|
22
|
+
"WebFetch", "WebSearch", "TaskCreate", "TaskGet", "TaskList",
|
|
23
|
+
"TaskUpdate", "TaskOutput", "TaskStop"}
|
|
24
|
+
|
|
25
|
+
def now_hms():
|
|
26
|
+
return datetime.now(timezone.utc).strftime("%H:%M:%S")
|
|
27
|
+
|
|
28
|
+
def trunc(s, n=60):
|
|
31
29
|
s = str(s).replace("\n", " ").strip()
|
|
32
30
|
return s[:n] + "…" if len(s) > n else s
|
|
33
31
|
|
|
34
|
-
def
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
elif t == "image":
|
|
66
|
-
parts.append("[image]")
|
|
67
|
-
else:
|
|
68
|
-
parts.append(str(c))
|
|
69
|
-
text = " ".join(parts)
|
|
70
|
-
else:
|
|
71
|
-
text = str(content) if content is not None else ""
|
|
72
|
-
# strip ansi for length check
|
|
73
|
-
clean = re.sub(r'\033\[[0-9;]*m', '', text)
|
|
74
|
-
lines = [l for l in clean.splitlines() if l.strip()]
|
|
75
|
-
if not lines:
|
|
76
|
-
return "(empty)"
|
|
77
|
-
# show first 3 lines, trim long lines
|
|
78
|
-
out = []
|
|
79
|
-
for l in lines[:3]:
|
|
80
|
-
out.append(" " + trunc(l, 100))
|
|
81
|
-
if len(lines) > 3:
|
|
82
|
-
out.append(f" {DIM}… ({len(lines)-3} more lines){RESET}")
|
|
83
|
-
return "\n".join(out)
|
|
84
|
-
|
|
85
|
-
def process_line(line):
|
|
86
|
-
line = line.rstrip()
|
|
87
|
-
if not line:
|
|
88
|
-
return
|
|
89
|
-
try:
|
|
90
|
-
ev = json.loads(line)
|
|
91
|
-
except json.JSONDecodeError:
|
|
92
|
-
# plain text passthrough
|
|
93
|
-
print(line)
|
|
94
|
-
return
|
|
95
|
-
|
|
96
|
-
etype = ev.get("type", "")
|
|
97
|
-
|
|
98
|
-
# ── system events ──────────────────────────────────────────────
|
|
99
|
-
if etype == "system":
|
|
100
|
-
subtype = ev.get("subtype", "")
|
|
101
|
-
if subtype in SKIP_SUBTYPES:
|
|
32
|
+
def step(category, label, detail="", ok=True):
|
|
33
|
+
cat_color = CYAN
|
|
34
|
+
label_color = GREEN if ok and category in ("ci", "pr") else (RED if not ok else WHITE)
|
|
35
|
+
arrow = f"{DARK_GRAY}→{RESET}"
|
|
36
|
+
cat = f" {cat_color}{category:<6}{RESET}"
|
|
37
|
+
lbl = f" {label_color}{label:<14}{RESET}"
|
|
38
|
+
det = f" {DARK_GRAY}{detail}{RESET}" if detail else ""
|
|
39
|
+
return f"{arrow}{cat}{lbl}{det}"
|
|
40
|
+
|
|
41
|
+
def stamp(text, muted=False):
|
|
42
|
+
ts = f"{DARK_GRAY}{now_hms()}{RESET}"
|
|
43
|
+
body = f"{DARK_GRAY}{text}{RESET}" if muted else text
|
|
44
|
+
return f"{ts} {body}"
|
|
45
|
+
|
|
46
|
+
class LoopFmt:
|
|
47
|
+
def __init__(self):
|
|
48
|
+
self.last_bash_cmd = ""
|
|
49
|
+
self.tcr_count = 0
|
|
50
|
+
self.last_test_count = None
|
|
51
|
+
self.cycle_num = None
|
|
52
|
+
self.pending_commit = False
|
|
53
|
+
self.pending_pr = False
|
|
54
|
+
self.pending_ci = False
|
|
55
|
+
|
|
56
|
+
def _extract_cycle_num(self, text):
|
|
57
|
+
m = re.search(r'cycle[#\s]+(\d+)', text, re.IGNORECASE)
|
|
58
|
+
return m.group(1) if m else "?"
|
|
59
|
+
|
|
60
|
+
def process(self, line):
|
|
61
|
+
line = line.rstrip()
|
|
62
|
+
if not line:
|
|
102
63
|
return
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
print(f"{DIM}[init] model={model} tools={tool_list}{RESET}")
|
|
64
|
+
|
|
65
|
+
# Plain text passthrough
|
|
66
|
+
try:
|
|
67
|
+
ev = json.loads(line)
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
self._handle_plain(line)
|
|
110
70
|
return
|
|
111
|
-
# unknown system — show raw briefly
|
|
112
|
-
print(f"{DIM}[sys/{subtype}]{RESET}")
|
|
113
|
-
return
|
|
114
71
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
72
|
+
etype = ev.get("type", "")
|
|
73
|
+
if etype == "system":
|
|
74
|
+
return # Tier 3: suppress all system events
|
|
75
|
+
if etype == "assistant":
|
|
76
|
+
self._handle_assistant(ev)
|
|
77
|
+
elif etype == "user":
|
|
78
|
+
self._handle_user(ev)
|
|
79
|
+
elif etype == "result":
|
|
80
|
+
self._handle_result(ev)
|
|
81
|
+
# All other types: suppress
|
|
82
|
+
|
|
83
|
+
def _handle_plain(self, line):
|
|
84
|
+
# [loop] cycle N: ... → Tier 1 stamp
|
|
85
|
+
m = re.search(r'\[loop\]\s+cycle\s+(\d+)[:\s]', line)
|
|
86
|
+
if m:
|
|
87
|
+
self.cycle_num = m.group(1)
|
|
88
|
+
self.tcr_count = 0
|
|
89
|
+
print(stamp(f"cycle #{self.cycle_num} — picking story"))
|
|
90
|
+
return
|
|
91
|
+
# Other plain text: suppress
|
|
118
92
|
|
|
119
|
-
|
|
120
|
-
if etype == "assistant":
|
|
93
|
+
def _handle_assistant(self, ev):
|
|
121
94
|
msg = ev.get("message", {})
|
|
122
95
|
for blk in msg.get("content", []):
|
|
123
96
|
btype = blk.get("type", "")
|
|
124
|
-
if btype == "
|
|
125
|
-
|
|
126
|
-
inp = blk.get("input", {})
|
|
127
|
-
summary = fmt_tool_input(name, inp)
|
|
128
|
-
print(f"{CYAN}→ {BOLD}{name}{RESET}{CYAN}: {summary}{RESET}")
|
|
97
|
+
if btype == "thinking":
|
|
98
|
+
return # Tier 3
|
|
129
99
|
elif btype == "text":
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
100
|
+
self._handle_text(blk.get("text", ""))
|
|
101
|
+
elif btype == "tool_use":
|
|
102
|
+
self._handle_tool_use(blk)
|
|
103
|
+
|
|
104
|
+
def _handle_text(self, text):
|
|
105
|
+
text = text.strip()
|
|
106
|
+
if not text:
|
|
107
|
+
return
|
|
108
|
+
# Peer verdict detection
|
|
109
|
+
for verdict in ("AGREE", "REFINE", "OBJECT", "ESCALATE"):
|
|
110
|
+
if verdict in text:
|
|
111
|
+
m = re.search(r'round\s+(\d+)[/\\](\d+)', text, re.IGNORECASE)
|
|
112
|
+
round_str = f"round {m.group(1)}/{m.group(2)}" if m else "round ?"
|
|
113
|
+
# agent names — look for common patterns
|
|
114
|
+
agents = "claude → peer"
|
|
115
|
+
m2 = re.search(r'(\w+)\s*→\s*(\w+)', text)
|
|
116
|
+
if m2:
|
|
117
|
+
agents = f"{m2.group(1)} → {m2.group(2)}"
|
|
118
|
+
print(step("peer", agents, f"{round_str} · {verdict}"))
|
|
119
|
+
return
|
|
120
|
+
# All other text: Tier 3, suppress
|
|
121
|
+
|
|
122
|
+
def _handle_tool_use(self, blk):
|
|
123
|
+
name = blk.get("name", "")
|
|
124
|
+
inp = blk.get("input", {})
|
|
125
|
+
|
|
126
|
+
if name in SUPPRESS_TOOLS:
|
|
127
|
+
return # Tier 3
|
|
128
|
+
|
|
129
|
+
if name in ("Edit", "Write"):
|
|
130
|
+
path = inp.get("file_path") or inp.get("path", "")
|
|
131
|
+
print(f" {DARK_GRAY}✏ {path}{RESET}")
|
|
132
|
+
return # Tier 2
|
|
133
|
+
|
|
134
|
+
if name == "Bash":
|
|
135
|
+
cmd = inp.get("command", "")
|
|
136
|
+
first_line = next((l.strip() for l in cmd.splitlines() if l.strip()), cmd)
|
|
137
|
+
self.last_bash_cmd = first_line
|
|
138
|
+
if re.search(r'git commit.*tcr:', cmd):
|
|
139
|
+
self.pending_commit = True
|
|
140
|
+
elif re.search(r'gh pr (create|merge)', cmd):
|
|
141
|
+
self.pending_pr = True
|
|
142
|
+
elif re.search(r'(roll ci|npm run ci|ci:local)', cmd):
|
|
143
|
+
self.pending_ci = True
|
|
144
|
+
return # Wait for result
|
|
145
|
+
|
|
146
|
+
if name == "Skill":
|
|
147
|
+
skill = inp.get("skill", "")
|
|
148
|
+
args = inp.get("args", "").strip()
|
|
149
|
+
if skill in ("roll-build", "roll-fix"):
|
|
150
|
+
us_id = args.split()[0] if args else "?"
|
|
151
|
+
print()
|
|
152
|
+
print(stamp(f"cycle #{self.cycle_num or '?'} — picking story"))
|
|
153
|
+
print(step("story", us_id, trunc(args, 60)))
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
# All other tools (Agent, ToolSearch, etc.): suppress
|
|
157
|
+
|
|
158
|
+
def _handle_user(self, ev):
|
|
144
159
|
msg = ev.get("message", {})
|
|
145
160
|
for blk in msg.get("content", []):
|
|
146
|
-
if blk.get("type")
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
161
|
+
if blk.get("type") != "tool_result":
|
|
162
|
+
continue
|
|
163
|
+
is_err = blk.get("is_error", False)
|
|
164
|
+
content = blk.get("content", "")
|
|
165
|
+
text = self._extract_text(content)
|
|
166
|
+
|
|
167
|
+
# Scan for test count (bats ok N pattern)
|
|
168
|
+
m = re.search(r'\bok\s+(\d+)', text)
|
|
169
|
+
if m:
|
|
170
|
+
self.last_test_count = int(m.group(1))
|
|
171
|
+
|
|
172
|
+
if is_err:
|
|
173
|
+
tool_name = "tool"
|
|
174
|
+
lines = [l for l in text.splitlines() if l.strip()][:3]
|
|
175
|
+
detail = " | ".join(lines)
|
|
176
|
+
print(step("error", tool_name, trunc(detail, 80), ok=False))
|
|
177
|
+
self.pending_commit = self.pending_pr = self.pending_ci = False
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
if self.pending_commit:
|
|
181
|
+
self.pending_commit = False
|
|
182
|
+
# Extract hash and message from git commit output: [branch hash] msg
|
|
183
|
+
m = re.search(r'\[[\w/\-]+ ([0-9a-f]{7,})\]\s*tcr:\s*(.+)', text)
|
|
184
|
+
if m:
|
|
185
|
+
commit_hash = m.group(1)[:7]
|
|
186
|
+
commit_msg = m.group(2).strip()
|
|
187
|
+
self.tcr_count += 1
|
|
188
|
+
test_part = f" · {self.last_test_count} tests" if self.last_test_count else ""
|
|
189
|
+
print(step("tcr", commit_hash, f"{commit_msg}{test_part}"))
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
if self.pending_pr:
|
|
193
|
+
self.pending_pr = False
|
|
194
|
+
m = re.search(r'#(\d+)', text)
|
|
195
|
+
if m:
|
|
196
|
+
pr_num = f"#{m.group(1)}"
|
|
197
|
+
branch = re.search(r'loop/[\w\-]+', self.last_bash_cmd)
|
|
198
|
+
branch_str = branch.group(0) if branch else ""
|
|
199
|
+
detail = f"auto-merged · {branch_str}" if branch_str else "auto-merged"
|
|
200
|
+
print(step("pr", pr_num, detail, ok=True))
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
if self.pending_ci:
|
|
204
|
+
self.pending_ci = False
|
|
205
|
+
has_green = re.search(r'(green|pass|success|all tests)', text, re.IGNORECASE)
|
|
206
|
+
has_red = re.search(r'(red|fail|error)', text, re.IGNORECASE)
|
|
207
|
+
m_dur = re.search(r'(\d+(?:\.\d+)?)\s*s\b', text)
|
|
208
|
+
m_test = re.search(r'(\d+)\s+tests?', text)
|
|
209
|
+
dur_str = f"{m_dur.group(1)}s" if m_dur else ""
|
|
210
|
+
test_str = f"{m_test.group(1)} tests" if m_test else (f"{self.last_test_count} tests" if self.last_test_count else "")
|
|
211
|
+
detail = " · ".join(filter(None, [dur_str, test_str]))
|
|
212
|
+
if has_green and not has_red:
|
|
213
|
+
print(step("ci", "green", detail, ok=True))
|
|
214
|
+
else:
|
|
215
|
+
print(step("ci", "red", detail, ok=False))
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
# Non-matching result: suppress (Tier 3)
|
|
219
|
+
|
|
220
|
+
def _extract_text(self, content):
|
|
221
|
+
if isinstance(content, str):
|
|
222
|
+
return content
|
|
223
|
+
if isinstance(content, list):
|
|
224
|
+
parts = []
|
|
225
|
+
for c in content:
|
|
226
|
+
if isinstance(c, dict) and c.get("type") == "text":
|
|
227
|
+
parts.append(c.get("text", ""))
|
|
228
|
+
return "\n".join(parts)
|
|
229
|
+
return str(content) if content else ""
|
|
230
|
+
|
|
231
|
+
def _handle_result(self, ev):
|
|
156
232
|
dur_ms = ev.get("duration_ms", 0)
|
|
157
233
|
cost_usd = ev.get("total_cost_usd", 0)
|
|
158
|
-
turns = ev.get("num_turns", "?")
|
|
159
234
|
dur_s = dur_ms / 1000
|
|
160
|
-
cost_str = f"${cost_usd:.
|
|
235
|
+
cost_str = f"${cost_usd:.2f}" if cost_usd else ""
|
|
236
|
+
tcr_str = f"{self.tcr_count} tcr" if self.tcr_count else ""
|
|
237
|
+
parts = [p for p in [tcr_str, f"{dur_s:.0f}s", cost_str] if p]
|
|
238
|
+
detail = " · ".join(parts)
|
|
161
239
|
subtype = ev.get("subtype", "")
|
|
162
240
|
if subtype == "error_max_turns":
|
|
163
|
-
print(
|
|
241
|
+
print(step("error", "max-turns", f"{dur_s:.0f}s", ok=False))
|
|
164
242
|
else:
|
|
165
|
-
|
|
166
|
-
print(f"
|
|
167
|
-
return
|
|
168
|
-
|
|
169
|
-
# ── fallback ────────────────────────────────────────────────
|
|
170
|
-
print(f"{DIM}{trunc(line, 160)}{RESET}")
|
|
243
|
+
cycle_str = f"cycle #{self.cycle_num}" if self.cycle_num else "cycle done"
|
|
244
|
+
print(stamp(f"{cycle_str} — done · {detail}" if detail else f"{cycle_str} — done", muted=True))
|
|
171
245
|
|
|
172
246
|
|
|
173
247
|
def main():
|
|
248
|
+
fmt = LoopFmt()
|
|
174
249
|
for line in sys.stdin:
|
|
175
|
-
|
|
250
|
+
fmt.process(line)
|
|
176
251
|
sys.stdout.flush()
|
|
177
252
|
|
|
178
|
-
|
|
179
253
|
if __name__ == "__main__":
|
|
180
254
|
main()
|
package/package.json
CHANGED
|
@@ -127,6 +127,18 @@ find docs/ -maxdepth 1 -name '*.md' 2>/dev/null
|
|
|
127
127
|
|
|
128
128
|
Flag any `.md` file directly in `docs/` root (allowed subdirs: `guide/`, `domain/`, `features/`, `practices/`, `briefs/`, `dream/`).
|
|
129
129
|
|
|
130
|
+
**Check D — features.md Feature Coverage (US-DOC-009):**
|
|
131
|
+
|
|
132
|
+
Dependency gate: skip when `docs/features.md` does not exist.
|
|
133
|
+
|
|
134
|
+
Parse BACKLOG.md for all `### Feature: <name>` groups that contain ≥1 ✅ Done story. Parse `docs/features.md` for Feature names. If any Feature group with Done stories is absent from `docs/features.md`, the catalog is stale — flag as REFACTOR:
|
|
135
|
+
|
|
136
|
+
```markdown
|
|
137
|
+
| REFACTOR-XXX | features.md 功能目录落后于 BACKLOG,N 个已完成功能区未收录,用户无法通过产品目录发现这些功能 — flagged by dream YYYY-MM-DD | 📋 Todo |
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The catalog is auto-updated by `scripts/release.sh` at release time (Section 8 of roll-.changelog). Between releases, this check surfaces the coverage gap so it isn't silently skipped.
|
|
141
|
+
|
|
130
142
|
**REFACTOR entry format for doc findings:**
|
|
131
143
|
|
|
132
144
|
```markdown
|
|
@@ -137,7 +149,8 @@ Flag any `.md` file directly in `docs/` root (allowed subdirs: `guide/`, `domain
|
|
|
137
149
|
|
|
138
150
|
```markdown
|
|
139
151
|
## 文档覆盖度
|
|
140
|
-
{
|
|
152
|
+
- features.md 功能区覆盖:{N}/{M} 个已完成功能区已收录(缺失:{列表 或 "无"})
|
|
153
|
+
{其他发现内容 或 "文档结构符合规范,无缺口。"}
|
|
141
154
|
```
|
|
142
155
|
|
|
143
156
|
### Scan 6 — 文档新鲜度 (Doc Freshness)
|
|
@@ -302,6 +302,13 @@ When any signal appears, **do not stop — flag it**:
|
|
|
302
302
|
|
|
303
303
|
Then continue implementing the current Story normally.
|
|
304
304
|
|
|
305
|
+
**Event emission** — after all TCR micro-steps for a Story complete, emit a `build` event so the cycle event stream reflects the work done:
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
# _tcr_count = number of "tcr:" prefix commits made during this Story
|
|
309
|
+
_loop_event build "$US_ID" "${_tcr_count} commits" "" 2>/dev/null || true
|
|
310
|
+
```
|
|
311
|
+
|
|
305
312
|
### Phase 5.5: E2E Deposit
|
|
306
313
|
|
|
307
314
|
After TCR micro-steps pass, deposit an E2E test for this Story's core user flow.
|
|
@@ -195,6 +195,13 @@ For each item, **before invoking the executor skill**, mark the story 🔨 In Pr
|
|
|
195
195
|
|
|
196
196
|
This commit is what makes the work visible — without it, tcr micro-commits during execution are invisible to `roll-brief`.
|
|
197
197
|
|
|
198
|
+
选定故事后,调用 `_loop_event` 发出 story 事件,让 monitor 和 attach 能渲染当前进度:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
# 选定故事后立即 emit(在调用 executor skill 之前)
|
|
202
|
+
_loop_event story "$US_ID" "$story_title" ""
|
|
203
|
+
```
|
|
204
|
+
|
|
198
205
|
Then invoke the executor:
|
|
199
206
|
|
|
200
207
|
```
|
|
@@ -276,6 +283,11 @@ After each item completes:
|
|
|
276
283
|
|
|
277
284
|
### Step 5 — Write Run Summary
|
|
278
285
|
|
|
286
|
+
> **FIX-044**: The inner runner script (`_write_loop_runner_script` in `bin/roll`)
|
|
287
|
+
> now appends this record deterministically at cycle end. The shell write is the
|
|
288
|
+
> authoritative record; the agent should still emit a run summary in the cycle's
|
|
289
|
+
> final report for `cron.log` visibility.
|
|
290
|
+
|
|
279
291
|
After all items in this cycle:
|
|
280
292
|
|
|
281
293
|
```yaml
|
|
@@ -65,6 +65,14 @@ Allowed states only. No invented words.
|
|
|
65
65
|
- **OBJECT**: The proposal is wrong. Provide an alternative. Proceed to next round.
|
|
66
66
|
- **ESCALATE**: Round 3 reached without AGREE, or a round fails due to API/token error. Hand off to the human user.
|
|
67
67
|
|
|
68
|
+
After each round decision, emit a `peer` event to the cycle event stream:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# $round = current round number, $total = max rounds, $verdict = AGREE/REFINE/OBJECT/ESCALATE
|
|
72
|
+
# $agents = e.g. "claude→deepseek"
|
|
73
|
+
_loop_event peer "${round}/${total}" "$verdict" "$agents" 2>/dev/null || true
|
|
74
|
+
```
|
|
75
|
+
|
|
68
76
|
If information is insufficient:
|
|
69
77
|
```
|
|
70
78
|
REFINE: Need to confirm X/Y/Z with the user first.
|