@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/loop_pick_agent.py
CHANGED
|
@@ -1,46 +1,189 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""Classify a backlog story into a complexity tier (US-AGENT-022).
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
and
|
|
6
|
-
|
|
4
|
+
Supersedes the three-dimensional (type/est_min/risk_zone) hard-rule matcher
|
|
5
|
+
and the history-driven soft preference (US-AGENT-004/005). Routing now turns
|
|
6
|
+
on a single axis: the story's ``est_min`` estimate maps to one of three
|
|
7
|
+
complexity tiers — ``easy`` / ``default`` / ``hard``. The tier → agent
|
|
8
|
+
resolution (reading ``agents.yaml`` slots, fallback) lands in US-AGENT-023;
|
|
9
|
+
this module is the pure classifier.
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
Emits a single line on stdout::
|
|
12
|
+
|
|
13
|
+
<tier> <rationale>
|
|
14
|
+
|
|
15
|
+
where ``tier`` is one of ``easy`` / ``default`` / ``hard``.
|
|
16
|
+
|
|
17
|
+
Tier boundaries (centralised constants, intentionally NOT user-configurable
|
|
18
|
+
to keep routing variance to a single axis):
|
|
19
|
+
|
|
20
|
+
est_min <= 8 → easy
|
|
21
|
+
8 < est_min <= 20 → default
|
|
22
|
+
est_min > 20 → hard
|
|
23
|
+
missing / illegal est → default
|
|
9
24
|
|
|
10
25
|
Exit codes:
|
|
11
|
-
0 —
|
|
12
|
-
1 — story id not found / unrecoverable error
|
|
26
|
+
0 — tier classified (always succeeds once the story is found)
|
|
27
|
+
1 — story id not found in backlog / unrecoverable error
|
|
13
28
|
|
|
14
29
|
Usage:
|
|
15
|
-
loop_pick_agent.py --story-id US-AGENT-
|
|
16
|
-
|
|
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.
|
|
30
|
+
loop_pick_agent.py --story-id US-AGENT-022 --backlog .roll/backlog.md
|
|
31
|
+
loop_pick_agent.py --est-min 12 # classify a bare estimate
|
|
21
32
|
"""
|
|
22
33
|
from __future__ import annotations
|
|
23
34
|
|
|
24
35
|
import argparse
|
|
25
|
-
import json
|
|
26
36
|
import re
|
|
27
37
|
import sys
|
|
28
38
|
from pathlib import Path
|
|
29
39
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
# Complexity-tier boundaries. Single source of truth — change here only.
|
|
41
|
+
EASY_MAX_MIN = 8 # est_min <= 8 → easy
|
|
42
|
+
HARD_MIN_MIN = 20 # est_min > 20 → hard
|
|
43
|
+
TIER_EASY = "easy"
|
|
44
|
+
TIER_DEFAULT = "default"
|
|
45
|
+
TIER_HARD = "hard"
|
|
36
46
|
|
|
37
47
|
PROFILE_BLOCK_RE = re.compile(r"\*\*Agent profile:\*\*")
|
|
38
48
|
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
49
|
ANCHOR_TEMPLATE = '<a id="{anchor}"></a>'
|
|
42
50
|
|
|
43
51
|
|
|
52
|
+
def _classify_complexity(est_min) -> str:
|
|
53
|
+
"""Map an ``est_min`` estimate onto a complexity tier.
|
|
54
|
+
|
|
55
|
+
``<= 8`` → easy, ``> 20`` → hard, ``8 < x <= 20`` → default. A missing or
|
|
56
|
+
non-integer estimate (None, "", non-numeric) falls back to ``default``.
|
|
57
|
+
"""
|
|
58
|
+
if est_min is None:
|
|
59
|
+
return TIER_DEFAULT
|
|
60
|
+
try:
|
|
61
|
+
n = int(est_min)
|
|
62
|
+
except (TypeError, ValueError):
|
|
63
|
+
return TIER_DEFAULT
|
|
64
|
+
if n < 0:
|
|
65
|
+
# Negative estimate is invalid data → treat like missing.
|
|
66
|
+
return TIER_DEFAULT
|
|
67
|
+
if n <= EASY_MAX_MIN:
|
|
68
|
+
return TIER_EASY
|
|
69
|
+
if n > HARD_MIN_MIN:
|
|
70
|
+
return TIER_HARD
|
|
71
|
+
return TIER_DEFAULT
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
# US-AGENT-030: transparent, auditable in-tier soft nudge.
|
|
76
|
+
#
|
|
77
|
+
# The complexity tier (easy/default/hard from ``_classify_complexity``) is a
|
|
78
|
+
# HARD constraint — it decides which agents.yaml slot is consulted and a task is
|
|
79
|
+
# NEVER moved out of its tier. On top of that hard floor this adds a SOFT
|
|
80
|
+
# priority: among the candidate agents already associated with this tier, prefer
|
|
81
|
+
# the one with the best per-(agent × story_type) historical hit-rate.
|
|
82
|
+
#
|
|
83
|
+
# How this differs from the US-AGENT-022-retired soft preference (the whole
|
|
84
|
+
# point of the story):
|
|
85
|
+
# - deterministic: same history in → same agent out. No rng, no time seed,
|
|
86
|
+
# no decay clock. ``nudge_within_tier`` is a pure function of its arguments.
|
|
87
|
+
# - auditable: every decision returns a human-readable rationale string that
|
|
88
|
+
# the caller logs into runs.jsonl + the event log.
|
|
89
|
+
# - sample floor: a (agent, story_type) combo below ``sample_floor`` does not
|
|
90
|
+
# participate; the slot agent is kept and the audit line says so.
|
|
91
|
+
# - one switch: ``enabled=False`` makes this an exact identity — it returns
|
|
92
|
+
# the slot agent unchanged, behaving precisely like US-AGENT-023.
|
|
93
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
# Default minimum samples a (agent × story_type) combo needs before its hit-rate
|
|
96
|
+
# is allowed to influence routing. Below this the combo is statistically
|
|
97
|
+
# meaningless, so we keep the operator's slot choice. Centralised constant.
|
|
98
|
+
SAMPLE_FLOOR = 8
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def nudge_within_tier(slot_agent, candidates, story_type, hit_rates,
|
|
102
|
+
sample_floor=SAMPLE_FLOOR, enabled=True):
|
|
103
|
+
"""Reorder same-tier candidates by historical hit-rate; return the winner.
|
|
104
|
+
|
|
105
|
+
Pure function — no I/O, no randomness, no clock. Given the same arguments
|
|
106
|
+
it always returns the same ``(chosen_agent, rationale)`` pair.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
slot_agent: the agent the est_min tier slot resolved to (the hard-floor
|
|
110
|
+
default). Always the fallback / tie-break winner.
|
|
111
|
+
candidates: iterable of in-tier candidate agent names (already
|
|
112
|
+
constrained to this tier + installed by the caller). The
|
|
113
|
+
slot agent is folded in even if absent.
|
|
114
|
+
story_type: the story's type bucket (e.g. "US" / "FIX"); the hit-rate is
|
|
115
|
+
looked up per (agent, story_type).
|
|
116
|
+
hit_rates: {"<agent>\\x1f<story_type>": {"hit_rate": float,
|
|
117
|
+
"sample_n": int}} (the loop_result_eval read model).
|
|
118
|
+
sample_floor: combos with sample_n < this are ignored (default 8).
|
|
119
|
+
enabled: when False, returns (slot_agent, "<reason: disabled>") with
|
|
120
|
+
no reordering — exact US-AGENT-023 behaviour.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
(chosen_agent, rationale) where rationale is a one-line audit string.
|
|
124
|
+
"""
|
|
125
|
+
if not slot_agent:
|
|
126
|
+
return (slot_agent, "no slot agent; nudge skipped")
|
|
127
|
+
if not enabled:
|
|
128
|
+
return (slot_agent, "nudge disabled; keeping est_min slot %s" % slot_agent)
|
|
129
|
+
|
|
130
|
+
# Build the candidate set: the slot agent is always in the running, plus any
|
|
131
|
+
# caller-supplied in-tier candidates. De-dup but keep a deterministic order
|
|
132
|
+
# (slot agent first, then the rest sorted) so iteration is reproducible.
|
|
133
|
+
seen = {slot_agent}
|
|
134
|
+
rest = []
|
|
135
|
+
for c in (candidates or []):
|
|
136
|
+
if c and c not in seen:
|
|
137
|
+
seen.add(c)
|
|
138
|
+
rest.append(c)
|
|
139
|
+
ordered = [slot_agent] + sorted(rest)
|
|
140
|
+
|
|
141
|
+
def _stat(agent):
|
|
142
|
+
key = "%s\x1f%s" % (agent, story_type)
|
|
143
|
+
st = (hit_rates or {}).get(key) or {}
|
|
144
|
+
try:
|
|
145
|
+
n = int(st.get("sample_n", 0))
|
|
146
|
+
except (TypeError, ValueError):
|
|
147
|
+
n = 0
|
|
148
|
+
try:
|
|
149
|
+
hr = float(st.get("hit_rate", 0.0))
|
|
150
|
+
except (TypeError, ValueError):
|
|
151
|
+
hr = 0.0
|
|
152
|
+
return hr, n
|
|
153
|
+
|
|
154
|
+
# Eligible = combos that clear the sample floor.
|
|
155
|
+
eligible = []
|
|
156
|
+
for a in ordered:
|
|
157
|
+
hr, n = _stat(a)
|
|
158
|
+
if n >= sample_floor:
|
|
159
|
+
eligible.append((a, hr, n))
|
|
160
|
+
|
|
161
|
+
if not eligible:
|
|
162
|
+
return (slot_agent,
|
|
163
|
+
"n<%d for all %s candidates in this tier; keeping slot %s"
|
|
164
|
+
% (sample_floor, story_type, slot_agent))
|
|
165
|
+
|
|
166
|
+
# Best hit-rate wins. Deterministic tie-break: the slot agent first (it is
|
|
167
|
+
# always index 0 in ``ordered``), then the earliest candidate in the stable
|
|
168
|
+
# order. Sort by (-hit_rate, ordered_index) so ties never depend on dict
|
|
169
|
+
# iteration or locale.
|
|
170
|
+
index_of = {}
|
|
171
|
+
for i, a in enumerate(ordered):
|
|
172
|
+
index_of[a] = i
|
|
173
|
+
eligible.sort(key=lambda t: (-t[1], index_of[t[0]]))
|
|
174
|
+
best_agent, best_hr, best_n = eligible[0]
|
|
175
|
+
|
|
176
|
+
slot_hr, slot_n = _stat(slot_agent)
|
|
177
|
+
if best_agent == slot_agent:
|
|
178
|
+
return (slot_agent,
|
|
179
|
+
"%s best for %s in-tier (hit_rate %.2f, n=%d); slot kept"
|
|
180
|
+
% (slot_agent, story_type, best_hr, best_n))
|
|
181
|
+
return (best_agent,
|
|
182
|
+
"%s in-tier hit_rate %.2f (n=%d) > slot %s %.2f (n=%d) for %s -> prefer %s"
|
|
183
|
+
% (best_agent, best_hr, best_n, slot_agent, slot_hr, slot_n,
|
|
184
|
+
story_type, best_agent))
|
|
185
|
+
|
|
186
|
+
|
|
44
187
|
def _id_to_anchor(story_id: str) -> str:
|
|
45
188
|
return story_id.lower()
|
|
46
189
|
|
|
@@ -53,19 +196,19 @@ def _find_feature_md(backlog_path: Path, story_id: str) -> Path | None:
|
|
|
53
196
|
r"\[" + re.escape(story_id) + r"\]\((\.roll/features/[^)]+?)#",
|
|
54
197
|
re.IGNORECASE,
|
|
55
198
|
)
|
|
56
|
-
for line in backlog_path.read_text().splitlines():
|
|
199
|
+
for line in backlog_path.read_text(encoding="utf-8").splitlines():
|
|
57
200
|
m = link_re.search(line)
|
|
58
201
|
if m:
|
|
59
202
|
return Path(m.group(1))
|
|
60
203
|
return None
|
|
61
204
|
|
|
62
205
|
|
|
63
|
-
def
|
|
64
|
-
"""Return
|
|
206
|
+
def _read_est_min(feature_md: Path, story_id: str):
|
|
207
|
+
"""Return the story's est_min as an int, or None if not found."""
|
|
65
208
|
if not feature_md.exists():
|
|
66
209
|
return None
|
|
67
210
|
anchor = ANCHOR_TEMPLATE.format(anchor=_id_to_anchor(story_id))
|
|
68
|
-
text = feature_md.read_text()
|
|
211
|
+
text = feature_md.read_text(encoding="utf-8")
|
|
69
212
|
if anchor not in text:
|
|
70
213
|
return None
|
|
71
214
|
|
|
@@ -78,166 +221,94 @@ def _read_profile(feature_md: Path, story_id: str) -> dict | None:
|
|
|
78
221
|
if not PROFILE_BLOCK_RE.search(section):
|
|
79
222
|
return None
|
|
80
223
|
|
|
81
|
-
profile: dict[str, object] = {}
|
|
82
224
|
for line in section.splitlines():
|
|
83
225
|
m = EST_RE.match(line)
|
|
84
226
|
if m:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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)
|
|
227
|
+
return int(m.group(1))
|
|
228
|
+
return None
|
|
229
|
+
|
|
168
230
|
|
|
231
|
+
def classify_story(story_id: str, backlog_path: Path) -> tuple[str, str] | None:
|
|
232
|
+
"""Return (tier, rationale) for a backlog story, or None on hard error."""
|
|
169
233
|
feature_md = _find_feature_md(backlog_path, story_id)
|
|
170
234
|
if feature_md is None:
|
|
171
235
|
return None # story id not in backlog
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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)
|
|
236
|
+
est_min = _read_est_min(feature_md, story_id)
|
|
237
|
+
tier = _classify_complexity(est_min)
|
|
238
|
+
if est_min is None:
|
|
239
|
+
rationale = f"no est_min for {story_id}; tier={tier} (default)"
|
|
240
|
+
else:
|
|
241
|
+
rationale = f"est_min={est_min} → tier={tier}"
|
|
242
|
+
return (tier, rationale)
|
|
223
243
|
|
|
224
244
|
|
|
225
245
|
def main() -> int:
|
|
226
246
|
parser = argparse.ArgumentParser()
|
|
227
|
-
parser.add_argument("--story-id"
|
|
247
|
+
parser.add_argument("--story-id")
|
|
228
248
|
parser.add_argument("--backlog", default=".roll/backlog.md")
|
|
229
|
-
parser.add_argument("--
|
|
230
|
-
|
|
231
|
-
|
|
249
|
+
parser.add_argument("--est-min", default=None,
|
|
250
|
+
help="classify a bare estimate without a backlog lookup")
|
|
251
|
+
# Accepted for backward-compatible invocation; routing no longer reads
|
|
252
|
+
# agent-routes.yaml or runs.jsonl (US-AGENT-022 retires the 3-dim matcher
|
|
253
|
+
# and history soft preference). Tier→agent resolution is US-AGENT-023.
|
|
254
|
+
parser.add_argument("--routes", default=None, help=argparse.SUPPRESS)
|
|
255
|
+
parser.add_argument("--runs", default=None, help=argparse.SUPPRESS)
|
|
256
|
+
# US-AGENT-030: in-tier soft nudge. When --nudge is given, the other --nudge-*
|
|
257
|
+
# args drive nudge_within_tier and the chosen agent + rationale are printed as
|
|
258
|
+
# "<agent>\t<rationale>" (tab-separated so the rationale can carry spaces).
|
|
259
|
+
parser.add_argument("--nudge", action="store_true",
|
|
260
|
+
help="reorder in-tier candidates by historical hit-rate")
|
|
261
|
+
parser.add_argument("--slot-agent", default=None,
|
|
262
|
+
help="the est_min tier slot agent (nudge hard-floor default)")
|
|
263
|
+
parser.add_argument("--story-type", default="",
|
|
264
|
+
help="story type bucket for the hit-rate lookup (US/FIX/...)")
|
|
265
|
+
parser.add_argument("--candidates", default="",
|
|
266
|
+
help="comma-separated in-tier candidate agent names")
|
|
267
|
+
parser.add_argument("--hit-rates", default=None,
|
|
268
|
+
help="hit-rate read model JSON (from loop_result_eval --hit-rates); "
|
|
269
|
+
"reads stdin if omitted")
|
|
270
|
+
parser.add_argument("--sample-floor", type=int, default=SAMPLE_FLOOR,
|
|
271
|
+
help="min sample_n a combo needs to influence routing")
|
|
272
|
+
parser.add_argument("--disabled", action="store_true",
|
|
273
|
+
help="run the identity path (exact US-AGENT-023 behaviour)")
|
|
232
274
|
args = parser.parse_args()
|
|
233
275
|
|
|
234
|
-
|
|
235
|
-
|
|
276
|
+
if args.nudge:
|
|
277
|
+
if not args.slot_agent:
|
|
278
|
+
print("loop_pick_agent: --slot-agent required with --nudge", file=sys.stderr)
|
|
279
|
+
return 1
|
|
280
|
+
import json
|
|
281
|
+
raw = args.hit_rates
|
|
282
|
+
if raw is None:
|
|
283
|
+
raw = sys.stdin.read()
|
|
284
|
+
try:
|
|
285
|
+
hit_rates = json.loads(raw) if raw and raw.strip() else {}
|
|
286
|
+
except (ValueError, TypeError) as exc:
|
|
287
|
+
print(f"loop_pick_agent: bad hit-rates JSON: {exc}", file=sys.stderr)
|
|
288
|
+
return 1
|
|
289
|
+
candidates = [c.strip() for c in args.candidates.split(",") if c.strip()]
|
|
290
|
+
chosen, rationale = nudge_within_tier(
|
|
291
|
+
args.slot_agent, candidates, args.story_type, hit_rates,
|
|
292
|
+
sample_floor=args.sample_floor, enabled=not args.disabled)
|
|
293
|
+
# Tab-separated: field 1 = chosen agent, field 2 = audit rationale.
|
|
294
|
+
print(f"{chosen}\t{rationale}")
|
|
295
|
+
return 0
|
|
296
|
+
|
|
297
|
+
if args.est_min is not None:
|
|
298
|
+
tier = _classify_complexity(args.est_min)
|
|
299
|
+
print(f"{tier} est_min={args.est_min} → tier={tier}")
|
|
300
|
+
return 0
|
|
301
|
+
|
|
302
|
+
if not args.story_id:
|
|
303
|
+
print("loop_pick_agent: --story-id or --est-min required", file=sys.stderr)
|
|
304
|
+
return 1
|
|
305
|
+
|
|
306
|
+
result = classify_story(args.story_id, Path(args.backlog))
|
|
236
307
|
if result is None:
|
|
237
|
-
print(f"loop_pick_agent: cannot
|
|
308
|
+
print(f"loop_pick_agent: cannot classify {args.story_id}", file=sys.stderr)
|
|
238
309
|
return 1
|
|
239
|
-
|
|
240
|
-
print(f"{
|
|
310
|
+
tier, rationale = result
|
|
311
|
+
print(f"{tier} {rationale}")
|
|
241
312
|
return 0
|
|
242
313
|
|
|
243
314
|
|