@seanyao/roll 0.5.0 → 2.602.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 (181) hide show
  1. package/CHANGELOG.md +736 -0
  2. package/LICENSE +21 -0
  3. package/README.md +65 -165
  4. package/bin/dream-test-quality-scan +110 -0
  5. package/bin/roll +15030 -814
  6. package/conventions/config.yaml +17 -1
  7. package/conventions/global/AGENTS.md +146 -100
  8. package/conventions/global/CLAUDE.md +1 -21
  9. package/conventions/global/GEMINI.md +8 -22
  10. package/conventions/global/project_rules.md +9 -0
  11. package/conventions/templates/backend-service/AGENTS.md +30 -81
  12. package/conventions/templates/backend-service/GEMINI.md +3 -3
  13. package/conventions/templates/backend-service/project_rules.md +16 -0
  14. package/conventions/templates/cli/AGENTS.md +31 -58
  15. package/conventions/templates/cli/CLAUDE.md +3 -5
  16. package/conventions/templates/cli/GEMINI.md +3 -3
  17. package/conventions/templates/cli/project_rules.md +16 -0
  18. package/conventions/templates/frontend-only/AGENTS.md +29 -64
  19. package/conventions/templates/frontend-only/GEMINI.md +3 -3
  20. package/conventions/templates/frontend-only/project_rules.md +14 -0
  21. package/conventions/templates/fullstack/AGENTS.md +31 -79
  22. package/conventions/templates/fullstack/CLAUDE.md +1 -1
  23. package/conventions/templates/fullstack/GEMINI.md +3 -3
  24. package/conventions/templates/fullstack/project_rules.md +15 -0
  25. package/lib/README.md +42 -0
  26. package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
  27. package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
  28. package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
  29. package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
  30. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  31. package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
  32. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  33. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  34. package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
  35. package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
  36. package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
  37. package/lib/agent_usage/README.md +49 -0
  38. package/lib/agent_usage/__init__.py +108 -0
  39. package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
  40. package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
  41. package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
  42. package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
  43. package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
  44. package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
  45. package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
  46. package/lib/agent_usage/gemini.py +127 -0
  47. package/lib/agent_usage/kimi.py +278 -0
  48. package/lib/agent_usage/kimi_emit.py +123 -0
  49. package/lib/agent_usage/openai.py +126 -0
  50. package/lib/agent_usage/pi.py +200 -0
  51. package/lib/agent_usage/pi_emit.py +135 -0
  52. package/lib/agent_usage/qwen.py +128 -0
  53. package/lib/backfill-pi-usage.py +243 -0
  54. package/lib/changelog_audit.py +155 -0
  55. package/lib/changelog_generate.py +263 -0
  56. package/lib/context_feed_budget.sh +194 -0
  57. package/lib/github_sync.py +876 -0
  58. package/lib/i18n/README.md +54 -0
  59. package/lib/i18n/agent.sh +75 -0
  60. package/lib/i18n/alert.sh +20 -0
  61. package/lib/i18n/backlog.sh +96 -0
  62. package/lib/i18n/brief.sh +5 -0
  63. package/lib/i18n/changelog.sh +5 -0
  64. package/lib/i18n/ci.sh +15 -0
  65. package/lib/i18n/debug.sh +0 -0
  66. package/lib/i18n/doctor.sh +44 -0
  67. package/lib/i18n/dream.sh +0 -0
  68. package/lib/i18n/init.sh +91 -0
  69. package/lib/i18n/lang.sh +10 -0
  70. package/lib/i18n/loop.sh +140 -0
  71. package/lib/i18n/migrate.sh +74 -0
  72. package/lib/i18n/offboard.sh +31 -0
  73. package/lib/i18n/onboard.sh +0 -0
  74. package/lib/i18n/peer.sh +41 -0
  75. package/lib/i18n/peer_help.sh +25 -0
  76. package/lib/i18n/peer_reset.sh +7 -0
  77. package/lib/i18n/peer_status.sh +5 -0
  78. package/lib/i18n/prices.sh +3 -0
  79. package/lib/i18n/prices_refresh.sh +17 -0
  80. package/lib/i18n/prices_show.sh +7 -0
  81. package/lib/i18n/propose.sh +0 -0
  82. package/lib/i18n/release.sh +0 -0
  83. package/lib/i18n/research.sh +0 -0
  84. package/lib/i18n/review_pr.sh +0 -0
  85. package/lib/i18n/sentinel.sh +0 -0
  86. package/lib/i18n/setup.sh +3 -0
  87. package/lib/i18n/shared.sh +157 -0
  88. package/lib/i18n/skills/roll-brief.sh +47 -0
  89. package/lib/i18n/skills/roll-build.sh +97 -0
  90. package/lib/i18n/skills/roll-design.sh +18 -0
  91. package/lib/i18n/skills/roll-fix.sh +53 -0
  92. package/lib/i18n/skills/roll-loop.sh +28 -0
  93. package/lib/i18n/skills/roll-onboard.sh +33 -0
  94. package/lib/i18n/skills_catalog.sh +30 -0
  95. package/lib/i18n/slides.sh +3 -0
  96. package/lib/i18n/slides_build.sh +38 -0
  97. package/lib/i18n/slides_delete.sh +19 -0
  98. package/lib/i18n/slides_list.sh +14 -0
  99. package/lib/i18n/slides_logs.sh +12 -0
  100. package/lib/i18n/slides_new.sh +15 -0
  101. package/lib/i18n/slides_preview.sh +14 -0
  102. package/lib/i18n/slides_templates.sh +7 -0
  103. package/lib/i18n/status.sh +21 -0
  104. package/lib/i18n/update.sh +24 -0
  105. package/lib/i18n.sh +211 -0
  106. package/lib/loop-exit-summary.py +393 -0
  107. package/lib/loop-fmt.py +589 -0
  108. package/lib/loop_pick_agent.py +316 -0
  109. package/lib/loop_result_eval.py +469 -0
  110. package/lib/loop_unstick.py +180 -0
  111. package/lib/model_prices.py +194 -0
  112. package/lib/prices/README.md +35 -0
  113. package/lib/prices/snapshot-2026-05-22.json +22 -0
  114. package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
  115. package/lib/prices/snapshot-2026-05-23-kimi.json +15 -0
  116. package/lib/prices_fetcher.py +285 -0
  117. package/lib/roll-backlog.py +225 -0
  118. package/lib/roll-brief.py +286 -0
  119. package/lib/roll-help.py +158 -0
  120. package/lib/roll-home.py +556 -0
  121. package/lib/roll-init.py +156 -0
  122. package/lib/roll-loop-status.py +1683 -0
  123. package/lib/roll-loop-story.py +191 -0
  124. package/lib/roll-onboard-render.py +378 -0
  125. package/lib/roll-peer.py +252 -0
  126. package/lib/roll-plan-validate.py +386 -0
  127. package/lib/roll-setup.py +102 -0
  128. package/lib/roll-status.py +367 -0
  129. package/lib/roll_git.py +41 -0
  130. package/lib/roll_render.py +414 -0
  131. package/lib/slides/components/README.md +123 -0
  132. package/lib/slides/components/cards-2.html +9 -0
  133. package/lib/slides/components/cards-3.html +9 -0
  134. package/lib/slides/components/cards-4.html +9 -0
  135. package/lib/slides/components/compare.html +22 -0
  136. package/lib/slides/components/highlight.html +9 -0
  137. package/lib/slides/components/pipeline.html +12 -0
  138. package/lib/slides/components/plain.html +7 -0
  139. package/lib/slides/components/quote.html +4 -0
  140. package/lib/slides/components/timeline.html +9 -0
  141. package/lib/slides/templates/introduction-v3.html +571 -0
  142. package/lib/slides/templates/pitch.html +0 -0
  143. package/lib/slides-render.py +778 -0
  144. package/lib/slides-validate.py +357 -0
  145. package/lib/test_quality_gate.py +143 -0
  146. package/package.json +8 -7
  147. package/skills/roll-.changelog/SKILL.md +406 -33
  148. package/skills/roll-.clarify/SKILL.md +5 -2
  149. package/skills/roll-.dream/SKILL.md +374 -0
  150. package/skills/roll-.echo/SKILL.md +5 -2
  151. package/skills/roll-.qa/SKILL.md +57 -3
  152. package/skills/roll-.review/SKILL.md +42 -3
  153. package/skills/roll-brief/SKILL.md +209 -0
  154. package/skills/roll-build/SKILL.md +308 -63
  155. package/skills/roll-debug/SKILL.md +341 -162
  156. package/skills/roll-debug/injectable-bb.js +263 -0
  157. package/skills/roll-deck/SKILL.md +296 -0
  158. package/skills/roll-design/ENGINEERING_CHECKLIST.md +1 -1
  159. package/skills/roll-design/SKILL.md +733 -94
  160. package/skills/roll-doc/SKILL.md +595 -0
  161. package/skills/roll-doctor/SKILL.md +192 -0
  162. package/skills/roll-fix/SKILL.md +149 -32
  163. package/skills/{roll-jot → roll-idea}/SKILL.md +18 -10
  164. package/skills/roll-loop/SKILL.md +579 -0
  165. package/skills/roll-notes/SKILL.md +103 -0
  166. package/skills/roll-onboard/SKILL.md +234 -0
  167. package/skills/roll-peer/SKILL.md +336 -0
  168. package/skills/roll-propose/SKILL.md +157 -0
  169. package/skills/roll-review-pr/SKILL.md +58 -0
  170. package/skills/roll-sentinel/SKILL.md +11 -2
  171. package/skills/roll-spar/SKILL.md +8 -6
  172. package/template/.github/workflows/ci.yml +5 -2
  173. package/template/AGENTS.md +20 -74
  174. package/skills/roll-research/SKILL.md +0 -307
  175. package/skills/roll-research/references/schema.json +0 -162
  176. package/skills/roll-research/scripts/md_to_pdf.py +0 -289
  177. package/tools/roll-fetch/SKILL.md +0 -182
  178. package/tools/roll-fetch/package.json +0 -15
  179. package/tools/roll-fetch/smart-web-fetch.js +0 -558
  180. package/tools/roll-probe/SKILL.md +0 -84
  181. /package/template/{BACKLOG.md → .roll/backlog.md} +0 -0
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env python3
2
+ """Classify a backlog story into a complexity tier (US-AGENT-022).
3
+
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.
10
+
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
24
+
25
+ Exit codes:
26
+ 0 — tier classified (always succeeds once the story is found)
27
+ 1 — story id not found in backlog / unrecoverable error
28
+
29
+ Usage:
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
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import re
37
+ import sys
38
+ from pathlib import Path
39
+
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"
46
+
47
+ PROFILE_BLOCK_RE = re.compile(r"\*\*Agent profile:\*\*")
48
+ EST_RE = re.compile(r"^\s*-\s*est_min:\s*(\d+)")
49
+ ANCHOR_TEMPLATE = '<a id="{anchor}"></a>'
50
+
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
+
187
+ def _id_to_anchor(story_id: str) -> str:
188
+ return story_id.lower()
189
+
190
+
191
+ def _find_feature_md(backlog_path: Path, story_id: str) -> Path | None:
192
+ """Resolve feature md path by scanning backlog rows for the story id."""
193
+ if not backlog_path.exists():
194
+ return None
195
+ link_re = re.compile(
196
+ r"\[" + re.escape(story_id) + r"\]\((\.roll/features/[^)]+?)#",
197
+ re.IGNORECASE,
198
+ )
199
+ for line in backlog_path.read_text(encoding="utf-8").splitlines():
200
+ m = link_re.search(line)
201
+ if m:
202
+ return Path(m.group(1))
203
+ return None
204
+
205
+
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."""
208
+ if not feature_md.exists():
209
+ return None
210
+ anchor = ANCHOR_TEMPLATE.format(anchor=_id_to_anchor(story_id))
211
+ text = feature_md.read_text(encoding="utf-8")
212
+ if anchor not in text:
213
+ return None
214
+
215
+ # Slice from the anchor to the next anchor or EOF.
216
+ start = text.index(anchor)
217
+ next_anchor_match = re.search(r'<a id="[^"]+"></a>', text[start + len(anchor):])
218
+ end = start + len(anchor) + (next_anchor_match.start() if next_anchor_match else len(text))
219
+ section = text[start:end]
220
+
221
+ if not PROFILE_BLOCK_RE.search(section):
222
+ return None
223
+
224
+ for line in section.splitlines():
225
+ m = EST_RE.match(line)
226
+ if m:
227
+ return int(m.group(1))
228
+ return None
229
+
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."""
233
+ feature_md = _find_feature_md(backlog_path, story_id)
234
+ if feature_md is None:
235
+ return None # story id not in backlog
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)
243
+
244
+
245
+ def main() -> int:
246
+ parser = argparse.ArgumentParser()
247
+ parser.add_argument("--story-id")
248
+ parser.add_argument("--backlog", default=".roll/backlog.md")
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)")
274
+ args = parser.parse_args()
275
+
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))
307
+ if result is None:
308
+ print(f"loop_pick_agent: cannot classify {args.story_id}", file=sys.stderr)
309
+ return 1
310
+ tier, rationale = result
311
+ print(f"{tier} {rationale}")
312
+ return 0
313
+
314
+
315
+ if __name__ == "__main__":
316
+ sys.exit(main())