@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/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).
@@ -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.
@@ -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 "窗口 %02d:00–%02d:00)\n"
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\n"
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\n"
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 "窗口 %02d:00–%02d:00)\n"
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\n"
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\n"
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 = [