@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.
@@ -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 = [
@@ -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()