@seanyao/roll 2026.529.1 → 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 +10 -0
- package/README.md +1 -0
- package/bin/roll +679 -4
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/agent_routes_lint.py +203 -0
- package/lib/loop_pick_agent.py +245 -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
|
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,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())
|
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 = [
|
package/lib/roll-loop-status.py
CHANGED
|
@@ -706,6 +706,97 @@ def rollup_for_story(cycles: List[Dict[str, Any]], story_id: str) -> Dict[str, A
|
|
|
706
706
|
r["model"] = cy["model"]
|
|
707
707
|
return r
|
|
708
708
|
|
|
709
|
+
|
|
710
|
+
# US-SKILL-014: aggregate the last N self-score notes for the dashboard.
|
|
711
|
+
# Reads .roll/notes/*.md (frontmatter format from US-SKILL-010), returns
|
|
712
|
+
# "self-score: mean 7.8 / min 4 / redo 2 (last 14)"
|
|
713
|
+
# or "" when no notes / "self-score: (n/a) — N sample(s), need 3 (last N)"
|
|
714
|
+
# when sample is too small.
|
|
715
|
+
def _self_score_summary_line(notes_dir = None, window: int = 14) -> str:
|
|
716
|
+
notes_dir = notes_dir if notes_dir is not None else Path(".roll/notes")
|
|
717
|
+
if not notes_dir.exists():
|
|
718
|
+
return ""
|
|
719
|
+
files = sorted(notes_dir.glob("*.md"))[-window:]
|
|
720
|
+
if not files:
|
|
721
|
+
return ""
|
|
722
|
+
total = 0
|
|
723
|
+
count = 0
|
|
724
|
+
minv = 11
|
|
725
|
+
redo = 0
|
|
726
|
+
for f in files:
|
|
727
|
+
score = None
|
|
728
|
+
verdict = None
|
|
729
|
+
for line in f.read_text(errors="ignore").splitlines():
|
|
730
|
+
if line.startswith("score: "):
|
|
731
|
+
try:
|
|
732
|
+
score = int(line.split(": ", 1)[1].strip())
|
|
733
|
+
except ValueError:
|
|
734
|
+
score = None
|
|
735
|
+
elif line.startswith("verdict: "):
|
|
736
|
+
verdict = line.split(": ", 1)[1].strip()
|
|
737
|
+
if score is not None and verdict is not None:
|
|
738
|
+
break
|
|
739
|
+
if score is None:
|
|
740
|
+
continue
|
|
741
|
+
count += 1
|
|
742
|
+
total += score
|
|
743
|
+
if score < minv:
|
|
744
|
+
minv = score
|
|
745
|
+
if verdict == "regression":
|
|
746
|
+
redo += 1
|
|
747
|
+
elif verdict == "ok" and score < 6:
|
|
748
|
+
redo += 1
|
|
749
|
+
if count < 3:
|
|
750
|
+
return f"self-score: (n/a) — {count} sample(s), need 3 (last {window})"
|
|
751
|
+
mean = total / count
|
|
752
|
+
return f"self-score: mean {mean:.1f} / min {minv} / redo {redo} (last {window})"
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
# US-AGENT-010: per-agent hit-rate summary for the ROLLUP block.
|
|
756
|
+
# Aggregates the last `window_cycles` runs.jsonl records grouped by `agent`.
|
|
757
|
+
# Returns a single-line string like
|
|
758
|
+
# "agents: pi 8/22 (36%) · deepseek 5/8 (63%) · claude 2/2 (n/a)"
|
|
759
|
+
# Empty agents / missing agent field are skipped. Sample < min_sample renders
|
|
760
|
+
# as "(n/a)" instead of a percentage to avoid noise from tiny windows.
|
|
761
|
+
def _agent_summary_line(records: List[Dict[str, Any]], window_cycles: int = 50,
|
|
762
|
+
min_sample: int = 5) -> str:
|
|
763
|
+
if not records or window_cycles <= 0:
|
|
764
|
+
return ""
|
|
765
|
+
# Take the most recent `window_cycles` records that have an agent field.
|
|
766
|
+
tail: List[Dict[str, Any]] = []
|
|
767
|
+
for rec in records[-window_cycles:]:
|
|
768
|
+
agent = (rec or {}).get("agent") or ""
|
|
769
|
+
if not agent:
|
|
770
|
+
continue
|
|
771
|
+
tail.append(rec)
|
|
772
|
+
if not tail:
|
|
773
|
+
return ""
|
|
774
|
+
counts: Dict[str, List[int]] = {}
|
|
775
|
+
# preserve first-seen order for stable output
|
|
776
|
+
order: List[str] = []
|
|
777
|
+
for rec in tail:
|
|
778
|
+
agent = rec.get("agent") or ""
|
|
779
|
+
if not agent:
|
|
780
|
+
continue
|
|
781
|
+
if agent not in counts:
|
|
782
|
+
counts[agent] = [0, 0]
|
|
783
|
+
order.append(agent)
|
|
784
|
+
counts[agent][1] += 1
|
|
785
|
+
if rec.get("status") == "built":
|
|
786
|
+
counts[agent][0] += 1
|
|
787
|
+
if not order:
|
|
788
|
+
return ""
|
|
789
|
+
parts: List[str] = []
|
|
790
|
+
for agent in order:
|
|
791
|
+
built, total = counts[agent]
|
|
792
|
+
if total < min_sample:
|
|
793
|
+
parts.append(f"{agent} {built}/{total} (n/a)")
|
|
794
|
+
else:
|
|
795
|
+
pct = round(100 * built / total) if total else 0
|
|
796
|
+
parts.append(f"{agent} {built}/{total} ({pct}%)")
|
|
797
|
+
return "agents: " + " · ".join(parts)
|
|
798
|
+
|
|
799
|
+
|
|
709
800
|
def rollup_for_day(day_cycles: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
710
801
|
# US-VIEW-012: track input + output separately so the daily summary can
|
|
711
802
|
# show two metric rows. cache_read tokens deliberately excluded — they're
|
|
@@ -930,6 +1021,24 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
|
|
|
930
1021
|
d2["cost_by_cur"].get(_cur, 0.0),
|
|
931
1022
|
partial=is_partial, symbol=_sym)
|
|
932
1023
|
|
|
1024
|
+
# US-AGENT-010: per-agent hit-rate summary (single line).
|
|
1025
|
+
try:
|
|
1026
|
+
runs_records = list(runs.values()) if isinstance(runs, dict) else list(runs or [])
|
|
1027
|
+
runs_records.sort(key=lambda r: (r or {}).get("ts", ""))
|
|
1028
|
+
_agent_line = _agent_summary_line(runs_records, window_cycles=50)
|
|
1029
|
+
except Exception:
|
|
1030
|
+
_agent_line = ""
|
|
1031
|
+
if _agent_line:
|
|
1032
|
+
print(" " + c("dim", _agent_line))
|
|
1033
|
+
|
|
1034
|
+
# US-SKILL-014: per-skill self-score trend (single line) under the agent line.
|
|
1035
|
+
try:
|
|
1036
|
+
_skill_line = _self_score_summary_line()
|
|
1037
|
+
except Exception:
|
|
1038
|
+
_skill_line = ""
|
|
1039
|
+
if _skill_line:
|
|
1040
|
+
print(" " + c("dim", _skill_line))
|
|
1041
|
+
|
|
933
1042
|
print()
|
|
934
1043
|
print(c("faint", "─" * COLS))
|
|
935
1044
|
print()
|