@seanyao/roll 2026.529.5 → 2026.601.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -25
- package/README.md +10 -7
- package/bin/roll +3952 -317
- package/conventions/config.yaml +7 -0
- package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
- package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
- package/lib/agent_usage/__init__.py +4 -0
- package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
- package/lib/agent_usage/gemini.py +127 -0
- package/lib/agent_usage/kimi.py +127 -0
- package/lib/agent_usage/openai.py +126 -0
- package/lib/agent_usage/qwen.py +128 -0
- package/lib/context_feed_budget.sh +194 -0
- package/lib/github_sync.py +876 -0
- package/lib/i18n/agent.sh +54 -0
- package/lib/i18n/init.sh +22 -0
- package/lib/i18n/peer.sh +7 -0
- package/lib/i18n/peer_help.sh +4 -0
- package/lib/i18n/skills_catalog.sh +30 -0
- package/lib/loop-exit-summary.py +393 -0
- package/lib/loop-fmt.py +93 -75
- package/lib/loop_pick_agent.py +241 -170
- package/lib/loop_result_eval.py +469 -0
- package/lib/model_prices.py +0 -10
- package/lib/roll-home.py +1 -28
- package/lib/roll-loop-status.py +330 -40
- package/lib/roll-onboard-render.py +378 -0
- package/lib/roll-peer.py +1 -1
- package/lib/roll-plan-validate.py +165 -0
- package/lib/roll_git.py +41 -0
- package/lib/slides/components/README.md +8 -2
- package/lib/slides/templates/introduction-v3.html +1 -6
- package/lib/slides-render.py +305 -15
- package/lib/slides-validate.py +195 -7
- package/package.json +1 -1
- package/skills/roll-.changelog/SKILL.md +67 -56
- package/skills/roll-brief/SKILL.md +1 -1
- package/skills/roll-build/SKILL.md +14 -12
- package/skills/roll-deck/SKILL.md +152 -0
- package/skills/roll-design/SKILL.md +13 -6
- package/skills/roll-doc/SKILL.md +269 -6
- package/skills/roll-fix/SKILL.md +15 -9
- package/skills/roll-loop/SKILL.md +9 -7
- package/skills/roll-notes/SKILL.md +1 -1
- package/skills/roll-onboard/SKILL.md +85 -0
- package/skills/roll-peer/SKILL.md +6 -5
- package/lib/agent_routes_lint.py +0 -203
- package/skills/roll-research/SKILL.md +0 -316
- package/skills/roll-research/references/schema.json +0 -166
- package/skills/roll-research/scripts/md_to_pdf.py +0 -289
package/lib/i18n/agent.sh
CHANGED
|
@@ -19,3 +19,57 @@ _i18n_set en agent.not_found_in_path_setting_anyway "%s not found in PATH — se
|
|
|
19
19
|
_i18n_set zh agent.not_found_in_path_setting_anyway "未找到 %s,仍写入配置"
|
|
20
20
|
_i18n_set en agent.available_agents "Available agents"
|
|
21
21
|
_i18n_set zh agent.available_agents "可用 agent"
|
|
22
|
+
|
|
23
|
+
# US-AGENT-025: `roll agent` four-slot view.
|
|
24
|
+
_i18n_set en agent.view_header "Complexity routing (.roll/agents.yaml)"
|
|
25
|
+
_i18n_set zh agent.view_header "复杂度路由(.roll/agents.yaml)"
|
|
26
|
+
_i18n_set en agent.view_col_slot "SLOT"
|
|
27
|
+
_i18n_set zh agent.view_col_slot "槽位"
|
|
28
|
+
_i18n_set en agent.view_col_agent "AGENT"
|
|
29
|
+
_i18n_set zh agent.view_col_agent "AGENT"
|
|
30
|
+
_i18n_set en agent.view_col_status "STATUS"
|
|
31
|
+
_i18n_set zh agent.view_col_status "状态"
|
|
32
|
+
_i18n_set en agent.view_col_note "NOTE"
|
|
33
|
+
_i18n_set zh agent.view_col_note "备注"
|
|
34
|
+
_i18n_set en agent.view_slot_unset "(unset)"
|
|
35
|
+
_i18n_set zh agent.view_slot_unset "(未设)"
|
|
36
|
+
_i18n_set en agent.view_fallback_idle "idle"
|
|
37
|
+
_i18n_set zh agent.view_fallback_idle "待命"
|
|
38
|
+
_i18n_set en agent.view_fallback_active "active (in use)"
|
|
39
|
+
_i18n_set zh agent.view_fallback_active "已启用"
|
|
40
|
+
_i18n_set en agent.view_recent_downgrades "Recent downgrades"
|
|
41
|
+
_i18n_set zh agent.view_recent_downgrades "近期降级"
|
|
42
|
+
_i18n_set en agent.view_downgrade_line "%s slot %s → ran %s"
|
|
43
|
+
_i18n_set zh agent.view_downgrade_line "%s 档本该 %s → 实际 %s"
|
|
44
|
+
_i18n_set en agent.view_no_config "No .roll/agents.yaml yet — routing falls back to the first installed agent."
|
|
45
|
+
_i18n_set zh agent.view_no_config "尚无 .roll/agents.yaml — 路由回退到首个已装 agent。"
|
|
46
|
+
_i18n_set en agent.view_no_config_hint "Set up routing: roll agent set <slot> <agent> (or migrate from a legacy config)"
|
|
47
|
+
_i18n_set zh agent.view_no_config_hint "配置路由:roll agent set <slot> <agent>(或从旧配置迁移)"
|
|
48
|
+
|
|
49
|
+
# US-AGENT-026: `roll agent set` cascade picker.
|
|
50
|
+
_i18n_set en agent.set_pick_slot "Pick a complexity slot to configure:"
|
|
51
|
+
_i18n_set zh agent.set_pick_slot "选择要配置的复杂度档:"
|
|
52
|
+
_i18n_set en agent.set_enter_number "Enter number"
|
|
53
|
+
_i18n_set zh agent.set_enter_number "输入编号"
|
|
54
|
+
_i18n_set en agent.set_no_input "No input received — aborting."
|
|
55
|
+
_i18n_set zh agent.set_no_input "未收到输入 — 已取消。"
|
|
56
|
+
_i18n_set en agent.set_invalid_choice "Invalid choice: %s"
|
|
57
|
+
_i18n_set zh agent.set_invalid_choice "无效选择:%s"
|
|
58
|
+
_i18n_set en agent.set_unknown_slot "Unknown slot '%s' (expected easy|default|hard|fallback)"
|
|
59
|
+
_i18n_set zh agent.set_unknown_slot "未知槽位 '%s'(应为 easy|default|hard|fallback)"
|
|
60
|
+
_i18n_set en agent.set_unknown_agent "Unknown agent '%s' — run: roll agent list"
|
|
61
|
+
_i18n_set zh agent.set_unknown_agent "未知 agent '%s' — 运行:roll agent list"
|
|
62
|
+
_i18n_set en agent.set_no_online_agents "No installed agent is online right now — nothing to pick from."
|
|
63
|
+
_i18n_set zh agent.set_no_online_agents "当前没有在线的已装 agent — 无可选项。"
|
|
64
|
+
_i18n_set en agent.set_write_failed "Could not write slot '%s' to agents.yaml"
|
|
65
|
+
_i18n_set zh agent.set_write_failed "无法将槽位 '%s' 写入 agents.yaml"
|
|
66
|
+
_i18n_set en agent.set_saved "%s → %s saved"
|
|
67
|
+
_i18n_set zh agent.set_saved "%s → %s 已保存"
|
|
68
|
+
|
|
69
|
+
# US-AGENT-027: `roll agent use <name>` — lock easy/default/hard to one agent.
|
|
70
|
+
_i18n_set en agent.use_usage "Usage: roll agent use <name> (locks easy/default/hard to one agent)"
|
|
71
|
+
_i18n_set zh agent.use_usage "用法:roll agent use <name>(把 easy/default/hard 三档一次锁成同一 agent)"
|
|
72
|
+
_i18n_set en agent.use_unknown_agent "Unknown or uninstalled agent '%s' — run: roll agent list"
|
|
73
|
+
_i18n_set zh agent.use_unknown_agent "未知或未安装的 agent '%s' — 运行:roll agent list"
|
|
74
|
+
_i18n_set en agent.use_locked "easy/default/hard all locked to %s (fallback unchanged)"
|
|
75
|
+
_i18n_set zh agent.use_locked "已将 easy/default/hard 三档锁为 %s(fallback 不变)"
|
package/lib/i18n/init.sh
CHANGED
|
@@ -53,6 +53,28 @@ _i18n_set zh init.syncing_conventions_to_ai_tools "正在同步约定到 AI 工
|
|
|
53
53
|
_i18n_set en init.onboard_apply_complete_onboard "Onboard apply complete. Onboard"
|
|
54
54
|
_i18n_set zh init.onboard_apply_complete_onboard "应用完成。"
|
|
55
55
|
|
|
56
|
+
# US-ONBOARD-017: render the three analysis sections + gated BACKLOG seeding.
|
|
57
|
+
_i18n_set en init.onboard_rendered "Rendered: %s"
|
|
58
|
+
_i18n_set zh init.onboard_rendered "已生成:%s"
|
|
59
|
+
_i18n_set en init.onboard_render_failed "Could not render analysis markdown (skipping); onboard continues."
|
|
60
|
+
_i18n_set zh init.onboard_render_failed "分析 markdown 生成失败(已跳过),onboard 继续。"
|
|
61
|
+
_i18n_set en init.onboard_seed_preview_story "About to seed %s candidate stories to BACKLOG:"
|
|
62
|
+
_i18n_set zh init.onboard_seed_preview_story "准备向 BACKLOG 播种 %s 条候选 story:"
|
|
63
|
+
_i18n_set en init.onboard_seed_preview_fix "About to seed %s HIGH-severity risks as FIX entries:"
|
|
64
|
+
_i18n_set zh init.onboard_seed_preview_fix "准备将 %s 条高严重度风险播种为 FIX 条目:"
|
|
65
|
+
_i18n_set en init.onboard_seed_prompt "Seed these to BACKLOG?"
|
|
66
|
+
_i18n_set zh init.onboard_seed_prompt "把这些播种到 BACKLOG 吗?"
|
|
67
|
+
_i18n_set en init.onboard_seed_cancelled "Seeding cancelled. The analysis markdown was still generated."
|
|
68
|
+
_i18n_set zh init.onboard_seed_cancelled "已取消播种。分析 markdown 仍已生成。"
|
|
69
|
+
_i18n_set en init.onboard_seed_noninteractive "Non-interactive stdin — skipping BACKLOG seeding (markdown still generated)."
|
|
70
|
+
_i18n_set zh init.onboard_seed_noninteractive "stdin 非交互 —— 跳过 BACKLOG 播种(markdown 仍已生成)。"
|
|
71
|
+
_i18n_set en init.onboard_seeded_stories "Seeded %s candidate stories to BACKLOG."
|
|
72
|
+
_i18n_set zh init.onboard_seeded_stories "已向 BACKLOG 播种 %s 条候选 story。"
|
|
73
|
+
_i18n_set en init.onboard_seeded_fixes "Seeded %s candidate FIX entries to BACKLOG."
|
|
74
|
+
_i18n_set zh init.onboard_seeded_fixes "已向 BACKLOG 播种 %s 条候选 FIX。"
|
|
75
|
+
_i18n_set en init.onboard_seed_no_backlog "No .roll/backlog.md (backlog scope not approved) — skipping seeding."
|
|
76
|
+
_i18n_set zh init.onboard_seed_no_backlog "无 .roll/backlog.md(未批准 backlog scope)—— 跳过播种。"
|
|
77
|
+
|
|
56
78
|
_i18n_set en init.no_ai_agent_detected_install_one "No AI agent detected. Install one (e.g., claude, codex, kimi) and try again."
|
|
57
79
|
_i18n_set zh init.no_ai_agent_detected_install_one "未检测到 AI agent。请先安装 (如 claude / codex / kimi) 后重试。"
|
|
58
80
|
_i18n_set en init.the_process_will_use_your_agent "The process will use your agent to call models. Token cost is on your own account."
|
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"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Roll i18n catalog — `roll skills` command (US-SKILL-016).
|
|
3
|
+
# Generated skills catalog: scan skills/*/SKILL.md frontmatter → guide/skills.md.
|
|
4
|
+
|
|
5
|
+
_i18n_set en skills.generated "Generated skill catalog: %s"
|
|
6
|
+
_i18n_set zh skills.generated "已生成技能清单:%s"
|
|
7
|
+
|
|
8
|
+
_i18n_set en skills.check_ok "Skill catalog is up to date."
|
|
9
|
+
_i18n_set zh skills.check_ok "技能清单已是最新。"
|
|
10
|
+
|
|
11
|
+
_i18n_set en skills.check_drift "Skill catalog drift: %s differs from a fresh scan. Run 'roll skills generate'."
|
|
12
|
+
_i18n_set zh skills.check_drift "技能清单漂移:%s 与最新扫描不一致。请运行 'roll skills generate'。"
|
|
13
|
+
|
|
14
|
+
_i18n_set en skills.check_missing "Skill catalog not found at %s. Run 'roll skills generate'."
|
|
15
|
+
_i18n_set zh skills.check_missing "未找到技能清单 %s。请运行 'roll skills generate'。"
|
|
16
|
+
|
|
17
|
+
_i18n_set en skills.unknown_sub "Unknown 'roll skills' subcommand: %s"
|
|
18
|
+
_i18n_set zh skills.unknown_sub "未知的 'roll skills' 子命令:%s"
|
|
19
|
+
|
|
20
|
+
_i18n_set en skills.usage "Usage: roll skills <generate|check>"
|
|
21
|
+
_i18n_set zh skills.usage "用法:roll skills <generate|check>"
|
|
22
|
+
|
|
23
|
+
_i18n_set en skills.doctor_heading "Skill catalog"
|
|
24
|
+
_i18n_set zh skills.doctor_heading "技能清单"
|
|
25
|
+
|
|
26
|
+
_i18n_set en skills.doctor_ok "✅ guide/skills.md matches skills/*/SKILL.md"
|
|
27
|
+
_i18n_set zh skills.doctor_ok "✅ guide/skills.md 与 skills/*/SKILL.md 一致"
|
|
28
|
+
|
|
29
|
+
_i18n_set en skills.doctor_drift "⚠️ guide/skills.md is stale — run 'roll skills generate'"
|
|
30
|
+
_i18n_set zh skills.doctor_drift "⚠️ guide/skills.md 已过期 — 请运行 'roll skills generate'"
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render a one-cycle exit summary block for the loop's .command window (US-LOOP-040).
|
|
3
|
+
|
|
4
|
+
When a `roll loop` cycle ends, the macOS `.command` Terminal window that was
|
|
5
|
+
attached to the tmux session is left showing only a `press enter to close`
|
|
6
|
+
prompt. The full Cycle Phase Breakdown / runs.jsonl data already exists on disk
|
|
7
|
+
but the user has to scroll back through tmux scrollback or open
|
|
8
|
+
``~/.shared/roll/loop/cron-<slug>.log`` to see what the cycle did.
|
|
9
|
+
|
|
10
|
+
This helper renders a compact ``─── Cycle <CYCLE_ID> Summary ───`` block to
|
|
11
|
+
stdout, consumed by the ``.command`` shell *before* the `press enter` prompt.
|
|
12
|
+
It is a pure read-side view: it never writes new files and never mutates loop
|
|
13
|
+
state.
|
|
14
|
+
|
|
15
|
+
Five signals (per the US-LOOP-040 issue):
|
|
16
|
+
|
|
17
|
+
1. result — runs.jsonl latest row's ``status`` + ``built[]`` + ``tcr_count``
|
|
18
|
+
(idle cycle → ``idle: no story picked``)
|
|
19
|
+
2. ci — newest ``ci`` event outcome from events.ndjson tail
|
|
20
|
+
(``ok``→green, ``red``→red, ``heal-attempting`` passthrough,
|
|
21
|
+
no event → ``ci: n/a``)
|
|
22
|
+
3. todo — count of ``📋 Todo`` lines in .roll/backlog.md
|
|
23
|
+
4. phases — runs.jsonl ``phases`` map, top 5 by duration desc
|
|
24
|
+
5. alerts — raw failure / alert text placeholder
|
|
25
|
+
|
|
26
|
+
US-LOOP-041 layers failure / alert *highlighting* on top of the US-LOOP-040
|
|
27
|
+
renderer. The relevant signal lines are flagged with a severity prefix and
|
|
28
|
+
ANSI colour:
|
|
29
|
+
|
|
30
|
+
* RED + ``✗`` — runs.jsonl ``status`` is ``failed`` / ``aborted``; the latest
|
|
31
|
+
``ci`` outcome is ``red``; or the events tail has a ``cycle_end`` whose
|
|
32
|
+
outcome is not ``ok`` / ``idle``.
|
|
33
|
+
* YELLOW + ``⚠`` — latest ``ci`` outcome is ``heal-attempting``; an
|
|
34
|
+
``ALERT-<slug>.md`` exists and is non-empty; or ``tcr_count == 0`` while
|
|
35
|
+
``built[]`` is non-empty (suspected zero-diff).
|
|
36
|
+
* default colour, no prefix — a fully green cycle (built/idle + ci green +
|
|
37
|
+
no alert).
|
|
38
|
+
|
|
39
|
+
ANSI escapes are only emitted when stdout is a TTY (``sys.stdout.isatty()``)
|
|
40
|
+
and ``NO_COLOR`` is unset (see https://no-color.org). Pipes, redirects and
|
|
41
|
+
test captures get plain text — no escape codes are written.
|
|
42
|
+
|
|
43
|
+
Data-source priority: runs.jsonl latest matching row > events.ndjson tail >
|
|
44
|
+
fall back to the cron log's last 30 lines. Any missing source degrades
|
|
45
|
+
silently — the renderer never errors and never blocks `press enter`.
|
|
46
|
+
|
|
47
|
+
When no usable data exists at all (idle/aborted early-exit, runs.jsonl not yet
|
|
48
|
+
flushed) it prints a single
|
|
49
|
+
``(summary unavailable — see log: <cron-log>)`` line instead.
|
|
50
|
+
|
|
51
|
+
Invocation::
|
|
52
|
+
|
|
53
|
+
python3 loop-exit-summary.py \
|
|
54
|
+
--runs <runs.jsonl> \
|
|
55
|
+
--events <events.ndjson> \
|
|
56
|
+
--backlog <.roll/backlog.md> \
|
|
57
|
+
--cron-log <cron-<slug>.log> \
|
|
58
|
+
--alert <ALERT-<slug>.md> \
|
|
59
|
+
[--cycle-id <id>] [--color {auto,always,never}]
|
|
60
|
+
|
|
61
|
+
All paths are optional; a missing / unreadable file is treated as absent.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from __future__ import annotations
|
|
65
|
+
|
|
66
|
+
import argparse
|
|
67
|
+
import json
|
|
68
|
+
import os
|
|
69
|
+
import sys
|
|
70
|
+
from typing import Any, Dict, List, Optional
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_last_json_line(path: Optional[str], cycle_id: str = "") -> Optional[Dict[str, Any]]:
|
|
74
|
+
"""Return the last well-formed JSON object from a .jsonl file.
|
|
75
|
+
|
|
76
|
+
When ``cycle_id`` is given, prefer the last row whose ``cycle_id`` matches;
|
|
77
|
+
otherwise fall back to the last parseable row. Returns None when the file
|
|
78
|
+
is absent, empty, or has no parseable rows.
|
|
79
|
+
"""
|
|
80
|
+
if not path or not os.path.isfile(path):
|
|
81
|
+
return None
|
|
82
|
+
last: Optional[Dict[str, Any]] = None
|
|
83
|
+
matched: Optional[Dict[str, Any]] = None
|
|
84
|
+
try:
|
|
85
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
86
|
+
for line in fh:
|
|
87
|
+
line = line.strip()
|
|
88
|
+
if not line:
|
|
89
|
+
continue
|
|
90
|
+
try:
|
|
91
|
+
obj = json.loads(line)
|
|
92
|
+
except (ValueError, TypeError):
|
|
93
|
+
continue
|
|
94
|
+
if not isinstance(obj, dict):
|
|
95
|
+
continue
|
|
96
|
+
last = obj
|
|
97
|
+
if cycle_id and obj.get("cycle_id") == cycle_id:
|
|
98
|
+
matched = obj
|
|
99
|
+
except OSError:
|
|
100
|
+
return None
|
|
101
|
+
return matched if matched is not None else last
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _read_json_lines(path: Optional[str]) -> List[Dict[str, Any]]:
|
|
105
|
+
"""Return all well-formed JSON objects from a .ndjson file (in order)."""
|
|
106
|
+
out: List[Dict[str, Any]] = []
|
|
107
|
+
if not path or not os.path.isfile(path):
|
|
108
|
+
return out
|
|
109
|
+
try:
|
|
110
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
111
|
+
for line in fh:
|
|
112
|
+
line = line.strip()
|
|
113
|
+
if not line:
|
|
114
|
+
continue
|
|
115
|
+
try:
|
|
116
|
+
obj = json.loads(line)
|
|
117
|
+
except (ValueError, TypeError):
|
|
118
|
+
continue
|
|
119
|
+
if isinstance(obj, dict):
|
|
120
|
+
out.append(obj)
|
|
121
|
+
except OSError:
|
|
122
|
+
return out
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _latest_ci_outcome(events: List[Dict[str, Any]]) -> Optional[str]:
|
|
127
|
+
"""Newest ``ci`` event outcome from an events stream, or None."""
|
|
128
|
+
for ev in reversed(events):
|
|
129
|
+
if ev.get("stage") == "ci":
|
|
130
|
+
outcome = ev.get("outcome")
|
|
131
|
+
if outcome:
|
|
132
|
+
return str(outcome)
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _latest_cycle_end_outcome(events: List[Dict[str, Any]]) -> Optional[str]:
|
|
137
|
+
"""Newest ``cycle_end`` event outcome from an events stream, or None."""
|
|
138
|
+
for ev in reversed(events):
|
|
139
|
+
if ev.get("stage") == "cycle_end":
|
|
140
|
+
outcome = ev.get("outcome")
|
|
141
|
+
if outcome:
|
|
142
|
+
return str(outcome)
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _alert_active(path: Optional[str]) -> bool:
|
|
147
|
+
"""True when an ALERT-<slug>.md file exists and has non-whitespace content."""
|
|
148
|
+
if not path or not os.path.isfile(path):
|
|
149
|
+
return False
|
|
150
|
+
try:
|
|
151
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
152
|
+
return bool(fh.read().strip())
|
|
153
|
+
except OSError:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── ANSI colouring (US-LOOP-041) ─────────────────────────────────────────────
|
|
158
|
+
# Severity ranks: 0 = none/green, 1 = warn (yellow), 2 = fail (red).
|
|
159
|
+
_SEV_NONE = 0
|
|
160
|
+
_SEV_WARN = 1
|
|
161
|
+
_SEV_FAIL = 2
|
|
162
|
+
|
|
163
|
+
_ANSI = {_SEV_WARN: "\033[33m", _SEV_FAIL: "\033[31m"}
|
|
164
|
+
_ANSI_RESET = "\033[0m"
|
|
165
|
+
_PREFIX = {_SEV_NONE: "", _SEV_WARN: "⚠ ", _SEV_FAIL: "✗ "}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _color_enabled(mode: str) -> bool:
|
|
169
|
+
"""Decide whether ANSI escapes should be emitted.
|
|
170
|
+
|
|
171
|
+
``always`` forces colour, ``never`` forces plain text, ``auto`` (default)
|
|
172
|
+
honours NO_COLOR (https://no-color.org) and only colours a real TTY.
|
|
173
|
+
"""
|
|
174
|
+
if mode == "always":
|
|
175
|
+
return True
|
|
176
|
+
if mode == "never":
|
|
177
|
+
return False
|
|
178
|
+
if os.environ.get("NO_COLOR") is not None:
|
|
179
|
+
return False
|
|
180
|
+
try:
|
|
181
|
+
return bool(sys.stdout.isatty())
|
|
182
|
+
except (ValueError, AttributeError):
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _decorate(text: str, sev: int, color: bool) -> str:
|
|
187
|
+
"""Apply severity prefix + (optional) ANSI colour to a single line.
|
|
188
|
+
|
|
189
|
+
The leading indentation is preserved; the prefix and colour wrap only the
|
|
190
|
+
non-indented payload so columns still line up.
|
|
191
|
+
"""
|
|
192
|
+
if sev == _SEV_NONE:
|
|
193
|
+
return text
|
|
194
|
+
stripped = text.lstrip(" ")
|
|
195
|
+
indent = text[: len(text) - len(stripped)]
|
|
196
|
+
payload = _PREFIX[sev] + stripped
|
|
197
|
+
if color:
|
|
198
|
+
payload = _ANSI[sev] + payload + _ANSI_RESET
|
|
199
|
+
return indent + payload
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _count_todo(path: Optional[str]) -> Optional[int]:
|
|
203
|
+
"""Count lines bearing the 📋 Todo marker in backlog.md. None if absent."""
|
|
204
|
+
if not path or not os.path.isfile(path):
|
|
205
|
+
return None
|
|
206
|
+
count = 0
|
|
207
|
+
try:
|
|
208
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
209
|
+
for line in fh:
|
|
210
|
+
if "📋" in line and "Todo" in line:
|
|
211
|
+
count += 1
|
|
212
|
+
except OSError:
|
|
213
|
+
return None
|
|
214
|
+
return count
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _tail_lines(path: Optional[str], n: int) -> List[str]:
|
|
218
|
+
"""Last ``n`` non-empty lines of a text file, or [] when absent."""
|
|
219
|
+
if not path or not os.path.isfile(path):
|
|
220
|
+
return []
|
|
221
|
+
try:
|
|
222
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
223
|
+
lines = [ln.rstrip("\n") for ln in fh]
|
|
224
|
+
except OSError:
|
|
225
|
+
return []
|
|
226
|
+
return lines[-n:]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _fmt_ci(outcome: Optional[str]) -> str:
|
|
230
|
+
if outcome is None:
|
|
231
|
+
return "ci: n/a"
|
|
232
|
+
mapping = {"ok": "green", "green": "green", "red": "red",
|
|
233
|
+
"heal-attempting": "heal-attempting"}
|
|
234
|
+
return "ci: " + mapping.get(outcome, outcome)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _fmt_result(row: Dict[str, Any]) -> str:
|
|
238
|
+
status = row.get("status", "")
|
|
239
|
+
built = row.get("built") or []
|
|
240
|
+
tcr = row.get("tcr_count", 0)
|
|
241
|
+
if status == "idle" or (not built and status in ("", "idle")):
|
|
242
|
+
return "idle: no story picked"
|
|
243
|
+
if built:
|
|
244
|
+
built_str = " ".join(str(b) for b in built)
|
|
245
|
+
return "built: {0} · tcr commits: {1}".format(built_str, tcr)
|
|
246
|
+
# non-idle terminal with no built[] (failed/aborted/blocked/orphan)
|
|
247
|
+
return "{0} · tcr commits: {1}".format(status or "unknown", tcr)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _fmt_phases(phases: Dict[str, Any], limit: int = 5) -> List[str]:
|
|
251
|
+
rows: List[tuple] = []
|
|
252
|
+
for name, dur in phases.items():
|
|
253
|
+
try:
|
|
254
|
+
rows.append((int(dur), str(name)))
|
|
255
|
+
except (ValueError, TypeError):
|
|
256
|
+
continue
|
|
257
|
+
rows.sort(key=lambda r: (-r[0], r[1]))
|
|
258
|
+
out = []
|
|
259
|
+
for dur, name in rows[:limit]:
|
|
260
|
+
out.append(" {0:<22} {1:>5}s".format(name, dur))
|
|
261
|
+
return out
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _result_severity(row: Dict[str, Any]) -> int:
|
|
265
|
+
"""Severity for the result line (US-LOOP-041)."""
|
|
266
|
+
status = str(row.get("status", ""))
|
|
267
|
+
if status in ("failed", "aborted"):
|
|
268
|
+
return _SEV_FAIL
|
|
269
|
+
built = row.get("built") or []
|
|
270
|
+
tcr = row.get("tcr_count", 0)
|
|
271
|
+
if built and tcr == 0: # suspected zero-diff: built something but no commit
|
|
272
|
+
return _SEV_WARN
|
|
273
|
+
return _SEV_NONE
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _ci_severity(outcome: Optional[str]) -> int:
|
|
277
|
+
"""Severity for the ci line (US-LOOP-041)."""
|
|
278
|
+
if outcome == "red":
|
|
279
|
+
return _SEV_FAIL
|
|
280
|
+
if outcome == "heal-attempting":
|
|
281
|
+
return _SEV_WARN
|
|
282
|
+
return _SEV_NONE
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def render(runs: Optional[str], events: Optional[str], backlog: Optional[str],
|
|
286
|
+
cron_log: Optional[str], cycle_id: str = "",
|
|
287
|
+
alert: Optional[str] = None, color: bool = False) -> str:
|
|
288
|
+
"""Build the summary block as a string.
|
|
289
|
+
|
|
290
|
+
``color`` toggles ANSI escapes; severity prefixes (``✗`` / ``⚠``) are
|
|
291
|
+
always applied to flagged lines regardless of ``color`` (US-LOOP-041).
|
|
292
|
+
"""
|
|
293
|
+
row = _read_last_json_line(runs, cycle_id)
|
|
294
|
+
ev_list = _read_json_lines(events)
|
|
295
|
+
ci_outcome = _latest_ci_outcome(ev_list)
|
|
296
|
+
cycle_end_outcome = _latest_cycle_end_outcome(ev_list)
|
|
297
|
+
alert_on = _alert_active(alert)
|
|
298
|
+
todo = _count_todo(backlog)
|
|
299
|
+
|
|
300
|
+
# Source priority: a usable runs.jsonl row is the primary feed. With no
|
|
301
|
+
# row AND no events, fall back to the cron log's tail; if even that is
|
|
302
|
+
# empty, emit the single "unavailable" placeholder line.
|
|
303
|
+
have_primary = row is not None
|
|
304
|
+
have_events = bool(ev_list)
|
|
305
|
+
|
|
306
|
+
cid = cycle_id or (row.get("cycle_id") if row else "") or "unknown"
|
|
307
|
+
lines: List[str] = []
|
|
308
|
+
title = "─── Cycle {0} Summary ───".format(cid)
|
|
309
|
+
|
|
310
|
+
if not have_primary and not have_events:
|
|
311
|
+
tail = _tail_lines(cron_log, 30)
|
|
312
|
+
if not tail:
|
|
313
|
+
log_hint = cron_log or "~/.shared/roll/loop/cron-<slug>.log"
|
|
314
|
+
return "(summary unavailable — see log: {0})".format(log_hint)
|
|
315
|
+
# Degraded view: header + raw cron tail so the user still sees output.
|
|
316
|
+
lines.append(title)
|
|
317
|
+
lines.append(" (runs.jsonl + events unavailable — showing cron log tail)")
|
|
318
|
+
for ln in tail:
|
|
319
|
+
lines.append(" " + ln)
|
|
320
|
+
return "\n".join(lines)
|
|
321
|
+
|
|
322
|
+
lines.append(title)
|
|
323
|
+
|
|
324
|
+
# cycle_end fail severity applies to the result line when it's worse than
|
|
325
|
+
# what the runs.jsonl status alone implies.
|
|
326
|
+
cycle_end_sev = _SEV_NONE
|
|
327
|
+
if cycle_end_outcome is not None and cycle_end_outcome not in ("ok", "idle"):
|
|
328
|
+
cycle_end_sev = _SEV_FAIL
|
|
329
|
+
|
|
330
|
+
# 1. result
|
|
331
|
+
if row is not None:
|
|
332
|
+
result_sev = max(_result_severity(row), cycle_end_sev)
|
|
333
|
+
lines.append(_decorate(" " + _fmt_result(row), result_sev, color))
|
|
334
|
+
else:
|
|
335
|
+
lines.append(_decorate(" result: n/a", cycle_end_sev, color))
|
|
336
|
+
|
|
337
|
+
# 2. ci
|
|
338
|
+
lines.append(_decorate(" " + _fmt_ci(ci_outcome),
|
|
339
|
+
_ci_severity(ci_outcome), color))
|
|
340
|
+
|
|
341
|
+
# 3. todo
|
|
342
|
+
if todo is not None:
|
|
343
|
+
lines.append(" todo remaining: {0}".format(todo))
|
|
344
|
+
else:
|
|
345
|
+
lines.append(" todo remaining: n/a")
|
|
346
|
+
|
|
347
|
+
# 4. phases (top 5 by duration desc)
|
|
348
|
+
phases = (row.get("phases") if row else None) or {}
|
|
349
|
+
if isinstance(phases, dict) and phases:
|
|
350
|
+
phase_rows = _fmt_phases(phases)
|
|
351
|
+
if phase_rows:
|
|
352
|
+
lines.append(" phases (top 5 by time):")
|
|
353
|
+
lines.extend(phase_rows)
|
|
354
|
+
|
|
355
|
+
# 5. alerts / failure highlight (US-LOOP-041)
|
|
356
|
+
alerts = (row.get("alerts") if row else None) or []
|
|
357
|
+
if isinstance(alerts, list) and alerts:
|
|
358
|
+
lines.append(_decorate(" alerts:", _SEV_FAIL, color))
|
|
359
|
+
for a in alerts:
|
|
360
|
+
lines.append(_decorate(" " + str(a), _SEV_FAIL, color))
|
|
361
|
+
if alert_on:
|
|
362
|
+
lines.append(_decorate(" alert: ALERT file active — see log",
|
|
363
|
+
_SEV_WARN, color))
|
|
364
|
+
|
|
365
|
+
return "\n".join(lines)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
369
|
+
parser = argparse.ArgumentParser(
|
|
370
|
+
description="Render a loop cycle exit summary block.")
|
|
371
|
+
parser.add_argument("--runs", default=None, help="path to runs.jsonl")
|
|
372
|
+
parser.add_argument("--events", default=None, help="path to events.ndjson")
|
|
373
|
+
parser.add_argument("--backlog", default=None, help="path to .roll/backlog.md")
|
|
374
|
+
parser.add_argument("--cron-log", default=None, help="path to cron-<slug>.log")
|
|
375
|
+
parser.add_argument("--cycle-id", default="", help="cycle id to prefer")
|
|
376
|
+
parser.add_argument("--alert", default=None, help="path to ALERT-<slug>.md")
|
|
377
|
+
parser.add_argument("--color", choices=("auto", "always", "never"),
|
|
378
|
+
default="auto", help="ANSI colour mode (default: auto)")
|
|
379
|
+
args = parser.parse_args(argv)
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
color = _color_enabled(args.color)
|
|
383
|
+
out = render(args.runs, args.events, args.backlog,
|
|
384
|
+
args.cron_log, args.cycle_id,
|
|
385
|
+
alert=args.alert, color=color)
|
|
386
|
+
except Exception: # noqa: BLE001 — silent fallback per AC: never error
|
|
387
|
+
return 0
|
|
388
|
+
sys.stdout.write(out + "\n")
|
|
389
|
+
return 0
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
if __name__ == "__main__":
|
|
393
|
+
sys.exit(main())
|