@seanyao/roll 2026.528.2 → 2026.529.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 +31 -8
- package/README.md +2 -0
- package/bin/roll +917 -50
- package/lib/README.md +42 -0
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/agent_routes_lint.py +203 -0
- package/lib/i18n/README.md +54 -0
- package/lib/i18n/doctor.sh +13 -0
- package/lib/i18n/loop.sh +12 -12
- package/lib/loop_pick_agent.py +245 -0
- package/lib/prices/README.md +35 -0
- package/lib/roll-help.py +1 -0
- package/lib/roll-loop-status.py +109 -0
- package/lib/test_quality_gate.py +143 -0
- package/package.json +1 -1
- package/skills/roll-brief/SKILL.md +7 -0
- package/skills/roll-build/SKILL.md +95 -0
- package/skills/roll-design/SKILL.md +45 -0
- package/skills/roll-fix/SKILL.md +76 -0
- package/skills/roll-loop/SKILL.md +13 -0
- package/skills/roll-onboard/SKILL.md +6 -0
package/lib/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
> **Draft** — auto-generated by roll-doc on 2026-05-28. Review before treating as authoritative.
|
|
2
|
+
|
|
3
|
+
# lib/ — Python helpers and i18n runtime
|
|
4
|
+
|
|
5
|
+
Python scripts and shell libraries that `bin/roll` delegates to for rendering-heavy or data-processing tasks.
|
|
6
|
+
|
|
7
|
+
## Key files
|
|
8
|
+
|
|
9
|
+
| File | Purpose |
|
|
10
|
+
|------|---------|
|
|
11
|
+
| `roll-loop-status.py` | Renders the `roll loop status` health dashboard — reads cycle event NDJSON, computes per-cycle rows, daily rollups, and phase-tracing breakdown |
|
|
12
|
+
| `roll-loop-story.py` | Per-story rollup: aggregates cycles, tokens, cost, and PR outcomes for `roll loop story <ID>` |
|
|
13
|
+
| `roll-status.py` | Renders the `roll status` one-screen sync health view |
|
|
14
|
+
| `roll-init.py` | Init-flow helpers called by `roll init` |
|
|
15
|
+
| `roll-setup.py` | Setup-flow helpers (convention sync, tool config write) |
|
|
16
|
+
| `roll-brief.py` | Brief generation: reads cycle records and produces the feature brief |
|
|
17
|
+
| `roll-backlog.py` | Backlog read/write helpers |
|
|
18
|
+
| `roll-peer.py` | Peer review coordination helpers |
|
|
19
|
+
| `roll-help.py` | Renders `roll --help` output |
|
|
20
|
+
| `roll-plan-validate.py` | Validates plan files before story execution |
|
|
21
|
+
| `model_prices.py` | List-price table for AI model API pricing (per MTok, native currency) |
|
|
22
|
+
| `prices_fetcher.py` | Fetches fresh price snapshots from vendor APIs |
|
|
23
|
+
| `roll_render.py` | Shared rendering utilities (tables, color, formatting) |
|
|
24
|
+
| `loop-fmt.py` | Loop log formatter (ANSI-strip, timestamp alignment) |
|
|
25
|
+
| `loop_unstick.py` | Diagnostic: detects and unsticks hung loop state |
|
|
26
|
+
| `backfill-pi-usage.py` | Backfills pi/deepseek token and cost data into existing cycle records |
|
|
27
|
+
| `changelog_audit.py` | Audits CHANGELOG.md against backlog entries |
|
|
28
|
+
| `i18n.sh` | Shell wrapper that delegates i18n string lookups to `lib/i18n/` |
|
|
29
|
+
| `slides-render.py` | Renders `.deck.md` → HTML slides |
|
|
30
|
+
| `slides-validate.py` | Validates deck file syntax and asset references |
|
|
31
|
+
|
|
32
|
+
## Sub-directories
|
|
33
|
+
|
|
34
|
+
- `agent_usage/` — token-usage capture and cost attribution per agent invocation
|
|
35
|
+
- `i18n/` — localized string tables for all CLI output (EN + ZH)
|
|
36
|
+
- `prices/` — price snapshot JSON files (per-vendor, dated)
|
|
37
|
+
- `slides/` — slide component library for `roll deck`
|
|
38
|
+
|
|
39
|
+
## Dependencies
|
|
40
|
+
|
|
41
|
+
Imported by `bin/roll` via subprocess calls (`python3 lib/<script>.py`).
|
|
42
|
+
No third-party pip dependencies — standard library only (json, sys, os, re, datetime).
|
|
Binary file
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lint .roll/agent-routes.yaml against schema v1 (US-AGENT-002).
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
agent_routes_lint.py <path>
|
|
6
|
+
|
|
7
|
+
Exit 0 when valid, exit 1 with line-numbered errors on stderr otherwise.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import yaml
|
|
16
|
+
except ImportError:
|
|
17
|
+
print("agent-routes lint: PyYAML not installed", file=sys.stderr)
|
|
18
|
+
sys.exit(2)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
VALID_TYPES = {"FIX", "US", "REFACTOR"}
|
|
22
|
+
VALID_RISK = {"low", "medium", "high"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LintError:
|
|
26
|
+
__slots__ = ("line", "msg")
|
|
27
|
+
|
|
28
|
+
def __init__(self, line: int, msg: str) -> None:
|
|
29
|
+
self.line = line
|
|
30
|
+
self.msg = msg
|
|
31
|
+
|
|
32
|
+
def __str__(self) -> str:
|
|
33
|
+
# Format: "line N: <message>" — match test regex `line[[:space:]]+[0-9]+`
|
|
34
|
+
if self.line > 0:
|
|
35
|
+
return f"line {self.line}: {self.msg}"
|
|
36
|
+
return self.msg
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _node_line(node) -> int:
|
|
40
|
+
"""Return 1-based line number of a ruamel node, or 0 if unavailable."""
|
|
41
|
+
mark = getattr(node, "start_mark", None)
|
|
42
|
+
if mark is None:
|
|
43
|
+
return 0
|
|
44
|
+
return mark.line + 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _scan(path: Path) -> list[LintError]:
|
|
48
|
+
"""Load and validate the YAML file. Returns a list of LintError."""
|
|
49
|
+
errs: list[LintError] = []
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
text = path.read_text()
|
|
53
|
+
except FileNotFoundError:
|
|
54
|
+
return [LintError(0, f"file not found: {path}")]
|
|
55
|
+
|
|
56
|
+
# Use safe loader with composer to get line info per top-level key.
|
|
57
|
+
try:
|
|
58
|
+
# Parse with composer to retain line marks.
|
|
59
|
+
loader = yaml.SafeLoader(text)
|
|
60
|
+
try:
|
|
61
|
+
node = loader.get_single_node()
|
|
62
|
+
finally:
|
|
63
|
+
loader.dispose()
|
|
64
|
+
except yaml.YAMLError as exc:
|
|
65
|
+
line = getattr(getattr(exc, "problem_mark", None), "line", -1)
|
|
66
|
+
return [LintError(line + 1 if line >= 0 else 0, f"YAML parse error: {exc}")]
|
|
67
|
+
|
|
68
|
+
if node is None:
|
|
69
|
+
return [LintError(0, "empty YAML document")]
|
|
70
|
+
|
|
71
|
+
if not isinstance(node, yaml.MappingNode):
|
|
72
|
+
return [LintError(_node_line(node), "top-level must be a mapping")]
|
|
73
|
+
|
|
74
|
+
# Walk top-level fields to capture line numbers.
|
|
75
|
+
top: dict[str, tuple[int, yaml.Node]] = {}
|
|
76
|
+
for key_node, value_node in node.value:
|
|
77
|
+
if isinstance(key_node, yaml.ScalarNode):
|
|
78
|
+
top[key_node.value] = (_node_line(key_node), value_node)
|
|
79
|
+
|
|
80
|
+
# --- schema field ---
|
|
81
|
+
if "schema" not in top:
|
|
82
|
+
errs.append(LintError(1, "missing required field `schema`"))
|
|
83
|
+
else:
|
|
84
|
+
schema_line, schema_val = top["schema"]
|
|
85
|
+
if not (isinstance(schema_val, yaml.ScalarNode) and schema_val.value == "v1"):
|
|
86
|
+
errs.append(LintError(schema_line, "field `schema` must be `v1`"))
|
|
87
|
+
|
|
88
|
+
# --- agents field ---
|
|
89
|
+
if "agents" not in top:
|
|
90
|
+
errs.append(LintError(1, "missing required field `agents`"))
|
|
91
|
+
else:
|
|
92
|
+
agents_line, agents_val = top["agents"]
|
|
93
|
+
if not isinstance(agents_val, yaml.MappingNode):
|
|
94
|
+
errs.append(LintError(agents_line, "field `agents` must be a mapping"))
|
|
95
|
+
else:
|
|
96
|
+
for agent_key, agent_val in agents_val.value:
|
|
97
|
+
if not isinstance(agent_key, yaml.ScalarNode):
|
|
98
|
+
continue
|
|
99
|
+
name = agent_key.value
|
|
100
|
+
name_line = _node_line(agent_key)
|
|
101
|
+
_validate_agent(name, name_line, agent_val, errs)
|
|
102
|
+
|
|
103
|
+
# --- history (optional) ---
|
|
104
|
+
if "history" in top:
|
|
105
|
+
hist_line, hist_val = top["history"]
|
|
106
|
+
if not isinstance(hist_val, yaml.MappingNode):
|
|
107
|
+
errs.append(LintError(hist_line, "field `history` must be a mapping"))
|
|
108
|
+
else:
|
|
109
|
+
_validate_history(hist_val, errs)
|
|
110
|
+
|
|
111
|
+
return errs
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _validate_agent(name: str, name_line: int, node: yaml.Node, errs: list[LintError]) -> None:
|
|
115
|
+
if not isinstance(node, yaml.MappingNode):
|
|
116
|
+
errs.append(LintError(name_line, f"agent `{name}` must be a mapping"))
|
|
117
|
+
return
|
|
118
|
+
fields: dict[str, tuple[int, yaml.Node]] = {}
|
|
119
|
+
for k, v in node.value:
|
|
120
|
+
if isinstance(k, yaml.ScalarNode):
|
|
121
|
+
fields[k.value] = (_node_line(k), v)
|
|
122
|
+
|
|
123
|
+
# types
|
|
124
|
+
if "types" not in fields:
|
|
125
|
+
errs.append(LintError(name_line, f"agent `{name}` missing `types`"))
|
|
126
|
+
else:
|
|
127
|
+
tl, tv = fields["types"]
|
|
128
|
+
if not isinstance(tv, yaml.SequenceNode):
|
|
129
|
+
errs.append(LintError(tl, f"agent `{name}`.types must be a list"))
|
|
130
|
+
else:
|
|
131
|
+
for item in tv.value:
|
|
132
|
+
if isinstance(item, yaml.ScalarNode) and item.value not in VALID_TYPES:
|
|
133
|
+
errs.append(LintError(_node_line(item), f"agent `{name}`.types: invalid value `{item.value}` (expect one of FIX/US/REFACTOR)"))
|
|
134
|
+
|
|
135
|
+
# est_min
|
|
136
|
+
if "est_min" not in fields:
|
|
137
|
+
errs.append(LintError(name_line, f"agent `{name}` missing `est_min`"))
|
|
138
|
+
else:
|
|
139
|
+
el, ev = fields["est_min"]
|
|
140
|
+
if not isinstance(ev, yaml.MappingNode):
|
|
141
|
+
errs.append(LintError(el, f"agent `{name}`.est_min must be a mapping {{min, max}}"))
|
|
142
|
+
else:
|
|
143
|
+
est_fields = {k.value: v for k, v in ev.value if isinstance(k, yaml.ScalarNode)}
|
|
144
|
+
if "min" not in est_fields or "max" not in est_fields:
|
|
145
|
+
errs.append(LintError(el, f"agent `{name}`.est_min requires both `min` and `max`"))
|
|
146
|
+
|
|
147
|
+
# risk
|
|
148
|
+
if "risk" not in fields:
|
|
149
|
+
errs.append(LintError(name_line, f"agent `{name}` missing `risk`"))
|
|
150
|
+
else:
|
|
151
|
+
rl, rv = fields["risk"]
|
|
152
|
+
if not isinstance(rv, yaml.SequenceNode):
|
|
153
|
+
errs.append(LintError(rl, f"agent `{name}`.risk must be a list"))
|
|
154
|
+
else:
|
|
155
|
+
for item in rv.value:
|
|
156
|
+
if isinstance(item, yaml.ScalarNode) and item.value not in VALID_RISK:
|
|
157
|
+
errs.append(LintError(_node_line(item), f"agent `{name}`.risk: invalid value `{item.value}` (expect low/medium/high)"))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _validate_history(node: yaml.MappingNode, errs: list[LintError]) -> None:
|
|
161
|
+
fields = {k.value: (_node_line(k), v) for k, v in node.value if isinstance(k, yaml.ScalarNode)}
|
|
162
|
+
|
|
163
|
+
if "window_cycles" in fields:
|
|
164
|
+
wl, wv = fields["window_cycles"]
|
|
165
|
+
if isinstance(wv, yaml.ScalarNode):
|
|
166
|
+
try:
|
|
167
|
+
n = int(wv.value)
|
|
168
|
+
if n < 0:
|
|
169
|
+
errs.append(LintError(wl, "history.window_cycles must be >= 0 (0 disables history)"))
|
|
170
|
+
except ValueError:
|
|
171
|
+
errs.append(LintError(wl, "history.window_cycles must be an integer"))
|
|
172
|
+
|
|
173
|
+
if "prefer_threshold" in fields:
|
|
174
|
+
pl, pv = fields["prefer_threshold"]
|
|
175
|
+
if isinstance(pv, yaml.ScalarNode):
|
|
176
|
+
try:
|
|
177
|
+
f = float(pv.value)
|
|
178
|
+
if not (0.0 <= f <= 1.0):
|
|
179
|
+
errs.append(LintError(pl, f"history.prefer_threshold must be in [0.0, 1.0], got {f}"))
|
|
180
|
+
except ValueError:
|
|
181
|
+
errs.append(LintError(pl, "history.prefer_threshold must be a number"))
|
|
182
|
+
|
|
183
|
+
if "cold_start_default" in fields:
|
|
184
|
+
cl, cv = fields["cold_start_default"]
|
|
185
|
+
if not isinstance(cv, yaml.ScalarNode):
|
|
186
|
+
errs.append(LintError(cl, "history.cold_start_default must be a string"))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def main() -> int:
|
|
190
|
+
if len(sys.argv) != 2:
|
|
191
|
+
print("usage: agent_routes_lint.py <path>", file=sys.stderr)
|
|
192
|
+
return 2
|
|
193
|
+
path = Path(sys.argv[1])
|
|
194
|
+
errors = _scan(path)
|
|
195
|
+
if not errors:
|
|
196
|
+
return 0
|
|
197
|
+
for err in errors:
|
|
198
|
+
print(str(err), file=sys.stderr)
|
|
199
|
+
return 1
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
sys.exit(main())
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
> **Draft** — auto-generated by roll-doc on 2026-05-28. Review before treating as authoritative.
|
|
2
|
+
|
|
3
|
+
# lib/i18n/ — Localized string tables
|
|
4
|
+
|
|
5
|
+
All user-visible CLI output strings for both `en` and `zh` locales, organized by command domain.
|
|
6
|
+
|
|
7
|
+
## Structure
|
|
8
|
+
|
|
9
|
+
Each `.sh` file under `lib/i18n/` is a shell associative-array fragment exporting a `MSG_*` namespace:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
lib/i18n/
|
|
13
|
+
├── agent.sh # roll agent use / install messages
|
|
14
|
+
├── alert.sh # ALERT lifecycle messages
|
|
15
|
+
├── backlog.sh # backlog read/write output
|
|
16
|
+
├── brief.sh # roll-brief generation output
|
|
17
|
+
├── changelog.sh # changelog sync messages
|
|
18
|
+
├── ci.sh # CI self-heal messages
|
|
19
|
+
├── debug.sh # roll debug diagnostics
|
|
20
|
+
├── doctor.sh # roll-doctor check output
|
|
21
|
+
├── dream.sh # roll-.dream scan output
|
|
22
|
+
├── init.sh # roll init setup messages
|
|
23
|
+
├── lang.sh # locale detection + ROLL_LANG resolution
|
|
24
|
+
├── loop.sh # roll loop subcommand output (largest file)
|
|
25
|
+
├── migrate.sh # roll migrate messages
|
|
26
|
+
├── offboard.sh # roll offboard output
|
|
27
|
+
├── onboard.sh # roll onboard / legacy-onboard output
|
|
28
|
+
├── peer.sh # roll peer review messages
|
|
29
|
+
├── peer_help.sh # peer --help text
|
|
30
|
+
├── peer_reset.sh # peer reset confirmation messages
|
|
31
|
+
├── peer_status.sh # peer status output
|
|
32
|
+
├── prices_refresh.sh # prices refresh output
|
|
33
|
+
└── skills/ # per-skill i18n overrides
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Locale selection
|
|
37
|
+
|
|
38
|
+
`ROLL_LANG` env var controls which locale is active. Resolved by `lang.sh`:
|
|
39
|
+
|
|
40
|
+
1. `ROLL_LANG` explicit → use it
|
|
41
|
+
2. `LC_ALL` / `LANG` contains `zh` → `zh`
|
|
42
|
+
3. Default → `en`
|
|
43
|
+
|
|
44
|
+
Each `.sh` file branches on `ROLL_LANG` and exports the appropriate string set.
|
|
45
|
+
|
|
46
|
+
## skills/
|
|
47
|
+
|
|
48
|
+
Per-skill message overrides for `roll-build`, `roll-design`, `roll-fix`, `roll-loop`, `roll-onboard`. Same structure as top-level files — sourced after the base file to allow skill-specific overrides without editing shared strings.
|
|
49
|
+
|
|
50
|
+
## Adding a new string
|
|
51
|
+
|
|
52
|
+
1. Add the key to both `en` and `zh` branches in the appropriate domain file.
|
|
53
|
+
2. Reference via `msg <KEY>` in `bin/roll` or the relevant skill.
|
|
54
|
+
3. Never hardcode user-facing strings in `bin/roll` directly — always go through i18n.
|
package/lib/i18n/doctor.sh
CHANGED
|
@@ -29,3 +29,16 @@ _i18n_set en doctor.pr_event_without_zh "contributors get AI feedback on PR open
|
|
|
29
29
|
_i18n_set zh doctor.pr_event_without_zh "PR 一开即触发 AI 评审。"
|
|
30
30
|
_i18n_set en doctor.pr_event_secret "Then set the API key secret for your configured agent in GitHub repo settings."
|
|
31
31
|
_i18n_set zh doctor.pr_event_secret "然后在 GitHub 仓库设置中添加你配置的 agent 对应的 API key secret。"
|
|
32
|
+
|
|
33
|
+
_i18n_set en doctor.agent_detection "Agent detection"
|
|
34
|
+
_i18n_set zh doctor.agent_detection "Agent 检测"
|
|
35
|
+
_i18n_set en doctor.agent_installed "CLI on PATH"
|
|
36
|
+
_i18n_set zh doctor.agent_installed "CLI 可用"
|
|
37
|
+
_i18n_set en doctor.agent_missing "CLI not found"
|
|
38
|
+
_i18n_set zh doctor.agent_missing "CLI 未安装"
|
|
39
|
+
_i18n_set en doctor.agent_dir_exists "config dir exists"
|
|
40
|
+
_i18n_set zh doctor.agent_dir_exists "配置目录存在"
|
|
41
|
+
_i18n_set en doctor.agent_dir_missing "config dir missing"
|
|
42
|
+
_i18n_set zh doctor.agent_dir_missing "配置目录不存在"
|
|
43
|
+
_i18n_set en doctor.agent_primary_label "primary"
|
|
44
|
+
_i18n_set zh doctor.agent_primary_label "默认"
|
package/lib/i18n/loop.sh
CHANGED
|
@@ -3,22 +3,22 @@ _i18n_set en loop.loop_already_enabled_for_this_project "Loop already enabled fo
|
|
|
3
3
|
_i18n_set zh loop.loop_already_enabled_for_this_project "当前项目 loop 已启用"
|
|
4
4
|
_i18n_set en loop.loop_enabled "Loop enabled"
|
|
5
5
|
_i18n_set zh loop.loop_enabled "已启用"
|
|
6
|
-
_i18n_set en loop.roll_loop_s_active_02d_00 " • roll-loop %s active %02d:00–%02d:00 %s
|
|
7
|
-
_i18n_set zh loop.roll_loop_s_active_02d_00 "
|
|
8
|
-
_i18n_set en loop.roll_dream_daily_at_02d_02d " • roll-.dream daily at %02d:%02d"
|
|
9
|
-
_i18n_set zh loop.roll_dream_daily_at_02d_02d "每天 %02d:%02d
|
|
10
|
-
_i18n_set en loop.roll_brief_daily_at_02d_02d " • roll-brief daily at %02d:%02d"
|
|
11
|
-
_i18n_set zh loop.roll_brief_daily_at_02d_02d "每天 %02d:%02d
|
|
6
|
+
_i18n_set en loop.roll_loop_s_active_02d_00 " • roll-loop %s active %02d:00–%02d:00 %s(窗口 %02d:00–%02d:00)"
|
|
7
|
+
_i18n_set zh loop.roll_loop_s_active_02d_00 " • roll-loop %s 有效窗口 %02d:00–%02d:00 %s(active %02d:00–%02d:00)"
|
|
8
|
+
_i18n_set en loop.roll_dream_daily_at_02d_02d " • roll-.dream daily at %02d:%02d 每天 %02d:%02d"
|
|
9
|
+
_i18n_set zh loop.roll_dream_daily_at_02d_02d " • roll-.dream daily at %02d:%02d 每天 %02d:%02d"
|
|
10
|
+
_i18n_set en loop.roll_brief_daily_at_02d_02d " • roll-brief daily at %02d:%02d 每天 %02d:%02d"
|
|
11
|
+
_i18n_set zh loop.roll_brief_daily_at_02d_02d " • roll-brief daily at %02d:%02d 每天 %02d:%02d"
|
|
12
12
|
_i18n_set en loop.loop_already_enabled_for_this_project_2 "Loop already enabled for this project"
|
|
13
13
|
_i18n_set zh loop.loop_already_enabled_for_this_project_2 "当前项目 loop 已启用"
|
|
14
14
|
_i18n_set en loop.loop_enabled_2 "Loop enabled"
|
|
15
15
|
_i18n_set zh loop.loop_enabled_2 "已启用"
|
|
16
|
-
_i18n_set en loop.roll_loop_s_active_02d_00_2 " • roll-loop %s active %02d:00–%02d:00 %s
|
|
17
|
-
_i18n_set zh loop.roll_loop_s_active_02d_00_2 "
|
|
18
|
-
_i18n_set en loop.roll_dream_daily_at_02d_02d_2 " • roll-.dream daily at %02d:%02d"
|
|
19
|
-
_i18n_set zh loop.roll_dream_daily_at_02d_02d_2 "每天 %02d:%02d
|
|
20
|
-
_i18n_set en loop.roll_brief_daily_at_02d_02d_2 " • roll-brief daily at %02d:%02d"
|
|
21
|
-
_i18n_set zh loop.roll_brief_daily_at_02d_02d_2 "每天 %02d:%02d
|
|
16
|
+
_i18n_set en loop.roll_loop_s_active_02d_00_2 " • roll-loop %s active %02d:00–%02d:00 %s(窗口 %02d:00–%02d:00)"
|
|
17
|
+
_i18n_set zh loop.roll_loop_s_active_02d_00_2 " • roll-loop %s 有效窗口 %02d:00–%02d:00 %s(active %02d:00–%02d:00)"
|
|
18
|
+
_i18n_set en loop.roll_dream_daily_at_02d_02d_2 " • roll-.dream daily at %02d:%02d 每天 %02d:%02d"
|
|
19
|
+
_i18n_set zh loop.roll_dream_daily_at_02d_02d_2 " • roll-.dream daily at %02d:%02d 每天 %02d:%02d"
|
|
20
|
+
_i18n_set en loop.roll_brief_daily_at_02d_02d_2 " • roll-brief daily at %02d:%02d 每天 %02d:%02d"
|
|
21
|
+
_i18n_set zh loop.roll_brief_daily_at_02d_02d_2 " • roll-brief daily at %02d:%02d 每天 %02d:%02d"
|
|
22
22
|
_i18n_set en loop.loop_not_enabled_for_this_project "Loop not enabled for this project"
|
|
23
23
|
_i18n_set zh loop.loop_not_enabled_for_this_project "当前项目 loop 未启用"
|
|
24
24
|
_i18n_set en loop.loop_disabled "Loop disabled"
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pick a routing agent for a backlog story (US-AGENT-004).
|
|
3
|
+
|
|
4
|
+
Reads story metadata from the feature markdown (linked from the BACKLOG row)
|
|
5
|
+
and matches it against agent-routes.yaml hard rules. Emits a single line on
|
|
6
|
+
stdout:
|
|
7
|
+
|
|
8
|
+
<agent> <rule_kind> <rationale>
|
|
9
|
+
|
|
10
|
+
Exit codes:
|
|
11
|
+
0 — agent picked (rule_kind in {hard, default})
|
|
12
|
+
1 — story id not found / unrecoverable error
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
loop_pick_agent.py --story-id US-AGENT-004 \\
|
|
16
|
+
--backlog .roll/backlog.md \\
|
|
17
|
+
--routes .roll/agent-routes.yaml
|
|
18
|
+
|
|
19
|
+
History-driven soft preference (US-AGENT-005) lands on top of this in a
|
|
20
|
+
later commit; the present module only implements hard-rule selection.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import yaml
|
|
32
|
+
except ImportError:
|
|
33
|
+
print("loop_pick_agent: PyYAML not installed", file=sys.stderr)
|
|
34
|
+
sys.exit(2)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
PROFILE_BLOCK_RE = re.compile(r"\*\*Agent profile:\*\*")
|
|
38
|
+
EST_RE = re.compile(r"^\s*-\s*est_min:\s*(\d+)")
|
|
39
|
+
RISK_RE = re.compile(r"^\s*-\s*risk_zone:\s*([a-zA-Z]+)")
|
|
40
|
+
CHAIN_RE = re.compile(r"^\s*-\s*chain_depth:\s*(\d+)")
|
|
41
|
+
ANCHOR_TEMPLATE = '<a id="{anchor}"></a>'
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _id_to_anchor(story_id: str) -> str:
|
|
45
|
+
return story_id.lower()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _find_feature_md(backlog_path: Path, story_id: str) -> Path | None:
|
|
49
|
+
"""Resolve feature md path by scanning backlog rows for the story id."""
|
|
50
|
+
if not backlog_path.exists():
|
|
51
|
+
return None
|
|
52
|
+
link_re = re.compile(
|
|
53
|
+
r"\[" + re.escape(story_id) + r"\]\((\.roll/features/[^)]+?)#",
|
|
54
|
+
re.IGNORECASE,
|
|
55
|
+
)
|
|
56
|
+
for line in backlog_path.read_text().splitlines():
|
|
57
|
+
m = link_re.search(line)
|
|
58
|
+
if m:
|
|
59
|
+
return Path(m.group(1))
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read_profile(feature_md: Path, story_id: str) -> dict | None:
|
|
64
|
+
"""Return {est_min, risk_zone, chain_depth} or None if not found."""
|
|
65
|
+
if not feature_md.exists():
|
|
66
|
+
return None
|
|
67
|
+
anchor = ANCHOR_TEMPLATE.format(anchor=_id_to_anchor(story_id))
|
|
68
|
+
text = feature_md.read_text()
|
|
69
|
+
if anchor not in text:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
# Slice from the anchor to the next anchor or EOF.
|
|
73
|
+
start = text.index(anchor)
|
|
74
|
+
next_anchor_match = re.search(r'<a id="[^"]+"></a>', text[start + len(anchor):])
|
|
75
|
+
end = start + len(anchor) + (next_anchor_match.start() if next_anchor_match else len(text))
|
|
76
|
+
section = text[start:end]
|
|
77
|
+
|
|
78
|
+
if not PROFILE_BLOCK_RE.search(section):
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
profile: dict[str, object] = {}
|
|
82
|
+
for line in section.splitlines():
|
|
83
|
+
m = EST_RE.match(line)
|
|
84
|
+
if m:
|
|
85
|
+
profile["est_min"] = int(m.group(1))
|
|
86
|
+
continue
|
|
87
|
+
m = RISK_RE.match(line)
|
|
88
|
+
if m:
|
|
89
|
+
profile["risk_zone"] = m.group(1).lower()
|
|
90
|
+
continue
|
|
91
|
+
m = CHAIN_RE.match(line)
|
|
92
|
+
if m:
|
|
93
|
+
profile["chain_depth"] = int(m.group(1))
|
|
94
|
+
if "est_min" not in profile or "risk_zone" not in profile:
|
|
95
|
+
return None
|
|
96
|
+
profile.setdefault("chain_depth", 0)
|
|
97
|
+
return profile
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _story_type(story_id: str) -> str:
|
|
101
|
+
# Story id prefix → routing type. US-AGENT-004 → "US", FIX-* → "FIX",
|
|
102
|
+
# REFACTOR-* → "REFACTOR". Default falls through to "US".
|
|
103
|
+
prefix = story_id.split("-", 1)[0].upper()
|
|
104
|
+
return prefix if prefix in {"FIX", "US", "REFACTOR"} else "US"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _agent_matches(agent_cfg: dict, story_type: str, est_min: int, risk_zone: str) -> bool:
|
|
108
|
+
types = agent_cfg.get("types") or []
|
|
109
|
+
if story_type not in types:
|
|
110
|
+
return False
|
|
111
|
+
est_range = agent_cfg.get("est_min") or {}
|
|
112
|
+
lo = est_range.get("min")
|
|
113
|
+
hi = est_range.get("max")
|
|
114
|
+
if lo is not None and est_min < lo:
|
|
115
|
+
return False
|
|
116
|
+
if hi is not None and est_min > hi:
|
|
117
|
+
return False
|
|
118
|
+
risk_list = agent_cfg.get("risk") or []
|
|
119
|
+
if risk_zone not in risk_list:
|
|
120
|
+
return False
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _hit_rates(runs_path: Path, story_type: str, window: int) -> dict[str, tuple[int, int]]:
|
|
125
|
+
"""Return {agent: (built_count, total_count)} for the requested story type
|
|
126
|
+
over the last `window` runs.jsonl records that targeted that type. Records
|
|
127
|
+
must carry `agent` and `story_type` (forward-looking schema, US-AGENT-005).
|
|
128
|
+
Older records lacking these fields are skipped silently.
|
|
129
|
+
"""
|
|
130
|
+
rates: dict[str, list[int]] = {}
|
|
131
|
+
if window <= 0 or not runs_path.exists():
|
|
132
|
+
return {}
|
|
133
|
+
# Read all then take last N matching story_type.
|
|
134
|
+
matching: list[dict] = []
|
|
135
|
+
for line in runs_path.read_text().splitlines():
|
|
136
|
+
line = line.strip()
|
|
137
|
+
if not line:
|
|
138
|
+
continue
|
|
139
|
+
try:
|
|
140
|
+
rec = json.loads(line)
|
|
141
|
+
except ValueError:
|
|
142
|
+
continue
|
|
143
|
+
if rec.get("story_type") != story_type:
|
|
144
|
+
continue
|
|
145
|
+
if "agent" not in rec:
|
|
146
|
+
continue
|
|
147
|
+
matching.append(rec)
|
|
148
|
+
for rec in matching[-window:]:
|
|
149
|
+
agent = rec["agent"]
|
|
150
|
+
slot = rates.setdefault(agent, [0, 0])
|
|
151
|
+
slot[1] += 1
|
|
152
|
+
if rec.get("status") == "built":
|
|
153
|
+
slot[0] += 1
|
|
154
|
+
return {a: (b, t) for a, (b, t) in rates.items()}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def pick(story_id: str, backlog_path: Path, routes_path: Path,
|
|
158
|
+
runs_path: Path | None = None) -> tuple[str, str, str] | None:
|
|
159
|
+
"""Return (agent, rule_kind, rationale) or None on hard error."""
|
|
160
|
+
if not routes_path.exists():
|
|
161
|
+
return None
|
|
162
|
+
routes = yaml.safe_load(routes_path.read_text()) or {}
|
|
163
|
+
agents = routes.get("agents") or {}
|
|
164
|
+
history = routes.get("history") or {}
|
|
165
|
+
cold = history.get("cold_start_default") or next(iter(agents), None)
|
|
166
|
+
window = int(history.get("window_cycles", 0) or 0)
|
|
167
|
+
threshold = float(history.get("prefer_threshold", 0.0) or 0.0)
|
|
168
|
+
|
|
169
|
+
feature_md = _find_feature_md(backlog_path, story_id)
|
|
170
|
+
if feature_md is None:
|
|
171
|
+
return None # story id not in backlog
|
|
172
|
+
|
|
173
|
+
profile = _read_profile(feature_md, story_id)
|
|
174
|
+
if profile is None:
|
|
175
|
+
if cold is None:
|
|
176
|
+
return None
|
|
177
|
+
return (cold, "default", f"no profile for {story_id}; fell back to cold_start_default")
|
|
178
|
+
|
|
179
|
+
story_type = _story_type(story_id)
|
|
180
|
+
est_min = profile["est_min"]
|
|
181
|
+
risk_zone = profile["risk_zone"]
|
|
182
|
+
|
|
183
|
+
# Hard-rule candidate set in declaration order.
|
|
184
|
+
matched: list[str] = []
|
|
185
|
+
for name, cfg in agents.items():
|
|
186
|
+
if _agent_matches(cfg or {}, story_type, est_min, risk_zone):
|
|
187
|
+
matched.append(name)
|
|
188
|
+
|
|
189
|
+
if not matched:
|
|
190
|
+
if cold is None:
|
|
191
|
+
return None
|
|
192
|
+
return (cold, "default", f"no agent matched {story_type}/{est_min}/{risk_zone}; cold_start_default")
|
|
193
|
+
|
|
194
|
+
# Single match → no soft pref needed.
|
|
195
|
+
if len(matched) == 1 or runs_path is None or window <= 0:
|
|
196
|
+
chosen = matched[0]
|
|
197
|
+
rationale = f"hard: type={story_type} est={est_min} risk={risk_zone} matched {chosen}"
|
|
198
|
+
return (chosen, "hard", rationale)
|
|
199
|
+
|
|
200
|
+
# Multiple matches → consider history soft preference.
|
|
201
|
+
rates = _hit_rates(runs_path, story_type, window)
|
|
202
|
+
# Filter rates to candidates only, require sample ≥ 5 and rate ≥ threshold.
|
|
203
|
+
eligible = []
|
|
204
|
+
for cand in matched:
|
|
205
|
+
built, total = rates.get(cand, (0, 0))
|
|
206
|
+
if total >= 5:
|
|
207
|
+
rate = built / total if total else 0.0
|
|
208
|
+
if rate >= threshold:
|
|
209
|
+
eligible.append((rate, cand))
|
|
210
|
+
if eligible:
|
|
211
|
+
eligible.sort(reverse=True) # highest rate first
|
|
212
|
+
rate, chosen = eligible[0]
|
|
213
|
+
rationale = (
|
|
214
|
+
f"soft: type={story_type} est={est_min} risk={risk_zone} "
|
|
215
|
+
f"history_rate={rate:.2f} (threshold={threshold}) matched {chosen}"
|
|
216
|
+
)
|
|
217
|
+
return (chosen, "soft", rationale)
|
|
218
|
+
|
|
219
|
+
# Fallback to hard-rule first.
|
|
220
|
+
chosen = matched[0]
|
|
221
|
+
rationale = f"hard: type={story_type} est={est_min} risk={risk_zone} matched {chosen} (no eligible history)"
|
|
222
|
+
return (chosen, "hard", rationale)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def main() -> int:
|
|
226
|
+
parser = argparse.ArgumentParser()
|
|
227
|
+
parser.add_argument("--story-id", required=True)
|
|
228
|
+
parser.add_argument("--backlog", default=".roll/backlog.md")
|
|
229
|
+
parser.add_argument("--routes", default=".roll/agent-routes.yaml")
|
|
230
|
+
parser.add_argument("--runs", default=None,
|
|
231
|
+
help="runs.jsonl path for history soft preference (US-AGENT-005)")
|
|
232
|
+
args = parser.parse_args()
|
|
233
|
+
|
|
234
|
+
runs = Path(args.runs) if args.runs else None
|
|
235
|
+
result = pick(args.story_id, Path(args.backlog), Path(args.routes), runs)
|
|
236
|
+
if result is None:
|
|
237
|
+
print(f"loop_pick_agent: cannot route {args.story_id}", file=sys.stderr)
|
|
238
|
+
return 1
|
|
239
|
+
agent, rule_kind, rationale = result
|
|
240
|
+
print(f"{agent} {rule_kind} {rationale}")
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == "__main__":
|
|
245
|
+
sys.exit(main())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
> **Draft** — auto-generated by roll-doc on 2026-05-28. Review before treating as authoritative.
|
|
2
|
+
|
|
3
|
+
# lib/prices/ — Model price snapshots
|
|
4
|
+
|
|
5
|
+
Dated JSON snapshots of AI model list prices, used by `roll loop status` to compute per-cycle cost in both USD and native currency (CNY for pi/DeepSeek/Kimi).
|
|
6
|
+
|
|
7
|
+
## Files
|
|
8
|
+
|
|
9
|
+
| File | Contents |
|
|
10
|
+
|------|---------|
|
|
11
|
+
| `snapshot-2026-05-22.json` | Multi-vendor snapshot (Claude, GPT, Gemini, DeepSeek, Kimi, pi) |
|
|
12
|
+
| `snapshot-2026-05-23-deepseek.json` | DeepSeek-specific refresh |
|
|
13
|
+
| `snapshot-2026-05-23-kimi.json` | Kimi-specific refresh |
|
|
14
|
+
|
|
15
|
+
## Format
|
|
16
|
+
|
|
17
|
+
Each snapshot is a JSON object keyed by model ID:
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"claude-opus-4-7": {
|
|
22
|
+
"input_per_mtok": 15.0,
|
|
23
|
+
"output_per_mtok": 75.0,
|
|
24
|
+
"cache_write_per_mtok": 18.75,
|
|
25
|
+
"cache_read_per_mtok": 1.5,
|
|
26
|
+
"currency": "USD"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
CNY-priced models (pi, DeepSeek, Kimi) use `"currency": "CNY"`.
|
|
32
|
+
|
|
33
|
+
## Refresh
|
|
34
|
+
|
|
35
|
+
`prices_fetcher.py` fetches fresh snapshots from vendor pricing APIs and writes a new dated file here. Run via `roll prices refresh`.
|
package/lib/roll-help.py
CHANGED
|
@@ -42,6 +42,7 @@ AUTONOMY = [
|
|
|
42
42
|
("backlog", "[block|defer|…]", "view and manage pending tasks", "查看和管理待处理任务", True),
|
|
43
43
|
("peer", "", "cross-agent negotiation & review", "跨 Agent 协商对审", False),
|
|
44
44
|
("alert", "", "view and clear loop alerts", "查看 / 清除 loop 告警", False),
|
|
45
|
+
("feedback", "--type bug|idea|ux …", "open a GitHub issue for this project", "为本项目提交反馈", False),
|
|
45
46
|
]
|
|
46
47
|
|
|
47
48
|
PROJECT = [
|