@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +57 -25
  2. package/README.md +10 -7
  3. package/bin/roll +3952 -317
  4. package/conventions/config.yaml +7 -0
  5. package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
  6. package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
  7. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  8. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  9. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  10. package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
  11. package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
  12. package/lib/agent_usage/__init__.py +4 -0
  13. package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
  14. package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
  15. package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
  16. package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
  17. package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
  18. package/lib/agent_usage/gemini.py +127 -0
  19. package/lib/agent_usage/kimi.py +127 -0
  20. package/lib/agent_usage/openai.py +126 -0
  21. package/lib/agent_usage/qwen.py +128 -0
  22. package/lib/context_feed_budget.sh +194 -0
  23. package/lib/github_sync.py +876 -0
  24. package/lib/i18n/agent.sh +54 -0
  25. package/lib/i18n/init.sh +22 -0
  26. package/lib/i18n/peer.sh +7 -0
  27. package/lib/i18n/peer_help.sh +4 -0
  28. package/lib/i18n/skills_catalog.sh +30 -0
  29. package/lib/loop-exit-summary.py +393 -0
  30. package/lib/loop-fmt.py +93 -75
  31. package/lib/loop_pick_agent.py +241 -170
  32. package/lib/loop_result_eval.py +469 -0
  33. package/lib/model_prices.py +0 -10
  34. package/lib/roll-home.py +1 -28
  35. package/lib/roll-loop-status.py +330 -40
  36. package/lib/roll-onboard-render.py +378 -0
  37. package/lib/roll-peer.py +1 -1
  38. package/lib/roll-plan-validate.py +165 -0
  39. package/lib/roll_git.py +41 -0
  40. package/lib/slides/components/README.md +8 -2
  41. package/lib/slides/templates/introduction-v3.html +1 -6
  42. package/lib/slides-render.py +305 -15
  43. package/lib/slides-validate.py +195 -7
  44. package/package.json +1 -1
  45. package/skills/roll-.changelog/SKILL.md +67 -56
  46. package/skills/roll-brief/SKILL.md +1 -1
  47. package/skills/roll-build/SKILL.md +14 -12
  48. package/skills/roll-deck/SKILL.md +152 -0
  49. package/skills/roll-design/SKILL.md +13 -6
  50. package/skills/roll-doc/SKILL.md +269 -6
  51. package/skills/roll-fix/SKILL.md +15 -9
  52. package/skills/roll-loop/SKILL.md +9 -7
  53. package/skills/roll-notes/SKILL.md +1 -1
  54. package/skills/roll-onboard/SKILL.md +85 -0
  55. package/skills/roll-peer/SKILL.md +6 -5
  56. package/lib/agent_routes_lint.py +0 -203
  57. package/skills/roll-research/SKILL.md +0 -316
  58. package/skills/roll-research/references/schema.json +0 -166
  59. package/skills/roll-research/scripts/md_to_pdf.py +0 -289
@@ -1,46 +1,189 @@
1
1
  #!/usr/bin/env python3
2
- """Pick a routing agent for a backlog story (US-AGENT-004).
2
+ """Classify a backlog story into a complexity tier (US-AGENT-022).
3
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:
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
- <agent> <rule_kind> <rationale>
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 — agent picked (rule_kind in {hard, default})
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-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.
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
- try:
31
- import yaml
32
- except ImportError:
33
- print("loop_pick_agent: PyYAML not installed", file=sys.stderr)
34
- sys.exit(2)
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 _read_profile(feature_md: Path, story_id: str) -> dict | None:
64
- """Return {est_min, risk_zone, chain_depth} or None if not found."""
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
- 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)
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
- 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)
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", required=True)
247
+ parser.add_argument("--story-id")
228
248
  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)")
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
- runs = Path(args.runs) if args.runs else None
235
- result = pick(args.story_id, Path(args.backlog), Path(args.routes), runs)
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 route {args.story_id}", file=sys.stderr)
308
+ print(f"loop_pick_agent: cannot classify {args.story_id}", file=sys.stderr)
238
309
  return 1
239
- agent, rule_kind, rationale = result
240
- print(f"{agent} {rule_kind} {rationale}")
310
+ tier, rationale = result
311
+ print(f"{tier} {rationale}")
241
312
  return 0
242
313
 
243
314