@seanyao/roll 2026.517.2 → 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 +6 -0
- package/bin/roll +155 -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,6 +1,12 @@
|
|
|
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
|
+
|
|
3
8
|
## v2026.517.2
|
|
9
|
+
- **Fixed**: `roll loop runs` 现在能看到刚跑完的循环记录(不再无历史) `[loop]`
|
|
4
10
|
- **Fixed**: `roll dream`、`roll brief`、`roll loop` 的定时任务不再被 Claude 升级后的弹窗拦住,悄悄失效
|
|
5
11
|
|
|
6
12
|
## v2026.517.1
|
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"
|
|
@@ -2073,7 +2073,7 @@ _LAUNCHD_DIR="${HOME}/Library/LaunchAgents"
|
|
|
2073
2073
|
# Returns a filesystem-safe slug combining the project basename and a 6-char
|
|
2074
2074
|
# hash of the full path, ensuring uniqueness across sibling dirs with same name.
|
|
2075
2075
|
_project_slug() {
|
|
2076
|
-
local path="$1"
|
|
2076
|
+
local path="${1:-$(pwd -P 2>/dev/null || pwd)}"
|
|
2077
2077
|
# FIX-034: when inside a git worktree, git-common-dir returns the main tree's
|
|
2078
2078
|
# absolute .git path; resolve to the main tree so worktree and main-tree runs
|
|
2079
2079
|
# produce the same slug.
|
|
@@ -2115,6 +2115,55 @@ _loop_derive_minute() {
|
|
|
2115
2115
|
echo $(( (hash_dec + offset) % 55 + 1 ))
|
|
2116
2116
|
}
|
|
2117
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
|
+
|
|
2118
2167
|
_launchd_label() {
|
|
2119
2168
|
local service="$1" project_path="$2"
|
|
2120
2169
|
printf 'com.roll.%s.%s' "$service" "$(_project_slug "$project_path")"
|
|
@@ -2237,6 +2286,7 @@ set +e
|
|
|
2237
2286
|
# On any failure (no remote, no main, etc.) fall back to running in the
|
|
2238
2287
|
# project's main tree (degraded — no isolation, like pre-037 behavior).
|
|
2239
2288
|
CYCLE_ID="\$(date -u +%Y%m%d-%H%M%S)-\$\$"
|
|
2289
|
+
CYCLE_START=\$(date -u +%s)
|
|
2240
2290
|
WT="\$(_worktree_path "${slug}" "cycle-\${CYCLE_ID}")"
|
|
2241
2291
|
BRANCH="loop/cycle-\${CYCLE_ID}"
|
|
2242
2292
|
_USE_WORKTREE=0
|
|
@@ -2278,6 +2328,7 @@ if _worktree_fetch_origin main \\
|
|
|
2278
2328
|
_USE_WORKTREE=1
|
|
2279
2329
|
_worktree_submodule_init "\$WT" 2>/dev/null || true
|
|
2280
2330
|
echo "[loop] cycle \${CYCLE_ID}: worktree \$WT on \$BRANCH"
|
|
2331
|
+
_loop_event cycle_start "\${CYCLE_ID}" "" "" || true
|
|
2281
2332
|
else
|
|
2282
2333
|
# P3 fix: skip the cycle entirely when worktree isolation fails.
|
|
2283
2334
|
# --dangerously-skip-permissions is only safe paired with worktree isolation;
|
|
@@ -2302,6 +2353,25 @@ for _attempt in 1 2 3; do
|
|
|
2302
2353
|
fi
|
|
2303
2354
|
done
|
|
2304
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
|
+
|
|
2305
2375
|
# US-AUTO-038: diff snapshot vs current and delete any claude/* branches this
|
|
2306
2376
|
# session pushed to origin. Runs regardless of claude's exit code (cleanup is
|
|
2307
2377
|
# orthogonal to success/failure) and is silent on non-GitHub / unreachable.
|
|
@@ -2320,6 +2390,7 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
|
|
|
2320
2390
|
_cycle_commits=\$(cd "\$WT" && git rev-list --count origin/main..HEAD 2>/dev/null || echo 0)
|
|
2321
2391
|
if [ "\$_cycle_commits" -eq 0 ]; then
|
|
2322
2392
|
_worktree_cleanup "\$WT" "\$BRANCH"
|
|
2393
|
+
_loop_event idle "\${CYCLE_ID}" "" "" || true
|
|
2323
2394
|
echo "[loop] cycle \${CYCLE_ID}: idle (no new commits); worktree cleaned"
|
|
2324
2395
|
else
|
|
2325
2396
|
if ( cd "\$WT" && _loop_is_doc_only_change ); then
|
|
@@ -2330,6 +2401,7 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
|
|
|
2330
2401
|
_publish_status=\$?
|
|
2331
2402
|
if [ "\$_publish_status" -eq 0 ]; then
|
|
2332
2403
|
_worktree_cleanup "\$WT" "\$BRANCH"
|
|
2404
|
+
_loop_event cycle_end "\${CYCLE_ID}" "" "done" || true
|
|
2333
2405
|
echo "[loop] cycle \${CYCLE_ID}: published; worktree cleaned"
|
|
2334
2406
|
elif [ "\$_publish_status" -eq 2 ]; then
|
|
2335
2407
|
if ( cd "${project_path}" && _worktree_merge_back "\$BRANCH" ); then
|
|
@@ -2374,6 +2446,32 @@ fi
|
|
|
2374
2446
|
|
|
2375
2447
|
# US-AUTO-040: fallback GC — delete remote loop/cycle-* branches already merged to main.
|
|
2376
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
|
|
2377
2475
|
INNER
|
|
2378
2476
|
chmod +x "$inner_path"
|
|
2379
2477
|
|
|
@@ -2626,6 +2724,7 @@ cmd_loop() {
|
|
|
2626
2724
|
status) _loop_status ;;
|
|
2627
2725
|
monitor) _loop_monitor "${1:-3}" ;;
|
|
2628
2726
|
runs) _loop_runs "$@" ;;
|
|
2727
|
+
events) _loop_event_log "${1:-20}" ;;
|
|
2629
2728
|
attach) _loop_attach ;;
|
|
2630
2729
|
mute) _loop_mute ;;
|
|
2631
2730
|
unmute) _loop_unmute ;;
|
|
@@ -2633,7 +2732,7 @@ cmd_loop() {
|
|
|
2633
2732
|
resume) _loop_resume ;;
|
|
2634
2733
|
reset) _loop_reset ;;
|
|
2635
2734
|
notify) _notify "${1:-roll}" "${2:-}" ;;
|
|
2636
|
-
*) 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 ;;
|
|
2637
2736
|
esac
|
|
2638
2737
|
}
|
|
2639
2738
|
|
|
@@ -3317,8 +3416,12 @@ _loop_diagnose_open_prs() {
|
|
|
3317
3416
|
# When gh unavailable: returns 0 (graceful skip).
|
|
3318
3417
|
_loop_enforce_ci() {
|
|
3319
3418
|
local story_id="$1"
|
|
3320
|
-
|
|
3321
|
-
_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
|
|
3322
3425
|
|
|
3323
3426
|
mkdir -p "$(dirname "$_LOOP_ALERT")"
|
|
3324
3427
|
cat > "$_LOOP_ALERT" << EOF
|
|
@@ -4145,6 +4248,7 @@ _loop_publish_pr() {
|
|
|
4145
4248
|
fi
|
|
4146
4249
|
gh -R "$slug" pr merge "$branch" --auto --squash --delete-branch >/dev/null 2>&1 \
|
|
4147
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
|
|
4148
4252
|
echo "$pr_url"
|
|
4149
4253
|
return 0
|
|
4150
4254
|
}
|
|
@@ -4336,11 +4440,57 @@ _loop_monitor() {
|
|
|
4336
4440
|
echo -e " ${YELLOW}(no log yet)${NC}"
|
|
4337
4441
|
fi
|
|
4338
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
|
+
|
|
4339
4467
|
echo ""
|
|
4340
4468
|
sleep "$interval"
|
|
4341
4469
|
done
|
|
4342
4470
|
}
|
|
4343
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
|
+
|
|
4344
4494
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
4345
4495
|
# BRIEF — owner-facing project digest
|
|
4346
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.
|